sqllens 1.3.0 → 1.5.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.
Files changed (64) hide show
  1. package/README.md +19 -0
  2. package/dist/bigquery/behavior.js +2 -1
  3. package/dist/bigquery/fold.d.ts +6 -0
  4. package/dist/bigquery/fold.js +8 -0
  5. package/dist/bigquery/lower.js +37 -6
  6. package/dist/completion/complete.d.ts +1 -1
  7. package/dist/completion/complete.js +166 -20
  8. package/dist/completion/jinja-slot.d.ts +6 -0
  9. package/dist/completion/jinja-slot.js +12 -2
  10. package/dist/databricks/behavior.js +2 -1
  11. package/dist/databricks/fold.d.ts +4 -0
  12. package/dist/databricks/fold.js +6 -0
  13. package/dist/databricks/lower.js +10 -3
  14. package/dist/dialect-behavior/behavior.d.ts +5 -0
  15. package/dist/duckdb/behavior.js +2 -1
  16. package/dist/duckdb/fold.d.ts +4 -0
  17. package/dist/duckdb/fold.js +6 -0
  18. package/dist/duckdb/lower.js +39 -8
  19. package/dist/index.d.ts +1 -1
  20. package/dist/ir/ir.d.ts +16 -6
  21. package/dist/ir/qualified-name.d.ts +43 -0
  22. package/dist/ir/qualified-name.js +74 -0
  23. package/dist/lineage/hops.js +7 -3
  24. package/dist/lineage/lineage.js +2 -1
  25. package/dist/minijinja/apply-tags.js +43 -11
  26. package/dist/mysql/behavior.js +2 -1
  27. package/dist/mysql/fold.d.ts +4 -0
  28. package/dist/mysql/fold.js +6 -0
  29. package/dist/mysql/lower.js +19 -4
  30. package/dist/postgres/behavior.js +2 -1
  31. package/dist/postgres/fold.d.ts +5 -0
  32. package/dist/postgres/fold.js +7 -0
  33. package/dist/postgres/lower.js +21 -5
  34. package/dist/qualify/qualify.d.ts +1 -1
  35. package/dist/qualify/qualify.js +75 -50
  36. package/dist/qualify/schema-provider.d.ts +13 -0
  37. package/dist/qualify/schema.d.ts +18 -1
  38. package/dist/qualify/schema.js +60 -6
  39. package/dist/qualify/template-provider.d.ts +5 -2
  40. package/dist/qualify/template-provider.js +5 -2
  41. package/dist/redshift/behavior.js +2 -1
  42. package/dist/redshift/fold.d.ts +4 -0
  43. package/dist/redshift/fold.js +6 -0
  44. package/dist/redshift/lower.js +20 -4
  45. package/dist/scope/scope.d.ts +24 -2
  46. package/dist/scope/scope.js +65 -13
  47. package/dist/sema/resolve.js +21 -10
  48. package/dist/snowflake/behavior.js +2 -1
  49. package/dist/snowflake/fold.d.ts +4 -0
  50. package/dist/snowflake/fold.js +6 -0
  51. package/dist/snowflake/lower.js +23 -4
  52. package/dist/sqlite/behavior.js +2 -1
  53. package/dist/sqlite/fold.d.ts +4 -0
  54. package/dist/sqlite/fold.js +6 -0
  55. package/dist/sqlite/lower.js +11 -2
  56. package/dist/trino/behavior.js +2 -1
  57. package/dist/trino/fold.d.ts +3 -0
  58. package/dist/trino/fold.js +5 -0
  59. package/dist/trino/lower.js +22 -4
  60. package/dist/tsql/behavior.js +2 -1
  61. package/dist/tsql/fold.d.ts +5 -0
  62. package/dist/tsql/fold.js +7 -0
  63. package/dist/tsql/lower.js +18 -5
  64. package/package.json +1 -1
@@ -3,7 +3,13 @@ import { DuckdbParser as P } from "../generated/duckdb/DuckdbParser.js";
3
3
  import { keywordCategory, swallowedCategories, swallowedStatements } from "../ir/statement.js";
4
4
  import { partSpansOf } from "../ir/part-span.js";
5
5
  import { freezeIR } from "../ir/freeze.js";
6
- import { displayName } from "./fold.js";
6
+ import { qualifiedNameOf } from "../ir/qualified-name.js";
7
+ import { displayName, DUCKDB_NAME_CONFIG } from "./fold.js";
8
+ /** The structured name for a table source's raw parts (issue #38) — role assignment + identity
9
+ * key + fqn happen HERE, at lowering, where the dialect's namespace shape is known. */
10
+ function relationOf(rawParts) {
11
+ return qualifiedNameOf(rawParts, DUCKDB_NAME_CONFIG);
12
+ }
7
13
  // ---------------------------------------------------------------------------
8
14
  // Lowering — DuckDB (fork of this repo's grammars/postgres pair, TVL lineage)
9
15
  // CST -> the shared dialect-neutral IR (src/ir/ir.ts). The semantic layer runs
