Skip to content

Commit 2ef198c

Browse files
authored
feat: alias resolution for hover and completion (#164)
## Summary Nothing in the extension previously understood table aliases: in `SELECT u.name FROM users u`, hovering `u` or `u.name` found nothing, and there was no alias-qualified column completion. This PR adds a shared, statement-scoped alias/CTE analysis and uses it in hover and a new completion source. ### New `src/sql/query-context.ts` - `analyzeQueryContext(sql, parser, {state})` returns a `QueryContext`: referenced `tables` (name / dotted path / alias), `ctes` (name, declared-or-inferred output columns, name span), an `aliases` map (lowercased alias → dotted table path or CTE name), and top-level `selectAliases`. - Walks the node-sql-parser AST defensively (unknown node shapes are skipped, never thrown on), including joins, subqueries, and `WITH` clauses. - Falls back to a small `FROM|JOIN <table> [AS] <alias>` regex (with keyword filtering and quote stripping) when the statement doesn't parse, so aliases still resolve mid-edit; the fallback also infers CTE output columns from the CTE body's select list. - `QueryContextAnalyzer` adds a cache keyed on statement text (same pattern as `SqlStructureAnalyzer`), shareable between hover and completion via the new `contextAnalyzer` config options. ### Hover (`src/sql/hover.ts`) - Analysis is now scoped to the statement containing the hover position (previously the whole doc), so tables from other statements in a multi-statement doc no longer leak into resolution. - Alias-qualified words are rewritten through the alias map before namespace resolution: `u` resolves as table `users` (tooltip shows "`u` is an alias for `users`"), `u.name` resolves as column `users.name`. Alias wins over a same-named real table within the statement. Custom renderers receive the new `aliasFor` field on `NamespaceTooltipData`. - Fixed the `enableTables`/`enableColumns` gating bug: both flags previously gated the same block, so disabling one without the other did nothing. Gating now filters on the resolved item's `semanticType`. ### Completion (`src/sql/alias-completion-source.ts`) - New `aliasColumnCompletionSource({ schema?, parser?, contextAnalyzer? })`: typing `u.` offers the columns of `users` (`type: "property"`; schema `Completion` objects preserved). Handles nested schemas (`FROM mydb.users u`), CTE aliases (declared/inferred CTE columns), falls back to `sqlSchemaFacet`, ignores multi-segment qualifiers like `db.table.`, and scopes strictly to the statement containing the cursor. - Registered in the demo and documented in the README alongside `cteCompletionSource`. Semantic diagnostics needed no change — the scope collector from #163 already resolves alias-qualified column refs. ## Test plan - 40 new tests: `query-context.test.ts` (aliases with/without `AS`, joins, alias shadowing, qualified paths, subqueries, CTE columns/spans, regex fallback, caching), `alias-completion-source.test.ts` (explicit/typed-prefix, joins, CTE aliases, facet fallback, statement scoping, mid-edit), and hover-integration additions (alias hover, gating fix, multi-statement isolation, mid-edit fallback). - `pnpm vitest run`: 386 passed, 1 pre-existing expected fail; `pnpm vitest run --project browser`: passing; `tsc --noEmit` and `oxlint` clean; demo builds. Note: `pnpm run test:browser` is broken independently of this PR — it references a `vitest.browser.config.ts` that doesn't exist; the browser project runs via `pnpm vitest run --project browser`. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds statement-scoped SQL alias resolution for hover and a new alias-aware column completion, so `u` and `u.name` resolve and `u.` completes columns (even mid-edit). Also fixes hover gating, prevents cross-statement leakage, hardens alias parsing, and rewrites a parser regex to avoid backtracking flagged by CodeQL. - New Features - Added `analyzeQueryContext` and cached `QueryContextAnalyzer` to extract tables, CTEs (columns/spans), aliases, and select aliases via an AST walk with a regex fallback. - Hover uses per-statement context, resolves aliases (`u` → `users`, `u.name` → `users.name`), shows an “alias for” note, and supports CTE aliases via `aliasFor` on `NamespaceTooltipData`. - Added `aliasColumnCompletionSource({ schema?, parser?, contextAnalyzer? })`: typing `alias.` completes columns for the aliased table/CTE; supports nested schemas and quoted qualifiers, ignores `db.table.`, scopes to the current statement, and falls back to `sqlSchemaFacet`. Exported `aliasColumnCompletionSource`, `analyzeQueryContext`, and `QueryContextAnalyzer`; demo and README updated. - Bug Fixes - Fixed `enableTables`/`enableColumns` gating to filter by resolved `semanticType` and stopped resolution leaking across statements. - Hardened fallback: masks single-quoted strings and comments to avoid phantom tables and guards against keywords being treated as aliases. - Escaped alias names/targets in hover HTML; completion forces option type `"property"` while preserving schema-provided details. - Rewrote the string-literal matcher in `replaceBracketsWithQuotes` to an unrolled-loop regex to prevent polynomial backtracking on unclosed strings (CodeQL). <sup>Written for commit 84a0dfc. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/marimo-team/codemirror-sql/pull/164?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
1 parent e690b8d commit 2ef198c

11 files changed

Lines changed: 1422 additions & 11 deletions

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ A CodeMirror extension for SQL linting and visual gutter indicators. Built by an
99
- 🎨 **Visual gutter** - Color-coded statement indicators and error highlighting
1010
- 💡 **Hover tooltips** - Schema info, keywords, and column details on hover
1111
- 🔮 **CTE autocomplete** - Auto-complete support for CTEs
12+
- 🏷️ **Alias resolution** - Hover and completion understand table aliases (`SELECT u.name FROM users u`)
1213
- 🎯 **Query-aware resolution** - Context-sensitive schema and column suggestions
1314
- 🔍 **Additional dialects** - DuckDB, BigQuery, Dremio
1415
- 🛠️ **Custom renderers** - Customizable tooltip rendering for tables, columns, and keywords
@@ -28,7 +29,7 @@ pnpm add @marimo-team/codemirror-sql
2829
```ts
2930
import { sql, StandardSQL } from "@codemirror/lang-sql";
3031
import { basicSetup, EditorView } from "codemirror";
31-
import { sqlExtension, cteCompletionSource } from "@marimo-team/codemirror-sql";
32+
import { sqlExtension, cteCompletionSource, aliasColumnCompletionSource } from "@marimo-team/codemirror-sql";
3233

3334
const schema = {
3435
users: ["id", "name", "email", "active"],
@@ -47,6 +48,10 @@ const editor = new EditorView({
4748
StandardSQL.language.data.of({
4849
autocomplete: cteCompletionSource,
4950
}),
51+
StandardSQL.language.data.of({
52+
// Complete `u.` -> columns of `users` in `SELECT ... FROM users u`
53+
autocomplete: aliasColumnCompletionSource({ schema }),
54+
}),
5055
sqlExtension({
5156
// Shared by hover tooltips and semantic linting
5257
schema: schema,

demo/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Compartment, type EditorState, StateEffect, StateField } from "@codemir
44
import { keymap } from "@codemirror/view";
55
import { basicSetup, EditorView } from "codemirror";
66
import {
7+
aliasColumnCompletionSource,
78
cteCompletionSource,
89
DefaultSqlTooltipRenders,
910
defaultSqlHoverTheme,
@@ -177,6 +178,10 @@ function initializeEditor() {
177178
defaultDialect.language.data.of({
178179
autocomplete: cteCompletionSource,
179180
}),
181+
defaultDialect.language.data.of({
182+
// Complete `u.` -> columns of `users` in `SELECT ... FROM users u`
183+
autocomplete: aliasColumnCompletionSource({ schema, parser }),
184+
}),
180185
// Custom theme for better SQL editing
181186
EditorView.theme({
182187
"&": {

src/__tests__/index.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ describe("index.ts exports", () => {
1111
[
1212
"DefaultSqlTooltipRenders",
1313
"NodeSqlParser",
14+
"QueryContextAnalyzer",
1415
"SqlStructureAnalyzer",
16+
"aliasColumnCompletionSource",
17+
"analyzeQueryContext",
1518
"cteCompletionSource",
1619
"defaultSqlHoverTheme",
1720
"resolveSqlSchema",

src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
export {
2+
type AliasCompletionConfig,
3+
aliasColumnCompletionSource,
4+
} from "./sql/alias-completion-source.js";
15
export { cteCompletionSource } from "./sql/cte-completion-source.js";
26
export { type SqlLinterConfig, sqlLinter } from "./sql/diagnostics.js";
37
export { type SqlExtensionConfig, sqlExtension } from "./sql/extension.js";
@@ -15,6 +19,13 @@ export {
1519
type ParserOption,
1620
type SupportedDialects,
1721
} from "./sql/parser.js";
22+
export {
23+
analyzeQueryContext,
24+
type QueryContext,
25+
QueryContextAnalyzer,
26+
type QueryContextCte,
27+
type QueryContextTable,
28+
} from "./sql/query-context.js";
1829
export { resolveSqlSchema, type SqlSchemaSource, sqlSchemaFacet } from "./sql/schema-facet.js";
1930
export {
2031
type SemanticSeverity,
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { CompletionContext, type CompletionResult } from "@codemirror/autocomplete";
2+
import type { SQLNamespace } from "@codemirror/lang-sql";
3+
import { EditorState } from "@codemirror/state";
4+
import { describe, expect, it } from "vitest";
5+
import { aliasColumnCompletionSource } from "../alias-completion-source.js";
6+
import { sqlSchemaFacet } from "../schema-facet.js";
7+
8+
const schema: SQLNamespace = {
9+
users: ["id", "username", "email"],
10+
orders: [
11+
{ label: "order_id", detail: "Order ID", type: "property" },
12+
"total",
13+
],
14+
};
15+
16+
const nestedSchema: SQLNamespace = {
17+
mydb: {
18+
users: ["id", "username"],
19+
},
20+
};
21+
22+
async function complete(
23+
doc: string,
24+
opts: {
25+
schema?: SQLNamespace;
26+
pos?: number;
27+
explicit?: boolean;
28+
facetSchema?: SQLNamespace;
29+
} = {},
30+
): Promise<CompletionResult | null> {
31+
const source = aliasColumnCompletionSource(
32+
opts.schema === undefined && opts.facetSchema !== undefined ? {} : { schema: opts.schema },
33+
);
34+
const state = EditorState.create({
35+
doc,
36+
extensions: opts.facetSchema !== undefined ? [sqlSchemaFacet.of(opts.facetSchema)] : [],
37+
});
38+
const pos = opts.pos ?? doc.length;
39+
const context = new CompletionContext(state, pos, opts.explicit ?? false);
40+
return (await source(context)) as CompletionResult | null;
41+
}
42+
43+
function labels(result: CompletionResult | null): string[] {
44+
return (result?.options ?? []).map((option) => option.label);
45+
}
46+
47+
describe("aliasColumnCompletionSource", () => {
48+
it("offers the aliased table's columns after `alias.`", async () => {
49+
const result = await complete("SELECT u. FROM users u", {
50+
schema,
51+
pos: "SELECT u.".length,
52+
});
53+
expect(labels(result)).toEqual(["id", "username", "email"]);
54+
expect(result?.from).toBe("SELECT u.".length);
55+
expect(result?.options.every((option) => option.type === "property")).toBe(true);
56+
});
57+
58+
it("completes with a typed prefix and keeps `from` after the dot", async () => {
59+
const doc = "SELECT u.user FROM users u";
60+
const result = await complete(doc, { schema, pos: "SELECT u.user".length });
61+
expect(labels(result)).toEqual(["id", "username", "email"]);
62+
expect(result?.from).toBe("SELECT u.".length);
63+
expect(result?.validFor).toBeTruthy();
64+
});
65+
66+
it("works on explicit completion requests", async () => {
67+
const result = await complete("SELECT u. FROM users u", {
68+
schema,
69+
pos: "SELECT u.".length,
70+
explicit: true,
71+
});
72+
expect(labels(result)).toEqual(["id", "username", "email"]);
73+
});
74+
75+
it("resolves each alias in a join to its own table", async () => {
76+
const doc = "SELECT o. FROM users u JOIN orders o ON u.id = o.user_id";
77+
const result = await complete(doc, { schema, pos: "SELECT o.".length });
78+
expect(labels(result)).toEqual(["order_id", "total"]);
79+
// Completion objects from the schema are preserved
80+
expect(result?.options.find((option) => option.label === "order_id")?.detail).toBe(
81+
"Order ID",
82+
);
83+
});
84+
85+
it("resolves aliases of tables nested in the schema", async () => {
86+
const result = await complete("SELECT u. FROM mydb.users u", {
87+
schema: nestedSchema,
88+
pos: "SELECT u.".length,
89+
});
90+
expect(labels(result)).toEqual(["id", "username"]);
91+
});
92+
93+
it("offers CTE columns for a CTE alias", async () => {
94+
const doc = "WITH recent AS (SELECT x, y FROM logs) SELECT r. FROM recent r";
95+
const result = await complete(doc, { schema, pos: doc.indexOf("r. FROM") + 2 });
96+
expect(labels(result)).toEqual(["x", "y"]);
97+
});
98+
99+
it("falls back to the sqlSchemaFacet when no schema is configured", async () => {
100+
const result = await complete("SELECT u. FROM users u", {
101+
facetSchema: schema,
102+
pos: "SELECT u.".length,
103+
});
104+
expect(labels(result)).toEqual(["id", "username", "email"]);
105+
});
106+
107+
it("returns null when the qualifier is not an alias", async () => {
108+
const result = await complete("SELECT x. FROM users u", {
109+
schema,
110+
pos: "SELECT x.".length,
111+
});
112+
expect(result).toBeNull();
113+
});
114+
115+
it("returns null without a dot before the cursor", async () => {
116+
const result = await complete("SELECT u FROM users u", {
117+
schema,
118+
pos: "SELECT u".length,
119+
});
120+
expect(result).toBeNull();
121+
});
122+
123+
it("ignores multi-segment qualifiers like `db.table.`", async () => {
124+
const doc = "SELECT mydb.users. FROM mydb.users u";
125+
const result = await complete(doc, { schema: nestedSchema, pos: "SELECT mydb.users.".length });
126+
expect(result).toBeNull();
127+
});
128+
129+
it("scopes aliases to the statement containing the cursor", async () => {
130+
const doc = "SELECT id FROM users u; SELECT u. FROM orders o";
131+
const result = await complete(doc, { schema, pos: doc.indexOf("u. FROM orders") + 2 });
132+
// `u` is aliased in statement 1, not statement 2
133+
expect(result).toBeNull();
134+
});
135+
136+
it("still completes while the statement is mid-edit (unparsable)", async () => {
137+
const doc = "SELECT u. FROM users u WHERE";
138+
const result = await complete(doc, { schema, pos: "SELECT u.".length });
139+
expect(labels(result)).toEqual(["id", "username", "email"]);
140+
});
141+
142+
it("completes after a quoted alias qualifier", async () => {
143+
const quotedSchema: SQLNamespace = { "User Table": ["id", "full_name"] };
144+
const doc = 'SELECT "ut". FROM "User Table" ut';
145+
const result = await complete(doc, { schema: quotedSchema, pos: 'SELECT "ut".'.length });
146+
expect(labels(result)).toEqual(["id", "full_name"]);
147+
});
148+
149+
it("forces the property type but keeps schema-provided details", async () => {
150+
const typedSchema: SQLNamespace = {
151+
users: [{ label: "id", type: "keyword", detail: "Primary key" }],
152+
};
153+
const result = await complete("SELECT u. FROM users u", {
154+
schema: typedSchema,
155+
pos: "SELECT u.".length,
156+
});
157+
expect(result?.options).toEqual([
158+
{ label: "id", type: "property", detail: "Primary key" },
159+
]);
160+
});
161+
162+
it("returns null when the aliased table is not in the schema", async () => {
163+
const result = await complete("SELECT m. FROM missing m", {
164+
schema,
165+
pos: "SELECT m.".length,
166+
});
167+
expect(result).toBeNull();
168+
});
169+
});

src/sql/__tests__/hover-integration.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,20 @@ describe("Query-aware hover behavior - edge cases", () => {
809809
return { state } as unknown as EditorView;
810810
}
811811

812+
it("only shows hovers for tables when columns are disabled (and vice versa)", async () => {
813+
const schema = { users: ["id", "name"] };
814+
const doc = "SELECT name FROM users";
815+
const view = createMockView(doc);
816+
817+
const tablesOnly = createHoverSource({ schema, enableColumns: false });
818+
expect(await tablesOnly(view, doc.indexOf("users") + 2, 1)).not.toBeNull();
819+
expect(await tablesOnly(view, doc.indexOf("name") + 2, 1)).toBeNull();
820+
821+
const columnsOnly = createHoverSource({ schema, enableTables: false });
822+
expect(await columnsOnly(view, doc.indexOf("users") + 2, 1)).toBeNull();
823+
expect(await columnsOnly(view, doc.indexOf("name") + 2, 1)).not.toBeNull();
824+
});
825+
812826
it("does not show keyword tooltips for Object.prototype members", async () => {
813827
const source = createHoverSource({ keywords: {}, schema: {} });
814828

@@ -833,6 +847,119 @@ describe("Query-aware hover behavior - edge cases", () => {
833847
expect(tooltip).not.toBeNull();
834848
});
835849

850+
describe("alias-aware resolution", () => {
851+
const schema = {
852+
users: ["id", "name", "email"],
853+
orders: ["order_id", "total"],
854+
};
855+
856+
function renderTooltip(
857+
tooltip: Awaited<ReturnType<ReturnType<typeof createHoverSource>>>,
858+
view: EditorView,
859+
): string {
860+
if (!tooltip) {
861+
throw new Error("expected a tooltip");
862+
}
863+
return tooltip.create(view).dom.innerHTML;
864+
}
865+
866+
it("resolves a bare alias to its table and labels it as an alias", async () => {
867+
const source = createHoverSource({ schema });
868+
const doc = "SELECT u.name FROM users u";
869+
const view = createMockView(doc);
870+
871+
const tooltip = await source(view, doc.length - 1, 1);
872+
const html = renderTooltip(tooltip, view);
873+
expect(html).toContain("is an alias for");
874+
expect(html).toContain("users");
875+
expect(html).toContain("table");
876+
});
877+
878+
it("resolves alias-qualified columns to the aliased table", async () => {
879+
const source = createHoverSource({ schema });
880+
const doc = "SELECT u.name FROM users u";
881+
const view = createMockView(doc);
882+
883+
const tooltip = await source(view, doc.indexOf("u.name") + 3, 1);
884+
const html = renderTooltip(tooltip, view);
885+
expect(html).toContain("<strong>name</strong>");
886+
expect(html).toContain("column");
887+
expect(html).toContain("users");
888+
});
889+
890+
it("supports AS aliases", async () => {
891+
const source = createHoverSource({ schema });
892+
const doc = "SELECT u.email FROM users AS u";
893+
const view = createMockView(doc);
894+
895+
const tooltip = await source(view, doc.indexOf("u.email") + 3, 1);
896+
const html = renderTooltip(tooltip, view);
897+
expect(html).toContain("<strong>email</strong>");
898+
expect(html).toContain("column");
899+
});
900+
901+
it("resolves each alias in a join to its own table", async () => {
902+
const source = createHoverSource({ schema });
903+
const doc = "SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.order_id";
904+
const view = createMockView(doc);
905+
906+
const totalHtml = renderTooltip(await source(view, doc.indexOf("o.total") + 3, 1), view);
907+
expect(totalHtml).toContain("<strong>total</strong>");
908+
expect(totalHtml).toContain("orders");
909+
910+
const nameHtml = renderTooltip(await source(view, doc.indexOf("u.name") + 3, 1), view);
911+
expect(nameHtml).toContain("<strong>name</strong>");
912+
expect(nameHtml).toContain("users");
913+
});
914+
915+
it("lets an alias shadow a real table name within the statement", async () => {
916+
const source = createHoverSource({ schema });
917+
// `orders` aliases the users table here; `orders.name` must resolve to
918+
// users.name (the real orders table has no `name` column)
919+
const doc = "SELECT orders.name FROM users orders";
920+
const view = createMockView(doc);
921+
922+
const tooltip = await source(view, doc.indexOf("orders.name") + 8, 1);
923+
const html = renderTooltip(tooltip, view);
924+
expect(html).toContain("<strong>name</strong>");
925+
expect(html).toContain("users");
926+
});
927+
928+
it("escapes markup in alias targets before rendering", async () => {
929+
const source = createHoverSource({ schema: { "<b>t</b>": ["x"] } });
930+
const doc = 'SELECT x FROM "<b>t</b>" a';
931+
const view = createMockView(doc);
932+
933+
const tooltip = await source(view, doc.length - 1, 1);
934+
const html = renderTooltip(tooltip, view);
935+
expect(html).toContain("is an alias for");
936+
expect(html).toContain("&lt;b&gt;t&lt;/b&gt;");
937+
expect(html).not.toContain("alias for <code><b>");
938+
});
939+
940+
it("does not leak tables across statement boundaries", async () => {
941+
const source = createHoverSource({ schema, enableFuzzySearch: true });
942+
const doc = "SELECT name FROM users; SELECT name FROM orders";
943+
const view = createMockView(doc);
944+
945+
// In statement 1, `name` resolves in users
946+
expect(await source(view, doc.indexOf("name") + 2, 1)).not.toBeNull();
947+
// In statement 2, only orders is in scope and it has no `name` column
948+
expect(await source(view, doc.lastIndexOf("name") + 2, 1)).toBeNull();
949+
});
950+
951+
it("still resolves aliases while the statement is mid-edit", async () => {
952+
const source = createHoverSource({ schema });
953+
// Trailing WHERE makes the statement unparsable
954+
const doc = "SELECT u.name FROM users u WHERE";
955+
const view = createMockView(doc);
956+
957+
const tooltip = await source(view, doc.indexOf("u.name") + 3, 1);
958+
const html = renderTooltip(tooltip, view);
959+
expect(html).toContain("<strong>name</strong>");
960+
});
961+
});
962+
836963
it("does not permanently disable hovers when the keywords promise rejects", async () => {
837964
const keywords = vi.fn(async (): Promise<Record<string, { description: string }>> => {
838965
if (keywords.mock.calls.length === 1) {

0 commit comments

Comments
 (0)