@memberjunction/generic-database-provider 5.28.0 → 5.29.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.
@@ -1,7 +1,8 @@
1
1
  import { UUIDsEqual } from "@memberjunction/global";
2
- import { SQLServerDialect, PostgreSQLDialect } from "@memberjunction/sql-dialect";
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
- // 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
- }
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
- const refSQL = referencedQuery.GetPlatformSQL(platform);
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
- switch (platform) {
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
- // Track every CTE name allocated to the assembled WITH (case-insensitive).
413
- // We seed with the outer-CTE names from the entries themselves so that an
414
- // inner CTE happening to share a name with another entry's outer CTE
415
- // also gets renamed.
416
- const allocatedNames = new Set(cteEntries.map(e => this.canonicalCTEName(e.CTEName).toLowerCase()));
417
- // Build CTE definitions, hoisting any inner WITH clauses from dependency SQL
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
- // Dependency SQL has its own WITH clause — hoist inner CTEs as siblings.
426
- // Pass the comment-stripped version so ExtractCTEs can detect the WITH prefix.
427
- const { innerCTEDefinitions, mainSelect } = this.hoistInnerCTEs(commentStrippedSQL, platform);
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
- * Walks `innerCTEDefinitions` (each formatted as `Name AS (\nbody\n)` by
457
- * `extractCTEsViaRegex`/`extractCTEsViaAST`) and ensures every CTE name is
458
- * unique across the assembled WITH. When a collision is detected, the inner
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
- * NOTE (Phase 0 trade-off): reference rewriting is a word-boundary regex
465
- * over raw SQL. It does NOT skip string literals or comments. Real
466
- * dependencies' inner-CTE names are unlikely to appear as quoted text or
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
- deconflictInnerCTEs(innerCTEDefinitions, mainSelect, allocatedNames) {
472
- // Pre-compute renames first, so all sibling/main-select rewrites use
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
- const lower = canonical.toLowerCase();
487
- if (!allocatedNames.has(lower)) {
488
- allocatedNames.add(lower);
489
- finalDefinitionHeaders.push({ original: def, rewritten: def });
490
- continue;
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
- // Collision — find a fresh name.
493
- let suffix = 2;
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
- * `deconflictInnerCTEs` for the trade-off rationale.
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 platform Database platform, used to select the AST dialect
544
+ * @param dialect SQL dialect for AST parsing
545
545
  */
546
- hoistInnerCTEs(sql, platform) {
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
- * Uses a 4-tier strategy:
566
- * 1. Fast exit — no ORDER keyword at all, or dialect allows ORDER BY in CTEs
567
- * 2. AST path — parse (with Nunjucks preprocessing if needed), check if ORDER BY is legal
568
- * (TOP/OFFSET/FOR XML via AST nodes), null out orderby if not, regenerate.
569
- * Handles window functions, UNION/EXCEPT, subqueries, string literals, and Nunjucks templates.
570
- * 3. Regex fallback — paren-depth heuristic for SQL the parser still can't handle
571
- * (e.g. STRING_AGG WITHIN GROUP)
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
- // Tier 2: AST-based stripping
586
- const astResult = this.stripOrderByViaAST(trimmed, dialect.ParserDialect);
587
- if (astResult !== null)
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
- return this.injectOffset0Rows(trimmed);
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
- const lastPos = positions[positions.length - 1];
704
- return sql.substring(0, lastPos).trimEnd();
705
- }
706
- /**
707
- * Finds character positions of all ORDER BY keywords at the outermost level
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. Uses the position-aware scanner
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