@memberjunction/generic-database-provider 5.13.0 → 5.15.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.
- package/dist/GenericDatabaseProvider.d.ts +42 -6
- package/dist/GenericDatabaseProvider.d.ts.map +1 -1
- package/dist/GenericDatabaseProvider.js +151 -18
- package/dist/GenericDatabaseProvider.js.map +1 -1
- package/dist/SqlLogger.d.ts +11 -0
- package/dist/SqlLogger.d.ts.map +1 -1
- package/dist/SqlLogger.js +38 -2
- package/dist/SqlLogger.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/queryCompositionEngine.d.ts +278 -0
- package/dist/queryCompositionEngine.d.ts.map +1 -0
- package/dist/queryCompositionEngine.js +820 -0
- package/dist/queryCompositionEngine.js.map +1 -0
- package/dist/queryPagingEngine.d.ts +87 -0
- package/dist/queryPagingEngine.d.ts.map +1 -0
- package/dist/queryPagingEngine.js +399 -0
- package/dist/queryPagingEngine.js.map +1 -0
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -10
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
import { UUIDsEqual } from "@memberjunction/global";
|
|
2
|
+
import { SQLServerDialect, PostgreSQLDialect } from "@memberjunction/sql-dialect";
|
|
3
|
+
import { SQLParser } from "@memberjunction/sql-parser";
|
|
4
|
+
import { Metadata, QueryInfo } from "@memberjunction/core";
|
|
5
|
+
/**
|
|
6
|
+
* Maximum depth for recursive query composition resolution.
|
|
7
|
+
* Prevents runaway recursion from deeply nested compositions.
|
|
8
|
+
*/
|
|
9
|
+
const MAX_COMPOSITION_DEPTH = 10;
|
|
10
|
+
/**
|
|
11
|
+
* QueryCompositionEngine resolves {{query:"CategoryPath/QueryName(params)"}} tokens
|
|
12
|
+
* in query SQL into Common Table Expressions (CTEs). It handles:
|
|
13
|
+
*
|
|
14
|
+
* - Recursive resolution of nested compositions
|
|
15
|
+
* - Cycle detection via an in-progress set
|
|
16
|
+
* - Parameter modes: static literals and pass-through from outer query
|
|
17
|
+
* - Deduplication of identical query+params references
|
|
18
|
+
* - Platform-aware SQL resolution via QueryInfo.GetPlatformSQL()
|
|
19
|
+
*
|
|
20
|
+
* This engine runs BEFORE Nunjucks template processing, so regular {{param}}
|
|
21
|
+
* tokens are preserved for later substitution.
|
|
22
|
+
*/
|
|
23
|
+
export class QueryCompositionEngine {
|
|
24
|
+
/**
|
|
25
|
+
* Checks whether SQL contains any {{query:"..."}} composition tokens.
|
|
26
|
+
* Use this as a fast guard before calling ResolveComposition().
|
|
27
|
+
* Only considers tokens outside of SQL comments.
|
|
28
|
+
*/
|
|
29
|
+
HasCompositionTokens(sql) {
|
|
30
|
+
if (!sql)
|
|
31
|
+
return false;
|
|
32
|
+
const stripped = this.stripSQLComments(sql);
|
|
33
|
+
const tokens = SQLParser.Tokenize(stripped);
|
|
34
|
+
return tokens.some(t => t.type === 'MJ_COMPOSITION_REF');
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parses all {{query:"..."}} tokens from SQL without resolving them.
|
|
38
|
+
* Useful for dependency extraction during the save pipeline.
|
|
39
|
+
* Only considers tokens outside of SQL comments.
|
|
40
|
+
*
|
|
41
|
+
* Uses MJLexer for structured tokenization instead of regex, providing
|
|
42
|
+
* full parsing of category paths, query names, and parameter lists.
|
|
43
|
+
*
|
|
44
|
+
* @param sql - The SQL text to parse
|
|
45
|
+
* @returns Array of parsed token metadata
|
|
46
|
+
*/
|
|
47
|
+
ParseCompositionTokens(sql) {
|
|
48
|
+
if (!sql)
|
|
49
|
+
return [];
|
|
50
|
+
const stripped = this.stripSQLComments(sql);
|
|
51
|
+
const refs = SQLParser.ExtractCompositionRefs(stripped);
|
|
52
|
+
return refs.map(ref => {
|
|
53
|
+
const categorySegments = ref.categoryPath
|
|
54
|
+
? ref.categoryPath.split('/').map((s) => s.trim()).filter((s) => s.length > 0)
|
|
55
|
+
: [];
|
|
56
|
+
const fullPath = [...categorySegments, ref.queryName].join('/');
|
|
57
|
+
return {
|
|
58
|
+
FullToken: ref.raw,
|
|
59
|
+
CategorySegments: categorySegments,
|
|
60
|
+
QueryName: ref.queryName,
|
|
61
|
+
FullPath: fullPath,
|
|
62
|
+
Parameters: ref.parameters.map(p => ({
|
|
63
|
+
Name: p.key,
|
|
64
|
+
StaticValue: p.isPassThrough ? null : p.value,
|
|
65
|
+
PassThroughName: p.isPassThrough ? p.value : null,
|
|
66
|
+
})),
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolves all {{query:"..."}} composition tokens in the given SQL into CTEs.
|
|
72
|
+
*
|
|
73
|
+
* @param sql - The SQL containing composition tokens
|
|
74
|
+
* @param platform - Target database platform for SQL resolution
|
|
75
|
+
* @param contextUser - User context for permission checks on referenced queries
|
|
76
|
+
* @param outerParams - Parameter values from the outer/parent query (for pass-through resolution)
|
|
77
|
+
* @param inlineDependencies - Optional inline dependency specs for transient query testing.
|
|
78
|
+
* When provided, these are checked first before falling back to Metadata.Provider.Queries.
|
|
79
|
+
* Inline dependencies skip governance validation (Reusable, IsApproved, UserCanRun).
|
|
80
|
+
* @returns CompositionResult with fully resolved SQL and provenance metadata
|
|
81
|
+
* @throws Error if a referenced query is not found, not composable, or creates a cycle
|
|
82
|
+
*/
|
|
83
|
+
ResolveComposition(sql, platform, contextUser, outerParams, inlineDependencies) {
|
|
84
|
+
const cteEntries = [];
|
|
85
|
+
const dependencyGraph = new Map();
|
|
86
|
+
const inProgressSet = new Set();
|
|
87
|
+
// Mutable flag passed by reference through recursion — short-circuits once true
|
|
88
|
+
const templateFlag = { value: false };
|
|
89
|
+
const resolvedSQL = this.resolveTokensRecursive(sql, platform, contextUser, outerParams || {}, cteEntries, dependencyGraph, inProgressSet, templateFlag, 0, inlineDependencies);
|
|
90
|
+
const hasCompositions = cteEntries.length > 0;
|
|
91
|
+
let finalSQL = resolvedSQL;
|
|
92
|
+
if (hasCompositions) {
|
|
93
|
+
finalSQL = this.assembleCTEs(cteEntries, resolvedSQL, platform);
|
|
94
|
+
}
|
|
95
|
+
// If any dependency uses templates, Nunjucks will run on the resolved SQL.
|
|
96
|
+
// Neutralize any {{ }} patterns inside SQL comments so Nunjucks doesn't
|
|
97
|
+
// try to parse them as template expressions (e.g. -- Demonstrates {{query:"..."}}).
|
|
98
|
+
if (templateFlag.value) {
|
|
99
|
+
finalSQL = this.escapeTemplateTokensInComments(finalSQL);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
ResolvedSQL: finalSQL,
|
|
103
|
+
CTEs: cteEntries.map(e => e.Info),
|
|
104
|
+
DependencyGraph: dependencyGraph,
|
|
105
|
+
HasCompositions: hasCompositions,
|
|
106
|
+
AnyDependencyUsesTemplates: templateFlag.value
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Recursively resolves composition tokens in SQL, building up CTE entries.
|
|
111
|
+
*/
|
|
112
|
+
resolveTokensRecursive(sql, platform, contextUser, outerParams, cteEntries, dependencyGraph, inProgressSet, templateFlag, depth, inlineDependencies) {
|
|
113
|
+
if (depth > MAX_COMPOSITION_DEPTH) {
|
|
114
|
+
throw new Error(`Query composition depth exceeds maximum of ${MAX_COMPOSITION_DEPTH}. ` +
|
|
115
|
+
`This likely indicates an overly deep nesting chain.`);
|
|
116
|
+
}
|
|
117
|
+
const tokens = this.ParseCompositionTokens(sql);
|
|
118
|
+
if (tokens.length === 0)
|
|
119
|
+
return sql;
|
|
120
|
+
let resolvedSQL = sql;
|
|
121
|
+
for (const token of tokens) {
|
|
122
|
+
const lookupResult = this.lookupQueryWithInline(token, inlineDependencies);
|
|
123
|
+
const referencedQuery = lookupResult.Query;
|
|
124
|
+
// Only validate governance (Reusable, IsApproved, permissions) for metadata-backed queries.
|
|
125
|
+
// Inline dependencies are inherently authorized by the caller.
|
|
126
|
+
if (!lookupResult.IsInline) {
|
|
127
|
+
this.validateQueryComposable(referencedQuery, token, contextUser);
|
|
128
|
+
}
|
|
129
|
+
// Depth-first transitive UsesTemplate check — short-circuit once true
|
|
130
|
+
if (!templateFlag.value && referencedQuery.UsesTemplate) {
|
|
131
|
+
templateFlag.value = true;
|
|
132
|
+
}
|
|
133
|
+
// Cycle detection
|
|
134
|
+
if (inProgressSet.has(referencedQuery.ID)) {
|
|
135
|
+
const cyclePath = [...inProgressSet, referencedQuery.ID].join(' → ');
|
|
136
|
+
throw new Error(`Circular query dependency detected: ${cyclePath}. ` +
|
|
137
|
+
`Query "${referencedQuery.Name}" is already being resolved.`);
|
|
138
|
+
}
|
|
139
|
+
// Resolve parameter values
|
|
140
|
+
const resolvedParams = this.resolveParameters(token.Parameters, outerParams);
|
|
141
|
+
// Build deduplication key
|
|
142
|
+
const dedupeKey = this.buildDeduplicationKey(referencedQuery.ID, resolvedParams);
|
|
143
|
+
// Check if we already have this exact CTE
|
|
144
|
+
const existingCTE = cteEntries.find(e => e.DeduplicationKey === dedupeKey);
|
|
145
|
+
if (existingCTE) {
|
|
146
|
+
resolvedSQL = resolvedSQL.replace(token.FullToken, existingCTE.CTEName);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// Get platform-specific SQL for the referenced query
|
|
150
|
+
const refSQL = referencedQuery.GetPlatformSQL(platform);
|
|
151
|
+
// Substitute static parameter values directly into the referenced query's SQL
|
|
152
|
+
const paramSubstitutedSQL = this.substituteStaticParams(refSQL, resolvedParams);
|
|
153
|
+
// Track in-progress for cycle detection
|
|
154
|
+
inProgressSet.add(referencedQuery.ID);
|
|
155
|
+
// For inline deps, pass their nested dependencies into the recursive call.
|
|
156
|
+
// For metadata deps, pass the parent's inline deps so sibling references work.
|
|
157
|
+
const nestedInlineDeps = lookupResult.IsInline
|
|
158
|
+
? lookupResult.NestedDependencies
|
|
159
|
+
: inlineDependencies;
|
|
160
|
+
// Recursively resolve any nested composition tokens in the referenced query
|
|
161
|
+
const nestedResolvedSQL = this.resolveTokensRecursive(paramSubstitutedSQL, platform, contextUser, resolvedParams, cteEntries, dependencyGraph, inProgressSet, templateFlag, depth + 1, nestedInlineDeps);
|
|
162
|
+
inProgressSet.delete(referencedQuery.ID);
|
|
163
|
+
// Track dependency
|
|
164
|
+
const parentDeps = dependencyGraph.get('__current__') || [];
|
|
165
|
+
if (!parentDeps.some(id => UUIDsEqual(id, referencedQuery.ID))) {
|
|
166
|
+
parentDeps.push(referencedQuery.ID);
|
|
167
|
+
dependencyGraph.set('__current__', parentDeps);
|
|
168
|
+
}
|
|
169
|
+
// Generate CTE name (platform-aware: brackets for SQL Server, double quotes for PG)
|
|
170
|
+
const cteName = this.generateCTEName(referencedQuery, resolvedParams, platform);
|
|
171
|
+
const cteEntry = {
|
|
172
|
+
DeduplicationKey: dedupeKey,
|
|
173
|
+
CTEName: cteName,
|
|
174
|
+
SQL: nestedResolvedSQL,
|
|
175
|
+
Info: {
|
|
176
|
+
QueryID: referencedQuery.ID,
|
|
177
|
+
QueryName: referencedQuery.Name,
|
|
178
|
+
CategoryPath: token.FullPath,
|
|
179
|
+
CTEName: cteName,
|
|
180
|
+
OriginalSQL: refSQL,
|
|
181
|
+
ResolvedSQL: nestedResolvedSQL,
|
|
182
|
+
Parameters: resolvedParams
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
cteEntries.push(cteEntry);
|
|
186
|
+
resolvedSQL = resolvedSQL.replace(token.FullToken, cteName);
|
|
187
|
+
}
|
|
188
|
+
return resolvedSQL;
|
|
189
|
+
}
|
|
190
|
+
// parseTokenContent and splitParams removed — composition token parsing
|
|
191
|
+
// is now handled by MJLexer via SQLParser.ExtractCompositionRefs()
|
|
192
|
+
/**
|
|
193
|
+
* Looks up a query by category path + name, checking inline dependencies first,
|
|
194
|
+
* then falling back to the metadata provider.
|
|
195
|
+
*
|
|
196
|
+
* For inline dependencies, creates a synthetic QueryInfo with the SQL and flags set
|
|
197
|
+
* appropriately. Inline queries skip governance validation (Reusable, IsApproved, etc.)
|
|
198
|
+
* since they are inherently authorized by the caller.
|
|
199
|
+
*/
|
|
200
|
+
lookupQueryWithInline(token, inlineDependencies) {
|
|
201
|
+
// Check inline dependencies first
|
|
202
|
+
if (inlineDependencies && inlineDependencies.length > 0) {
|
|
203
|
+
const inlineMatch = this.findInlineDependency(token, inlineDependencies);
|
|
204
|
+
if (inlineMatch) {
|
|
205
|
+
const syntheticQuery = this.buildSyntheticQueryInfo(inlineMatch);
|
|
206
|
+
return {
|
|
207
|
+
Query: syntheticQuery,
|
|
208
|
+
IsInline: true,
|
|
209
|
+
NestedDependencies: inlineMatch.Dependencies,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Fall back to metadata provider
|
|
214
|
+
return {
|
|
215
|
+
Query: this.lookupQueryFromMetadata(token),
|
|
216
|
+
IsInline: false,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Finds a matching inline dependency spec for the given token.
|
|
221
|
+
*/
|
|
222
|
+
findInlineDependency(token, inlineDependencies) {
|
|
223
|
+
const queryName = token.QueryName.toLowerCase();
|
|
224
|
+
// Try category path + name match first
|
|
225
|
+
if (token.CategorySegments.length > 0) {
|
|
226
|
+
const expectedPath = `/${token.CategorySegments.join('/')}/`;
|
|
227
|
+
const match = inlineDependencies.find(d => d.Name.toLowerCase() === queryName &&
|
|
228
|
+
d.CategoryPath.toLowerCase() === expectedPath.toLowerCase());
|
|
229
|
+
if (match)
|
|
230
|
+
return match;
|
|
231
|
+
}
|
|
232
|
+
// Fall back to name-only match
|
|
233
|
+
return inlineDependencies.find(d => d.Name.toLowerCase() === queryName);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Builds a synthetic QueryInfo from an inline dependency spec.
|
|
237
|
+
* Sets flags so that GetPlatformSQL returns the inline SQL directly.
|
|
238
|
+
*/
|
|
239
|
+
buildSyntheticQueryInfo(dep) {
|
|
240
|
+
const synthetic = new QueryInfo();
|
|
241
|
+
// Use a deterministic synthetic ID based on name+path to support cycle detection and deduplication
|
|
242
|
+
synthetic.ID = `__inline__${dep.CategoryPath}${dep.Name}`.toLowerCase();
|
|
243
|
+
synthetic.Name = dep.Name;
|
|
244
|
+
synthetic.SQL = dep.SQL;
|
|
245
|
+
synthetic.UsesTemplate = dep.UsesTemplate ?? false;
|
|
246
|
+
synthetic.Reusable = true;
|
|
247
|
+
synthetic.Status = 'Approved';
|
|
248
|
+
return synthetic;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Looks up a query by category path + name from the metadata provider only.
|
|
252
|
+
*/
|
|
253
|
+
lookupQueryFromMetadata(token) {
|
|
254
|
+
const allQueries = Metadata.Provider.Queries;
|
|
255
|
+
const queryName = token.QueryName.toLowerCase();
|
|
256
|
+
// If category segments provided, build expected category path
|
|
257
|
+
if (token.CategorySegments.length > 0) {
|
|
258
|
+
const expectedPath = `/${token.CategorySegments.join('/')}/`;
|
|
259
|
+
const match = allQueries.find(q => q.Name.toLowerCase() === queryName &&
|
|
260
|
+
q.CategoryPath.toLowerCase() === expectedPath.toLowerCase());
|
|
261
|
+
if (match)
|
|
262
|
+
return match;
|
|
263
|
+
}
|
|
264
|
+
// Fall back to name-only match
|
|
265
|
+
const matches = allQueries.filter(q => q.Name.toLowerCase() === queryName);
|
|
266
|
+
if (matches.length === 0) {
|
|
267
|
+
throw new Error(`Referenced query not found: "${token.FullPath}". ` +
|
|
268
|
+
`Ensure the query exists and the category path is correct.`);
|
|
269
|
+
}
|
|
270
|
+
if (matches.length > 1) {
|
|
271
|
+
throw new Error(`Ambiguous query reference: "${token.FullPath}" matches ${matches.length} queries. ` +
|
|
272
|
+
`Use the full category path to disambiguate.`);
|
|
273
|
+
}
|
|
274
|
+
return matches[0];
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Validates that a referenced query is eligible for composition.
|
|
278
|
+
*/
|
|
279
|
+
validateQueryComposable(query, token, contextUser) {
|
|
280
|
+
if (!query.Reusable) {
|
|
281
|
+
throw new Error(`Query "${token.FullPath}" (${query.Name}) is not marked as Reusable. ` +
|
|
282
|
+
`Set Reusable=true on the query to allow composition.`);
|
|
283
|
+
}
|
|
284
|
+
if (!query.IsApproved) {
|
|
285
|
+
throw new Error(`Query "${token.FullPath}" (${query.Name}) is not Approved (status: ${query.Status}). ` +
|
|
286
|
+
`Only Approved queries can be composed.`);
|
|
287
|
+
}
|
|
288
|
+
if (!query.UserCanRun(contextUser)) {
|
|
289
|
+
throw new Error(`User does not have permission to run referenced query "${token.FullPath}" (${query.Name}).`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Resolves parameter values for a composition reference.
|
|
294
|
+
* Static values are used directly; pass-through values are looked up from outer params.
|
|
295
|
+
*/
|
|
296
|
+
resolveParameters(params, outerParams) {
|
|
297
|
+
const resolved = {};
|
|
298
|
+
for (const param of params) {
|
|
299
|
+
if (param.StaticValue !== null) {
|
|
300
|
+
resolved[param.Name] = param.StaticValue;
|
|
301
|
+
}
|
|
302
|
+
else if (param.PassThroughName !== null) {
|
|
303
|
+
// Pass-through: the value comes from the outer query's parameters
|
|
304
|
+
// At composition time, we create a Nunjucks placeholder so that
|
|
305
|
+
// the downstream Nunjucks processor can substitute the actual value
|
|
306
|
+
resolved[param.Name] = `{{${param.PassThroughName}}}`;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return resolved;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Substitutes resolved parameter values into a query's SQL.
|
|
313
|
+
* - Static values: replaces {{paramName}} with 'value'
|
|
314
|
+
* - Pass-through values: renames {{paramName}} to {{outerParamName}} so
|
|
315
|
+
* the downstream Nunjucks processor can resolve it from the outer query's parameters.
|
|
316
|
+
*/
|
|
317
|
+
substituteStaticParams(sql, params) {
|
|
318
|
+
let result = sql;
|
|
319
|
+
for (const [name, value] of Object.entries(params)) {
|
|
320
|
+
const paramRegex = new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`, 'g');
|
|
321
|
+
if (value.startsWith('{{') && value.endsWith('}}')) {
|
|
322
|
+
// Pass-through: rename the inner param token to the outer param name
|
|
323
|
+
// e.g., {{region}} → {{userRegion}} when the mapping is region=userRegion
|
|
324
|
+
result = result.replace(paramRegex, value);
|
|
325
|
+
}
|
|
326
|
+
else if (/^-?\d+(\.\d+)?$/.test(value)) {
|
|
327
|
+
// Numeric value: substitute as bare literal (no quotes)
|
|
328
|
+
// so expressions like DATEADD(DAY, -{{lookbackDays}}, ...) work correctly
|
|
329
|
+
result = result.replace(paramRegex, value);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
// String value: substitute as a quoted literal
|
|
333
|
+
result = result.replace(paramRegex, `'${value.replace(/'/g, "''")}'`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Builds a deduplication key for a CTE based on query ID and sorted parameter values.
|
|
340
|
+
*/
|
|
341
|
+
buildDeduplicationKey(queryID, params) {
|
|
342
|
+
const sortedParams = Object.entries(params)
|
|
343
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
344
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
345
|
+
.join('&');
|
|
346
|
+
return `${queryID}|${sortedParams}`;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Generates a SQL-safe CTE name from the query name + short hash for uniqueness.
|
|
350
|
+
*/
|
|
351
|
+
generateCTEName(query, params, platform) {
|
|
352
|
+
// Sanitize query name: remove non-alphanumeric chars, replace spaces with underscores
|
|
353
|
+
const sanitized = query.Name
|
|
354
|
+
.replace(/[^a-zA-Z0-9_ ]/g, '')
|
|
355
|
+
.replace(/\s+/g, '_')
|
|
356
|
+
.substring(0, 50);
|
|
357
|
+
// Create a short hash suffix from query ID + params for uniqueness
|
|
358
|
+
const hashInput = query.ID + JSON.stringify(params);
|
|
359
|
+
const hash = this.simpleHash(hashInput);
|
|
360
|
+
const identifier = `__cte_${sanitized}_${hash}`;
|
|
361
|
+
return this.getDialect(platform).QuoteIdentifier(identifier);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Resolves a DatabasePlatform string to the corresponding SQLDialect instance.
|
|
365
|
+
*/
|
|
366
|
+
getDialect(platform) {
|
|
367
|
+
switch (platform) {
|
|
368
|
+
case 'postgresql': return new PostgreSQLDialect();
|
|
369
|
+
case 'sqlserver': return new SQLServerDialect();
|
|
370
|
+
default: throw new Error(`Unsupported database platform: ${platform}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Simple string hash for generating short, deterministic suffixes.
|
|
375
|
+
*/
|
|
376
|
+
simpleHash(input) {
|
|
377
|
+
let hash = 0;
|
|
378
|
+
for (let i = 0; i < input.length; i++) {
|
|
379
|
+
const char = input.charCodeAt(i);
|
|
380
|
+
hash = ((hash << 5) - hash) + char;
|
|
381
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
382
|
+
}
|
|
383
|
+
return Math.abs(hash).toString(36).substring(0, 6);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Assembles CTE entries into a WITH clause prepended to the main SQL.
|
|
387
|
+
*
|
|
388
|
+
* Handles the case where a dependency query's SQL itself contains a WITH clause
|
|
389
|
+
* (inner CTEs). SQL does not allow nested WITH clauses, so inner CTEs are "hoisted"
|
|
390
|
+
* out as sibling CTE definitions preceding the dependency's own CTE.
|
|
391
|
+
*/
|
|
392
|
+
assembleCTEs(cteEntries, mainSQL, platform) {
|
|
393
|
+
if (cteEntries.length === 0)
|
|
394
|
+
return mainSQL;
|
|
395
|
+
// Check if the main SQL already starts with a WITH clause
|
|
396
|
+
const trimmedMain = mainSQL.trimStart();
|
|
397
|
+
const startsWithWith = /^WITH\s/i.test(trimmedMain);
|
|
398
|
+
const dialect = this.getDialect(platform);
|
|
399
|
+
// Build CTE definitions, hoisting any inner WITH clauses from dependency SQL
|
|
400
|
+
const cteDefinitions = [];
|
|
401
|
+
for (const entry of cteEntries) {
|
|
402
|
+
const strippedSQL = this.stripTrailingOrderBy(entry.SQL, dialect);
|
|
403
|
+
const trimmedSQL = strippedSQL.trimStart();
|
|
404
|
+
if (/^WITH\s/i.test(trimmedSQL)) {
|
|
405
|
+
// Dependency SQL has its own WITH clause — hoist inner CTEs as siblings
|
|
406
|
+
const { innerCTEDefinitions, mainSelect } = this.hoistInnerCTEs(trimmedSQL, platform);
|
|
407
|
+
cteDefinitions.push(...innerCTEDefinitions);
|
|
408
|
+
cteDefinitions.push(`${entry.CTEName} AS (\n${mainSelect}\n)`);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
cteDefinitions.push(`${entry.CTEName} AS (\n${strippedSQL}\n)`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (startsWithWith) {
|
|
415
|
+
// Main SQL has its own WITH — merge by removing the leading WITH
|
|
416
|
+
// and prepending our CTEs before it
|
|
417
|
+
const mainWithoutWith = trimmedMain.replace(/^WITH\s+/i, '');
|
|
418
|
+
return `WITH ${cteDefinitions.join(',\n')},\n${mainWithoutWith}`;
|
|
419
|
+
}
|
|
420
|
+
return `WITH ${cteDefinitions.join(',\n')}\n${mainSQL}`;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Extracts inner CTE definitions from SQL that starts with a WITH clause.
|
|
424
|
+
*
|
|
425
|
+
* Delegates to {@link SQLParser.ExtractCTEs} which uses AST parsing first
|
|
426
|
+
* (via node-sql-parser), falling back to a paren-depth regex approach when
|
|
427
|
+
* AST parsing fails (e.g. SQL contains Nunjucks template tokens).
|
|
428
|
+
*
|
|
429
|
+
* @param sql SQL starting with a WITH clause
|
|
430
|
+
* @param platform Database platform, used to select the AST dialect
|
|
431
|
+
*/
|
|
432
|
+
hoistInnerCTEs(sql, platform) {
|
|
433
|
+
const dialect = platform === 'postgresql' ? 'PostgresQL' : 'TransactSQL';
|
|
434
|
+
const extraction = SQLParser.ExtractCTEs(sql, dialect);
|
|
435
|
+
if (!extraction) {
|
|
436
|
+
// Should not happen since caller already verified WITH prefix,
|
|
437
|
+
// but handle gracefully by treating the whole SQL as the main select
|
|
438
|
+
return { innerCTEDefinitions: [], mainSelect: sql };
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
innerCTEDefinitions: extraction.CTEDefinitions,
|
|
442
|
+
mainSelect: extraction.MainStatement,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Strips a trailing ORDER BY clause from SQL that will be wrapped in a CTE.
|
|
447
|
+
*
|
|
448
|
+
* SQL Server disallows ORDER BY inside CTEs unless TOP, OFFSET, or FOR XML is present.
|
|
449
|
+
* PostgreSQL allows ORDER BY in CTEs, so no stripping is needed there.
|
|
450
|
+
*
|
|
451
|
+
* Uses a 4-tier strategy:
|
|
452
|
+
* 1. Fast exit — no ORDER keyword at all, or dialect allows ORDER BY in CTEs
|
|
453
|
+
* 2. AST path — parse (with Nunjucks preprocessing if needed), check if ORDER BY is legal
|
|
454
|
+
* (TOP/OFFSET/FOR XML via AST nodes), null out orderby if not, regenerate.
|
|
455
|
+
* Handles window functions, UNION/EXCEPT, subqueries, string literals, and Nunjucks templates.
|
|
456
|
+
* 3. Regex fallback — paren-depth heuristic for SQL the parser still can't handle
|
|
457
|
+
* (e.g. STRING_AGG WITHIN GROUP)
|
|
458
|
+
* 4. OFFSET 0 ROWS injection — last resort when both AST and regex fail to strip ORDER BY.
|
|
459
|
+
* Injects OFFSET 0 ROWS after the ORDER BY clause to make it legal in CTEs.
|
|
460
|
+
* This is semantically neutral (returns all rows starting from 0) but switches
|
|
461
|
+
* SQL Server into paging mode internally, which may affect query plan shape.
|
|
462
|
+
*/
|
|
463
|
+
stripTrailingOrderBy(sql, dialect) {
|
|
464
|
+
if (!sql)
|
|
465
|
+
return sql;
|
|
466
|
+
const trimmed = sql.trimEnd();
|
|
467
|
+
if (!/ORDER/i.test(trimmed))
|
|
468
|
+
return sql;
|
|
469
|
+
if (dialect.AllowsOrderByInCTE)
|
|
470
|
+
return sql;
|
|
471
|
+
// Tier 2: AST-based stripping
|
|
472
|
+
const astResult = this.stripOrderByViaAST(trimmed, dialect.ParserDialect);
|
|
473
|
+
if (astResult !== null)
|
|
474
|
+
return astResult;
|
|
475
|
+
// Tier 3: Regex fallback
|
|
476
|
+
const regexResult = this.stripOrderByViaRegex(trimmed);
|
|
477
|
+
if (regexResult !== trimmed)
|
|
478
|
+
return regexResult;
|
|
479
|
+
// Tier 4: OFFSET 0 ROWS injection — last resort.
|
|
480
|
+
// If we reach here, the SQL has an ORDER BY that neither AST nor regex could strip
|
|
481
|
+
// (e.g. STRING_AGG WITHIN GROUP with a trailing ORDER BY). Rather than returning
|
|
482
|
+
// the SQL unchanged (which would cause a SQL Server CTE error), inject OFFSET 0 ROWS
|
|
483
|
+
// after the ORDER BY to make it legal. This is semantically neutral but may affect
|
|
484
|
+
// query plan shape on large result sets.
|
|
485
|
+
return this.injectOffset0Rows(trimmed);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Attempts to strip the top-level ORDER BY clause using AST parsing.
|
|
489
|
+
* Tries direct parsing first, then MJPlaceholder-preprocessed parsing if the SQL
|
|
490
|
+
* contains MJ template syntax. Handles UNION/EXCEPT by walking the _next chain.
|
|
491
|
+
*/
|
|
492
|
+
stripOrderByViaAST(sql, parserDialect) {
|
|
493
|
+
const directResult = this.tryASTStrip(sql, parserDialect);
|
|
494
|
+
if (directResult !== null)
|
|
495
|
+
return directResult;
|
|
496
|
+
// Check for MJ extensions using MJLexer (replaces regex check)
|
|
497
|
+
const mjParse = SQLParser.Analyze(sql);
|
|
498
|
+
if (mjParse.hasMJExtensions) {
|
|
499
|
+
return this.tryNunjucksAwareStrip(sql, parserDialect);
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Core AST stripping: parse, analyze, and regenerate SQL without ORDER BY.
|
|
505
|
+
*/
|
|
506
|
+
tryASTStrip(sql, parserDialect) {
|
|
507
|
+
try {
|
|
508
|
+
// Use SQLParser.ParseSQL for FOR XML multi-directive workaround
|
|
509
|
+
const ast = SQLParser.ParseSQL(sql, parserDialect);
|
|
510
|
+
if (!ast)
|
|
511
|
+
return null;
|
|
512
|
+
const stmt = Array.isArray(ast) ? ast[0] : ast;
|
|
513
|
+
if (!stmt)
|
|
514
|
+
return sql;
|
|
515
|
+
const stmtRecord = stmt;
|
|
516
|
+
const orderByStmt = this.findOrderByStatement(stmtRecord);
|
|
517
|
+
if (!orderByStmt)
|
|
518
|
+
return sql;
|
|
519
|
+
if (this.isOrderByLegalInCTE(orderByStmt))
|
|
520
|
+
return sql;
|
|
521
|
+
orderByStmt.orderby = null;
|
|
522
|
+
return SQLParser.SqlifyAST(Array.isArray(ast) ? ast : [stmt], parserDialect);
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Walks the _next chain (UNION/EXCEPT/INTERSECT) to find the statement
|
|
530
|
+
* that carries the ORDER BY clause.
|
|
531
|
+
*/
|
|
532
|
+
findOrderByStatement(stmt) {
|
|
533
|
+
if (stmt.orderby)
|
|
534
|
+
return stmt;
|
|
535
|
+
if (stmt._next)
|
|
536
|
+
return this.findOrderByStatement(stmt._next);
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Nunjucks-aware ORDER BY stripping: preprocess templates into placeholder SQL,
|
|
541
|
+
* parse with AST to confirm top-level ORDER BY exists, then use the position-aware
|
|
542
|
+
* scanner on the original SQL to strip only the last top-level ORDER BY.
|
|
543
|
+
*/
|
|
544
|
+
tryNunjucksAwareStrip(sql, parserDialect) {
|
|
545
|
+
const preprocessed = this.preprocessNunjucks(sql);
|
|
546
|
+
try {
|
|
547
|
+
const ast = SQLParser.ParseSQL(preprocessed, parserDialect);
|
|
548
|
+
if (!ast)
|
|
549
|
+
return null;
|
|
550
|
+
const stmt = Array.isArray(ast) ? ast[0] : ast;
|
|
551
|
+
if (!stmt)
|
|
552
|
+
return sql;
|
|
553
|
+
const stmtRecord = stmt;
|
|
554
|
+
const orderByStmt = this.findOrderByStatement(stmtRecord);
|
|
555
|
+
if (!orderByStmt)
|
|
556
|
+
return sql;
|
|
557
|
+
if (this.isOrderByLegalInCTE(orderByStmt))
|
|
558
|
+
return sql;
|
|
559
|
+
return this.stripLastTopLevelOrderBy(sql);
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Checks AST properties to determine if ORDER BY is legal in a CTE context.
|
|
567
|
+
*/
|
|
568
|
+
isOrderByLegalInCTE(stmt) {
|
|
569
|
+
if (stmt.top)
|
|
570
|
+
return true;
|
|
571
|
+
if (stmt.limit)
|
|
572
|
+
return true;
|
|
573
|
+
const forClause = stmt.for;
|
|
574
|
+
if (forClause && typeof forClause === 'object' && forClause.type &&
|
|
575
|
+
String(forClause.type).toLowerCase().includes('xml')) {
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Strips the last top-level ORDER BY clause using position-aware scanning.
|
|
582
|
+
* Skips strings, comments, Nunjucks tags, and tracks paren depth.
|
|
583
|
+
*/
|
|
584
|
+
stripLastTopLevelOrderBy(sql) {
|
|
585
|
+
const positions = this.findTopLevelOrderByPositions(sql);
|
|
586
|
+
if (positions.length === 0)
|
|
587
|
+
return sql;
|
|
588
|
+
const lastPos = positions[positions.length - 1];
|
|
589
|
+
return sql.substring(0, lastPos).trimEnd();
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Finds character positions of all ORDER BY keywords at the outermost level
|
|
593
|
+
* (paren depth 0, not inside strings, comments, or MJ template tokens).
|
|
594
|
+
*
|
|
595
|
+
* Uses MJLexer to skip MJ tokens ({{ }}, {% %}, {# #}), then scans only
|
|
596
|
+
* SQL_TEXT segments for ORDER BY keywords while tracking paren depth and
|
|
597
|
+
* respecting SQL string literals and comments.
|
|
598
|
+
*/
|
|
599
|
+
findTopLevelOrderByPositions(sql) {
|
|
600
|
+
const tokens = SQLParser.Tokenize(sql);
|
|
601
|
+
const positions = [];
|
|
602
|
+
let parenDepth = 0;
|
|
603
|
+
for (const token of tokens) {
|
|
604
|
+
// Only scan SQL_TEXT tokens — MJ tokens are skipped entirely
|
|
605
|
+
if (token.type !== 'SQL_TEXT')
|
|
606
|
+
continue;
|
|
607
|
+
const text = token.raw;
|
|
608
|
+
let i = 0;
|
|
609
|
+
while (i < text.length) {
|
|
610
|
+
const ch = text[i];
|
|
611
|
+
// Skip single-quoted string literals
|
|
612
|
+
if (ch === "'") {
|
|
613
|
+
i++;
|
|
614
|
+
while (i < text.length) {
|
|
615
|
+
if (text[i] === "'" && i + 1 < text.length && text[i + 1] === "'") {
|
|
616
|
+
i += 2;
|
|
617
|
+
}
|
|
618
|
+
else if (text[i] === "'") {
|
|
619
|
+
i++;
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
i++;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
// Skip line comments
|
|
629
|
+
if (ch === '-' && i + 1 < text.length && text[i + 1] === '-') {
|
|
630
|
+
while (i < text.length && text[i] !== '\n')
|
|
631
|
+
i++;
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
// Skip block comments
|
|
635
|
+
if (ch === '/' && i + 1 < text.length && text[i + 1] === '*') {
|
|
636
|
+
i += 2;
|
|
637
|
+
while (i < text.length) {
|
|
638
|
+
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '/') {
|
|
639
|
+
i += 2;
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
i++;
|
|
643
|
+
}
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (ch === '(') {
|
|
647
|
+
parenDepth++;
|
|
648
|
+
i++;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (ch === ')') {
|
|
652
|
+
parenDepth--;
|
|
653
|
+
i++;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (parenDepth === 0 && /^ORDER\s+BY\b/i.test(text.substring(i))) {
|
|
657
|
+
const absPos = token.start + i;
|
|
658
|
+
if (absPos === 0 || /[\s,;()\n]/.test(sql[absPos - 1])) {
|
|
659
|
+
positions.push(absPos);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
i++;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return positions;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Preprocesses Nunjucks templates into valid SQL for AST parsing.
|
|
669
|
+
* Uses MJPlaceholderSubstitution for context-aware placeholder generation.
|
|
670
|
+
*/
|
|
671
|
+
preprocessNunjucks(sql) {
|
|
672
|
+
return SQLParser.Substitute(sql).cleanSQL;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Injects OFFSET 0 ROWS after the last top-level ORDER BY clause to make it
|
|
676
|
+
* legal in a CTE without changing the result set. Uses the position-aware scanner
|
|
677
|
+
* to find the correct insertion point after the ORDER BY columns.
|
|
678
|
+
*/
|
|
679
|
+
injectOffset0Rows(sql) {
|
|
680
|
+
// Find the end of the last top-level ORDER BY clause.
|
|
681
|
+
// We append OFFSET 0 ROWS right at the end of the SQL.
|
|
682
|
+
const trimmed = sql.trimEnd();
|
|
683
|
+
// Remove trailing semicolon if present
|
|
684
|
+
const withoutSemicolon = trimmed.replace(/;\s*$/, '');
|
|
685
|
+
return `${withoutSemicolon} OFFSET 0 ROWS`;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Regex-based fallback for stripping trailing ORDER BY.
|
|
689
|
+
* Uses parenthesis depth counting to avoid stripping ORDER BY inside subqueries.
|
|
690
|
+
*/
|
|
691
|
+
stripOrderByViaRegex(sql) {
|
|
692
|
+
const orderByMatch = sql.match(/\bORDER\s+BY\s+[\s\S]+$/i);
|
|
693
|
+
if (!orderByMatch)
|
|
694
|
+
return sql;
|
|
695
|
+
const beforeOrderBy = sql.substring(0, orderByMatch.index);
|
|
696
|
+
let parenDepth = 0;
|
|
697
|
+
for (const ch of beforeOrderBy) {
|
|
698
|
+
if (ch === '(')
|
|
699
|
+
parenDepth++;
|
|
700
|
+
else if (ch === ')')
|
|
701
|
+
parenDepth--;
|
|
702
|
+
}
|
|
703
|
+
if (parenDepth !== 0)
|
|
704
|
+
return sql;
|
|
705
|
+
return sql.substring(0, orderByMatch.index).trimEnd();
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Strips SQL comments from the input string so that composition tokens
|
|
709
|
+
* inside comments are not treated as real references.
|
|
710
|
+
* Handles both single-line (-- ...) and multi-line block comments.
|
|
711
|
+
* Preserves string literals (single-quoted) to avoid stripping inside them.
|
|
712
|
+
*/
|
|
713
|
+
stripSQLComments(sql) {
|
|
714
|
+
let result = '';
|
|
715
|
+
let i = 0;
|
|
716
|
+
while (i < sql.length) {
|
|
717
|
+
// Single-quoted string literal — preserve as-is
|
|
718
|
+
if (sql[i] === "'") {
|
|
719
|
+
result += sql[i++];
|
|
720
|
+
while (i < sql.length) {
|
|
721
|
+
if (sql[i] === "'" && i + 1 < sql.length && sql[i + 1] === "'") {
|
|
722
|
+
// Escaped quote inside string
|
|
723
|
+
result += "''";
|
|
724
|
+
i += 2;
|
|
725
|
+
}
|
|
726
|
+
else if (sql[i] === "'") {
|
|
727
|
+
result += sql[i++];
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
result += sql[i++];
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
// Single-line comment: -- to end of line
|
|
736
|
+
else if (sql[i] === '-' && i + 1 < sql.length && sql[i + 1] === '-') {
|
|
737
|
+
// Skip to end of line
|
|
738
|
+
while (i < sql.length && sql[i] !== '\n') {
|
|
739
|
+
i++;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// Block comment: /* ... */
|
|
743
|
+
else if (sql[i] === '/' && i + 1 < sql.length && sql[i + 1] === '*') {
|
|
744
|
+
i += 2; // skip /*
|
|
745
|
+
while (i < sql.length) {
|
|
746
|
+
if (sql[i] === '*' && i + 1 < sql.length && sql[i + 1] === '/') {
|
|
747
|
+
i += 2; // skip */
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
i++;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// Normal character
|
|
754
|
+
else {
|
|
755
|
+
result += sql[i++];
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return result;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Escapes {{ and }} inside SQL comments so that Nunjucks doesn't try to parse them.
|
|
762
|
+
* This is needed because dependency queries may carry comments containing
|
|
763
|
+
* {{query:"..."}} examples or documentation that would otherwise cause
|
|
764
|
+
* Nunjucks "expected variable end" errors.
|
|
765
|
+
*
|
|
766
|
+
* Only modifies content inside -- single-line and block comments.
|
|
767
|
+
* Leaves string literals and normal SQL untouched.
|
|
768
|
+
*/
|
|
769
|
+
escapeTemplateTokensInComments(sql) {
|
|
770
|
+
let result = '';
|
|
771
|
+
let i = 0;
|
|
772
|
+
while (i < sql.length) {
|
|
773
|
+
// Single-quoted string literal — preserve as-is
|
|
774
|
+
if (sql[i] === "'") {
|
|
775
|
+
result += sql[i++];
|
|
776
|
+
while (i < sql.length) {
|
|
777
|
+
if (sql[i] === "'" && i + 1 < sql.length && sql[i + 1] === "'") {
|
|
778
|
+
result += "''";
|
|
779
|
+
i += 2;
|
|
780
|
+
}
|
|
781
|
+
else if (sql[i] === "'") {
|
|
782
|
+
result += sql[i++];
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
result += sql[i++];
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
// Single-line comment: -- to end of line — escape {{ and }} inside
|
|
791
|
+
else if (sql[i] === '-' && i + 1 < sql.length && sql[i + 1] === '-') {
|
|
792
|
+
let comment = '';
|
|
793
|
+
while (i < sql.length && sql[i] !== '\n') {
|
|
794
|
+
comment += sql[i++];
|
|
795
|
+
}
|
|
796
|
+
result += comment.replace(/\{\{/g, '{ {').replace(/\}\}/g, '} }');
|
|
797
|
+
}
|
|
798
|
+
// Block comment: /* ... */ — escape {{ and }} inside
|
|
799
|
+
else if (sql[i] === '/' && i + 1 < sql.length && sql[i + 1] === '*') {
|
|
800
|
+
let comment = '/*';
|
|
801
|
+
i += 2;
|
|
802
|
+
while (i < sql.length) {
|
|
803
|
+
if (sql[i] === '*' && i + 1 < sql.length && sql[i + 1] === '/') {
|
|
804
|
+
comment += '*/';
|
|
805
|
+
i += 2;
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
comment += sql[i++];
|
|
809
|
+
}
|
|
810
|
+
result += comment.replace(/\{\{/g, '{ {').replace(/\}\}/g, '} }');
|
|
811
|
+
}
|
|
812
|
+
// Normal character
|
|
813
|
+
else {
|
|
814
|
+
result += sql[i++];
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return result;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
//# sourceMappingURL=queryCompositionEngine.js.map
|