Skip to content

Commit fc2fd19

Browse files
committed
fix(sql): address PR review — path-literal scoping, gzip, dup columns, cache, engine routing
Resolves the 6 P2 review findings: core (packages/core/src/ops/sql.ts): - Only rewrite path literals in FROM/JOIN table position, never value-position strings — `WHERE x = '/data/sales.csv'` is no longer clobbered. - Honor a real .gz suffix even when the format is explicitly overridden, so the temp file keeps its .gz extension and DuckDB decompresses it. - Uniquify duplicate result column names (e.g. two projected `id`s) so building row objects never drops data by key collision. live (sql-engine): - formatForPath strips an optional .gz suffix (and deriveTableName too); the document picker adds .gz globs — gzipped text docs are pickable/seedable. - Browser-engine file registration keys on the current file revision (stat), so an edited doc re-registers instead of serving a stale buffer. - canRunInBrowser is query-aware: a FROM/JOIN path literal that isn't a bound doc routes to the server (only it auto-binds bare path literals). + tests for path-literal scoping, gzip override, and duplicate columns.
1 parent d4dc343 commit fc2fd19

7 files changed

Lines changed: 136 additions & 35 deletions

File tree

live/src/components/sql/DocumentPicker.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ import { QUERYABLE_EXTENSIONS, sanitizeTableName, type BoundDoc } from "@/lib/sq
1212
import type { GlobMatch, GlobResult } from "@/api/types"
1313

1414
/** The glob op treats `{}` as literal characters (no brace expansion), so run
15-
* one glob per queryable extension in parallel and merge the matches. */
16-
const QUERYABLE_GLOBS = Object.keys(QUERYABLE_EXTENSIONS).map((ext) => `**/*.${ext}`)
15+
* one glob per queryable extension in parallel and merge the matches. Gzipped
16+
* text formats (`.csv.gz`, …) get their own globs since the op handles them. */
17+
const GZIP_EXTENSIONS = ["csv", "tsv", "json", "jsonl", "ndjson"]
18+
const QUERYABLE_GLOBS = [
19+
...Object.keys(QUERYABLE_EXTENSIONS).map((ext) => `**/*.${ext}`),
20+
...GZIP_EXTENSIONS.map((ext) => `**/*.${ext}.gz`),
21+
]
1722

