001package gudusoft.gsqlparser.pp2.token; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.ETokenType; 005import gudusoft.gsqlparser.TGSqlParser; 006import gudusoft.gsqlparser.TSourceToken; 007import gudusoft.gsqlparser.TSourceTokenList; 008import gudusoft.gsqlparser.pp.para.styleenums.TCaseOption; 009import gudusoft.gsqlparser.pp2.Pp2FormatOptions; 010 011import java.util.ArrayList; 012import java.util.Arrays; 013import java.util.List; 014import java.util.Locale; 015 016/** 017 * Compares two SQL inputs for token-level equivalence modulo formatting. 018 * 019 * <p>The contract — verified by 12 hand-built cases in 020 * {@code TokenEquivalenceTest} — is that two SQL strings are equivalent 021 * when: 022 * 023 * <ul> 024 * <li>Their <i>solid + comment</i> token sequences (in order) are equal 025 * character-by-character.</li> 026 * <li>Whitespace differences (spaces, tabs, linebreaks, indent) are 027 * ignored.</li> 028 * <li>Comment position may shift between the two streams — the comments 029 * must appear in the same relative order, but their surrounding 030 * whitespace does not matter.</li> 031 * <li>Case differences on keywords and unquoted identifiers are tolerated 032 * when {@code caseInsensitive=true}. Quoted identifiers, string 033 * literals, and operator/punctuation tokens are always compared 034 * byte-exact because case changes there alter semantics.</li> 035 * </ul> 036 * 037 * <p>This helper is the safety net for several downstream slices: 038 * <ul> 039 * <li><b>S13</b> ({@code GuardedAstDelegate}) — verifies that 040 * {@code FormatterFactory.pp()}'s output preserves every solid 041 * input token.</li> 042 * <li><b>S32</b> golden tests — sanity-checks goldens against their 043 * inputs.</li> 044 * <li><b>S33</b> overlay annotator — exercises the equivalence helper 045 * on real corpus inputs.</li> 046 * <li><b>S34</b> content-preservation — the strongest property test on 047 * the 50-SQL corpus.</li> 048 * <li><b>S35</b> per-vendor smoke — confirms every vendor preserves 049 * content through the pp2 pipeline.</li> 050 * </ul> 051 * 052 * <p>The helper deliberately operates on raw SQL strings (not 053 * {@link Pp2TokenStream} instances) because the comparison is between an 054 * arbitrary input and an arbitrary output — there is no single pp2 stream 055 * shared between the two. The internal tokenization uses the GSP lexer 056 * directly with the same overlap-skip discipline as 057 * {@link Pp2TokenStreamBuilder}. 058 * 059 * <p>Changes the user explicitly asked for (variable or delimited-name case, 060 * compact mode's comment rewrite, comment removal) are accepted in their exact 061 * expected form when a {@link TransformPolicy} allows them; see that class. 062 * {@code Pp2FormatOptions.transformPolicy} carries it to pp2's guards. 063 * 064 * <p>Plan reference: §7.3/S10, §7.4/S10, Q8. 065 */ 066public final class TokenEquivalence { 067 068 private TokenEquivalence() { 069 // utility class 070 } 071 072 /** 073 * Case-insensitive comparison using the default Oracle dialect. The 074 * "case insensitive" mode tolerates case differences on keywords and 075 * unquoted identifiers; literals and quoted identifiers are always 076 * byte-exact. 077 */ 078 public static boolean equalsModuloFormatting(String left, String right, 079 Pp2FormatOptions opts) { 080 return equalsModuloFormatting(left, right, opts, 081 EDbVendor.dbvoracle, true); 082 } 083 084 /** 085 * Full-control comparison. 086 * 087 * @param left first SQL string; must not be null 088 * @param right second SQL string; must not be null 089 * @param opts pp2 options; its {@code transformPolicy} allows the 090 * requested transformations; may be null (none allowed) 091 * @param vendor dialect to tokenize with; must not be null 092 * @param caseInsensitive when {@code true}, keyword and unquoted 093 * identifier comparisons are case-insensitive; 094 * when {@code false}, all comparisons are 095 * byte-exact on text 096 * @return {@code true} iff the two strings have the same token 097 * sequence under the rules described in the class Javadoc 098 * @throws NullPointerException if {@code left}, {@code right}, or 099 * {@code vendor} is null 100 */ 101 public static boolean equalsModuloFormatting(String left, String right, 102 Pp2FormatOptions opts, 103 EDbVendor vendor, 104 boolean caseInsensitive) { 105 return equalsModuloFormatting(left, right, opts, vendor, caseInsensitive, false); 106 } 107 108 /** 109 * {@link #equalsModuloFormatting(String, String, Pp2FormatOptions, EDbVendor, boolean)} for two 110 * texts that follow other statements: {@code stringsAtStart} is true when one of those turned 111 * the quoted-identifier option off, so that every double-quoted text of both may be a string 112 * (see {@link DoubleQuotedStrings}). 113 */ 114 public static boolean equalsModuloFormatting(String left, String right, 115 Pp2FormatOptions opts, 116 EDbVendor vendor, 117 boolean caseInsensitive, 118 boolean stringsAtStart) { 119 if (left == null) throw new NullPointerException("left"); 120 if (right == null) throw new NullPointerException("right"); 121 if (vendor == null) throw new NullPointerException("vendor"); 122 TransformPolicy policy = opts == null || opts.transformPolicy == null 123 ? TransformPolicy.NONE : opts.transformPolicy; 124 SplitTokens a = tokenizeSplit(left, vendor, stringsAtStart); 125 SplitTokens b = tokenizeSplit(right, vendor, stringsAtStart); 126 // Solid (non-comment) tokens must match in order. 127 if (!compareSolids(a.solids, b.solids, caseInsensitive, policy, vendor)) { 128 return false; 129 } 130 // Comments must appear in the same relative order, and each 131 // comment's text must be byte-exact. Position may shift relative 132 // to the solid tokens (plan §16 Q8). 133 return compareComments(a.comments, b.comments, policy); 134 } 135 136 /** 137 * The solid-token half of {@link #equalsModuloFormatting(String, String, 138 * Pp2FormatOptions, EDbVendor, boolean)}: every non-comment token of 139 * {@code left} appears in {@code right} once, in order, with the same 140 * type and (case-insensitively for keywords and unquoted identifiers 141 * when {@code caseInsensitive}) the same text. Exposed so that callers 142 * reporting the two invariants separately (the format worker) apply the 143 * same policy as the combined check. 144 */ 145 public static boolean solidTokensEquivalent(String left, String right, 146 EDbVendor vendor, 147 boolean caseInsensitive) { 148 return solidTokensEquivalent(left, right, vendor, caseInsensitive, TransformPolicy.NONE); 149 } 150 151 /** 152 * {@link #solidTokensEquivalent(String, String, EDbVendor, boolean)} that 153 * also accepts the solid-token transformations {@code policy} allows. 154 */ 155 public static boolean solidTokensEquivalent(String left, String right, 156 EDbVendor vendor, 157 boolean caseInsensitive, 158 TransformPolicy policy) { 159 if (left == null) throw new NullPointerException("left"); 160 if (right == null) throw new NullPointerException("right"); 161 if (vendor == null) throw new NullPointerException("vendor"); 162 return compareSolids(tokenizeSplit(left, vendor, false).solids, 163 tokenizeSplit(right, vendor, false).solids, caseInsensitive, 164 policy == null ? TransformPolicy.NONE : policy, vendor); 165 } 166 167 /** 168 * The comment half of {@link #equalsModuloFormatting(String, String, 169 * Pp2FormatOptions, EDbVendor, boolean)}: the comments of {@code left} 170 * appear in {@code right} in the same relative order with byte-exact 171 * text; their position relative to the solid tokens may differ. 172 */ 173 public static boolean commentsPreserved(String left, String right, 174 EDbVendor vendor) { 175 return commentsPreserved(left, right, vendor, TransformPolicy.NONE); 176 } 177 178 /** 179 * {@link #commentsPreserved(String, String, EDbVendor)} that also accepts 180 * the comment transformations {@code policy} allows. 181 */ 182 public static boolean commentsPreserved(String left, String right, 183 EDbVendor vendor, 184 TransformPolicy policy) { 185 if (left == null) throw new NullPointerException("left"); 186 if (right == null) throw new NullPointerException("right"); 187 if (vendor == null) throw new NullPointerException("vendor"); 188 return compareComments(tokenizeSplit(left, vendor, false).comments, 189 tokenizeSplit(right, vendor, false).comments, 190 policy == null ? TransformPolicy.NONE : policy); 191 } 192 193 private static boolean compareSolids(List<TokenInfo> a, List<TokenInfo> b, 194 boolean caseInsensitive, TransformPolicy policy, 195 EDbVendor vendor) { 196 if (a.size() != b.size()) return false; 197 for (int i = 0; i < a.size(); i++) { 198 TokenInfo x = a.get(i); 199 TokenInfo y = b.get(i); 200 if (tokensEquivalent(x, y, caseInsensitive)) continue; 201 if (!policy.isNone() && transformedAsRequested(x, y, policy, vendor)) continue; 202 return false; 203 } 204 return true; 205 } 206 207 /** Dialects in which a bracketed token is a delimited name. */ 208 private static final java.util.Set<String> BRACKETS_ARE_NAMES = new java.util.HashSet<String>(Arrays.asList( 209 "dbvmssql", "dbvazuresql", "dbvsybase", "dbvsybasease", "dbvsybaseiq", "dbvsqlanywhere", 210 "dbvaccess", "dbvsqlite")); 211 212 /** Solid-token transformations: the output token is exactly the expected one. */ 213 private static boolean transformedAsRequested(TokenInfo x, TokenInfo y, TransformPolicy policy, 214 EDbVendor vendor) { 215 if (x.type != y.type) return false; 216 switch (x.type) { 217 case ttsqlvar: 218 // The classic formatter's variable case covers @variables only. 219 return policy.getVariableCase() != null 220 && y.text.equals(variableWithCase(x.text, policy.getVariableCase())); 221 case ttdqstring: 222 // a double-quoted text that may be a string keeps its case (DoubleQuotedStrings) 223 return policy.getQuotedIdentifierCase() != null 224 && !x.mayBeString && !y.mayBeString 225 && x.text.startsWith("\"") && x.text.endsWith("\"") 226 && delimitedWithCase(x.text, policy.getQuotedIdentifierCase()).contains(y.text); 227 case ttdbstring: 228 return policy.getQuotedIdentifierCase() != null 229 && BRACKETS_ARE_NAMES.contains(vendor.name()) 230 && x.text.startsWith("[") && x.text.endsWith("]") 231 && delimitedWithCase(x.text, policy.getQuotedIdentifierCase()).contains(y.text); 232 default: 233 // {...} is an ODBC escape, never a name; everything else is 234 // outside every policy. 235 return false; 236 } 237 } 238 239 /** 240 * The variable as the classic formatter cases it: the first character (the 241 * {@code @} sigil) kept, the case applied to the rest; InitCap lowercases the 242 * rest and then capitalizes its first character only if it is an ASCII 243 * letter ({@code @@ROWCOUNT} becomes {@code @@rowcount}). 244 */ 245 static String variableWithCase(String text, TCaseOption c) { 246 if (text.isEmpty()) return text; 247 String prefix = text.substring(0, 1); 248 String rest = text.substring(1); 249 switch (c) { 250 case CoUppercase: 251 return prefix + rest.toUpperCase(Locale.ROOT); 252 case CoLowercase: 253 return prefix + rest.toLowerCase(Locale.ROOT); 254 case CoInitCap: { 255 String lower = rest.toLowerCase(Locale.ROOT); 256 if (!lower.isEmpty() && lower.charAt(0) >= 'a' && lower.charAt(0) <= 'z') { 257 lower = Character.toUpperCase(lower.charAt(0)) + lower.substring(1); 258 } 259 return prefix + lower; 260 } 261 default: 262 return text; 263 } 264 } 265 266 /** 267 * The delimited name with {@code c} applied inside its delimiters. For 268 * InitCap the two engines differ, so both forms are accepted: the classic 269 * formatter capitalizes the first letter inside the delimiters; GSP's 270 * {@code CapitalisationProcessor} capitalizes the first character of the 271 * token (the delimiter) and each character after a space. 272 */ 273 static List<String> delimitedWithCase(String text, TCaseOption c) { 274 if (text.length() < 2) return new ArrayList<String>(); 275 String inner = text.substring(1, text.length() - 1); 276 String classic = text.charAt(0) + withCase(inner, c) + text.charAt(text.length() - 1); 277 if (c != TCaseOption.CoInitCap) return Arrays.asList(classic); 278 StringBuilder gsp = new StringBuilder(text.length()); 279 char prev = ' '; 280 for (char ch : text.toCharArray()) { 281 gsp.append(ch != ' ' && prev == ' ' ? Character.toUpperCase(ch) : Character.toLowerCase(ch)); 282 prev = ch; 283 } 284 return Arrays.asList(classic, gsp.toString()); 285 } 286 287 private static String withCase(String s, TCaseOption c) { 288 switch (c) { 289 case CoUppercase: 290 return s.toUpperCase(Locale.ROOT); 291 case CoLowercase: 292 return s.toLowerCase(Locale.ROOT); 293 case CoInitCap: 294 return s.isEmpty() ? s 295 : s.substring(0, 1).toUpperCase(Locale.ROOT) + s.substring(1).toLowerCase(Locale.ROOT); 296 default: 297 return s; 298 } 299 } 300 301 /** 302 * Comments that are code, not commentary: optimizer hints ({@code /*+}, 303 * Oracle {@code --+}) and MySQL executable comments ({@code /*!}). No 304 * policy lets them change or disappear. 305 */ 306 private static boolean isProtectedComment(TokenInfo t) { 307 return t.text.startsWith("/*+") || t.text.startsWith("--+") || t.text.startsWith("/*!"); 308 } 309 310 /** 311 * Comments: byte-exact and in order, unless the policy allows compact 312 * mode's rewrite of ordinary {@code --} comments or the removal of every 313 * comment that is not code. 314 */ 315 private static boolean compareComments(List<TokenInfo> a, List<TokenInfo> b, TransformPolicy policy) { 316 if (compareSequences(a, b, false)) return true; 317 if (policy.isRemoveComments()) { 318 List<TokenInfo> kept = new ArrayList<TokenInfo>(); 319 for (TokenInfo t : a) { 320 if (isProtectedComment(t)) kept.add(t); 321 } 322 if (compareSequences(kept, b, false)) return true; 323 } 324 if (policy.isCompactComments() && a.size() == b.size()) { 325 for (int i = 0; i < a.size(); i++) { 326 TokenInfo x = a.get(i); 327 TokenInfo y = b.get(i); 328 if (x.type == y.type && x.text.equals(y.text)) continue; 329 if (!compactRewrite(x, y)) return false; 330 } 331 return true; 332 } 333 return false; 334 } 335 336 /** 337 * Whether {@code y} is compact mode's rewrite of the line comment {@code x}: 338 * {@code -- body} as {@code /* -- body *}{@code /} or {@code /* -- body*}{@code /}, 339 * with the body as written or without its trailing blanks. Only ordinary 340 * {@code --} comments qualify: not hints ({@code --+}), not {@code #} or 341 * {@code //} comments, and not a body that would close the block early. 342 */ 343 private static boolean compactRewrite(TokenInfo x, TokenInfo y) { 344 if (x.type != ETokenType.ttsimplecomment || y.type != ETokenType.ttbracketedcomment) return false; 345 if (!x.text.startsWith("--") || isProtectedComment(x) || x.text.contains("*/")) return false; 346 String body = x.text; 347 int end = body.length(); 348 while (end > 0 && (body.charAt(end - 1) == '\n' || body.charAt(end - 1) == '\r')) end--; 349 body = body.substring(0, end); 350 for (String b : Arrays.asList(body, rtrim(body))) { 351 if (y.text.equals("/* " + b + " */") || y.text.equals("/* " + b + "*/")) return true; 352 } 353 return false; 354 } 355 356 private static String rtrim(String s) { 357 int end = s.length(); 358 while (end > 0 && Character.isWhitespace(s.charAt(end - 1))) end--; 359 return s.substring(0, end); 360 } 361 362 private static boolean compareSequences(List<TokenInfo> a, List<TokenInfo> b, 363 boolean caseInsensitive) { 364 if (a.size() != b.size()) return false; 365 for (int i = 0; i < a.size(); i++) { 366 if (!tokensEquivalent(a.get(i), b.get(i), caseInsensitive)) { 367 return false; 368 } 369 } 370 return true; 371 } 372 373 /** Lightweight value carrier for comparable tokens. */ 374 private static final class TokenInfo { 375 final ETokenType type; 376 final String text; 377 /** A double-quoted text that may be a string rather than a name. */ 378 final boolean mayBeString; 379 TokenInfo(ETokenType type, String text, boolean mayBeString) { 380 this.type = type; 381 this.text = text; 382 this.mayBeString = mayBeString; 383 } 384 } 385 386 /** Tokenization result split into solid tokens and comments. */ 387 private static final class SplitTokens { 388 final List<TokenInfo> solids; 389 final List<TokenInfo> comments; 390 SplitTokens(List<TokenInfo> solids, List<TokenInfo> comments) { 391 this.solids = solids; 392 this.comments = comments; 393 } 394 } 395 396 private static SplitTokens tokenizeSplit(String sql, EDbVendor vendor, boolean stringsAtStart) { 397 TGSqlParser parser = new TGSqlParser(vendor); 398 parser.sqltext = sql; 399 parser.tokenizeSqltext(); 400 TSourceTokenList list = parser.getSourcetokenlist(); 401 List<TokenInfo> solids = new ArrayList<TokenInfo>(list.size()); 402 List<TokenInfo> comments = new ArrayList<TokenInfo>(); 403 long lastEmittedEnd = -1L; 404 // the lexer types a double-quoted text as a delimited name even where it may be a string 405 long stringsAfter = stringsAtStart ? DoubleQuotedStrings.ALL : DoubleQuotedStrings.stringsAfter(vendor, list); 406 for (int i = 0; i < list.size(); i++) { 407 TSourceToken t = list.get(i); 408 if (t == null) continue; 409 String text = t.toString(); 410 if (text == null || text.isEmpty()) continue; 411 // Skip phantom shadowed tokens (GSP "${name}" overlap quirk — 412 // see TokenCoverage Javadoc and slice S9 resume doc). 413 if (TokenCoverage.isFullyShadowed(t, lastEmittedEnd)) { 414 continue; 415 } 416 ETokenType type = t.tokentype; 417 // Skip whitespace entirely. 418 if (Pp2TokenStreamBuilder.isFoldable(type)) { 419 continue; 420 } 421 TokenInfo info = new TokenInfo(type, text, 422 type == ETokenType.ttdqstring && t.offset > stringsAfter); 423 if (isCommentType(type)) { 424 comments.add(info); 425 } else { 426 solids.add(info); 427 } 428 lastEmittedEnd = TokenCoverage.endOffset(t); 429 } 430 return new SplitTokens(solids, comments); 431 } 432 433 private static boolean isCommentType(ETokenType type) { 434 if (type == null) return false; 435 switch (type) { 436 case ttsimplecomment: 437 case ttbracketedcomment: 438 case ttCPPComment: 439 return true; 440 default: 441 return false; 442 } 443 } 444 445 private static boolean tokensEquivalent(TokenInfo a, TokenInfo b, 446 boolean caseInsensitive) { 447 if (a.type != b.type) { 448 // The Oracle lexer classifies a word at column 0 as a SQL*Plus 449 // command (EXIT, SET ...) and the same word, once indented by the 450 // formatter, as the PL/SQL keyword it really is: same text, only 451 // the lexer's guess changed (sqllog corpus, oracle/020). 452 boolean sqlplusVsKeyword = (a.type == ETokenType.ttsqlpluscmd && b.type == ETokenType.ttkeyword) 453 || (a.type == ETokenType.ttkeyword && b.type == ETokenType.ttsqlpluscmd); 454 return sqlplusVsKeyword && a.text.equalsIgnoreCase(b.text); 455 } 456 if (caseInsensitive && typeAllowsCaseChange(a.type)) { 457 return a.text.equalsIgnoreCase(b.text); 458 } 459 return a.text.equals(b.text); 460 } 461 462 /** 463 * Token types whose case may legitimately change between input and 464 * output: keywords and unquoted identifiers. Everything else 465 * (literals, quoted identifiers, comments, punctuation, operators) 466 * is compared byte-exact even in case-insensitive mode. 467 */ 468 private static boolean typeAllowsCaseChange(ETokenType type) { 469 if (type == null) return false; 470 switch (type) { 471 case ttkeyword: 472 case ttnonreservedkeyword: 473 case ttidentifier: 474 return true; 475 default: 476 return false; 477 } 478 } 479}