Skip to content

Commit 78e434a

Browse files
committed
Fix join aliasing to preserve table names; add comprehensive execution tests
1 parent 8d08925 commit 78e434a

2 files changed

Lines changed: 294 additions & 6 deletions

File tree

src/ra-engine/relationalAlgebra.test.ts

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,4 +1128,287 @@ describe("SQLite execution", () => {
11281128
const res = execRA("π_{name, city}(Person)");
11291129
expect(res[0].columns).toEqual(["name", "city"]);
11301130
});
1131+
1132+
it("ρ_{old→new}(R)", () => {
1133+
const res = execRA("ρ_{name→fullName}(Person)");
1134+
expect(res[0].columns).toContain("fullName");
1135+
expect(res[0].columns).not.toContain("name");
1136+
});
1137+
1138+
it("σ{cond}(R) without underscore", () => {
1139+
const res = execRA("σ{age > 20}(Person)");
1140+
expect(res[0].values.length).toBe(2);
1141+
});
1142+
1143+
it("⋈{cond} theta join with curly braces", () => {
1144+
const res = execRA("Person ⋈{age > credits} Course");
1145+
expect(res[0].values.length).toBeGreaterThan(0);
1146+
});
1147+
1148+
// ── Selection edge cases ──
1149+
1150+
it("σ with OR condition", () => {
1151+
const res = execRA("σ[city = 'Stockholm' or city = 'York'](Person)");
1152+
expect(res[0].values.length).toBe(2); // Alice and Bob
1153+
});
1154+
1155+
it("σ with NOT condition", () => {
1156+
const res = execRA("σ[not age > 20](Person)");
1157+
expect(res[0].values.length).toBe(1); // Only Bob (19)
1158+
expect(res[0].values[0]).toContain("Bob");
1159+
});
1160+
1161+
it("σ with nested OR and AND", () => {
1162+
const res = execRA("σ[age > 20 or (name = 'Bob' and city = 'York')](Person)");
1163+
expect(res[0].values.length).toBe(3); // Alice, Bob, Carol
1164+
});
1165+
1166+
it("σ with all comparison operators", () => {
1167+
expect(execRA("σ[age = 25](Person)")[0].values.length).toBe(1);
1168+
expect(execRA("σ[age <> 25](Person)")[0].values.length).toBe(2);
1169+
expect(execRA("σ[age != 25](Person)")[0].values.length).toBe(2);
1170+
expect(execRA("σ[age < 25](Person)")[0].values.length).toBe(1);
1171+
expect(execRA("σ[age > 25](Person)")[0].values.length).toBe(1);
1172+
expect(execRA("σ[age <= 25](Person)")[0].values.length).toBe(2);
1173+
expect(execRA("σ[age >= 25](Person)")[0].values.length).toBe(2);
1174+
});
1175+
1176+
it("σ with table.column references", () => {
1177+
const res = execRA("σ[Person.age > 20](Person)");
1178+
expect(res[0].values.length).toBe(2);
1179+
});
1180+
1181+
// ── Rename edge cases ──
1182+
1183+
it("ρ with multiple rename mappings", () => {
1184+
const res = execRA("ρ[name→fullName, age→years](Person)");
1185+
expect(res[0].columns).toContain("fullName");
1186+
expect(res[0].columns).toContain("years");
1187+
expect(res[0].columns).not.toContain("name");
1188+
expect(res[0].columns).not.toContain("age");
1189+
expect(res[0].values.length).toBe(3);
1190+
});
1191+
1192+
it("ρ with ASCII arrow", () => {
1193+
const res = execRA("rho[name->fullName](Person)");
1194+
expect(res[0].columns).toContain("fullName");
1195+
});
1196+
1197+
// ── Outer joins ──
1198+
1199+
it("rightjoin", () => {
1200+
const res = execRA("Student rightjoin[age > hasDisability] Person");
1201+
expect(res[0].values.length).toBeGreaterThan(0);
1202+
});
1203+
1204+
it("fulljoin", () => {
1205+
const res = execRA("Person fulljoin[age > credits] Course");
1206+
expect(res[0].values.length).toBeGreaterThan(0);
1207+
});
1208+
1209+
it("⟕ left outer join with Unicode", () => {
1210+
const res = execRA("Person ⟕[age > credits] Course");
1211+
expect(res[0].values.length).toBeGreaterThan(0);
1212+
});
1213+
1214+
it("left join preserves non-matching rows", () => {
1215+
// Carol (age 30) has no Student row — left join should keep her with NULLs
1216+
const res = execRA("Person leftjoin[Person.id = Student.id] Student");
1217+
expect(res[0].values.length).toBe(3); // All 3 Person rows
1218+
});
1219+
1220+
// ── Semi-join edge cases ──
1221+
1222+
it("⋊ right semi-join", () => {
1223+
const res = execRA("Student ⋊ Person");
1224+
// Student ids 1,2 both exist in Person — all Student rows match
1225+
expect(res[0].values.length).toBe(2);
1226+
});
1227+
1228+
it("rightsemijoin keyword", () => {
1229+
const res = execRA("Student rightsemijoin Person");
1230+
expect(res[0].values.length).toBe(2);
1231+
});
1232+
1233+
it("leftsemijoin keyword", () => {
1234+
const res = execRA("Person leftsemijoin Student");
1235+
expect(res[0].values.length).toBe(2);
1236+
});
1237+
1238+
it("antijoin keyword", () => {
1239+
const res = execRA("Person antijoin Student");
1240+
expect(res[0].values.length).toBe(1);
1241+
expect(res[0].values[0]).toContain("Carol");
1242+
});
1243+
1244+
// ── Sort edge cases ──
1245+
1246+
it("τ with multiple sort columns", () => {
1247+
const res = execRA("τ[city, age DESC](Person)");
1248+
// Bristol(30), Stockholm(25), York(19)
1249+
expect(res[0].values[0]).toContain("Carol"); // Bristol
1250+
expect(res[0].values[1]).toContain("Alice"); // Stockholm
1251+
expect(res[0].values[2]).toContain("Bob"); // York
1252+
});
1253+
1254+
it("sort keyword", () => {
1255+
const res = execRA("sort[name](Person)");
1256+
expect(res[0].values[0]).toContain("Alice");
1257+
expect(res[0].values[2]).toContain("Carol");
1258+
});
1259+
1260+
// ── Aggregation edge cases ──
1261+
1262+
it("γ with SUM", () => {
1263+
const res = execRA("γ[city; SUM(age) AS totalAge](Person)");
1264+
expect(res[0].columns).toContain("totalAge");
1265+
expect(res[0].values.length).toBe(3);
1266+
});
1267+
1268+
it("γ with AVG", () => {
1269+
const res = execRA("γ[city; AVG(age) AS avgAge](Person)");
1270+
expect(res[0].columns).toContain("avgAge");
1271+
expect(res[0].values.length).toBe(3);
1272+
});
1273+
1274+
it("γ with MIN and MAX", () => {
1275+
const res = execRA("γ[city; MIN(age) AS youngest, MAX(age) AS oldest](Person)");
1276+
expect(res[0].columns).toContain("youngest");
1277+
expect(res[0].columns).toContain("oldest");
1278+
});
1279+
1280+
it("γ COUNT without alias", () => {
1281+
const res = execRA("γ[city; COUNT(id)](Person)");
1282+
expect(res[0].values.length).toBe(3);
1283+
});
1284+
1285+
it("gamma keyword", () => {
1286+
const res = execRA("gamma[city; COUNT(id) AS cnt](Person)");
1287+
expect(res[0].columns).toContain("cnt");
1288+
});
1289+
1290+
// ── Distinct edge cases ──
1291+
1292+
it("delta keyword", () => {
1293+
const res = execRA("delta(Person)");
1294+
expect(res[0].values.length).toBe(3);
1295+
});
1296+
1297+
it("distinct keyword", () => {
1298+
const res = execRA("distinct(Person)");
1299+
expect(res[0].values.length).toBe(3);
1300+
});
1301+
1302+
it("δ without parens", () => {
1303+
const res = execRA("δ Person");
1304+
expect(res[0].values.length).toBe(3);
1305+
});
1306+
1307+
// ── Keyword variants for operators ──
1308+
1309+
it("select keyword", () => {
1310+
const res = execRA("select[age > 20](Person)");
1311+
expect(res[0].values.length).toBe(2);
1312+
});
1313+
1314+
it("project keyword", () => {
1315+
const res = execRA("project[name, city](Person)");
1316+
expect(res[0].columns).toEqual(["name", "city"]);
1317+
});
1318+
1319+
it("cross keyword", () => {
1320+
const res = execRA("Person cross Course");
1321+
expect(res[0].values.length).toBe(6);
1322+
});
1323+
1324+
it("join keyword with condition", () => {
1325+
const res = execRA("Person join[age > credits] Course");
1326+
expect(res[0].values.length).toBeGreaterThan(0);
1327+
});
1328+
1329+
it("|X| as natural join", () => {
1330+
const res = execRA("Person |X| Student");
1331+
expect(res[0].values.length).toBe(2);
1332+
});
1333+
1334+
it("|><| as natural join", () => {
1335+
const res = execRA("Person |><| Student");
1336+
expect(res[0].values.length).toBe(2);
1337+
});
1338+
1339+
it("intersect keyword", () => {
1340+
const res = execRA("π[name](Person) intersect π[name](Teacher)");
1341+
expect(res.length === 0 || res[0].values.length === 0).toBe(true);
1342+
});
1343+
1344+
it("divide keyword", () => {
1345+
const res = execRA("π[id, course_id](Enrollment) divide π[course_id](Course)");
1346+
expect(res[0].values.length).toBe(1);
1347+
expect(res[0].values[0]).toContain(1);
1348+
});
1349+
1350+
// ── Implicit subscripts edge cases ──
1351+
1352+
it("ρ old→new (R) implicit", () => {
1353+
const res = execRA("ρ name→fullName (Person)");
1354+
expect(res[0].columns).toContain("fullName");
1355+
});
1356+
1357+
it("τ col (R) implicit", () => {
1358+
const res = execRA("τ name (Person)");
1359+
expect(res[0].values[0]).toContain("Alice");
1360+
});
1361+
1362+
it("σ compound implicit with AND", () => {
1363+
const res = execRA("σ age > 20 and city = 'Stockholm' (Person)");
1364+
expect(res[0].values.length).toBe(1);
1365+
expect(res[0].values[0]).toContain("Alice");
1366+
});
1367+
1368+
it("nested implicit subscripts", () => {
1369+
const res = execRA("π name (σ age > 20 (Person))");
1370+
expect(res[0].columns).toEqual(["name"]);
1371+
expect(res[0].values.length).toBe(2);
1372+
});
1373+
1374+
// ── Parenthesis-free edge cases ──
1375+
1376+
it("triple chain without parens", () => {
1377+
const res = execRA("π[name] σ[age > 20] δ Person");
1378+
expect(res[0].columns).toEqual(["name"]);
1379+
expect(res[0].values.length).toBe(2);
1380+
});
1381+
1382+
it("π[cols] over union with parens", () => {
1383+
const res = execRA("π[name] (π[name](Person) ∪ π[name](Teacher))");
1384+
expect(res[0].values.length).toBe(5);
1385+
});
1386+
1387+
// ── Assignment edge cases ──
1388+
1389+
it("assignment with <- ASCII arrow", () => {
1390+
const res = execRA("A <- σ[age > 20](Person)\nπ[name](A)");
1391+
expect(res[0].values.length).toBe(2);
1392+
});
1393+
1394+
it("semicolons as statement separators", () => {
1395+
const res = execRA("A ← Person; π[name](A)");
1396+
expect(res[0].values.length).toBe(3);
1397+
});
1398+
1399+
it("comments in multi-line input", () => {
1400+
const res = execRA("-- Get adults\nA ← σ[age > 20](Person)\n-- Project names\nπ[name](A)");
1401+
expect(res[0].values.length).toBe(2);
1402+
});
1403+
1404+
it("implicit return from last assignment", () => {
1405+
const res = execRA("A ← σ[age > 20](Person)");
1406+
expect(res[0].values.length).toBe(2);
1407+
});
1408+
1409+
it("complex pipeline with reassignment", () => {
1410+
const res = execRA("X ← Person ⋈ Student\nX ← σ[age > 20](X)\nX ← π[name](X)");
1411+
expect(res[0].values.length).toBe(1);
1412+
expect(res[0].values[0]).toContain("Alice");
1413+
});
11311414
});

