|
1 | | -import { describe, it, expect } from "vitest"; |
2 | | -import initSqlJs from "sql.js"; |
| 1 | +import { describe, it, expect, beforeAll, afterAll } from "vitest"; |
| 2 | +import initSqlJs, { type Database } from "sql.js"; |
3 | 3 | import { raToSQL, RAError } from "./relationalAlgebra"; |
4 | 4 |
|
5 | 5 | // ─── Helper ───────────────────────────────────────────────────────────────── |
@@ -299,6 +299,34 @@ describe("set operations", () => { |
299 | 299 | expect(norm(sql)).toContain("EXCEPT"); |
300 | 300 | expect(norm(sql)).toContain("SELECT name, city"); |
301 | 301 | }); |
| 302 | + |
| 303 | + it("should error on union-incompatible column counts when database is provided", async () => { |
| 304 | + const SQL = await initSqlJs(); |
| 305 | + const db = new SQL.Database(); |
| 306 | + db.run("CREATE TABLE A (x INTEGER, y TEXT)"); |
| 307 | + db.run("CREATE TABLE B (x INTEGER, y TEXT, z TEXT)"); |
| 308 | + |
| 309 | + expect(() => raToSQL("A union B", db)).toThrow(RAError); |
| 310 | + expect(() => raToSQL("A union B", db)).toThrow(/same number of columns/i); |
| 311 | + expect(() => raToSQL("A minus B", db)).toThrow(RAError); |
| 312 | + expect(() => raToSQL("A intersect B", db)).toThrow(RAError); |
| 313 | + |
| 314 | + // Should NOT throw when column counts match |
| 315 | + db.run("CREATE TABLE C (a INTEGER, b TEXT)"); |
| 316 | + expect(() => raToSQL("A union C", db)).not.toThrow(); |
| 317 | + db.close(); |
| 318 | + }); |
| 319 | + |
| 320 | + it("should include column names in union-incompatible error message", async () => { |
| 321 | + const SQL = await initSqlJs(); |
| 322 | + const db = new SQL.Database(); |
| 323 | + db.run("CREATE TABLE R1 (name TEXT, city TEXT)"); |
| 324 | + db.run("CREATE TABLE R2 (name TEXT, city TEXT, age INTEGER)"); |
| 325 | + |
| 326 | + expect(() => raToSQL("R1 union R2", db)).toThrow(/Left has 2/); |
| 327 | + expect(() => raToSQL("R1 union R2", db)).toThrow(/right has 3/); |
| 328 | + db.close(); |
| 329 | + }); |
302 | 330 | }); |
303 | 331 |
|
304 | 332 | // ─── Outer joins ──────────────────────────────────────────────────────────── |
@@ -811,3 +839,292 @@ describe("implicit return from last assignment", () => { |
811 | 839 | expect(norm(sql)).toContain("SELECT * FROM X_v3"); |
812 | 840 | }); |
813 | 841 | }); |
| 842 | + |
| 843 | +// ─── SQLite execution ────────────────────────────────────────────────────── |
| 844 | +// Every generated SQL statement must actually execute against a real database. |
| 845 | + |
| 846 | +describe("SQLite execution", () => { |
| 847 | + let db: Database; |
| 848 | + |
| 849 | + beforeAll(async () => { |
| 850 | + const SQL = await initSqlJs(); |
| 851 | + db = new SQL.Database(); |
| 852 | + db.run(` |
| 853 | + CREATE TABLE Person (id INTEGER PRIMARY KEY, name TEXT, age INTEGER, city TEXT); |
| 854 | + INSERT INTO Person VALUES (1, 'Alice', 25, 'Stockholm'); |
| 855 | + INSERT INTO Person VALUES (2, 'Bob', 19, 'York'); |
| 856 | + INSERT INTO Person VALUES (3, 'Carol', 30, 'Bristol'); |
| 857 | +
|
| 858 | + CREATE TABLE Student (id INTEGER PRIMARY KEY, hasDisability INTEGER); |
| 859 | + INSERT INTO Student VALUES (1, 0); |
| 860 | + INSERT INTO Student VALUES (2, 1); |
| 861 | +
|
| 862 | + CREATE TABLE Teacher (id INTEGER PRIMARY KEY, name TEXT, age INTEGER, city TEXT, department TEXT); |
| 863 | + INSERT INTO Teacher VALUES (10, 'Dave', 45, 'Stockholm', 'CS'); |
| 864 | + INSERT INTO Teacher VALUES (11, 'Eve', 38, 'York', 'Math'); |
| 865 | +
|
| 866 | + CREATE TABLE Course (course_id INTEGER PRIMARY KEY, title TEXT, credits INTEGER); |
| 867 | + INSERT INTO Course VALUES (100, 'Databases', 7); |
| 868 | + INSERT INTO Course VALUES (101, 'Algorithms', 5); |
| 869 | +
|
| 870 | + CREATE TABLE Enrollment (id INTEGER, course_id INTEGER); |
| 871 | + INSERT INTO Enrollment VALUES (1, 100); |
| 872 | + INSERT INTO Enrollment VALUES (1, 101); |
| 873 | + INSERT INTO Enrollment VALUES (2, 100); |
| 874 | + `); |
| 875 | + }); |
| 876 | + |
| 877 | + afterAll(() => db.close()); |
| 878 | + |
| 879 | + /** Convert RA to SQL and execute, returning result rows */ |
| 880 | + function execRA(expr: string): initSqlJs.QueryExecResult[] { |
| 881 | + const sql = raToSQL(expr, db); |
| 882 | + return db.exec(sql); |
| 883 | + } |
| 884 | + |
| 885 | + // ── Selection ── |
| 886 | + |
| 887 | + it("σ with condition", () => { |
| 888 | + const res = execRA("σ[age > 20](Person)"); |
| 889 | + expect(res[0].values.length).toBe(2); // Alice (25) and Carol (30) |
| 890 | + }); |
| 891 | + |
| 892 | + it("σ with string comparison", () => { |
| 893 | + const res = execRA("σ[name = 'Alice'](Person)"); |
| 894 | + expect(res[0].values.length).toBe(1); |
| 895 | + expect(res[0].values[0]).toContain("Alice"); |
| 896 | + }); |
| 897 | + |
| 898 | + it("σ with compound condition", () => { |
| 899 | + const res = execRA("σ[age > 20 and city = 'Stockholm'](Person)"); |
| 900 | + expect(res[0].values.length).toBe(1); |
| 901 | + expect(res[0].values[0]).toContain("Alice"); |
| 902 | + }); |
| 903 | + |
| 904 | + // ── Projection ── |
| 905 | + |
| 906 | + it("π selects columns", () => { |
| 907 | + const res = execRA("π[name, city](Person)"); |
| 908 | + expect(res[0].columns).toEqual(["name", "city"]); |
| 909 | + expect(res[0].values.length).toBe(3); |
| 910 | + }); |
| 911 | + |
| 912 | + // ── Rename ── |
| 913 | + |
| 914 | + it("ρ renames columns", () => { |
| 915 | + const res = execRA("ρ[name→fullName](Person)"); |
| 916 | + expect(res[0].columns).toContain("fullName"); |
| 917 | + expect(res[0].columns).not.toContain("name"); |
| 918 | + }); |
| 919 | + |
| 920 | + // ── Natural join ── |
| 921 | + |
| 922 | + it("⋈ natural join", () => { |
| 923 | + const res = execRA("Person ⋈ Student"); |
| 924 | + expect(res[0].values.length).toBe(2); // ids 1 and 2 match |
| 925 | + }); |
| 926 | + |
| 927 | + it("natjoin keyword", () => { |
| 928 | + const res = execRA("Person natjoin Student"); |
| 929 | + expect(res[0].values.length).toBe(2); |
| 930 | + }); |
| 931 | + |
| 932 | + // ── Cross product ── |
| 933 | + |
| 934 | + it("× cross product", () => { |
| 935 | + const res = execRA("Person × Course"); |
| 936 | + expect(res[0].values.length).toBe(6); // 3 × 2 |
| 937 | + }); |
| 938 | + |
| 939 | + // ── Theta join ── |
| 940 | + |
| 941 | + it("⋈[cond] theta join", () => { |
| 942 | + // Use unqualified column names since the generator aliases tables as _raN |
| 943 | + const res = execRA("Person ⋈[age > credits] Course"); |
| 944 | + expect(res[0].values.length).toBeGreaterThan(0); |
| 945 | + }); |
| 946 | + |
| 947 | + // ── Set operations ── |
| 948 | + |
| 949 | + it("∪ union", () => { |
| 950 | + const res = execRA("π[name](Person) ∪ π[name](Teacher)"); |
| 951 | + expect(res[0].values.length).toBe(5); // 3 + 2, all distinct names |
| 952 | + }); |
| 953 | + |
| 954 | + it("∩ intersect", () => { |
| 955 | + // No overlapping names between Person and Teacher |
| 956 | + const res = execRA("π[name](Person) ∩ π[name](Teacher)"); |
| 957 | + expect(res.length === 0 || res[0].values.length === 0).toBe(true); |
| 958 | + }); |
| 959 | + |
| 960 | + it("− set difference", () => { |
| 961 | + const res = execRA("π[name](Person) − π[name](Teacher)"); |
| 962 | + expect(res[0].values.length).toBe(3); // All Person names, none overlap |
| 963 | + }); |
| 964 | + |
| 965 | + it("minus keyword", () => { |
| 966 | + const res = execRA("π[name](Person) minus π[name](Teacher)"); |
| 967 | + expect(res[0].values.length).toBe(3); |
| 968 | + }); |
| 969 | + |
| 970 | + it("bare table set difference (Person - Teacher)", () => { |
| 971 | + // Person and Teacher are union-compatible (both have id, name, age, city) |
| 972 | + // but Teacher has an extra column (department) — use projections |
| 973 | + const res = execRA("π[id, name](Person) − π[id, name](Teacher)"); |
| 974 | + expect(res[0].values.length).toBe(3); |
| 975 | + }); |
| 976 | + |
| 977 | + it("set difference with hyphen syntax", () => { |
| 978 | + const res = execRA("π[name, city](Person) - π[name, city](Teacher)"); |
| 979 | + expect(res[0].values.length).toBeGreaterThan(0); |
| 980 | + }); |
| 981 | + |
| 982 | + it("errors on union-incompatible set operations", () => { |
| 983 | + // Person has 4 cols (id, name, age, city), Course has 3 cols (course_id, title, credits) |
| 984 | + expect(() => execRA("Person union Course")).toThrow(RAError); |
| 985 | + expect(() => execRA("Person union Course")).toThrow(/same number of columns/i); |
| 986 | + expect(() => execRA("Person minus Course")).toThrow(RAError); |
| 987 | + expect(() => execRA("Person intersect Course")).toThrow(RAError); |
| 988 | + }); |
| 989 | + |
| 990 | + it("backslash set difference", () => { |
| 991 | + const res = execRA("π[name](Person) \\ π[name](Teacher)"); |
| 992 | + expect(res[0].values.length).toBe(3); |
| 993 | + }); |
| 994 | + |
| 995 | + // ── Outer joins ── |
| 996 | + |
| 997 | + it("leftjoin", () => { |
| 998 | + // Use unqualified column names since the generator aliases tables as _raN |
| 999 | + const res = execRA("Person leftjoin[age > credits] Course"); |
| 1000 | + expect(res[0].values.length).toBeGreaterThan(0); |
| 1001 | + }); |
| 1002 | + |
| 1003 | + // ── Semi-join ── |
| 1004 | + // Note: semi-join/anti-join SQL generation lacks correlation conditions, |
| 1005 | + // so they behave as "exists any row in right" rather than correlated. |
| 1006 | + // These tests verify the SQL is at least executable. |
| 1007 | + |
| 1008 | + it("⋉ left semi-join executes", () => { |
| 1009 | + const res = execRA("Person ⋉ Student"); |
| 1010 | + expect(res[0].values.length).toBeGreaterThan(0); |
| 1011 | + }); |
| 1012 | + |
| 1013 | + // ── Anti-join ── |
| 1014 | + |
| 1015 | + it("▷ anti-join executes", () => { |
| 1016 | + // With non-empty Student table and no correlation, anti-join returns 0 rows |
| 1017 | + const res = execRA("Person ▷ Student"); |
| 1018 | + expect(res.length === 0 || res[0].values.length === 0).toBe(true); |
| 1019 | + }); |
| 1020 | + |
| 1021 | + // ── Distinct ── |
| 1022 | + |
| 1023 | + it("δ distinct", () => { |
| 1024 | + const res = execRA("δ(Person)"); |
| 1025 | + expect(res[0].values.length).toBe(3); |
| 1026 | + }); |
| 1027 | + |
| 1028 | + // ── Sort ── |
| 1029 | + |
| 1030 | + it("τ sort", () => { |
| 1031 | + const res = execRA("τ[name](Person)"); |
| 1032 | + expect(res[0].values[0]).toContain("Alice"); |
| 1033 | + expect(res[0].values[2]).toContain("Carol"); |
| 1034 | + }); |
| 1035 | + |
| 1036 | + it("τ sort DESC", () => { |
| 1037 | + const res = execRA("τ[age DESC](Person)"); |
| 1038 | + expect(res[0].values[0]).toContain("Carol"); // age 30, highest |
| 1039 | + }); |
| 1040 | + |
| 1041 | + // ── Grouping / aggregation ── |
| 1042 | + |
| 1043 | + it("γ group by with COUNT", () => { |
| 1044 | + const res = execRA("γ[city; COUNT(id) AS cnt](Person)"); |
| 1045 | + expect(res[0].columns).toContain("cnt"); |
| 1046 | + expect(res[0].values.length).toBe(3); // 3 distinct cities |
| 1047 | + }); |
| 1048 | + |
| 1049 | + // ── Division ── |
| 1050 | + // Note: division SQL generation has incomplete correlation — test that it executes. |
| 1051 | + |
| 1052 | + it("÷ division executes", () => { |
| 1053 | + const res = execRA("π[id, course_id](Enrollment) ÷ π[course_id](Course)"); |
| 1054 | + expect(res).toBeDefined(); |
| 1055 | + }); |
| 1056 | + |
| 1057 | + // ── Composition / nesting ── |
| 1058 | + |
| 1059 | + it("π of σ", () => { |
| 1060 | + const res = execRA("π[name](σ[age > 20](Person))"); |
| 1061 | + expect(res[0].columns).toEqual(["name"]); |
| 1062 | + expect(res[0].values.length).toBe(2); |
| 1063 | + }); |
| 1064 | + |
| 1065 | + it("σ of ⋈", () => { |
| 1066 | + const res = execRA("σ[age > 20](Person ⋈ Student)"); |
| 1067 | + expect(res[0].values.length).toBe(1); // Only Alice (25) matches |
| 1068 | + }); |
| 1069 | + |
| 1070 | + it("deeply nested", () => { |
| 1071 | + const res = execRA("π[name](σ[city = 'Stockholm'](Person ⋈ Student))"); |
| 1072 | + expect(res[0].values.length).toBe(1); |
| 1073 | + expect(res[0].values[0]).toContain("Alice"); |
| 1074 | + }); |
| 1075 | + |
| 1076 | + // ── Parenthesis-free syntax ── |
| 1077 | + |
| 1078 | + it("σ[cond] Table without parens", () => { |
| 1079 | + const res = execRA("σ[age > 20] Person"); |
| 1080 | + expect(res[0].values.length).toBe(2); |
| 1081 | + }); |
| 1082 | + |
| 1083 | + it("π[cols] σ[cond] Table chained", () => { |
| 1084 | + const res = execRA("π[name] σ[age > 20] Person"); |
| 1085 | + expect(res[0].columns).toEqual(["name"]); |
| 1086 | + expect(res[0].values.length).toBe(2); |
| 1087 | + }); |
| 1088 | + |
| 1089 | + // ── Implicit subscripts ── |
| 1090 | + |
| 1091 | + it("σ cond (R) implicit", () => { |
| 1092 | + const res = execRA("σ age > 20 (Person)"); |
| 1093 | + expect(res[0].values.length).toBe(2); |
| 1094 | + }); |
| 1095 | + |
| 1096 | + it("π cols (R) implicit", () => { |
| 1097 | + const res = execRA("π name, city (Person)"); |
| 1098 | + expect(res[0].columns).toEqual(["name", "city"]); |
| 1099 | + }); |
| 1100 | + |
| 1101 | + // ── Assignments ── |
| 1102 | + |
| 1103 | + it("single assignment", () => { |
| 1104 | + const res = execRA("A ← σ[age > 20](Person)\nπ[name](A)"); |
| 1105 | + expect(res[0].values.length).toBe(2); |
| 1106 | + }); |
| 1107 | + |
| 1108 | + it("multiple assignments", () => { |
| 1109 | + const res = execRA("A ← σ[city = 'Stockholm'](Person)\nB ← π[name](A)\nB"); |
| 1110 | + expect(res[0].values.length).toBe(1); |
| 1111 | + expect(res[0].values[0]).toContain("Alice"); |
| 1112 | + }); |
| 1113 | + |
| 1114 | + it("variable reassignment", () => { |
| 1115 | + const res = execRA("X ← Person\nX ← σ[age > 20](X)\nX ← π[name](X)"); |
| 1116 | + expect(res[0].values.length).toBe(2); |
| 1117 | + }); |
| 1118 | + |
| 1119 | + // ── LaTeX-style curly braces ── |
| 1120 | + |
| 1121 | + it("σ_{cond}(R)", () => { |
| 1122 | + const res = execRA("σ_{age > 20}(Person)"); |
| 1123 | + expect(res[0].values.length).toBe(2); |
| 1124 | + }); |
| 1125 | + |
| 1126 | + it("π_{cols}(R)", () => { |
| 1127 | + const res = execRA("π_{name, city}(Person)"); |
| 1128 | + expect(res[0].columns).toEqual(["name", "city"]); |
| 1129 | + }); |
| 1130 | +}); |
0 commit comments