rawsql-ts 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,8 @@ import { WhereClause, SubQuerySource, TableSource } from "../models/Clause";
2
2
  import { SimpleSelectQuery } from "../models/SelectQuery";
3
3
  import { BinaryExpression, ColumnReference, IdentifierString, InlineQuery, LiteralValue, ParameterExpression, ParenExpression, RawString, UnaryExpression } from "../models/ValueComponent";
4
4
  import { SelectQueryParser } from "../parsers/SelectQueryParser";
5
+ import { SqlTokenizer } from "../parsers/SqlTokenizer";
6
+ import { TokenType } from "../models/Lexeme";
5
7
  import { UpstreamSelectQueryFinder } from "./UpstreamSelectQueryFinder";
6
8
  import { ColumnReferenceCollector } from "./ColumnReferenceCollector";
7
9
  import { SelectableColumnCollector, DuplicateDetectionMode } from "./SelectableColumnCollector";
@@ -13,6 +15,222 @@ const SUPPORTED_SCALAR_OPERATORS = new Set(["=", "<>", "<", "<=", ">", ">=", "li
13
15
  let formatter = null;
14
16
  const normalizeIdentifier = (value) => value.trim().toLowerCase();
15
17
  const normalizeSql = (value) => value.replace(/\s+/g, " ").trim().toLowerCase();
18
+ const normalizeRewriteTokenType = (lexeme) => {
19
+ if ((lexeme.type & TokenType.Command) !== 0) {
20
+ return TokenType.Command;
21
+ }
22
+ if ((lexeme.type & TokenType.Identifier) !== 0) {
23
+ return TokenType.Identifier;
24
+ }
25
+ return lexeme.type;
26
+ };
27
+ const normalizeRewriteTokenValue = (lexeme) => {
28
+ if ((lexeme.type & TokenType.Command) !== 0) {
29
+ return lexeme.value.toLowerCase();
30
+ }
31
+ return lexeme.value;
32
+ };
33
+ const tokenizeForRewritePlan = (sql) => {
34
+ return new SqlTokenizer(sql).tokenize().map(lexeme => ({
35
+ type: normalizeRewriteTokenType(lexeme),
36
+ value: normalizeRewriteTokenValue(lexeme)
37
+ }));
38
+ };
39
+ const tokenSequencesEqual = (left, right) => {
40
+ if (left.length !== right.length) {
41
+ return false;
42
+ }
43
+ return left.every((token, index) => {
44
+ const other = right[index];
45
+ return other !== undefined && token.type === other.type && token.value === other.value;
46
+ });
47
+ };
48
+ const collectCommentFragments = (sql) => {
49
+ return new SqlTokenizer(sql).tokenize().flatMap(lexeme => {
50
+ if (lexeme.positionedComments) {
51
+ return lexeme.positionedComments.flatMap(positioned => positioned.comments);
52
+ }
53
+ return lexeme.comments ? [...lexeme.comments] : [];
54
+ });
55
+ };
56
+ const commentsPreservedInOrder = (before, after) => {
57
+ let cursor = 0;
58
+ for (const comment of before) {
59
+ const foundAt = after.indexOf(comment, cursor);
60
+ if (foundAt < 0) {
61
+ return false;
62
+ }
63
+ cursor = foundAt + 1;
64
+ }
65
+ return true;
66
+ };
67
+ const countCommandToken = (tokens, value) => {
68
+ return tokens.filter(token => token.type === TokenType.Command && token.value === value).length;
69
+ };
70
+ const applyRewriteEdits = (sql, edits) => {
71
+ return [...edits]
72
+ .sort((left, right) => right.start - left.start)
73
+ .reduce((current, edit) => {
74
+ return current.slice(0, edit.start) + edit.after + current.slice(edit.end);
75
+ }, sql);
76
+ };
77
+ const getStatementEndPosition = (sql) => {
78
+ let end = sql.length;
79
+ while (end > 0 && /\s/.test(sql[end - 1])) {
80
+ end--;
81
+ }
82
+ if (end > 0 && sql[end - 1] === ";") {
83
+ end--;
84
+ while (end > 0 && /\s/.test(sql[end - 1])) {
85
+ end--;
86
+ }
87
+ }
88
+ return end;
89
+ };
90
+ const clauseBoundaryCommands = new Set(["group by", "having", "order by", "limit", "offset", "fetch", "for"]);
91
+ const isClauseBoundary = (lexeme) => {
92
+ return (lexeme.type & TokenType.Command) !== 0
93
+ && clauseBoundaryCommands.has(lexeme.value.toLowerCase());
94
+ };
95
+ const findMinimalWhereInsertPosition = (sql) => {
96
+ var _a, _b, _c, _d;
97
+ const lexemes = new SqlTokenizer(sql).tokenize();
98
+ const whereIndex = lexemes.findIndex(lexeme => (lexeme.type & TokenType.Command) !== 0 && lexeme.value.toLowerCase() === "where");
99
+ const statementEnd = getStatementEndPosition(sql);
100
+ if (whereIndex >= 0) {
101
+ const tail = lexemes.slice(whereIndex + 1).find(isClauseBoundary);
102
+ return {
103
+ position: (_b = (_a = tail === null || tail === void 0 ? void 0 : tail.position) === null || _a === void 0 ? void 0 : _a.startPosition) !== null && _b !== void 0 ? _b : statementEnd,
104
+ hasWhere: true
105
+ };
106
+ }
107
+ const tail = lexemes.find(isClauseBoundary);
108
+ return {
109
+ position: (_d = (_c = tail === null || tail === void 0 ? void 0 : tail.position) === null || _c === void 0 ? void 0 : _c.startPosition) !== null && _d !== void 0 ? _d : statementEnd,
110
+ hasWhere: false
111
+ };
112
+ };
113
+ const findMatchingParenEnd = (sql, start) => {
114
+ let depth = 0;
115
+ let quote = null;
116
+ for (let index = start; index < sql.length; index++) {
117
+ const char = sql[index];
118
+ if (quote) {
119
+ if (char === quote) {
120
+ if (quote === "'" && sql[index + 1] === "'") {
121
+ index++;
122
+ continue;
123
+ }
124
+ quote = null;
125
+ }
126
+ continue;
127
+ }
128
+ if (char === "'" || char === "\"") {
129
+ quote = char;
130
+ continue;
131
+ }
132
+ if (char === "(") {
133
+ depth++;
134
+ }
135
+ if (char === ")") {
136
+ depth--;
137
+ if (depth === 0) {
138
+ return index + 1;
139
+ }
140
+ }
141
+ }
142
+ return -1;
143
+ };
144
+ // Fallback for #854: findOptionalBranchSpans, findBooleanOperatorBefore, and
145
+ // findBooleanOperatorAfter recover minimal remove spans until the AST can expose
146
+ // source positions for optional parenthesized OR/IS NULL branches reliably.
147
+ // Keep regex-based rewriting limited to this fallback path.
148
+ const findOptionalBranchSpans = (sql, parameterName) => {
149
+ const spans = [];
150
+ const parameterNeedle = `:${parameterName.toLowerCase()}`;
151
+ const lowerSql = sql.toLowerCase();
152
+ for (let index = 0; index < sql.length; index++) {
153
+ if (sql[index] !== "(") {
154
+ continue;
155
+ }
156
+ const end = findMatchingParenEnd(sql, index);
157
+ if (end < 0) {
158
+ break;
159
+ }
160
+ const text = sql.slice(index, end);
161
+ const normalized = lowerSql.slice(index, end);
162
+ if (normalized.includes(parameterNeedle) && normalized.includes(" is null") && normalized.includes(" or ")) {
163
+ spans.push({ start: index, end, text });
164
+ }
165
+ index = end - 1;
166
+ }
167
+ return spans;
168
+ };
169
+ const findBooleanOperatorBefore = (sql, start) => {
170
+ const prefix = sql.slice(0, start);
171
+ const match = /(\s+)(and|or)(\s*)$/i.exec(prefix);
172
+ if (!match || match.index === undefined) {
173
+ return null;
174
+ }
175
+ return {
176
+ start: match.index,
177
+ end: start,
178
+ value: match[2].toLowerCase()
179
+ };
180
+ };
181
+ const findBooleanOperatorAfter = (sql, end) => {
182
+ const suffix = sql.slice(end);
183
+ const match = /^(\s*)(and|or)(\s+)/i.exec(suffix);
184
+ if (!match) {
185
+ return null;
186
+ }
187
+ return {
188
+ start: end,
189
+ end: end + match[0].length,
190
+ value: match[2].toLowerCase()
191
+ };
192
+ };
193
+ const findWhereBefore = (sql, position) => {
194
+ var _a, _b;
195
+ const lexemes = new SqlTokenizer(sql).tokenize();
196
+ let found = null;
197
+ for (const lexeme of lexemes) {
198
+ if (((_b = (_a = lexeme.position) === null || _a === void 0 ? void 0 : _a.startPosition) !== null && _b !== void 0 ? _b : 0) >= position) {
199
+ break;
200
+ }
201
+ if ((lexeme.type & TokenType.Command) !== 0 && lexeme.value.toLowerCase() === "where") {
202
+ found = lexeme;
203
+ }
204
+ }
205
+ if (!(found === null || found === void 0 ? void 0 : found.position)) {
206
+ return null;
207
+ }
208
+ return {
209
+ start: found.position.startPosition,
210
+ end: found.position.endPosition
211
+ };
212
+ };
213
+ const findSourceColumnReferenceText = (sql, reference) => {
214
+ const namespace = normalizeIdentifier(reference.getNamespace());
215
+ const column = normalizeIdentifier(reference.column.name);
216
+ const lexemes = new SqlTokenizer(sql).tokenize();
217
+ for (let index = 0; index < lexemes.length - 2; index++) {
218
+ const first = lexemes[index];
219
+ const dot = lexemes[index + 1];
220
+ const last = lexemes[index + 2];
221
+ if ((first.type & TokenType.Identifier) === 0 || dot.value !== "." || (last.type & TokenType.Identifier) === 0) {
222
+ continue;
223
+ }
224
+ if (normalizeIdentifier(first.value) !== namespace || normalizeIdentifier(last.value) !== column) {
225
+ continue;
226
+ }
227
+ if (!first.position || !last.position) {
228
+ continue;
229
+ }
230
+ return sql.slice(first.position.startPosition, last.position.endPosition);
231
+ }
232
+ return normalizeColumnReferenceText(reference);
233
+ };
16
234
  const normalizeColumnReferenceKey = (reference) => {
17
235
  return `${normalizeIdentifier(reference.getNamespace())}.${normalizeIdentifier(reference.column.name)}`;
18
236
  };
@@ -358,6 +576,78 @@ export class SSSQLFilterBuilder {
358
576
  const parsed = this.parseQuery(query);
359
577
  return collectSupportedOptionalConditionBranches(parsed).map(getBranchInfo);
360
578
  }
579
+ planScaffold(query, filters) {
580
+ if (typeof query === "string") {
581
+ const entries = Object.entries(filters);
582
+ if (entries.length === 1) {
583
+ const [filterName, filterValue] = entries[0];
584
+ if (isExplicitEqualityScaffoldValue(filterValue)) {
585
+ try {
586
+ return this.planScalarInsert(query, {
587
+ target: filterName,
588
+ parameterName: makeParameterName(filterName),
589
+ operator: "="
590
+ });
591
+ }
592
+ catch (_a) {
593
+ // Fall through to the conservative formatter-backed plan, which reports rewrite errors.
594
+ }
595
+ }
596
+ }
597
+ }
598
+ return this.planRewrite(query, parsed => this.scaffold(parsed, filters));
599
+ }
600
+ dryRunScaffold(query, filters) {
601
+ return this.planScaffold(query, filters);
602
+ }
603
+ planScaffoldBranch(query, spec) {
604
+ if (typeof query === "string" && (spec.kind === "exists" || spec.kind === "not-exists")) {
605
+ try {
606
+ return this.planExistsInsert(query, spec);
607
+ }
608
+ catch (_a) {
609
+ // Fall through to the conservative formatter-backed plan, which reports rewrite errors.
610
+ }
611
+ }
612
+ if (typeof query === "string" && spec.kind !== "exists" && spec.kind !== "not-exists") {
613
+ try {
614
+ return this.planScalarInsert(query, spec);
615
+ }
616
+ catch (_b) {
617
+ // Fall through to the conservative formatter-backed plan, which reports rewrite errors.
618
+ }
619
+ }
620
+ return this.planRewrite(query, parsed => this.scaffoldBranch(parsed, spec));
621
+ }
622
+ dryRunScaffoldBranch(query, spec) {
623
+ return this.planScaffoldBranch(query, spec);
624
+ }
625
+ planRefresh(query, filters) {
626
+ return this.planRewrite(query, parsed => this.refresh(parsed, filters));
627
+ }
628
+ dryRunRefresh(query, filters) {
629
+ return this.planRefresh(query, filters);
630
+ }
631
+ planRemove(query, spec) {
632
+ if (typeof query === "string") {
633
+ try {
634
+ return this.planBranchRemoval(query, spec);
635
+ }
636
+ catch (_a) {
637
+ // Fall through to the conservative formatter-backed plan, which reports rewrite errors.
638
+ }
639
+ }
640
+ return this.planRewrite(query, parsed => this.remove(parsed, spec));
641
+ }
642
+ dryRunRemove(query, spec) {
643
+ return this.planRemove(query, spec);
644
+ }
645
+ planRemoveAll(query) {
646
+ return this.planRewrite(query, parsed => this.removeAll(parsed));
647
+ }
648
+ dryRunRemoveAll(query) {
649
+ return this.planRemoveAll(query);
650
+ }
361
651
  scaffold(query, filters) {
362
652
  const parsed = this.parseQuery(query);
363
653
  for (const [filterName, filterValue] of Object.entries(filters)) {
@@ -464,6 +754,383 @@ export class SSSQLFilterBuilder {
464
754
  parseQuery(query) {
465
755
  return typeof query === "string" ? SelectQueryParser.parse(query) : query;
466
756
  }
757
+ planScalarInsert(sourceSql, spec) {
758
+ var _a;
759
+ const parsed = SelectQueryParser.parse(sourceSql);
760
+ const target = this.resolveTarget(parsed, spec.target);
761
+ if (target.query !== parsed) {
762
+ return this.planRewrite(sourceSql, query => this.scaffoldBranch(query, spec));
763
+ }
764
+ const parameterName = ((_a = spec.parameterName) === null || _a === void 0 ? void 0 : _a.trim()) || target.parameterName;
765
+ const operator = normalizeScalarOperator(spec.operator);
766
+ const targetColumnText = findSourceColumnReferenceText(sourceSql, target.column);
767
+ const branchSql = `(:${parameterName} is null or ${targetColumnText} ${operator} :${parameterName})`;
768
+ const normalizedBranch = normalizeSql(branchSql);
769
+ const duplicate = this.list(parsed).find(existing => existing.query === target.query && normalizeSql(existing.sql) === normalizedBranch);
770
+ if (duplicate) {
771
+ return this.buildPlanFromEdits(sourceSql, [], [], []);
772
+ }
773
+ return this.buildMinimalInsertPlan(sourceSql, branchSql, {
774
+ branchKind: "scalar",
775
+ parameterName,
776
+ column: targetColumnText
777
+ });
778
+ }
779
+ planExistsInsert(sourceSql, spec) {
780
+ const parameterName = spec.parameterName.trim();
781
+ if (!parameterName) {
782
+ throw new Error("SSSQL EXISTS/NOT EXISTS scaffold requires parameterName.");
783
+ }
784
+ if (spec.anchorColumns.length === 0) {
785
+ throw new Error("SSSQL EXISTS/NOT EXISTS scaffold requires at least one anchorColumn.");
786
+ }
787
+ const parsed = SelectQueryParser.parse(sourceSql);
788
+ const anchorTargets = spec.anchorColumns.map(anchorColumn => this.resolveTarget(parsed, anchorColumn));
789
+ const targetQueries = [...new Set(anchorTargets.map(target => target.query))];
790
+ if (targetQueries.length !== 1) {
791
+ throw new Error("SSSQL EXISTS/NOT EXISTS scaffold anchor columns must resolve within one query scope.");
792
+ }
793
+ const targetQuery = targetQueries[0];
794
+ if (targetQuery !== parsed) {
795
+ return this.planRewrite(sourceSql, query => this.scaffoldBranch(query, spec));
796
+ }
797
+ const sourceColumns = anchorTargets.map(target => findSourceColumnReferenceText(sourceSql, target.column));
798
+ const substitutedSql = substituteAnchorPlaceholders(spec.query, sourceColumns).trim();
799
+ enforceSubqueryConstraints(substitutedSql);
800
+ const subquery = SelectQueryParser.parse(substitutedSql);
801
+ const parameterNames = new Set(ParameterCollector.collect(subquery).map(parameter => parameter.name.value));
802
+ if (parameterNames.size !== 1 || !parameterNames.has(parameterName)) {
803
+ throw new Error(`SSSQL ${spec.kind.toUpperCase()} scaffold query must reference only parameter ':${parameterName}'.`);
804
+ }
805
+ const branchSql = `(:${parameterName} is null or ${spec.kind === "not-exists" ? "not exists" : "exists"} (${substitutedSql}))`;
806
+ const duplicate = this.list(parsed).find(existing => existing.query === targetQuery && normalizeSql(existing.sql) === normalizeSql(branchSql));
807
+ if (duplicate) {
808
+ return this.buildPlanFromEdits(sourceSql, [], [], []);
809
+ }
810
+ return this.buildMinimalInsertPlan(sourceSql, branchSql, {
811
+ branchKind: spec.kind,
812
+ parameterName,
813
+ column: sourceColumns.join(", ")
814
+ });
815
+ }
816
+ buildMinimalInsertPlan(sourceSql, branchSql, target) {
817
+ const insertPosition = findMinimalWhereInsertPosition(sourceSql);
818
+ const needsLeadingSpace = insertPosition.position === 0
819
+ || !/\s/.test(sourceSql[insertPosition.position - 1]);
820
+ const prefix = `${needsLeadingSpace ? " " : ""}${insertPosition.hasWhere ? "and" : "where"} `;
821
+ const suffix = insertPosition.position < getStatementEndPosition(sourceSql) ? " " : "";
822
+ const branchLabel = target.branchKind === "scalar" ? "scalar" : target.branchKind;
823
+ const edit = {
824
+ start: insertPosition.position,
825
+ end: insertPosition.position,
826
+ before: "",
827
+ after: `${prefix}${branchSql}${suffix}`,
828
+ kind: "insert",
829
+ reason: insertPosition.hasWhere
830
+ ? `Append SSSQL ${branchLabel} branch to the existing WHERE clause.`
831
+ : `Create a WHERE clause for the SSSQL ${branchLabel} branch.`,
832
+ target
833
+ };
834
+ const changedRegions = [{
835
+ kind: "target-branch",
836
+ start: edit.start + prefix.length,
837
+ end: edit.start + edit.after.length - suffix.length,
838
+ message: `Inserted SSSQL ${branchLabel} optional branch.`
839
+ }];
840
+ if (insertPosition.hasWhere) {
841
+ changedRegions.unshift({
842
+ kind: "boolean-operator",
843
+ start: edit.start,
844
+ end: edit.start + prefix.length,
845
+ message: `Inserted AND before the SSSQL ${branchLabel} branch.`
846
+ });
847
+ }
848
+ else {
849
+ changedRegions.unshift({
850
+ kind: "where-keyword",
851
+ start: edit.start,
852
+ end: edit.start + prefix.length,
853
+ message: `Inserted WHERE before the SSSQL ${branchLabel} branch.`
854
+ });
855
+ }
856
+ return this.buildPlanFromEdits(sourceSql, [edit], changedRegions, []);
857
+ }
858
+ planBranchRemoval(sourceSql, spec) {
859
+ const parsed = SelectQueryParser.parse(sourceSql);
860
+ const matches = this.findMatchingBranchInfos(parsed, spec);
861
+ if (matches.length > 1) {
862
+ return this.buildPlanFromEdits(sourceSql, [], [], [], [{
863
+ code: "REWRITE_FAILED",
864
+ message: "SSSQL remove planning found multiple matching branches.",
865
+ detail: `Multiple SSSQL branches matched parameter ':${spec.parameterName}'. Remove is ambiguous.`
866
+ }]);
867
+ }
868
+ if (matches.length === 0) {
869
+ return this.buildPlanFromEdits(sourceSql, [], [], []);
870
+ }
871
+ const branchSpans = findOptionalBranchSpans(sourceSql, spec.parameterName);
872
+ if (branchSpans.length > 1) {
873
+ return this.planRewrite(sourceSql, query => this.remove(query, spec));
874
+ }
875
+ const span = branchSpans[0];
876
+ if (!span) {
877
+ return this.planRewrite(sourceSql, query => this.remove(query, spec));
878
+ }
879
+ const beforeOperator = findBooleanOperatorBefore(sourceSql, span.start);
880
+ const afterOperator = findBooleanOperatorAfter(sourceSql, span.end);
881
+ const where = findWhereBefore(sourceSql, span.start);
882
+ let start = span.start;
883
+ let end = span.end;
884
+ const changedRegions = [{
885
+ kind: "target-branch",
886
+ start: span.start,
887
+ end: span.end,
888
+ message: "Removed SSSQL optional branch."
889
+ }];
890
+ if (beforeOperator) {
891
+ start = beforeOperator.start;
892
+ changedRegions.unshift({
893
+ kind: "boolean-operator",
894
+ start: beforeOperator.start,
895
+ end: beforeOperator.end,
896
+ message: `Removed adjacent ${beforeOperator.value.toUpperCase()} before the SSSQL branch.`
897
+ });
898
+ }
899
+ else if (afterOperator) {
900
+ end = afterOperator.end;
901
+ changedRegions.push({
902
+ kind: "boolean-operator",
903
+ start: afterOperator.start,
904
+ end: afterOperator.end,
905
+ message: `Removed adjacent ${afterOperator.value.toUpperCase()} after the SSSQL branch.`
906
+ });
907
+ }
908
+ else if (where) {
909
+ start = where.start;
910
+ while (end < sourceSql.length && /\s/.test(sourceSql[end])) {
911
+ end++;
912
+ }
913
+ changedRegions.unshift({
914
+ kind: "where-keyword",
915
+ start: where.start,
916
+ end: where.end,
917
+ message: "Removed WHERE because the SSSQL branch was the only condition."
918
+ });
919
+ }
920
+ const edit = {
921
+ start,
922
+ end,
923
+ before: sourceSql.slice(start, end),
924
+ after: "",
925
+ kind: "delete",
926
+ reason: "Remove the targeted SSSQL optional branch from the source SQL.",
927
+ target: {
928
+ branchKind: matches[0].kind,
929
+ parameterName: matches[0].parameterName,
930
+ column: matches[0].target
931
+ }
932
+ };
933
+ return this.buildPlanFromEdits(sourceSql, [edit], changedRegions, []);
934
+ }
935
+ buildPlanFromEdits(sourceSql, edits, changedRegions, warnings, errors = []) {
936
+ const plannedSql = errors.length === 0 ? applyRewriteEdits(sourceSql, edits) : undefined;
937
+ const beforeTokens = tokenizeForRewritePlan(sourceSql);
938
+ const beforeComments = collectCommentFragments(sourceSql);
939
+ const afterTokens = plannedSql !== undefined ? tokenizeForRewritePlan(plannedSql) : [];
940
+ const afterComments = plannedSql !== undefined ? collectCommentFragments(plannedSql) : [];
941
+ const commentsPreserved = plannedSql !== undefined
942
+ ? commentsPreservedInOrder(beforeComments, afterComments)
943
+ : false;
944
+ const changedOnlyTargetBranches = errors.length === 0
945
+ && changedRegions.every(region => region.kind === "target-branch"
946
+ || region.kind === "where-keyword"
947
+ || region.kind === "boolean-operator"
948
+ || region.kind === "parentheses")
949
+ && commentsPreserved;
950
+ const planWarnings = [...warnings];
951
+ if (plannedSql !== undefined && applyRewriteEdits(sourceSql, edits) !== plannedSql) {
952
+ errors = [...errors, {
953
+ code: "APPLY_PLAN_MISMATCH",
954
+ message: "Applying SSSQL rewrite plan edits did not reproduce the planned SQL."
955
+ }];
956
+ }
957
+ if (plannedSql !== undefined) {
958
+ try {
959
+ SelectQueryParser.parse(plannedSql);
960
+ }
961
+ catch (error) {
962
+ errors = [...errors, {
963
+ code: "PARSE_AFTER_FAILED",
964
+ message: "The SQL produced by SSSQL rewrite planning could not be parsed.",
965
+ detail: error instanceof Error ? error.message : error
966
+ }];
967
+ }
968
+ }
969
+ if (plannedSql !== undefined && !commentsPreserved) {
970
+ planWarnings.push({
971
+ code: "COMMENTS_NOT_PRESERVED",
972
+ message: "One or more input SQL comments are missing or reordered after the SSSQL rewrite."
973
+ });
974
+ }
975
+ return {
976
+ ok: errors.length === 0,
977
+ requiresFullReformat: false,
978
+ edits,
979
+ sql: plannedSql,
980
+ safety: {
981
+ tokenCountBefore: beforeTokens.length,
982
+ tokenCountAfter: afterTokens.length,
983
+ tokenSequencePreserved: plannedSql !== undefined ? tokenSequencesEqual(beforeTokens, afterTokens) : false,
984
+ commentsPreserved,
985
+ changedOnlyTargetBranches,
986
+ changedRegions
987
+ },
988
+ warnings: planWarnings,
989
+ errors
990
+ };
991
+ }
992
+ planRewrite(query, rewrite) {
993
+ const warnings = [];
994
+ const errors = [];
995
+ const sourceSql = typeof query === "string"
996
+ ? query
997
+ : formatSqlComponent(query);
998
+ if (typeof query !== "string") {
999
+ warnings.push({
1000
+ code: "SOURCE_SQL_UNAVAILABLE",
1001
+ message: "SSSQL rewrite planning received an AST, so the source SQL had to be formatter-generated before analysis."
1002
+ });
1003
+ }
1004
+ let beforeTokens = [];
1005
+ let beforeComments = [];
1006
+ try {
1007
+ beforeTokens = tokenizeForRewritePlan(sourceSql);
1008
+ beforeComments = collectCommentFragments(sourceSql);
1009
+ }
1010
+ catch (error) {
1011
+ errors.push({
1012
+ code: "TOKENIZE_BEFORE_FAILED",
1013
+ message: "Could not tokenize the input SQL before SSSQL rewrite planning.",
1014
+ detail: error instanceof Error ? error.message : error
1015
+ });
1016
+ }
1017
+ let plannedSql;
1018
+ let afterTokens = [];
1019
+ let afterComments = [];
1020
+ if (errors.length === 0) {
1021
+ try {
1022
+ const parsed = SelectQueryParser.parse(sourceSql);
1023
+ const rewritten = rewrite(parsed);
1024
+ plannedSql = formatSqlComponent(rewritten);
1025
+ }
1026
+ catch (error) {
1027
+ errors.push({
1028
+ code: "REWRITE_FAILED",
1029
+ message: "SSSQL rewrite planning could not produce a rewritten query.",
1030
+ detail: error instanceof Error ? error.message : error
1031
+ });
1032
+ }
1033
+ }
1034
+ if (plannedSql !== undefined) {
1035
+ try {
1036
+ SelectQueryParser.parse(plannedSql);
1037
+ }
1038
+ catch (error) {
1039
+ errors.push({
1040
+ code: "PARSE_AFTER_FAILED",
1041
+ message: "The SQL produced by SSSQL rewrite planning could not be parsed.",
1042
+ detail: error instanceof Error ? error.message : error
1043
+ });
1044
+ }
1045
+ try {
1046
+ afterTokens = tokenizeForRewritePlan(plannedSql);
1047
+ afterComments = collectCommentFragments(plannedSql);
1048
+ }
1049
+ catch (error) {
1050
+ errors.push({
1051
+ code: "TOKENIZE_AFTER_FAILED",
1052
+ message: "Could not tokenize the SQL produced by SSSQL rewrite planning.",
1053
+ detail: error instanceof Error ? error.message : error
1054
+ });
1055
+ }
1056
+ }
1057
+ const edits = plannedSql !== undefined && plannedSql !== sourceSql
1058
+ ? [{
1059
+ start: 0,
1060
+ end: sourceSql.length,
1061
+ before: sourceSql,
1062
+ after: plannedSql,
1063
+ kind: "replace",
1064
+ reason: "Current SSSQL rewrite planning is backed by AST rewrite plus formatter output."
1065
+ }]
1066
+ : [];
1067
+ const changedRegions = edits.length > 0
1068
+ ? [{
1069
+ kind: "formatter-rewrite",
1070
+ start: 0,
1071
+ end: sourceSql.length,
1072
+ message: "The conservative SSSQL rewrite plan requires replacing formatter output for the full SQL text."
1073
+ }]
1074
+ : [];
1075
+ const requiresFullReformat = edits.length > 0;
1076
+ const tokenSequencePreserved = plannedSql !== undefined
1077
+ ? tokenSequencesEqual(beforeTokens, afterTokens)
1078
+ : false;
1079
+ const commentsPreserved = plannedSql !== undefined
1080
+ ? commentsPreservedInOrder(beforeComments, afterComments)
1081
+ : false;
1082
+ const changedOnlyTargetBranches = edits.length === 0;
1083
+ if (requiresFullReformat) {
1084
+ warnings.push({
1085
+ code: "FULL_REFORMAT_REQUIRED",
1086
+ message: "The current SSSQL rewrite plan can only represent the change as a full SQL replacement."
1087
+ });
1088
+ }
1089
+ if (plannedSql !== undefined && !tokenSequencePreserved) {
1090
+ warnings.push({
1091
+ code: "TOKEN_SEQUENCE_CHANGED",
1092
+ message: "The SQL token sequence changes after the SSSQL rewrite. The conservative planner cannot prove that only target branches changed.",
1093
+ detail: {
1094
+ tokenCountBefore: beforeTokens.length,
1095
+ tokenCountAfter: afterTokens.length
1096
+ }
1097
+ });
1098
+ }
1099
+ if (plannedSql !== undefined && !commentsPreserved) {
1100
+ warnings.push({
1101
+ code: "COMMENTS_NOT_PRESERVED",
1102
+ message: "One or more input SQL comments are missing or reordered after the SSSQL rewrite."
1103
+ });
1104
+ }
1105
+ if (plannedSql !== undefined && countCommandToken(afterTokens, "as") > countCommandToken(beforeTokens, "as")) {
1106
+ warnings.push({
1107
+ code: "OPTIONAL_ALIAS_AS_ADDED",
1108
+ message: "The rewrite output contains more AS tokens than the input, which may indicate formatter-added aliases."
1109
+ });
1110
+ }
1111
+ if (plannedSql !== undefined && !sourceSql.includes("\"") && plannedSql.includes("\"")) {
1112
+ warnings.push({
1113
+ code: "IDENTIFIER_QUOTES_ADDED",
1114
+ message: "The rewrite output contains double-quoted identifiers that were not present in the input SQL."
1115
+ });
1116
+ }
1117
+ return {
1118
+ ok: errors.length === 0,
1119
+ requiresFullReformat,
1120
+ edits,
1121
+ sql: plannedSql,
1122
+ safety: {
1123
+ tokenCountBefore: beforeTokens.length,
1124
+ tokenCountAfter: afterTokens.length,
1125
+ tokenSequencePreserved,
1126
+ commentsPreserved,
1127
+ changedOnlyTargetBranches,
1128
+ changedRegions
1129
+ },
1130
+ warnings,
1131
+ errors
1132
+ };
1133
+ }
467
1134
  findMatchingBranchInfos(root, spec) {
468
1135
  const normalizedOperator = spec.operator ? normalizeScalarOperator(spec.operator) : undefined;
469
1136
  const normalizedTarget = spec.target ? normalizeIdentifier(spec.target) : undefined;