src/ra-engine/relationalAlgebra.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,11 @@ function columnRefToSQL(ref: ColumnRef): string {
10011001

10021002
let subqueryCounter = 0;
10031003

1004+
/** Return a SQL alias for a node: use the table name for bare tables, otherwise _raN */
1005+
function aliasFor(node: RANode): string {
1006+
return node.type === "table" ? node.name : `_ra${subqueryCounter++}`;
1007+
}
1008+
10041009
/**
10051010
* Interface for a minimal database handle used to resolve column names.
10061011
* Compatible with sql.js Database.
@@ -1116,7 +1121,7 @@ function nodeToSQL(node: RANode, db?: DatabaseHandle): string {
11161121
return `SELECT DISTINCT * FROM (${nodeToSQL(node.relation, db)})`;
11171122

11181123
case "crossProduct":
1119-
return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} CROSS JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++}`;
1124+
return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS ${aliasFor(node.left)} CROSS JOIN (${nodeToSQL(node.right, db)}) AS ${aliasFor(node.right)}`;
11201125

11211126
case "naturalJoin": {
11221127
const leftSQL = nodeToSQL(node.left, db);
@@ -1135,20 +1140,20 @@ function nodeToSQL(node: RANode, db?: DatabaseHandle): string {
11351140
}
11361141
}
11371142
}
1138-
return `SELECT * FROM (${leftSQL}) AS _ra${subqueryCounter++} NATURAL JOIN (${rightSQL}) AS _ra${subqueryCounter++}`;
1143+
return `SELECT * FROM (${leftSQL}) AS ${aliasFor(node.left)} NATURAL JOIN (${rightSQL}) AS ${aliasFor(node.right)}`;
11391144
}
11401145

11411146
case "thetaJoin":
1142-
return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`;
1147+
return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS ${aliasFor(node.left)} JOIN (${nodeToSQL(node.right, db)}) AS ${aliasFor(node.right)} ON ${conditionToSQL(node.condition)}`;
11431148

11441149
case "leftJoin":
1145-
return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} LEFT JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`;
1150+
return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS ${aliasFor(node.left)} LEFT JOIN (${nodeToSQL(node.right, db)}) AS ${aliasFor(node.right)} ON ${conditionToSQL(node.condition)}`;
11461151

11471152
case "rightJoin":
1148-
return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} RIGHT JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`;
1153+
return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS ${aliasFor(node.left)} RIGHT JOIN (${nodeToSQL(node.right, db)}) AS ${aliasFor(node.right)} ON ${conditionToSQL(node.condition)}`;
11491154

11501155
case "fullJoin":
1151-
return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} FULL OUTER JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`;
1156+
return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS ${aliasFor(node.left)} FULL OUTER JOIN (${nodeToSQL(node.right, db)}) AS ${aliasFor(node.right)} ON ${conditionToSQL(node.condition)}`;
11521157

11531158
case "leftSemiJoin": {
11541159
const lAlias = `_ra${subqueryCounter++}`;

0 commit comments

Comments
 (0)