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