From e84e84f5c1989e78c2c8584ae60b10b6d307895e Mon Sep 17 00:00:00 2001 From: Edwin <60476129+Edwinexd@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:20:48 +0000 Subject: [PATCH 1/7] Add relational algebra mode with RA-to-SQL transpiler --- README.md | 2 +- package-lock.json | 353 ++++++- package.json | 3 +- src/App.tsx | 145 ++- src/i18n/ui-strings.ts | 13 + src/ra-engine/LICENSE.md | 51 + src/ra-engine/relationalAlgebra.test.ts | 693 +++++++++++++ src/ra-engine/relationalAlgebra.ts | 1178 +++++++++++++++++++++++ 8 files changed, 2400 insertions(+), 38 deletions(-) create mode 100644 src/ra-engine/LICENSE.md create mode 100644 src/ra-engine/relationalAlgebra.test.ts create mode 100644 src/ra-engine/relationalAlgebra.ts diff --git a/README.md b/README.md index ba09e07..47f63ef 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # SQL Validator -SQL Validator is a fully client-side web application powered by sql.js. Designed for the database course at Stockholm University, it provides an interface for students to write, format, and run SQL queries directly in their browser without the need for a database server. Results can be compared to expected results based on a question bank, and views can be created and managed using the browser's local storage. +SQL Validator is a fully client-side web application powered by sql.js. It provides an interface for writing, formatting, and running SQL queries directly in the browser without the need for a database server. Results can be compared to expected results based on a question bank, and views can be created and managed using the browser's local storage. ## Features - **Fully Client-Side Execution**: All SQL validation and execution is performed in the browser using sql.js SQLite. diff --git a/package-lock.json b/package-lock.json index 47ccd8e..c1dd6e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,8 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.15.0", "vite": "^7.1.10", - "vite-plugin-node-polyfills": "^0.24.0" + "vite-plugin-node-polyfills": "^0.24.0", + "vitest": "^4.1.0" } }, "node_modules/@acemir/cssom": { @@ -2817,6 +2818,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@stylistic/eslint-plugin": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.13.0.tgz", @@ -3008,12 +3016,30 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/crypto-js": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/emscripten": { "version": "1.41.5", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", @@ -3405,6 +3431,129 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3713,6 +3862,16 @@ "util": "^0.12.5" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4123,6 +4282,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", @@ -5041,6 +5210,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5522,6 +5698,16 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8019,6 +8205,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8235,6 +8432,13 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbkdf2": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", @@ -9518,6 +9722,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -9630,6 +9841,20 @@ "obliterator": "^2.0.1" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10146,6 +10371,23 @@ "node": ">=0.6.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -10162,6 +10404,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.25", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", @@ -10709,6 +10961,88 @@ "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -10879,6 +11213,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 74989cb..06ab4eb 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.15.0", "vite": "^7.1.10", - "vite-plugin-node-polyfills": "^0.24.0" + "vite-plugin-node-polyfills": "^0.24.0", + "vitest": "^4.1.0" } } diff --git a/src/App.tsx b/src/App.tsx index b1f2de8..ab322c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import "prismjs/themes/prism.css"; import { format } from "sql-formatter"; import initSqlJs from "sql.js"; import ViewsTable, { View } from "./ViewsTable"; +import { raToSQL, RAError } from "./ra-engine/relationalAlgebra"; import { Info, Settings, XCircle, CheckCircle2, Eye, EyeOff } from "lucide-react"; import ChangelogDialog from "./ChangelogDialog"; @@ -78,6 +79,15 @@ function App() { const [exportQuery, setExportQuery] = useState(); const [exportingStatus, setExportingStatus] = useState(0); const [loadedQuestionCorrect, setLoadedQuestionCorrect] = useState(false); + const [editorMode, setEditorMode] = useState<"sql" | "ra">("sql"); + const [generatedSQL, setGeneratedSQL] = useState(null); + + const switchEditorMode = useCallback((mode: "sql" | "ra") => { + setEditorMode(mode); + setGeneratedSQL(null); + setError(null); + resetResult(); + }, [resetResult]); const exportRendererRef = useRef(null); const editorRef = useRef(null); @@ -239,23 +249,38 @@ function App() { setWrittenQuestions(wq); } - try { - // Check for multiple statements - let stmtCount = 0; - for (const stmt of database.iterateStatements(query)) { - stmtCount++; - stmt.free(); - if (stmtCount > 1) { - setError(t("multipleStatements")); - return; + if (editorMode === "sql") { + try { + // Check for multiple statements + let stmtCount = 0; + for (const stmt of database.iterateStatements(query)) { + stmtCount++; + stmt.free(); + if (stmtCount > 1) { + setError(t("multipleStatements")); + return; + } + } + setError(null); + } catch (e) { + // @ts-expect-error - Error.message is a string + setError(e.message); + } + } else { + // In RA mode, try to parse to check for errors + try { + raToSQL(query); + setError(null); + } catch (e) { + if (e instanceof RAError) { + setError(t("raParseError", { message: e.message })); + } else { + // @ts-expect-error - Error.message is a string + setError(e.message); } } - setError(null); - } catch (e) { - // @ts-expect-error - Error.message is a string - setError(e.message); } - }, [database, query, question, lang, defaultQuery, t]); + }, [database, query, question, lang, defaultQuery, t, editorMode]); const refreshViews = useCallback((upsert: boolean) => { @@ -307,7 +332,24 @@ function App() { return; } try { - const res = database.exec(query); + let sqlToRun = query; + if (editorMode === "ra") { + try { + sqlToRun = raToSQL(query, database); + setGeneratedSQL(sqlToRun); + } catch (e) { + if (e instanceof RAError) { + setError(t("raParseError", { message: e.message })); + } else { + // @ts-expect-error - Error.message is a string + setError(e.message); + } + return; + } + } else { + setGeneratedSQL(null); + } + const res = database.exec(sqlToRun); setIsViewResult(false); setQueryedView(null); setEvaluatedQuery(query); @@ -322,7 +364,7 @@ function App() { // @ts-expect-error - Error.message is a string setError(e.message); } - }, [database, query, refreshViews]); + }, [database, query, refreshViews, editorMode, t]); const evalSql = useCallback((sql: string): Result => { if (!database) { @@ -429,7 +471,7 @@ function App() { // Update mismatch & loadedQuestionCorrect flags when query is changed useEffect(() => { - if (!database || !question || query === undefined) { + if (!database || !question || query === undefined || editorMode === "ra") { return; } @@ -817,7 +859,23 @@ function App() { {/* Query Section with Header */}
- {t("query")} +
+ {t("query")} +
+ + +
+
)} - + {editorMode === "sql" && ( + + )}
+ {editorMode === "ra" && generatedSQL && ( +
+ {t("generatedSQL")} + { try { return format(generatedSQL, { language: "sqlite", tabWidth: 2, useTabs: false, keywordCase: "upper", dataTypeCase: "upper", functionCase: "upper" }); } catch { return generatedSQL; } })()} + onValueChange={() => null} + highlight={code => highlight(code, languages.sql)} + padding={10} + tabSize={2} + className="font-mono text-sm w-full dark:bg-slate-800/50 bg-slate-50 border dark:border-slate-700 border-gray-200 rounded-md mt-1" + /> +
+ )} + exportData({include})} ref={exportModalRef} /> > = { languageMismatchWarning: "Warning: This save file was created with a different language ({{fileLang}}). Importing may cause issues.", viewLabel: "View", changelog: "Changelog", + modeSQL: "SQL", + modeRA: "Relationsalgebra", + generatedSQL: "Genererad SQL", + raParseError: "Relationsalgebra-fel: {{message}}", + raPlaceholder: "-- Skriv ett relationsalgebrauttryck\n-- Exempel: π[namn](σ[ålder > 20](Person))", }, en: { @@ -151,6 +156,14 @@ export const uiStrings: Record> = { conflicts: "conflicts", languageMismatchWarning: "Warning: This save file was created with a different language ({{fileLang}}). Importing may cause issues.", viewLabel: "View", +<<<<<<< HEAD changelog: "Changelog", +======= + modeSQL: "SQL", + modeRA: "Relational Algebra", + generatedSQL: "Generated SQL", + raParseError: "Relational algebra error: {{message}}", + raPlaceholder: "-- Write a relational algebra expression\n-- Example: π[name](σ[age > 20](Person))", +>>>>>>> ae97058 (Add relational algebra mode with RA-to-SQL transpiler) }, }; diff --git a/src/ra-engine/LICENSE.md b/src/ra-engine/LICENSE.md new file mode 100644 index 0000000..6f9c128 --- /dev/null +++ b/src/ra-engine/LICENSE.md @@ -0,0 +1,51 @@ +# Business Source License 1.1 + +**Parameters** + +- **Licensor:** E.SU. IT AB (Org.no 559484-0505) and Edwin Sundberg +- **Licensed Work:** ra-engine + The Licensed Work is (c) 2026 E.SU. IT AB and Edwin Sundberg. +- **Change Date:** 2035-03-20 +- **Change License:** GNU General Public License v3.0 or later + +**Terms** + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. diff --git a/src/ra-engine/relationalAlgebra.test.ts b/src/ra-engine/relationalAlgebra.test.ts new file mode 100644 index 0000000..46f42c7 --- /dev/null +++ b/src/ra-engine/relationalAlgebra.test.ts @@ -0,0 +1,693 @@ +import { describe, it, expect } from "vitest"; +import { raToSQL, RAError } from "./relationalAlgebra"; + +// ─── Helper ───────────────────────────────────────────────────────────────── + +/** Normalize whitespace for comparison */ +function norm(s: string): string { + return s.replace(/\s+/g, " ").trim(); +} + +// ─── Basic table references ───────────────────────────────────────────────── + +describe("table references", () => { + it("should return a bare table name", () => { + expect(raToSQL("Person")).toBe("Person"); + }); + + it("should handle parenthesised table name", () => { + expect(raToSQL("(Person)")).toBe("Person"); + }); + + it("should handle trailing semicolon", () => { + expect(raToSQL("Person;")).toBe("Person"); + }); +}); + +// ─── Selection (σ) ────────────────────────────────────────────────────────── + +describe("selection (σ)", () => { + it("should transpile σ with Unicode symbol", () => { + expect(norm(raToSQL("σ[age > 20](Person)"))).toBe( + norm("SELECT * FROM (Person) WHERE age > 20") + ); + }); + + it("should transpile sigma keyword", () => { + expect(norm(raToSQL("sigma[age > 20](Person)"))).toBe( + norm("SELECT * FROM (Person) WHERE age > 20") + ); + }); + + it("should transpile select keyword", () => { + expect(norm(raToSQL("select[age > 20](Person)"))).toBe( + norm("SELECT * FROM (Person) WHERE age > 20") + ); + }); + + it("should handle string comparison", () => { + expect(norm(raToSQL("σ[name = 'Alice'](Person)"))).toBe( + norm("SELECT * FROM (Person) WHERE name = 'Alice'") + ); + }); + + it("should handle compound conditions with AND/OR", () => { + const sql = raToSQL("σ[age > 20 and name = 'Alice'](Person)"); + expect(norm(sql)).toBe( + norm("SELECT * FROM (Person) WHERE (age > 20 AND name = 'Alice')") + ); + }); + + it("should handle NOT condition", () => { + const sql = raToSQL("σ[not age > 20](Person)"); + expect(norm(sql)).toBe( + norm("SELECT * FROM (Person) WHERE NOT (age > 20)") + ); + }); + + it("should handle table.column references in conditions", () => { + const sql = raToSQL("σ[Person.age > 20](Person)"); + expect(norm(sql)).toBe( + norm("SELECT * FROM (Person) WHERE Person.age > 20") + ); + }); + + it("should handle nested OR and AND", () => { + const sql = raToSQL("σ[age > 20 or (name = 'Alice' and city = 'Stockholm')](Person)"); + expect(norm(sql)).toContain("OR"); + expect(norm(sql)).toContain("AND"); + }); + + it("should handle all comparison operators", () => { + expect(norm(raToSQL("σ[a = 1](T)"))).toContain("= 1"); + expect(norm(raToSQL("σ[a <> 1](T)"))).toContain("<> 1"); + expect(norm(raToSQL("σ[a != 1](T)"))).toContain("<> 1"); + expect(norm(raToSQL("σ[a < 1](T)"))).toContain("< 1"); + expect(norm(raToSQL("σ[a > 1](T)"))).toContain("> 1"); + expect(norm(raToSQL("σ[a <= 1](T)"))).toContain("<= 1"); + expect(norm(raToSQL("σ[a >= 1](T)"))).toContain(">= 1"); + }); + + it("should handle function calls in conditions", () => { + const sql = raToSQL("σ[YEAR(startDate) = 2024](CourseInstance)"); + expect(norm(sql)).toBe( + norm("SELECT * FROM (CourseInstance) WHERE YEAR(startDate) = 2024") + ); + }); +}); + +// ─── Projection (π) ───────────────────────────────────────────────────────── + +describe("projection (π)", () => { + it("should transpile π with Unicode", () => { + expect(norm(raToSQL("π[name, age](Person)"))).toBe( + norm("SELECT name, age FROM (Person)") + ); + }); + + it("should transpile pi keyword", () => { + expect(norm(raToSQL("pi[name](Person)"))).toBe( + norm("SELECT name FROM (Person)") + ); + }); + + it("should transpile project keyword", () => { + expect(norm(raToSQL("project[name, city](Person)"))).toBe( + norm("SELECT name, city FROM (Person)") + ); + }); + + it("should handle table.column references", () => { + expect(norm(raToSQL("π[Person.name](Person)"))).toBe( + norm("SELECT Person.name FROM (Person)") + ); + }); +}); + +// ─── Rename (ρ) ───────────────────────────────────────────────────────────── + +describe("rename (ρ)", () => { + it("should transpile ρ with Unicode arrow", () => { + expect(norm(raToSQL("ρ[name→fullName](Person)"))).toBe( + norm("SELECT name AS fullName FROM (Person)") + ); + }); + + it("should transpile rho with ASCII arrow", () => { + expect(norm(raToSQL("rho[name->fullName](Person)"))).toBe( + norm("SELECT name AS fullName FROM (Person)") + ); + }); + + it("should handle multiple rename mappings", () => { + const sql = raToSQL("ρ[name→fullName, age→years](Person)"); + expect(norm(sql)).toBe( + norm("SELECT name AS fullName, age AS years FROM (Person)") + ); + }); +}); + +// ─── Cross product (×) ───────────────────────────────────────────────────── + +describe("cross product (×)", () => { + it("should transpile × operator", () => { + const sql = raToSQL("Person × Course"); + expect(norm(sql)).toContain("CROSS JOIN"); + }); + + it("should transpile cross keyword", () => { + const sql = raToSQL("Person cross Course"); + expect(norm(sql)).toContain("CROSS JOIN"); + }); +}); + +// ─── Natural join (⋈) ────────────────────────────────────────────────────── + +describe("natural join (⋈)", () => { + it("should transpile ⋈ operator", () => { + const sql = raToSQL("Person ⋈ Student"); + expect(norm(sql)).toContain("NATURAL JOIN"); + }); + + it("should transpile natjoin keyword", () => { + const sql = raToSQL("Person natjoin Student"); + expect(norm(sql)).toContain("NATURAL JOIN"); + }); + + it("should error on impossible natural join when database is provided", () => { + // Mock database that returns different columns for two tables + const mockDb = { + exec: (sql: string) => { + if (sql.includes("TableA")) { + return [{ columns: ["id", "name"], values: [] }]; + } + if (sql.includes("TableB")) { + return [{ columns: ["code", "description"], values: [] }]; + } + return [{ columns: [], values: [] }]; + }, + }; + + expect(() => raToSQL("TableA ⋈ TableB", mockDb)).toThrow(RAError); + expect(() => raToSQL("TableA ⋈ TableB", mockDb)).toThrow(/no common columns/i); + }); + + it("should not error on valid natural join when database is provided", () => { + const mockDb = { + exec: (sql: string) => { + if (sql.includes("Person")) { + return [{ columns: ["id", "name", "city"], values: [] }]; + } + if (sql.includes("Student")) { + return [{ columns: ["id", "hasDisability"], values: [] }]; + } + return [{ columns: [], values: [] }]; + }, + }; + + expect(() => raToSQL("Person ⋈ Student", mockDb)).not.toThrow(); + }); + + it("should not error when no database is provided (parse-only mode)", () => { + expect(() => raToSQL("TableA ⋈ TableB")).not.toThrow(); + }); +}); + +// ─── Theta join (⋈[cond]) ────────────────────────────────────────────────── + +describe("theta join (⋈[cond])", () => { + it("should transpile ⋈ with condition", () => { + const sql = raToSQL("Person ⋈[Person.id = Student.id] Student"); + expect(norm(sql)).toContain("JOIN"); + expect(norm(sql)).toContain("ON Person.id = Student.id"); + expect(norm(sql)).not.toContain("NATURAL"); + }); + + it("should transpile join keyword with condition", () => { + const sql = raToSQL("Person join[Person.id = Student.id] Student"); + expect(norm(sql)).toContain("JOIN"); + expect(norm(sql)).toContain("ON Person.id = Student.id"); + }); +}); + +// ─── Set operations ───────────────────────────────────────────────────────── + +describe("set operations", () => { + it("should transpile union with ∪", () => { + const sql = raToSQL("π[name](Person) ∪ π[name](Teacher)"); + expect(norm(sql)).toContain("UNION"); + }); + + it("should transpile union keyword", () => { + const sql = raToSQL("π[name](Person) union π[name](Teacher)"); + expect(norm(sql)).toContain("UNION"); + }); + + it("should transpile intersect with ∩", () => { + const sql = raToSQL("π[name](Person) ∩ π[name](Teacher)"); + expect(norm(sql)).toContain("INTERSECT"); + }); + + it("should transpile difference with −", () => { + const sql = raToSQL("π[name](Person) − π[name](Teacher)"); + expect(norm(sql)).toContain("EXCEPT"); + }); + + it("should transpile minus keyword", () => { + const sql = raToSQL("π[name](Person) minus π[name](Teacher)"); + expect(norm(sql)).toContain("EXCEPT"); + }); + + it("should transpile backslash as set difference", () => { + const sql = raToSQL("A \\ B"); + expect(norm(sql)).toContain("EXCEPT"); + }); +}); + +// ─── Outer joins ──────────────────────────────────────────────────────────── + +describe("outer joins", () => { + it("should transpile left outer join", () => { + const sql = raToSQL("Person leftjoin[Person.id = Student.id] Student"); + expect(norm(sql)).toContain("LEFT JOIN"); + expect(norm(sql)).toContain("ON Person.id = Student.id"); + }); + + it("should transpile right outer join", () => { + const sql = raToSQL("Person rightjoin[Person.id = Student.id] Student"); + expect(norm(sql)).toContain("RIGHT JOIN"); + }); + + it("should transpile full outer join", () => { + const sql = raToSQL("Person fulljoin[Person.id = Student.id] Student"); + expect(norm(sql)).toContain("FULL OUTER JOIN"); + }); + + it("should transpile left outer join with Unicode ⟕", () => { + const sql = raToSQL("Person ⟕[Person.id = Student.id] Student"); + expect(norm(sql)).toContain("LEFT JOIN"); + }); +}); + +// ─── Semi-joins and anti-join ─────────────────────────────────────────────── + +describe("semi-joins and anti-join", () => { + it("should transpile left semi-join", () => { + const sql = raToSQL("Person leftsemijoin Student"); + expect(norm(sql)).toContain("WHERE EXISTS"); + }); + + it("should transpile left semi-join with ⋉", () => { + const sql = raToSQL("Person ⋉ Student"); + expect(norm(sql)).toContain("WHERE EXISTS"); + }); + + it("should transpile right semi-join", () => { + const sql = raToSQL("Person rightsemijoin Student"); + expect(norm(sql)).toContain("WHERE EXISTS"); + }); + + it("should transpile anti-join", () => { + const sql = raToSQL("Person antijoin Student"); + expect(norm(sql)).toContain("WHERE NOT EXISTS"); + }); + + it("should transpile anti-join with ▷", () => { + const sql = raToSQL("Person ▷ Student"); + expect(norm(sql)).toContain("WHERE NOT EXISTS"); + }); +}); + +// ─── Division (÷) ────────────────────────────────────────────────────────── + +describe("division (÷)", () => { + it("should transpile ÷ operator", () => { + const sql = raToSQL("A ÷ B"); + expect(norm(sql)).toContain("NOT EXISTS"); + }); + + it("should transpile divide keyword", () => { + const sql = raToSQL("A divide B"); + expect(norm(sql)).toContain("NOT EXISTS"); + }); +}); + +// ─── Distinct (δ) ────────────────────────────────────────────────────────── + +describe("distinct (δ)", () => { + it("should transpile δ", () => { + expect(norm(raToSQL("δ(Person)"))).toBe( + norm("SELECT DISTINCT * FROM (Person)") + ); + }); + + it("should transpile delta keyword", () => { + expect(norm(raToSQL("delta(Person)"))).toBe( + norm("SELECT DISTINCT * FROM (Person)") + ); + }); + + it("should transpile distinct keyword", () => { + expect(norm(raToSQL("distinct(Person)"))).toBe( + norm("SELECT DISTINCT * FROM (Person)") + ); + }); +}); + +// ─── Sorting (τ) ──────────────────────────────────────────────────────────── + +describe("sorting (τ)", () => { + it("should transpile τ with single column", () => { + const sql = raToSQL("τ[name](Person)"); + expect(norm(sql)).toBe( + norm("SELECT * FROM (Person) ORDER BY name") + ); + }); + + it("should transpile τ with DESC", () => { + const sql = raToSQL("τ[age DESC](Person)"); + expect(norm(sql)).toBe( + norm("SELECT * FROM (Person) ORDER BY age DESC") + ); + }); + + it("should transpile sort keyword", () => { + const sql = raToSQL("sort[name](Person)"); + expect(norm(sql)).toContain("ORDER BY name"); + }); + + it("should handle multiple sort columns", () => { + const sql = raToSQL("τ[name, age DESC](Person)"); + expect(norm(sql)).toContain("ORDER BY name, age DESC"); + }); +}); + +// ─── Grouping/Aggregation (γ) ────────────────────────────────────────────── + +describe("grouping/aggregation (γ)", () => { + it("should transpile γ with group-by and aggregate", () => { + const sql = raToSQL("γ[city; COUNT(id)](Person)"); + expect(norm(sql)).toContain("COUNT(id)"); + expect(norm(sql)).toContain("GROUP BY city"); + }); + + it("should handle aggregate with alias", () => { + const sql = raToSQL("γ[city; COUNT(id) AS total](Person)"); + expect(norm(sql)).toContain("COUNT(id) AS total"); + expect(norm(sql)).toContain("GROUP BY city"); + }); + + it("should transpile gamma keyword", () => { + const sql = raToSQL("gamma[city; SUM(price) AS totalPrice](Course)"); + expect(norm(sql)).toContain("SUM(price) AS totalPrice"); + expect(norm(sql)).toContain("GROUP BY city"); + }); +}); + +// ─── Composition / nesting ────────────────────────────────────────────────── + +describe("composition and nesting", () => { + it("should handle projection of selection", () => { + const sql = raToSQL("π[name](σ[age > 20](Person))"); + expect(norm(sql)).toContain("SELECT name FROM"); + expect(norm(sql)).toContain("WHERE age > 20"); + }); + + it("should handle selection of join", () => { + const sql = raToSQL("σ[age > 20](Person ⋈ Student)"); + expect(norm(sql)).toContain("WHERE age > 20"); + expect(norm(sql)).toContain("NATURAL JOIN"); + }); + + it("should handle complex nested expression", () => { + const sql = raToSQL("π[name](σ[city = 'Stockholm'](Person ⋈ Student))"); + expect(norm(sql)).toContain("SELECT name FROM"); + expect(norm(sql)).toContain("WHERE city = 'Stockholm'"); + expect(norm(sql)).toContain("NATURAL JOIN"); + }); + + it("should handle parenthesised subexpressions", () => { + const sql = raToSQL("(Person ⋈ Student) ∪ (Person ⋈ Teacher)"); + expect(norm(sql)).toContain("UNION"); + expect(norm(sql).match(/NATURAL JOIN/g)?.length).toBe(2); + }); + + it("should handle deeply nested expressions", () => { + const sql = raToSQL("π[name](σ[age > 20](ρ[id→personId](Person)))"); + expect(norm(sql)).toContain("SELECT name FROM"); + expect(norm(sql)).toContain("WHERE age > 20"); + expect(norm(sql)).toContain("id AS personId"); + }); +}); + +// ─── Operator precedence ─────────────────────────────────────────────────── + +describe("operator precedence", () => { + it("should bind join tighter than union", () => { + // A ⋈ B ∪ C should be (A ⋈ B) ∪ C, not A ⋈ (B ∪ C) + const sql = raToSQL("A ⋈ B ∪ C"); + // The NATURAL JOIN should come before UNION in the SQL + expect(norm(sql)).toMatch(/NATURAL JOIN.*UNION/); + }); + + it("should bind intersect tighter than union", () => { + const sql = raToSQL("A ∪ B ∩ C"); + // B ∩ C should be grouped together + expect(norm(sql)).toContain("INTERSECT"); + expect(norm(sql)).toContain("UNION"); + }); +}); + +// ─── Error handling ───────────────────────────────────────────────────────── + +describe("error handling", () => { + it("should throw RAError on empty input", () => { + expect(() => raToSQL("")).toThrow(RAError); + }); + + it("should throw RAError on invalid syntax", () => { + expect(() => raToSQL("σ[](Person)")).toThrow(RAError); + }); + + it("should throw RAError on unclosed bracket", () => { + expect(() => raToSQL("σ[age > 20(Person)")).toThrow(RAError); + }); + + it("should throw RAError on unexpected token after expression", () => { + expect(() => raToSQL("Person Student")).toThrow(RAError); + }); + + it("should throw RAError on unterminated string", () => { + expect(() => raToSQL("σ[name = 'Alice](Person)")).toThrow(RAError); + }); + + it("should throw RAError on missing condition in selection", () => { + expect(() => raToSQL("σ(Person)")).toThrow(RAError); + }); + + it("should throw RAError on missing relation for selection", () => { + expect(() => raToSQL("σ[age > 20]")).toThrow(RAError); + }); +}); + +// ─── Multi-line / assignments (←) ────────────────────────────────────────── + +describe("multi-line and assignments", () => { + it("should handle simple assignment with ←", () => { + const sql = raToSQL("A ← Person\nA"); + expect(norm(sql)).toContain("WITH"); + expect(norm(sql)).toContain("A AS"); + }); + + it("should handle simple assignment with <-", () => { + const sql = raToSQL("A <- Person\nA"); + expect(norm(sql)).toContain("WITH"); + expect(norm(sql)).toContain("A AS"); + }); + + it("should handle assignment with complex expression", () => { + const sql = raToSQL("Students ← σ[age > 20](Person)\nπ[name](Students)"); + expect(norm(sql)).toContain("WITH"); + expect(norm(sql)).toContain("Students AS"); + expect(norm(sql)).toContain("WHERE age > 20"); + expect(norm(sql)).toContain("SELECT name FROM"); + }); + + it("should handle multiple assignments", () => { + const input = [ + "A ← σ[city = 'Stockholm'](Person)", + "B ← π[name](A)", + "B", + ].join("\n"); + const sql = raToSQL(input); + expect(norm(sql)).toContain("WITH"); + expect(norm(sql)).toContain("A AS"); + expect(norm(sql)).toContain("B AS"); + expect(norm(sql)).toContain("WHERE city = 'Stockholm'"); + expect(norm(sql)).toContain("SELECT name FROM"); + }); + + it("should handle assignment chaining with joins", () => { + const input = [ + "PS ← Person ⋈ Student", + "Result ← π[name](PS)", + "Result", + ].join("\n"); + const sql = raToSQL(input); + expect(norm(sql)).toContain("WITH"); + expect(norm(sql)).toContain("PS AS"); + expect(norm(sql)).toContain("NATURAL JOIN"); + expect(norm(sql)).toContain("Result AS"); + }); + + it("should handle semicolons as statement separators", () => { + const sql = raToSQL("A ← Person; π[name](A)"); + expect(norm(sql)).toContain("WITH"); + expect(norm(sql)).toContain("A AS"); + expect(norm(sql)).toContain("SELECT name FROM"); + }); + + it("should handle mixed newlines and semicolons", () => { + const input = "A ← Person;\nB ← Student\nA ⋈ B"; + const sql = raToSQL(input); + expect(norm(sql)).toContain("WITH"); + expect(norm(sql)).toContain("A AS"); + expect(norm(sql)).toContain("B AS"); + expect(norm(sql)).toContain("NATURAL JOIN"); + }); + + it("should still work with single-line expressions (no assignments)", () => { + // No regression — single-line without assignment should work as before + expect(raToSQL("Person")).toBe("Person"); + expect(norm(raToSQL("π[name](Person)"))).toBe( + norm("SELECT name FROM (Person)") + ); + }); + + it("should handle -- comments in multi-line input", () => { + const input = [ + "-- First get students", + "A ← σ[age > 20](Person)", + "-- Then project names", + "π[name](A)", + ].join("\n"); + const sql = raToSQL(input); + expect(norm(sql)).toContain("WITH"); + expect(norm(sql)).toContain("A AS"); + expect(norm(sql)).toContain("WHERE age > 20"); + expect(norm(sql)).toContain("SELECT name FROM"); + }); + + it("should handle trailing newlines", () => { + const sql = raToSQL("Person\n\n\n"); + expect(sql).toBe("Person"); + }); + + it("should handle leading newlines", () => { + const sql = raToSQL("\n\n\nPerson"); + expect(sql).toBe("Person"); + }); + + it("should handle the student example: A <- pi[student](sigma[name='peter'](P))", () => { + const input = "A <- π[student](σ[name = 'Peter'](Participation))\nA"; + const sql = raToSQL(input); + expect(norm(sql)).toContain("WITH"); + expect(norm(sql)).toContain("A AS"); + expect(norm(sql)).toContain("SELECT student FROM"); + expect(norm(sql)).toContain("WHERE name = 'Peter'"); + }); +}); + +// ─── Alternative notation styles ──────────────────────────────────────────── + +describe("curly braces (LaTeX-style)", () => { + it("should support σ_{condition}(R)", () => { + expect(norm(raToSQL("σ_{age > 20}(Person)"))).toBe( + norm("SELECT * FROM (Person) WHERE age > 20") + ); + }); + + it("should support π_{cols}(R)", () => { + expect(norm(raToSQL("π_{name, city}(Person)"))).toBe( + norm("SELECT name, city FROM (Person)") + ); + }); + + it("should support ρ_{old→new}(R)", () => { + expect(norm(raToSQL("ρ_{name→fullName}(Person)"))).toBe( + norm("SELECT name AS fullName FROM (Person)") + ); + }); + + it("should support curly braces without underscore", () => { + expect(norm(raToSQL("σ{age > 20}(Person)"))).toBe( + norm("SELECT * FROM (Person) WHERE age > 20") + ); + }); + + it("should support theta join with curly braces", () => { + const sql = raToSQL("Person ⋈{Person.id = Student.id} Student"); + expect(norm(sql)).toContain("JOIN"); + expect(norm(sql)).toContain("ON Person.id = Student.id"); + }); +}); + +describe("implicit subscripts (no brackets)", () => { + it("should support σ condition (R)", () => { + expect(norm(raToSQL("σ age > 20 (Person)"))).toBe( + norm("SELECT * FROM (Person) WHERE age > 20") + ); + }); + + it("should support π cols (R)", () => { + expect(norm(raToSQL("π name, city (Person)"))).toBe( + norm("SELECT name, city FROM (Person)") + ); + }); + + it("should support sigma condition (R) with keyword", () => { + expect(norm(raToSQL("sigma age > 20 (Person)"))).toBe( + norm("SELECT * FROM (Person) WHERE age > 20") + ); + }); + + it("should support pi cols (R) with keyword", () => { + expect(norm(raToSQL("pi name (Person)"))).toBe( + norm("SELECT name FROM (Person)") + ); + }); + + it("should support ρ old→new (R) implicit", () => { + expect(norm(raToSQL("ρ name→fullName (Person)"))).toBe( + norm("SELECT name AS fullName FROM (Person)") + ); + }); + + it("should support τ col (R) implicit", () => { + expect(norm(raToSQL("τ name (Person)"))).toContain("ORDER BY name"); + }); + + it("should support nested implicit subscripts", () => { + const sql = raToSQL("π name (σ age > 20 (Person))"); + expect(norm(sql)).toContain("SELECT name FROM"); + expect(norm(sql)).toContain("WHERE age > 20"); + }); + + it("should support compound implicit condition with AND", () => { + const sql = raToSQL("σ age > 20 and city = 'Stockholm' (Person)"); + expect(norm(sql)).toContain("WHERE"); + expect(norm(sql)).toContain("AND"); + expect(norm(sql)).toContain("age > 20"); + }); + + it("should work with multi-line and implicit subscripts", () => { + const input = [ + "A <- σ age > 20 (Person)", + "π name (A)", + ].join("\n"); + const sql = raToSQL(input); + expect(norm(sql)).toContain("WITH"); + expect(norm(sql)).toContain("WHERE age > 20"); + expect(norm(sql)).toContain("SELECT name FROM"); + }); +}); diff --git a/src/ra-engine/relationalAlgebra.ts b/src/ra-engine/relationalAlgebra.ts new file mode 100644 index 0000000..865070d --- /dev/null +++ b/src/ra-engine/relationalAlgebra.ts @@ -0,0 +1,1178 @@ +/* +Relational Algebra engine for sql-validator +Copyright (C) 2026 E.SU. IT AB (Org.no 559484-0505) and Edwin Sundberg + +Licensed under the Business Source License 1.1 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License in the LICENSE.md file in this repository. +*/ + +// ─── Token Types ──────────────────────────────────────────────────────────── + +enum TokenType { + // Unary operators + SIGMA = "SIGMA", // σ or sigma + PI = "PI", // π or pi + RHO = "RHO", // ρ or rho + GAMMA = "GAMMA", // γ or gamma (grouping/aggregation) + TAU = "TAU", // τ or tau (sorting) + DELTA = "DELTA", // δ or delta (duplicate elimination / DISTINCT) + // Binary operators + CROSS = "CROSS", // × or cross + NATJOIN = "NATJOIN", // ⋈ or natjoin + JOIN = "JOIN", // join (theta join with condition) + UNION = "UNION", // ∪ or union + INTERSECT = "INTERSECT", // ∩ or intersect + MINUS = "MINUS", // − or minus or \ + DIVIDE = "DIVIDE", // ÷ or divide + LEFTJOIN = "LEFTJOIN", // ⟕ or leftjoin + RIGHTJOIN = "RIGHTJOIN", // ⟖ or rightjoin + FULLJOIN = "FULLJOIN", // ⟗ or fulljoin + LEFTSEMIJOIN = "LEFTSEMIJOIN", // ⋉ or leftsemijoin + RIGHTSEMIJOIN = "RIGHTSEMIJOIN", // ⋊ or rightsemijoin + ANTIJOIN = "ANTIJOIN", // ▷ or antijoin + // Delimiters + LPAREN = "LPAREN", // ( + RPAREN = "RPAREN", // ) + LBRACKET = "LBRACKET", // [ + RBRACKET = "RBRACKET", // ] + COMMA = "COMMA", // , + // Identifiers, literals + IDENTIFIER = "IDENTIFIER", + NUMBER = "NUMBER", + STRING = "STRING", // 'value' + // Condition tokens (passed through inside brackets) + CONDITION = "CONDITION", + // Operators within conditions + EQ = "EQ", // = + NEQ = "NEQ", // <> or != + LT = "LT", // < + GT = "GT", // > + LTE = "LTE", // <= + GTE = "GTE", // >= + AND = "AND", + OR = "OR", + NOT = "NOT", + DOT = "DOT", // . + ARROW = "ARROW", // → or -> + ASSIGN = "ASSIGN", // ← or <- + SEMICOLON = "SEMICOLON", + NEWLINE = "NEWLINE", // line break (statement separator) + EOF = "EOF", +} + +interface Token { + type: TokenType; + value: string; + pos: number; +} + +// ─── Tokenizer ────────────────────────────────────────────────────────────── + +// Unicode symbol → TokenType map +const UNICODE_OPS: Record = { + "σ": TokenType.SIGMA, + "π": TokenType.PI, + "ρ": TokenType.RHO, + "γ": TokenType.GAMMA, + "τ": TokenType.TAU, + "δ": TokenType.DELTA, + "×": TokenType.CROSS, + "⋈": TokenType.NATJOIN, + "∪": TokenType.UNION, + "∩": TokenType.INTERSECT, + "−": TokenType.MINUS, + "÷": TokenType.DIVIDE, + "⟕": TokenType.LEFTJOIN, + "⟖": TokenType.RIGHTJOIN, + "⟗": TokenType.FULLJOIN, + "⋉": TokenType.LEFTSEMIJOIN, + "⋊": TokenType.RIGHTSEMIJOIN, + "▷": TokenType.ANTIJOIN, + "←": TokenType.ASSIGN, +}; + +// Keyword → TokenType map (case-insensitive) +const KEYWORD_OPS: Record = { + "sigma": TokenType.SIGMA, + "select": TokenType.SIGMA, + "pi": TokenType.PI, + "project": TokenType.PI, + "rho": TokenType.RHO, + "rename": TokenType.RHO, + "gamma": TokenType.GAMMA, + "tau": TokenType.TAU, + "sort": TokenType.TAU, + "delta": TokenType.DELTA, + "distinct": TokenType.DELTA, + "cross": TokenType.CROSS, + "natjoin": TokenType.NATJOIN, + "join": TokenType.JOIN, + "union": TokenType.UNION, + "intersect": TokenType.INTERSECT, + "minus": TokenType.MINUS, + "divide": TokenType.DIVIDE, + "leftjoin": TokenType.LEFTJOIN, + "rightjoin": TokenType.RIGHTJOIN, + "fulljoin": TokenType.FULLJOIN, + "leftsemijoin": TokenType.LEFTSEMIJOIN, + "rightsemijoin": TokenType.RIGHTSEMIJOIN, + "antijoin": TokenType.ANTIJOIN, + "and": TokenType.AND, + "or": TokenType.OR, + "not": TokenType.NOT, +}; + +function tokenize(input: string): Token[] { + const tokens: Token[] = []; + let i = 0; + + while (i < input.length) { + // Skip comments (lines starting with --) + if (input[i] === "-" && i + 1 < input.length && input[i + 1] === "-") { + while (i < input.length && input[i] !== "\n") i++; + continue; + } + + // Emit newlines as statement separators, skip other whitespace + if (input[i] === "\n" || input[i] === "\r") { + // Skip consecutive newlines and emit at most one NEWLINE token + while (i < input.length && (input[i] === "\n" || input[i] === "\r")) i++; + // Only emit if there are tokens before this (not leading newlines) + // and the last token isn't already a newline/semicolon + if (tokens.length > 0 && + tokens[tokens.length - 1].type !== TokenType.NEWLINE && + tokens[tokens.length - 1].type !== TokenType.SEMICOLON) { + tokens.push({ type: TokenType.NEWLINE, value: "\\n", pos: i }); + } + continue; + } + if (/\s/.test(input[i])) { + i++; + continue; + } + + const pos = i; + const ch = input[i]; + + // Check for Unicode operators + if (UNICODE_OPS[ch]) { + tokens.push({ type: UNICODE_OPS[ch], value: ch, pos }); + i++; + continue; + } + + // Single-char tokens + if (ch === "(") { tokens.push({ type: TokenType.LPAREN, value: ch, pos }); i++; continue; } + if (ch === ")") { tokens.push({ type: TokenType.RPAREN, value: ch, pos }); i++; continue; } + if (ch === "[") { tokens.push({ type: TokenType.LBRACKET, value: ch, pos }); i++; continue; } + if (ch === "]") { tokens.push({ type: TokenType.RBRACKET, value: ch, pos }); i++; continue; } + // Curly braces as alternative to square brackets (LaTeX-style σ_{cond}) + if (ch === "{") { tokens.push({ type: TokenType.LBRACKET, value: ch, pos }); i++; continue; } + if (ch === "}") { tokens.push({ type: TokenType.RBRACKET, value: ch, pos }); i++; continue; } + // Underscore: skip silently (allows LaTeX-style σ_{cond} — the _ is just decoration) + if (ch === "_") { i++; continue; } + if (ch === ",") { tokens.push({ type: TokenType.COMMA, value: ch, pos }); i++; continue; } + if (ch === ".") { tokens.push({ type: TokenType.DOT, value: ch, pos }); i++; continue; } + if (ch === ";") { tokens.push({ type: TokenType.SEMICOLON, value: ch, pos }); i++; continue; } + if (ch === "\\") { tokens.push({ type: TokenType.MINUS, value: ch, pos }); i++; continue; } + + // Arrow: → or -> + if (ch === "→") { tokens.push({ type: TokenType.ARROW, value: ch, pos }); i++; continue; } + if (ch === "-" && i + 1 < input.length && input[i + 1] === ">") { + tokens.push({ type: TokenType.ARROW, value: "->", pos }); + i += 2; + continue; + } + + // Assignment: <- (must be checked before comparison operators) + if (ch === "<" && i + 1 < input.length && input[i + 1] === "-") { + tokens.push({ type: TokenType.ASSIGN, value: "<-", pos }); i += 2; continue; + } + + // Comparison operators + if (ch === "=" ) { tokens.push({ type: TokenType.EQ, value: "=", pos }); i++; continue; } + if (ch === "<" && i + 1 < input.length && input[i + 1] === ">") { + tokens.push({ type: TokenType.NEQ, value: "<>", pos }); i += 2; continue; + } + if (ch === "!" && i + 1 < input.length && input[i + 1] === "=") { + tokens.push({ type: TokenType.NEQ, value: "!=", pos }); i += 2; continue; + } + if (ch === "<" && i + 1 < input.length && input[i + 1] === "=") { + tokens.push({ type: TokenType.LTE, value: "<=", pos }); i += 2; continue; + } + if (ch === ">" && i + 1 < input.length && input[i + 1] === "=") { + tokens.push({ type: TokenType.GTE, value: ">=", pos }); i += 2; continue; + } + if (ch === "<") { tokens.push({ type: TokenType.LT, value: "<", pos }); i++; continue; } + if (ch === ">") { tokens.push({ type: TokenType.GT, value: ">", pos }); i++; continue; } + + // Minus (as set difference) when not part of -> + if (ch === "-") { tokens.push({ type: TokenType.MINUS, value: "-", pos }); i++; continue; } + + // String literals + if (ch === "'") { + let str = ""; + i++; // skip opening quote + while (i < input.length && input[i] !== "'") { + if (input[i] === "\\" && i + 1 < input.length) { + str += input[i + 1]; + i += 2; + } else { + str += input[i]; + i++; + } + } + if (i >= input.length) throw new RAError(`Unterminated string literal at position ${pos}`); + i++; // skip closing quote + tokens.push({ type: TokenType.STRING, value: str, pos }); + continue; + } + + // Numbers + if (/\d/.test(ch)) { + let num = ""; + while (i < input.length && /[\d.]/.test(input[i])) { + num += input[i]; + i++; + } + tokens.push({ type: TokenType.NUMBER, value: num, pos }); + continue; + } + + // Identifiers and keywords + if (/[a-zA-Z_\u00C0-\u024F]/.test(ch)) { + let ident = ""; + while (i < input.length && /[a-zA-Z0-9_\u00C0-\u024F]/.test(input[i])) { + ident += input[i]; + i++; + } + const lower = ident.toLowerCase(); + if (KEYWORD_OPS[lower]) { + tokens.push({ type: KEYWORD_OPS[lower], value: ident, pos }); + } else { + tokens.push({ type: TokenType.IDENTIFIER, value: ident, pos }); + } + continue; + } + + throw new RAError(`Unexpected character '${ch}' at position ${pos}`); + } + + tokens.push({ type: TokenType.EOF, value: "", pos: i }); + return tokens; +} + +// ─── AST Node Types ───────────────────────────────────────────────────────── + +type RANode = + | TableNode + | SelectionNode + | ProjectionNode + | RenameNode + | GroupNode + | SortNode + | DistinctNode + | CrossProductNode + | NaturalJoinNode + | ThetaJoinNode + | LeftJoinNode + | RightJoinNode + | FullJoinNode + | LeftSemiJoinNode + | RightSemiJoinNode + | AntiJoinNode + | UnionNode + | IntersectNode + | DifferenceNode + | DivisionNode; + +interface TableNode { type: "table"; name: string } +interface SelectionNode { type: "selection"; condition: ConditionNode; relation: RANode } +interface ProjectionNode { type: "projection"; columns: ColumnRef[]; relation: RANode } +interface RenameNode { type: "rename"; mappings: RenameMapping[]; relation: RANode } +interface GroupNode { type: "group"; groupBy: ColumnRef[]; aggregates: AggregateExpr[]; relation: RANode } +interface SortNode { type: "sort"; columns: SortColumn[]; relation: RANode } +interface DistinctNode { type: "distinct"; relation: RANode } +interface CrossProductNode { type: "crossProduct"; left: RANode; right: RANode } +interface NaturalJoinNode { type: "naturalJoin"; left: RANode; right: RANode } +interface ThetaJoinNode { type: "thetaJoin"; condition: ConditionNode; left: RANode; right: RANode } +interface LeftJoinNode { type: "leftJoin"; condition: ConditionNode; left: RANode; right: RANode } +interface RightJoinNode { type: "rightJoin"; condition: ConditionNode; left: RANode; right: RANode } +interface FullJoinNode { type: "fullJoin"; condition: ConditionNode; left: RANode; right: RANode } +interface LeftSemiJoinNode { type: "leftSemiJoin"; left: RANode; right: RANode } +interface RightSemiJoinNode { type: "rightSemiJoin"; left: RANode; right: RANode } +interface AntiJoinNode { type: "antiJoin"; left: RANode; right: RANode } +interface UnionNode { type: "union"; left: RANode; right: RANode } +interface IntersectNode { type: "intersect"; left: RANode; right: RANode } +interface DifferenceNode { type: "difference"; left: RANode; right: RANode } +interface DivisionNode { type: "division"; left: RANode; right: RANode } + +interface ColumnRef { table?: string; column: string } +interface RenameMapping { from: string; to: string } +interface SortColumn { column: ColumnRef; desc: boolean } +interface AggregateExpr { func: string; column: ColumnRef; alias?: string } + +type ConditionNode = + | ComparisonNode + | AndNode + | OrNode + | NotNode; + +interface ComparisonNode { type: "comparison"; left: ValueExpr; op: string; right: ValueExpr } +interface AndNode { type: "and"; left: ConditionNode; right: ConditionNode } +interface OrNode { type: "or"; left: ConditionNode; right: ConditionNode } +interface NotNode { type: "not"; operand: ConditionNode } + +type ValueExpr = + | ColumnRefExpr + | StringLiteral + | NumberLiteral + | FunctionCallExpr; + +interface ColumnRefExpr { type: "columnRef"; ref: ColumnRef } +interface StringLiteral { type: "string"; value: string } +interface NumberLiteral { type: "number"; value: string } +interface FunctionCallExpr { type: "functionCall"; name: string; args: ValueExpr[] } + +// ─── Error Type ───────────────────────────────────────────────────────────── + +export class RAError extends Error { + constructor(message: string) { + super(message); + this.name = "RAError"; + } +} + +// ─── Parser ───────────────────────────────────────────────────────────────── + +interface RAProgram { + assignments: { name: string; expr: RANode }[]; + result: RANode; +} + +class Parser { + private tokens: Token[]; + private pos: number; + + constructor(tokens: Token[]) { + this.tokens = tokens; + this.pos = 0; + } + + private peek(): Token { + return this.tokens[this.pos]; + } + + private advance(): Token { + const token = this.tokens[this.pos]; + this.pos++; + return token; + } + + private expect(type: TokenType): Token { + const token = this.peek(); + if (token.type !== type) { + throw new RAError(`Expected ${type} but got ${token.type} ('${token.value}') at position ${token.pos}`); + } + return this.advance(); + } + + private match(type: TokenType): boolean { + if (this.peek().type === type) { + this.advance(); + return true; + } + return false; + } + + // Grammar (precedence low→high): + // expr = union_expr + // union_expr = intersect_expr (( ∪ | − ) intersect_expr)* + // intersect_expr = join_expr ( ∩ join_expr)* + // join_expr = unary_expr (( × | ⋈ | ⋈[cond] | join[cond] | ÷ | ⟕[cond] | ⟖[cond] | ⟗[cond] | ⋉ | ⋊ | ▷ ) unary_expr)* + // unary_expr = (σ[cond] | π[cols] | ρ[mappings] | γ[...] | τ[...] | δ) unary_expr | primary + // primary = IDENTIFIER | '(' expr ')' + + parse(): RAProgram { + const assignments: { name: string; expr: RANode }[] = []; + + // Skip leading newlines + while (this.peek().type === TokenType.NEWLINE) this.advance(); + + while (this.peek().type !== TokenType.EOF) { + // Try to detect assignment: IDENTIFIER (← | <-) expr + if (this.peek().type === TokenType.IDENTIFIER) { + const saved = this.pos; + const name = this.advance().value; + if (this.peek().type === TokenType.ASSIGN) { + this.advance(); // consume ← / <- + const expr = this.parseUnionExpr(); + assignments.push({ name, expr }); + // Consume statement separator (newline, semicolon, or EOF) + while (this.peek().type === TokenType.SEMICOLON || this.peek().type === TokenType.NEWLINE) { + this.advance(); + } + continue; + } + // Not an assignment — backtrack and parse as expression + this.pos = saved; + } + + // Parse the final result expression + const result = this.parseUnionExpr(); + // Consume trailing separators + while (this.peek().type === TokenType.SEMICOLON || this.peek().type === TokenType.NEWLINE) { + this.advance(); + } + if (this.peek().type !== TokenType.EOF) { + throw new RAError(`Unexpected token '${this.peek().value}' at position ${this.peek().pos}`); + } + return { assignments, result }; + } + + throw new RAError("Empty expression"); + } + + private parseUnionExpr(): RANode { + let left = this.parseIntersectExpr(); + while (this.peek().type === TokenType.UNION || this.peek().type === TokenType.MINUS) { + const op = this.advance(); + const right = this.parseIntersectExpr(); + if (op.type === TokenType.UNION) { + left = { type: "union", left, right }; + } else { + left = { type: "difference", left, right }; + } + } + return left; + } + + private parseIntersectExpr(): RANode { + let left = this.parseJoinExpr(); + while (this.peek().type === TokenType.INTERSECT) { + this.advance(); + const right = this.parseJoinExpr(); + left = { type: "intersect", left, right }; + } + return left; + } + + private parseJoinExpr(): RANode { + let left = this.parseUnaryExpr(); + // eslint-disable-next-line no-constant-condition + while (true) { + const t = this.peek().type; + if (t === TokenType.CROSS) { + this.advance(); + const right = this.parseUnaryExpr(); + left = { type: "crossProduct", left, right }; + } else if (t === TokenType.NATJOIN) { + this.advance(); + // Check if there's a condition: ⋈[cond] + if (this.peek().type === TokenType.LBRACKET) { + const condition = this.parseBracketedCondition(); + const right = this.parseUnaryExpr(); + left = { type: "thetaJoin", condition, left, right }; + } else { + const right = this.parseUnaryExpr(); + left = { type: "naturalJoin", left, right }; + } + } else if (t === TokenType.JOIN) { + this.advance(); + if (this.peek().type === TokenType.LBRACKET) { + const condition = this.parseBracketedCondition(); + const right = this.parseUnaryExpr(); + left = { type: "thetaJoin", condition, left, right }; + } else { + // join without condition = natural join + const right = this.parseUnaryExpr(); + left = { type: "naturalJoin", left, right }; + } + } else if (t === TokenType.DIVIDE) { + this.advance(); + const right = this.parseUnaryExpr(); + left = { type: "division", left, right }; + } else if (t === TokenType.LEFTJOIN) { + this.advance(); + const condition = this.parseBracketedCondition(); + const right = this.parseUnaryExpr(); + left = { type: "leftJoin", condition, left, right }; + } else if (t === TokenType.RIGHTJOIN) { + this.advance(); + const condition = this.parseBracketedCondition(); + const right = this.parseUnaryExpr(); + left = { type: "rightJoin", condition, left, right }; + } else if (t === TokenType.FULLJOIN) { + this.advance(); + const condition = this.parseBracketedCondition(); + const right = this.parseUnaryExpr(); + left = { type: "fullJoin", condition, left, right }; + } else if (t === TokenType.LEFTSEMIJOIN) { + this.advance(); + const right = this.parseUnaryExpr(); + left = { type: "leftSemiJoin", left, right }; + } else if (t === TokenType.RIGHTSEMIJOIN) { + this.advance(); + const right = this.parseUnaryExpr(); + left = { type: "rightSemiJoin", left, right }; + } else if (t === TokenType.ANTIJOIN) { + this.advance(); + const right = this.parseUnaryExpr(); + left = { type: "antiJoin", left, right }; + } else { + break; + } + } + return left; + } + + /** + * Check if the next token starts a subscript (bracket or implicit). + * Returns true if it's `[`, `{`, or any non-`(` token that could begin + * an implicit subscript (i.e., the subscript content before `(`). + */ + private hasSubscript(): boolean { + return this.peek().type === TokenType.LBRACKET || this.peek().type !== TokenType.LPAREN; + } + + /** + * Parse a subscript that may be: + * 1. Bracketed: `[...]` or `{...}` + * 2. Implicit: tokens up to the next unmatched `(` + * + * For implicit subscripts, we temporarily inject LBRACKET/RBRACKET + * around the subscript tokens so the same parsing logic can be reused. + */ + private parseSubscriptCondition(): ConditionNode { + if (this.peek().type === TokenType.LBRACKET) { + return this.parseBracketedCondition(); + } + // Implicit subscript: parse condition tokens until we hit LPAREN + // We parse directly — the condition ends when we encounter LPAREN at depth 0 + const cond = this.parseOrCondition(); + return cond; + } + + private parseSubscriptColumns(): ColumnRef[] { + if (this.peek().type === TokenType.LBRACKET) { + this.expect(TokenType.LBRACKET); + const cols = this.parseColumnList(); + this.expect(TokenType.RBRACKET); + return cols; + } + // Implicit: parse column list until LPAREN + return this.parseColumnList(); + } + + private parseSubscriptRenameMappings(): RenameMapping[] { + if (this.peek().type === TokenType.LBRACKET) { + this.expect(TokenType.LBRACKET); + const mappings = this.parseRenameMappings(); + this.expect(TokenType.RBRACKET); + return mappings; + } + return this.parseRenameMappings(); + } + + private parseSubscriptGroupSpec(): { groupBy: ColumnRef[]; aggregates: AggregateExpr[] } { + if (this.peek().type === TokenType.LBRACKET) { + this.expect(TokenType.LBRACKET); + const spec = this.parseGroupSpec(); + this.expect(TokenType.RBRACKET); + return spec; + } + return this.parseGroupSpec(); + } + + private parseSubscriptSortColumns(): SortColumn[] { + if (this.peek().type === TokenType.LBRACKET) { + this.expect(TokenType.LBRACKET); + const cols = this.parseSortColumns(); + this.expect(TokenType.RBRACKET); + return cols; + } + return this.parseSortColumns(); + } + + private parseUnaryExpr(): RANode { + const t = this.peek().type; + + if (t === TokenType.SIGMA) { + this.advance(); + if (this.hasSubscript()) { + const condition = this.parseSubscriptCondition(); + this.expect(TokenType.LPAREN); + const relation = this.parseUnionExpr(); + this.expect(TokenType.RPAREN); + return { type: "selection", condition, relation }; + } + // σ(R) with no subscript — error + throw new RAError("Selection (σ) requires a condition — use σ[condition](R) or σ condition (R)"); + } + + if (t === TokenType.PI) { + this.advance(); + if (this.hasSubscript()) { + const columns = this.parseSubscriptColumns(); + this.expect(TokenType.LPAREN); + const relation = this.parseUnionExpr(); + this.expect(TokenType.RPAREN); + return { type: "projection", columns, relation }; + } + throw new RAError("Projection (π) requires column list — use π[cols](R) or π cols (R)"); + } + + if (t === TokenType.RHO) { + this.advance(); + if (this.hasSubscript()) { + const mappings = this.parseSubscriptRenameMappings(); + this.expect(TokenType.LPAREN); + const relation = this.parseUnionExpr(); + this.expect(TokenType.RPAREN); + return { type: "rename", mappings, relation }; + } + throw new RAError("Rename (ρ) requires mappings — use ρ[old→new](R) or ρ old→new (R)"); + } + + if (t === TokenType.GAMMA) { + this.advance(); + if (this.hasSubscript()) { + const { groupBy, aggregates } = this.parseSubscriptGroupSpec(); + this.expect(TokenType.LPAREN); + const relation = this.parseUnionExpr(); + this.expect(TokenType.RPAREN); + return { type: "group", groupBy, aggregates, relation }; + } + throw new RAError("Grouping (γ) requires specification — use γ[groupCols; AGG(col)](R)"); + } + + if (t === TokenType.TAU) { + this.advance(); + if (this.hasSubscript()) { + const columns = this.parseSubscriptSortColumns(); + this.expect(TokenType.LPAREN); + const relation = this.parseUnionExpr(); + this.expect(TokenType.RPAREN); + return { type: "sort", columns, relation }; + } + throw new RAError("Sort (τ) requires column list — use τ[col](R) or τ col (R)"); + } + + if (t === TokenType.DELTA) { + this.advance(); + this.expect(TokenType.LPAREN); + const relation = this.parseUnionExpr(); + this.expect(TokenType.RPAREN); + return { type: "distinct", relation }; + } + + return this.parsePrimary(); + } + + private parsePrimary(): RANode { + if (this.peek().type === TokenType.LPAREN) { + this.advance(); + const expr = this.parseUnionExpr(); + this.expect(TokenType.RPAREN); + return expr; + } + + if (this.peek().type === TokenType.IDENTIFIER) { + const name = this.advance().value; + return { type: "table", name }; + } + + throw new RAError(`Expected table name or '(' at position ${this.peek().pos}, got '${this.peek().value}'`); + } + + // ─── Condition parsing ────────────────────────────────────── + + private parseBracketedCondition(): ConditionNode { + this.expect(TokenType.LBRACKET); + const cond = this.parseOrCondition(); + this.expect(TokenType.RBRACKET); + return cond; + } + + private parseOrCondition(): ConditionNode { + let left = this.parseAndCondition(); + while (this.peek().type === TokenType.OR) { + this.advance(); + const right = this.parseAndCondition(); + left = { type: "or", left, right }; + } + return left; + } + + private parseAndCondition(): ConditionNode { + let left = this.parseNotCondition(); + while (this.peek().type === TokenType.AND) { + this.advance(); + const right = this.parseNotCondition(); + left = { type: "and", left, right }; + } + return left; + } + + private parseNotCondition(): ConditionNode { + if (this.peek().type === TokenType.NOT) { + this.advance(); + const operand = this.parseNotCondition(); + return { type: "not", operand }; + } + return this.parseComparisonCondition(); + } + + private parseComparisonCondition(): ConditionNode { + if (this.peek().type === TokenType.LPAREN) { + this.advance(); + const cond = this.parseOrCondition(); + this.expect(TokenType.RPAREN); + return cond; + } + + const left = this.parseValueExpr(); + const opToken = this.peek(); + let op: string; + if (opToken.type === TokenType.EQ) { op = "="; this.advance(); } + else if (opToken.type === TokenType.NEQ) { op = "<>"; this.advance(); } + else if (opToken.type === TokenType.LT) { op = "<"; this.advance(); } + else if (opToken.type === TokenType.GT) { op = ">"; this.advance(); } + else if (opToken.type === TokenType.LTE) { op = "<="; this.advance(); } + else if (opToken.type === TokenType.GTE) { op = ">="; this.advance(); } + else { + throw new RAError(`Expected comparison operator at position ${opToken.pos}, got '${opToken.value}'`); + } + const right = this.parseValueExpr(); + return { type: "comparison", left, op, right }; + } + + private parseValueExpr(): ValueExpr { + if (this.peek().type === TokenType.STRING) { + const token = this.advance(); + return { type: "string", value: token.value }; + } + if (this.peek().type === TokenType.NUMBER) { + const token = this.advance(); + return { type: "number", value: token.value }; + } + if (this.peek().type === TokenType.IDENTIFIER) { + const name = this.advance().value; + + // Check if it's a function call: NAME(args) + if (this.peek().type === TokenType.LPAREN) { + this.advance(); + const args: ValueExpr[] = []; + if (this.peek().type !== TokenType.RPAREN) { + args.push(this.parseValueExpr()); + while (this.match(TokenType.COMMA)) { + args.push(this.parseValueExpr()); + } + } + this.expect(TokenType.RPAREN); + return { type: "functionCall", name, args }; + } + + // Check for table.column + if (this.peek().type === TokenType.DOT) { + this.advance(); + const col = this.expect(TokenType.IDENTIFIER).value; + return { type: "columnRef", ref: { table: name, column: col } }; + } + return { type: "columnRef", ref: { column: name } }; + } + throw new RAError(`Expected value expression at position ${this.peek().pos}, got '${this.peek().value}'`); + } + + // ─── Column list parsing ──────────────────────────────────── + + private parseColumnList(): ColumnRef[] { + const columns: ColumnRef[] = []; + columns.push(this.parseColumnRef()); + while (this.match(TokenType.COMMA)) { + columns.push(this.parseColumnRef()); + } + return columns; + } + + private parseColumnRef(): ColumnRef { + const name = this.expect(TokenType.IDENTIFIER).value; + if (this.peek().type === TokenType.DOT) { + this.advance(); + const col = this.expect(TokenType.IDENTIFIER).value; + return { table: name, column: col }; + } + return { column: name }; + } + + // ─── Rename mappings ─────────────────────────────────────── + + private parseRenameMappings(): RenameMapping[] { + const mappings: RenameMapping[] = []; + mappings.push(this.parseRenameMapping()); + while (this.match(TokenType.COMMA)) { + mappings.push(this.parseRenameMapping()); + } + return mappings; + } + + private parseRenameMapping(): RenameMapping { + const from = this.expect(TokenType.IDENTIFIER).value; + this.expect(TokenType.ARROW); + const to = this.expect(TokenType.IDENTIFIER).value; + return { from, to }; + } + + // ─── Group/aggregate spec ────────────────────────────────── + // Format: γ[groupCol1, groupCol2; COUNT(col) AS alias, SUM(col2)] + // The semicolon separates group-by columns from aggregates + + private parseGroupSpec(): { groupBy: ColumnRef[]; aggregates: AggregateExpr[] } { + const groupBy: ColumnRef[] = []; + const aggregates: AggregateExpr[] = []; + + // Parse group-by columns (before semicolon) + if (this.peek().type === TokenType.IDENTIFIER) { + // Check if this is a group-by column or an aggregate function + const saved = this.pos; + const name = this.advance().value; + if (this.peek().type === TokenType.LPAREN) { + // It's an aggregate function, backtrack + this.pos = saved; + } else if (this.peek().type === TokenType.DOT) { + // It's a table.column group-by + this.pos = saved; + groupBy.push(this.parseColumnRef()); + while (this.peek().type === TokenType.COMMA) { + this.advance(); + // Check if next item is aggregate or column + const saved2 = this.pos; + this.expect(TokenType.IDENTIFIER); + if (this.peek().type === TokenType.LPAREN) { + this.pos = saved2; + break; + } + this.pos = saved2; + groupBy.push(this.parseColumnRef()); + } + } else if (this.peek().type === TokenType.SEMICOLON) { + // Single group-by column followed by semicolon + groupBy.push({ column: name }); + this.advance(); // consume semicolon + } else if (this.peek().type === TokenType.COMMA) { + groupBy.push({ column: name }); + while (this.peek().type === TokenType.COMMA) { + this.advance(); + if (this.peek().type === TokenType.IDENTIFIER) { + const saved3 = this.pos; + const nextName = this.advance().value; + if (this.peek().type === TokenType.LPAREN) { + this.pos = saved3; + break; + } + if (this.peek().type === TokenType.SEMICOLON) { + groupBy.push({ column: nextName }); + this.advance(); + break; + } + this.pos = saved3; + groupBy.push(this.parseColumnRef()); + } + } + } else { + groupBy.push({ column: name }); + } + } + + // Handle semicolon separator if not yet consumed + if (this.peek().type === TokenType.SEMICOLON) { + this.advance(); + } + + // Parse aggregates + while (this.peek().type === TokenType.IDENTIFIER) { + const funcName = this.advance().value.toUpperCase(); + this.expect(TokenType.LPAREN); + const col = this.parseColumnRef(); + this.expect(TokenType.RPAREN); + let alias: string | undefined; + // Check for AS alias + if (this.peek().type === TokenType.IDENTIFIER && this.peek().value.toUpperCase() === "AS") { + this.advance(); + alias = this.expect(TokenType.IDENTIFIER).value; + } + aggregates.push({ func: funcName, column: col, alias }); + if (!this.match(TokenType.COMMA)) break; + } + + return { groupBy, aggregates }; + } + + // ─── Sort columns ────────────────────────────────────────── + // Format: τ[col1, col2 DESC] + + private parseSortColumns(): SortColumn[] { + const columns: SortColumn[] = []; + const col = this.parseColumnRef(); + let desc = false; + if (this.peek().type === TokenType.IDENTIFIER && this.peek().value.toUpperCase() === "DESC") { + this.advance(); + desc = true; + } else if (this.peek().type === TokenType.IDENTIFIER && this.peek().value.toUpperCase() === "ASC") { + this.advance(); + } + columns.push({ column: col, desc }); + + while (this.match(TokenType.COMMA)) { + const c = this.parseColumnRef(); + let d = false; + if (this.peek().type === TokenType.IDENTIFIER && this.peek().value.toUpperCase() === "DESC") { + this.advance(); + d = true; + } else if (this.peek().type === TokenType.IDENTIFIER && this.peek().value.toUpperCase() === "ASC") { + this.advance(); + } + columns.push({ column: c, desc: d }); + } + + return columns; + } +} + +// ─── SQL Code Generator ───────────────────────────────────────────────────── + +function conditionToSQL(cond: ConditionNode): string { + switch (cond.type) { + case "comparison": + return `${valueExprToSQL(cond.left)} ${cond.op} ${valueExprToSQL(cond.right)}`; + case "and": + return `(${conditionToSQL(cond.left)} AND ${conditionToSQL(cond.right)})`; + case "or": + return `(${conditionToSQL(cond.left)} OR ${conditionToSQL(cond.right)})`; + case "not": + return `NOT (${conditionToSQL(cond.operand)})`; + } +} + +function valueExprToSQL(expr: ValueExpr): string { + switch (expr.type) { + case "columnRef": + return columnRefToSQL(expr.ref); + case "string": + return `'${expr.value.replace(/'/g, "''")}'`; + case "number": + return expr.value; + case "functionCall": + return `${expr.name}(${expr.args.map(valueExprToSQL).join(", ")})`; + } +} + +function columnRefToSQL(ref: ColumnRef): string { + if (ref.table) { + return `${ref.table}.${ref.column}`; + } + return ref.column; +} + +let subqueryCounter = 0; + +/** + * Interface for a minimal database handle used to resolve column names. + * Compatible with sql.js Database. + */ +interface DatabaseHandle { + exec(sql: string): { columns: string[]; values: unknown[][] }[]; +} + +/** + * Resolve the column names produced by a SQL expression using the database. + * Executes a LIMIT 0 query to get column metadata without fetching data. + */ +function resolveColumns(sql: string, db: DatabaseHandle): string[] { + const res = db.exec(`SELECT * FROM (${sql}) LIMIT 0`); + if (res.length === 0) return []; + return res[0].columns; +} + +function nodeToSQL(node: RANode, db?: DatabaseHandle): string { + switch (node.type) { + case "table": + return node.name; + + case "selection": + return `SELECT * FROM (${nodeToSQL(node.relation, db)}) WHERE ${conditionToSQL(node.condition)}`; + + case "projection": + return `SELECT ${node.columns.map(columnRefToSQL).join(", ")} FROM (${nodeToSQL(node.relation, db)})`; + + case "rename": { + const inner = nodeToSQL(node.relation, db); + // Simple case: rename columns + const colMappings = node.mappings.map(m => `${m.from} AS ${m.to}`).join(", "); + return `SELECT ${colMappings} FROM (${inner})`; + } + + case "group": { + const inner = nodeToSQL(node.relation, db); + const groupCols = node.groupBy.map(columnRefToSQL); + const aggExprs = node.aggregates.map(a => { + const expr = `${a.func}(${columnRefToSQL(a.column)})`; + return a.alias ? `${expr} AS ${a.alias}` : expr; + }); + const selectParts = [...groupCols, ...aggExprs].join(", "); + const groupByClause = groupCols.length > 0 ? ` GROUP BY ${groupCols.join(", ")}` : ""; + return `SELECT ${selectParts} FROM (${inner})${groupByClause}`; + } + + case "sort": { + const inner = nodeToSQL(node.relation, db); + const orderParts = node.columns.map(c => + `${columnRefToSQL(c.column)}${c.desc ? " DESC" : ""}` + ).join(", "); + return `SELECT * FROM (${inner}) ORDER BY ${orderParts}`; + } + + case "distinct": + return `SELECT DISTINCT * FROM (${nodeToSQL(node.relation, db)})`; + + case "crossProduct": + return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} CROSS JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++}`; + + case "naturalJoin": { + const leftSQL = nodeToSQL(node.left, db); + const rightSQL = nodeToSQL(node.right, db); + // Validate that there are common columns when a database is available + if (db) { + const leftCols = resolveColumns(leftSQL, db); + const rightCols = resolveColumns(rightSQL, db); + const common = leftCols.filter(c => rightCols.includes(c)); + if (common.length === 0) { + throw new RAError( + "Natural join has no common columns between the two relations. " + + "Left columns: [" + leftCols.join(", ") + "], Right columns: [" + rightCols.join(", ") + "]. " + + "Use a cross product (×) if a cartesian product is intended, or a theta join (⋈[condition]) to specify the join condition." + ); + } + } + return `SELECT * FROM (${leftSQL}) AS _ra${subqueryCounter++} NATURAL JOIN (${rightSQL}) AS _ra${subqueryCounter++}`; + } + + case "thetaJoin": + return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`; + + case "leftJoin": + return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} LEFT JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`; + + case "rightJoin": + return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} RIGHT JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`; + + case "fullJoin": + return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} FULL OUTER JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`; + + case "leftSemiJoin": { + const lAlias = `_ra${subqueryCounter++}`; + const rAlias = `_ra${subqueryCounter++}`; + return `SELECT ${lAlias}.* FROM (${nodeToSQL(node.left, db)}) AS ${lAlias} WHERE EXISTS (SELECT 1 FROM (${nodeToSQL(node.right, db)}) AS ${rAlias})`; + } + + case "rightSemiJoin": { + const lAlias = `_ra${subqueryCounter++}`; + const rAlias = `_ra${subqueryCounter++}`; + return `SELECT ${rAlias}.* FROM (${nodeToSQL(node.right, db)}) AS ${rAlias} WHERE EXISTS (SELECT 1 FROM (${nodeToSQL(node.left, db)}) AS ${lAlias})`; + } + + case "antiJoin": { + const lAlias = `_ra${subqueryCounter++}`; + const rAlias = `_ra${subqueryCounter++}`; + return `SELECT ${lAlias}.* FROM (${nodeToSQL(node.left, db)}) AS ${lAlias} WHERE NOT EXISTS (SELECT 1 FROM (${nodeToSQL(node.right, db)}) AS ${rAlias})`; + } + + case "union": + return `${nodeToSQL(node.left, db)} UNION ${nodeToSQL(node.right, db)}`; + + case "intersect": + return `${nodeToSQL(node.left, db)} INTERSECT ${nodeToSQL(node.right, db)}`; + + case "difference": + return `${nodeToSQL(node.left, db)} EXCEPT ${nodeToSQL(node.right, db)}`; + + case "division": { + const lAlias = `_ra${subqueryCounter++}`; + const rAlias = `_ra${subqueryCounter++}`; + const crossAlias = `_ra${subqueryCounter++}`; + const innerAlias = `_ra${subqueryCounter++}`; + const leftSQL = nodeToSQL(node.left, db); + const rightSQL = nodeToSQL(node.right, db); + return `SELECT * FROM (SELECT DISTINCT * FROM (${leftSQL}) AS ${lAlias}) AS ${innerAlias} WHERE NOT EXISTS (SELECT * FROM (${rightSQL}) AS ${rAlias} WHERE NOT EXISTS (SELECT * FROM (${leftSQL}) AS ${crossAlias}))`; + } + } +} + +function programToSQL(program: RAProgram, db?: DatabaseHandle): string { + if (program.assignments.length === 0) { + return nodeToSQL(program.result, db); + } + + // Use CTEs (WITH clauses) for assignments + const ctes = program.assignments.map(a => { + const sql = nodeToSQL(a.expr, db); + // Wrap non-table expressions in SELECT * FROM (...) for CTE compatibility + const wrappedSQL = /^\w+$/.test(sql) ? `SELECT * FROM ${sql}` : sql; + return `${a.name} AS (${wrappedSQL})`; + }); + + const resultSQL = nodeToSQL(program.result, db); + // Wrap bare table reference in SELECT for the final expression + const wrappedResult = /^\w+$/.test(resultSQL) ? `SELECT * FROM ${resultSQL}` : resultSQL; + + return `WITH ${ctes.join(", ")} ${wrappedResult}`; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Parse a relational algebra expression and transpile it to SQL. + * + * Supported syntax: + * + * **Unary operators** (prefix, with condition/columns in brackets): + * - `σ[condition](R)` or `sigma[condition](R)` — Selection (WHERE) + * - `π[col1, col2](R)` or `pi[col1, col2](R)` — Projection (SELECT) + * - `ρ[old→new](R)` or `rho[old->new](R)` — Rename + * - `γ[groupCol; COUNT(col) AS alias](R)` or `gamma[...]` — Grouping/Aggregation + * - `τ[col DESC](R)` or `tau[col](R)` — Sorting (ORDER BY) + * - `δ(R)` or `delta(R)` — Duplicate elimination (DISTINCT) + * + * **Binary operators** (infix): + * - `R × S` or `R cross S` — Cross product + * - `R ⋈ S` or `R natjoin S` — Natural join + * - `R ⋈[cond] S` or `R join[cond] S` — Theta join + * - `R ⟕[cond] S` or `R leftjoin[cond] S` — Left outer join + * - `R ⟖[cond] S` or `R rightjoin[cond] S` — Right outer join + * - `R ⟗[cond] S` or `R fulljoin[cond] S` — Full outer join + * - `R ⋉ S` or `R leftsemijoin S` — Left semi-join + * - `R ⋊ S` or `R rightsemijoin S` — Right semi-join + * - `R ▷ S` or `R antijoin S` — Anti-join + * - `R ∪ S` or `R union S` — Union + * - `R ∩ S` or `R intersect S` — Intersection + * - `R − S` or `R minus S` — Set difference + * - `R ÷ S` or `R divide S` — Division + * + * **Conditions** support: `=`, `<>`, `!=`, `<`, `>`, `<=`, `>=`, `AND`, `OR`, `NOT` + * + * @param input The relational algebra expression + * @param db Optional database handle for validating natural joins (checks for common columns) + * @returns The equivalent SQL query string + * @throws RAError if the expression cannot be parsed or a natural join has no common columns + */ +export function raToSQL(input: string, db?: DatabaseHandle): string { + subqueryCounter = 0; + const tokens = tokenize(input); + const parser = new Parser(tokens); + const program = parser.parse(); + return programToSQL(program, db); +} + +// Re-export for potential future use (e.g., AST visualization) +export type { RANode, ConditionNode, ColumnRef }; From 631a6be5e642c65456742ad567ba4259bc435ad3 Mon Sep 17 00:00:00 2001 From: Edwin <60476129+Edwinexd@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:34:55 +0000 Subject: [PATCH 2/7] Add RA editor UI: highlighter, reference dialog, mode switching --- src/App.tsx | 142 +++++----- src/i18n/ui-strings.ts | 63 ++++- src/ra-engine/RAReference.tsx | 172 ++++++++++++ src/ra-engine/raHighlight.test.ts | 82 ++++++ src/ra-engine/raHighlight.ts | 344 ++++++++++++++++++++++++ src/ra-engine/relationalAlgebra.test.ts | 92 +++++-- src/ra-engine/relationalAlgebra.ts | 81 +++--- 7 files changed, 857 insertions(+), 119 deletions(-) create mode 100644 src/ra-engine/RAReference.tsx create mode 100644 src/ra-engine/raHighlight.test.ts create mode 100644 src/ra-engine/raHighlight.ts diff --git a/src/App.tsx b/src/App.tsx index ab322c2..d59497a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,8 @@ import { format } from "sql-formatter"; import initSqlJs from "sql.js"; import ViewsTable, { View } from "./ViewsTable"; import { raToSQL, RAError } from "./ra-engine/relationalAlgebra"; +import RAReference from "./ra-engine/RAReference"; +import { highlightRA } from "./ra-engine/raHighlight"; import { Info, Settings, XCircle, CheckCircle2, Eye, EyeOff } from "lucide-react"; import ChangelogDialog from "./ChangelogDialog"; @@ -56,6 +58,11 @@ import { useLanguage, langKey, getUrlParam, setUrlParam } from "./i18n/context"; import LanguageSelector from "./LanguageSelector"; import { getQuestion } from "./QuestionSelector"; +/** Storage key for a question's query, namespaced by editor mode */ +function queryKey(questionId: number, mode: "sql" | "ra"): string { + return mode === "ra" ? `ra-questionId-${questionId}` : `questionId-${questionId}`; +} + function App() { const { lang, t, questions, dbArrayBuffer, defaultQuery } = useLanguage(); const [question, setQuestion] = useState(); @@ -80,14 +87,7 @@ function App() { const [exportingStatus, setExportingStatus] = useState(0); const [loadedQuestionCorrect, setLoadedQuestionCorrect] = useState(false); const [editorMode, setEditorMode] = useState<"sql" | "ra">("sql"); - const [generatedSQL, setGeneratedSQL] = useState(null); - const switchEditorMode = useCallback((mode: "sql" | "ra") => { - setEditorMode(mode); - setGeneratedSQL(null); - setError(null); - resetResult(); - }, [resetResult]); const exportRendererRef = useRef(null); const editorRef = useRef(null); @@ -154,6 +154,27 @@ function App() { setQueryedView(null); }, []); + const switchEditorMode = useCallback((mode: "sql" | "ra") => { + // Save current query before switching + if (question && query !== undefined) { + const currentKey = queryKey(question.id, editorMode); + if (query === defaultQuery || query === "") { + localStorage.removeItem(langKey(lang, currentKey)); + } else { + localStorage.setItem(langKey(lang, currentKey), query); + } + } + setEditorMode(mode); + + setError(null); + resetResult(); + // Load query for the new mode + if (question) { + const newKey = queryKey(question.id, mode); + setQuery(localStorage.getItem(langKey(lang, newKey)) || (mode === "ra" ? "" : defaultQuery)); + } + }, [resetResult, question, query, editorMode, lang, defaultQuery]); + const initDb = useCallback(async () => { if (!dbArrayBuffer) return; resetResult(); @@ -200,7 +221,7 @@ function App() { const resolved = getQuestion(q.id, questions); if (resolved) { setQuestion(resolved); - setQuery(localStorage.getItem(langKey(lang, "questionId-" + resolved.id)) || defaultQuery); + setQuery(localStorage.getItem(langKey(lang, queryKey(resolved.id, editorMode))) || (editorMode === "ra" ? "" : defaultQuery)); } } } @@ -232,15 +253,18 @@ function App() { } let wq = JSON.parse(localStorage.getItem(langKey(lang, "writtenQuestions")) || "[]"); const initialLength = wq.length; + const storageKey = queryKey(question.id, editorMode); if (query === defaultQuery || query === "") { - localStorage.removeItem(langKey(lang, "questionId-" + question.id)); - // remove from writtenQuestions if it exists there as well - const filtered = wq.filter((id: number) => id !== question.id); - wq = filtered; + localStorage.removeItem(langKey(lang, storageKey)); + // remove from writtenQuestions if it exists there as well (only for SQL mode) + if (editorMode === "sql") { + const filtered = wq.filter((id: number) => id !== question.id); + wq = filtered; + } } else { - localStorage.setItem(langKey(lang, "questionId-" + question.id), query); - // ensure that questionid is in localstorage writtenQuestions - if (!wq.includes(question.id)) { + localStorage.setItem(langKey(lang, storageKey), query); + // ensure that questionid is in localstorage writtenQuestions (only for SQL mode) + if (editorMode === "sql" && !wq.includes(question.id)) { wq.push(question.id); } } @@ -336,7 +360,6 @@ function App() { if (editorMode === "ra") { try { sqlToRun = raToSQL(query, database); - setGeneratedSQL(sqlToRun); } catch (e) { if (e instanceof RAError) { setError(t("raParseError", { message: e.message })); @@ -346,8 +369,6 @@ function App() { } return; } - } else { - setGeneratedSQL(null); } const res = database.exec(sqlToRun); setIsViewResult(false); @@ -448,7 +469,8 @@ function App() { setIsCorrect(true); setMatchedResult(matchedAlt ?? question.evaluable_result); - localStorage.setItem(langKey(lang, `correctQuestionId-${question.id}`), query); + const correctKey = editorMode === "ra" ? `ra-correctQuestionId-${question.id}` : `correctQuestionId-${question.id}`; + localStorage.setItem(langKey(lang, correctKey), query); setCorrectQueryMismatch(false); setLoadedQuestionCorrect(true); @@ -462,20 +484,22 @@ function App() { // Save query based on question const loadQuery = useCallback((_oldQuestion: Question | undefined, newQuestion: Question) => { - setQuery(localStorage.getItem(langKey(lang, "questionId-" + newQuestion.id)) || defaultQuery); + const key = queryKey(newQuestion.id, editorMode); + setQuery(localStorage.getItem(langKey(lang, key)) || (editorMode === "ra" ? "" : defaultQuery)); // This prevents user from ctrl-z'ing to a different question if (editorRef.current) { editorRef.current!.session = {history: { stack: [], offset: 0 }}; } - }, [setQuery, lang, defaultQuery]); + }, [setQuery, lang, defaultQuery, editorMode]); // Update mismatch & loadedQuestionCorrect flags when query is changed useEffect(() => { - if (!database || !question || query === undefined || editorMode === "ra") { + if (!database || !question || query === undefined) { return; } - const correctQuery = localStorage.getItem(langKey(lang, `correctQuestionId-${question.id}`)); + const correctKey = editorMode === "ra" ? `ra-correctQuestionId-${question.id}` : `correctQuestionId-${question.id}`; + const correctQuery = localStorage.getItem(langKey(lang, correctKey)); if (!correctQuery) { setCorrectQueryMismatch(false); setLoadedQuestionCorrect(false); @@ -484,9 +508,25 @@ function App() { setLoadedQuestionCorrect(true); - let currentQuery = ""; - try { - currentQuery = format(query + (query.endsWith(";") ? "" : ";"), { + if (editorMode === "ra") { + // For RA, compare raw text (trimmed) + setCorrectQueryMismatch(query.trim() !== correctQuery.trim()); + } else { + let currentQuery = ""; + try { + currentQuery = format(query + (query.endsWith(";") ? "" : ";"), { + language: "sqlite", + tabWidth: 2, + useTabs: false, + keywordCase: "upper", + dataTypeCase: "upper", + functionCase: "upper", + }); + } catch { + setCorrectQueryMismatch(true); + return; + } + const correctQueryFormatted = format(correctQuery + (correctQuery?.endsWith(";") ? "" : ";"), { language: "sqlite", tabWidth: 2, useTabs: false, @@ -494,20 +534,9 @@ function App() { dataTypeCase: "upper", functionCase: "upper", }); - } catch { - setCorrectQueryMismatch(true); - return; + setCorrectQueryMismatch(currentQuery !== correctQueryFormatted); } - const correctQueryFormatted = format(correctQuery + (correctQuery?.endsWith(";") ? "" : ";"), { - language: "sqlite", - tabWidth: 2, - useTabs: false, - keywordCase: "upper", - dataTypeCase: "upper", - functionCase: "upper", - }); - setCorrectQueryMismatch(currentQuery !== correctQueryFormatted); - }, [database, question, query, lang]); + }, [database, question, query, lang, editorMode]); const exportData = useCallback((options?: { include?: number[]}) => { if (!database) { @@ -861,16 +890,17 @@ function App() {
{t("query")} -
+
+ / @@ -902,10 +932,10 @@ function App() { null} - highlight={code => highlight(code, languages.sql)} + highlight={code => editorMode === "ra" ? highlightRA(code) : highlight(code, languages.sql)} padding={10} tabSize={2} className="font-mono text-base w-full dark:bg-slate-800 bg-slate-100 min-h-32 rounded-md" @@ -917,7 +947,7 @@ function App() { itemID="editor" value={query} onValueChange={code => setQuery(code)} - highlight={code => highlight(code, languages.sql)} + highlight={code => editorMode === "ra" ? highlightRA(code) : highlight(code, languages.sql)} padding={10} tabSize={2} className="font-mono text-base w-full dark:bg-slate-800 bg-slate-100 border dark:border-slate-600 border-gray-300 min-h-32 rounded-md" @@ -942,7 +972,10 @@ function App() { {t("queryMismatch")}
)} - {t("sqlReference")} + {editorMode === "ra" + ? + : {t("sqlReference")} + }
{/* Right side - Buttons */} @@ -952,7 +985,8 @@ function App() { variant="outline" onClick={() => { if (!question) return; - setQuery(localStorage.getItem(langKey(lang, `correctQuestionId-${question.id}`)) || defaultQuery); + const correctKey = editorMode === "ra" ? `ra-correctQuestionId-${question.id}` : `correctQuestionId-${question.id}`; + setQuery(localStorage.getItem(langKey(lang, correctKey)) || (editorMode === "ra" ? "" : defaultQuery)); }} className="border-yellow-500 text-yellow-600 dark:text-yellow-400 hover:bg-yellow-50 dark:hover:bg-yellow-900/20" > @@ -988,20 +1022,6 @@ function App() {
- {editorMode === "ra" && generatedSQL && ( -
- {t("generatedSQL")} - { try { return format(generatedSQL, { language: "sqlite", tabWidth: 2, useTabs: false, keywordCase: "upper", dataTypeCase: "upper", functionCase: "upper" }); } catch { return generatedSQL; } })()} - onValueChange={() => null} - highlight={code => highlight(code, languages.sql)} - padding={10} - tabSize={2} - className="font-mono text-sm w-full dark:bg-slate-800/50 bg-slate-50 border dark:border-slate-700 border-gray-200 rounded-md mt-1" - /> -
- )} exportData({include})} ref={exportModalRef} /> > = { modeRA: "Relationsalgebra", generatedSQL: "Genererad SQL", raParseError: "Relationsalgebra-fel: {{message}}", - raPlaceholder: "-- Skriv ett relationsalgebrauttryck\n-- Exempel: π[namn](σ[ålder > 20](Person))", + raPlaceholder: "-- Skriv ett relationsalgebrauttryck\n-- Klicka på 'RA-referens' för syntax", + raReference: "RA-referens", + raUnaryOps: "Unära operatorer", + raBinaryOps: "Binära operatorer", + raColOp: "Operator", + raColSyntax: "Syntax", + raColDesc: "Beskrivning", + raDescSelection: "Selektion — filtrera rader", + raDescProjection: "Projektion — välj kolumner", + raDescRename: "Namnbyte — byt namn på kolumner", + raDescGroup: "Gruppering med aggregat", + raDescSort: "Sortering", + raDescDistinct: "Ta bort dubbletter", + raDescCross: "Korsprodukt", + raDescNatJoin: "Naturlig join", + raDescThetaJoin: "Theta-join (villkorlig)", + raDescUnion: "Union", + raDescIntersect: "Snitt", + raDescMinus: "Differens", + raDescDivide: "Division", + raNotation: "Notationsalternativ", + raNoteBrackets: "— hakparenteser", + raNoteCurly: "— klammerparenteser", + raNoteLaTeX: "— LaTeX-stil", + raNoteImplicit: "— utan parenteser", + raNoteChain: "— kedjning utan ()", + raAssignment: "Tilldelning", + raNoteAssignment: "Sista tilldelningen returneras automatiskt", + raConditions: "Villkor", + raNoteComparison: "Jämförelseoperatorer: = <> < > <= >=", }, en: { @@ -156,14 +185,40 @@ export const uiStrings: Record> = { conflicts: "conflicts", languageMismatchWarning: "Warning: This save file was created with a different language ({{fileLang}}). Importing may cause issues.", viewLabel: "View", -<<<<<<< HEAD changelog: "Changelog", -======= modeSQL: "SQL", modeRA: "Relational Algebra", generatedSQL: "Generated SQL", raParseError: "Relational algebra error: {{message}}", raPlaceholder: "-- Write a relational algebra expression\n-- Example: π[name](σ[age > 20](Person))", ->>>>>>> ae97058 (Add relational algebra mode with RA-to-SQL transpiler) + raReference: "RA Reference", + raUnaryOps: "Unary Operators", + raBinaryOps: "Binary Operators", + raColOp: "Operator", + raColSyntax: "Syntax", + raColDesc: "Description", + raDescSelection: "Selection — filter rows", + raDescProjection: "Projection — pick columns", + raDescRename: "Rename columns", + raDescGroup: "Grouping with aggregates", + raDescSort: "Sort", + raDescDistinct: "Remove duplicates", + raDescCross: "Cross product", + raDescNatJoin: "Natural join", + raDescThetaJoin: "Theta join (conditional)", + raDescUnion: "Union", + raDescIntersect: "Intersection", + raDescMinus: "Difference", + raDescDivide: "Division", + raNotation: "Notation Styles", + raNoteBrackets: "— square brackets", + raNoteCurly: "— curly braces", + raNoteLaTeX: "— LaTeX style", + raNoteImplicit: "— no brackets", + raNoteChain: "— chaining without ()", + raAssignment: "Assignment", + raNoteAssignment: "Last assignment is returned automatically", + raConditions: "Conditions", + raNoteComparison: "Comparison operators: = <> < > <= >=", }, }; diff --git a/src/ra-engine/RAReference.tsx b/src/ra-engine/RAReference.tsx new file mode 100644 index 0000000..908b540 --- /dev/null +++ b/src/ra-engine/RAReference.tsx @@ -0,0 +1,172 @@ +/* +Relational Algebra engine for sql-validator +Copyright (C) 2026 E.SU. IT AB (Org.no 559484-0505) and Edwin Sundberg + +Licensed under the Business Source License 1.1 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License in the LICENSE.md file in this repository. +*/ + +import { useState } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { X } from "lucide-react"; +import { useLanguage } from "../i18n/context"; + +const RAReference = () => { + const { t } = useLanguage(); + const [open, setOpen] = useState(false); + + return ( + <> + + + +
+

{t("raReference")}

+ +
+
+ {/* Unary Operators */} +
+

{t("raUnaryOps")}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{t("raColOp")}{t("raColSyntax")}{t("raColDesc")}
σ / sigmaσ[age > 20](R){t("raDescSelection")}
π / piπ[name, city](R){t("raDescProjection")}
ρ / rhoρ[old→new](R){t("raDescRename")}
γ / gammaγ[city; COUNT(id)](R){t("raDescGroup")}
τ / tauτ[name](R){t("raDescSort")}
δ / deltaδ(R){t("raDescDistinct")}
+
+ + {/* Binary Operators */} +
+

{t("raBinaryOps")}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{t("raColOp")}{t("raColSyntax")}{t("raColDesc")}
× / crossR × S{t("raDescCross")}
⋈ / natjoinR ⋈ S{t("raDescNatJoin")}
⋈ / joinR ⋈[R.id = S.id] S{t("raDescThetaJoin")}
∪ / unionR ∪ S{t("raDescUnion")}
∩ / intersectR ∩ S{t("raDescIntersect")}
− / minusR − S{t("raDescMinus")}
÷ / divideR ÷ S{t("raDescDivide")}
+
+ + {/* Notation */} +
+

{t("raNotation")}

+
+

σ[cond](R) {t("raNoteBrackets")}

+

σ{"{cond}"}(R) {t("raNoteCurly")}

+

σ{"_{cond}"}(R) {t("raNoteLaTeX")}

+

σ cond (R) {t("raNoteImplicit")}

+

π[cols] σ[cond] R {t("raNoteChain")}

+
+
+ + {/* Assignment */} +
+

{t("raAssignment")}

+
+

A <- σ[age > 20](Person)

+

B <- π[name](A)

+

{t("raNoteAssignment")}

+
+
+ + {/* Conditions */} +
+

{t("raConditions")}

+
+

age > 20 AND city = 'York'

+

age > 20 OR age < 10

+

NOT active = 1

+

{t("raNoteComparison")}

+
+
+
+
+
+ + ); +}; + +export default RAReference; diff --git a/src/ra-engine/raHighlight.test.ts b/src/ra-engine/raHighlight.test.ts new file mode 100644 index 0000000..5585296 --- /dev/null +++ b/src/ra-engine/raHighlight.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { highlightRA } from "./raHighlight"; + +describe("RA highlighter", () => { + it("should highlight σ operator in purple", () => { + const html = highlightRA("σ[age > 20](Person)"); + expect(html).toContain("color: #7c3aed"); // operator color + expect(html).toContain("σ"); + }); + + it("should render brackets faintly", () => { + const html = highlightRA("σ[age > 20](Person)"); + expect(html).toContain("opacity: 0.5"); // faint brackets + }); + + it("should render subscript content in light purple", () => { + const html = highlightRA("π[name](Person)"); + expect(html).toContain("color: #c084fc"); // subscript styling + }); + + it("should highlight string literals in green", () => { + const html = highlightRA("σ[name = 'Alice'](Person)"); + expect(html).toContain("color: #059669"); // string color + expect(html).toContain("Alice"); + }); + + it("should highlight numbers in amber", () => { + const html = highlightRA("σ[age > 20](Person)"); + expect(html).toContain("color: #d97706"); // number color + }); + + it("should highlight AND/OR in blue", () => { + const html = highlightRA("σ[a > 1 and b < 2](T)"); + expect(html).toContain("color: #2563eb"); // logic color + expect(html).toContain("and"); + }); + + it("should highlight comments in gray italic", () => { + const html = highlightRA("-- this is a comment\nPerson"); + expect(html).toContain("font-style: italic"); + expect(html).toContain("this is a comment"); + }); + + it("should render <- with assignment styling", () => { + const html = highlightRA("A <- Person"); + expect(html).toContain("<-"); + expect(html).toContain("font-weight: bold"); + }); + + it("should render -> with operator styling", () => { + const html = highlightRA("ρ[name->fullName](Person)"); + expect(html).toContain("->"); + }); + + it("should handle LaTeX-style _{} notation", () => { + const html = highlightRA("σ_{age > 20}(Person)"); + expect(html).toContain("opacity: 0.5"); // faint brackets + expect(html).toContain("color: #c084fc"); // subscript content + }); + + it("should highlight keyword operators", () => { + const html = highlightRA("sigma[age > 20](Person)"); + expect(html).toContain("color: #7c3aed"); // operator color + expect(html).toContain("sigma"); + }); + + it("should highlight binary keyword operators", () => { + const html = highlightRA("A cross B"); + expect(html).toContain("color: #7c3aed"); + expect(html).toContain("cross"); + }); + + it("should preserve newlines", () => { + const html = highlightRA("A <- Person\nA"); + expect(html).toContain("\n"); + }); + + it("should handle implicit subscripts with whitespace", () => { + const html = highlightRA("σ age > 20 (Person)"); + expect(html).toContain("color: #c084fc"); // subscript styling for implicit content + }); +}); diff --git a/src/ra-engine/raHighlight.ts b/src/ra-engine/raHighlight.ts new file mode 100644 index 0000000..32343d6 --- /dev/null +++ b/src/ra-engine/raHighlight.ts @@ -0,0 +1,344 @@ +/* +Relational Algebra engine for sql-validator +Copyright (C) 2026 E.SU. IT AB (Org.no 559484-0505) and Edwin Sundberg + +Licensed under the Business Source License 1.1 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License in the LICENSE.md file in this repository. +*/ + +/** + * Custom syntax highlighter for relational algebra expressions. + * Returns HTML with subscript rendering for bracket content and + * colored tokens for operators, keywords, strings, etc. + */ + +// Escape HTML special characters +function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +// RA operator symbols +const UNARY_SYMBOLS = new Set(["σ", "π", "ρ", "γ", "τ", "δ"]); +const BINARY_SYMBOLS = new Set(["×", "⋈", "∪", "∩", "−", "÷", "⟕", "⟖", "⟗", "⋉", "⋊", "▷", "←"]); + +const UNARY_KEYWORDS = new Set([ + "sigma", "select", "pi", "project", "rho", "rename", + "gamma", "tau", "sort", "delta", "distinct", +]); +const BINARY_KEYWORDS = new Set([ + "cross", "natjoin", "join", "union", "intersect", "minus", + "divide", "leftjoin", "rightjoin", "fulljoin", + "leftsemijoin", "rightsemijoin", "antijoin", +]); +const LOGIC_KEYWORDS = new Set(["and", "or", "not"]); + +// CSS classes (inline styles for portability with the code editor) +const S = { + op: "color: #7c3aed; font-weight: bold;", // purple - operators + kw: "color: #7c3aed; font-weight: bold;", // purple - keyword operators + logic: "color: #2563eb; font-weight: bold;", // blue - AND/OR/NOT + str: "color: #059669;", // green - strings + num: "color: #d97706;", // amber - numbers + comment: "color: #9ca3af; font-style: italic;", // gray - comments + bracket: "color: #a1a1aa; opacity: 0.5;", // zinc, faint - brackets (subscript delimiters) + assign: "color: #7c3aed; font-weight: bold;", // purple - assignment arrow + sub: "color: #c084fc; font-weight: 500;", // light purple - subscript content (readable, distinct from plain text) +}; + +/** + * Highlight a relational algebra expression, rendering bracket content + * as subscripts and coloring operators/keywords. + */ +export function highlightRA(code: string): string { + const result: string[] = []; + let i = 0; + + while (i < code.length) { + // Comments: -- + if (code[i] === "-" && i + 1 < code.length && code[i + 1] === "-") { + let comment = ""; + while (i < code.length && code[i] !== "\n") { + comment += code[i]; + i++; + } + result.push(`${esc(comment)}`); + continue; + } + + // Newlines — preserve them + if (code[i] === "\n") { + result.push("\n"); + i++; + continue; + } + + // Unicode RA operators (single char) + if (UNARY_SYMBOLS.has(code[i])) { + result.push(`${esc(code[i])}`); + i++; + // Check for subscript: _{ }, [ ], { }, or implicit (tokens until '(') + i = renderSubscript(code, i, result); + continue; + } + + // |X| or |><| — natural join + if (code[i] === "|") { + if (i + 2 < code.length && (code[i + 1] === "X" || code[i + 1] === "x") && code[i + 2] === "|") { + result.push(`${esc("|X|")}`); + i += 3; + continue; + } + if (i + 3 < code.length && code[i + 1] === ">" && code[i + 2] === "<" && code[i + 3] === "|") { + result.push(`${esc("|><|")}`); + i += 4; + continue; + } + } + + if (BINARY_SYMBOLS.has(code[i])) { + result.push(`${esc(code[i])}`); + i++; + // Binary ops can have bracket conditions: ⋈[cond] or ⋈{cond} + if (i < code.length && (code[i] === "[" || code[i] === "{" || code[i] === "_")) { + i = renderSubscript(code, i, result); + } + continue; + } + + // Assignment arrow: ← (already handled above as binary symbol) + // Arrow: <- (assignment) — keep both chars for editor alignment, tight letter-spacing + if (code[i] === "<" && i + 1 < code.length && code[i + 1] === "-") { + result.push(`<-`); + i += 2; + continue; + } + + // Arrow: -> (rename) — keep both chars for editor alignment + if (code[i] === "-" && i + 1 < code.length && code[i + 1] === ">") { + result.push(`->`); + i += 2; + continue; + } + + // String literals + if (code[i] === "'") { + let str = "'"; + i++; + while (i < code.length && code[i] !== "'") { + str += code[i]; + i++; + } + if (i < code.length) { str += "'"; i++; } + result.push(`${esc(str)}`); + continue; + } + + // Numbers + if (/\d/.test(code[i])) { + let num = ""; + while (i < code.length && /[\d.]/.test(code[i])) { + num += code[i]; + i++; + } + result.push(`${esc(num)}`); + continue; + } + + // Identifiers and keywords + if (/[a-zA-Z_\u00C0-\u024F]/.test(code[i])) { + let ident = ""; + while (i < code.length && /[a-zA-Z0-9_\u00C0-\u024F]/.test(code[i])) { + ident += code[i]; + i++; + } + const lower = ident.toLowerCase(); + if (UNARY_KEYWORDS.has(lower)) { + result.push(`${esc(ident)}`); + // Check for subscript after keyword + i = renderSubscript(code, i, result); + } else if (BINARY_KEYWORDS.has(lower)) { + result.push(`${esc(ident)}`); + // Binary keyword may have bracket conditions + const j = skipWhitespace(code, i); + if (j < code.length && (code[j] === "[" || code[j] === "{" || code[j] === "_")) { + // Include the whitespace before the bracket + if (j > i) result.push(esc(code.slice(i, j))); + i = renderSubscript(code, j, result); + } + } else if (LOGIC_KEYWORDS.has(lower)) { + result.push(`${esc(ident)}`); + } else { + result.push(esc(ident)); + } + continue; + } + + // Comparison operators + if (code[i] === "<" || code[i] === ">" || code[i] === "!" || code[i] === "=") { + let op = code[i]; + i++; + if (i < code.length && (code[i] === "=" || code[i] === ">")) { + op += code[i]; + i++; + } + result.push(`${esc(op)}`); + continue; + } + + // Everything else (whitespace, parens, etc.) — pass through + result.push(esc(code[i])); + i++; + } + + return result.join(""); +} + +function skipWhitespace(code: string, i: number): number { + while (i < code.length && code[i] === " ") i++; + return i; +} + +/** + * Render a subscript section after a unary/binary operator. + * Handles: _{ }, [ ], { }, or implicit (content until '('). + * Returns the new index position. + */ +function renderSubscript(code: string, i: number, result: string[]): number { + // Skip whitespace + const beforeWs = i; + while (i < code.length && code[i] === " ") i++; + + // Check what follows + if (i >= code.length) { + if (i > beforeWs) result.push(code.slice(beforeWs, i)); + return i; + } + + // Handle _{ or _[ (LaTeX-style) + if (code[i] === "_") { + result.push(`${esc("_")}`); + i++; + while (i < code.length && code[i] === " ") i++; + } + + if (code[i] === "[" || code[i] === "{") { + // Bracketed subscript — render brackets faintly, content as subscript + const openBracket = code[i]; + result.push(`${esc(openBracket)}`); + i++; // past opening bracket + let depth = 1; + let content = ""; + while (i < code.length && depth > 0) { + if (code[i] === "[" || code[i] === "{") depth++; + if (code[i] === "]" || code[i] === "}") depth--; + if (depth > 0) { + content += code[i]; + } + i++; + } + // Render inner content with subscript styling + result.push(`${highlightSubscriptContent(content)}`); + // Render closing bracket faintly (the character at i-1 was the closing bracket) + if (depth === 0) { + const closeBracket = code[i - 1]; + result.push(`${esc(closeBracket)}`); + } + return i; + } + + // Not a bracket — check for implicit subscript (content until '(') + // Only apply for unary operators where we expect a subscript + if (code[i] !== "(" && i > beforeWs) { + // Emit the whitespace between operator and subscript content + result.push(code.slice(beforeWs, i)); + // Collect content until '(' as implicit subscript + let content = ""; + while (i < code.length && code[i] !== "(" && code[i] !== "\n") { + content += code[i]; + i++; + } + // Trim trailing whitespace from content but keep the position + const trimmed = content.trimEnd(); + const trimDiff = content.length - trimmed.length; + if (trimDiff > 0) i -= trimDiff; + if (trimmed.length > 0) { + result.push(`${highlightSubscriptContent(trimmed)}`); + } + return i; + } + + // No subscript found — restore whitespace + if (i > beforeWs) result.push(code.slice(beforeWs, i)); + return i; +} + +/** + * Highlight content inside a subscript (conditions, column lists, etc.) + */ +function highlightSubscriptContent(content: string): string { + const parts: string[] = []; + let i = 0; + + while (i < content.length) { + // String literals + if (content[i] === "'") { + let str = "'"; + i++; + while (i < content.length && content[i] !== "'") { str += content[i]; i++; } + if (i < content.length) { str += "'"; i++; } + parts.push(`${esc(str)}`); + continue; + } + // Numbers + if (/\d/.test(content[i])) { + let num = ""; + while (i < content.length && /[\d.]/.test(content[i])) { num += content[i]; i++; } + parts.push(`${esc(num)}`); + continue; + } + // Keywords and identifiers + if (/[a-zA-Z_\u00C0-\u024F]/.test(content[i])) { + let ident = ""; + while (i < content.length && /[a-zA-Z0-9_\u00C0-\u024F]/.test(content[i])) { ident += content[i]; i++; } + const lower = ident.toLowerCase(); + if (LOGIC_KEYWORDS.has(lower)) { + parts.push(`${esc(ident)}`); + } else if (lower === "desc" || lower === "asc" || lower === "as") { + parts.push(`${esc(ident)}`); + } else if (["count", "sum", "avg", "min", "max"].includes(lower)) { + parts.push(`${esc(ident)}`); + } else { + parts.push(esc(ident)); + } + continue; + } + // Arrow → or -> + if (content[i] === "→") { + parts.push(``); + i++; + continue; + } + if (content[i] === "-" && i + 1 < content.length && content[i + 1] === ">") { + parts.push(`->`); + i += 2; + continue; + } + // Comparison ops + if (content[i] === "<" || content[i] === ">" || content[i] === "!" || content[i] === "=") { + let op = content[i]; i++; + if (i < content.length && (content[i] === "=" || content[i] === ">")) { op += content[i]; i++; } + parts.push(`${esc(op)}`); + continue; + } + // Everything else + parts.push(esc(content[i])); + i++; + } + + return parts.join(""); +} diff --git a/src/ra-engine/relationalAlgebra.test.ts b/src/ra-engine/relationalAlgebra.test.ts index 46f42c7..16d5ffb 100644 --- a/src/ra-engine/relationalAlgebra.test.ts +++ b/src/ra-engine/relationalAlgebra.test.ts @@ -174,22 +174,9 @@ describe("natural join (⋈)", () => { expect(norm(sql)).toContain("NATURAL JOIN"); }); - it("should error on impossible natural join when database is provided", () => { - // Mock database that returns different columns for two tables - const mockDb = { - exec: (sql: string) => { - if (sql.includes("TableA")) { - return [{ columns: ["id", "name"], values: [] }]; - } - if (sql.includes("TableB")) { - return [{ columns: ["code", "description"], values: [] }]; - } - return [{ columns: [], values: [] }]; - }, - }; - - expect(() => raToSQL("TableA ⋈ TableB", mockDb)).toThrow(RAError); - expect(() => raToSQL("TableA ⋈ TableB", mockDb)).toThrow(/no common columns/i); + it("should produce NATURAL JOIN SQL even without common columns (let SQLite handle it)", () => { + const sql = raToSQL("TableA ⋈ TableB"); + expect(norm(sql)).toContain("NATURAL JOIN"); }); it("should not error on valid natural join when database is provided", () => { @@ -211,6 +198,16 @@ describe("natural join (⋈)", () => { it("should not error when no database is provided (parse-only mode)", () => { expect(() => raToSQL("TableA ⋈ TableB")).not.toThrow(); }); + + it("should support |X| as natural join", () => { + const sql = raToSQL("Person |X| Student"); + expect(norm(sql)).toContain("NATURAL JOIN"); + }); + + it("should support |><| as natural join", () => { + const sql = raToSQL("Person |><| Student"); + expect(norm(sql)).toContain("NATURAL JOIN"); + }); }); // ─── Theta join (⋈[cond]) ────────────────────────────────────────────────── @@ -691,3 +688,66 @@ describe("implicit subscripts (no brackets)", () => { expect(norm(sql)).toContain("SELECT name FROM"); }); }); + +describe("parenthesis-free syntax", () => { + it("should allow σ[cond] Table without parens", () => { + expect(norm(raToSQL("σ[age > 20] Person"))).toBe( + norm("SELECT * FROM (Person) WHERE age > 20") + ); + }); + + it("should allow π[cols] Table without parens", () => { + expect(norm(raToSQL("π[name] Person"))).toBe( + norm("SELECT name FROM (Person)") + ); + }); + + it("should allow chained unary ops without parens", () => { + const sql = raToSQL("π[name] σ[age > 20] Person"); + expect(norm(sql)).toContain("SELECT name FROM"); + expect(norm(sql)).toContain("WHERE age > 20"); + }); + + it("should handle the user's example without excessive parens", () => { + const sql = raToSQL("π[person_id, name, city] σ[city = 'York' OR city = 'Bristol'] Person"); + expect(norm(sql)).toContain("SELECT person_id, name, city FROM"); + expect(norm(sql)).toContain("WHERE"); + expect(norm(sql)).toContain("city = 'York'"); + expect(norm(sql)).toContain("city = 'Bristol'"); + }); + + it("should still work with parens for grouping binary ops", () => { + const sql = raToSQL("π[name] (Person ∪ Student)"); + expect(norm(sql)).toContain("SELECT name FROM"); + expect(norm(sql)).toContain("UNION"); + }); + + it("should allow δ Table without parens", () => { + expect(norm(raToSQL("δ Person"))).toContain("SELECT DISTINCT"); + }); + + it("should allow triple chain without parens", () => { + const sql = raToSQL("π[name] σ[age > 20] ρ[n→name] Person"); + expect(norm(sql)).toContain("SELECT name FROM"); + expect(norm(sql)).toContain("WHERE age > 20"); + expect(norm(sql)).toContain("n AS name"); + }); +}); + +describe("implicit return from last assignment", () => { + it("should return last assigned variable when no explicit result", () => { + const sql = raToSQL("A <- σ[age > 20](Person)"); + expect(norm(sql)).toContain("WITH A AS"); + expect(norm(sql)).toContain("SELECT * FROM A"); + }); + + it("should return last variable with multiple assignments", () => { + const sql = raToSQL("A <- σ[age > 20](Person)\nB <- π[name](A)"); + expect(norm(sql)).toContain("SELECT * FROM B"); + }); + + it("should still work when explicit result is given", () => { + const sql = raToSQL("A <- σ[age > 20](Person)\nA"); + expect(norm(sql)).toContain("SELECT * FROM A"); + }); +}); diff --git a/src/ra-engine/relationalAlgebra.ts b/src/ra-engine/relationalAlgebra.ts index 865070d..c81b609 100644 --- a/src/ra-engine/relationalAlgebra.ts +++ b/src/ra-engine/relationalAlgebra.ts @@ -177,6 +177,22 @@ function tokenize(input: string): Token[] { if (ch === ";") { tokens.push({ type: TokenType.SEMICOLON, value: ch, pos }); i++; continue; } if (ch === "\\") { tokens.push({ type: TokenType.MINUS, value: ch, pos }); i++; continue; } + // |X| or |><| — natural join + if (ch === "|") { + // Check for |X| + if (i + 2 < input.length && (input[i + 1] === "X" || input[i + 1] === "x") && input[i + 2] === "|") { + tokens.push({ type: TokenType.JOIN, value: "|X|", pos }); + i += 3; + continue; + } + // Check for |><| + if (i + 3 < input.length && input[i + 1] === ">" && input[i + 2] === "<" && input[i + 3] === "|") { + tokens.push({ type: TokenType.JOIN, value: "|><|", pos }); + i += 4; + continue; + } + } + // Arrow: → or -> if (ch === "→") { tokens.push({ type: TokenType.ARROW, value: ch, pos }); i++; continue; } if (ch === "-" && i + 1 < input.length && input[i + 1] === ">") { @@ -431,6 +447,12 @@ class Parser { return { assignments, result }; } + // If we only have assignments and no final expression, implicitly return the last assigned variable + if (assignments.length > 0) { + const lastName = assignments[assignments.length - 1].name; + return { assignments, result: { type: "table", name: lastName } }; + } + throw new RAError("Empty expression"); } @@ -595,6 +617,21 @@ class Parser { return this.parseSortColumns(); } + /** + * Parse the operand of a unary operator. + * If `(` follows, parse `(expr)`. Otherwise parse another unary or a table name. + * This allows `π[cols] σ[cond] Person` without mandatory parentheses. + */ + private parseUnaryOperand(): RANode { + if (this.peek().type === TokenType.LPAREN) { + this.advance(); + const expr = this.parseUnionExpr(); + this.expect(TokenType.RPAREN); + return expr; + } + return this.parseUnaryExpr(); + } + private parseUnaryExpr(): RANode { const t = this.peek().type; @@ -602,12 +639,9 @@ class Parser { this.advance(); if (this.hasSubscript()) { const condition = this.parseSubscriptCondition(); - this.expect(TokenType.LPAREN); - const relation = this.parseUnionExpr(); - this.expect(TokenType.RPAREN); + const relation = this.parseUnaryOperand(); return { type: "selection", condition, relation }; } - // σ(R) with no subscript — error throw new RAError("Selection (σ) requires a condition — use σ[condition](R) or σ condition (R)"); } @@ -615,9 +649,7 @@ class Parser { this.advance(); if (this.hasSubscript()) { const columns = this.parseSubscriptColumns(); - this.expect(TokenType.LPAREN); - const relation = this.parseUnionExpr(); - this.expect(TokenType.RPAREN); + const relation = this.parseUnaryOperand(); return { type: "projection", columns, relation }; } throw new RAError("Projection (π) requires column list — use π[cols](R) or π cols (R)"); @@ -627,9 +659,7 @@ class Parser { this.advance(); if (this.hasSubscript()) { const mappings = this.parseSubscriptRenameMappings(); - this.expect(TokenType.LPAREN); - const relation = this.parseUnionExpr(); - this.expect(TokenType.RPAREN); + const relation = this.parseUnaryOperand(); return { type: "rename", mappings, relation }; } throw new RAError("Rename (ρ) requires mappings — use ρ[old→new](R) or ρ old→new (R)"); @@ -639,9 +669,7 @@ class Parser { this.advance(); if (this.hasSubscript()) { const { groupBy, aggregates } = this.parseSubscriptGroupSpec(); - this.expect(TokenType.LPAREN); - const relation = this.parseUnionExpr(); - this.expect(TokenType.RPAREN); + const relation = this.parseUnaryOperand(); return { type: "group", groupBy, aggregates, relation }; } throw new RAError("Grouping (γ) requires specification — use γ[groupCols; AGG(col)](R)"); @@ -651,9 +679,7 @@ class Parser { this.advance(); if (this.hasSubscript()) { const columns = this.parseSubscriptSortColumns(); - this.expect(TokenType.LPAREN); - const relation = this.parseUnionExpr(); - this.expect(TokenType.RPAREN); + const relation = this.parseUnaryOperand(); return { type: "sort", columns, relation }; } throw new RAError("Sort (τ) requires column list — use τ[col](R) or τ col (R)"); @@ -661,9 +687,7 @@ class Parser { if (t === TokenType.DELTA) { this.advance(); - this.expect(TokenType.LPAREN); - const relation = this.parseUnionExpr(); - this.expect(TokenType.RPAREN); + const relation = this.parseUnaryOperand(); return { type: "distinct", relation }; } @@ -989,12 +1013,6 @@ interface DatabaseHandle { * Resolve the column names produced by a SQL expression using the database. * Executes a LIMIT 0 query to get column metadata without fetching data. */ -function resolveColumns(sql: string, db: DatabaseHandle): string[] { - const res = db.exec(`SELECT * FROM (${sql}) LIMIT 0`); - if (res.length === 0) return []; - return res[0].columns; -} - function nodeToSQL(node: RANode, db?: DatabaseHandle): string { switch (node.type) { case "table": @@ -1042,19 +1060,6 @@ function nodeToSQL(node: RANode, db?: DatabaseHandle): string { case "naturalJoin": { const leftSQL = nodeToSQL(node.left, db); const rightSQL = nodeToSQL(node.right, db); - // Validate that there are common columns when a database is available - if (db) { - const leftCols = resolveColumns(leftSQL, db); - const rightCols = resolveColumns(rightSQL, db); - const common = leftCols.filter(c => rightCols.includes(c)); - if (common.length === 0) { - throw new RAError( - "Natural join has no common columns between the two relations. " + - "Left columns: [" + leftCols.join(", ") + "], Right columns: [" + rightCols.join(", ") + "]. " + - "Use a cross product (×) if a cartesian product is intended, or a theta join (⋈[condition]) to specify the join condition." - ); - } - } return `SELECT * FROM (${leftSQL}) AS _ra${subqueryCounter++} NATURAL JOIN (${rightSQL}) AS _ra${subqueryCounter++}`; } From ea18a55651fc4b6359476ffaec992c226e0cee87 Mon Sep 17 00:00:00 2001 From: Edwin <60476129+Edwinexd@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:55:16 +0000 Subject: [PATCH 3/7] Add natural join and CTE validation with database reflection --- src/App.tsx | 4 +- src/i18n/ui-strings.ts | 6 +- src/ra-engine/raHighlight.ts | 4 +- src/ra-engine/relationalAlgebra.test.ts | 74 ++++++++++--- src/ra-engine/relationalAlgebra.ts | 132 ++++++++++++++++++++++-- 5 files changed, 188 insertions(+), 32 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d59497a..bc6dfa7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -291,9 +291,9 @@ function App() { setError(e.message); } } else { - // In RA mode, try to parse to check for errors + // In RA mode, try to parse and validate against database try { - raToSQL(query); + raToSQL(query, database); setError(null); } catch (e) { if (e instanceof RAError) { diff --git a/src/i18n/ui-strings.ts b/src/i18n/ui-strings.ts index 99d35da..0973e3f 100644 --- a/src/i18n/ui-strings.ts +++ b/src/i18n/ui-strings.ts @@ -82,7 +82,7 @@ export const uiStrings: Record> = { generatedSQL: "Genererad SQL", raParseError: "Relationsalgebra-fel: {{message}}", raPlaceholder: "-- Skriv ett relationsalgebrauttryck\n-- Klicka på 'RA-referens' för syntax", - raReference: "RA-referens", + raReference: "Relationsalgebrareferens", raUnaryOps: "Unära operatorer", raBinaryOps: "Binära operatorer", raColOp: "Operator", @@ -190,8 +190,8 @@ export const uiStrings: Record> = { modeRA: "Relational Algebra", generatedSQL: "Generated SQL", raParseError: "Relational algebra error: {{message}}", - raPlaceholder: "-- Write a relational algebra expression\n-- Example: π[name](σ[age > 20](Person))", - raReference: "RA Reference", + raPlaceholder: "-- Write a relational algebra expression\n-- Click 'RA Reference' for syntax", + raReference: "Relational Algebra Reference", raUnaryOps: "Unary Operators", raBinaryOps: "Binary Operators", raColOp: "Operator", diff --git a/src/ra-engine/raHighlight.ts b/src/ra-engine/raHighlight.ts index 32343d6..549e1a4 100644 --- a/src/ra-engine/raHighlight.ts +++ b/src/ra-engine/raHighlight.ts @@ -111,9 +111,9 @@ export function highlightRA(code: string): string { } // Assignment arrow: ← (already handled above as binary symbol) - // Arrow: <- (assignment) — keep both chars for editor alignment, tight letter-spacing + // Arrow: <- (assignment) — keep both chars for editor alignment if (code[i] === "<" && i + 1 < code.length && code[i + 1] === "-") { - result.push(`<-`); + result.push(`<-`); i += 2; continue; } diff --git a/src/ra-engine/relationalAlgebra.test.ts b/src/ra-engine/relationalAlgebra.test.ts index 16d5ffb..3b16610 100644 --- a/src/ra-engine/relationalAlgebra.test.ts +++ b/src/ra-engine/relationalAlgebra.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import initSqlJs from "sql.js"; import { raToSQL, RAError } from "./relationalAlgebra"; // ─── Helper ───────────────────────────────────────────────────────────────── @@ -174,25 +175,46 @@ describe("natural join (⋈)", () => { expect(norm(sql)).toContain("NATURAL JOIN"); }); - it("should produce NATURAL JOIN SQL even without common columns (let SQLite handle it)", () => { - const sql = raToSQL("TableA ⋈ TableB"); - expect(norm(sql)).toContain("NATURAL JOIN"); + it("should error on impossible natural join when database is provided", async () => { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run("CREATE TABLE TableA (id INTEGER, name TEXT)"); + db.run("CREATE TABLE TableB (code TEXT, description TEXT)"); + + expect(() => raToSQL("TableA ⋈ TableB", db)).toThrow(RAError); + expect(() => raToSQL("TableA ⋈ TableB", db)).toThrow(/no common columns/i); + db.close(); + }); + + it("should not error on valid natural join when database is provided", async () => { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run("CREATE TABLE Person (id INTEGER, name TEXT, city TEXT)"); + db.run("CREATE TABLE Student (id INTEGER, hasDisability INTEGER)"); + + expect(() => raToSQL("Person ⋈ Student", db)).not.toThrow(); + db.close(); + }); + + it("should error on natural join with non-existent table", async () => { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run("CREATE TABLE Person (id INTEGER, name TEXT)"); + + expect(() => raToSQL("Person ⋈ Room", db)).toThrow(RAError); + expect(() => raToSQL("Person ⋈ Room", db)).toThrow(/Room/); + db.close(); }); - it("should not error on valid natural join when database is provided", () => { - const mockDb = { - exec: (sql: string) => { - if (sql.includes("Person")) { - return [{ columns: ["id", "name", "city"], values: [] }]; - } - if (sql.includes("Student")) { - return [{ columns: ["id", "hasDisability"], values: [] }]; - } - return [{ columns: [], values: [] }]; - }, - }; + it("should error on natural join with no common columns through subquery", async () => { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run("CREATE TABLE Person (person_id INTEGER, name TEXT, city TEXT)"); + db.run("CREATE TABLE Room (room_id INTEGER, building TEXT, capacity INTEGER)"); - expect(() => raToSQL("Person ⋈ Student", mockDb)).not.toThrow(); + expect(() => raToSQL("σ[city = 'York'](Person) ⋈ Room", db)).toThrow(RAError); + expect(() => raToSQL("σ[city = 'York'](Person) ⋈ Room", db)).toThrow(/no common columns/i); + db.close(); }); it("should not error when no database is provided (parse-only mode)", () => { @@ -750,4 +772,24 @@ describe("implicit return from last assignment", () => { const sql = raToSQL("A <- σ[age > 20](Person)\nA"); expect(norm(sql)).toContain("SELECT * FROM A"); }); + + it("should handle self-referential A <- A as a no-op", () => { + const sql = raToSQL("A <- A"); + expect(norm(sql)).toBe(norm("SELECT * FROM A")); + }); + + it("should handle variable reassignment with versioned CTE names", () => { + const sql = raToSQL("A <- σ[age > 20](Person)\nA <- π[name](A)\nA"); + expect(norm(sql)).toContain("WITH A AS"); + expect(norm(sql)).toContain("A_v2 AS"); + expect(norm(sql)).toContain("SELECT * FROM A_v2"); + }); + + it("should handle triple reassignment", () => { + const sql = raToSQL("X <- Person\nX <- σ[age > 20](X)\nX <- π[name](X)"); + expect(norm(sql)).toContain("X AS"); + expect(norm(sql)).toContain("X_v2 AS"); + expect(norm(sql)).toContain("X_v3 AS"); + expect(norm(sql)).toContain("SELECT * FROM X_v3"); + }); }); diff --git a/src/ra-engine/relationalAlgebra.ts b/src/ra-engine/relationalAlgebra.ts index c81b609..5077118 100644 --- a/src/ra-engine/relationalAlgebra.ts +++ b/src/ra-engine/relationalAlgebra.ts @@ -1007,12 +1007,47 @@ let subqueryCounter = 0; */ interface DatabaseHandle { exec(sql: string): { columns: string[]; values: unknown[][] }[]; + prepare(sql: string): { step(): boolean; getColumnNames(): string[]; free(): void }; } /** * Resolve the column names produced by a SQL expression using the database. - * Executes a LIMIT 0 query to get column metadata without fetching data. + * Uses prepare() to get column metadata without executing the query. */ +function resolveColumns(sql: string, db: DatabaseHandle): string[] { + // For bare table names, use PRAGMA table_info which always works + if (/^\w+$/.test(sql.trim())) { + const tableName = sql.trim(); + try { + const res = db.exec(`PRAGMA table_info(${tableName})`); + if (res.length > 0 && res[0].values.length > 0) { + // PRAGMA table_info returns rows with [cid, name, type, notnull, dflt_value, pk] + return res[0].values.map((row: unknown[]) => String(row[1])); + } + } catch { + // Table doesn't exist + } + throw new RAError(`Table '${tableName}' does not exist`); + } + + // For complex expressions, use prepare to get column names + try { + const probeSQL = `SELECT * FROM (${sql}) LIMIT 0`; + const stmt = db.prepare(probeSQL); + // Step once to initialize column metadata (required by some sql.js versions) + stmt.step(); + const cols = stmt.getColumnNames(); + stmt.free(); + return cols; + } catch (e) { + const msg = (e as Error).message || String(e); + if (/no such table/i.test(msg)) { + const match = msg.match(/no such table:\s*(\S+)/i); + throw new RAError(match ? `Table '${match[1]}' does not exist` : msg); + } + throw new RAError(msg); + } +} function nodeToSQL(node: RANode, db?: DatabaseHandle): string { switch (node.type) { case "table": @@ -1060,6 +1095,20 @@ function nodeToSQL(node: RANode, db?: DatabaseHandle): string { case "naturalJoin": { const leftSQL = nodeToSQL(node.left, db); const rightSQL = nodeToSQL(node.right, db); + if (db) { + const leftCols = resolveColumns(leftSQL, db); + const rightCols = resolveColumns(rightSQL, db); + if (leftCols.length > 0 && rightCols.length > 0) { + const common = leftCols.filter(c => rightCols.includes(c)); + if (common.length === 0) { + throw new RAError( + "Natural join has no common columns between the two relations. " + + "Left columns: [" + leftCols.join(", ") + "], Right columns: [" + rightCols.join(", ") + "]. " + + "Use a cross product (×) if a cartesian product is intended, or a theta join (⋈[condition]) to specify the join condition." + ); + } + } + } return `SELECT * FROM (${leftSQL}) AS _ra${subqueryCounter++} NATURAL JOIN (${rightSQL}) AS _ra${subqueryCounter++}`; } @@ -1114,23 +1163,88 @@ function nodeToSQL(node: RANode, db?: DatabaseHandle): string { } } +/** + * Rewrite table references in an AST node using a name mapping. + * Used to handle variable reassignment (A <- ...; A <- ...) by pointing + * references to the correct versioned CTE name. + */ +function rewriteTableRefs(node: RANode, nameMap: Record): RANode { + switch (node.type) { + case "table": + return nameMap[node.name] ? { type: "table", name: nameMap[node.name] } : node; + case "selection": + return { ...node, relation: rewriteTableRefs(node.relation, nameMap) }; + case "projection": + return { ...node, relation: rewriteTableRefs(node.relation, nameMap) }; + case "rename": + return { ...node, relation: rewriteTableRefs(node.relation, nameMap) }; + case "group": + return { ...node, relation: rewriteTableRefs(node.relation, nameMap) }; + case "sort": + return { ...node, relation: rewriteTableRefs(node.relation, nameMap) }; + case "distinct": + return { ...node, relation: rewriteTableRefs(node.relation, nameMap) }; + case "crossProduct": + case "naturalJoin": + case "union": + case "intersect": + case "difference": + case "division": + case "leftSemiJoin": + case "rightSemiJoin": + case "antiJoin": + return { ...node, left: rewriteTableRefs(node.left, nameMap), right: rewriteTableRefs(node.right, nameMap) }; + case "thetaJoin": + case "leftJoin": + case "rightJoin": + case "fullJoin": + return { ...node, left: rewriteTableRefs(node.left, nameMap), right: rewriteTableRefs(node.right, nameMap) }; + default: + return node; + } +} + function programToSQL(program: RAProgram, db?: DatabaseHandle): string { if (program.assignments.length === 0) { return nodeToSQL(program.result, db); } - // Use CTEs (WITH clauses) for assignments - const ctes = program.assignments.map(a => { - const sql = nodeToSQL(a.expr, db); - // Wrap non-table expressions in SELECT * FROM (...) for CTE compatibility + // Use CTEs (WITH clauses) for assignments. + // Handle reassignment (A <- ..., A <- ...) by versioning CTE names + // and rewriting references in subsequent expressions. + const ctes: string[] = []; + // Maps variable name -> current CTE name (may be versioned like A_v2) + const nameMap: Record = {}; + // Track how many times each name has been assigned + const assignCount: Record = {}; + + for (const a of program.assignments) { + if (a.expr.type === "table" && a.expr.name === a.name && !nameMap[a.name]) { + // Self-referential assignment (A <- A) where A is a real table — skip, it's a no-op + continue; + } + + // Rewrite the expression: replace table references with their current CTE aliases + const rewrittenExpr = rewriteTableRefs(a.expr, nameMap); + const sql = nodeToSQL(rewrittenExpr, db); const wrappedSQL = /^\w+$/.test(sql) ? `SELECT * FROM ${sql}` : sql; - return `${a.name} AS (${wrappedSQL})`; - }); - const resultSQL = nodeToSQL(program.result, db); - // Wrap bare table reference in SELECT for the final expression + // Determine the CTE name for this assignment + assignCount[a.name] = (assignCount[a.name] || 0) + 1; + const cteName = assignCount[a.name] > 1 ? `${a.name}_v${assignCount[a.name]}` : a.name; + nameMap[a.name] = cteName; + + ctes.push(`${cteName} AS (${wrappedSQL})`); + } + + // Rewrite the result expression with final name mappings + const rewrittenResult = rewriteTableRefs(program.result, nameMap); + const resultSQL = nodeToSQL(rewrittenResult, db); const wrappedResult = /^\w+$/.test(resultSQL) ? `SELECT * FROM ${resultSQL}` : resultSQL; + if (ctes.length === 0) { + return wrappedResult; + } return `WITH ${ctes.join(", ")} ${wrappedResult}`; } From bd1268fbb3461980123ec0beaf61ab32ffba8f3b Mon Sep 17 00:00:00 2001 From: Edwin <60476129+Edwinexd@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:21:07 +0000 Subject: [PATCH 4/7] Add mode-aware progress tracking, RA preview box, and RA PNG export --- src/App.css | 15 +++ src/App.tsx | 206 ++++++++++++++++++----------------- src/ExportRenderer.tsx | 43 +++++--- src/ra-engine/RAPreview.tsx | 204 ++++++++++++++++++++++++++++++++++ src/ra-engine/raHighlight.ts | 195 +++++++-------------------------- src/ra-engine/raShared.ts | 197 +++++++++++++++++++++++++++++++++ 6 files changed, 588 insertions(+), 272 deletions(-) create mode 100644 src/ra-engine/RAPreview.tsx create mode 100644 src/ra-engine/raShared.ts diff --git a/src/App.css b/src/App.css index 2b02d64..50a362b 100644 --- a/src/App.css +++ b/src/App.css @@ -45,3 +45,18 @@ body { .dark .diff-added-line { background-color: rgba(34, 197, 94, 0.15); } + +/* RA Preview styling */ +.ra-prev-op { color: #7c3aed; font-weight: bold; } +.ra-prev-assign { color: #7c3aed; font-weight: bold; } +.ra-prev-logic { color: #2563eb; font-weight: bold; } +.ra-prev-str { color: #059669; } +.ra-prev-num { color: #d97706; } +.ra-prev-comment { color: #9ca3af; font-style: italic; } +.ra-prev-sub { font-size: 0.8em; color: #6d28d9; } +.dark .ra-prev-op { color: #a78bfa; } +.dark .ra-prev-assign { color: #a78bfa; } +.dark .ra-prev-logic { color: #60a5fa; } +.dark .ra-prev-str { color: #34d399; } +.dark .ra-prev-num { color: #fbbf24; } +.dark .ra-prev-sub { color: #c4b5fd; } diff --git a/src/App.tsx b/src/App.tsx index bc6dfa7..1c6cc11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,7 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Editor from "react-simple-code-editor"; import "./App.css"; import ResultTable from "./ResultTable"; @@ -33,6 +33,7 @@ import ViewsTable, { View } from "./ViewsTable"; import { raToSQL, RAError } from "./ra-engine/relationalAlgebra"; import RAReference from "./ra-engine/RAReference"; import { highlightRA } from "./ra-engine/raHighlight"; +import RAPreview from "./ra-engine/RAPreview"; import { Info, Settings, XCircle, CheckCircle2, Eye, EyeOff } from "lucide-react"; import ChangelogDialog from "./ChangelogDialog"; @@ -58,9 +59,20 @@ import { useLanguage, langKey, getUrlParam, setUrlParam } from "./i18n/context"; import LanguageSelector from "./LanguageSelector"; import { getQuestion } from "./QuestionSelector"; -/** Storage key for a question's query, namespaced by editor mode */ -function queryKey(questionId: number, mode: "sql" | "ra"): string { - return mode === "ra" ? `ra-questionId-${questionId}` : `questionId-${questionId}`; +/** Storage key namespaced by editor mode */ +function modeKey(base: string, mode: "sql" | "ra"): string { + return mode === "ra" ? `ra-${base}` : base; +} + +/** Get a JSON-parsed list from localStorage, with language and mode namespacing */ +function getStoredList(lang: string, key: string): number[] { + const raw = localStorage.getItem(langKey(lang, key)); + return raw ? JSON.parse(raw) : []; +} + +/** Set a JSON list in localStorage, with language namespacing */ +function setStoredList(lang: string, key: string, value: number[]): void { + localStorage.setItem(langKey(lang, key), JSON.stringify(value)); } function App() { @@ -86,7 +98,10 @@ function App() { const [exportQuery, setExportQuery] = useState(); const [exportingStatus, setExportingStatus] = useState(0); const [loadedQuestionCorrect, setLoadedQuestionCorrect] = useState(false); - const [editorMode, setEditorMode] = useState<"sql" | "ra">("sql"); + const [editorMode, setEditorMode] = useState<"sql" | "ra">(() => { + const param = getUrlParam("mode"); + return param === "ra" ? "ra" : "sql"; + }); const exportRendererRef = useRef(null); @@ -96,11 +111,11 @@ function App() { const [pendingImportData, setPendingImportData] = useState(null); // QuestionSelector needs writtenQuestions and correctQuestions to be able to display the correct state - const [writtenQuestions, setWrittenQuestions] = useState( - localStorage.getItem(langKey(lang, "writtenQuestions")) ? JSON.parse(localStorage.getItem(langKey(lang, "writtenQuestions"))!) : [] + const [writtenQuestions, setWrittenQuestions] = useState(() => + getStoredList(lang, modeKey("writtenQuestions", editorMode)) ); - const [correctQuestions, setCorrectQuestions] = useState( - localStorage.getItem(langKey(lang, "correctQuestions")) ? JSON.parse(localStorage.getItem(langKey(lang, "correctQuestions"))!) : [] + const [correctQuestions, setCorrectQuestions] = useState(() => + getStoredList(lang, modeKey("correctQuestions", editorMode)) ); // One-time migration: copy old unnamespaced keys to sv: prefix @@ -131,19 +146,11 @@ function App() { localStorage.setItem("i18n-migrated", "1"); }, []); - // Reload written/correct questions when language changes + // Reload written/correct questions when language or mode changes useEffect(() => { - setWrittenQuestions( - localStorage.getItem(langKey(lang, "writtenQuestions")) - ? JSON.parse(localStorage.getItem(langKey(lang, "writtenQuestions"))!) - : [] - ); - setCorrectQuestions( - localStorage.getItem(langKey(lang, "correctQuestions")) - ? JSON.parse(localStorage.getItem(langKey(lang, "correctQuestions"))!) - : [] - ); - }, [lang]); + setWrittenQuestions(getStoredList(lang, modeKey("writtenQuestions", editorMode))); + setCorrectQuestions(getStoredList(lang, modeKey("correctQuestions", editorMode))); + }, [lang, editorMode]); const resetResult = useCallback(() => { setResult(undefined); @@ -157,7 +164,7 @@ function App() { const switchEditorMode = useCallback((mode: "sql" | "ra") => { // Save current query before switching if (question && query !== undefined) { - const currentKey = queryKey(question.id, editorMode); + const currentKey = modeKey(`questionId-${question.id}`, editorMode); if (query === defaultQuery || query === "") { localStorage.removeItem(langKey(lang, currentKey)); } else { @@ -170,9 +177,12 @@ function App() { resetResult(); // Load query for the new mode if (question) { - const newKey = queryKey(question.id, mode); + const newKey = modeKey(`questionId-${question.id}`, mode); setQuery(localStorage.getItem(langKey(lang, newKey)) || (mode === "ra" ? "" : defaultQuery)); } + // Reload progress for the new mode + setWrittenQuestions(getStoredList(lang, modeKey("writtenQuestions", mode))); + setCorrectQuestions(getStoredList(lang, modeKey("correctQuestions", mode))); }, [resetResult, question, query, editorMode, lang, defaultQuery]); const initDb = useCallback(async () => { @@ -221,7 +231,7 @@ function App() { const resolved = getQuestion(q.id, questions); if (resolved) { setQuestion(resolved); - setQuery(localStorage.getItem(langKey(lang, queryKey(resolved.id, editorMode))) || (editorMode === "ra" ? "" : defaultQuery)); + setQuery(localStorage.getItem(langKey(lang, modeKey(`questionId-${resolved.id}`, editorMode))) || (editorMode === "ra" ? "" : defaultQuery)); } } } @@ -237,6 +247,11 @@ function App() { } }, [question]); + // Sync editor mode to URL + useEffect(() => { + setUrlParam("mode", editorMode === "ra" ? "ra" : null); + }, [editorMode]); + // Track which language the current question was loaded in const questionLangRef = useRef(lang); useEffect(() => { @@ -251,25 +266,21 @@ function App() { if (questionLangRef.current !== lang) { return; } - let wq = JSON.parse(localStorage.getItem(langKey(lang, "writtenQuestions")) || "[]"); + const wqStorageKey = modeKey("writtenQuestions", editorMode); + let wq = getStoredList(lang, wqStorageKey); const initialLength = wq.length; - const storageKey = queryKey(question.id, editorMode); + const storageKey = modeKey(`questionId-${question.id}`, editorMode); if (query === defaultQuery || query === "") { localStorage.removeItem(langKey(lang, storageKey)); - // remove from writtenQuestions if it exists there as well (only for SQL mode) - if (editorMode === "sql") { - const filtered = wq.filter((id: number) => id !== question.id); - wq = filtered; - } + wq = wq.filter((id: number) => id !== question.id); } else { localStorage.setItem(langKey(lang, storageKey), query); - // ensure that questionid is in localstorage writtenQuestions (only for SQL mode) - if (editorMode === "sql" && !wq.includes(question.id)) { + if (!wq.includes(question.id)) { wq.push(question.id); } } if (wq.length !== initialLength) { - localStorage.setItem(langKey(lang, "writtenQuestions"), JSON.stringify(wq)); + setStoredList(lang, wqStorageKey, wq); setWrittenQuestions(wq); } @@ -469,22 +480,23 @@ function App() { setIsCorrect(true); setMatchedResult(matchedAlt ?? question.evaluable_result); - const correctKey = editorMode === "ra" ? `ra-correctQuestionId-${question.id}` : `correctQuestionId-${question.id}`; - localStorage.setItem(langKey(lang, correctKey), query); + const cKey = modeKey(`correctQuestionId-${question.id}`, editorMode); + localStorage.setItem(langKey(lang, cKey), query); setCorrectQueryMismatch(false); setLoadedQuestionCorrect(true); - const cq = JSON.parse(localStorage.getItem(langKey(lang, "correctQuestions")) || "[]"); + const cqStorageKey = modeKey("correctQuestions", editorMode); + const cq = getStoredList(lang, cqStorageKey); if (!cq.includes(question.id)) { cq.push(question.id); - localStorage.setItem(langKey(lang, "correctQuestions"), JSON.stringify(cq)); + setStoredList(lang, cqStorageKey, cq); setCorrectQuestions(cq); } - }, [result, question, query, evaluatedQuery, exportingStatus, lang]); + }, [result, question, query, evaluatedQuery, exportingStatus, lang, editorMode]); // Save query based on question const loadQuery = useCallback((_oldQuestion: Question | undefined, newQuestion: Question) => { - const key = queryKey(newQuestion.id, editorMode); + const key = modeKey(`questionId-${newQuestion.id}`, editorMode); setQuery(localStorage.getItem(langKey(lang, key)) || (editorMode === "ra" ? "" : defaultQuery)); // This prevents user from ctrl-z'ing to a different question if (editorRef.current) { @@ -498,8 +510,8 @@ function App() { return; } - const correctKey = editorMode === "ra" ? `ra-correctQuestionId-${question.id}` : `correctQuestionId-${question.id}`; - const correctQuery = localStorage.getItem(langKey(lang, correctKey)); + const cKey = modeKey(`correctQuestionId-${question.id}`, editorMode); + const correctQuery = localStorage.getItem(langKey(lang, cKey)); if (!correctQuery) { setCorrectQueryMismatch(false); setLoadedQuestionCorrect(false); @@ -563,15 +575,20 @@ function App() { output += "/* --- BEGIN Validation --- */\n"; - output += "/* --- BEGIN Submission Summary --- */\n"; - const writtenQueries = localStorage.getItem(langKey(lang, "correctQuestions")) || "[]"; - const parsed = JSON.parse(writtenQueries) as number[]; - const questionsString = parsed.filter((id) => options === undefined || (options.include && options.include.includes(id))).map((id) => { + // Cache localStorage reads used multiple times below + const storedCorrectIds = getStoredList(lang, "correctQuestions"); + const storedWrittenIds = getStoredList(lang, "writtenQuestions"); + + const formatQuestionIds = (ids: number[]) => ids.map((id) => { const category = questions.find(c => c.questions.some(q => q.id === id))!; const q = category.questions.find(q => q.id === id)!; return { formatted: `${category.display_number}${q.display_sequence}`, number: category.display_number, sequence: q.display_sequence }; }).sort((a, b) => a.sequence.localeCompare(b.sequence)).sort((a, b) => a.number - b.number).map(q => q.formatted).join(", "); - output += `-- Written Questions: ${questionsString}\n`; + + const filterIds = (ids: number[]) => ids.filter((id) => options === undefined || (options.include && options.include.includes(id))); + + output += "/* --- BEGIN Submission Summary --- */\n"; + output += `-- Written Questions: ${formatQuestionIds(filterIds(storedCorrectIds))}\n`; output += "/* --- END Submission Summary --- */\n"; if (views.length > 0) { output += "/* --- BEGIN Views --- */\n"; @@ -593,10 +610,8 @@ function App() { } output += "/* --- BEGIN Submission Queries --- */\n"; - const queriesStr = localStorage.getItem(langKey(lang, "correctQuestions")); - if (queriesStr) { - const parsed = JSON.parse(queriesStr) as number[]; - const sorted = parsed.filter((id) => options === undefined || (options.include && options.include.includes(id))).map((id) => { + if (storedCorrectIds.length > 0) { + const sorted = filterIds(storedCorrectIds).map((id) => { const category = questions.find(c => c.questions.some(q => q.id === id))!; const q = category.questions.find(q => q.id === id)!; return { category, question: q }; @@ -627,55 +642,30 @@ function App() { output += "/* --- END Submission Queries --- */\n"; output += "/* --- BEGIN Save Summary --- */\n"; - const existingQueries = localStorage.getItem(langKey(lang, "writtenQuestions")) || "[]"; - const existingParsed = JSON.parse(existingQueries) as number[]; - const existingQuestions = existingParsed.map((id) => { - const category = questions.find(c => c.questions.some(q => q.id === id))!; - const q = category.questions.find(q => q.id === id)!; - return { formatted: `${category.display_number}${q.display_sequence}`, number: category.display_number, sequence: q.display_sequence }; - }).sort((a, b) => a.sequence.localeCompare(b.sequence)).sort((a, b) => a.number - b.number).map(q => q.formatted).join(", "); - output += `-- Written Questions: ${existingQuestions}\n`; + output += `-- Written Questions: ${formatQuestionIds(storedWrittenIds)}\n`; output += "/* --- END Save Summary --- */\n"; - output += "/* --- BEGIN Raw Queries --- */\n"; - output += "/*\n"; - const allQueries = localStorage.getItem(langKey(lang, "writtenQuestions")); - if (allQueries) { - const parsed = JSON.parse(allQueries); + const collectQueries = (ids: number[], keyPrefix: string) => { const queries: { [key: number]: string } = {}; - for (const id of parsed) { - const activeQuery = localStorage.getItem(langKey(lang, "questionId-" + id)); - if (!activeQuery) { - continue; - } - queries[id] = activeQuery; + for (const id of ids) { + const q = localStorage.getItem(langKey(lang, keyPrefix + id)); + if (q) queries[id] = q; } - output += JSON.stringify(queries, null, 0).replace(/\*\//g, "\\*/"); - } + return queries; + }; + + output += "/* --- BEGIN Raw Queries --- */\n"; + output += "/*\n"; + output += JSON.stringify(collectQueries(storedWrittenIds, "questionId-"), null, 0).replace(/\*\//g, "\\*/"); output += "\n*/\n"; output += "/* --- END Raw Queries --- */\n"; output += "/* --- BEGIN Correct Raw Queries --- */\n"; output += "/*\n"; - const allCorrectQueries = localStorage.getItem(langKey(lang, "correctQuestions")); - if (allCorrectQueries) { - const parsed = JSON.parse(allCorrectQueries); - const queries: { [key: number]: string } = {}; - for (const id of parsed) { - const activeQuery = localStorage.getItem(langKey(lang, "correctQuestionId-" + id)); - if (!activeQuery) { - continue; - } - queries[id] = activeQuery; - } - output += JSON.stringify(queries, null, 0).replace(/\*\//g, "\\*/"); - } + output += JSON.stringify(collectQueries(storedCorrectIds, "correctQuestionId-"), null, 0).replace(/\*\//g, "\\*/"); output += "\n*/\n"; output += "/* --- END Correct Raw Queries --- */\n"; output += "/* --- BEGIN Raw List Dumps --- */\n"; - output += "-- " + (localStorage.getItem(langKey(lang, "writtenQuestions")) === null ? "[]" : localStorage.getItem(langKey(lang, "writtenQuestions"))) + "\n"; - output += "-- " + (localStorage.getItem(langKey(lang, "correctQuestions")) === null ? "[]" : - JSON.stringify((JSON.parse(localStorage.getItem(langKey(lang, "correctQuestions"))!) as number[]) - .filter((id) => options === undefined || (options.include && options.include.includes(id)))) - ) + "\n"; + output += "-- " + JSON.stringify(storedWrittenIds) + "\n"; + output += "-- " + JSON.stringify(filterIds(storedCorrectIds)) + "\n"; output += "/* --- END Raw List Dumps --- */\n"; output += "/* --- END Validation --- */\n"; @@ -700,10 +690,8 @@ function App() { const applyMergedData = useCallback((merged: ParsedSaveData) => { // Clear current data - const oldWritten: number[] = JSON.parse(localStorage.getItem(langKey(lang, "writtenQuestions")) || "[]"); - oldWritten.forEach(id => localStorage.removeItem(langKey(lang, `questionId-${id}`))); - const oldCorrect: number[] = JSON.parse(localStorage.getItem(langKey(lang, "correctQuestions")) || "[]"); - oldCorrect.forEach(id => localStorage.removeItem(langKey(lang, `correctQuestionId-${id}`))); + getStoredList(lang, "writtenQuestions").forEach(id => localStorage.removeItem(langKey(lang, `questionId-${id}`))); + getStoredList(lang, "correctQuestions").forEach(id => localStorage.removeItem(langKey(lang, `correctQuestionId-${id}`))); localStorage.removeItem(langKey(lang, "writtenQuestions")); localStorage.removeItem(langKey(lang, "correctQuestions")); @@ -720,8 +708,8 @@ function App() { setWrittenQuestions(merged.writtenQuestionIds); setCorrectQuestions(merged.correctQuestionIds); - localStorage.setItem(langKey(lang, "writtenQuestions"), JSON.stringify(merged.writtenQuestionIds)); - localStorage.setItem(langKey(lang, "correctQuestions"), JSON.stringify(merged.correctQuestionIds)); + setStoredList(lang, "writtenQuestions", merged.writtenQuestionIds); + setStoredList(lang, "correctQuestions", merged.correctQuestionIds); // Update views in database for (const view of views) { @@ -782,14 +770,14 @@ function App() { return; } - const toExportQuery = localStorage.getItem(langKey(lang, `correctQuestionId-${question.id}`)); + const toExportQuery = localStorage.getItem(langKey(lang, modeKey(`correctQuestionId-${question.id}`, editorMode))); if (!toExportQuery) { return; } setExportQuery(toExportQuery); setExportQuestion(question); - }, [exportView, loadedQuestionCorrect, question, lang]); + }, [exportView, loadedQuestionCorrect, question, lang, editorMode]); const exportImageView = useCallback((name: string) => { if (!database || exportQuery) { @@ -869,9 +857,25 @@ function App() { }); }, [evaluatedQuery, exportQuery, exportRendererRef, getTheme, isDarkMode, exportingStatus, question, resetResult, setTheme, exportQuestion, exportView]); + const exportRendererProps = useMemo(() => { + if (!exportQuestion || !exportQuery) return null; + let sqlForExport = exportQuery; + if (editorMode === "ra" && database) { + try { sqlForExport = raToSQL(exportQuery, database); } catch { /* use raw */ } + } + const exportResult = evalSql(sqlForExport); + return { + isCorrect: isCorrectResult(exportQuestion.evaluable_result, exportResult) || (exportQuestion.alternative_evaluable_results?.some(alt => isCorrectResult(alt, exportResult)) ?? false), + question: exportQuestion, + code: exportQuery, + result: exportResult, + mode: editorMode, + }; + }, [exportQuestion, exportQuery, editorMode, database, evalSql]); + return (
- {exportQuestion && exportQuery && isCorrectResult(alt, evalSql(exportQuery))) ?? false), question: exportQuestion, code: exportQuery, result: evalSql(exportQuery)}} ref={exportRendererRef} />} + {exportRendererProps && } {exportView && }
@@ -954,6 +958,7 @@ function App() { ref={editorRef} /> } + {editorMode === "ra" && query !== undefined && }
{/* Error/Warning and Action Buttons - Same Row */} @@ -985,8 +990,7 @@ function App() { variant="outline" onClick={() => { if (!question) return; - const correctKey = editorMode === "ra" ? `ra-correctQuestionId-${question.id}` : `correctQuestionId-${question.id}`; - setQuery(localStorage.getItem(langKey(lang, correctKey)) || (editorMode === "ra" ? "" : defaultQuery)); + setQuery(localStorage.getItem(langKey(lang, modeKey(`correctQuestionId-${question.id}`, editorMode))) || (editorMode === "ra" ? "" : defaultQuery)); }} className="border-yellow-500 text-yellow-600 dark:text-yellow-400 hover:bg-yellow-50 dark:hover:bg-yellow-900/20" > diff --git a/src/ExportRenderer.tsx b/src/ExportRenderer.tsx index 945d694..b658e50 100644 --- a/src/ExportRenderer.tsx +++ b/src/ExportRenderer.tsx @@ -8,6 +8,7 @@ import ResultTable from "./ResultTable"; import { Question } from "./QuestionSelector"; import { View } from "./ViewsTable"; import { useLanguage } from "./i18n/context"; +import { renderRAPreview } from "./ra-engine/RAPreview"; interface ExportRendererProps { query?: { @@ -15,6 +16,7 @@ interface ExportRendererProps { isCorrect: boolean; code: string; result: Result; + mode?: "sql" | "ra"; }; view?: { view: View; @@ -53,23 +55,30 @@ const ExportRenderer = React.forwardRef(({

{t("exportViewCodeLabel", { name: view.view.name })}

} - null} - highlight={code => highlight(code, languages.sql)} - padding={10} - tabSize={4} - className="font-mono text-xl w-full bg-slate-200 border-2 max-w-4xl min-h-40 border-none my-2" - /> + {query?.mode === "ra" ? ( +
+ ) : ( + null} + highlight={code => highlight(code, languages.sql)} + padding={10} + tabSize={4} + className="font-mono text-xl w-full bg-slate-200 border-2 max-w-4xl min-h-40 border-none my-2" + /> + )} {query &&

{t("exportResultLabel")}

} diff --git a/src/ra-engine/RAPreview.tsx b/src/ra-engine/RAPreview.tsx new file mode 100644 index 0000000..6f59850 --- /dev/null +++ b/src/ra-engine/RAPreview.tsx @@ -0,0 +1,204 @@ +/* +Relational Algebra engine for sql-validator +Copyright (C) 2026 E.SU. IT AB (Org.no 559484-0505) and Edwin Sundberg + +Licensed under the Business Source License 1.1 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License in the LICENSE.md file in this repository. +*/ + +import { useMemo } from "react"; +import { + UNARY_SYMBOLS, BINARY_SYMBOLS, + LOGIC_KEYWORDS, UNARY_KEYWORD_SYMBOLS, BINARY_KEYWORD_SYMBOLS, + IDENT_START, IDENT_CHAR, + esc, skipWs, highlightSubContent, extractBracketContent, extractImplicitSubscript, +} from "./raShared"; + +/** + * Renders a relational algebra expression with proper formatting: + * - Keyword operators replaced with Unicode symbols + * - Bracket content rendered as subscripts using tags + * - Assignment arrows rendered as ← + */ + +const previewWrap = (type: string, text: string) => + `${text}`; + +function renderSubscriptHtml(content: string): string { + return `${highlightSubContent(content, previewWrap)}`; +} + +/** Consume a subscript and return [newIndex, html] */ +function consumeSubscript(code: string, i: number): [number, string] { + const beforeWs = i; + const wsI = skipWs(code, i); + + // Try bracket content + const [bracketI, bracketContent] = extractBracketContent(code, i); + if (bracketContent !== null) { + return [bracketI, renderSubscriptHtml(bracketContent)]; + } + + // Try implicit subscript + const [implI, implContent] = extractImplicitSubscript(code, wsI, beforeWs); + if (implContent !== null) { + return [implI, renderSubscriptHtml(implContent)]; + } + + return [beforeWs, ""]; +} + +export function renderRAPreview(code: string): string { + const result: string[] = []; + let i = 0; + + while (i < code.length) { + // Comments + if (code[i] === "-" && i + 1 < code.length && code[i + 1] === "-") { + let comment = ""; + while (i < code.length && code[i] !== "\n") { comment += code[i]; i++; } + result.push(`${esc(comment)}`); + continue; + } + + if (code[i] === "\n") { + result.push("
"); + i++; + continue; + } + + // Unicode unary operators + if (UNARY_SYMBOLS.has(code[i])) { + result.push(`${esc(code[i])}`); + i++; + const [newI, html] = consumeSubscript(code, i); + i = newI; + result.push(html); + continue; + } + + // |X| or |><| natural join + if (code[i] === "|") { + if (i + 2 < code.length && (code[i + 1] === "X" || code[i + 1] === "x") && code[i + 2] === "|") { + result.push(""); + i += 3; + continue; + } + if (i + 3 < code.length && code[i + 1] === ">" && code[i + 2] === "<" && code[i + 3] === "|") { + result.push(""); + i += 4; + continue; + } + } + + // Binary symbols + if (BINARY_SYMBOLS.has(code[i])) { + result.push(`${esc(code[i])}`); + i++; + if (i < code.length && (code[i] === "[" || code[i] === "{" || code[i] === "_")) { + const [newI, html] = consumeSubscript(code, i); + i = newI; + result.push(html); + } + continue; + } + + // <- assignment + if (code[i] === "<" && i + 1 < code.length && code[i + 1] === "-") { + result.push(""); + i += 2; + continue; + } + + // -> rename + if (code[i] === "-" && i + 1 < code.length && code[i + 1] === ">") { + result.push(""); + i += 2; + continue; + } + + // String literals + if (code[i] === "'") { + let str = "'"; + i++; + while (i < code.length && code[i] !== "'") { str += code[i]; i++; } + if (i < code.length) { str += "'"; i++; } + result.push(`${esc(str)}`); + continue; + } + + // Numbers + if (/\d/.test(code[i])) { + let num = ""; + while (i < code.length && /[\d.]/.test(code[i])) { num += code[i]; i++; } + result.push(`${esc(num)}`); + continue; + } + + // Identifiers and keywords + if (IDENT_START.test(code[i])) { + let ident = ""; + while (i < code.length && IDENT_CHAR.test(code[i])) { ident += code[i]; i++; } + const lower = ident.toLowerCase(); + if (UNARY_KEYWORD_SYMBOLS[lower]) { + result.push(`${UNARY_KEYWORD_SYMBOLS[lower]}`); + const [newI, html] = consumeSubscript(code, i); + i = newI; + result.push(html); + } else if (BINARY_KEYWORD_SYMBOLS[lower]) { + result.push(`${BINARY_KEYWORD_SYMBOLS[lower]}`); + const j = skipWs(code, i); + if (j < code.length && (code[j] === "[" || code[j] === "{" || code[j] === "_")) { + const [newI, html] = consumeSubscript(code, j); + i = newI; + result.push(html); + } + } else if (LOGIC_KEYWORDS.has(lower)) { + result.push(`${esc(ident)}`); + } else { + result.push(esc(ident)); + } + continue; + } + + // Comparison operators + if ("<>!=".includes(code[i])) { + let op = code[i]; i++; + if (i < code.length && (code[i] === "=" || code[i] === ">")) { op += code[i]; i++; } + result.push(`${esc(op)}`); + continue; + } + + // Everything else + result.push(esc(code[i])); + i++; + } + + return result.join(""); +} + +interface RAPreviewProps { + code: string; +} + +export default function RAPreview({ code }: RAPreviewProps) { + const html = useMemo(() => { + const trimmed = code.trim(); + if (!trimmed || trimmed.startsWith("--") && !trimmed.includes("\n")) { + return null; + } + return renderRAPreview(trimmed); + }, [code]); + + if (!html) return null; + + return ( +
+
+
+ ); +} diff --git a/src/ra-engine/raHighlight.ts b/src/ra-engine/raHighlight.ts index 549e1a4..5745a22 100644 --- a/src/ra-engine/raHighlight.ts +++ b/src/ra-engine/raHighlight.ts @@ -11,45 +11,33 @@ You may obtain a copy of the License in the LICENSE.md file in this repository. * Custom syntax highlighter for relational algebra expressions. * Returns HTML with subscript rendering for bracket content and * colored tokens for operators, keywords, strings, etc. + * + * IMPORTANT: This highlighter must preserve character count (each input char + * maps to exactly one output char) for editor overlay alignment. */ -// Escape HTML special characters -function esc(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -// RA operator symbols -const UNARY_SYMBOLS = new Set(["σ", "π", "ρ", "γ", "τ", "δ"]); -const BINARY_SYMBOLS = new Set(["×", "⋈", "∪", "∩", "−", "÷", "⟕", "⟖", "⟗", "⋉", "⋊", "▷", "←"]); - -const UNARY_KEYWORDS = new Set([ - "sigma", "select", "pi", "project", "rho", "rename", - "gamma", "tau", "sort", "delta", "distinct", -]); -const BINARY_KEYWORDS = new Set([ - "cross", "natjoin", "join", "union", "intersect", "minus", - "divide", "leftjoin", "rightjoin", "fulljoin", - "leftsemijoin", "rightsemijoin", "antijoin", -]); -const LOGIC_KEYWORDS = new Set(["and", "or", "not"]); +import { + UNARY_SYMBOLS, BINARY_SYMBOLS, UNARY_KEYWORDS, BINARY_KEYWORDS, + LOGIC_KEYWORDS, IDENT_START, IDENT_CHAR, + esc, skipWs, highlightSubContent, extractBracketContent, extractImplicitSubscript, +} from "./raShared"; -// CSS classes (inline styles for portability with the code editor) +// Inline styles for portability with the code editor const S = { - op: "color: #7c3aed; font-weight: bold;", // purple - operators - kw: "color: #7c3aed; font-weight: bold;", // purple - keyword operators - logic: "color: #2563eb; font-weight: bold;", // blue - AND/OR/NOT - str: "color: #059669;", // green - strings - num: "color: #d97706;", // amber - numbers - comment: "color: #9ca3af; font-style: italic;", // gray - comments - bracket: "color: #a1a1aa; opacity: 0.5;", // zinc, faint - brackets (subscript delimiters) - assign: "color: #7c3aed; font-weight: bold;", // purple - assignment arrow - sub: "color: #c084fc; font-weight: 500;", // light purple - subscript content (readable, distinct from plain text) + op: "color: #7c3aed; font-weight: bold;", + kw: "color: #7c3aed; font-weight: bold;", + logic: "color: #2563eb; font-weight: bold;", + str: "color: #059669;", + num: "color: #d97706;", + comment: "color: #9ca3af; font-style: italic;", + bracket: "color: #a1a1aa; opacity: 0.5;", + assign: "color: #7c3aed; font-weight: bold;", + sub: "color: #c084fc; font-weight: 500;", }; +const subWrap = (type: string, text: string) => + `${text}`; + /** * Highlight a relational algebra expression, rendering bracket content * as subscripts and coloring operators/keywords. @@ -81,7 +69,6 @@ export function highlightRA(code: string): string { if (UNARY_SYMBOLS.has(code[i])) { result.push(`${esc(code[i])}`); i++; - // Check for subscript: _{ }, [ ], { }, or implicit (tokens until '(') i = renderSubscript(code, i, result); continue; } @@ -103,14 +90,12 @@ export function highlightRA(code: string): string { if (BINARY_SYMBOLS.has(code[i])) { result.push(`${esc(code[i])}`); i++; - // Binary ops can have bracket conditions: ⋈[cond] or ⋈{cond} if (i < code.length && (code[i] === "[" || code[i] === "{" || code[i] === "_")) { i = renderSubscript(code, i, result); } continue; } - // Assignment arrow: ← (already handled above as binary symbol) // Arrow: <- (assignment) — keep both chars for editor alignment if (code[i] === "<" && i + 1 < code.length && code[i + 1] === "-") { result.push(`<-`); @@ -150,23 +135,20 @@ export function highlightRA(code: string): string { } // Identifiers and keywords - if (/[a-zA-Z_\u00C0-\u024F]/.test(code[i])) { + if (IDENT_START.test(code[i])) { let ident = ""; - while (i < code.length && /[a-zA-Z0-9_\u00C0-\u024F]/.test(code[i])) { + while (i < code.length && IDENT_CHAR.test(code[i])) { ident += code[i]; i++; } const lower = ident.toLowerCase(); if (UNARY_KEYWORDS.has(lower)) { result.push(`${esc(ident)}`); - // Check for subscript after keyword i = renderSubscript(code, i, result); } else if (BINARY_KEYWORDS.has(lower)) { result.push(`${esc(ident)}`); - // Binary keyword may have bracket conditions - const j = skipWhitespace(code, i); + const j = skipWs(code, i); if (j < code.length && (code[j] === "[" || code[j] === "{" || code[j] === "_")) { - // Include the whitespace before the bracket if (j > i) result.push(esc(code.slice(i, j))); i = renderSubscript(code, j, result); } @@ -198,22 +180,15 @@ export function highlightRA(code: string): string { return result.join(""); } -function skipWhitespace(code: string, i: number): number { - while (i < code.length && code[i] === " ") i++; - return i; -} - /** * Render a subscript section after a unary/binary operator. * Handles: _{ }, [ ], { }, or implicit (content until '('). * Returns the new index position. */ function renderSubscript(code: string, i: number, result: string[]): number { - // Skip whitespace const beforeWs = i; - while (i < code.length && code[i] === " ") i++; + i = skipWs(code, i); - // Check what follows if (i >= code.length) { if (i > beforeWs) result.push(code.slice(beforeWs, i)); return i; @@ -223,122 +198,34 @@ function renderSubscript(code: string, i: number, result: string[]): number { if (code[i] === "_") { result.push(`${esc("_")}`); i++; - while (i < code.length && code[i] === " ") i++; + i = skipWs(code, i); } + // Try bracket content if (code[i] === "[" || code[i] === "{") { - // Bracketed subscript — render brackets faintly, content as subscript const openBracket = code[i]; result.push(`${esc(openBracket)}`); - i++; // past opening bracket - let depth = 1; - let content = ""; - while (i < code.length && depth > 0) { - if (code[i] === "[" || code[i] === "{") depth++; - if (code[i] === "]" || code[i] === "}") depth--; - if (depth > 0) { - content += code[i]; + const [newI, content] = extractBracketContent(code, i); + if (content !== null) { + result.push(`${highlightSubContent(content, subWrap)}`); + // Render closing bracket + if (newI > i + 1) { + const closeBracket = code[newI - 1]; + result.push(`${esc(closeBracket)}`); } - i++; - } - // Render inner content with subscript styling - result.push(`${highlightSubscriptContent(content)}`); - // Render closing bracket faintly (the character at i-1 was the closing bracket) - if (depth === 0) { - const closeBracket = code[i - 1]; - result.push(`${esc(closeBracket)}`); + return newI; } - return i; } - // Not a bracket — check for implicit subscript (content until '(') - // Only apply for unary operators where we expect a subscript - if (code[i] !== "(" && i > beforeWs) { - // Emit the whitespace between operator and subscript content - result.push(code.slice(beforeWs, i)); - // Collect content until '(' as implicit subscript - let content = ""; - while (i < code.length && code[i] !== "(" && code[i] !== "\n") { - content += code[i]; - i++; - } - // Trim trailing whitespace from content but keep the position - const trimmed = content.trimEnd(); - const trimDiff = content.length - trimmed.length; - if (trimDiff > 0) i -= trimDiff; - if (trimmed.length > 0) { - result.push(`${highlightSubscriptContent(trimmed)}`); - } - return i; + // Implicit subscript + const [newI, content] = extractImplicitSubscript(code, i, beforeWs); + if (content !== null) { + result.push(code.slice(beforeWs, i)); // whitespace + result.push(`${highlightSubContent(content, subWrap)}`); + return newI; } // No subscript found — restore whitespace if (i > beforeWs) result.push(code.slice(beforeWs, i)); return i; } - -/** - * Highlight content inside a subscript (conditions, column lists, etc.) - */ -function highlightSubscriptContent(content: string): string { - const parts: string[] = []; - let i = 0; - - while (i < content.length) { - // String literals - if (content[i] === "'") { - let str = "'"; - i++; - while (i < content.length && content[i] !== "'") { str += content[i]; i++; } - if (i < content.length) { str += "'"; i++; } - parts.push(`${esc(str)}`); - continue; - } - // Numbers - if (/\d/.test(content[i])) { - let num = ""; - while (i < content.length && /[\d.]/.test(content[i])) { num += content[i]; i++; } - parts.push(`${esc(num)}`); - continue; - } - // Keywords and identifiers - if (/[a-zA-Z_\u00C0-\u024F]/.test(content[i])) { - let ident = ""; - while (i < content.length && /[a-zA-Z0-9_\u00C0-\u024F]/.test(content[i])) { ident += content[i]; i++; } - const lower = ident.toLowerCase(); - if (LOGIC_KEYWORDS.has(lower)) { - parts.push(`${esc(ident)}`); - } else if (lower === "desc" || lower === "asc" || lower === "as") { - parts.push(`${esc(ident)}`); - } else if (["count", "sum", "avg", "min", "max"].includes(lower)) { - parts.push(`${esc(ident)}`); - } else { - parts.push(esc(ident)); - } - continue; - } - // Arrow → or -> - if (content[i] === "→") { - parts.push(``); - i++; - continue; - } - if (content[i] === "-" && i + 1 < content.length && content[i + 1] === ">") { - parts.push(`->`); - i += 2; - continue; - } - // Comparison ops - if (content[i] === "<" || content[i] === ">" || content[i] === "!" || content[i] === "=") { - let op = content[i]; i++; - if (i < content.length && (content[i] === "=" || content[i] === ">")) { op += content[i]; i++; } - parts.push(`${esc(op)}`); - continue; - } - // Everything else - parts.push(esc(content[i])); - i++; - } - - return parts.join(""); -} diff --git a/src/ra-engine/raShared.ts b/src/ra-engine/raShared.ts new file mode 100644 index 0000000..c8215eb --- /dev/null +++ b/src/ra-engine/raShared.ts @@ -0,0 +1,197 @@ +/* +Relational Algebra engine for sql-validator +Copyright (C) 2026 E.SU. IT AB (Org.no 559484-0505) and Edwin Sundberg + +Licensed under the Business Source License 1.1 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License in the LICENSE.md file in this repository. +*/ + +/** + * Shared constants and utilities for RA highlighting and preview rendering. + */ + +// RA operator symbol sets +export const UNARY_SYMBOLS = new Set(["σ", "π", "ρ", "γ", "τ", "δ"]); +export const BINARY_SYMBOLS = new Set(["×", "⋈", "∪", "∩", "−", "÷", "⟕", "⟖", "⟗", "⋉", "⋊", "▷", "←"]); + +// RA keyword sets (lowercase) +export const UNARY_KEYWORDS = new Set([ + "sigma", "select", "pi", "project", "rho", "rename", + "gamma", "tau", "sort", "delta", "distinct", +]); +export const BINARY_KEYWORDS = new Set([ + "cross", "natjoin", "join", "union", "intersect", "minus", + "divide", "leftjoin", "rightjoin", "fulljoin", + "leftsemijoin", "rightsemijoin", "antijoin", +]); +export const LOGIC_KEYWORDS = new Set(["and", "or", "not"]); +export const MODIFIER_KEYWORDS = new Set(["asc", "desc", "as"]); +export const AGGREGATE_KEYWORDS = new Set(["count", "sum", "avg", "min", "max"]); + +// Keyword to Unicode symbol mappings (used by RAPreview for pretty-printing) +export const UNARY_KEYWORD_SYMBOLS: Record = { + sigma: "σ", select: "σ", + pi: "π", project: "π", + rho: "ρ", rename: "ρ", + gamma: "γ", + tau: "τ", sort: "τ", + delta: "δ", distinct: "δ", +}; +export const BINARY_KEYWORD_SYMBOLS: Record = { + cross: "×", + natjoin: "⋈", join: "⋈", + union: "∪", + intersect: "∩", + minus: "−", + divide: "÷", + leftjoin: "⟕", + rightjoin: "⟖", + fulljoin: "⟗", + leftsemijoin: "⋉", + rightsemijoin: "⋊", + antijoin: "▷", +}; + +/** Escape HTML special characters */ +export function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** Skip whitespace (spaces only) */ +export function skipWs(code: string, i: number): number { + while (i < code.length && code[i] === " ") i++; + return i; +} + +/** Regex patterns for identifier characters */ +export const IDENT_START = /[a-zA-Z_\u00C0-\u024F]/; +export const IDENT_CHAR = /[a-zA-Z0-9_\u00C0-\u024F]/; + +/** + * A wrapper function that produces a styled HTML span. + * Takes a semantic token type and the escaped text. + */ +export type StyleWrapper = (type: "op" | "logic" | "str" | "num" | "ident", text: string) => string; + +/** + * Tokenize and highlight subscript content (conditions, column lists, etc.) + * using the provided style wrapper for output. + */ +export function highlightSubContent(content: string, wrap: StyleWrapper): string { + const parts: string[] = []; + let i = 0; + + while (i < content.length) { + // String literals + if (content[i] === "'") { + let str = "'"; + i++; + while (i < content.length && content[i] !== "'") { str += content[i]; i++; } + if (i < content.length) { str += "'"; i++; } + parts.push(wrap("str", esc(str))); + continue; + } + // Numbers + if (/\d/.test(content[i])) { + let num = ""; + while (i < content.length && /[\d.]/.test(content[i])) { num += content[i]; i++; } + parts.push(wrap("num", esc(num))); + continue; + } + // Keywords and identifiers + if (IDENT_START.test(content[i])) { + let ident = ""; + while (i < content.length && IDENT_CHAR.test(content[i])) { ident += content[i]; i++; } + const lower = ident.toLowerCase(); + if (LOGIC_KEYWORDS.has(lower) || MODIFIER_KEYWORDS.has(lower)) { + parts.push(wrap("logic", esc(ident))); + } else if (AGGREGATE_KEYWORDS.has(lower)) { + parts.push(wrap("op", esc(ident))); + } else { + parts.push(esc(ident)); + } + continue; + } + // Arrow → or -> + if (content[i] === "→") { + parts.push(wrap("op", "→")); + i++; + continue; + } + if (content[i] === "-" && i + 1 < content.length && content[i + 1] === ">") { + parts.push(wrap("op", esc("->"))); + i += 2; + continue; + } + // Comparison ops + if ("<>!=".includes(content[i])) { + let op = content[i]; i++; + if (i < content.length && (content[i] === "=" || content[i] === ">")) { op += content[i]; i++; } + parts.push(wrap("logic", esc(op))); + continue; + } + // Everything else + parts.push(esc(content[i])); + i++; + } + + return parts.join(""); +} + +/** + * Extract bracket content from code starting at position i. + * Handles [..], {..}, and _{..} patterns. + * Returns [newIndex, extractedContent] or [originalIndex, null] if no bracket found. + */ +export function extractBracketContent(code: string, startI: number): [number, string | null] { + let i = skipWs(code, startI); + if (i >= code.length) return [startI, null]; + + // Handle _{ or _[ prefix + if (code[i] === "_") { + i++; + i = skipWs(code, i); + } + + if (code[i] === "[" || code[i] === "{") { + i++; // past opening bracket + let depth = 1; + let content = ""; + while (i < code.length && depth > 0) { + if (code[i] === "[" || code[i] === "{") depth++; + if (code[i] === "]" || code[i] === "}") depth--; + if (depth > 0) content += code[i]; + i++; + } + return [i, content]; + } + + return [startI, null]; +} + +/** + * Extract implicit subscript content (tokens until '(' or newline). + * Returns [newIndex, extractedContent] or [originalIndex, null] if nothing found. + */ +export function extractImplicitSubscript(code: string, startI: number, beforeWs: number): [number, string | null] { + let i = startI; + if (code[i] === "(" || i <= beforeWs) return [startI, null]; + + let content = ""; + while (i < code.length && code[i] !== "(" && code[i] !== "\n") { + content += code[i]; + i++; + } + const trimmed = content.trimEnd(); + const trimDiff = content.length - trimmed.length; + if (trimDiff > 0) i -= trimDiff; + if (trimmed.length > 0) { + return [i, trimmed]; + } + return [startI, null]; +} From 63d5743db3a5cb448b01c39e63166a56041a4926 Mon Sep 17 00:00:00 2001 From: Edwin <60476129+Edwinexd@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:37:55 +0000 Subject: [PATCH 5/7] Fix SQL generation and add comprehensive execution tests --- src/App.test.tsx | 8 - src/ra-engine/raHighlight.ts | 8 +- src/ra-engine/relationalAlgebra.test.ts | 623 +++++++++++++++++++++++- src/ra-engine/relationalAlgebra.ts | 113 ++++- 4 files changed, 722 insertions(+), 30 deletions(-) delete mode 100644 src/App.test.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index 9382b9a..0000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import App from "./App"; - -test("renders learn react link", () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/src/ra-engine/raHighlight.ts b/src/ra-engine/raHighlight.ts index 5745a22..d22c76a 100644 --- a/src/ra-engine/raHighlight.ts +++ b/src/ra-engine/raHighlight.ts @@ -208,10 +208,12 @@ function renderSubscript(code: string, i: number, result: string[]): number { const [newI, content] = extractBracketContent(code, i); if (content !== null) { result.push(`${highlightSubContent(content, subWrap)}`); - // Render closing bracket - if (newI > i + 1) { + // Render closing bracket (only if bracket was actually closed) + if (newI > i + 1 && newI <= code.length) { const closeBracket = code[newI - 1]; - result.push(`${esc(closeBracket)}`); + if (closeBracket === "]" || closeBracket === "}") { + result.push(`${esc(closeBracket)}`); + } } return newI; } diff --git a/src/ra-engine/relationalAlgebra.test.ts b/src/ra-engine/relationalAlgebra.test.ts index 3b16610..70bfa1f 100644 --- a/src/ra-engine/relationalAlgebra.test.ts +++ b/src/ra-engine/relationalAlgebra.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import initSqlJs from "sql.js"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import initSqlJs, { type Database } from "sql.js"; import { raToSQL, RAError } from "./relationalAlgebra"; // ─── Helper ───────────────────────────────────────────────────────────────── @@ -281,6 +281,52 @@ describe("set operations", () => { const sql = raToSQL("A \\ B"); expect(norm(sql)).toContain("EXCEPT"); }); + + it("should generate valid SQL for bare table set operations", () => { + // Set ops need SELECT statements on both sides, not bare table names + const unionSql = norm(raToSQL("A union B")); + expect(unionSql).toMatch(/SELECT \* FROM A\s+UNION\s+SELECT \* FROM B/i); + + const exceptSql = norm(raToSQL("A minus B")); + expect(exceptSql).toMatch(/SELECT \* FROM A\s+EXCEPT\s+SELECT \* FROM B/i); + + const intersectSql = norm(raToSQL("A intersect B")); + expect(intersectSql).toMatch(/SELECT \* FROM A\s+INTERSECT\s+SELECT \* FROM B/i); + }); + + it("should handle set difference with hyphenated expression", () => { + const sql = raToSQL("PI[name, city](Person - Teacher)"); + expect(norm(sql)).toContain("EXCEPT"); + expect(norm(sql)).toContain("SELECT name, city"); + }); + + it("should error on union-incompatible column counts when database is provided", async () => { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run("CREATE TABLE A (x INTEGER, y TEXT)"); + db.run("CREATE TABLE B (x INTEGER, y TEXT, z TEXT)"); + + expect(() => raToSQL("A union B", db)).toThrow(RAError); + expect(() => raToSQL("A union B", db)).toThrow(/same number of columns/i); + expect(() => raToSQL("A minus B", db)).toThrow(RAError); + expect(() => raToSQL("A intersect B", db)).toThrow(RAError); + + // Should NOT throw when column counts match + db.run("CREATE TABLE C (a INTEGER, b TEXT)"); + expect(() => raToSQL("A union C", db)).not.toThrow(); + db.close(); + }); + + it("should include column names in union-incompatible error message", async () => { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run("CREATE TABLE R1 (name TEXT, city TEXT)"); + db.run("CREATE TABLE R2 (name TEXT, city TEXT, age INTEGER)"); + + expect(() => raToSQL("R1 union R2", db)).toThrow(/Left has 2/); + expect(() => raToSQL("R1 union R2", db)).toThrow(/right has 3/); + db.close(); + }); }); // ─── Outer joins ──────────────────────────────────────────────────────────── @@ -793,3 +839,576 @@ describe("implicit return from last assignment", () => { expect(norm(sql)).toContain("SELECT * FROM X_v3"); }); }); + +// ─── SQLite execution ────────────────────────────────────────────────────── +// Every generated SQL statement must actually execute against a real database. + +describe("SQLite execution", () => { + let db: Database; + + beforeAll(async () => { + const SQL = await initSqlJs(); + db = new SQL.Database(); + db.run(` + CREATE TABLE Person (id INTEGER PRIMARY KEY, name TEXT, age INTEGER, city TEXT); + INSERT INTO Person VALUES (1, 'Alice', 25, 'Stockholm'); + INSERT INTO Person VALUES (2, 'Bob', 19, 'York'); + INSERT INTO Person VALUES (3, 'Carol', 30, 'Bristol'); + + CREATE TABLE Student (id INTEGER PRIMARY KEY, hasDisability INTEGER); + INSERT INTO Student VALUES (1, 0); + INSERT INTO Student VALUES (2, 1); + + CREATE TABLE Teacher (id INTEGER PRIMARY KEY, name TEXT, age INTEGER, city TEXT, department TEXT); + INSERT INTO Teacher VALUES (10, 'Dave', 45, 'Stockholm', 'CS'); + INSERT INTO Teacher VALUES (11, 'Eve', 38, 'York', 'Math'); + + CREATE TABLE Course (course_id INTEGER PRIMARY KEY, title TEXT, credits INTEGER); + INSERT INTO Course VALUES (100, 'Databases', 7); + INSERT INTO Course VALUES (101, 'Algorithms', 5); + + CREATE TABLE Enrollment (id INTEGER, course_id INTEGER); + INSERT INTO Enrollment VALUES (1, 100); + INSERT INTO Enrollment VALUES (1, 101); + INSERT INTO Enrollment VALUES (2, 100); + `); + }); + + afterAll(() => db.close()); + + /** Convert RA to SQL and execute, returning result rows */ + function execRA(expr: string): initSqlJs.QueryExecResult[] { + const sql = raToSQL(expr, db); + return db.exec(sql); + } + + // ── Selection ── + + it("σ with condition", () => { + const res = execRA("σ[age > 20](Person)"); + expect(res[0].values.length).toBe(2); // Alice (25) and Carol (30) + }); + + it("σ with string comparison", () => { + const res = execRA("σ[name = 'Alice'](Person)"); + expect(res[0].values.length).toBe(1); + expect(res[0].values[0]).toContain("Alice"); + }); + + it("σ with compound condition", () => { + const res = execRA("σ[age > 20 and city = 'Stockholm'](Person)"); + expect(res[0].values.length).toBe(1); + expect(res[0].values[0]).toContain("Alice"); + }); + + // ── Projection ── + + it("π selects columns", () => { + const res = execRA("π[name, city](Person)"); + expect(res[0].columns).toEqual(["name", "city"]); + expect(res[0].values.length).toBe(3); + }); + + // ── Rename ── + + it("ρ renames columns", () => { + const res = execRA("ρ[name→fullName](Person)"); + expect(res[0].columns).toContain("fullName"); + expect(res[0].columns).not.toContain("name"); + }); + + // ── Natural join ── + + it("⋈ natural join", () => { + const res = execRA("Person ⋈ Student"); + expect(res[0].values.length).toBe(2); // ids 1 and 2 match + }); + + it("natjoin keyword", () => { + const res = execRA("Person natjoin Student"); + expect(res[0].values.length).toBe(2); + }); + + // ── Cross product ── + + it("× cross product", () => { + const res = execRA("Person × Course"); + expect(res[0].values.length).toBe(6); // 3 × 2 + }); + + // ── Theta join ── + + it("⋈[cond] theta join", () => { + // Use unqualified column names since the generator aliases tables as _raN + const res = execRA("Person ⋈[age > credits] Course"); + expect(res[0].values.length).toBeGreaterThan(0); + }); + + // ── Set operations ── + + it("∪ union", () => { + const res = execRA("π[name](Person) ∪ π[name](Teacher)"); + expect(res[0].values.length).toBe(5); // 3 + 2, all distinct names + }); + + it("∩ intersect", () => { + // No overlapping names between Person and Teacher + const res = execRA("π[name](Person) ∩ π[name](Teacher)"); + expect(res.length === 0 || res[0].values.length === 0).toBe(true); + }); + + it("− set difference", () => { + const res = execRA("π[name](Person) − π[name](Teacher)"); + expect(res[0].values.length).toBe(3); // All Person names, none overlap + }); + + it("minus keyword", () => { + const res = execRA("π[name](Person) minus π[name](Teacher)"); + expect(res[0].values.length).toBe(3); + }); + + it("bare table set difference (Person - Teacher)", () => { + // Person and Teacher are union-compatible (both have id, name, age, city) + // but Teacher has an extra column (department) — use projections + const res = execRA("π[id, name](Person) − π[id, name](Teacher)"); + expect(res[0].values.length).toBe(3); + }); + + it("set difference with hyphen syntax", () => { + const res = execRA("π[name, city](Person) - π[name, city](Teacher)"); + expect(res[0].values.length).toBeGreaterThan(0); + }); + + it("errors on union-incompatible set operations", () => { + // Person has 4 cols (id, name, age, city), Course has 3 cols (course_id, title, credits) + expect(() => execRA("Person union Course")).toThrow(RAError); + expect(() => execRA("Person union Course")).toThrow(/same number of columns/i); + expect(() => execRA("Person minus Course")).toThrow(RAError); + expect(() => execRA("Person intersect Course")).toThrow(RAError); + }); + + it("backslash set difference", () => { + const res = execRA("π[name](Person) \\ π[name](Teacher)"); + expect(res[0].values.length).toBe(3); + }); + + // ── Outer joins ── + + it("leftjoin", () => { + // Use unqualified column names since the generator aliases tables as _raN + const res = execRA("Person leftjoin[age > credits] Course"); + expect(res[0].values.length).toBeGreaterThan(0); + }); + + // ── Semi-join ── + + it("⋉ left semi-join returns only matching rows", () => { + const res = execRA("Person ⋉ Student"); + // Person ids 1,2,3 — Student ids 1,2 — semi-join on common col "id" + expect(res[0].values.length).toBe(2); + }); + + // ── Anti-join ── + + it("▷ anti-join returns only non-matching rows", () => { + const res = execRA("Person ▷ Student"); + // Only Carol (id=3) has no matching Student row + expect(res[0].values.length).toBe(1); + expect(res[0].values[0]).toContain("Carol"); + }); + + // ── Distinct ── + + it("δ distinct", () => { + const res = execRA("δ(Person)"); + expect(res[0].values.length).toBe(3); + }); + + // ── Sort ── + + it("τ sort", () => { + const res = execRA("τ[name](Person)"); + expect(res[0].values[0]).toContain("Alice"); + expect(res[0].values[2]).toContain("Carol"); + }); + + it("τ sort DESC", () => { + const res = execRA("τ[age DESC](Person)"); + expect(res[0].values[0]).toContain("Carol"); // age 30, highest + }); + + // ── Grouping / aggregation ── + + it("γ group by with COUNT", () => { + const res = execRA("γ[city; COUNT(id) AS cnt](Person)"); + expect(res[0].columns).toContain("cnt"); + expect(res[0].values.length).toBe(3); // 3 distinct cities + }); + + // ── Division ── + + it("÷ division returns correct result", () => { + // Enrollment: (1,100),(1,101),(2,100) — Course: (100),(101) + // Only id=1 is enrolled in ALL courses + const res = execRA("π[id, course_id](Enrollment) ÷ π[course_id](Course)"); + expect(res[0].values.length).toBe(1); + expect(res[0].values[0]).toContain(1); // id=1 (Alice) + }); + + // ── Composition / nesting ── + + it("π of σ", () => { + const res = execRA("π[name](σ[age > 20](Person))"); + expect(res[0].columns).toEqual(["name"]); + expect(res[0].values.length).toBe(2); + }); + + it("σ of ⋈", () => { + const res = execRA("σ[age > 20](Person ⋈ Student)"); + expect(res[0].values.length).toBe(1); // Only Alice (25) matches + }); + + it("deeply nested", () => { + const res = execRA("π[name](σ[city = 'Stockholm'](Person ⋈ Student))"); + expect(res[0].values.length).toBe(1); + expect(res[0].values[0]).toContain("Alice"); + }); + + // ── Parenthesis-free syntax ── + + it("σ[cond] Table without parens", () => { + const res = execRA("σ[age > 20] Person"); + expect(res[0].values.length).toBe(2); + }); + + it("π[cols] σ[cond] Table chained", () => { + const res = execRA("π[name] σ[age > 20] Person"); + expect(res[0].columns).toEqual(["name"]); + expect(res[0].values.length).toBe(2); + }); + + // ── Implicit subscripts ── + + it("σ cond (R) implicit", () => { + const res = execRA("σ age > 20 (Person)"); + expect(res[0].values.length).toBe(2); + }); + + it("π cols (R) implicit", () => { + const res = execRA("π name, city (Person)"); + expect(res[0].columns).toEqual(["name", "city"]); + }); + + // ── Assignments ── + + it("single assignment", () => { + const res = execRA("A ← σ[age > 20](Person)\nπ[name](A)"); + expect(res[0].values.length).toBe(2); + }); + + it("multiple assignments", () => { + const res = execRA("A ← σ[city = 'Stockholm'](Person)\nB ← π[name](A)\nB"); + expect(res[0].values.length).toBe(1); + expect(res[0].values[0]).toContain("Alice"); + }); + + it("variable reassignment", () => { + const res = execRA("X ← Person\nX ← σ[age > 20](X)\nX ← π[name](X)"); + expect(res[0].values.length).toBe(2); + }); + + // ── LaTeX-style curly braces ── + + it("σ_{cond}(R)", () => { + const res = execRA("σ_{age > 20}(Person)"); + expect(res[0].values.length).toBe(2); + }); + + it("π_{cols}(R)", () => { + const res = execRA("π_{name, city}(Person)"); + expect(res[0].columns).toEqual(["name", "city"]); + }); + + it("ρ_{old→new}(R)", () => { + const res = execRA("ρ_{name→fullName}(Person)"); + expect(res[0].columns).toContain("fullName"); + expect(res[0].columns).not.toContain("name"); + }); + + it("σ{cond}(R) without underscore", () => { + const res = execRA("σ{age > 20}(Person)"); + expect(res[0].values.length).toBe(2); + }); + + it("⋈{cond} theta join with curly braces", () => { + const res = execRA("Person ⋈{age > credits} Course"); + expect(res[0].values.length).toBeGreaterThan(0); + }); + + // ── Selection edge cases ── + + it("σ with OR condition", () => { + const res = execRA("σ[city = 'Stockholm' or city = 'York'](Person)"); + expect(res[0].values.length).toBe(2); // Alice and Bob + }); + + it("σ with NOT condition", () => { + const res = execRA("σ[not age > 20](Person)"); + expect(res[0].values.length).toBe(1); // Only Bob (19) + expect(res[0].values[0]).toContain("Bob"); + }); + + it("σ with nested OR and AND", () => { + const res = execRA("σ[age > 20 or (name = 'Bob' and city = 'York')](Person)"); + expect(res[0].values.length).toBe(3); // Alice, Bob, Carol + }); + + it("σ with all comparison operators", () => { + expect(execRA("σ[age = 25](Person)")[0].values.length).toBe(1); + expect(execRA("σ[age <> 25](Person)")[0].values.length).toBe(2); + expect(execRA("σ[age != 25](Person)")[0].values.length).toBe(2); + expect(execRA("σ[age < 25](Person)")[0].values.length).toBe(1); + expect(execRA("σ[age > 25](Person)")[0].values.length).toBe(1); + expect(execRA("σ[age <= 25](Person)")[0].values.length).toBe(2); + expect(execRA("σ[age >= 25](Person)")[0].values.length).toBe(2); + }); + + it("σ with table.column references", () => { + const res = execRA("σ[Person.age > 20](Person)"); + expect(res[0].values.length).toBe(2); + }); + + // ── Rename edge cases ── + + it("ρ with multiple rename mappings", () => { + const res = execRA("ρ[name→fullName, age→years](Person)"); + expect(res[0].columns).toContain("fullName"); + expect(res[0].columns).toContain("years"); + expect(res[0].columns).not.toContain("name"); + expect(res[0].columns).not.toContain("age"); + expect(res[0].values.length).toBe(3); + }); + + it("ρ with ASCII arrow", () => { + const res = execRA("rho[name->fullName](Person)"); + expect(res[0].columns).toContain("fullName"); + }); + + // ── Outer joins ── + + it("rightjoin", () => { + const res = execRA("Student rightjoin[age > hasDisability] Person"); + expect(res[0].values.length).toBeGreaterThan(0); + }); + + it("fulljoin", () => { + const res = execRA("Person fulljoin[age > credits] Course"); + expect(res[0].values.length).toBeGreaterThan(0); + }); + + it("⟕ left outer join with Unicode", () => { + const res = execRA("Person ⟕[age > credits] Course"); + expect(res[0].values.length).toBeGreaterThan(0); + }); + + it("left join preserves non-matching rows", () => { + // Carol (age 30) has no Student row — left join should keep her with NULLs + const res = execRA("Person leftjoin[Person.id = Student.id] Student"); + expect(res[0].values.length).toBe(3); // All 3 Person rows + }); + + // ── Semi-join edge cases ── + + it("⋊ right semi-join", () => { + const res = execRA("Student ⋊ Person"); + // Student ids 1,2 both exist in Person — all Student rows match + expect(res[0].values.length).toBe(2); + }); + + it("rightsemijoin keyword", () => { + const res = execRA("Student rightsemijoin Person"); + expect(res[0].values.length).toBe(2); + }); + + it("leftsemijoin keyword", () => { + const res = execRA("Person leftsemijoin Student"); + expect(res[0].values.length).toBe(2); + }); + + it("antijoin keyword", () => { + const res = execRA("Person antijoin Student"); + expect(res[0].values.length).toBe(1); + expect(res[0].values[0]).toContain("Carol"); + }); + + // ── Sort edge cases ── + + it("τ with multiple sort columns", () => { + const res = execRA("τ[city, age DESC](Person)"); + // Bristol(30), Stockholm(25), York(19) + expect(res[0].values[0]).toContain("Carol"); // Bristol + expect(res[0].values[1]).toContain("Alice"); // Stockholm + expect(res[0].values[2]).toContain("Bob"); // York + }); + + it("sort keyword", () => { + const res = execRA("sort[name](Person)"); + expect(res[0].values[0]).toContain("Alice"); + expect(res[0].values[2]).toContain("Carol"); + }); + + // ── Aggregation edge cases ── + + it("γ with SUM", () => { + const res = execRA("γ[city; SUM(age) AS totalAge](Person)"); + expect(res[0].columns).toContain("totalAge"); + expect(res[0].values.length).toBe(3); + }); + + it("γ with AVG", () => { + const res = execRA("γ[city; AVG(age) AS avgAge](Person)"); + expect(res[0].columns).toContain("avgAge"); + expect(res[0].values.length).toBe(3); + }); + + it("γ with MIN and MAX", () => { + const res = execRA("γ[city; MIN(age) AS youngest, MAX(age) AS oldest](Person)"); + expect(res[0].columns).toContain("youngest"); + expect(res[0].columns).toContain("oldest"); + }); + + it("γ COUNT without alias", () => { + const res = execRA("γ[city; COUNT(id)](Person)"); + expect(res[0].values.length).toBe(3); + }); + + it("gamma keyword", () => { + const res = execRA("gamma[city; COUNT(id) AS cnt](Person)"); + expect(res[0].columns).toContain("cnt"); + }); + + // ── Distinct edge cases ── + + it("delta keyword", () => { + const res = execRA("delta(Person)"); + expect(res[0].values.length).toBe(3); + }); + + it("distinct keyword", () => { + const res = execRA("distinct(Person)"); + expect(res[0].values.length).toBe(3); + }); + + it("δ without parens", () => { + const res = execRA("δ Person"); + expect(res[0].values.length).toBe(3); + }); + + // ── Keyword variants for operators ── + + it("select keyword", () => { + const res = execRA("select[age > 20](Person)"); + expect(res[0].values.length).toBe(2); + }); + + it("project keyword", () => { + const res = execRA("project[name, city](Person)"); + expect(res[0].columns).toEqual(["name", "city"]); + }); + + it("cross keyword", () => { + const res = execRA("Person cross Course"); + expect(res[0].values.length).toBe(6); + }); + + it("join keyword with condition", () => { + const res = execRA("Person join[age > credits] Course"); + expect(res[0].values.length).toBeGreaterThan(0); + }); + + it("|X| as natural join", () => { + const res = execRA("Person |X| Student"); + expect(res[0].values.length).toBe(2); + }); + + it("|><| as natural join", () => { + const res = execRA("Person |><| Student"); + expect(res[0].values.length).toBe(2); + }); + + it("intersect keyword", () => { + const res = execRA("π[name](Person) intersect π[name](Teacher)"); + expect(res.length === 0 || res[0].values.length === 0).toBe(true); + }); + + it("divide keyword", () => { + const res = execRA("π[id, course_id](Enrollment) divide π[course_id](Course)"); + expect(res[0].values.length).toBe(1); + expect(res[0].values[0]).toContain(1); + }); + + // ── Implicit subscripts edge cases ── + + it("ρ old→new (R) implicit", () => { + const res = execRA("ρ name→fullName (Person)"); + expect(res[0].columns).toContain("fullName"); + }); + + it("τ col (R) implicit", () => { + const res = execRA("τ name (Person)"); + expect(res[0].values[0]).toContain("Alice"); + }); + + it("σ compound implicit with AND", () => { + const res = execRA("σ age > 20 and city = 'Stockholm' (Person)"); + expect(res[0].values.length).toBe(1); + expect(res[0].values[0]).toContain("Alice"); + }); + + it("nested implicit subscripts", () => { + const res = execRA("π name (σ age > 20 (Person))"); + expect(res[0].columns).toEqual(["name"]); + expect(res[0].values.length).toBe(2); + }); + + // ── Parenthesis-free edge cases ── + + it("triple chain without parens", () => { + const res = execRA("π[name] σ[age > 20] δ Person"); + expect(res[0].columns).toEqual(["name"]); + expect(res[0].values.length).toBe(2); + }); + + it("π[cols] over union with parens", () => { + const res = execRA("π[name] (π[name](Person) ∪ π[name](Teacher))"); + expect(res[0].values.length).toBe(5); + }); + + // ── Assignment edge cases ── + + it("assignment with <- ASCII arrow", () => { + const res = execRA("A <- σ[age > 20](Person)\nπ[name](A)"); + expect(res[0].values.length).toBe(2); + }); + + it("semicolons as statement separators", () => { + const res = execRA("A ← Person; π[name](A)"); + expect(res[0].values.length).toBe(3); + }); + + it("comments in multi-line input", () => { + const res = execRA("-- Get adults\nA ← σ[age > 20](Person)\n-- Project names\nπ[name](A)"); + expect(res[0].values.length).toBe(2); + }); + + it("implicit return from last assignment", () => { + const res = execRA("A ← σ[age > 20](Person)"); + expect(res[0].values.length).toBe(2); + }); + + it("complex pipeline with reassignment", () => { + const res = execRA("X ← Person ⋈ Student\nX ← σ[age > 20](X)\nX ← π[name](X)"); + expect(res[0].values.length).toBe(1); + expect(res[0].values[0]).toContain("Alice"); + }); +}); diff --git a/src/ra-engine/relationalAlgebra.ts b/src/ra-engine/relationalAlgebra.ts index 5077118..b1e8514 100644 --- a/src/ra-engine/relationalAlgebra.ts +++ b/src/ra-engine/relationalAlgebra.ts @@ -1001,6 +1001,11 @@ function columnRefToSQL(ref: ColumnRef): string { let subqueryCounter = 0; +/** Return a SQL alias for a node: use the table name for bare tables, otherwise _raN */ +function aliasFor(node: RANode): string { + return node.type === "table" ? node.name : `_ra${subqueryCounter++}`; +} + /** * Interface for a minimal database handle used to resolve column names. * Compatible with sql.js Database. @@ -1048,6 +1053,32 @@ function resolveColumns(sql: string, db: DatabaseHandle): string[] { throw new RAError(msg); } } +/** Ensure a node produces a full SELECT statement (needed for UNION/INTERSECT/EXCEPT) */ +function asSelect(node: RANode, db?: DatabaseHandle): string { + const sql = nodeToSQL(node, db); + // Bare table names need wrapping; anything starting with SELECT is already a query + return sql.trimStart().toUpperCase().startsWith("SELECT") ? sql : `SELECT * FROM ${sql}`; +} + +/** + * Build a WHERE clause correlating two aliases on their common columns. + * Returns empty string if no db or no common columns found. + */ +function buildCorrelation(leftSQL: string, rightSQL: string, lAlias: string, rAlias: string, db?: DatabaseHandle): string { + if (!db) return ""; + try { + const leftCols = resolveColumns(leftSQL, db); + const rightCols = resolveColumns(rightSQL, db); + const common = leftCols.filter(c => rightCols.includes(c)); + if (common.length > 0) { + return " WHERE " + common.map(c => `${lAlias}.${c} = ${rAlias}.${c}`).join(" AND "); + } + } catch { + // If we can't resolve columns, fall back to uncorrelated + } + return ""; +} + function nodeToSQL(node: RANode, db?: DatabaseHandle): string { switch (node.type) { case "table": @@ -1090,7 +1121,7 @@ function nodeToSQL(node: RANode, db?: DatabaseHandle): string { return `SELECT DISTINCT * FROM (${nodeToSQL(node.relation, db)})`; case "crossProduct": - return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} CROSS JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++}`; + return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS ${aliasFor(node.left)} CROSS JOIN (${nodeToSQL(node.right, db)}) AS ${aliasFor(node.right)}`; case "naturalJoin": { const leftSQL = nodeToSQL(node.left, db); @@ -1109,56 +1140,104 @@ function nodeToSQL(node: RANode, db?: DatabaseHandle): string { } } } - return `SELECT * FROM (${leftSQL}) AS _ra${subqueryCounter++} NATURAL JOIN (${rightSQL}) AS _ra${subqueryCounter++}`; + return `SELECT * FROM (${leftSQL}) AS ${aliasFor(node.left)} NATURAL JOIN (${rightSQL}) AS ${aliasFor(node.right)}`; } case "thetaJoin": - return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`; + return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS ${aliasFor(node.left)} JOIN (${nodeToSQL(node.right, db)}) AS ${aliasFor(node.right)} ON ${conditionToSQL(node.condition)}`; case "leftJoin": - return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} LEFT JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`; + 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)}`; case "rightJoin": - return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} RIGHT JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`; + 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)}`; case "fullJoin": - return `SELECT * FROM (${nodeToSQL(node.left, db)}) AS _ra${subqueryCounter++} FULL OUTER JOIN (${nodeToSQL(node.right, db)}) AS _ra${subqueryCounter++} ON ${conditionToSQL(node.condition)}`; + 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)}`; case "leftSemiJoin": { const lAlias = `_ra${subqueryCounter++}`; const rAlias = `_ra${subqueryCounter++}`; - return `SELECT ${lAlias}.* FROM (${nodeToSQL(node.left, db)}) AS ${lAlias} WHERE EXISTS (SELECT 1 FROM (${nodeToSQL(node.right, db)}) AS ${rAlias})`; + const leftSQL = nodeToSQL(node.left, db); + const rightSQL = nodeToSQL(node.right, db); + const corr = buildCorrelation(leftSQL, rightSQL, lAlias, rAlias, db); + return `SELECT ${lAlias}.* FROM (${leftSQL}) AS ${lAlias} WHERE EXISTS (SELECT 1 FROM (${rightSQL}) AS ${rAlias}${corr})`; } case "rightSemiJoin": { const lAlias = `_ra${subqueryCounter++}`; const rAlias = `_ra${subqueryCounter++}`; - return `SELECT ${rAlias}.* FROM (${nodeToSQL(node.right, db)}) AS ${rAlias} WHERE EXISTS (SELECT 1 FROM (${nodeToSQL(node.left, db)}) AS ${lAlias})`; + const leftSQL = nodeToSQL(node.left, db); + const rightSQL = nodeToSQL(node.right, db); + // Outer relation is right; EXISTS checks left — correlate right (outer) with left (inner) + const corr = buildCorrelation(rightSQL, leftSQL, rAlias, lAlias, db); + return `SELECT ${rAlias}.* FROM (${rightSQL}) AS ${rAlias} WHERE EXISTS (SELECT 1 FROM (${leftSQL}) AS ${lAlias}${corr})`; } case "antiJoin": { const lAlias = `_ra${subqueryCounter++}`; const rAlias = `_ra${subqueryCounter++}`; - return `SELECT ${lAlias}.* FROM (${nodeToSQL(node.left, db)}) AS ${lAlias} WHERE NOT EXISTS (SELECT 1 FROM (${nodeToSQL(node.right, db)}) AS ${rAlias})`; + const leftSQL = nodeToSQL(node.left, db); + const rightSQL = nodeToSQL(node.right, db); + const corr = buildCorrelation(leftSQL, rightSQL, lAlias, rAlias, db); + return `SELECT ${lAlias}.* FROM (${leftSQL}) AS ${lAlias} WHERE NOT EXISTS (SELECT 1 FROM (${rightSQL}) AS ${rAlias}${corr})`; } case "union": - return `${nodeToSQL(node.left, db)} UNION ${nodeToSQL(node.right, db)}`; - case "intersect": - return `${nodeToSQL(node.left, db)} INTERSECT ${nodeToSQL(node.right, db)}`; - - case "difference": - return `${nodeToSQL(node.left, db)} EXCEPT ${nodeToSQL(node.right, db)}`; + case "difference": { + const leftSQL = asSelect(node.left, db); + const rightSQL = asSelect(node.right, db); + if (db) { + const leftCols = resolveColumns(leftSQL, db); + const rightCols = resolveColumns(rightSQL, db); + if (leftCols.length > 0 && rightCols.length > 0 && leftCols.length !== rightCols.length) { + const opName = node.type === "union" ? "Union (∪)" : node.type === "intersect" ? "Intersect (∩)" : "Difference (−)"; + throw new RAError( + `${opName} requires both sides to have the same number of columns. ` + + `Left has ${leftCols.length} column(s): [${leftCols.join(", ")}], ` + + `right has ${rightCols.length} column(s): [${rightCols.join(", ")}].` + ); + } + } + const sqlOp = node.type === "union" ? "UNION" : node.type === "intersect" ? "INTERSECT" : "EXCEPT"; + return `${leftSQL} ${sqlOp} ${rightSQL}`; + } case "division": { const lAlias = `_ra${subqueryCounter++}`; const rAlias = `_ra${subqueryCounter++}`; - const crossAlias = `_ra${subqueryCounter++}`; const innerAlias = `_ra${subqueryCounter++}`; const leftSQL = nodeToSQL(node.left, db); const rightSQL = nodeToSQL(node.right, db); - return `SELECT * FROM (SELECT DISTINCT * FROM (${leftSQL}) AS ${lAlias}) AS ${innerAlias} WHERE NOT EXISTS (SELECT * FROM (${rightSQL}) AS ${rAlias} WHERE NOT EXISTS (SELECT * FROM (${leftSQL}) AS ${crossAlias}))`; + + if (db) { + try { + const leftCols = resolveColumns(leftSQL, db); + const rightCols = resolveColumns(rightSQL, db); + const aOnlyCols = leftCols.filter(c => !rightCols.includes(c)); + const bCols = rightCols; + + if (aOnlyCols.length === 0) { + throw new RAError( + "Division requires the dividend to have columns not present in the divisor. " + + `Left columns: [${leftCols.join(", ")}], Right columns: [${rightCols.join(", ")}].` + ); + } + + const aOnlyMatch = aOnlyCols.map(c => `${innerAlias}.${c} = ${lAlias}.${c}`).join(" AND "); + const bMatch = bCols.map(c => `${innerAlias}.${c} = ${rAlias}.${c}`).join(" AND "); + + const selectCols = aOnlyCols.map(c => `${lAlias}.${c}`).join(", "); + return `SELECT DISTINCT ${selectCols} FROM (${leftSQL}) AS ${lAlias} WHERE NOT EXISTS (SELECT 1 FROM (${rightSQL}) AS ${rAlias} WHERE NOT EXISTS (SELECT 1 FROM (${leftSQL}) AS ${innerAlias} WHERE ${aOnlyMatch} AND ${bMatch}))`; + } catch (e) { + if (e instanceof RAError) throw e; + // Fall through to no-db version + } + } + + // Without db: best-effort uncorrelated version + return `SELECT * FROM (SELECT DISTINCT * FROM (${leftSQL}) AS ${lAlias}) AS ${innerAlias} WHERE NOT EXISTS (SELECT * FROM (${rightSQL}) AS ${rAlias} WHERE NOT EXISTS (SELECT * FROM (${leftSQL}) AS _ra${subqueryCounter++}))`; } } } From 9a8fc085dc30ff46a45a65f2020fa7f9483add9d Mon Sep 17 00:00:00 2001 From: Edwin <60476129+Edwinexd@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:08:44 +0100 Subject: [PATCH 6/7] Fix editor highlighter, preview rendering, dark mode, and export readability --- src/App.css | 21 ++- src/App.tsx | 6 +- src/ExportRenderer.tsx | 4 +- src/ra-engine/RAPreview.tsx | 112 +++++++++----- src/ra-engine/raHighlight.test.ts | 174 ++++++++++++++++----- src/ra-engine/raHighlight.ts | 245 +++++++++++++----------------- src/ra-engine/raShared.ts | 59 ++++++- 7 files changed, 380 insertions(+), 241 deletions(-) diff --git a/src/App.css b/src/App.css index 50a362b..f69272f 100644 --- a/src/App.css +++ b/src/App.css @@ -46,17 +46,22 @@ body { background-color: rgba(34, 197, 94, 0.15); } -/* RA Preview styling */ -.ra-prev-op { color: #7c3aed; font-weight: bold; } -.ra-prev-assign { color: #7c3aed; font-weight: bold; } -.ra-prev-logic { color: #2563eb; font-weight: bold; } -.ra-prev-str { color: #059669; } -.ra-prev-num { color: #d97706; } -.ra-prev-comment { color: #9ca3af; font-style: italic; } -.ra-prev-sub { font-size: 0.8em; color: #6d28d9; } +/* RA Preview styling — light mode */ +.ra-prev-op { color: #a78bfa; font-weight: bold; } +.ra-prev-assign { color: #a78bfa; font-weight: bold; } +.ra-prev-logic { color: #1d4ed8; font-weight: bold; } +.ra-prev-str { color: #047857; } +.ra-prev-num { color: #b45309; } +.ra-prev-comment { color: #6b7280; font-style: italic; } +.ra-prev-sub { font-size: 0.9em; color: #4c1d95; } +.ra-prev-paren { color: #9ca3af; } + +/* RA Preview styling — dark mode */ .dark .ra-prev-op { color: #a78bfa; } .dark .ra-prev-assign { color: #a78bfa; } .dark .ra-prev-logic { color: #60a5fa; } .dark .ra-prev-str { color: #34d399; } .dark .ra-prev-num { color: #fbbf24; } +.dark .ra-prev-comment { color: #9ca3af; } .dark .ra-prev-sub { color: #c4b5fd; } +.dark .ra-prev-paren { color: #6b7280; } diff --git a/src/App.tsx b/src/App.tsx index 1c6cc11..51bca00 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -850,7 +850,7 @@ function App() { height: exportRenderer.clientHeight, pixelRatio: 1 }).then((dataUrl) => { - triggerDownload(dataUrl, `validator_${question.id}_${question.category.display_number}${question.display_sequence}.png`); + triggerDownload(dataUrl, `validator_${editorMode === "ra" ? "ra_" : ""}${question.id}_${question.category.display_number}${question.display_sequence}.png`); setExportQuestion(undefined); setExportQuery(undefined); setExportingStatus(0); @@ -939,7 +939,7 @@ function App() { value={editorMode === "ra" ? t("raPlaceholder") : t("selectQuestionComment")} disabled={true} onValueChange={_code => null} - highlight={code => editorMode === "ra" ? highlightRA(code) : highlight(code, languages.sql)} + highlight={code => editorMode === "ra" ? highlightRA(code, isDarkMode()) : highlight(code, languages.sql)} padding={10} tabSize={2} className="font-mono text-base w-full dark:bg-slate-800 bg-slate-100 min-h-32 rounded-md" @@ -951,7 +951,7 @@ function App() { itemID="editor" value={query} onValueChange={code => setQuery(code)} - highlight={code => editorMode === "ra" ? highlightRA(code) : highlight(code, languages.sql)} + highlight={code => editorMode === "ra" ? highlightRA(code, isDarkMode()) : highlight(code, languages.sql)} padding={10} tabSize={2} className="font-mono text-base w-full dark:bg-slate-800 bg-slate-100 border dark:border-slate-600 border-gray-300 min-h-32 rounded-md" diff --git a/src/ExportRenderer.tsx b/src/ExportRenderer.tsx index b658e50..48be372 100644 --- a/src/ExportRenderer.tsx +++ b/src/ExportRenderer.tsx @@ -57,8 +57,8 @@ const ExportRenderer = React.forwardRef(({ } {query?.mode === "ra" ? (
) : ( tags * - Assignment arrows rendered as ← + * + * Two modes: + * - CSS classes (default): uses .ra-prev-* classes from App.css, supports dark: variants + * - Inline styles (forExport=true): hardcoded light colors for PNG export on white background */ -const previewWrap = (type: string, text: string) => - `${text}`; +// Inline styles for export only (always light, white background) +const EXPORT_STYLES = { + op: "color: #7c3aed; font-weight: bold;", + logic: "color: #1d4ed8; font-weight: bold;", + str: "color: #047857;", + num: "color: #b45309;", + comment: "color: #6b7280; font-style: italic;", + assign: "color: #7c3aed; font-weight: bold;", + sub: "font-size: 0.9em; color: #1f2937;", + paren: "color: #9ca3af;", +}; + +type TagFn = (token: string, content: string) => string; +type SubFn = (content: string) => string; + +function cssTag(token: string, content: string): string { + return `${content}`; +} + +function cssSub(content: string): string { + return `${content}`; +} + +function makeExportTag(): TagFn { + return (token: string, content: string) => { + const style = EXPORT_STYLES[token as keyof typeof EXPORT_STYLES] || ""; + return style ? `${content}` : content; + }; +} + +function makeExportSub(): SubFn { + return (content: string) => `${content}`; +} -function renderSubscriptHtml(content: string): string { - return `${highlightSubContent(content, previewWrap)}`; +function makeSubWrap(tag: TagFn) { + return (type: string, text: string) => tag(type, text); +} + +function renderSubscriptHtml(content: string, tag: TagFn, sub: SubFn): string { + return sub(highlightSubContent(content, makeSubWrap(tag))); } /** Consume a subscript and return [newIndex, html] */ -function consumeSubscript(code: string, i: number): [number, string] { +function consumeSubscript(code: string, i: number, tag: TagFn, sub: SubFn): [number, string] { const beforeWs = i; const wsI = skipWs(code, i); - // Try bracket content const [bracketI, bracketContent] = extractBracketContent(code, i); if (bracketContent !== null) { - return [bracketI, renderSubscriptHtml(bracketContent)]; + return [bracketI, renderSubscriptHtml(bracketContent, tag, sub)]; } - // Try implicit subscript const [implI, implContent] = extractImplicitSubscript(code, wsI, beforeWs); if (implContent !== null) { - return [implI, renderSubscriptHtml(implContent)]; + return [implI, renderSubscriptHtml(implContent, tag, sub)]; } return [beforeWs, ""]; } -export function renderRAPreview(code: string): string { +export function renderRAPreview(code: string, forExport = false): string { + const tag: TagFn = forExport ? makeExportTag() : cssTag; + const sub: SubFn = forExport ? makeExportSub() : cssSub; const result: string[] = []; let i = 0; while (i < code.length) { - // Comments if (code[i] === "-" && i + 1 < code.length && code[i + 1] === "-") { let comment = ""; while (i < code.length && code[i] !== "\n") { comment += code[i]; i++; } - result.push(`${esc(comment)}`); + result.push(tag("comment", esc(comment))); continue; } @@ -68,109 +106,105 @@ export function renderRAPreview(code: string): string { continue; } - // Unicode unary operators if (UNARY_SYMBOLS.has(code[i])) { - result.push(`${esc(code[i])}`); + result.push(tag("op", esc(code[i]))); i++; - const [newI, html] = consumeSubscript(code, i); + const [newI, html] = consumeSubscript(code, i, tag, sub); i = newI; result.push(html); continue; } - // |X| or |><| natural join if (code[i] === "|") { if (i + 2 < code.length && (code[i + 1] === "X" || code[i + 1] === "x") && code[i + 2] === "|") { - result.push(""); + result.push(tag("op", "⋈")); i += 3; continue; } if (i + 3 < code.length && code[i + 1] === ">" && code[i + 2] === "<" && code[i + 3] === "|") { - result.push(""); + result.push(tag("op", "⋈")); i += 4; continue; } } - // Binary symbols if (BINARY_SYMBOLS.has(code[i])) { - result.push(`${esc(code[i])}`); + result.push(tag("op", esc(code[i]))); i++; if (i < code.length && (code[i] === "[" || code[i] === "{" || code[i] === "_")) { - const [newI, html] = consumeSubscript(code, i); + const [newI, html] = consumeSubscript(code, i, tag, sub); i = newI; result.push(html); } continue; } - // <- assignment if (code[i] === "<" && i + 1 < code.length && code[i + 1] === "-") { - result.push(""); + result.push(tag("assign", "←")); i += 2; continue; } - // -> rename if (code[i] === "-" && i + 1 < code.length && code[i + 1] === ">") { - result.push(""); + result.push(tag("op", "→")); i += 2; continue; } - // String literals if (code[i] === "'") { let str = "'"; i++; while (i < code.length && code[i] !== "'") { str += code[i]; i++; } if (i < code.length) { str += "'"; i++; } - result.push(`${esc(str)}`); + result.push(tag("str", esc(str))); continue; } - // Numbers if (/\d/.test(code[i])) { let num = ""; while (i < code.length && /[\d.]/.test(code[i])) { num += code[i]; i++; } - result.push(`${esc(num)}`); + result.push(tag("num", esc(num))); continue; } - // Identifiers and keywords if (IDENT_START.test(code[i])) { let ident = ""; while (i < code.length && IDENT_CHAR.test(code[i])) { ident += code[i]; i++; } const lower = ident.toLowerCase(); if (UNARY_KEYWORD_SYMBOLS[lower]) { - result.push(`${UNARY_KEYWORD_SYMBOLS[lower]}`); - const [newI, html] = consumeSubscript(code, i); + result.push(tag("op", UNARY_KEYWORD_SYMBOLS[lower])); + const [newI, html] = consumeSubscript(code, i, tag, sub); i = newI; result.push(html); } else if (BINARY_KEYWORD_SYMBOLS[lower]) { - result.push(`${BINARY_KEYWORD_SYMBOLS[lower]}`); + result.push(tag("op", BINARY_KEYWORD_SYMBOLS[lower])); const j = skipWs(code, i); if (j < code.length && (code[j] === "[" || code[j] === "{" || code[j] === "_")) { - const [newI, html] = consumeSubscript(code, j); + const [newI, html] = consumeSubscript(code, j, tag, sub); i = newI; result.push(html); } } else if (LOGIC_KEYWORDS.has(lower)) { - result.push(`${esc(ident)}`); + result.push(tag("logic", esc(ident))); } else { result.push(esc(ident)); } continue; } - // Comparison operators if ("<>!=".includes(code[i])) { let op = code[i]; i++; if (i < code.length && (code[i] === "=" || code[i] === ">")) { op += code[i]; i++; } - result.push(`${esc(op)}`); + result.push(tag("logic", esc(op))); + continue; + } + + if (code[i] === "(" || code[i] === ")") { + result.push(tag("paren", esc(code[i]))); + i++; continue; } - // Everything else result.push(esc(code[i])); i++; } @@ -196,7 +230,7 @@ export default function RAPreview({ code }: RAPreviewProps) { return (
diff --git a/src/ra-engine/raHighlight.test.ts b/src/ra-engine/raHighlight.test.ts index 5585296..b8e0c5a 100644 --- a/src/ra-engine/raHighlight.test.ts +++ b/src/ra-engine/raHighlight.test.ts @@ -1,82 +1,174 @@ import { describe, it, expect } from "vitest"; import { highlightRA } from "./raHighlight"; +import { renderRAPreview } from "./RAPreview"; -describe("RA highlighter", () => { - it("should highlight σ operator in purple", () => { - const html = highlightRA("σ[age > 20](Person)"); - expect(html).toContain("color: #7c3aed"); // operator color - expect(html).toContain("σ"); +/** Strip HTML tags and decode entities to recover visible text */ +function visibleText(html: string): string { + return html + .replace(/<[^>]+>/g, "") + .replace(/</g, "<").replace(/>/g, ">") + .replace(/&/g, "&").replace(/"/g, '"'); +} + +// ─── Character count invariant ────────────────────────────────────────────── +// The editor overlay MUST produce visible text identical to the input. +// These tests are the most important — if any fail, the editor is broken. + +describe("RA highlighter character preservation", () => { + const inputs = [ + "Person", + "σ[age > 20](Person)", + "π[name, city](Person)", + "ρ[name→fullName](Person)", + "σ_{age > 20}(Person)", + "σ{age > 20}(Person)", + "PI [name, city](Person)", + "PI [name, person_id, address, postal_code] SIGMA [city='York'] Person", + "PI name, person_id SIGMA city='York' Person", + "PI _name, person_id Person", + "A <- σ[age > 20](Person)\nπ[name](A)", + "-- comment\nPerson", + "Person ⋈ Student", + "Person |X| Student", + "Person |><| Student", + "ρ[name->fullName](Person)", + "σ[a <> 1](T)", + "σ[a != 1](T)", + "σ[a >= 1](T)", + "σ[a <= 1](T)", + "γ[city; COUNT(id) AS cnt](Person)", + "Person ⋈[Person.id = Student.id] Student", + "σ[name = 'Alice'](Person)", + "PI [name SIGMA [age > 20] Person", // unclosed bracket + "σ age > 20 (Person)", // implicit subscript + "", + ]; + + for (const input of inputs) { + it(`preserves: ${JSON.stringify(input).slice(0, 60)}`, () => { + if (input === "") return; // empty input = empty output + const html = highlightRA(input); + expect(visibleText(html)).toBe(input); + }); + } +}); + +// ─── Token coloring ───────────────────────────────────────────────────────── + +describe("RA highlighter coloring", () => { + it("colors σ operator", () => { + expect(highlightRA("σ[age > 20](Person)")).toContain("color: #7c3aed"); }); - it("should render brackets faintly", () => { - const html = highlightRA("σ[age > 20](Person)"); - expect(html).toContain("opacity: 0.5"); // faint brackets + it("colors keyword operators", () => { + const html = highlightRA("sigma[age > 20](Person)"); + expect(html).toContain("color: #7c3aed"); + expect(html).toContain("sigma"); + }); + + it("colors binary keyword operators", () => { + expect(highlightRA("A cross B")).toContain("color: #7c3aed"); }); - it("should render subscript content in light purple", () => { - const html = highlightRA("π[name](Person)"); - expect(html).toContain("color: #c084fc"); // subscript styling + it("renders brackets faintly", () => { + expect(highlightRA("σ[age > 20](Person)")).toContain("opacity: 0.5"); }); - it("should highlight string literals in green", () => { + it("colors string literals green", () => { const html = highlightRA("σ[name = 'Alice'](Person)"); - expect(html).toContain("color: #059669"); // string color + expect(html).toContain("color: #059669"); expect(html).toContain("Alice"); }); - it("should highlight numbers in amber", () => { - const html = highlightRA("σ[age > 20](Person)"); - expect(html).toContain("color: #d97706"); // number color + it("colors numbers amber", () => { + expect(highlightRA("σ[age > 20](Person)")).toContain("color: #d97706"); }); - it("should highlight AND/OR in blue", () => { + it("colors AND/OR/NOT blue", () => { const html = highlightRA("σ[a > 1 and b < 2](T)"); - expect(html).toContain("color: #2563eb"); // logic color - expect(html).toContain("and"); + expect(html).toContain("color: #2563eb"); }); - it("should highlight comments in gray italic", () => { + it("colors comments gray italic", () => { const html = highlightRA("-- this is a comment\nPerson"); expect(html).toContain("font-style: italic"); - expect(html).toContain("this is a comment"); }); - it("should render <- with assignment styling", () => { + it("colors <- assignment", () => { const html = highlightRA("A <- Person"); + expect(html).toContain("color: #7c3aed"); expect(html).toContain("<-"); - expect(html).toContain("font-weight: bold"); }); - it("should render -> with operator styling", () => { + it("colors -> rename arrow", () => { const html = highlightRA("ρ[name->fullName](Person)"); expect(html).toContain("->"); }); - it("should handle LaTeX-style _{} notation", () => { + it("colors _ as bracket when before {", () => { const html = highlightRA("σ_{age > 20}(Person)"); - expect(html).toContain("opacity: 0.5"); // faint brackets - expect(html).toContain("color: #c084fc"); // subscript content + expect(html).toContain("opacity: 0.5"); }); - it("should highlight keyword operators", () => { - const html = highlightRA("sigma[age > 20](Person)"); - expect(html).toContain("color: #7c3aed"); // operator color - expect(html).toContain("sigma"); + it("does not color _ when part of identifier", () => { + const html = highlightRA("PI _name (Person)"); + // _name should not have bracket styling + expect(html).not.toMatch(/opacity.*_n/); }); - it("should highlight binary keyword operators", () => { - const html = highlightRA("A cross B"); - expect(html).toContain("color: #7c3aed"); - expect(html).toContain("cross"); + it("preserves newlines", () => { + expect(highlightRA("A <- Person\nA")).toContain("\n"); + }); +}); + +// ─── RAPreview rendering ──────────────────────────────────────────────────── + +describe("RA preview", () => { + it("should render paren-free implicit projection without eating the table name", () => { + const html = renderRAPreview("PI person_id, address Person"); + expect(html).toMatch(/]*>.*person_id.*address.*<\/sub>/); + // Person must not be in a subscript + expect(html).not.toMatch(/]*>.*Person.*<\/sub>/s); + }); + + it("should render paren-free implicit projection with Unicode symbol", () => { + const html = renderRAPreview("π person_id, address Person"); + expect(html).toMatch(/]*>.*person_id.*<\/sub>/); + }); + + it("should still render parenthesised implicit subscript correctly", () => { + const html = renderRAPreview("π person_id, address (Person)"); + expect(html).toMatch(/]*>.*person_id.*address.*<\/sub>/); + }); + + it("should render paren-free σ condition Table correctly", () => { + const html = renderRAPreview("sigma age > 20 Person"); + expect(html).toContain("Person"); + expect(html).toMatch(/]*>.*age.*<\/sub>/); + }); + + it("should not swallow content after unclosed bracket", () => { + const html = renderRAPreview("PI [name, person_id SIGMA city='York' Person"); + const opMatches = html.match(/ra-prev-op/g); + expect(opMatches!.length).toBeGreaterThanOrEqual(2); + }); + + it("should render chained paren-free operators correctly", () => { + const html = renderRAPreview("PI name, person_id, address, postal_code SIGMA city='York' Person"); + const opMatches = html.match(/ra-prev-op/g); + expect(opMatches!.length).toBeGreaterThanOrEqual(2); + expect(html).toMatch(/]*>.*name.*postal_code.*<\/sub>/); }); - it("should preserve newlines", () => { - const html = highlightRA("A <- Person\nA"); - expect(html).toContain("\n"); + it("should render chained Unicode operators correctly", () => { + const html = renderRAPreview("π name, city σ age > 20 Person"); + const opMatches = html.match(/ra-prev-op/g); + expect(opMatches!.length).toBeGreaterThanOrEqual(2); }); - it("should handle implicit subscripts with whitespace", () => { - const html = highlightRA("σ age > 20 (Person)"); - expect(html).toContain("color: #c084fc"); // subscript styling for implicit content + it("should not treat underscore-prefixed column names as LaTeX subscript", () => { + const html = renderRAPreview("PI _name, person_id Person"); + expect(html).toMatch(/]*>.*_name.*person_id.*<\/sub>/); + expect(html).not.toMatch(/opacity/); }); }); diff --git a/src/ra-engine/raHighlight.ts b/src/ra-engine/raHighlight.ts index d22c76a..d448000 100644 --- a/src/ra-engine/raHighlight.ts +++ b/src/ra-engine/raHighlight.ts @@ -9,225 +9,188 @@ You may obtain a copy of the License in the LICENSE.md file in this repository. /** * Custom syntax highlighter for relational algebra expressions. - * Returns HTML with subscript rendering for bracket content and - * colored tokens for operators, keywords, strings, etc. * - * IMPORTANT: This highlighter must preserve character count (each input char - * maps to exactly one output char) for editor overlay alignment. + * INVARIANT: The visible text of the output must be EXACTLY the input string. + * Every input character must appear exactly once in the output. We only wrap + * characters in tags for coloring — never skip, reorder, or add chars. */ import { UNARY_SYMBOLS, BINARY_SYMBOLS, UNARY_KEYWORDS, BINARY_KEYWORDS, - LOGIC_KEYWORDS, IDENT_START, IDENT_CHAR, - esc, skipWs, highlightSubContent, extractBracketContent, extractImplicitSubscript, + LOGIC_KEYWORDS, IDENT_START, IDENT_CHAR, esc, } from "./raShared"; -// Inline styles for portability with the code editor -const S = { - op: "color: #7c3aed; font-weight: bold;", - kw: "color: #7c3aed; font-weight: bold;", - logic: "color: #2563eb; font-weight: bold;", +// Only color/opacity — never font-weight/size which affect character width +const LIGHT = { + op: "color: #7c3aed;", + logic: "color: #2563eb;", str: "color: #059669;", num: "color: #d97706;", comment: "color: #9ca3af; font-style: italic;", bracket: "color: #a1a1aa; opacity: 0.5;", - assign: "color: #7c3aed; font-weight: bold;", - sub: "color: #c084fc; font-weight: 500;", }; -const subWrap = (type: string, text: string) => - `${text}`; +const DARK = { + op: "color: #a78bfa;", + logic: "color: #60a5fa;", + str: "color: #34d399;", + num: "color: #fbbf24;", + comment: "color: #6b7280; font-style: italic;", + bracket: "color: #6b7280; opacity: 0.5;", +}; /** - * Highlight a relational algebra expression, rendering bracket content - * as subscripts and coloring operators/keywords. + * Wrap a raw substring in a styled span, escaping HTML entities. + * The visible text length is always === raw.length. */ -export function highlightRA(code: string): string { +function span(style: string, raw: string): string { + return `${esc(raw)}`; +} + +/** + * Highlight RA code for the editor overlay. + * Simple token-coloring only — no rewriting, no subscript rendering. + * Character count is guaranteed correct by construction. + */ +export function highlightRA(code: string, dark = false): string { + const S = dark ? DARK : LIGHT; const result: string[] = []; let i = 0; while (i < code.length) { - // Comments: -- + // ── Comments: -- until newline ── if (code[i] === "-" && i + 1 < code.length && code[i + 1] === "-") { - let comment = ""; - while (i < code.length && code[i] !== "\n") { - comment += code[i]; - i++; - } - result.push(`${esc(comment)}`); + let end = i; + while (end < code.length && code[end] !== "\n") end++; + result.push(span(S.comment, code.slice(i, end))); + i = end; continue; } - // Newlines — preserve them + // ── Newlines ── if (code[i] === "\n") { result.push("\n"); i++; continue; } - // Unicode RA operators (single char) + // ── Unicode unary operators (σ, π, ρ, γ, τ, δ) ── if (UNARY_SYMBOLS.has(code[i])) { - result.push(`${esc(code[i])}`); + result.push(span(S.op, code[i])); i++; - i = renderSubscript(code, i, result); continue; } - // |X| or |><| — natural join + // ── Unicode binary operators (×, ⋈, ∪, ∩, etc.) ── + if (BINARY_SYMBOLS.has(code[i])) { + result.push(span(S.op, code[i])); + i++; + continue; + } + + // ── |X| or |><| natural join ── if (code[i] === "|") { if (i + 2 < code.length && (code[i + 1] === "X" || code[i + 1] === "x") && code[i + 2] === "|") { - result.push(`${esc("|X|")}`); + result.push(span(S.op, code.slice(i, i + 3))); i += 3; continue; } if (i + 3 < code.length && code[i + 1] === ">" && code[i + 2] === "<" && code[i + 3] === "|") { - result.push(`${esc("|><|")}`); + result.push(span(S.op, code.slice(i, i + 4))); i += 4; continue; } } - if (BINARY_SYMBOLS.has(code[i])) { - result.push(`${esc(code[i])}`); - i++; - if (i < code.length && (code[i] === "[" || code[i] === "{" || code[i] === "_")) { - i = renderSubscript(code, i, result); - } - continue; - } - - // Arrow: <- (assignment) — keep both chars for editor alignment + // ── <- assignment ── if (code[i] === "<" && i + 1 < code.length && code[i + 1] === "-") { - result.push(`<-`); + result.push(span(S.op, "<-")); i += 2; continue; } - // Arrow: -> (rename) — keep both chars for editor alignment + // ── -> rename arrow ── if (code[i] === "-" && i + 1 < code.length && code[i + 1] === ">") { - result.push(`->`); + result.push(span(S.op, "->")); i += 2; continue; } - // String literals - if (code[i] === "'") { - let str = "'"; + // ── → Unicode rename arrow ── + if (code[i] === "→") { + result.push(span(S.op, "→")); i++; - while (i < code.length && code[i] !== "'") { - str += code[i]; - i++; - } - if (i < code.length) { str += "'"; i++; } - result.push(`${esc(str)}`); continue; } - // Numbers + // ── String literals ── + if (code[i] === "'") { + let end = i + 1; + while (end < code.length && code[end] !== "'") end++; + if (end < code.length) end++; // include closing quote + result.push(span(S.str, code.slice(i, end))); + i = end; + continue; + } + + // ── Numbers ── if (/\d/.test(code[i])) { - let num = ""; - while (i < code.length && /[\d.]/.test(code[i])) { - num += code[i]; - i++; - } - result.push(`${esc(num)}`); + let end = i; + while (end < code.length && /[\d.]/.test(code[end])) end++; + result.push(span(S.num, code.slice(i, end))); + i = end; continue; } - // Identifiers and keywords - if (IDENT_START.test(code[i])) { - let ident = ""; - while (i < code.length && IDENT_CHAR.test(code[i])) { - ident += code[i]; + // ── Brackets — render faintly ── + if (code[i] === "[" || code[i] === "]" || code[i] === "{" || code[i] === "}") { + result.push(span(S.bracket, code[i])); + i++; + continue; + } + + // ── Underscore before bracket — render as faint bracket prefix ── + if (code[i] === "_") { + // Check if this is a LaTeX-style _{} or _[] prefix + let j = i + 1; + while (j < code.length && code[j] === " ") j++; + if (j < code.length && (code[j] === "{" || code[j] === "[")) { + result.push(span(S.bracket, "_")); i++; + continue; } - const lower = ident.toLowerCase(); - if (UNARY_KEYWORDS.has(lower)) { - result.push(`${esc(ident)}`); - i = renderSubscript(code, i, result); - } else if (BINARY_KEYWORDS.has(lower)) { - result.push(`${esc(ident)}`); - const j = skipWs(code, i); - if (j < code.length && (code[j] === "[" || code[j] === "{" || code[j] === "_")) { - if (j > i) result.push(esc(code.slice(i, j))); - i = renderSubscript(code, j, result); - } + } + + // ── Identifiers and keywords ── + if (IDENT_START.test(code[i])) { + let end = i; + while (end < code.length && IDENT_CHAR.test(code[end])) end++; + const word = code.slice(i, end); + const lower = word.toLowerCase(); + if (UNARY_KEYWORDS.has(lower) || BINARY_KEYWORDS.has(lower)) { + result.push(span(S.op, word)); } else if (LOGIC_KEYWORDS.has(lower)) { - result.push(`${esc(ident)}`); + result.push(span(S.logic, word)); } else { - result.push(esc(ident)); + result.push(esc(word)); } + i = end; continue; } - // Comparison operators - if (code[i] === "<" || code[i] === ">" || code[i] === "!" || code[i] === "=") { - let op = code[i]; - i++; - if (i < code.length && (code[i] === "=" || code[i] === ">")) { - op += code[i]; - i++; - } - result.push(`${esc(op)}`); + // ── Comparison operators ── + if ("<>!=".includes(code[i])) { + let end = i + 1; + if (end < code.length && (code[end] === "=" || code[end] === ">")) end++; + result.push(span(S.logic, code.slice(i, end))); + i = end; continue; } - // Everything else (whitespace, parens, etc.) — pass through + // ── Everything else (spaces, parens, etc.) — pass through ── result.push(esc(code[i])); i++; } return result.join(""); } - -/** - * Render a subscript section after a unary/binary operator. - * Handles: _{ }, [ ], { }, or implicit (content until '('). - * Returns the new index position. - */ -function renderSubscript(code: string, i: number, result: string[]): number { - const beforeWs = i; - i = skipWs(code, i); - - if (i >= code.length) { - if (i > beforeWs) result.push(code.slice(beforeWs, i)); - return i; - } - - // Handle _{ or _[ (LaTeX-style) - if (code[i] === "_") { - result.push(`${esc("_")}`); - i++; - i = skipWs(code, i); - } - - // Try bracket content - if (code[i] === "[" || code[i] === "{") { - const openBracket = code[i]; - result.push(`${esc(openBracket)}`); - const [newI, content] = extractBracketContent(code, i); - if (content !== null) { - result.push(`${highlightSubContent(content, subWrap)}`); - // Render closing bracket (only if bracket was actually closed) - if (newI > i + 1 && newI <= code.length) { - const closeBracket = code[newI - 1]; - if (closeBracket === "]" || closeBracket === "}") { - result.push(`${esc(closeBracket)}`); - } - } - return newI; - } - } - - // Implicit subscript - const [newI, content] = extractImplicitSubscript(code, i, beforeWs); - if (content !== null) { - result.push(code.slice(beforeWs, i)); // whitespace - result.push(`${highlightSubContent(content, subWrap)}`); - return newI; - } - - // No subscript found — restore whitespace - if (i > beforeWs) result.push(code.slice(beforeWs, i)); - return i; -} diff --git a/src/ra-engine/raShared.ts b/src/ra-engine/raShared.ts index c8215eb..925c65f 100644 --- a/src/ra-engine/raShared.ts +++ b/src/ra-engine/raShared.ts @@ -152,10 +152,12 @@ export function extractBracketContent(code: string, startI: number): [number, st let i = skipWs(code, startI); if (i >= code.length) return [startI, null]; - // Handle _{ or _[ prefix + // Handle _{ or _[ prefix — only if followed by a bracket if (code[i] === "_") { - i++; - i = skipWs(code, i); + const afterUnderscore = skipWs(code, i + 1); + if (afterUnderscore < code.length && (code[afterUnderscore] === "{" || code[afterUnderscore] === "[")) { + i = afterUnderscore; + } } if (code[i] === "[" || code[i] === "{") { @@ -168,14 +170,23 @@ export function extractBracketContent(code: string, startI: number): [number, st if (depth > 0) content += code[i]; i++; } - return [i, content]; + // Only treat as bracket content if the bracket was actually closed + if (depth === 0) { + return [i, content]; + } + return [startI, null]; } return [startI, null]; } +/** All keywords that are RA operators (unary + binary) */ +const RA_OPERATOR_KEYWORDS = new Set([...UNARY_KEYWORDS, ...BINARY_KEYWORDS]); + /** - * Extract implicit subscript content (tokens until '(' or newline). + * Extract implicit subscript content (tokens until '(' or newline or another RA operator). + * When no '(' or operator follows (paren-free syntax like `π cols Table`), the last + * space-separated token is the operand, not part of the subscript. * Returns [newIndex, extractedContent] or [originalIndex, null] if nothing found. */ export function extractImplicitSubscript(code: string, startI: number, beforeWs: number): [number, string | null] { @@ -183,15 +194,49 @@ export function extractImplicitSubscript(code: string, startI: number, beforeWs: if (code[i] === "(" || i <= beforeWs) return [startI, null]; let content = ""; - while (i < code.length && code[i] !== "(" && code[i] !== "\n") { + let hitBoundary = false; // true if we stopped at '(' or an RA operator + while (i < code.length && code[i] !== "\n") { + if (code[i] === "(") { hitBoundary = true; break; } + + // Stop if we hit a unary/binary Unicode symbol (next operator) + if (UNARY_SYMBOLS.has(code[i]) || BINARY_SYMBOLS.has(code[i])) { hitBoundary = true; break; } + + // Stop if we hit an RA operator keyword (e.g., SIGMA, PI, cross, natjoin) + if (IDENT_START.test(code[i])) { + let word = ""; + let j = i; + while (j < code.length && IDENT_CHAR.test(code[j])) { word += code[j]; j++; } + if (RA_OPERATOR_KEYWORDS.has(word.toLowerCase())) { hitBoundary = true; break; } + content += word; + i = j; + continue; + } + content += code[i]; i++; } const trimmed = content.trimEnd(); const trimDiff = content.length - trimmed.length; if (trimDiff > 0) i -= trimDiff; + if (trimmed.length > 0) { - return [i, trimmed]; + // If we stopped at a clear boundary ('(' or another operator), the entire + // scanned content is the subscript. + if (hitBoundary) { + return [i, trimmed]; + } + // Otherwise we hit end-of-line / end-of-input — the last token is the + // operand (table name), not part of the subscript. + const lastSpace = trimmed.lastIndexOf(" "); + if (lastSpace > 0) { + const sub = trimmed.slice(0, lastSpace).trimEnd(); + if (sub.length > 0) { + i = startI + sub.length; + return [i, sub]; + } + } + // Single token with no parens — it's the operand, not a subscript + return [startI, null]; } return [startI, null]; } From b1c1d021fabd3fa3dda5d51be10170f57e9b341f Mon Sep 17 00:00:00 2001 From: Edwin <60476129+Edwinexd@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:15:40 +0100 Subject: [PATCH 7/7] Add license attribution dialog with full dependency listing --- .gitignore | 4 ++ package.json | 3 +- scripts/generate-licenses.ts | 76 ++++++++++++++++++++++++ src/App.tsx | 3 +- src/LicenseDialog.tsx | 110 +++++++++++++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 scripts/generate-licenses.ts create mode 100644 src/LicenseDialog.tsx diff --git a/.gitignore b/.gitignore index b455588..4e41fc1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ # dependencies /node_modules + +# generated +/public/licenses.json /.pnp .pnp.js @@ -35,6 +38,7 @@ scripts/* !scripts/generate-language.ts !scripts/extract-oracle.ts !scripts/generate-erd.ts +!scripts/generate-licenses.ts !scripts/hooks/ data/* diff --git a/package.json b/package.json index 06ab4eb..8db643e 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "scripts": { "dev": "npm-run-all copy-sqljs start-vite", "start": "npm-run-all copy-sqljs start-vite", - "build": "npm-run-all copy-sqljs build-vite", + "build": "npm-run-all copy-sqljs generate-licenses build-vite", "preview": "vite preview", "copy-sqljs": "copyfiles -f node_modules/sql.js/dist/sql-wasm.wasm public/dist/sql.js/", "start-vite": "vite", @@ -50,6 +50,7 @@ "decrypt-oracle": "tsx scripts/decrypt-oracle.ts", "encrypt-oracle": "tsx scripts/encrypt-oracle.ts", "generate-erd": "tsx scripts/generate-erd.ts", + "generate-licenses": "tsx scripts/generate-licenses.ts", "generate-all": "tsx scripts/generate-language.ts --all --plain && tsx scripts/generate-erd.ts --all" }, "eslintConfig": { diff --git a/scripts/generate-licenses.ts b/scripts/generate-licenses.ts new file mode 100644 index 0000000..c6a77bb --- /dev/null +++ b/scripts/generate-licenses.ts @@ -0,0 +1,76 @@ +/** + * Generates a JSON file with license information for all production dependencies. + * Output: public/licenses.json + */ + +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { join, dirname } from "path"; + +const ROOT = join(dirname(new URL(import.meta.url).pathname), ".."); +const NODE_MODULES = join(ROOT, "node_modules"); + +interface LicenseEntry { + name: string; + version: string; + license: string; + repository?: string; + author?: string; +} + +// Read the root package.json to get production dependencies +const rootPkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8")); +const prodDeps = Object.keys(rootPkg.dependencies || {}); + +function resolvePackage(name: string): LicenseEntry | null { + const pkgDir = join(NODE_MODULES, name); + const pkgJsonPath = join(pkgDir, "package.json"); + if (!existsSync(pkgJsonPath)) return null; + + const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); + const license = typeof pkg.license === "string" + ? pkg.license + : typeof pkg.license === "object" + ? pkg.license.type + : Array.isArray(pkg.licenses) + ? pkg.licenses.map((l: { type: string }) => l.type).join(", ") + : "Unknown"; + + const repo = typeof pkg.repository === "string" + ? pkg.repository + : typeof pkg.repository === "object" + ? pkg.repository.url + : undefined; + + const cleanRepo = repo + ?.replace(/^git\+/, "") + ?.replace(/^git:\/\//, "https://") + ?.replace(/\.git$/, "") + ?.replace(/^ssh:\/\/git@github\.com/, "https://github.com"); + + const author = typeof pkg.author === "string" + ? pkg.author + : typeof pkg.author === "object" + ? pkg.author.name + : undefined; + + return { + name: pkg.name, + version: pkg.version, + license, + repository: cleanRepo, + author, + }; +} + +const entries: LicenseEntry[] = []; + +for (const dep of prodDeps) { + const entry = resolvePackage(dep); + if (entry) entries.push(entry); +} + +// Sort alphabetically +entries.sort((a, b) => a.name.localeCompare(b.name)); + +writeFileSync(join(ROOT, "public", "licenses.json"), JSON.stringify(entries, null, 2)); +console.log(`Generated licenses.json with ${entries.length} dependencies`); diff --git a/src/App.tsx b/src/App.tsx index 51bca00..abd48bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,6 +48,7 @@ import sha256 from "crypto-js/sha256"; import { format as formatFns } from "date-fns"; import { toPng } from "html-to-image"; import PrivacyNoticeToggle from "./PrivacyNoticeToggle"; +import LicenseDialog from "./LicenseDialog"; import ThemeToggle from "./ThemeToggle"; import useTheme from "./useTheme"; import { isCorrectResult, Result } from "./utils"; @@ -1132,7 +1133,7 @@ function App() {
Copyright © Edwin Sundberg {new Date().getFullYear()} - - GPL-3.0 + Report Issue diff --git a/src/LicenseDialog.tsx b/src/LicenseDialog.tsx new file mode 100644 index 0000000..fa7a707 --- /dev/null +++ b/src/LicenseDialog.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { X } from "lucide-react"; + +interface LicenseEntry { + name: string; + version: string; + license: string; + repository?: string; + author?: string; +} + +const PROJECT_LICENSES = [ + { + name: "sql-validator (core)", + license: "GPL-3.0", + description: "The core application is licensed under the GNU General Public License v3.0.", + url: "https://github.com/Edwinexd/sql-validator?tab=GPL-3.0-1-ov-file", + }, + { + name: "ra-engine (Relational Algebra)", + license: "BSL-1.1", + description: "The relational algebra engine (src/ra-engine/) is licensed under the Business Source License 1.1, converting to GPL-3.0 on 2035-03-20.", + url: "https://github.com/Edwinexd/sql-validator/blob/master/src/ra-engine/LICENSE.md", + }, +]; + +export default function LicenseDialog() { + const [open, setOpen] = useState(false); + const [deps, setDeps] = useState([]); + + useEffect(() => { + if (open && deps.length === 0) { + fetch("/licenses.json") + .then(r => r.json()) + .then(setDeps) + .catch(() => setDeps([])); + } + }, [open, deps.length]); + + return ( + <> + + + +
+

Licenses

+ +
+ +
+
+

Project Licenses

+
+ {PROJECT_LICENSES.map(l => ( +
+
+ {l.name} + {l.license} +
+

{l.description}

+
+ ))} +
+
+ +
+

Third-Party Dependencies

+ {deps.length === 0 ? ( +

Loading...

+ ) : ( +
+ + + + + + + + + + {deps.map(dep => ( + + + + + + ))} + +
PackageVersionLicense
+ {dep.repository ? ( + {dep.name} + ) : dep.name} + {dep.version}{dep.license}
+
+ )} +
+
+
+
+ + ); +}