@@ -270,8 +276,15 @@ function lowerPivotStmt(stmt) {
270
276
  const isPivot = stmt.ruleIndex === P.RULE_pivotstmt;
271
277
  const from = [];
272
278
  const qn = directChildrenOfRule(stmt, P.RULE_qualified_name)[0];
273
- if (qn)
274
- from.push({ kind: "table", name: nameParts(qn), namePartSpans: columnPartSpans(qn), cst: qn });
279
+ if (qn) {
280
+ const pivotParts = nameParts(qn);
281
+ from.push({
282
+ kind: "table",
283
+ relation: relationOf(pivotParts),
284
+ namePartSpans: columnPartSpans(qn),
285
+ cst: qn,
286
+ });
287
+ }
275
288
  const sw = directChildrenOfRule(stmt, P.RULE_select_with_parens)[0];
276
289
  if (sw) {
277
290
  const inner = innerSelect(sw);
@@ -649,9 +662,10 @@ function buildPrimarySource(tr, unsupported) {
649
662
  // FROM 'file.parquet' — a string-literal relation (replacement scan, data/overview.md).
650
663
  const fileRel = directChildrenOfRule(tr, P.RULE_sconst)[0];
651
664
  if (fileRel) {
665
+ const fileRelName = [stripStringQuotes(fileRel.getText())];
652
666
  return {
653
667
  kind: "table",
654
- name: [stripStringQuotes(fileRel.getText())],
668
+ relation: relationOf(fileRelName),
655
669
  namePartSpans: partSpansOf([fileRel]),
656
670
  alias,
657
671
  aliasCst,
@@ -680,9 +694,10 @@ function buildPrimarySource(tr, unsupported) {
680
694
  if (funcTable) {
681
695
  const fname = firstShallow(funcTable, P.RULE_func_name);
682
696
  const funcAlias = directChildrenOfRule(tr, P.RULE_func_alias_clause)[0];
697
+ const funcTableName = [fname ? lastName(fname) : funcTable.getText()];
683
698
  return {
684
699
  kind: "table",
685
- name: [fname ? lastName(fname) : funcTable.getText()],
700
+ relation: relationOf(funcTableName),
686
701
  alias: alias ?? (funcAlias ? funcAliasName(funcAlias) : undefined),
687
702
  aliasCst,
688
703
  cst: tr,
@@ -690,7 +705,14 @@ function buildPrimarySource(tr, unsupported) {
690
705
  }
691
706
  const jsonTable = directChildrenOfRule(tr, P.RULE_json_table)[0];
692
707
  if (jsonTable) {
693
- return { kind: "table", name: ["json_table"], alias, aliasCst, columnAliases, cst: tr };
708
+ return {
709
+ kind: "table",
710
+ relation: relationOf(["json_table"]),
711
+ alias,
712
+ aliasCst,
713
+ columnAliases,
714
+ cst: tr,
715
+ };
694
716
  }
695
717
  const nestedTr = directChildrenOfRule(tr, P.RULE_table_ref)[0];
696
718
  if (nestedTr) {
@@ -703,14 +725,23 @@ function buildPrimarySource(tr, unsupported) {
703
725
  if (innerFrom.length)
704
726
  return innerFrom[0];
705
727
  }
706
- return { kind: "table", name: [textOrEmpty(tr)], alias, aliasCst, columnAliases, cst: tr };
728
+ const tableName = [textOrEmpty(tr)];
729
+ return { kind: "table", relation: relationOf(tableName), alias, aliasCst, columnAliases, cst: tr };
707
730
  }
708
731
  const nestedJoinConditions = [];
709
732
  function buildTableFromRelation(rel, alias, aliasCst, columnAliases) {
710
733
  const qn = directChildrenOfRule(rel, P.RULE_qualified_name)[0];
711
734
  const parts = qn ? nameParts(qn) : [textOrEmpty(rel)];
712
735
  const namePartSpans = qn ? columnPartSpans(qn) : undefined;
713
- return { kind: "table", name: parts, namePartSpans, alias, aliasCst, columnAliases, cst: rel };
736
+ return {
737
+ kind: "table",
738
+ relation: relationOf(parts),
739
+ namePartSpans,
740
+ alias,
741
+ aliasCst,
742
+ columnAliases,
743
+ cst: rel,
744
+ };
714
745
  }
715
746
  /** The alias identifier node — under table_alias (AS slot) or bare_table_alias (AS-less slot). */
716
747
  function aliasIdentNode(aliasClause) {
package/dist/index.d.ts CHANGED
@@ -22,7 +22,7 @@ export { lower as lowerSqlite } from "./sqlite/lower.js";
22
22
  export { lower as lowerMysql } from "./mysql/lower.js";
23
23
  export type { ParseResult } from "./parse-result.js";
24
24
  export { type TemplatedParseResult, type TemplatedParseOptions } from "./template/engine.js";
25
- export type { Clause, ColumnRef, CteDef, Expr, GraphElement, GraphTableSource, Join, JoinKind, LateralViewSource, LimitInfo, PartSpan, PipeBranch, PipeExpr, PipeSetItem, PipeStage, PivotInfo, Projection, QueryBody, QueryExpr, SelectExpr, SetOpExpr, Source, SubquerySource, TableSource, TemplateExprInfo, TemplateSourceInfo, UnpivotInfo, UnsupportedFlag, WindowSpec, } from "./ir/ir.js";
25
+ export type { Clause, ColumnRef, CteDef, Expr, GraphElement, GraphTableSource, Join, JoinKind, LateralViewSource, LimitInfo, PartSpan, PipeBranch, PipeExpr, PipeSetItem, PipeStage, PivotInfo, Projection, QualifiedName, QueryBody, QueryExpr, SelectExpr, SetOpExpr, Source, SubquerySource, TableSource, TemplateExprInfo, TemplateSourceInfo, UnpivotInfo, UnsupportedFlag, WindowSpec, } from "./ir/ir.js";
26
26
  export { partSpanOf, partSpansOf } from "./ir/part-span.js";
27
27
  export { endPosition } from "./ir/span.js";
28
28
  export { childExprs, walkExprs, allQueryExprs } from "./ir/walk.js";
package/dist/ir/ir.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { ParserRuleContext } from "antlr4ng";
2
2
  import type { PartSpan } from "./part-span.js";
3
+ import type { QualifiedName } from "./qualified-name.js";
3
4
  import type { StatementCategory } from "./statement.js";
4
5
  export type { PartSpan } from "./part-span.js";
6
+ export type { QualifiedName, QualifiedNameConfig, NameRole } from "./qualified-name.js";
5
7
  /**
6
8
  * The identity of one template call, the `expansion()` key. Args are LITERAL
7
9
  * string values: quote-stripped, escapes NOT resolved — an argument whose
@@ -647,12 +649,16 @@ export interface TemplateSourceInfo {
647
649
  }
648
650
  export interface TableSource {
649
651
  kind: "table";
650
- /** Multipart name parts as written, e.g. ["catalog","schema","t"]. */
651
- name: string[];
652
- /** Per-part spans PARALLEL to `name`, one per multipart segment — same all-or-nothing convention
653
- * as `ColumnRef.partSpans` (absent when any part lacks a real token). Lets a consumer hit-test a
654
- * cursor on `catalog` vs `schema` vs `t` in `catalog.schema.t`, and recover each part's raw
655
- * (delimiter-included) source text via span + document text without re-scanning the token stream. */
652
+ /** The STRUCTURED name (issue #38): the object's own `name`, `parts` exactly as written, roles
653
+ * right-aligned per the dialect's namespace, the folded identity `key`, and a display-ready
654
+ * `fqn`. Built by the dialect's lower() (the one layer that knows the namespace shape);
655
+ * consumers read structure here and never re-derive it from a parts array. */
656
+ relation: QualifiedName;
657
+ /** Per-part spans PARALLEL to `relation.parts`, one per multipart segment — same all-or-nothing
658
+ * convention as `ColumnRef.partSpans` (absent when any part lacks a real token). Lets a
659
+ * consumer hit-test a cursor on `catalog` vs `schema` vs `t` in `catalog.schema.t`, and recover
660
+ * each part's raw (delimiter-included) source text via span + document text without re-scanning
661
+ * the token stream. */
656
662
  namePartSpans?: PartSpan[];
657
663
  alias?: string;
658
664
  /** The alias identifier's own CST node (for its precise span), when aliased. */
@@ -670,6 +676,10 @@ export interface TableSource {
670
676
  /** Present when this source was written as a minijinja template tag in a FROM/JOIN slot; attached
671
677
  * post-lower by the jinja front end (src/minijinja/apply-tags.ts). See TemplateSourceInfo. */
672
678
  template?: TemplateSourceInfo;
679
+ /** True when the LIBRARY built this node rather than lowering it from user syntax (a graph
680
+ * element variable exposed as a relation). Catalog existence semantics don't apply: a closed
681
+ * world's unknown-table never fires for a synthesized source. */
682
+ synthesized?: true;
673
683
  cst: ParserRuleContext;
674
684
  }
675
685
  export interface SubquerySource {
@@ -0,0 +1,43 @@
1
+ import { type FoldRule } from "../ident/fold.js";
2
+ /** Qualifier roles, normalized across dialects (vendor vocabulary noted per dialect at its
3
+ * config): "catalog" covers database/project, "schema" covers dataset/attached-database,
4
+ * "server" is tsql's linked-server level. The object's own name is never a role — it is the
5
+ * final part, always. */
6
+ export type NameRole = "server" | "catalog" | "schema";
7
+ /** A dialect's namespace shape: its qualifier roles OUTER→INNER, and the fold/delimiter rule
8
+ * its identifiers obey. Declared once per dialect in src/<dialect>/fold.ts, doc-cited there. */
9
+ export interface QualifiedNameConfig {
10
+ roles: readonly NameRole[];
11
+ rule: FoldRule;
12
+ }
13
+ /** A structured, dialect-resolved name. Plain frozen data like every IR node. */
14
+ export interface QualifiedName {
15
+ /** The object's OWN name (the last part), delimiters stripped, as written. */
16
+ name: string;
17
+ /** Every part as written, outermost first, delimiters stripped. An elided middle part is "". */
18
+ parts: string[];
19
+ /** tsql linked-server level. Absent = not written. */
20
+ server?: string;
21
+ /** Catalog level (vendor: catalog / database / project). Absent = not written. */
22
+ catalog?: string;
23
+ /** Schema level (vendor: schema / dataset / attached database). Absent = not written;
24
+ * "" = written-and-elided (`db..table`). */
25
+ schema?: string;
26
+ /** Folded identity parts (the dialect's own quoting-aware fold). COMPARE with these — never
27
+ * display them, never re-fold. */
28
+ key: string[];
29
+ /** The qualified name as ONE display-ready string. Source-derived: the raw parts joined as
30
+ * written (quoting kept). Synthesized: parts joined with dialect quoting where needed. */
31
+ fqn: string;
32
+ }
33
+ /**
34
+ * Build a QualifiedName from SOURCE-derived raw parts (as they appear in the text, delimiters
35
+ * intact where the user wrote them). `fqn` preserves the user's spelling and quoting exactly.
36
+ */
37
+ export declare function qualifiedNameOf(rawParts: string[], config: QualifiedNameConfig): QualifiedName;
38
+ /**
39
+ * Build a QualifiedName from SYNTHESIZED plain parts (a provider's resolved relation — no source
40
+ * text exists). `fqn` renders each part with the dialect's quoting exactly where the part needs
41
+ * delimiters to round-trip; plain identifier parts stay bare.
42
+ */
43
+ export declare function synthesizedQualifiedName(parts: string[], config: QualifiedNameConfig): QualifiedName;
@@ -0,0 +1,74 @@
1
+ // ---------------------------------------------------------------------------
2
+ // QualifiedName — the structured relation/column name the IR carries (issue #38).
3
+ //
4
+ // Root cause this type exists to fix: LOSS OF INFORMATION AT LOWERING. A dotted name's
5
+ // structure (which part is the catalog, which the schema; what was quoted; what identity each
6
+ // part folds to) was flattened to string[] at lower time, and every downstream layer re-derived
7
+ // it by heuristic — collapsing, in practice, to last-part matching. The two standing principles
8
+ // applied to names:
9
+ //
10
+ // lossless — `parts` is exactly what was written (partial qualification represented, never
11
+ // defaulted; a tsql elided middle part `db..table` is an EXPLICITLY EMPTY part);
12
+ // `fqn` joins the raw parts as written, quoting included. Nothing re-cased,
13
+ // nothing invented.
14
+ // never-wrong — roles are assigned only to parts that exist, right-aligned against the
15
+ // dialect's namespace; a part beyond the dialect's depth gets NO role. `key` is
16
+ // the folded identity under the dialect's own quoting semantics; consumers
17
+ // compare keys and never fold or parse dotted strings themselves.
18
+ //
19
+ // The dialect's lower() builds these (it is the one layer that knows both the CST shape and the
20
+ // dialect's namespace); each dialect's config lives next to its fold rule in
21
+ // src/<dialect>/fold.ts. This module is dialect-agnostic mechanics only.
22
+ // ---------------------------------------------------------------------------
23
+ import { displayWith, foldWith } from "../ident/fold.js";
24
+ /** Right-align the dialect's roles onto the qualifier parts (everything before the last part).
25
+ * Only parts that exist get a role; qualifier parts beyond the dialect's depth get none. */
26
+ function assignRoles(q, stripped, roles) {
27
+ const qualifiers = stripped.slice(0, -1);
28
+ const n = Math.min(qualifiers.length, roles.length);
29
+ for (let j = 0; j < n; j++) {
30
+ const role = roles[roles.length - 1 - j];
31
+ q[role] = qualifiers[qualifiers.length - 1 - j];
32
+ }
33
+ }
34
+ /**
35
+ * Build a QualifiedName from SOURCE-derived raw parts (as they appear in the text, delimiters
36
+ * intact where the user wrote them). `fqn` preserves the user's spelling and quoting exactly.
37
+ */
38
+ export function qualifiedNameOf(rawParts, config) {
39
+ const parts = rawParts.map((p) => displayWith(config.rule, p));
40
+ const q = {
41
+ name: parts[parts.length - 1] ?? "",
42
+ parts,
43
+ key: rawParts.map((p) => foldWith(config.rule, p, "table")),
44
+ fqn: rawParts.join("."),
45
+ };
46
+ assignRoles(q, parts, config.roles);
47
+ return q;
48
+ }
49
+ /**
50
+ * Build a QualifiedName from SYNTHESIZED plain parts (a provider's resolved relation — no source
51
+ * text exists). `fqn` renders each part with the dialect's quoting exactly where the part needs
52
+ * delimiters to round-trip; plain identifier parts stay bare.
53
+ */
54
+ export function synthesizedQualifiedName(parts, config) {
55
+ const q = {
56
+ name: parts[parts.length - 1] ?? "",
57
+ parts: [...parts],
58
+ key: parts.map((p) => foldWith(config.rule, p, "table")),
59
+ fqn: parts.map((p) => renderPart(p, config.rule)).join("."),
60
+ };
61
+ assignRoles(q, parts, config.roles);
62
+ return q;
63
+ }
64
+ /** True when `part` round-trips as a bare identifier (letters/digits/underscore, non-digit lead).
65
+ * Anything else needs the dialect's delimiters. Reserved words are a documented boundary: this
66
+ * renderer does not consult keyword lists. */
67
+ const PLAIN_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
68
+ function renderPart(part, rule) {
69
+ if (PLAIN_IDENT.test(part))
70
+ return part;
71
+ const [open, close] = rule.delimiters[0] ?? ['"', '"'];
72
+ const body = rule.escapeStyle === "backslash" ? part.split(close).join(`\\${close}`) : part.split(close).join(close + close);
73
+ return `${open}${body}${close}`;
74
+ }
@@ -129,10 +129,14 @@ function followColumn(scope, parts, walk, trail = []) {
129
129
  return [{ kind: "unresolved", via }];
130
130
  const { source, column } = binding;
131
131
  if (source.kind === "table") {
132
- // A recursive CTE's self-reference is a plain table here (see Walk.activeCtes) — cycle-guard it.
133
- if (source.name.length === 1 && walk.activeCtes.has(behaviorOf(scope).fold(source.name[0])))
132
+ // A recursive CTE's self-reference is a plain table here (see Walk.activeCtes) — cycle-guard
133
+ // it. activeCtes keys are the DEFAULT ("other") fold of the declared raw name, so fold the
134
+ // self-reference's raw text the same way (single-part ⇒ relation.fqn IS the raw text).
135
+ if (source.name.length === 1 && walk.activeCtes.has(behaviorOf(scope).fold(source.source.relation.fqn))) {
134
136
  return [{ kind: "unresolved", via }];
135
- return [{ kind: "origin", origin: { table: source.name, column }, via }];
137
+ }
138
+ // Origins are DISPLAY-facing: the as-written parts, never the folded key.
139
+ return [{ kind: "origin", origin: { table: source.source.relation.parts, column }, via }];
136
140
  }
137
141
  const child = childScopeOf(source);
138
142
  if (!child) {
@@ -172,8 +172,9 @@ function columnRefOrigins(parts, scope, schema, seen) {
172
172
  * lineage walk (src/lineage/hops.ts) can defer pipe/lateral/graphtable/pivot sources — the ones
173
173
  * it has no hop model for — to this exact shared origin walk, so the two walks can't drift. */
174
174
  export function columnOrigins(src, column, schema, seen) {
175
+ // Origins are DISPLAY-facing: the as-written parts (relation.parts), never the folded key.
175
176
  if (src.kind === "table")
176
- return [{ table: src.name, column }];
177
+ return [{ table: src.source.relation.parts, column }];
177
178
  if (src.kind === "cte")
178
179
  return derivedOrigins(src.ref.scope, column, src.ref.def.columnAliases, schema, seen);
179
180
  if (src.kind === "subquery")
@@ -17,8 +17,9 @@
17
17
  // TemplateProvider for a call's relation (`provider.relationOf`) and carries no ref/source vocabulary
18
18
  // itself. The NEUTRAL provider answers nothing, so a bare parse leaves calls opaque; a
19
19
  // DbtTemplateProvider names ref/source. Naming is never-wrong: a resolved name comes from the call's
20
- // literal args, and a call whose relation the provider does not resolve keeps its placeholder name and
21
- // stays opaque. We NEVER fabricate a name.
20
+ // literal args, and a call whose relation the provider does not resolve takes the RAW TAG TEXT as its
21
+ // name ({{ ref('m') }} verbatim) and stays opaque. We NEVER fabricate a name — in particular the
22
+ // placeholder fill (scaffolding this library invented) never escapes as one (issue #35).
22
23
  //
23
24
  // The IR is frozen after lower(); this transform REBUILDS with STRUCTURAL SHARING (new objects only on
24
25
  // changed paths, an unchanged subtree keeps its original already-frozen reference) and re-freezes the
@@ -26,6 +27,8 @@
26
27
  // returns the input `ast` unchanged.
27
28
  // ---------------------------------------------------------------------------
28
29
  import { freezeIR } from "../ir/freeze.js";
30
+ import { qualifiedNameOf, synthesizedQualifiedName } from "../ir/qualified-name.js";
31
+ import { resolveBehavior } from "../dialect-behavior/registry.js";
29
32
  /** Record a freshly built node's correlation to the tag it came from, then return it unchanged
30
33
  * (a passthrough so call sites stay expression-shaped). A scalar-slot tag lowers to BOTH a
31
34
  * column Expr (`kind: "column"`) and a parallel ColumnRef record (`kind: "columnref"`, same
@@ -56,7 +59,16 @@ export function applyTemplateTags(ast, tags, text, provider) {
56
59
  const relTags = tags.filter((t) => (t.kind === "call" && !t.incomplete) || t.kind === "other");
57
60
  if (relTags.length === 0)
58
61
  return { ast, byNode, byTag };
59
- const ctx = { relTags, sets: resolveSets(tags, text, provider), text, provider, byNode, byTag };
62
+ const nameConfig = ast.dialect !== undefined ? resolveBehavior(ast.dialect).nameConfig : undefined;
63
+ const ctx = {
64
+ relTags,
65
+ sets: resolveSets(tags, text, provider),
66
+ text,
67
+ provider,
68
+ byNode,
69
+ byTag,
70
+ ...(nameConfig ? { nameConfig } : {}),
71
+ };
60
72
  const next = transformQuery(ast, ctx);
61
73
  // Scalar-slot marking: every column-shaped node whose token is a tag's placeholder
62
74
  // fill gets a `template` marker (span + provider key), so inference resolves it
@@ -152,7 +164,10 @@ function markTemplateExprs(node, ctx) {
152
164
  // never wrong.
153
165
  // ---------------------------------------------------------------------------
154
166
  /** Match a direct string-literal argument's raw text (no escapes, one token). */
155
- const LITERAL_ARG = /^(['"])([^'"\\]*)\1$/;
167
+ /** A literal argument's value: a quoted string (quote-stripped) or a bare numeric literal —
168
+ * both are the user's own text, never computed. Everything else stays `null` (computed). */
169
+ const LITERAL_ARG = /^(?:(['"])([^'"\\]*)\1|(-?\d+(?:\.\d+)?))$/;
170
+ const literalValueOf = (m) => m[2] ?? m[3];
156
171
  /** The raw text of a span. */
157
172
  function sliceSpan(text, span) {
158
173
  return text.slice(span.start, span.end);
@@ -172,11 +187,11 @@ export function callOf(mc, text) {
172
187
  const kw = KWARG_RE.exec(raw);
173
188
  if (kw) {
174
189
  const m = LITERAL_ARG.exec(kw[2].trim());
175
- kwargs.push({ name: kw[1], value: m ? m[2] : null });
190
+ kwargs.push({ name: kw[1], value: m ? literalValueOf(m) : null });
176
191
  }
177
192
  else {
178
193
  const m = LITERAL_ARG.exec(raw);
179
- args.push(m ? m[2] : null);
194
+ args.push(m ? literalValueOf(m) : null);
180
195
  }
181
196
  }
182
197
  return {
@@ -398,6 +413,23 @@ function transformTableSource(src, ctx) {
398
413
  // where `jjj…` was a fabrication.
399
414
  const aliasTok = src.aliasCst?.start;
400
415
  const base = aliasTok != null && inSpan(aliasTok.start, tag.tagSpan) ? withoutAlias(src) : src;
416
+ // An unresolved source's name is the RAW TAG TEXT — the bytes the user actually wrote. The
417
+ // placeholder fill is scaffolding this library invented so the grammar parses; letting it
418
+ // escape as a relation name (scope sources, lineage dependencies, go-to-def) is fabrication
419
+ // under the never-wrong rule (issue #35, reported by anvil).
420
+ const rawTagName = [ctx.text.slice(tag.tagSpan.start, tag.tagSpan.end)];
421
+ // Renaming a source rebuilds `relation` (#38): a provider-resolved name is SYNTHESIZED (plain
422
+ // logical parts, quoted where rendering needs it); the raw-tag-text fallback is source text.
423
+ // Without a dialect tag on the ast (never the parseTemplated path) the source keeps its
424
+ // original relation — the documented degrade.
425
+ const renamed = (b, parts, synthesized) => {
426
+ if (!ctx.nameConfig)
427
+ return b;
428
+ const relation = synthesized
429
+ ? synthesizedQualifiedName(parts, ctx.nameConfig)
430
+ : qualifiedNameOf(parts, ctx.nameConfig);
431
+ return { ...b, relation };
432
+ };
401
433
  // NOTE: `template.span` intentionally aliases `tag.tagSpan` BY REFERENCE. freezeIR
402
434
  // therefore also freezes the TagNode.tagSpan object returned in `.tags`, benign
403
435
  // since spans are read-only. Every call marker carries its `call`, the provider key
@@ -406,24 +438,24 @@ function transformTableSource(src, ctx) {
406
438
  const call = callOf(tag, ctx.text);
407
439
  const rel = ctx.provider.relationOf(call);
408
440
  // A call in a FROM slot (ref/source/a TVF-like macro). When the provider resolves its
409
- // relation, carry the resolved name; otherwise keep the placeholder name (never fabricated).
441
+ // relation, carry the resolved name; otherwise the raw tag text (never the fill).
410
442
  // Either way the `call` keeps it consultable, so an unresolved call is not a dead end: a
411
443
  // provider added later resolves it. ref vs source is not stored here, it is call.name.
412
- const named = rel ? { ...base, name: [...rel.nameParts] } : base;
444
+ const named = rel ? renamed(base, [...rel.nameParts], true) : renamed(base, rawTagName, false);
413
445
  const template = { kind: "call", span: tag.tagSpan, call };
414
446
  return attach(ctx, { ...named, template }, tag);
415
447
  }
416
448
  // Non-call expression tag (var / env_var / other) in a FROM slot. A bare `{{ t }}` resolving
417
449
  // through a `{% set t = … %}` single-call RHS carries the resolved relation name (when the
418
450
  // provider resolved it) or the call identity alone; every other case gets the opaque "expr"
419
- // marker, so the placeholder name stops posing as a real table.
451
+ // marker. In every unresolved case the name is the raw tag text, never the fill.
420
452
  const ident = tag.kind === "other" ? bareIdentOf(tag, ctx.text) : undefined;
421
453
  const resolved = ident !== undefined ? ctx.sets.get(ident) : undefined;
422
454
  if (resolved) {
423
- const named = resolved.name ? { ...base, name: [...resolved.name] } : base;
455
+ const named = resolved.name ? renamed(base, [...resolved.name], true) : renamed(base, rawTagName, false);
424
456
  const template = { kind: "call", span: tag.tagSpan, indirect: true, call: resolved.call };
425
457
  return attach(ctx, { ...named, template }, tag);
426
458
  }
427
459
  const template = { kind: "expr", span: tag.tagSpan, opaque: true };
428
- return attach(ctx, { ...base, template }, tag);
460
+ return attach(ctx, { ...renamed(base, rawTagName, false), template }, tag);
429
461
  }
@@ -1,7 +1,7 @@
1
1
  // The mysql DialectBehavior: everything the semantic layer needs for mysql, assembled from
2
2
  // this folder's own pieces. The registry wires it; nothing here reaches a central per-dialect table.
3
3
  import { mysqlLiteral, mysqlParseType, MYSQL_FUNCTION_RETURNS } from "./infer.js";
4
- import { displayName, fold, foldTableName, matchesSourceKey } from "./fold.js";
4
+ import { displayName, fold, foldTableName, matchesSourceKey, MYSQL_NAME_CONFIG } from "./fold.js";
5
5
  import { likePatternToRegExp } from "../scope/like-pattern.js";
6
6
  import { SIGNATURES } from "../signature/signatures.js";
7
7
  import { acceptsFor } from "../dialect-behavior/coerce-rules.js";
@@ -10,6 +10,7 @@ export const mysqlBehavior = {
10
10
  displayName,
11
11
  foldTableName,
12
12
  matchesSourceKey,
13
+ nameConfig: MYSQL_NAME_CONFIG,
13
14
  likeMatch: (pattern, value) => likePatternToRegExp(pattern).test(value),
14
15
  literal: mysqlLiteral,
15
16
  parseType: mysqlParseType,
@@ -1,5 +1,9 @@
1
1
  import { type FoldRule, type IdentKind } from "../ident/fold.js";
2
+ import type { QualifiedNameConfig } from "../ir/qualified-name.js";
2
3
  export declare const MYSQL_FOLD_RULE: FoldRule;
4
+ /** schema.table, two levels — MySQL's database IS its schema
5
+ * (dev.mysql.com/doc/refman/8.4/en/identifier-qualifiers.html). */
6
+ export declare const MYSQL_NAME_CONFIG: QualifiedNameConfig;
3
7
  /** Fold an identifier to its MySQL identity key. */
4
8
  export declare function fold(raw: string, kind?: IdentKind): string;
5
9
  /** Presentation twin: strip delimiters, no case change. */
@@ -33,6 +33,12 @@ export const MYSQL_FOLD_RULE = {
33
33
  unquoted: "lower",
34
34
  quoted: "lower",
35
35
  };
36
+ /** schema.table, two levels — MySQL's database IS its schema
37
+ * (dev.mysql.com/doc/refman/8.4/en/identifier-qualifiers.html). */
38
+ export const MYSQL_NAME_CONFIG = {
39
+ roles: ["schema"],
40
+ rule: MYSQL_FOLD_RULE,
41
+ };
36
42
  /** Fold an identifier to its MySQL identity key. */
37
43
  export function fold(raw, kind = "other") {
38
44
  return foldWith(MYSQL_FOLD_RULE, raw, kind);
@@ -3,6 +3,13 @@ import { MysqlParser as P } from "../generated/mysql/MysqlParser.js";
3
3
  import { keywordCategory, swallowedCategories, swallowedStatements } from "../ir/statement.js";
4
4
  import { collapsePartSpans, dotIdPartSpanOf, partSpanOf } from "../ir/part-span.js";
5
5
  import { freezeIR } from "../ir/freeze.js";
6
+ import { qualifiedNameOf } from "../ir/qualified-name.js";
7
+ import { MYSQL_NAME_CONFIG } from "./fold.js";
8
+ /** The structured name for a table source's raw parts (issue #38) — role assignment + identity
9
+ * key + fqn happen HERE, at lowering, where the dialect's namespace shape is known. */
10
+ function relationOf(rawParts) {
11
+ return qualifiedNameOf(rawParts, MYSQL_NAME_CONFIG);
12
+ }
6
13
  // ---------------------------------------------------------------------------
7
14
  // Lowering — MySQL (grammars-v4 sql/mysql/Positive-Technologies fork, Ivan
8
15
  // Kochurkin's split MySqlLexer/MySqlParser) CST -> the shared, dialect-neutral
@@ -636,7 +643,7 @@ function buildFrom(fromClause) {
636
643
  // tableSource: tableSourceItem joinPart* | '(' tableSourceItem joinPart* ')' | jsonTable.
637
644
  const jt = directChildrenOfRule(ts, P.RULE_jsonTable)[0];
638
645
  if (jt) {
639
- from.push({ kind: "table", name: [jt.getText()], cst: jt });
646
+ from.push({ kind: "table", relation: relationOf([jt.getText()]), cst: jt });
640
647
  continue;
641
648
  }
642
649
  const base = directChildrenOfRule(ts, P.RULE_tableSourceItem)[0];
@@ -709,16 +716,24 @@ function buildSourceItem(item, fromSubqueries) {
709
716
  const seq = directChildrenOfRule(item, P.RULE_sequenceFunctionName)[0];
710
717
  if (seq) {
711
718
  const alias = directChildrenOfRule(item, P.RULE_uid)[0];
712
- return [{ kind: "table", name: [seq.getText()], alias: alias?.getText(), aliasCst: alias, cst: item }];
719
+ return [
720
+ {
721
+ kind: "table",
722
+ relation: relationOf([seq.getText()]),
723
+ alias: alias?.getText(),
724
+ aliasCst: alias,
725
+ cst: item,
726
+ },
727
+ ];
713
728
  }
714
- return [{ kind: "table", name: [item.getText()], cst: item }];
729
+ return [{ kind: "table", relation: relationOf([item.getText()]), cst: item }];
715
730
  }
716
731
  function tableSourceFromName(tn, alias, cst) {
717
732
  const fullId = directChildrenOfRule(tn, P.RULE_fullId)[0] ?? tn;
718
733
  const { parts, spans } = dottedParts(fullId);
719
734
  return {
720
735
  kind: "table",
721
- name: parts.length ? parts : [tn.getText()],
736
+ relation: relationOf(parts.length ? parts : [tn.getText()]),
722
737
  namePartSpans: collapsePartSpans(spans),
723
738
  alias: alias?.getText(),
724
739
  aliasCst: alias,
@@ -1,7 +1,7 @@
1
1
  import { acceptsFor } from "../dialect-behavior/coerce-rules.js";
2
2
  import { likePatternToRegExp } from "../scope/like-pattern.js";
3
3
  import { SIGNATURES } from "../signature/signatures.js";
4
- import { displayName, fold, foldTableName, matchesSourceKey } from "./fold.js";
4
+ import { displayName, fold, foldTableName, matchesSourceKey, POSTGRES_NAME_CONFIG } from "./fold.js";
5
5
  import { postgresLiteral, postgresParseType, POSTGRES_FUNCTION_RETURNS } from "./infer.js";
6
6
  // PostgreSQL implicit coercion: str->num=true, bool<->num=false.
7
7
  export const postgresBehavior = {
@@ -9,6 +9,7 @@ export const postgresBehavior = {
9
9
  displayName,
10
10
  foldTableName,
11
11
  matchesSourceKey,
12
+ nameConfig: POSTGRES_NAME_CONFIG,
12
13
  likeMatch: (pattern, value) => likePatternToRegExp(pattern).test(value),
13
14
  literal: postgresLiteral,
14
15
  parseType: postgresParseType,
@@ -1,5 +1,10 @@
1
1
  import { type FoldRule, type IdentKind } from "../ident/fold.js";
2
+ import type { QualifiedNameConfig } from "../ir/qualified-name.js";
2
3
  export declare const POSTGRES_FOLD_RULE: FoldRule;
4
+ /** database.schema.table (postgresql.org/docs/18/sql-syntax-lexical + ddl-schemas — cross-
5
+ * database references are rejected at runtime but the three-part form parses). Normalized
6
+ * vocabulary: catalog = database. */
7
+ export declare const POSTGRES_NAME_CONFIG: QualifiedNameConfig;
3
8
  /** Fold an identifier to its PostgreSQL identity key. */
4
9
  export declare function fold(raw: string, kind?: IdentKind): string;
5
10
  /** Presentation twin: strip delimiters, no case change. */
@@ -20,6 +20,13 @@ export const POSTGRES_FOLD_RULE = {
20
20
  unquoted: "ascii-lower",
21
21
  quoted: "preserve",
22
22
  };
23
+ /** database.schema.table (postgresql.org/docs/18/sql-syntax-lexical + ddl-schemas — cross-
24
+ * database references are rejected at runtime but the three-part form parses). Normalized
25
+ * vocabulary: catalog = database. */
26
+ export const POSTGRES_NAME_CONFIG = {
27
+ roles: ["catalog", "schema"],
28
+ rule: POSTGRES_FOLD_RULE,
29
+ };
23
30
  /** Fold an identifier to its PostgreSQL identity key. */
24
31
  export function fold(raw, kind = "other") {
25
32
  return foldWith(POSTGRES_FOLD_RULE, raw, kind);
@@ -3,7 +3,13 @@ import { PostgresParser as P } from "../generated/postgres/PostgresParser.js";
3
3
  import { keywordCategory, swallowedCategories, swallowedStatements } from "../ir/statement.js";
4
4
  import { partSpansOf } from "../ir/part-span.js";
5
5
  import { freezeIR } from "../ir/freeze.js";
6
- import { displayName } from "./fold.js";
6
+ import { qualifiedNameOf } from "../ir/qualified-name.js";
7
+ import { displayName, POSTGRES_NAME_CONFIG } from "./fold.js";
8
+ /** The structured name for a table source's raw parts (issue #38) — role assignment + identity
9
+ * key + fqn happen HERE, at lowering, where the dialect's namespace shape is known. */
10
+ function relationOf(rawParts) {
11
+ return qualifiedNameOf(rawParts, POSTGRES_NAME_CONFIG);
12
+ }
7
13
  // ---------------------------------------------------------------------------
8
14
  // Lowering — PostgreSQL (bytebase/parser postgresql/ fork, TVL-lineage grammar)
9
15
  // CST -> the shared dialect-neutral IR (src/ir/ir.ts). The semantic layer runs
@@ -559,7 +565,7 @@ function buildPrimarySource(tr, unsupported) {
559
565
  if (jsonTable) {
560
566
  return {
561
567
  kind: "table",
562
- name: ["json_table"],
568
+ relation: relationOf(["json_table"]),
563
569
  alias,
564
570
  aliasCst,
565
571
  columnAliases: columnAliases ?? jsonTableColumns(jsonTable),
@@ -571,9 +577,10 @@ function buildPrimarySource(tr, unsupported) {
571
577
  if (funcTable) {
572
578
  const fname = firstShallow(funcTable, P.RULE_func_name);
573
579
  const funcAlias = directChildrenOfRule(tr, P.RULE_func_alias_clause)[0];
580
+ const funcTableName = [fname ? lastName(fname) : funcTable.getText()];
574
581
  return {
575
582
  kind: "table",
576
- name: [fname ? lastName(fname) : funcTable.getText()],
583
+ relation: relationOf(funcTableName),
577
584
  alias: alias ?? (funcAlias ? funcAliasName(funcAlias) : undefined),
578
585
  aliasCst,
579
586
  cst: tr,
@@ -592,7 +599,8 @@ function buildPrimarySource(tr, unsupported) {
592
599
  if (innerFrom.length)
593
600
  return innerFrom[0];
594
601
  }
595
- return { kind: "table", name: [textOrEmpty(tr)], alias, aliasCst, columnAliases, cst: tr };
602
+ const tableName = [textOrEmpty(tr)];
603
+ return { kind: "table", relation: relationOf(tableName), alias, aliasCst, columnAliases, cst: tr };
596
604
  }
597
605
  /** The output column names of a JSON_TABLE: every named column definition, NESTED levels included
598
606
  * (they flatten into the same row type). */
@@ -618,7 +626,15 @@ function buildTableFromRelation(rel, alias, aliasCst, columnAliases) {
618
626
  const qn = directChildrenOfRule(rel, P.RULE_qualified_name)[0];
619
627
  const parts = qn ? nameParts(qn) : [textOrEmpty(rel)];
620
628
  const namePartSpans = qn ? columnPartSpans(qn) : undefined;
621
- return { kind: "table", name: parts, namePartSpans, alias, aliasCst, columnAliases, cst: rel };
629
+ return {
630
+ kind: "table",
631
+ relation: relationOf(parts),
632
+ namePartSpans,
633
+ alias,
634
+ aliasCst,
635
+ columnAliases,
636
+ cst: rel,
637
+ };
622
638
  }
623
639
  function aliasName(aliasClause) {
624
640
  const ta = firstShallow(aliasClause, P.RULE_table_alias);
@@ -4,7 +4,7 @@ import type { Column } from "./schema.js";
4
4
  import { type SchemaProvider } from "./schema-provider.js";
5
5
  import { type ResolvedColumn } from "../sema/resolve.js";
6
6
  export interface Diagnostic {
7
- kind: "unknown-table" | "unknown-column" | "ambiguous-column" | "unknown-field" | "wrong-arity" | "wrong-argument-type";
7
+ kind: "unknown-table" | "ambiguous-table" | "unknown-column" | "ambiguous-column" | "unknown-field" | "wrong-arity" | "wrong-argument-type";
8
8
  message: string;
9
9
  /** Start of the offending node: 1-based line, 0-based column. */
10
10
  line: number;