1823
function useQueryableDocs() {
1924
const { client, orgId, driveId } = useAuth()

live/src/lib/sql-engine/duckdb.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,31 @@ function readerFor(doc: BoundDoc, file: string): string {
7474
}
7575

7676
/** Fetch + register each doc's bytes, skipping files already registered from
77-
* the same org/drive/path. */
77+
* the same org/drive/path *and revision*. Keying on the current version means
78+
* an edited/re-uploaded file at the same path re-registers instead of serving
79+
* a stale buffer. */
7880
async function ensureRegistered(
7981
db: duckdb.AsyncDuckDB,
8082
docs: BoundDoc[],
8183
ctx: SqlEngineContext,
8284
): Promise<void> {
8385
for (const doc of docs) {
8486
const name = virtualName(doc.path)
85-
const key = `${ctx.orgId}/${ctx.driveId}:${name}`
87+
// Resolve a revision token. If stat fails, fall back to a unique value so we
88+
// never reuse a possibly-stale buffer.
89+
let revision: string
90+
try {
91+
const stat = await ctx.client.callOp<{ currentVersion?: number; modifiedAt?: string }>(
92+
ctx.orgId,
93+
"stat",
94+
{ path: doc.path },
95+
ctx.driveId,
96+
)
97+
revision = String(stat.currentVersion ?? stat.modifiedAt ?? performance.now())
98+
} catch {
99+
revision = String(performance.now())
100+
}
101+
const key = `${ctx.orgId}/${ctx.driveId}:${name}@${revision}`
86102
if (registeredFiles.get(name) === key) continue
87103

88104
const blob = await ctx.client.fetchRaw(ctx.orgId, ctx.driveId, doc.path)

live/src/lib/sql-engine/index.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,24 @@ import type { BoundDoc, SqlEngineContext, SqlRunInput, SqlRunResult } from "./ty
55
* and sqlite/duckdb (flaky sqlite_scanner) must run on the server. */
66
const WASM_FORMATS = new Set<string>(["csv", "tsv", "parquet", "json", "ndjson"])
77

8+
/** Drive-path literal in table position, e.g. `FROM '/data/sales.csv'`. */
9+
const FROM_JOIN_PATH_RE = /\b(?:from|join)\s+(['"])([^'"]+\.[A-Za-z0-9]+(?:\.gz)?)\1/gi
10+
811
/**
9-
* True when every bound doc can be read by DuckDB-WASM in the browser. With no
10-
* bound docs the query can only reference drive paths directly, which only the
11-
* server resolves — so an empty doc set also routes to the server.
12+
* True when the query can run entirely in the browser (DuckDB-WASM). Requires:
13+
* at least one bound doc, every bound doc readable by WASM, and no FROM/JOIN
14+
* drive-path literal that isn't a bound doc — only the server auto-binds bare
15+
* path literals, so a query referencing an unbound one must run there.
1216
*/
13-
export function canRunInBrowser(docs: BoundDoc[]): boolean {
14-
return docs.length > 0 && docs.every((d) => WASM_FORMATS.has(d.format))
17+
export function canRunInBrowser(docs: BoundDoc[], query?: string): boolean {
18+
if (docs.length === 0 || !docs.every((d) => WASM_FORMATS.has(d.format))) return false
19+
if (query) {
20+
const bound = new Set(docs.map((d) => d.path.replace(/^\/+/, "")))
21+
for (const m of query.matchAll(FROM_JOIN_PATH_RE)) {
22+
if (!bound.has(m[2].replace(/^\/+/, ""))) return false
23+
}
24+
}
25+
return true
1526
}
1627

1728
/**
@@ -25,7 +36,7 @@ export async function runSql(
2536
ctx: SqlEngineContext,
2637
opts?: { forceServer?: boolean },
2738
): Promise<SqlRunResult> {
28-
if (!opts?.forceServer && canRunInBrowser(input.docs)) {
39+
if (!opts?.forceServer && canRunInBrowser(input.docs, input.query)) {
2940
const { runInBrowser } = await import("./duckdb")
3041
return runInBrowser(input, ctx)
3142
}

live/src/lib/sql-engine/types.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,22 @@ export const QUERYABLE_EXTENSIONS: Record<string, SqlFormat> = {
1616
duckdb: "duckdb",
1717
}
1818

19+
/** Formats that can be read from a gzipped file (`.csv.gz`, `.json.gz`, …). */
20+
const GZIP_FORMATS = new Set<SqlFormat>(["csv", "tsv", "json", "ndjson"])
21+
1922
export function formatForPath(path: string): SqlFormat | null {
20-
const ext = path.split(".").pop()?.toLowerCase() ?? ""
21-
return QUERYABLE_EXTENSIONS[ext] ?? null
23+
let name = path.toLowerCase()
24+
let gzipped = false
25+
if (name.endsWith(".gz")) {
26+
gzipped = true
27+
name = name.slice(0, -3)
28+
}
29+
const ext = name.split(".").pop() ?? ""
30+
const format = QUERYABLE_EXTENSIONS[ext] ?? null
31+
if (!format) return null
32+
// A .gz suffix only makes sense for the text formats DuckDB decompresses.
33+
if (gzipped && !GZIP_FORMATS.has(format)) return null
34+
return format
2235
}
2336

2437
export function isQueryablePath(path: string): boolean {
@@ -34,7 +47,9 @@ export function sanitizeTableName(name: string): string {
3447

3548
/** Derive a SQL-safe table name from a file path stem, unique against `taken`. */
3649
export function deriveTableName(path: string, taken: Iterable<string>): string {
37-
const stem = (path.split("/").pop() ?? path).replace(/\.[^.]+$/, "")
50+
const stem = (path.split("/").pop() ?? path)
51+
.replace(/\.gz$/i, "")
52+
.replace(/\.[^.]+$/, "")
3853
const base = sanitizeTableName(stem) || "doc"
3954
const set = new Set(taken)
4055
if (!set.has(base)) return base

live/src/pages/SqlPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export function SqlPage() {
185185
}
186186
}, [client, orgId, driveId, query, docs, maxRows, forceServer])
187187

188-
const wasmEligible = canRunInBrowser(docs)
188+
const wasmEligible = canRunInBrowser(docs, query)
189189
const runsInBrowser = !forceServer && wasmEligible
190190
const effectiveEngine: "browser" | "server" = runsInBrowser ? "browser" : "server"
191191
const engineHint = forceServer

packages/core/src/ops/__tests__/sql.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,34 @@ describe("sql op", () => {
324324
});
325325
expect(result.rows).toEqual([{ n: 3 }]);
326326
});
327+
328+
test("a bound path used as a value (not a table) is not rewritten", async () => {
329+
const { ctx } = createTestContext();
330+
await seedCsv(ctx);
331+
// '/data/sales.csv' appears in both FROM (table) and WHERE (value) position.
332+
// Only the FROM occurrence must become the table; the predicate stays a string.
333+
const result = await sql(ctx, {
334+
query:
335+
"SELECT count(*) AS n FROM '/data/sales.csv' WHERE name <> '/data/sales.csv'",
336+
});
337+
expect(result.rows).toEqual([{ n: 3 }]);
338+
});
339+
340+
test("gzip suffix is honored even when the format is overridden", async () => {
341+
const { ctx } = createTestContext();
342+
const gz = Bun.gzipSync(new TextEncoder().encode("a,b\n1,2\n3,4\n"));
343+
await writeRaw(ctx, { path: "/d/log.txt.gz", bytes: new Uint8Array(gz) });
344+
const result = await sql(ctx, {
345+
query: "SELECT sum(a) AS s FROM t",
346+
tables: { t: { path: "/d/log.txt.gz", format: "csv" } },
347+
});
348+
expect(result.rows).toEqual([{ s: 4 }]);
349+
});
350+
351+
test("duplicate result columns are preserved (uniquified)", async () => {
352+
const { ctx } = createTestContext();
353+
const result = await sql(ctx, { query: "SELECT 1 AS id, 2 AS id" });
354+
expect(result.columns.map((c) => c.name)).toEqual(["id", "id_2"]);
355+
expect(result.rows).toEqual([{ id: 1, id_2: 2 }]);
356+
});
327357
});

packages/core/src/ops/sql.ts

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,23 @@ interface Binding {
114114
path: string;
115115
format: SqlFormat;
116116
gzip: boolean;
117-
/** Exact quoted literal in the query to substitute with the table identifier. */
118-
literal?: string;
117+
/** True when bound from a FROM/JOIN path literal (rewritten to the table id). */
118+
autoBound?: boolean;
119119
localFile?: string;
120120
size: number;
121121
}
122122

123123
const TABLE_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
124-
// Quoted literal whose content has a queryable file extension, e.g. '/data/sales.csv'
125-
const PATH_LITERAL_RE = /'([^']+\.[A-Za-z0-9]+(?:\.gz)?)'/g;
124+
// A quoted path literal in table position (after FROM or JOIN), e.g.
125+
// `FROM '/data/sales.csv'`. Restricting to table position means an ordinary
126+
// string value like `WHERE source = '/data/sales.csv'` is never rewritten.
127+
const FROM_JOIN_PATH_RE =
128+
/\b(from|join)\s+(['"])([^'"]+\.[A-Za-z0-9]+(?:\.gz)?)\2/gi;
129+
130+
/** True when `path` is a gzipped file and `format` can read gzip. */
131+
function isGzip(path: string, format: SqlFormat): boolean {
132+
return /\.gz$/i.test(path) && GZIPPABLE.has(format);
133+
}
126134

127135
function findFile(
128136
ctx: OpContext,
@@ -181,25 +189,28 @@ function collectBindings(ctx: OpContext, params: SqlParams): Binding[] {
181189
table: name,
182190
path,
183191
format,
184-
gzip: explicitFormat ? false : detected?.gzip ?? false,
192+
// Honor a real .gz suffix even when the format is overridden, so the temp
193+
// file keeps its .gz extension and DuckDB decompresses it.
194+
gzip: isGzip(path, format),
185195
size: file.size,
186196
});
187197
}
188198

189-
// Auto-bind quoted drive-path literals like SELECT * FROM '/data/sales.csv'.
190-
// Only literals that resolve to an existing document are bound; anything else
191-
// is left untouched so plain string values are never misinterpreted.
192-
const seenLiterals = new Set<string>();
199+
// Auto-bind drive-path literals that sit in table position, like
200+
// `SELECT * FROM '/data/sales.csv'`. Only literals that resolve to an existing
201+
// document are bound; a path that merely appears as a string value elsewhere
202+
// (e.g. `WHERE source = '/data/sales.csv'`) is never matched here.
203+
const seenPaths = new Set<string>();
193204
let docIdx = 1;
194-
for (const match of params.query.matchAll(PATH_LITERAL_RE)) {
195-
const raw = match[1];
196-
if (seenLiterals.has(raw)) continue;
197-
seenLiterals.add(raw);
198-
205+
for (const match of params.query.matchAll(FROM_JOIN_PATH_RE)) {
206+
const raw = match[3];
199207
const detected = detectSqlFormat(raw);
200208
if (!detected || !FILE_FORMATS.has(detected.format)) continue;
201209

202210
const path = normalizePath(raw);
211+
if (seenPaths.has(path)) continue;
212+
seenPaths.add(path);
213+
203214
const file = findFile(ctx, path);
204215
if (!file) continue;
205216

@@ -212,7 +223,7 @@ function collectBindings(ctx: OpContext, params: SqlParams): Binding[] {
212223
path,
213224
format: detected.format,
214225
gzip: detected.gzip,
215-
literal: match[0],
226+
autoBound: true,
216227
size: file.size,
217228
});
218229
}
@@ -404,12 +415,18 @@ export async function sql(ctx: OpContext, params: SqlParams): Promise<SqlResult>
404415
}
405416

406417
// --- Query phase ---
407-
let query = params.query;
408-
for (const b of bindings) {
409-
if (b.literal) {
410-
query = query.split(b.literal).join(qid(b.table));
418+
// Rewrite only FROM/JOIN path literals (never value-position strings) to the
419+
// in-memory table they were bound to.
420+
const autoBound = new Map(
421+
bindings.filter((b) => b.autoBound).map((b) => [b.path, b.table])
422+
);
423+
const query = params.query.replace(
424+
FROM_JOIN_PATH_RE,
425+
(full, kw: string, _q: string, raw: string) => {
426+
const table = autoBound.get(normalizePath(raw));
427+
return table ? `${kw} ${qid(table)}` : full;
411428
}
412-
}
429+
);
413430

414431
let timedOut = false;
415432
timer = setTimeout(() => {
@@ -433,7 +450,14 @@ export async function sql(ctx: OpContext, params: SqlParams): Promise<SqlResult>
433450
const prepared = await extracted.prepare(extracted.count - 1);
434451
const reader = await prepared.runAndReadUntil(maxRows + 1);
435452

436-
const columnNames = reader.columnNames();
453+
// Uniquify duplicate column names (e.g. a join projecting two `id`s) so
454+
// building row objects never drops data by key collision.
455+
const seenCols = new Map<string, number>();
456+
const columnNames = reader.columnNames().map((name) => {
457+
const n = seenCols.get(name) ?? 0;
458+
seenCols.set(name, n + 1);
459+
return n === 0 ? name : `${name}_${n + 1}`;
460+
});
437461
const columnTypes = reader.columnTypes();
438462
const columns: SqlColumn[] = columnNames.map((name, i) => ({
439463
name,

0 commit comments

Comments
 (0)