@kaelio/ktx 0.13.0 → 0.13.1

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.
@@ -1,4 +1,4 @@
1
- import { assertReadOnlySql, stripTrailingSqlNoise } from '../../context/connections/read-only-sql.js';
1
+ import { assertReadOnlySql, hoistLeadingCte, stripTrailingSqlNoise } from '../../context/connections/read-only-sql.js';
2
2
  import { getDialectForDriver } from '../../context/connections/dialects.js';
3
3
  import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
4
4
  import { scopedTableNames } from '../../context/scan/table-ref.js';
@@ -136,7 +136,8 @@ function limitSqlForSqlServerExecution(sqlText, maxRows) {
136
136
  if (!Number.isInteger(maxRows) || maxRows <= 0) {
137
137
  throw new Error('maxRows must be a positive integer.');
138
138
  }
139
- return `SELECT TOP ${maxRows} * FROM (${trimmed}) AS ktx_query_result`;
139
+ const { withPrefix, body } = hoistLeadingCte(trimmed);
140
+ return `${withPrefix}SELECT TOP ${maxRows} * FROM (${body}) AS ktx_query_result`;
140
141
  }
141
142
  export function isKtxSqlServerConnectionConfig(connection) {
142
143
  return String(connection?.driver ?? '').toLowerCase() === 'sqlserver';
@@ -1,3 +1,8 @@
1
1
  export declare function assertReadOnlySql(sql: string): string;
2
+ /** @internal */
3
+ export declare function hoistLeadingCte(sql: string): {
4
+ withPrefix: string;
5
+ body: string;
6
+ };
2
7
  export declare function stripTrailingSqlNoise(sql: string): string;
3
8
  export declare function limitSqlForExecution(sql: string, maxRows: number | undefined): string;
@@ -91,6 +91,143 @@ export function assertReadOnlySql(sql) {
91
91
  assertSingleSqlStatement(trimmed);
92
92
  return trimmed;
93
93
  }
94
+ function isSqlIdentifierPart(char) {
95
+ return char !== undefined && /[A-Za-z0-9_$]/.test(char);
96
+ }
97
+ function keywordAt(sql, index, keyword) {
98
+ if (sql.slice(index, index + keyword.length).toLowerCase() !== keyword.toLowerCase()) {
99
+ return false;
100
+ }
101
+ return !isSqlIdentifierPart(sql[index - 1]) && !isSqlIdentifierPart(sql[index + keyword.length]);
102
+ }
103
+ function skipWhitespaceAndComments(sql, index) {
104
+ let current = index;
105
+ while (current < sql.length) {
106
+ while (/\s/.test(sql[current] ?? '')) {
107
+ current += 1;
108
+ }
109
+ if (sql.startsWith('--', current) || sql.startsWith('/*', current)) {
110
+ current = skipQuotedOrComment(sql, current);
111
+ continue;
112
+ }
113
+ return current;
114
+ }
115
+ return current;
116
+ }
117
+ function skipBracketIdentifier(sql, index) {
118
+ let current = index + 1;
119
+ while (current < sql.length) {
120
+ if (sql[current] === ']') {
121
+ if (sql[current + 1] === ']') {
122
+ current += 2;
123
+ continue;
124
+ }
125
+ return current + 1;
126
+ }
127
+ current += 1;
128
+ }
129
+ return -1;
130
+ }
131
+ function skipBacktickIdentifier(sql, index) {
132
+ let current = index + 1;
133
+ while (current < sql.length) {
134
+ if (sql[current] === '`') {
135
+ if (sql[current + 1] === '`') {
136
+ current += 2;
137
+ continue;
138
+ }
139
+ return current + 1;
140
+ }
141
+ current += 1;
142
+ }
143
+ return -1;
144
+ }
145
+ function skipIdentifier(sql, index) {
146
+ if (sql[index] === '"') {
147
+ const skipped = skipQuotedOrComment(sql, index);
148
+ return skipped > index ? skipped : -1;
149
+ }
150
+ if (sql[index] === '[') {
151
+ return skipBracketIdentifier(sql, index);
152
+ }
153
+ if (sql[index] === '`') {
154
+ return skipBacktickIdentifier(sql, index);
155
+ }
156
+ let current = index;
157
+ while (isSqlIdentifierPart(sql[current])) {
158
+ current += 1;
159
+ }
160
+ return current > index ? current : -1;
161
+ }
162
+ function skipBalancedParentheses(sql, index) {
163
+ if (sql[index] !== '(') {
164
+ return -1;
165
+ }
166
+ let current = index;
167
+ let depth = 0;
168
+ while (current < sql.length) {
169
+ const skipped = skipQuotedOrComment(sql, current);
170
+ if (skipped > current) {
171
+ current = skipped;
172
+ continue;
173
+ }
174
+ if (sql[current] === '(') {
175
+ depth += 1;
176
+ }
177
+ else if (sql[current] === ')') {
178
+ depth -= 1;
179
+ if (depth === 0) {
180
+ return current + 1;
181
+ }
182
+ }
183
+ current += 1;
184
+ }
185
+ return -1;
186
+ }
187
+ /** @internal */
188
+ export function hoistLeadingCte(sql) {
189
+ const trimmed = sql.trim();
190
+ if (!keywordAt(trimmed, 0, 'with')) {
191
+ return { withPrefix: '', body: sql };
192
+ }
193
+ let current = skipWhitespaceAndComments(trimmed, 4);
194
+ if (keywordAt(trimmed, current, 'recursive')) {
195
+ current = skipWhitespaceAndComments(trimmed, current + 'recursive'.length);
196
+ }
197
+ while (current < trimmed.length) {
198
+ current = skipIdentifier(trimmed, current);
199
+ if (current < 0) {
200
+ return { withPrefix: '', body: trimmed };
201
+ }
202
+ current = skipWhitespaceAndComments(trimmed, current);
203
+ if (trimmed[current] === '(') {
204
+ current = skipBalancedParentheses(trimmed, current);
205
+ if (current < 0) {
206
+ return { withPrefix: '', body: trimmed };
207
+ }
208
+ current = skipWhitespaceAndComments(trimmed, current);
209
+ }
210
+ if (!keywordAt(trimmed, current, 'as')) {
211
+ return { withPrefix: '', body: trimmed };
212
+ }
213
+ current = skipWhitespaceAndComments(trimmed, current + 2);
214
+ current = skipBalancedParentheses(trimmed, current);
215
+ if (current < 0) {
216
+ return { withPrefix: '', body: trimmed };
217
+ }
218
+ current = skipWhitespaceAndComments(trimmed, current);
219
+ if (trimmed[current] === ',') {
220
+ current = skipWhitespaceAndComments(trimmed, current + 1);
221
+ continue;
222
+ }
223
+ const body = trimmed.slice(current).trimStart();
224
+ if (!body) {
225
+ return { withPrefix: '', body: trimmed };
226
+ }
227
+ return { withPrefix: `${trimmed.slice(0, current).trimEnd()} `, body };
228
+ }
229
+ return { withPrefix: '', body: trimmed };
230
+ }
94
231
  // `assertReadOnlySql` deliberately keeps trailing semicolons, comments, and
95
232
  // whitespace (e.g. `select 1; -- done`) — harmless for direct single-statement
96
233
  // execution. A row-limit subquery wrapper needs a bare expression instead: a
@@ -130,5 +267,6 @@ export function limitSqlForExecution(sql, maxRows) {
130
267
  if (!Number.isInteger(maxRows) || maxRows <= 0) {
131
268
  throw new KtxQueryError('maxRows must be a positive integer.');
132
269
  }
133
- return `select * from (${trimmed}) as ktx_query_result limit ${maxRows}`;
270
+ const { withPrefix, body } = hoistLeadingCte(trimmed);
271
+ return `${withPrefix}select * from (${body}) as ktx_query_result limit ${maxRows}`;
134
272
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaelio/ktx",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Standalone ktx context layer for data agents",
5
5
  "author": {
6
6
  "name": "Kaelio",