Skip to content

Commit 991e53a

Browse files
authored
feat: unqualified column completion inferred from the FROM clause (#165)
## Summary Typing a bare identifier prefix now completes columns of the tables referenced in the current statement's `FROM`/`JOIN` clauses — even when the schema defines many tables: ```sql SELECT id, name, email FROM users WHERE active = true ORDER BY created_at DESC; ``` `SELECT e` → `email`, `ORDER BY cre` → `created_at`, because `FROM users` is in the statement. Previously unqualified column completion was delegated entirely to `@codemirror/lang-sql`'s `schemaCompletionSource`, which only offers top-level columns when a single `defaultTable` is configured — impossible with 2+ tables/schemas. ## Changes - **New `unqualifiedColumnCompletionSource({ schema?, parser?, contextAnalyzer? })`** (`src/sql/column-completion-source.ts`): - Matches bare identifier prefixes; skips qualified paths (after a dot — the alias source's territory) and table-name positions (right after `FROM`/`JOIN`/`INTO`/`UPDATE`/`TABLE`) - Scopes to the statement under the cursor; reuses `QueryContextAnalyzer` (AST walk + regex fallback, so it works mid-edit on unparsable SQL) - Offers the union of all FROM/JOIN tables' columns with `detail: "column of <table>"`; duplicate names across joins appear once - CTEs in FROM resolve to their declared/inferred columns; under-qualified tables that are ambiguous across schemas yield nothing rather than wrong columns - Empty prefix completes only on explicit (Ctrl-Space) requests; columns get a small boost to rank just above keywords - **`src/sql/completion-utils.ts`**: `resolveSchema` / `findTableColumns` / `toCompletion` lifted unchanged from the alias source and shared by both (no behavior change to `aliasColumnCompletionSource`) - Exported from `src/index.ts`, registered in the demo (sharing one `QueryContextAnalyzer` across completion sources), documented in README ## Test plan - 18 new tests in `src/sql/__tests__/column-completion-source.test.ts`: the motivating multi-table scenario, ORDER BY, joins + per-table details, dedup, dot/table-position guards, empty prefix, mid-edit SQL, multi-statement scoping, aliased/quoted/nested tables, ambiguity, CTEs, facet fallback, boosts - `pnpm test` (412 passed), `pnpm run typecheck`, `pnpm exec oxlint`, `pnpm run demo` all clean <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds FROM-aware autocomplete for bare column prefixes. Typing unqualified text like SELECT e now suggests columns from tables in the current statement, including joins and CTEs, with better multi‑schema support than `@codemirror/lang-sql`. - **New Features** - Add `unqualifiedColumnCompletionSource({ schema?, parser?, contextAnalyzer? })`. - Completes in SELECT/WHERE/ORDER BY; skips after a dot and in table-name positions; empty prefix only on explicit request. - Unions columns from FROM/JOIN tables; de-dupes; shows "column of <table>"; supports CTE columns; small boost over keywords; falls back to `sqlSchemaFacet`; preserves schema-provided boosts. - **Bug Fixes** - No suggestions inside string literals or comments. - Skip in comma-continued FROM table lists; still completes after commas in SELECT/GROUP BY. - Correctly resolves quoted table names containing dots (e.g. "my.table"). <sup>Written for commit 6510749. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/marimo-team/codemirror-sql/pull/165?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 2df4518 commit 991e53a

9 files changed

Lines changed: 541 additions & 85 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ A CodeMirror extension for SQL linting and visual gutter indicators. Built by an
1010
- 💡 **Hover tooltips** - Schema info, keywords, and column details on hover
1111
- 🔮 **CTE autocomplete** - Statement-scoped completion of CTE names and their output columns
1212
- 🏷️ **Alias resolution** - Hover and completion understand table aliases (`SELECT u.name FROM users u`)
13+
- 📇 **FROM-aware column completion** - Unqualified prefixes complete the columns of the tables in the statement's FROM clause (`SELECT e` -> `email` with `FROM users`), even with many tables in the schema
1314
- 🧭 **Navigation** - Go-to-definition, reference highlighting, and rename for CTEs and aliases
1415
- 🎯 **Query-aware resolution** - Context-sensitive schema and column suggestions
1516
- 🔍 **Additional dialects** - DuckDB, BigQuery, Dremio
@@ -30,7 +31,7 @@ pnpm add @marimo-team/codemirror-sql
3031
```ts
3132
import { sql, StandardSQL } from "@codemirror/lang-sql";
3233
import { basicSetup, EditorView } from "codemirror";
33-
import { sqlExtension, cteCompletionSource, aliasColumnCompletionSource } from "@marimo-team/codemirror-sql";
34+
import { sqlExtension, cteCompletionSource, aliasColumnCompletionSource, unqualifiedColumnCompletionSource } from "@marimo-team/codemirror-sql";
3435

3536
const schema = {
3637
users: ["id", "name", "email", "active"],
@@ -53,6 +54,10 @@ const editor = new EditorView({
5354
// Complete `u.` -> columns of `users` in `SELECT ... FROM users u`
5455
autocomplete: aliasColumnCompletionSource({ schema }),
5556
}),
57+
StandardSQL.language.data.of({
58+
// Complete `SELECT e` -> `email` because `FROM users` is in the statement
59+
autocomplete: unqualifiedColumnCompletionSource({ schema }),
60+
}),
5661
sqlExtension({
5762
// Shared by hover tooltips and semantic linting
5863
schema: schema,

demo/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import {
99
DefaultSqlTooltipRenders,
1010
defaultSqlHoverTheme,
1111
NodeSqlParser,
12+
QueryContextAnalyzer,
1213
type SupportedDialects,
1314
sqlExtension,
15+
unqualifiedColumnCompletionSource,
1416
} from "../src/index.js";
1517
import { tableTooltipRenderer } from "./custom-renderers.js";
1618
import { defaultSqlDoc, schema } from "./data.js";
@@ -127,6 +129,8 @@ function initializeEditor() {
127129
};
128130
},
129131
});
132+
// Shared between the completion sources so each edit is analyzed once
133+
const contextAnalyzer = new QueryContextAnalyzer(parser);
130134

131135
const extensions = [
132136
basicSetup,
@@ -187,7 +191,11 @@ function initializeEditor() {
187191
}),
188192
defaultDialect.language.data.of({
189193
// Complete `u.` -> columns of `users` in `SELECT ... FROM users u`
190-
autocomplete: aliasColumnCompletionSource({ schema, parser }),
194+
autocomplete: aliasColumnCompletionSource({ schema, parser, contextAnalyzer }),
195+
}),
196+
defaultDialect.language.data.of({
197+
// Complete `SELECT e` -> `email` because `FROM users` is in the statement
198+
autocomplete: unqualifiedColumnCompletionSource({ schema, parser, contextAnalyzer }),
191199
}),
192200
// Custom theme for better SQL editing
193201
EditorView.theme({

src/__tests__/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe("index.ts exports", () => {
3333
"sqlSchemaFacet",
3434
"sqlSemanticLinter",
3535
"sqlStructureGutter",
36+
"unqualifiedColumnCompletionSource",
3637
]
3738
`);
3839
});

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ export {
22
type AliasCompletionConfig,
33
aliasColumnCompletionSource,
44
} from "./sql/alias-completion-source.js";
5+
export {
6+
type ColumnCompletionConfig,
7+
unqualifiedColumnCompletionSource,
8+
} from "./sql/column-completion-source.js";
59
export {
610
createCteCompletionSource,
711
type CteCompletionConfig,
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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 { unqualifiedColumnCompletionSource } from "../column-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 = unqualifiedColumnCompletionSource(
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("unqualifiedColumnCompletionSource", () => {
48+
it("offers the FROM table's columns for a bare prefix", async () => {
49+
const result = await complete("SELECT e FROM users", {
50+
schema,
51+
pos: "SELECT e".length,
52+
});
53+
expect(labels(result)).toEqual(["id", "username", "email"]);
54+
expect(result?.from).toBe("SELECT ".length);
55+
expect(result?.options.every((option) => option.type === "property")).toBe(true);
56+
});
57+
58+
it("completes in ORDER BY position", async () => {
59+
const doc = "SELECT id FROM users ORDER BY user";
60+
const result = await complete(doc, { schema, pos: doc.length });
61+
expect(labels(result)).toContain("username");
62+
expect(result?.from).toBe(doc.indexOf("user", doc.indexOf("ORDER BY")));
63+
});
64+
65+
it("only offers columns of the referenced table when the schema has many", async () => {
66+
const result = await complete("SELECT e FROM users", {
67+
schema,
68+
pos: "SELECT e".length,
69+
});
70+
expect(labels(result)).not.toContain("total");
71+
expect(labels(result)).not.toContain("order_id");
72+
});
73+
74+
it("offers the union of all joined tables' columns with per-table details", async () => {
75+
const doc = "SELECT FROM users u JOIN orders o ON u.id = o.user_id";
76+
const result = await complete(doc, {
77+
schema,
78+
pos: "SELECT ".length,
79+
explicit: true,
80+
});
81+
expect(labels(result)).toEqual(["id", "username", "email", "order_id", "total"]);
82+
expect(result?.options.find((option) => option.label === "email")?.detail).toBe(
83+
"column of users",
84+
);
85+
expect(result?.options.find((option) => option.label === "total")?.detail).toBe(
86+
"column of orders",
87+
);
88+
// Schema-provided details are preserved
89+
expect(result?.options.find((option) => option.label === "order_id")?.detail).toBe(
90+
"Order ID",
91+
);
92+
});
93+
94+
it("offers a duplicate column name once, from the first table", async () => {
95+
const dupSchema: SQLNamespace = { a: ["id", "x"], b: ["id", "y"] };
96+
const doc = "SELECT FROM a JOIN b ON a.id = b.id";
97+
const result = await complete(doc, { schema: dupSchema, pos: "SELECT ".length, explicit: true });
98+
expect(labels(result)).toEqual(["id", "x", "y"]);
99+
expect(result?.options.find((option) => option.label === "id")?.detail).toBe("column of a");
100+
});
101+
102+
it("returns null after a dot", async () => {
103+
const result = await complete("SELECT u. FROM users u", {
104+
schema,
105+
pos: "SELECT u.".length,
106+
explicit: true,
107+
});
108+
expect(result).toBeNull();
109+
});
110+
111+
it("returns null in table-name position", async () => {
112+
expect(await complete("SELECT id FROM use", { schema, pos: "SELECT id FROM use".length })).toBeNull();
113+
const doc = "SELECT id FROM users JOIN ord";
114+
expect(await complete(doc, { schema, pos: doc.length })).toBeNull();
115+
});
116+
117+
it("returns null in a comma-continued FROM table list", async () => {
118+
expect(await complete("SELECT id FROM users, ord", { schema })).toBeNull();
119+
expect(await complete("SELECT id FROM users u, ord", { schema })).toBeNull();
120+
expect(await complete("SELECT id FROM users AS u, orders o, ord", { schema })).toBeNull();
121+
});
122+
123+
it("still completes after commas in non-FROM clauses", async () => {
124+
const groupBy = "SELECT id FROM users GROUP BY id, user";
125+
expect(labels(await complete(groupBy, { schema }))).toContain("username");
126+
const selectList = "SELECT id, e FROM users";
127+
expect(labels(await complete(selectList, { schema, pos: "SELECT id, e".length }))).toContain(
128+
"email",
129+
);
130+
});
131+
132+
it("returns null inside string literals and comments", async () => {
133+
const inString = "SELECT id FROM users WHERE name = 'em";
134+
expect(await complete(inString, { schema })).toBeNull();
135+
const inLineComment = "SELECT id FROM users -- em";
136+
expect(await complete(inLineComment, { schema })).toBeNull();
137+
const inBlockComment = "SELECT id FROM users /* em";
138+
expect(await complete(inBlockComment, { schema })).toBeNull();
139+
});
140+
141+
it("requires a prefix unless the request is explicit", async () => {
142+
const doc = "SELECT FROM users";
143+
expect(await complete(doc, { schema, pos: "SELECT ".length })).toBeNull();
144+
expect(labels(await complete(doc, { schema, pos: "SELECT ".length, explicit: true }))).toEqual([
145+
"id",
146+
"username",
147+
"email",
148+
]);
149+
});
150+
151+
it("returns null when the statement has no FROM clause yet", async () => {
152+
const result = await complete("SELECT em", { schema, pos: "SELECT em".length });
153+
expect(result).toBeNull();
154+
});
155+
156+
it("still completes while the statement is mid-edit (unparsable)", async () => {
157+
const result = await complete("SELECT em FROM users WHERE", {
158+
schema,
159+
pos: "SELECT em".length,
160+
});
161+
expect(labels(result)).toEqual(["id", "username", "email"]);
162+
});
163+
164+
it("scopes tables to the statement containing the cursor", async () => {
165+
const doc = "SELECT id FROM users; SELECT t FROM orders";
166+
const result = await complete(doc, { schema, pos: doc.indexOf("t FROM orders") + 1 });
167+
expect(labels(result)).toEqual(["order_id", "total"]);
168+
});
169+
170+
it("completes columns of an aliased table", async () => {
171+
const result = await complete("SELECT e FROM users u", {
172+
schema,
173+
pos: "SELECT e".length,
174+
});
175+
expect(labels(result)).toEqual(["id", "username", "email"]);
176+
});
177+
178+
it("resolves tables nested in the schema", async () => {
179+
const qualified = await complete("SELECT user FROM mydb.users", {
180+
schema: nestedSchema,
181+
pos: "SELECT user".length,
182+
});
183+
expect(labels(qualified)).toEqual(["id", "username"]);
184+
185+
const underQualified = await complete("SELECT user FROM users", {
186+
schema: nestedSchema,
187+
pos: "SELECT user".length,
188+
});
189+
expect(labels(underQualified)).toEqual(["id", "username"]);
190+
});
191+
192+
it("returns null when an under-qualified table is ambiguous across schemas", async () => {
193+
const ambiguousSchema: SQLNamespace = {
194+
db1: { users: ["id"] },
195+
db2: { users: ["email"] },
196+
};
197+
const result = await complete("SELECT i FROM users", {
198+
schema: ambiguousSchema,
199+
pos: "SELECT i".length,
200+
});
201+
expect(result).toBeNull();
202+
});
203+
204+
it("offers CTE columns when the FROM table is a CTE", async () => {
205+
const doc = "WITH recent AS (SELECT x, y FROM logs) SELECT x FROM recent";
206+
const result = await complete(doc, { schema, pos: doc.indexOf("x FROM recent") + 1 });
207+
const resultLabels = labels(result);
208+
expect(resultLabels).toContain("x");
209+
expect(resultLabels).toContain("y");
210+
expect(result?.options.find((option) => option.label === "x")?.detail).toBe(
211+
"column of recent",
212+
);
213+
});
214+
215+
it("completes columns of a quoted table name", async () => {
216+
const quotedSchema: SQLNamespace = { "User Table": ["id", "full_name"] };
217+
const doc = 'SELECT full FROM "User Table"';
218+
const result = await complete(doc, { schema: quotedSchema, pos: "SELECT full".length });
219+
expect(labels(result)).toEqual(["id", "full_name"]);
220+
});
221+
222+
it("completes columns of a quoted table name containing a dot", async () => {
223+
const dottedSchema: SQLNamespace = { "my.table": ["id", "email"] };
224+
const doc = 'SELECT e FROM "my.table"';
225+
const result = await complete(doc, { schema: dottedSchema, pos: "SELECT e".length });
226+
expect(labels(result)).toEqual(["id", "email"]);
227+
});
228+
229+
it("falls back to the sqlSchemaFacet when no schema is configured", async () => {
230+
const result = await complete("SELECT e FROM users", {
231+
facetSchema: schema,
232+
pos: "SELECT e".length,
233+
});
234+
expect(labels(result)).toEqual(["id", "username", "email"]);
235+
});
236+
237+
it("returns null when the table is not in the schema", async () => {
238+
const result = await complete("SELECT x FROM missing", {
239+
schema,
240+
pos: "SELECT x".length,
241+
});
242+
expect(result).toBeNull();
243+
});
244+
245+
it("boosts columns while preserving schema-provided boosts", async () => {
246+
const boostedSchema: SQLNamespace = {
247+
users: ["id", { label: "email", boost: 5 }],
248+
};
249+
const result = await complete("SELECT e FROM users", {
250+
schema: boostedSchema,
251+
pos: "SELECT e".length,
252+
});
253+
expect(result?.options.find((option) => option.label === "id")?.boost).toBe(1);
254+
expect(result?.options.find((option) => option.label === "email")?.boost).toBe(5);
255+
});
256+
});

0 commit comments

Comments
 (0)