Skip to content

Commit 5545e05

Browse files
committed
Restore natural join column validation using prepare() for reliable column metadata
The old resolveColumns used db.exec() which returns [] for 0-row results in sql.js, losing column metadata. Now uses db.prepare() + getColumnNames() which always returns column names regardless of row count. Validation only errors when both sides have resolvable columns (gracefully skips if either side fails to resolve). Removed letter-spacing hack on <- (messes up cursor placement). https://claude.ai/code/session_01TJyw8nESra9cc5RpVUpmt6
1 parent 375bd1f commit 5545e05

3 files changed

Lines changed: 45 additions & 14 deletions

File tree

src/raHighlight.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,9 @@ export function highlightRA(code: string): string {
120120
}
121121

122122
// Assignment arrow: ← (already handled above as binary symbol)
123-
// Arrow: <- (assignment) — keep both chars for editor alignment, tight letter-spacing
123+
// Arrow: <- (assignment) — keep both chars for editor alignment
124124
if (code[i] === "<" && i + 1 < code.length && code[i + 1] === "-") {
125-
result.push(`<span style="${S.assign} letter-spacing: -0.15em;">&lt;-</span>`);
125+
result.push(`<span style="${S.assign}">&lt;-</span>`);
126126
i += 2;
127127
continue;
128128
}

src/relationalAlgebra.test.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -174,21 +174,27 @@ describe("natural join (⋈)", () => {
174174
expect(norm(sql)).toContain("NATURAL JOIN");
175175
});
176176

177-
it("should produce NATURAL JOIN SQL even without common columns (let SQLite handle it)", () => {
178-
const sql = raToSQL("TableA ⋈ TableB");
179-
expect(norm(sql)).toContain("NATURAL JOIN");
177+
it("should error on impossible natural join when database is provided", () => {
178+
const mockDb = {
179+
exec: () => [{ columns: [], values: [] }],
180+
prepare: (sql: string) => {
181+
const cols = sql.includes("TableA") ? ["id", "name"] : ["code", "description"];
182+
return { getColumnNames: () => cols, free: () => {} };
183+
},
184+
};
185+
186+
expect(() => raToSQL("TableA ⋈ TableB", mockDb)).toThrow(RAError);
187+
expect(() => raToSQL("TableA ⋈ TableB", mockDb)).toThrow(/no common columns/i);
180188
});
181189

182190
it("should not error on valid natural join when database is provided", () => {
183191
const mockDb = {
184-
exec: (sql: string) => {
185-
if (sql.includes("Person")) {
186-
return [{ columns: ["id", "name", "city"], values: [] }];
187-
}
188-
if (sql.includes("Student")) {
189-
return [{ columns: ["id", "hasDisability"], values: [] }];
190-
}
191-
return [{ columns: [], values: [] }];
192+
exec: () => [{ columns: [], values: [] }],
193+
prepare: (sql: string) => {
194+
const cols = sql.includes("Person") ? ["id", "name", "city"]
195+
: sql.includes("Student") ? ["id", "hasDisability"]
196+
: [];
197+
return { getColumnNames: () => cols, free: () => {} };
192198
},
193199
};
194200

src/relationalAlgebra.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1016,12 +1016,23 @@ let subqueryCounter = 0;
10161016
*/
10171017
interface DatabaseHandle {
10181018
exec(sql: string): { columns: string[]; values: unknown[][] }[];
1019+
prepare(sql: string): { getColumnNames(): string[]; free(): void };
10191020
}
10201021

10211022
/**
10221023
* Resolve the column names produced by a SQL expression using the database.
1023-
* Executes a LIMIT 0 query to get column metadata without fetching data.
1024+
* Uses prepare() to get column metadata without executing the query.
10241025
*/
1026+
function resolveColumns(sql: string, db: DatabaseHandle): string[] {
1027+
try {
1028+
const stmt = db.prepare(`SELECT * FROM (${sql}) LIMIT 0`);
1029+
const cols = stmt.getColumnNames();
1030+
stmt.free();
1031+
return cols;
1032+
} catch {
1033+
return [];
1034+
}
1035+
}
10251036
function nodeToSQL(node: RANode, db?: DatabaseHandle): string {
10261037
switch (node.type) {
10271038
case "table":
@@ -1069,6 +1080,20 @@ function nodeToSQL(node: RANode, db?: DatabaseHandle): string {
10691080
case "naturalJoin": {
10701081
const leftSQL = nodeToSQL(node.left, db);
10711082
const rightSQL = nodeToSQL(node.right, db);
1083+
if (db) {
1084+
const leftCols = resolveColumns(leftSQL, db);
1085+
const rightCols = resolveColumns(rightSQL, db);
1086+
if (leftCols.length > 0 && rightCols.length > 0) {
1087+
const common = leftCols.filter(c => rightCols.includes(c));
1088+
if (common.length === 0) {
1089+
throw new RAError(
1090+
"Natural join has no common columns between the two relations. " +
1091+
"Left columns: [" + leftCols.join(", ") + "], Right columns: [" + rightCols.join(", ") + "]. " +
1092+
"Use a cross product (×) if a cartesian product is intended, or a theta join (⋈[condition]) to specify the join condition."
1093+
);
1094+
}
1095+
}
1096+
}
10721097
return `SELECT * FROM (${leftSQL}) AS _ra${subqueryCounter++} NATURAL JOIN (${rightSQL}) AS _ra${subqueryCounter++}`;
10731098
}
10741099

0 commit comments

Comments
 (0)