@memberjunction/generic-database-provider 5.28.0 → 5.30.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 +6 -8
- package/dist/GenericDatabaseProvider.d.ts.map +1 -1
- package/dist/GenericDatabaseProvider.js +32 -81
- package/dist/GenericDatabaseProvider.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/queryCompositionEngine.d.ts +24 -99
- package/dist/queryCompositionEngine.d.ts.map +1 -1
- package/dist/queryCompositionEngine.js +92 -369
- package/dist/queryCompositionEngine.js.map +1 -1
- package/dist/queryPagingEngine.d.ts +8 -59
- package/dist/queryPagingEngine.d.ts.map +1 -1
- package/dist/queryPagingEngine.js +55 -235
- package/dist/queryPagingEngine.js.map +1 -1
- package/dist/renderPipeline.d.ts +133 -0
- package/dist/renderPipeline.d.ts.map +1 -0
- package/dist/renderPipeline.js +218 -0
- package/dist/renderPipeline.js.map +1 -0
- package/dist/symbolTable.d.ts +47 -0
- package/dist/symbolTable.d.ts.map +1 -0
- package/dist/symbolTable.js +94 -0
- package/dist/symbolTable.js.map +1 -0
- package/package.json +13 -13
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { UUIDsEqual } from "@memberjunction/global";
|
|
2
|
-
import {
|
|
3
|
-
import { SQLParser } from "@memberjunction/sql-parser";
|
|
2
|
+
import { GetDialect } from "@memberjunction/sql-dialect";
|
|
3
|
+
import { SQLParser, AnalyzeTopLevelOrderBy } from "@memberjunction/sql-parser";
|
|
4
4
|
import { Metadata, QueryInfo } from "@memberjunction/core";
|
|
5
|
+
import { SymbolTable } from "./symbolTable.js";
|
|
5
6
|
/**
|
|
6
7
|
* Maximum depth for recursive query composition resolution.
|
|
7
8
|
* Prevents runaway recursion from deeply nested compositions.
|
|
@@ -92,12 +93,9 @@ export class QueryCompositionEngine {
|
|
|
92
93
|
if (hasCompositions) {
|
|
93
94
|
finalSQL = this.assembleCTEs(cteEntries, resolvedSQL, platform);
|
|
94
95
|
}
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
if (templateFlag.value) {
|
|
99
|
-
finalSQL = this.escapeTemplateTokensInComments(finalSQL);
|
|
100
|
-
}
|
|
96
|
+
// Comment-embedded {{ }} tokens (e.g. -- Example: {{query:"..."}}) are
|
|
97
|
+
// handled by the pipeline: RenderPipeline strips SQL comments before
|
|
98
|
+
// Nunjucks runs, so they never reach the template evaluator.
|
|
101
99
|
return {
|
|
102
100
|
ResolvedSQL: finalSQL,
|
|
103
101
|
CTEs: cteEntries.map(e => e.Info),
|
|
@@ -146,8 +144,13 @@ export class QueryCompositionEngine {
|
|
|
146
144
|
resolvedSQL = resolvedSQL.replace(token.FullToken, existingCTE.CTEName);
|
|
147
145
|
continue;
|
|
148
146
|
}
|
|
149
|
-
// Get platform-specific SQL for the referenced query
|
|
150
|
-
|
|
147
|
+
// Get platform-specific SQL for the referenced query.
|
|
148
|
+
// Strip SQL comments immediately — they serve no runtime purpose and
|
|
149
|
+
// can contain {{ }} patterns that confuse substituteStaticParams,
|
|
150
|
+
// Nunjucks, or ORDER BY detection. The pipeline also strips comments
|
|
151
|
+
// from the outer query before Nunjucks, but dep SQL enters here
|
|
152
|
+
// before it reaches the pipeline's comment-stripping step.
|
|
153
|
+
const refSQL = this.stripSQLComments(referencedQuery.GetPlatformSQL(platform));
|
|
151
154
|
// Substitute static parameter values directly into the referenced query's SQL
|
|
152
155
|
const paramSubstitutedSQL = this.substituteStaticParams(refSQL, resolvedParams);
|
|
153
156
|
// Track in-progress for cycle detection
|
|
@@ -369,11 +372,7 @@ export class QueryCompositionEngine {
|
|
|
369
372
|
* Resolves a DatabasePlatform string to the corresponding SQLDialect instance.
|
|
370
373
|
*/
|
|
371
374
|
getDialect(platform) {
|
|
372
|
-
|
|
373
|
-
case 'postgresql': return new PostgreSQLDialect();
|
|
374
|
-
case 'sqlserver': return new SQLServerDialect();
|
|
375
|
-
default: throw new Error(`Unsupported database platform: ${platform}`);
|
|
376
|
-
}
|
|
375
|
+
return GetDialect(platform);
|
|
377
376
|
}
|
|
378
377
|
/**
|
|
379
378
|
* Simple string hash for generating short, deterministic suffixes.
|
|
@@ -405,30 +404,24 @@ export class QueryCompositionEngine {
|
|
|
405
404
|
assembleCTEs(cteEntries, mainSQL, platform) {
|
|
406
405
|
if (cteEntries.length === 0)
|
|
407
406
|
return mainSQL;
|
|
408
|
-
// Check if the main SQL already starts with a WITH clause
|
|
409
407
|
const trimmedMain = mainSQL.trimStart();
|
|
410
408
|
const startsWithWith = /^WITH\s/i.test(trimmedMain);
|
|
411
409
|
const dialect = this.getDialect(platform);
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
//
|
|
415
|
-
// also
|
|
416
|
-
const
|
|
417
|
-
|
|
410
|
+
// SymbolTable guarantees CTE name uniqueness at registration time.
|
|
411
|
+
const symTable = new SymbolTable(dialect);
|
|
412
|
+
// Seed the symbol table with outer-CTE names so inner CTEs that happen
|
|
413
|
+
// to share a name with an outer CTE also get renamed.
|
|
414
|
+
for (const entry of cteEntries) {
|
|
415
|
+
symTable.Seed(this.canonicalCTEName(entry.CTEName));
|
|
416
|
+
}
|
|
418
417
|
const cteDefinitions = [];
|
|
419
418
|
for (const entry of cteEntries) {
|
|
420
419
|
const strippedSQL = this.stripTrailingOrderBy(entry.SQL, dialect);
|
|
421
|
-
// Strip SQL comments before checking for WITH, because dependency queries
|
|
422
|
-
// may have comment headers (-- or /* */) before the WITH clause.
|
|
423
420
|
const commentStrippedSQL = this.stripSQLComments(strippedSQL).trimStart();
|
|
424
421
|
if (/^WITH\s/i.test(commentStrippedSQL)) {
|
|
425
|
-
|
|
426
|
-
//
|
|
427
|
-
const {
|
|
428
|
-
// Rename any inner CTE whose name collides with an already-
|
|
429
|
-
// allocated name. Rewrites references in (a) other inner CTEs of
|
|
430
|
-
// this same dep (siblings), and (b) the dep's mainSelect.
|
|
431
|
-
const { definitions, rewrittenMainSelect } = this.deconflictInnerCTEs(innerCTEDefinitions, mainSelect, allocatedNames);
|
|
422
|
+
const { innerCTEDefinitions, mainSelect } = this.hoistInnerCTEs(commentStrippedSQL, dialect);
|
|
423
|
+
// Use SymbolTable for deconfliction instead of raw Set
|
|
424
|
+
const { definitions, rewrittenMainSelect } = this.deconflictInnerCTEsViaSymbolTable(innerCTEDefinitions, mainSelect, symTable);
|
|
432
425
|
cteDefinitions.push(...definitions);
|
|
433
426
|
cteDefinitions.push(`${entry.CTEName} AS (\n${rewrittenMainSelect}\n)`);
|
|
434
427
|
}
|
|
@@ -436,14 +429,43 @@ export class QueryCompositionEngine {
|
|
|
436
429
|
cteDefinitions.push(`${entry.CTEName} AS (\n${strippedSQL}\n)`);
|
|
437
430
|
}
|
|
438
431
|
}
|
|
432
|
+
if (!dialect.AllowsOrderByInCTE) {
|
|
433
|
+
this.validateCTEBodies(cteDefinitions, cteEntries, dialect);
|
|
434
|
+
}
|
|
439
435
|
if (startsWithWith) {
|
|
440
|
-
// Main SQL has its own WITH — merge by removing the leading WITH
|
|
441
|
-
// and prepending our CTEs before it
|
|
442
436
|
const mainWithoutWith = trimmedMain.replace(/^WITH\s+/i, '');
|
|
443
437
|
return `WITH ${cteDefinitions.join(',\n')},\n${mainWithoutWith}`;
|
|
444
438
|
}
|
|
445
439
|
return `WITH ${cteDefinitions.join(',\n')}\n${mainSQL}`;
|
|
446
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* Validates that no CTE body still contains an illegal top-level ORDER BY
|
|
443
|
+
* after stripping. Throws a diagnostic error with the dep name, category
|
|
444
|
+
* path, and specific reason — replacing the cryptic SQL Server error
|
|
445
|
+
* "The ORDER BY clause is invalid in views, inline functions...".
|
|
446
|
+
*/
|
|
447
|
+
validateCTEBodies(cteDefinitions, cteEntries, dialect) {
|
|
448
|
+
for (const def of cteDefinitions) {
|
|
449
|
+
// Extract the CTE body between the first "(" and the last ")"
|
|
450
|
+
const bodyMatch = def.match(/^([^\s]+)\s+AS\s*\(\n?([\s\S]*?)\n?\)$/i);
|
|
451
|
+
if (!bodyMatch)
|
|
452
|
+
continue;
|
|
453
|
+
const cteName = bodyMatch[1];
|
|
454
|
+
const body = bodyMatch[2];
|
|
455
|
+
const analysis = AnalyzeTopLevelOrderBy(body, dialect);
|
|
456
|
+
if (analysis.Positions.length > 0 && !analysis.IsLegalInCTE) {
|
|
457
|
+
const entry = cteEntries.find(e => e.CTEName === cteName);
|
|
458
|
+
const depName = entry?.Info.QueryName ?? cteName;
|
|
459
|
+
const categoryPath = entry?.Info.CategoryPath ?? '';
|
|
460
|
+
const position = analysis.Positions[analysis.Positions.length - 1];
|
|
461
|
+
throw new Error(`Composition error: dependency '${depName}'` +
|
|
462
|
+
(categoryPath ? ` (${categoryPath})` : '') +
|
|
463
|
+
` has a trailing ORDER BY at position ${position} that could not be stripped. ` +
|
|
464
|
+
`SQL Server does not allow ORDER BY in CTEs without TOP, OFFSET, or FOR XML. ` +
|
|
465
|
+
`Either remove the ORDER BY from the dependency, or add TOP/OFFSET.`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
447
469
|
/**
|
|
448
470
|
* Strips the surrounding bracket / quote characters from a CTE name so we
|
|
449
471
|
* can compare names case-insensitively across quoting styles. `[Foo]`,
|
|
@@ -453,61 +475,39 @@ export class QueryCompositionEngine {
|
|
|
453
475
|
return rawName.replace(/^[\["]|[\]"]$/g, '');
|
|
454
476
|
}
|
|
455
477
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
* CTE is renamed (`Name` → `Name__2`, `__3`, …) and the new name is
|
|
460
|
-
* substituted everywhere the original was referenced — both in subsequent
|
|
461
|
-
* inner CTE bodies (siblings can reference each other) and in the dep's
|
|
462
|
-
* main SELECT.
|
|
478
|
+
* Deconflicts inner CTE names using a SymbolTable for name allocation.
|
|
479
|
+
* The SymbolTable guarantees uniqueness at registration time — name
|
|
480
|
+
* collisions become impossible by construction.
|
|
463
481
|
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
* column aliases, so this is acceptable for the immediate fix; the
|
|
468
|
-
* IR-based redesign (see plans/query-rendering-pipeline-redesign.md)
|
|
469
|
-
* replaces this with a structured walk.
|
|
482
|
+
* Reference rewriting uses word-boundary regex over raw SQL. It does
|
|
483
|
+
* NOT skip string literals or comments — inner-CTE names are unlikely
|
|
484
|
+
* to appear as quoted text or column aliases in practice.
|
|
470
485
|
*/
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
// the final names regardless of definition order.
|
|
474
|
-
const renames = new Map(); // canonical-old → new bare name
|
|
486
|
+
deconflictInnerCTEsViaSymbolTable(innerCTEDefinitions, mainSelect, symTable) {
|
|
487
|
+
const renames = new Map();
|
|
475
488
|
const finalDefinitionHeaders = [];
|
|
476
489
|
for (const def of innerCTEDefinitions) {
|
|
477
490
|
const headerMatch = def.match(/^(\[[^\]]+\]|"[^"]+"|[A-Za-z_]\w*)(\s+AS\s*\()/i);
|
|
478
491
|
if (!headerMatch) {
|
|
479
|
-
// Couldn't parse — leave untouched. Future allocations may
|
|
480
|
-
// still collide, but this preserves existing behavior.
|
|
481
492
|
finalDefinitionHeaders.push({ original: def, rewritten: def });
|
|
482
493
|
continue;
|
|
483
494
|
}
|
|
484
495
|
const rawName = headerMatch[1];
|
|
485
496
|
const canonical = this.canonicalCTEName(rawName);
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
497
|
+
// SymbolTable.Register handles collision detection + suffix generation
|
|
498
|
+
const actual = symTable.Register(canonical);
|
|
499
|
+
if (actual !== canonical) {
|
|
500
|
+
renames.set(canonical, actual);
|
|
501
|
+
const rewrittenDef = actual + headerMatch[2] + def.substring(headerMatch[0].length);
|
|
502
|
+
finalDefinitionHeaders.push({ original: def, rewritten: rewrittenDef });
|
|
491
503
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
let candidate = `${canonical}__${suffix}`;
|
|
495
|
-
while (allocatedNames.has(candidate.toLowerCase())) {
|
|
496
|
-
suffix++;
|
|
497
|
-
candidate = `${canonical}__${suffix}`;
|
|
504
|
+
else {
|
|
505
|
+
finalDefinitionHeaders.push({ original: def, rewritten: def });
|
|
498
506
|
}
|
|
499
|
-
allocatedNames.add(candidate.toLowerCase());
|
|
500
|
-
renames.set(canonical, candidate);
|
|
501
|
-
// Replace just the header on the definition; the body will be
|
|
502
|
-
// rewritten in the next pass alongside other deps' references.
|
|
503
|
-
const rewrittenDef = candidate + headerMatch[2] + def.substring(headerMatch[0].length);
|
|
504
|
-
finalDefinitionHeaders.push({ original: def, rewritten: rewrittenDef });
|
|
505
507
|
}
|
|
506
508
|
if (renames.size === 0) {
|
|
507
509
|
return { definitions: finalDefinitionHeaders.map(d => d.rewritten), rewrittenMainSelect: mainSelect };
|
|
508
510
|
}
|
|
509
|
-
// Apply all renames to every inner CTE body and the main SELECT, so
|
|
510
|
-
// sibling CTEs that reference the renamed CTE point at the new name.
|
|
511
511
|
const rewriteAll = (sql) => {
|
|
512
512
|
let result = sql;
|
|
513
513
|
for (const [oldName, newName] of renames) {
|
|
@@ -525,7 +525,7 @@ export class QueryCompositionEngine {
|
|
|
525
525
|
* `MyAcronymBridge` is not affected by renaming `AcronymBridge`.
|
|
526
526
|
*
|
|
527
527
|
* Does not attempt to skip string literals or comments — see the note on
|
|
528
|
-
* `
|
|
528
|
+
* `deconflictInnerCTEsViaSymbolTable` for the trade-off rationale.
|
|
529
529
|
*/
|
|
530
530
|
renameSQLIdentifier(sql, oldName, newName) {
|
|
531
531
|
const escaped = oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -541,10 +541,9 @@ export class QueryCompositionEngine {
|
|
|
541
541
|
* AST parsing fails (e.g. SQL contains Nunjucks template tokens).
|
|
542
542
|
*
|
|
543
543
|
* @param sql SQL starting with a WITH clause
|
|
544
|
-
* @param
|
|
544
|
+
* @param dialect SQL dialect for AST parsing
|
|
545
545
|
*/
|
|
546
|
-
hoistInnerCTEs(sql,
|
|
547
|
-
const dialect = platform === 'postgresql' ? 'PostgresQL' : 'TransactSQL';
|
|
546
|
+
hoistInnerCTEs(sql, dialect) {
|
|
548
547
|
const extraction = SQLParser.ExtractCTEs(sql, dialect);
|
|
549
548
|
if (!extraction) {
|
|
550
549
|
// Should not happen since caller already verified WITH prefix,
|
|
@@ -562,17 +561,13 @@ export class QueryCompositionEngine {
|
|
|
562
561
|
* SQL Server disallows ORDER BY inside CTEs unless TOP, OFFSET, or FOR XML is present.
|
|
563
562
|
* PostgreSQL allows ORDER BY in CTEs, so no stripping is needed there.
|
|
564
563
|
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
* 4. OFFSET 0 ROWS injection — last resort when both AST and regex fail to strip ORDER BY.
|
|
573
|
-
* Injects OFFSET 0 ROWS after the ORDER BY clause to make it legal in CTEs.
|
|
574
|
-
* This is semantically neutral (returns all rows starting from 0) but switches
|
|
575
|
-
* SQL Server into paging mode internally, which may affect query plan shape.
|
|
564
|
+
* Delegates to the shared `AnalyzeTopLevelOrderBy` utility (orderByAnalyzer.ts)
|
|
565
|
+
* which implements a 2-tier strategy:
|
|
566
|
+
* Tier 1: AST parsing (with Nunjucks preprocessing if needed)
|
|
567
|
+
* Tier 2: MJLexer scanner with carried lexical state across token boundaries
|
|
568
|
+
*
|
|
569
|
+
* When both tiers fail to strip, falls back to OFFSET 0 ROWS injection as
|
|
570
|
+
* a last resort (semantically neutral but makes ORDER BY legal in CTEs).
|
|
576
571
|
*/
|
|
577
572
|
stripTrailingOrderBy(sql, dialect) {
|
|
578
573
|
if (!sql)
|
|
@@ -582,241 +577,28 @@ export class QueryCompositionEngine {
|
|
|
582
577
|
return sql;
|
|
583
578
|
if (dialect.AllowsOrderByInCTE)
|
|
584
579
|
return sql;
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
if (
|
|
588
|
-
return astResult;
|
|
589
|
-
// Tier 3: Position-aware fallback (skips comments, strings, MJ tokens via MJLexer)
|
|
590
|
-
const regexResult = this.stripOrderByViaRegex(trimmed);
|
|
591
|
-
if (regexResult !== trimmed)
|
|
592
|
-
return regexResult;
|
|
593
|
-
// Tier 4: OFFSET 0 ROWS injection — last resort, but ONLY when a real
|
|
594
|
-
// top-level ORDER BY exists. If there is none, returning SQL unchanged is
|
|
595
|
-
// correct; injecting OFFSET 0 ROWS would itself be an error
|
|
596
|
-
// (OFFSET requires ORDER BY in the same query level).
|
|
597
|
-
if (this.findTopLevelOrderByPositions(trimmed).length === 0) {
|
|
580
|
+
const analysis = AnalyzeTopLevelOrderBy(trimmed, dialect);
|
|
581
|
+
// No top-level ORDER BY found — nothing to strip
|
|
582
|
+
if (analysis.Positions.length === 0)
|
|
598
583
|
return sql;
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
}
|
|
602
|
-
/**
|
|
603
|
-
* Attempts to strip the top-level ORDER BY clause using AST parsing.
|
|
604
|
-
* Tries direct parsing first, then MJPlaceholder-preprocessed parsing if the SQL
|
|
605
|
-
* contains MJ template syntax. Handles UNION/EXCEPT by walking the _next chain.
|
|
606
|
-
*/
|
|
607
|
-
stripOrderByViaAST(sql, parserDialect) {
|
|
608
|
-
const directResult = this.tryASTStrip(sql, parserDialect);
|
|
609
|
-
if (directResult !== null)
|
|
610
|
-
return directResult;
|
|
611
|
-
// Check for MJ extensions using MJLexer (replaces regex check)
|
|
612
|
-
const mjParse = SQLParser.Analyze(sql);
|
|
613
|
-
if (mjParse.hasMJExtensions) {
|
|
614
|
-
return this.tryNunjucksAwareStrip(sql, parserDialect);
|
|
615
|
-
}
|
|
616
|
-
return null;
|
|
617
|
-
}
|
|
618
|
-
/**
|
|
619
|
-
* Core AST stripping: parse, analyze, and regenerate SQL without ORDER BY.
|
|
620
|
-
*/
|
|
621
|
-
tryASTStrip(sql, parserDialect) {
|
|
622
|
-
try {
|
|
623
|
-
// Use SQLParser.ParseSQL for FOR XML multi-directive workaround
|
|
624
|
-
const ast = SQLParser.ParseSQL(sql, parserDialect);
|
|
625
|
-
if (!ast)
|
|
626
|
-
return null;
|
|
627
|
-
const stmt = Array.isArray(ast) ? ast[0] : ast;
|
|
628
|
-
if (!stmt)
|
|
629
|
-
return sql;
|
|
630
|
-
const stmtRecord = stmt;
|
|
631
|
-
const orderByStmt = this.findOrderByStatement(stmtRecord);
|
|
632
|
-
if (!orderByStmt)
|
|
633
|
-
return sql;
|
|
634
|
-
if (this.isOrderByLegalInCTE(orderByStmt))
|
|
635
|
-
return sql;
|
|
636
|
-
orderByStmt.orderby = null;
|
|
637
|
-
return SQLParser.SqlifyAST(Array.isArray(ast) ? ast : [stmt], parserDialect);
|
|
638
|
-
}
|
|
639
|
-
catch {
|
|
640
|
-
return null;
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
/**
|
|
644
|
-
* Walks the _next chain (UNION/EXCEPT/INTERSECT) to find the statement
|
|
645
|
-
* that carries the ORDER BY clause.
|
|
646
|
-
*/
|
|
647
|
-
findOrderByStatement(stmt) {
|
|
648
|
-
if (stmt.orderby)
|
|
649
|
-
return stmt;
|
|
650
|
-
if (stmt._next)
|
|
651
|
-
return this.findOrderByStatement(stmt._next);
|
|
652
|
-
return null;
|
|
653
|
-
}
|
|
654
|
-
/**
|
|
655
|
-
* Nunjucks-aware ORDER BY stripping: preprocess templates into placeholder SQL,
|
|
656
|
-
* parse with AST to confirm top-level ORDER BY exists, then use the position-aware
|
|
657
|
-
* scanner on the original SQL to strip only the last top-level ORDER BY.
|
|
658
|
-
*/
|
|
659
|
-
tryNunjucksAwareStrip(sql, parserDialect) {
|
|
660
|
-
const preprocessed = this.preprocessNunjucks(sql);
|
|
661
|
-
try {
|
|
662
|
-
const ast = SQLParser.ParseSQL(preprocessed, parserDialect);
|
|
663
|
-
if (!ast)
|
|
664
|
-
return null;
|
|
665
|
-
const stmt = Array.isArray(ast) ? ast[0] : ast;
|
|
666
|
-
if (!stmt)
|
|
667
|
-
return sql;
|
|
668
|
-
const stmtRecord = stmt;
|
|
669
|
-
const orderByStmt = this.findOrderByStatement(stmtRecord);
|
|
670
|
-
if (!orderByStmt)
|
|
671
|
-
return sql;
|
|
672
|
-
if (this.isOrderByLegalInCTE(orderByStmt))
|
|
673
|
-
return sql;
|
|
674
|
-
return this.stripLastTopLevelOrderBy(sql);
|
|
675
|
-
}
|
|
676
|
-
catch {
|
|
677
|
-
return null;
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
/**
|
|
681
|
-
* Checks AST properties to determine if ORDER BY is legal in a CTE context.
|
|
682
|
-
*/
|
|
683
|
-
isOrderByLegalInCTE(stmt) {
|
|
684
|
-
if (stmt.top)
|
|
685
|
-
return true;
|
|
686
|
-
if (stmt.limit)
|
|
687
|
-
return true;
|
|
688
|
-
const forClause = stmt.for;
|
|
689
|
-
if (forClause && typeof forClause === 'object' && forClause.type &&
|
|
690
|
-
String(forClause.type).toLowerCase().includes('xml')) {
|
|
691
|
-
return true;
|
|
692
|
-
}
|
|
693
|
-
return false;
|
|
694
|
-
}
|
|
695
|
-
/**
|
|
696
|
-
* Strips the last top-level ORDER BY clause using position-aware scanning.
|
|
697
|
-
* Skips strings, comments, Nunjucks tags, and tracks paren depth.
|
|
698
|
-
*/
|
|
699
|
-
stripLastTopLevelOrderBy(sql) {
|
|
700
|
-
const positions = this.findTopLevelOrderByPositions(sql);
|
|
701
|
-
if (positions.length === 0)
|
|
584
|
+
// ORDER BY is legal in this CTE context (TOP/OFFSET/FOR XML)
|
|
585
|
+
if (analysis.IsLegalInCTE)
|
|
702
586
|
return sql;
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
* (paren depth 0, not inside strings, comments, or MJ template tokens).
|
|
709
|
-
*
|
|
710
|
-
* Uses MJLexer to skip MJ tokens ({{ }}, {% %}, {# #}), then scans only
|
|
711
|
-
* SQL_TEXT segments for ORDER BY keywords while tracking paren depth and
|
|
712
|
-
* respecting SQL string literals and comments.
|
|
713
|
-
*/
|
|
714
|
-
findTopLevelOrderByPositions(sql) {
|
|
715
|
-
const tokens = SQLParser.Tokenize(sql);
|
|
716
|
-
const positions = [];
|
|
717
|
-
let parenDepth = 0;
|
|
718
|
-
for (const token of tokens) {
|
|
719
|
-
// Only scan SQL_TEXT tokens — MJ tokens are skipped entirely
|
|
720
|
-
if (token.type !== 'SQL_TEXT')
|
|
721
|
-
continue;
|
|
722
|
-
const text = token.raw;
|
|
723
|
-
let i = 0;
|
|
724
|
-
while (i < text.length) {
|
|
725
|
-
const ch = text[i];
|
|
726
|
-
// Skip single-quoted string literals
|
|
727
|
-
if (ch === "'") {
|
|
728
|
-
i++;
|
|
729
|
-
while (i < text.length) {
|
|
730
|
-
if (text[i] === "'" && i + 1 < text.length && text[i + 1] === "'") {
|
|
731
|
-
i += 2;
|
|
732
|
-
}
|
|
733
|
-
else if (text[i] === "'") {
|
|
734
|
-
i++;
|
|
735
|
-
break;
|
|
736
|
-
}
|
|
737
|
-
else {
|
|
738
|
-
i++;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
continue;
|
|
742
|
-
}
|
|
743
|
-
// Skip line comments
|
|
744
|
-
if (ch === '-' && i + 1 < text.length && text[i + 1] === '-') {
|
|
745
|
-
while (i < text.length && text[i] !== '\n')
|
|
746
|
-
i++;
|
|
747
|
-
continue;
|
|
748
|
-
}
|
|
749
|
-
// Skip block comments
|
|
750
|
-
if (ch === '/' && i + 1 < text.length && text[i + 1] === '*') {
|
|
751
|
-
i += 2;
|
|
752
|
-
while (i < text.length) {
|
|
753
|
-
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '/') {
|
|
754
|
-
i += 2;
|
|
755
|
-
break;
|
|
756
|
-
}
|
|
757
|
-
i++;
|
|
758
|
-
}
|
|
759
|
-
continue;
|
|
760
|
-
}
|
|
761
|
-
if (ch === '(') {
|
|
762
|
-
parenDepth++;
|
|
763
|
-
i++;
|
|
764
|
-
continue;
|
|
765
|
-
}
|
|
766
|
-
if (ch === ')') {
|
|
767
|
-
parenDepth--;
|
|
768
|
-
i++;
|
|
769
|
-
continue;
|
|
770
|
-
}
|
|
771
|
-
if (parenDepth === 0 && /^ORDER\s+BY\b/i.test(text.substring(i))) {
|
|
772
|
-
const absPos = token.start + i;
|
|
773
|
-
if (absPos === 0 || /[\s,;()\n]/.test(sql[absPos - 1])) {
|
|
774
|
-
positions.push(absPos);
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
i++;
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
return positions;
|
|
781
|
-
}
|
|
782
|
-
/**
|
|
783
|
-
* Preprocesses Nunjucks templates into valid SQL for AST parsing.
|
|
784
|
-
* Uses MJPlaceholderSubstitution for context-aware placeholder generation.
|
|
785
|
-
*/
|
|
786
|
-
preprocessNunjucks(sql) {
|
|
787
|
-
return SQLParser.Substitute(sql).cleanSQL;
|
|
587
|
+
// Stripping succeeded — use the stripped SQL
|
|
588
|
+
if (analysis.SqlWithoutOrderBy !== trimmed)
|
|
589
|
+
return analysis.SqlWithoutOrderBy;
|
|
590
|
+
// Last resort: OFFSET 0 ROWS injection
|
|
591
|
+
return this.injectOffset0Rows(trimmed);
|
|
788
592
|
}
|
|
789
593
|
/**
|
|
790
594
|
* Injects OFFSET 0 ROWS after the last top-level ORDER BY clause to make it
|
|
791
|
-
* legal in a CTE without changing the result set.
|
|
792
|
-
* to find the correct insertion point after the ORDER BY columns.
|
|
595
|
+
* legal in a CTE without changing the result set.
|
|
793
596
|
*/
|
|
794
597
|
injectOffset0Rows(sql) {
|
|
795
|
-
// Find the end of the last top-level ORDER BY clause.
|
|
796
|
-
// We append OFFSET 0 ROWS right at the end of the SQL.
|
|
797
598
|
const trimmed = sql.trimEnd();
|
|
798
|
-
// Remove trailing semicolon if present
|
|
799
599
|
const withoutSemicolon = trimmed.replace(/;\s*$/, '');
|
|
800
600
|
return `${withoutSemicolon} OFFSET 0 ROWS`;
|
|
801
601
|
}
|
|
802
|
-
/**
|
|
803
|
-
* Position-aware fallback for stripping the last top-level ORDER BY clause.
|
|
804
|
-
* Delegates to `stripLastTopLevelOrderBy`, which uses MJLexer to skip MJ
|
|
805
|
-
* template tokens, then scans only SQL_TEXT segments while respecting paren
|
|
806
|
-
* depth, single-quoted string literals, line comments (--), and block
|
|
807
|
-
* comments (/* … */).
|
|
808
|
-
*
|
|
809
|
-
* The previous regex-only implementation matched any literal "ORDER BY" in
|
|
810
|
-
* the SQL — including text inside leading block comments like
|
|
811
|
-
* `/* No ORDER BY / TOP — composable. */` — and truncated the dep body
|
|
812
|
-
* mid-comment, producing illegal SQL ("Incorrect syntax near '('").
|
|
813
|
-
* Skip-Brain hit this against multiple AGRiP composition deps. See
|
|
814
|
-
* `__tests__/skip-failure-regressions.test.ts` and
|
|
815
|
-
* `SKIP-QUERY-RENDERING-BUGS.md` (Bug A) at the repo root.
|
|
816
|
-
*/
|
|
817
|
-
stripOrderByViaRegex(sql) {
|
|
818
|
-
return this.stripLastTopLevelOrderBy(sql);
|
|
819
|
-
}
|
|
820
602
|
/**
|
|
821
603
|
* Strips SQL comments from the input string so that composition tokens
|
|
822
604
|
* inside comments are not treated as real references.
|
|
@@ -870,64 +652,5 @@ export class QueryCompositionEngine {
|
|
|
870
652
|
}
|
|
871
653
|
return result;
|
|
872
654
|
}
|
|
873
|
-
/**
|
|
874
|
-
* Escapes {{ and }} inside SQL comments so that Nunjucks doesn't try to parse them.
|
|
875
|
-
* This is needed because dependency queries may carry comments containing
|
|
876
|
-
* {{query:"..."}} examples or documentation that would otherwise cause
|
|
877
|
-
* Nunjucks "expected variable end" errors.
|
|
878
|
-
*
|
|
879
|
-
* Only modifies content inside -- single-line and block comments.
|
|
880
|
-
* Leaves string literals and normal SQL untouched.
|
|
881
|
-
*/
|
|
882
|
-
escapeTemplateTokensInComments(sql) {
|
|
883
|
-
let result = '';
|
|
884
|
-
let i = 0;
|
|
885
|
-
while (i < sql.length) {
|
|
886
|
-
// Single-quoted string literal — preserve as-is
|
|
887
|
-
if (sql[i] === "'") {
|
|
888
|
-
result += sql[i++];
|
|
889
|
-
while (i < sql.length) {
|
|
890
|
-
if (sql[i] === "'" && i + 1 < sql.length && sql[i + 1] === "'") {
|
|
891
|
-
result += "''";
|
|
892
|
-
i += 2;
|
|
893
|
-
}
|
|
894
|
-
else if (sql[i] === "'") {
|
|
895
|
-
result += sql[i++];
|
|
896
|
-
break;
|
|
897
|
-
}
|
|
898
|
-
else {
|
|
899
|
-
result += sql[i++];
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
// Single-line comment: -- to end of line — escape {{ and }} inside
|
|
904
|
-
else if (sql[i] === '-' && i + 1 < sql.length && sql[i + 1] === '-') {
|
|
905
|
-
let comment = '';
|
|
906
|
-
while (i < sql.length && sql[i] !== '\n') {
|
|
907
|
-
comment += sql[i++];
|
|
908
|
-
}
|
|
909
|
-
result += comment.replace(/\{\{/g, '{ {').replace(/\}\}/g, '} }');
|
|
910
|
-
}
|
|
911
|
-
// Block comment: /* ... */ — escape {{ and }} inside
|
|
912
|
-
else if (sql[i] === '/' && i + 1 < sql.length && sql[i + 1] === '*') {
|
|
913
|
-
let comment = '/*';
|
|
914
|
-
i += 2;
|
|
915
|
-
while (i < sql.length) {
|
|
916
|
-
if (sql[i] === '*' && i + 1 < sql.length && sql[i + 1] === '/') {
|
|
917
|
-
comment += '*/';
|
|
918
|
-
i += 2;
|
|
919
|
-
break;
|
|
920
|
-
}
|
|
921
|
-
comment += sql[i++];
|
|
922
|
-
}
|
|
923
|
-
result += comment.replace(/\{\{/g, '{ {').replace(/\}\}/g, '} }');
|
|
924
|
-
}
|
|
925
|
-
// Normal character
|
|
926
|
-
else {
|
|
927
|
-
result += sql[i++];
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
return result;
|
|
931
|
-
}
|
|
932
655
|
}
|
|
933
656
|
//# sourceMappingURL=queryCompositionEngine.js.map
|