diff --git a/.changeset/quiet-sqlite-dialects.md b/.changeset/quiet-sqlite-dialects.md new file mode 100644 index 0000000..32eaa74 --- /dev/null +++ b/.changeset/quiet-sqlite-dialects.md @@ -0,0 +1,11 @@ +--- +"@maple-dev/effect-orm": minor +--- + +Add the `/sqlite` query entry with SQLite codecs, functions and positional bindings. Use +`sqliteDialect` with an Effect SQLite SqlClient, or `d1Dialect` for Cloudflare D1's binding limit +and explicit refusal of interactive transactions. `timestamp` stores SQLite's own +`YYYY-MM-DD HH:MM:SS.SSS` UTC text, so it compares correctly with what `strftime` writes; `LIKE` +escapes with `\` as on every dialect; text with U+0000 and integers past 2^53 are refused rather +than silently changed. `table` is a query schema only: SQLite DDL and +migration generation are not implemented yet. diff --git a/README.md b/README.md index 4863efe..4ad708a 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ > **Alpha.** The API is still changing and minor releases may break it. Pin an exact version, > read the [changelog](./CHANGELOG.md) before upgrading, and expect rough edges. -Type-safe ClickHouse and Postgres queries, result decoding, and reproducible benchmarks for Effect and TypeScript. +Type-safe ClickHouse, Postgres and SQLite queries, result decoding, and reproducible benchmarks for Effect and TypeScript. Formerly `@maple-dev/effect-clickhouse`. +SQLite and Cloudflare D1 use the same builder and row decoder through the new +[`/sqlite` entry](./docs/sqlite.md). SQLite schema/migration generation is not implemented. + [Read the documentation](https://effect-clickhouse.maple.dev) · [Getting started](./docs/getting-started.md) · [Recipes](./docs/recipes.md) diff --git a/bun.lock b/bun.lock index db92323..a8a9d5a 100644 --- a/bun.lock +++ b/bun.lock @@ -10,13 +10,16 @@ "@effect/language-service": "^0.87.3", "@effect/platform-bun": "4.0.0", "@effect/sql-clickhouse": "4.0.0", + "@effect/sql-d1": "4.0.0", "@effect/sql-pglite": "4.0.0", + "@effect/sql-sqlite-node": "4.0.0", "@effect/vitest": "4.0.0", "@electric-sql/pglite": "0.5.8", "@types/node": "^22.10.2", "effect": "4.0.0", "expect-type": "^1.3.0", "fast-check": "^4.9.0", + "miniflare": "4.20260730.0", "tsdown": "^0.22.14", "typescript": "^7.0.2", "vitest": "^4.1.9", @@ -68,6 +71,20 @@ "@clickhouse/client-common": ["@clickhouse/client-common@1.21.0", "", {}, "sha512-UjvA32aaLKbQ5U735qAGdzohrOrfU8zFoOWIOWorWQk+7UzHZxq6pf4Uh/wQ08zAMb9cmkd9h9N1FV8zP8UCOg=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260730.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260730.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260730.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260730.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260730.1", "", { "os": "win32", "cpu": "x64" }, "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20261024.1", "", {}, "sha512-sNhbgBsuVvmumSurgCcIrXShqACPfRt/XlYFstBeANu5BHEJhXW+e7PmHxDNYRXxKYZV57toUfkLwFp8/0SZKA=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@effect/language-service": ["@effect/language-service@0.87.3", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g1OmRBZF5chbW9QMNoirPo5Bb4zzU2HfP88tR7/VSZEUxHJ1/qVb8AQ0kUh2ggctLH1I715yCwP4gkw56PTjpQ=="], "@effect/platform-bun": ["@effect/platform-bun@4.0.0", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-g9O41Sub/+JKfLjvBNEoqR1Pg6wtc2bbYzv7Kw8YsMihbt/ypCn7ZoydEXaHvTTUvbVrXOQbVFlfplyo84Clug=="], @@ -78,14 +95,78 @@ "@effect/sql-clickhouse": ["@effect/sql-clickhouse@4.0.0", "", { "dependencies": { "@clickhouse/client": "^1.23.1" }, "peerDependencies": { "@effect/platform-node": "^4.0.0", "effect": "^4.0.0" } }, "sha512-KthAxSJAW4VR9YyFsgOwwWX2ym6aYMFEJkzyxJExzcGFiUbFMK7hsbwzagiBKJAr/N1gGWQYiZP2fZgkgruiuA=="], + "@effect/sql-d1": ["@effect/sql-d1@4.0.0", "", { "dependencies": { "@cloudflare/workers-types": "^5.20260926.1" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-pmkzncZCGD6CLyvc89keEBU85h+gHVCu/LKQnThd+ruYSUbG0R9jYA+lnk3R8Djp0wQW6SbsbmHQuhopIYyo3Q=="], + "@effect/sql-pglite": ["@effect/sql-pglite@4.0.0", "", { "dependencies": { "@electric-sql/pglite": "^0.5.8" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-BTlCPSipKMHZJEoJ94rR4f8Gx8cv8tLpZeHc3sajg2ko/2GY8s6CyycSra3Z6GLFUhcivRTYQ9Zc5LThz64qfQ=="], + "@effect/sql-sqlite-node": ["@effect/sql-sqlite-node@4.0.0", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-xyUSLY7v55iMgGxrjFrTo5ngixgQ3jLmOQ+AX8CCazO/caxuMM0oq8zb974FAcvQHRrpMEWU7Vnoo+rxct7PAQ=="], + "@effect/vitest": ["@effect/vitest@4.0.0", "", { "peerDependencies": { "effect": "^4.0.0", "vitest": ">=5.0.0 <6.0.0" } }, "sha512-kGElCPFGtP+CgMCwV/HjJWqtE3zYo6I1+8MSA54l+EI24lPU9p2Efa+TBp1jfkg9j711KKzEqgSAy3VzTsb76Q=="], "@electric-sql/pglite": ["@electric-sql/pglite@0.5.8", "", {}, "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@manypkg/find-root": ["@manypkg/find-root@3.1.0", "", { "dependencies": { "@manypkg/tools": "^2.1.0" } }, "sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA=="], "@manypkg/get-packages": ["@manypkg/get-packages@3.1.0", "", { "dependencies": { "@manypkg/find-root": "^3.1.0", "@manypkg/tools": "^2.1.0" } }, "sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg=="], @@ -96,6 +177,12 @@ "@pnpm/deps.graph-sequencer": ["@pnpm/deps.graph-sequencer@1100.0.1", "", {}, "sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@quansync/fs": ["@quansync/fs@1.1.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg=="], "@redis/bloom": ["@redis/bloom@6.3.0", "", { "peerDependencies": { "@redis/client": "^6.3.0" } }, "sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA=="], @@ -140,6 +227,10 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -266,6 +357,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -276,6 +369,8 @@ "empathic": ["empathic@2.1.0", "", {}, "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], @@ -308,6 +403,8 @@ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "launch-editor": ["launch-editor@2.14.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.10.0" } }, "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], @@ -336,6 +433,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "miniflare": ["miniflare@4.20260730.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.28.0", "workerd": "1.20260730.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-1Z9SB9r/o//80UA02Re3QhtcecSHAyAjf5EcKBfQVlQrCg7Miy79hl2PvtkwFLIaJ5rcrOPdDcRr577okwZPsg=="], + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], @@ -364,6 +463,8 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], + "shell-quote": ["shell-quote@1.12.0", "", {}, "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], @@ -376,6 +477,8 @@ "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], @@ -388,11 +491,13 @@ "tsdown": ["tsdown@0.22.14", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.5", "rolldown": "~1.2.0", "rolldown-plugin-dts": "^0.27.13", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.3.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.14", "@tsdown/exe": "0.22.14", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], - "undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -404,16 +509,26 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + "workerd": ["workerd@1.20260730.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260730.1", "@cloudflare/workerd-darwin-arm64": "1.20260730.1", "@cloudflare/workerd-linux-64": "1.20260730.1", "@cloudflare/workerd-linux-arm64": "1.20260730.1", "@cloudflare/workerd-windows-64": "1.20260730.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "yuku-ast": ["yuku-ast@0.8.7", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.7" } }, "sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ=="], "yuku-codegen": ["yuku-codegen@0.8.7", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.7" }, "optionalDependencies": { "@yuku-codegen/binding-android-arm64": "0.8.7", "@yuku-codegen/binding-darwin-arm64": "0.8.7", "@yuku-codegen/binding-darwin-x64": "0.8.7", "@yuku-codegen/binding-freebsd-x64": "0.8.7", "@yuku-codegen/binding-linux-arm-gnu": "0.8.7", "@yuku-codegen/binding-linux-arm-musl": "0.8.7", "@yuku-codegen/binding-linux-arm64-gnu": "0.8.7", "@yuku-codegen/binding-linux-arm64-musl": "0.8.7", "@yuku-codegen/binding-linux-x64-gnu": "0.8.7", "@yuku-codegen/binding-linux-x64-musl": "0.8.7", "@yuku-codegen/binding-win32-arm64": "0.8.7", "@yuku-codegen/binding-win32-x64": "0.8.7" } }, "sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw=="], "yuku-parser": ["yuku-parser@0.8.7", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.7", "yuku-ast": "^0.8.7" }, "optionalDependencies": { "@yuku-parser/binding-android-arm64": "0.8.7", "@yuku-parser/binding-darwin-arm64": "0.8.7", "@yuku-parser/binding-darwin-x64": "0.8.7", "@yuku-parser/binding-freebsd-x64": "0.8.7", "@yuku-parser/binding-linux-arm-gnu": "0.8.7", "@yuku-parser/binding-linux-arm-musl": "0.8.7", "@yuku-parser/binding-linux-arm64-gnu": "0.8.7", "@yuku-parser/binding-linux-arm64-musl": "0.8.7", "@yuku-parser/binding-linux-x64-gnu": "0.8.7", "@yuku-parser/binding-linux-x64-musl": "0.8.7", "@yuku-parser/binding-win32-arm64": "0.8.7", "@yuku-parser/binding-win32-x64": "0.8.7" } }, "sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ=="], + "@effect/platform-node/undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], + + "@effect/platform-node-shared/ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + "@effect/sql-clickhouse/@clickhouse/client": ["@clickhouse/client@1.23.1", "", {}, "sha512-vs3/Zc1dHvT171btW5nMoPsPCJ6QVJ5pp7obxzO5sjqwFx/jjz9wwCAqcFOdc2DhprugDBaVn+4dVY8hG3A9nw=="], } } diff --git a/docs/README.md b/docs/README.md index 7a61d11..cbdb224 100644 --- a/docs/README.md +++ b/docs/README.md @@ -99,3 +99,7 @@ The optional `@maple-dev/effect-orm/benchmark` entry point and bundled `ch-bench` CLI measure real queries, compare fixed workloads, and save evidence. See [Benchmarking](./benchmarking.md) and the [agent playbook](./benchmark-agent.md). The SQL builders remain driver-free. + +## SQLite and D1 + +[SQLite and D1](./sqlite.md) covers `@maple-dev/effect-orm/sqlite`, its codecs and functions, Effect SQL drivers, tenant checks and transaction limits. SQLite migration generation is not implemented. diff --git a/docs/reference.md b/docs/reference.md index abc2d46..f7cd842 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -5,9 +5,10 @@ The package has one entry per database, each self-contained: ```ts import * as CH from "@maple-dev/effect-orm/clickhouse" import * as PG from "@maple-dev/effect-orm/postgres" +import * as SQLite from "@maple-dev/effect-orm/sqlite" ``` -Both carry the whole [query builder](#query-builder-both-entries). Each adds its own column +All carry the whole [query builder](#query-builder-every-entry). Each adds its own column types, functions, table definitions and a `compile` that defaults to its dialect: [`/clickhouse`](#clickhouse) and [`/postgres`](#postgres). Everything else is on a [subpath](#other-subpaths) for a narrower job. @@ -32,9 +33,9 @@ uniformly, which some codebases prefer for exactly this reason. --- -## Query builder (both entries) +## Query builder (every entry) -Everything in this section is exported, identically, from both `/clickhouse` and `/postgres`. +Everything in this section is exported, identically, from `/clickhouse`, `/postgres` and `/sqlite`. ### Query construction @@ -46,7 +47,7 @@ Everything in this section is exported, identically, from both `/clickhouse` and | `unionAll` | `(...queries) => CHUnionQuery` | | `update` | `(table) => CHUpdateStart`, then `CHUpdate`: `.set(record \| fn)`, `.where(fn)` or `.allRows()`, `.returning(...)`, `.settings(record)`. See [Updating and deleting](./updates-and-deletes.md) | | `deleteFrom` | `(table) => CHDelete`: `.where(fn)` or `.allRows()`, `.returning(...)`, `.settings(record)` | -| `insertInto` | `(table) => CHInsertStart`, then `CHInsert`; `.values(row \| rows)` or `.select(query)` sets its rows, `.settings(record)` ClickHouse `SETTINGS`, `.returning(...)` the RETURNING list, `.onConflictDoNothing(options?)` / `.onConflictDoUpdate(options)` the ON CONFLICT clause (Postgres). See [Inserting rows](./inserts.md) | +| `insertInto` | `(table) => CHInsertStart`, then `CHInsert`; `.values(row \| rows)` or `.select(query)` sets its rows, `.settings(record)` ClickHouse `SETTINGS`, `.returning(...)` the RETURNING list, `.onConflictDoNothing(options?)` / `.onConflictDoUpdate(options)` the ON CONFLICT clause (Postgres and SQLite). See [Inserting rows](./inserts.md) | Tables come from the dialect's `table`: [`CH.table`](#tables-and-ddl) or [`PG.table`](#tables-and-ddl-1). @@ -530,3 +531,27 @@ verification, and JSON protocol. Note `/sql` exports a `compile` (fragment → string) distinct from the dialect entries' `compile` (query → `CompiledQuery`), and a `when` distinct from the query builder's `when` (optional conditions). + +## SQLite and D1 + +`@maple-dev/effect-orm/sqlite` exports the shared query builder and the four compile functions. +`sqliteDialect` is the default; `d1Dialect` uses the same SQL with a 100-binding limit and no +interactive transactions. See [SQLite and D1](./sqlite.md) for driver wiring and limits. + +| Export | Purpose | +| --- | --- | +| `SqliteType` | A SQLite SQL type with its Effect codec | +| `text`, `integer`, `real` | Text, safe integer and finite number codecs | +| `boolean` | A boolean encoded as 0/1; other integers fail decoding | +| `timestamp` | An instant stored as SQLite's `YYYY-MM-DD HH:MM:SS.SSS` UTC text; reads ISO-8601 too | +| `sqliteTimestampText(epochMillis)` | That text for an instant, for raw SQL and seeds | +| `blob` | Bytes from a Uint8Array or D1's array wire representation | +| `json(schema)` | JSON text decoded through the supplied schema | +| `nullable(type)`, `custom`, `brand` | Type composition | +| `table(name, columns, options?)`, `TableOptions` | A query schema, including `tenantColumn`, defaults and computed-column metadata; no generated DDL | +| `table(name, ExternalTableDefinition)`, `ExternalTableDefinition` | `{ external: true, columns, tenantColumn? }`, the same query schema in the `/clickhouse` and `/postgres` form | +| `count`, `countDistinct`, `countIf`, `sum`, `avg`, `min`, `max` | SQLite aggregates; the last four can return SQL NULL | +| `lower`, `upper`, `length`, `coalesce` | SQLite scalar functions | + +SQLite's table definition is for query typing and decoding. The schema/kit/migrate entries +currently support ClickHouse and Postgres DDL only. diff --git a/docs/sqlite.md b/docs/sqlite.md new file mode 100644 index 0000000..05bc3fd --- /dev/null +++ b/docs/sqlite.md @@ -0,0 +1,133 @@ +# SQLite and Cloudflare D1 + +Import `@maple-dev/effect-orm/sqlite` for the shared query builder, SQLite codecs and functions. +Compilation uses positional `?` bindings. Each occurrence of a parameter gets its own binding. +The package opens no connection: use the Effect `SqlClient` you already provide. + +## Define and compile a query + +```ts title="sqlite-quickstart.ts" +import * as SQLite from "@maple-dev/effect-orm/sqlite" + +export const Acks = SQLite.table("acknowledgments", { + id: SQLite.text, + org_id: SQLite.text, + entry_id: SQLite.text, +}, { tenantColumn: "org_id" }) + +export const byOrg = SQLite.from(Acks) + .select("entry_id") + .where(($) => [$.org_id.eq(SQLite.param.string("orgId"))]) + .orderBy(["entry_id", "asc"]) + +export const compiled = SQLite.compileUnsafe(byOrg, { orgId: "org_1" }) +// compiled.parameters is ["org_1"]; compiled.tenantScope is "single-tenant". +``` + +`table` describes the query schema. Manage its actual DDL and migrations outside this package. +Unlike the ClickHouse and Postgres entries, SQLite has no schema diff, introspection, migration +ledger or kit dialect yet. A `table` value here cannot be passed to the DDL generators. + +The entry exports `text`, `integer` (a safe JavaScript integer), `real` (finite numbers), +`boolean` (0/1), `blob`, `timestamp`, `json(schema)`, `nullable`, `custom` and `brand`. + +`integer` refuses a stored value past 2^53 with a decode error: as a JavaScript number it would +no longer be the stored integer (D1 rounds it; node:sqlite refuses it). Use `custom` with a +bigint codec for such columns. + +`timestamp` stores an instant as SQLite's own text form, `YYYY-MM-DD HH:MM:SS.SSS` in UTC, the +form `strftime('%Y-%m-%d %H:%M:%f')` and `datetime(..., 'subsec')` write. It reads that form +and ISO-8601; text without a zone is UTC. SQLite compares text by bytes, so a timestamp column +orders by time only when every value in it has one form: write them through the builder or with +`strftime('%Y-%m-%d %H:%M:%f', ...)`. `CURRENT_TIMESTAMP` writes no milliseconds +(`2026-01-01 00:00:00`), which compares as earlier than the same instant written with them. +`blob` accepts the Uint8Array wire form from Node and the array-of-bytes form from D1. +`json` always uses the supplied schema to validate decoded JSON. +Use `text` when a stored date/time must remain a string. + +SQLite aggregates include `count`, `countDistinct`, `countIf`, `sum`, `avg`, `min` and `max`. +The last four return NULL over an empty input. Scalar helpers include `lower`, `upper`, +`length` and `coalesce`. SQLite's own text and case rules apply. Foreign built-in function +sets, such as ClickHouse's `count()`, fail compilation instead of producing invalid SQL. + +## Execute on SQLite + +Install a compatible Effect SQL driver, for example `@effect/sql-sqlite-node` on Node 24+. +No driver is a runtime dependency of this package. Use current SQLite with RETURNING support. + +```ts title="sqlite-node.ts" +import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient" +import { Layer } from "effect" +import * as Db from "@maple-dev/effect-orm/database" +import { sqliteDialect } from "@maple-dev/effect-orm/sqlite" + +export const SQLiteLive = Db.layerSqlClient({ + dialect: sqliteDialect, + requireTenantScope: true, +}).pipe(Layer.provide(SqliteClient.layer({ filename: ":memory:" }))) +``` + +`Db.run(query, params)` compiles for the database dialect and decodes the selected rows. +SQLite transactions and nested savepoints use the supplied client's `withTransaction`. +`serializable` is the only accepted explicit isolation level. Per-transaction access modes +and deferrable settings are refused. The driver must support the declared transaction behavior; +use the D1 profile for a D1 client. + +## Execute on D1 + +Install `@effect/sql-d1` with a compatible Effect version. Provide the Worker's D1 binding: + +```ts title="sqlite-d-one.ts" +import * as D1Client from "@effect/sql-d1/D1Client" +import { Layer } from "effect" +import * as Db from "@maple-dev/effect-orm/database" +import { d1Dialect } from "@maple-dev/effect-orm/sqlite" + +export const d1DatabaseLayer = (db: D1Client.D1ClientConfig["db"]) => + Db.layerSqlClient({ dialect: d1Dialect, requireTenantScope: true }).pipe( + Layer.provide(D1Client.layer({ db })), + ) +``` + +Use `d1Dialect` when compiling a query separately as well: +`SQLite.compile(query, params, { dialect: SQLite.d1Dialect })`. A query compiled for `sqlite` +cannot later run on a database configured as `d1`; pass the builder to `Db.run` instead. + +D1 compilation refuses more than [100 bound parameters per statement](https://developers.cloudflare.com/d1/platform/limits/). +It never splits a write implicitly. Chunk an import explicitly if needed; several chunks do +not make one atomic write. SQLite compilation uses its modern default limit of 32766; a driver +with a lower limit needs a custom dialect profile. + +`Database.transaction` on D1 returns `TransactionUnsupported` before executing its body. +D1's [atomic batch API](https://developers.cloudflare.com/d1/worker-api/d1-database/#batch) +executes a fixed list of statements. It cannot run an arbitrary Effect transaction body with +reads that determine later writes. There is no ORM batch API in this first implementation. + +## Safety and syntax limits + +Derived schemas validate returned rows. A row with the wrong type returns +`CompiledQueryDecodeError`. Input values use each column's encoder before binding. + +Tenant enforcement is **opt-in**, through `requireTenantScope: true`. A compiled query over a +tenant table must pin its `tenantColumn` to one value, or explicitly use `.crossTenant()` for +an administrative read/write. Raw SQL passed to `query` or `execute` has no tenant proof and +bypasses this guard. It is not a replacement for an application's authorization checks. + +The dialect handles OFFSET without LIMIT, branch-local ordering/limits in UNION ALL, and +INSERT SELECT with ON CONFLICT. `LIKE` patterns escape `%`, `_` and `\` with `\` as on every +dialect: the builder writes `ESCAPE '\'`, which SQLite needs. A fraction is bound rather than +written as decimal text, because SQLite reads some decimals as a neighbouring double, and a +fraction compared with an `integer` column (`Amount > 9.75`) compiles as on Postgres. It refuses these unsupported forms at compile time: + +- `DISTINCT ON`, row locks and `ILIKE`. Use `LIKE`, which on SQLite ignores ASCII case + (`'acme' LIKE 'ACME'` is true, unlike Postgres and ClickHouse). +- Text containing U+0000. SQLite stores it, but its drivers end text there, so the value would + read back cut short; the builder refuses it in literals and bound params. +- `ON CONSTRAINT` conflict targets. Name the indexed columns instead. +- Per-cell `DEFAULT` in a VALUES tuple. Omit a defaulted column from every row in the statement, + or group rows by their present columns. All-default rows are not supported by the builder. +- ClickHouse FORMAT/SETTINGS and built-in functions from another dialect. + +The tests run real SQLite through `@effect/sql-sqlite-node` and local workerd/D1 through +Miniflare. They require no deployed database or Cloudflare credentials. Both run the shared core +builder suite (`tests/core.sqlite.test.ts`) and every fuzz property (`tests/fuzz`). diff --git a/package.json b/package.json index 079ebcd..5238372 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@maple-dev/effect-orm", "version": "0.7.2", - "description": "Type-safe ClickHouse and Postgres queries, result decoding, and reproducible benchmarks for Effect and TypeScript", + "description": "Type-safe ClickHouse, Postgres and SQLite queries, result decoding, and reproducible benchmarks for Effect and TypeScript", "keywords": [ "clickhouse", "effect", @@ -9,7 +9,9 @@ "postgres", "query-builder", "sql", - "typescript" + "typescript", + "sqlite", + "cloudflare-d1" ], "homepage": "https://effect-clickhouse.maple.dev", "bugs": { @@ -97,6 +99,11 @@ "types": "./dist/benchmark/cli.d.mts", "import": "./dist/benchmark/cli.mjs", "default": "./dist/benchmark/cli.mjs" + }, + "./sqlite": { + "types": "./dist/sqlite.d.mts", + "import": "./dist/sqlite.mjs", + "default": "./dist/sqlite.mjs" } }, "publishConfig": { @@ -121,13 +128,16 @@ "@effect/language-service": "^0.87.3", "@effect/platform-bun": "4.0.0", "@effect/sql-clickhouse": "4.0.0", + "@effect/sql-d1": "4.0.0", "@effect/sql-pglite": "4.0.0", + "@effect/sql-sqlite-node": "4.0.0", "@effect/vitest": "4.0.0", "@electric-sql/pglite": "0.5.8", "@types/node": "^22.10.2", "effect": "4.0.0", "expect-type": "^1.3.0", "fast-check": "^4.9.0", + "miniflare": "4.20260730.0", "tsdown": "^0.22.14", "typescript": "^7.0.2", "vitest": "^4.1.9" diff --git a/scripts/check-exports-documented.mjs b/scripts/check-exports-documented.mjs index 93bec71..0aa5ff8 100644 --- a/scripts/check-exports-documented.mjs +++ b/scripts/check-exports-documented.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Every export of the two dialect entries is named in docs/reference.md, whose +// Every export of the three dialect entries is named in docs/reference.md, whose // first line claims to be the catalog. A claim like that decays the moment // anything is added, so it is checked rather than promised. // @@ -11,7 +11,7 @@ import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" const root = join(dirname(fileURLToPath(import.meta.url)), "..") -const entries = ["clickhouse", "postgres"] +const entries = ["clickhouse", "postgres", "sqlite"] const exportsOf = (entry) => { const dts = join(root, `dist/${entry}.d.mts`) diff --git a/src/ch/compile.ts b/src/ch/compile.ts index afb784b..cf4ca5b 100644 --- a/src/ch/compile.ts +++ b/src/ch/compile.ts @@ -936,7 +936,9 @@ function compileInner< const sqlQuery: SqlQuery = { distinct: state.distinct !== undefined, distinctOn: Array.isArray(state.distinct) - ? (state.distinct.length === 0 + ? (currentDialect().clauses.distinctOn === false + ? fail>(new QueryBuilderDefect({ message: `${currentDialect().name} has no DISTINCT ON clause` }), []) + : state.distinct.length === 0 ? fail>(new QueryBuilderDefect({ message: "CHQuery: distinctOn() needs at least one key" }), []) : state.distinct ).map((key: string) => { @@ -967,7 +969,9 @@ function compileInner< orderBy: state.orderByFn === undefined ? orderByClause(state.orderBySpecs).map(raw) : orderByExprs(state.orderByFn($)).map(raw), - limit: rowCount("limit", state.limitValue), + limit: state.limitValue == null && state.offsetValue != null && currentDialect().clauses.offsetRequiresLimit === true + ? raw("-1") + : rowCount("limit", state.limitValue), offset: rowCount("offset", state.offsetValue), format: options?.skipFormat ? undefined : formatClause(state.formatValue), } @@ -1476,7 +1480,9 @@ function compileUnionInner, Params extends Re : "cross-tenant" const parenthesize = currentDialect().clauses.parenthesizeUnionBranches - let sql = subQueries.map((q) => (parenthesize ? `(\n${q.sql}\n)` : q.sql)).join("\nUNION ALL\n") + let sql = subQueries.map((q) => currentDialect().clauses.wrapUnionBranches === true + ? `SELECT * FROM (\n${q.sql}\n) AS ${quoteIdent("__union_branch")}` + : parenthesize ? `(\n${q.sql}\n)` : q.sql).join("\nUNION ALL\n") // Wrap in outer SELECT if ordering/pagination is needed const hasOuter = @@ -1496,6 +1502,8 @@ function compileUnionInner, Params extends Re } if (state.outerLimitValue != null) { sql += `\nLIMIT ${compileSqlFragment(rowCount("limit", state.outerLimitValue)!)}` + } else if (state.outerOffsetValue != null && currentDialect().clauses.offsetRequiresLimit === true) { + sql += "\nLIMIT -1" } if (state.outerOffsetValue != null) { sql += `\nOFFSET ${compileSqlFragment(rowCount("offset", state.outerOffsetValue)!)}` @@ -1582,6 +1590,12 @@ function renderParams( const value = literal !== undefined ? literal.wire : encodeParam(dialect, kind as ParamKind, name, params[name]) if (style._tag === "inline") return checkedLiteral(dialect, value, paramContext(kind, name)) + if (style.nulInText === false && typeof value === "string" && value.includes("\0")) { + return fail(new QueryBuilderError({ + code: "InvalidLiteral", + message: `compile: ${paramContext(kind, name)} contains U+0000, which ${dialect.name} drivers end text at; the value would arrive cut short`, + }), "NULL") + } const key = `${kind}\0${name}` const existing = style.reuse ? bound.get(key) : undefined if (existing !== undefined) return existing @@ -1725,6 +1739,9 @@ const onConflictClause = ( let target = "" if (conflict.target !== undefined) { if ("constraint" in conflict.target) { + if (dialect.clauses.onConflictConstraint === false) { + return fail(new QueryBuilderDefect({ message: `${where}: ${dialect.name} has no ON CONSTRAINT conflict target; name its columns instead` }), "") + } if (conflict.targetWhere !== undefined) { return fail(new QueryBuilderDefect({ message: `${where}: targetWhere needs a column target, not a constraint` }), "") } @@ -1819,7 +1836,9 @@ const valueCells = (table: Table, params: Record = { ...params } let next = 0 const cell = (column: string, value: unknown, context: string): string => { - if (value === undefined) return "DEFAULT" + if (value === undefined) return currentDialect().clauses.defaultInValues === false + ? fail(new QueryBuilderDefect({ message: `${where}: ${currentDialect().name} has no per-cell DEFAULT in VALUES; group rows by their present columns` }), "NULL") + : "DEFAULT" if (isExprLike(value)) return compileSqlFragment(value.toFragment()) const wire = encodeValue(table.columns[column]!.literalSchema, value, `${where}: ${context}, column ${column}`) let name = `${VALUE_PARAM}${next++}` @@ -1995,7 +2014,9 @@ function compileInsert(insert: CHInsert, params: Record ({ dialect: dialect.name, + ilike: dialect.ilike, + likeEscape: dialect.likeEscape, + inexactNumberText: dialect.inexactNumberText, + integerComparesFraction: dialect.integerComparesFraction, quoteIdent: dialect.quoteIdent, quoteString: (value) => checked(dialect, dialect.quoteString(value), "a string literal"), literal: (value, context) => checkedLiteral(dialect, value, context), diff --git a/src/ch/expr.ts b/src/ch/expr.ts index 99773df..6c2f84e 100644 --- a/src/ch/expr.ts +++ b/src/ch/expr.ts @@ -12,8 +12,8 @@ import { raw, str, ident, compile, as_ as sqlAs, known } from "../sql/sql-fragme import { activeSqlSyntax } from "../sql/sql-syntax" import { activeLiteralBinder } from "../sql/literal-binder" import { chDateTimeLiteral, CHFloatResult, CHNumber, string as chString, type CHType, type InferEncoded, type InferTS } from "./types" -import { encodeColumnLiteral, isSubnormal, numberText, sqlLiteral } from "./literal" -import { QueryBuilderError } from "./errors" +import { encodeColumnLiteral, encodeLiteral, isSubnormal, numberText, sqlLiteral } from "./literal" +import { QueryBuilderDefect, QueryBuilderError } from "./errors" import { fail } from "./failure" import { markTenantColumn, markTenantPredicate, tenantColumnOf, tenantPredicatesOf } from "./tenant" @@ -341,7 +341,9 @@ const describeUntyped = (value: unknown): string => * column at all. */ const comparisonLiteral = (columnType: CHType, value: unknown, column: string): string => { - const literal = encodeColumnLiteral(columnType, value, column) + const literal = sqliteFraction(columnType, value) + ? encodeLiteral(Schema.Finite, value, `column ${column}`) + : encodeColumnLiteral(columnType, value, column) // Outside a compile, ClickHouse is the default dialect. const dialect = activeSqlSyntax()?.dialect ?? "clickhouse" const bound = activeLiteralBinder() !== undefined @@ -357,11 +359,32 @@ const comparisonLiteral = (columnType: CHType, value: unknown, return literal } +/** + * A fraction compared with a SQLite `integer` column: `Amount > 9.75` is a + * meaningful comparison, but the column's codec only encodes whole numbers. + * SQLite compares an INTEGER with a REAL numerically, so the value is written + * as the number it is. Inserting a fraction is still refused by the codec. + */ +const sqliteFraction = (columnType: CHType, value: unknown): boolean => + columnType.sql === "integer" && + typeof value === "number" && + Number.isFinite(value) && + !Number.isInteger(value) && + activeSqlSyntax()?.integerComparesFraction === true + const POSTGRES_INTEGERS = new Set(["int2", "int4", "int8", "smallint", "integer", "bigint"]) /** A number as SQL that reads back exactly. A subnormal goes through the dialect: ClickHouse cannot parse one as written. */ -const numberFragment = (value: number): SqlFragment => - isSubnormal(value) ? known(() => (activeSqlSyntax()?.literal ?? sqlLiteral)(value, "a number literal")) : raw(numberText(value)) +const numberFragment = (value: number): SqlFragment => { + if (Number.isInteger(value)) return raw(numberText(value)) + return known(() => { + // SQLite reads some decimals back as a neighbouring double (`1.23456789e-95`), so a + // fraction is bound there, which the driver sends as the exact double. + const bind = activeSqlSyntax()?.inexactNumberText === true ? activeLiteralBinder() : undefined + if (bind !== undefined) return bind(value) + return isSubnormal(value) ? (activeSqlSyntax()?.literal ?? sqlLiteral)(value, "a number literal") : numberText(value) + }) +} const untypedLiteral = (value: boolean): string => activeSqlSyntax()?.literal(value, "an untyped boolean") ?? (value ? "1" : "0") @@ -387,6 +410,9 @@ const likePattern = (pattern: string | Expr): string => { ) } +/** `ESCAPE '\'` where LIKE has no default escape, so `\%` means a literal `%` on every dialect. */ +const likeEscape = (): string => (activeSqlSyntax()?.likeEscape === true ? " ESCAPE '\\'" : "") + const dateTimeLiteral = (value: DateTime.Utc): string => activeSqlSyntax()?.dateTimeLiteral(value) ?? compile(str(chDateTimeLiteral(value))) @@ -531,9 +557,11 @@ export function makeExpr( notBetween: (low, high) => makeCond(known(() => `${compile(fragment)} NOT BETWEEN ${compile(operand(low))} AND ${compile(operand(high))}`)), - like: (pattern) => makeCond(known(() => `${compile(fragment)} LIKE ${likePattern(pattern)}`)), - notLike: (pattern) => makeCond(known(() => `${compile(fragment)} NOT LIKE ${likePattern(pattern)}`)), - ilike: (pattern) => makeCond(known(() => `${compile(fragment)} ILIKE ${likePattern(pattern)}`)), + like: (pattern) => makeCond(known(() => `${compile(fragment)} LIKE ${likePattern(pattern)}${likeEscape()}`)), + notLike: (pattern) => makeCond(known(() => `${compile(fragment)} NOT LIKE ${likePattern(pattern)}${likeEscape()}`)), + ilike: (pattern) => makeCond(known(() => activeSqlSyntax()?.ilike === false + ? fail(new QueryBuilderDefect({ message: `ILIKE is not supported by the ${activeSqlSyntax()?.dialect} dialect; use LIKE with SQLite's own case rules` }), "FALSE") + : `${compile(fragment)} ILIKE ${likePattern(pattern)}`)), in_: (...values) => inCond(fragment, "IN", values.map((v) => () => operand(v))), notIn: (...values) => inCond(fragment, "NOT IN", values.map((v) => () => operand(v))), diff --git a/src/ch/functions/builtin.ts b/src/ch/functions/builtin.ts index 766d2a5..7e63f68 100644 --- a/src/ch/functions/builtin.ts +++ b/src/ch/functions/builtin.ts @@ -21,10 +21,11 @@ import { QueryBuilderDefect } from "../errors" import { type Condition, type Expr, makeCond, makeExpr, makeUntypedExpr, type ParamsIn, toFragment } from "../expr" import type { FnResult } from "../define-fn" -export type FunctionSet = "clickhouse" | "postgres" +export type FunctionSet = "clickhouse" | "postgres" | "sqlite" export type BuiltinKind = "scalar" | "aggregate" | "window" const setLabel: Record = { + sqlite: "a SQLite function (from @maple-dev/effect-orm/sqlite)", clickhouse: "a ClickHouse function (from @maple-dev/effect-orm/clickhouse)", postgres: "a Postgres function (from @maple-dev/effect-orm/postgres)", } diff --git a/src/sql/sql-syntax.ts b/src/sql/sql-syntax.ts index bf0fe19..e87bb68 100644 --- a/src/sql/sql-syntax.ts +++ b/src/sql/sql-syntax.ts @@ -11,6 +11,25 @@ import type { DateTime } from "effect" export interface SqlSyntax { /** The dialect's name (`clickhouse`, `postgres`, …), for the few choices expression code makes per dialect. */ readonly dialect: string + /** False when the dialect has no ILIKE operator. */ + readonly ilike?: boolean + /** + * True when LIKE has no default escape character, so `\` in a pattern is + * literal unless the statement says `ESCAPE '\'` (SQLite). Patterns use `\` + * to escape `%`, `_` and `\` on every dialect. + */ + readonly likeEscape?: boolean + /** + * True when the database parses some decimal literals to a neighbouring + * double (SQLite), so a fraction must be bound rather than written as text. + */ + readonly inexactNumberText?: boolean + /** + * True when an integer column compares with a fraction numerically (SQLite's + * type affinity), so `integer > 9.75` writes the fraction instead of refusing + * it through the column's whole-number codec. + */ + readonly integerComparesFraction?: boolean /** One identifier (a column, alias, table or schema name), quoted as needed. */ readonly quoteIdent: (name: string) => string /** A string as a quoted literal. */ diff --git a/src/sqlite.ts b/src/sqlite.ts new file mode 100644 index 0000000..d8c0df2 --- /dev/null +++ b/src/sqlite.ts @@ -0,0 +1,63 @@ +// @maple-dev/effect-orm/sqlite +// +// SQLite and Cloudflare D1 queries, codecs, functions and execution through an +// Effect SqlClient. `table` is a query schema only: this entry writes no DDL +// and generates no migrations yet. See docs/sqlite.md. +import { compileCH, compileCHRaw, compileCHUnsafe, compileUnion as compileUnionCH, compileUnionUnsafe as compileUnionUnsafeCH } from "./ch/compile" +import { compiled, compiledUnsafe } from "./ch/failure" +import { table as queryTable, type Table, type TableOptions } from "./ch/table" +import type { ColumnDefs } from "./ch/types" +import { sqliteDialect } from "./sqlite/dialect" + +export * from "./core" +export * from "./sqlite/types" +export * from "./sqlite/functions" +export type { TableOptions } + +/** A table another tool owns, as on `/clickhouse` and `/postgres`. */ +export interface ExternalTableDefinition { + readonly external: true + readonly columns: Columns + readonly tenantColumn?: keyof Columns & string +} + +/** + * A SQLite query schema: `table(name, columns, options?)`, or + * `table(name, { external: true, columns, tenantColumn? })`. Neither carries DDL. + */ +export function table( + name: Name, + definition: ExternalTableDefinition, +): Table +export function table< + const Name extends string, + const Columns extends ColumnDefs, + const Defaulted extends keyof Columns & string = never, + const Computed extends keyof Columns & string = never, +>(name: Name, columns: Columns, options?: TableOptions): Table +export function table(name: string, definition: any, options?: TableOptions): Table { + if (options === undefined && definition.external === true && typeof definition.columns === "object") { + return queryTable(name, definition.columns, definition.tenantColumn === undefined ? undefined : { tenantColumn: definition.tenantColumn }) + } + return queryTable(name, definition, options) +} +export { sqliteDialect, d1Dialect } from "./sqlite/dialect" + +/** The shared compiler, defaulting to SQLite. Pass d1Dialect for D1's limits. */ +export const compile: typeof compileCH = ( + query: Parameters[0], + params?: Record, + options?: Parameters[2], +) => compiled(() => compileCHRaw(query, params, { ...options, dialect: options?.dialect ?? sqliteDialect })) + +/** Compile immediately, throwing on a failed compile. */ +export const compileUnsafe: typeof compileCHUnsafe = ( + query: Parameters[0], + params?: Record, + options?: Parameters[2], +) => compiledUnsafe(() => compileCHRaw(query, params, { ...options, dialect: options?.dialect ?? sqliteDialect })) + +export const compileUnion: typeof compileUnionCH = (query, params, options) => + compileUnionCH(query, params, { ...options, dialect: options?.dialect ?? sqliteDialect }) +export const compileUnionUnsafe: typeof compileUnionUnsafeCH = (query, params, options) => + compileUnionUnsafeCH(query, params, { ...options, dialect: options?.dialect ?? sqliteDialect }) diff --git a/src/sqlite/d1.test.ts b/src/sqlite/d1.test.ts new file mode 100644 index 0000000..c35df5a --- /dev/null +++ b/src/sqlite/d1.test.ts @@ -0,0 +1,100 @@ +// A real local Workers D1 binding; no cloud account or network database needed. +import * as D1Client from "@effect/sql-d1/D1Client" +import { Cause, Effect, Exit, Schema } from "effect" +import * as SqlClient from "effect/sql/SqlClient" +import { Miniflare } from "miniflare" +import { expect, it } from "vitest" +import * as Db from "../database" +import * as SQLite from "../sqlite" + +const Ack = SQLite.table("whats_new_ack", { + id: SQLite.text, + entry_id: SQLite.text, + user_id: SQLite.text, + org_id: SQLite.text, + acked_at: SQLite.text, +}, { tenantColumn: "org_id" }) +const first = { id: "ack_1", entry_id: "release_1", user_id: "user_1", org_id: "org_1", acked_at: "2026-10-01T09:00:00.000Z" } +const second = { ...first, id: "ack_2", entry_id: "release_2" } +const other = { ...first, id: "ack_3", user_id: "user_2", org_id: "org_2" } + +const withD1 = async (body: (db: Db.DatabaseApi, statements: Array) => Effect.Effect) => { + const mf = new Miniflare({ + modules: true, + script: "export default { fetch() { return new Response('D1 dialect test') } }", + compatibilityDate: "2026-07-01", + d1Databases: ["DB"], + }) + try { + const binding = await mf.getD1Database("DB") + return await Effect.runPromise(Effect.gen(function* () { + const client = yield* SqlClient.SqlClient + const statements: Array = [] + const db = Db.fromSqlClient(client, { + dialect: SQLite.d1Dialect, + requireTenantScope: true, + observe: (statement) => Effect.sync(() => void statements.push(statement.sql)), + }) + yield* db.execute({ sql: "CREATE TABLE whats_new_ack (id TEXT PRIMARY KEY, entry_id TEXT NOT NULL, user_id TEXT NOT NULL, org_id TEXT NOT NULL, acked_at TEXT NOT NULL, UNIQUE(entry_id, user_id))" }) + statements.length = 0 + return yield* body(db, statements) + }).pipe(Effect.provide(D1Client.layer({ db: binding })))) + } finally { + await mf.dispose() + } +} + +it("copies acknowledgments on D1, keeps the first row, scopes reads and pages explicitly across tenants", async () => { + const result = await withD1((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Ack).values([first, second, other]).onConflictDoNothing().crossTenant()) + yield* db.run(SQLite.insertInto(Ack).values({ ...first, id: "ack_9", acked_at: "2026-10-09T00:00:00.000Z" }).onConflictDoNothing()) + const byUser = yield* db.run(SQLite.from(Ack).select("entry_id").where(($) => [$.org_id.eq("org_1"), $.user_id.eq("user_1")]).orderBy(["entry_id", "asc"])) + const page = yield* db.run(SQLite.from(Ack).select().where(($) => [$.id.gt("ack_1")]).orderBy(["id", "asc"]).limit(2).crossTenant()) + const kept = yield* db.run(SQLite.from(Ack).select().where(($) => [$.org_id.eq("org_1"), $.id.eq("ack_1")])) + return { byUser, page, kept } + })) + expect(result).toEqual({ byUser: [{ entry_id: "release_1" }, { entry_id: "release_2" }], page: [second, other], kept: [first] }) +}, 30_000) + +it("refuses an unscoped query, too many bindings and interactive transactions before dispatch", async () => { + const seen = await withD1((db, statements) => Effect.gen(function* () { + const scope = yield* Effect.exit(db.run(SQLite.from(Ack).select())) + const limits = yield* Effect.exit(db.run(SQLite.insertInto(Ack).values(Array.from({ length: 21 }, (_, i) => ({ ...first, id: `${i}` }))).crossTenant())) + let bodyRan = false + const transaction = yield* Effect.exit(db.transaction(Effect.sync(() => { bodyRan = true }))) + return { scope, limits, transaction, bodyRan, statements: [...statements] } + })) + if (!Exit.isFailure(seen.scope) || !Exit.isFailure(seen.limits) || !Exit.isFailure(seen.transaction)) throw new Error("a D1 guard accepted an invalid request") + expect(Cause.findDefect(seen.scope.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/TenantScopeViolation" } }) + expect(Cause.findError(seen.limits.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/QueryBuilderError" } }) + expect(Cause.findError(seen.transaction.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/TransactionUnsupported" } }) + expect(seen.bodyRan).toBe(false) + expect(seen.statements).toEqual([]) +}, 30_000) + +it("decodes D1 JSON and blob wire values and returns typed errors for malformed rows", async () => { + const Payloads = SQLite.table("payloads", { id: SQLite.integer, payload: SQLite.json(Schema.Struct({ ok: Schema.Boolean })), bytes: SQLite.blob }) + const seen = await withD1((db) => Effect.gen(function* () { + yield* db.execute({ sql: "CREATE TABLE payloads (id INTEGER, payload TEXT, bytes BLOB)" }) + yield* db.run(SQLite.insertInto(Payloads).values({ id: 1, payload: { ok: true }, bytes: new Uint8Array([0, 127, 255]) })) + const rows = yield* db.run(SQLite.from(Payloads).select()) + yield* db.execute({ sql: "UPDATE payloads SET payload = '{}' WHERE id = 1" }) + const malformed = yield* Effect.exit(db.run(SQLite.from(Payloads).select())) + return { rows, malformed } + })) + expect(seen.rows).toEqual([{ id: 1, payload: { ok: true }, bytes: new Uint8Array([0, 127, 255]) }]) + if (!Exit.isFailure(seen.malformed)) throw new Error("malformed JSON was accepted") + expect(Cause.findError(seen.malformed.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/CompiledQueryDecodeError" } }) +}, 30_000) + +it("refuses a D1 integer past 2^53 instead of decoding a different number", async () => { + const Big = SQLite.table("big", { id: SQLite.integer }) + const exit = await withD1((db) => Effect.gen(function* () { + yield* db.execute({ sql: "CREATE TABLE big (id INTEGER)" }) + // D1 reads 9007199254740993 as the JS number 9007199254740992: no longer the stored value. + yield* db.execute({ sql: "INSERT INTO big VALUES (9007199254740993)" }) + return yield* Effect.exit(db.run(SQLite.from(Big).select().crossTenant())) + })) + if (!Exit.isFailure(exit)) throw new Error(`an unsafe integer decoded as ${JSON.stringify(exit.value)}`) + expect(Cause.findError(exit.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/CompiledQueryDecodeError" } }) +}, 30_000) diff --git a/src/sqlite/dialect.ts b/src/sqlite/dialect.ts new file mode 100644 index 0000000..f4a79f9 --- /dev/null +++ b/src/sqlite/dialect.ts @@ -0,0 +1,85 @@ +import { DateTime } from "effect" +import { noTransactions, type Dialect } from "../ch/dialect" +import { QueryBuilderError } from "../ch/errors" +import { fail } from "../ch/failure" +import { PARAM_MARKER_PREFIX } from "../ch/param" +import { SqliteTimestampLiteral, sqliteTimestampLiteral, sqliteTimestampText } from "./timestamp" + +const hex = (value: Uint8Array): string => + Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("") + +/** + * Hex text keeps the compiler's reserved param marker out of SQL text. U+0000 + * is refused: SQLite stores it, but its drivers end text there, so the value + * would read back cut short. + */ +const quoteString = (value: string): string => + value.includes("\0") + ? fail(new QueryBuilderError({ code: "InvalidLiteral", message: "a string literal contains U+0000, which SQLite drivers end text at; the value would read back cut short" }), "''") + : value.includes(PARAM_MARKER_PREFIX) + ? `CAST(X'${hex(new TextEncoder().encode(value))}' AS TEXT)` + : `'${value.replace(/'/g, "''")}'` + +const literal = (value: unknown, context: string): string => { + if (value === null) return "NULL" + if (typeof value === "string") return quoteString(value) + if (typeof value === "boolean") return value ? "1" : "0" + if (typeof value === "number" && Number.isFinite(value)) return String(value) + if (value instanceof Uint8Array) return `X'${hex(value)}'` + return fail(new QueryBuilderError({ code: "InvalidLiteral", message: `${context}: cannot write this value as a SQLite literal` }), "NULL") +} + +/** SQLite with positional bindings and transactions supplied by the caller's SqlClient. */ +export const sqliteDialect: Dialect = { + name: "sqlite", + functions: "sqlite", + ilike: false, + likeEscape: true, + inexactNumberText: true, + integerComparesFraction: true, + quoteIdent: (name) => `"${name.replace(/"/g, '""')}"`, + quoteString, + literal, + dateTimeLiteral: (value) => { + try { + return quoteString(sqliteTimestampText(DateTime.toEpochMillis(value))) + } catch (error) { + return fail(new QueryBuilderError({ code: "InvalidLiteral", message: (error as Error).message }), "NULL") + } + }, + params: { _tag: "bind", placeholder: () => "?", reuse: false, maxParameters: 32766, nulInText: false }, + clauses: { + format: false, + derivedTableAlias: true, + groupByAlias: false, + parenthesizeUnionBranches: false, + wrapUnionBranches: true, + offsetRequiresLimit: true, + returning: true, + onConflict: true, + onConflictConstraint: false, + insertSelectNeedsWhere: true, + defaultInValues: false, + distinctOn: false, + }, + paramCodecs: { + dateTime: SqliteTimestampLiteral, + dateTimeSeconds: sqliteTimestampLiteral((millis) => Math.floor(millis / 1000) * 1000), + }, + transactions: { + support: "full", + savepoints: true, + isolationLevels: ["serializable"], + accessModes: false, + deferrable: false, + setTransaction: () => undefined, + }, +} + +/** D1 executes SQLite SQL, limits each statement to 100 bindings, and has no interactive transactions. */ +export const d1Dialect: Dialect = { + ...sqliteDialect, + name: "d1", + params: { ...sqliteDialect.params, _tag: "bind", placeholder: () => "?", reuse: false, maxParameters: 100 }, + transactions: noTransactions, +} diff --git a/src/sqlite/functions.ts b/src/sqlite/functions.ts new file mode 100644 index 0000000..afcc9b6 --- /dev/null +++ b/src/sqlite/functions.ts @@ -0,0 +1,37 @@ +import { Schema } from "effect" +import { makeExpr, type Condition, type Expr } from "../ch/expr" +import { schemaOf } from "../ch/define-fn" +import { builtins } from "../ch/functions/builtin" +import { compile } from "../sql/sql-fragment" + +const aggregate = builtins("sqlite", "aggregate") +const scalar = builtins("sqlite", "scalar") +const sql = (expr: Expr | Condition): string => compile(expr.toFragment()) +const nullableNumber = Schema.NullOr(Schema.Finite) + +export const count = (): Expr => makeExpr(aggregate.lazy(() => "count(*)", "count"), Schema.Int) +export const countDistinct =

(expr: Expr): Expr => + makeExpr(aggregate.lazy(() => `count(DISTINCT ${sql(expr)})`, "count"), Schema.Int) +export const countIf =

(condition: Condition

): Expr => + makeExpr(aggregate.lazy(() => `count(*) FILTER (WHERE ${sql(condition)})`, "count"), Schema.Int) +export const sum =

(expr: Expr): Expr => + makeExpr(aggregate.lazy(() => `sum(${sql(expr)})`, "sum"), nullableNumber) +export const avg =

(expr: Expr): Expr => + makeExpr(aggregate.lazy(() => `avg(${sql(expr)})`, "avg"), nullableNumber) +export const min = (expr: Expr): Expr => { + const schema = schemaOf(expr) + return makeExpr(aggregate.lazy(() => `min(${sql(expr)})`, "min"), schema === undefined ? undefined : Schema.NullOr(schema)) +} +export const max = (expr: Expr): Expr => { + const schema = schemaOf(expr) + return makeExpr(aggregate.lazy(() => `max(${sql(expr)})`, "max"), schema === undefined ? undefined : Schema.NullOr(schema)) +} +export const lower =

(expr: Expr): Expr => + makeExpr(scalar.lazy(() => `lower(${sql(expr)})`, "lower"), Schema.NullOr(Schema.String)) +export const upper =

(expr: Expr): Expr => + makeExpr(scalar.lazy(() => `upper(${sql(expr)})`, "upper"), Schema.NullOr(Schema.String)) +/** Text length in SQLite characters, or blob length in bytes. */ +export const length =

(expr: Expr): Expr => + makeExpr(scalar.lazy(() => `length(${sql(expr)})`, "length"), Schema.NullOr(Schema.Int)) +export const coalesce = (first: Expr, fallback: Expr): Expr => + makeExpr(scalar.lazy(() => `coalesce(${sql(first)}, ${sql(fallback)})`, "coalesce"), schemaOf(fallback)) diff --git a/src/sqlite/sqlite.test-d.ts b/src/sqlite/sqlite.test-d.ts new file mode 100644 index 0000000..7d3f78a --- /dev/null +++ b/src/sqlite/sqlite.test-d.ts @@ -0,0 +1,22 @@ +import type { Effect } from "effect" +import { expectTypeOf } from "expect-type" +import * as SQLite from "../sqlite" + +const Items = SQLite.table("items", { id: SQLite.integer, note: SQLite.text }) +const query = SQLite.from(Items).select().where(($) => [$.id.eq(SQLite.param.int("id"))]) +const compiled = SQLite.compileUnsafe(query, { id: 1 }) +expectTypeOf>>().toEqualTypeOf>() +const effect = SQLite.compile(query, { id: 1 }) +expectTypeOf>().toEqualTypeOf() +const insert = SQLite.compileUnsafe(SQLite.insertInto(Items).values({ id: 1, note: "n" }).returning("note")) +expectTypeOf>>().toEqualTypeOf>() +// @ts-expect-error a select cannot name a missing column +SQLite.from(Items).select("missing") +// @ts-expect-error required params remain required through the SQLite compile wrapper +SQLite.compile(query, {}) +// @ts-expect-error an integer column cannot take a string +SQLite.insertInto(Items).values({ id: "wrong", note: "n" }) +const External = SQLite.table("ext", { external: true, columns: { id: SQLite.integer } }) +SQLite.from(External).select("id") +// @ts-expect-error an external table has only its declared columns +SQLite.from(External).select("columns") diff --git a/src/sqlite/sqlite.test.ts b/src/sqlite/sqlite.test.ts new file mode 100644 index 0000000..fe5ff92 --- /dev/null +++ b/src/sqlite/sqlite.test.ts @@ -0,0 +1,218 @@ +import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient" +import { Cause, DateTime, Effect, Exit, Schema } from "effect" +import * as SqlClient from "effect/sql/SqlClient" +import { expect, it } from "vitest" +import * as Db from "../database" +import * as SQLite from "../sqlite" + +const Items = SQLite.table("items", { + id: SQLite.integer, + note: SQLite.text, + enabled: SQLite.boolean, + score: SQLite.nullable(SQLite.real), +}) +const withDatabase = (body: (db: Db.DatabaseApi) => Effect.Effect) => + Effect.runPromise(Effect.gen(function* () { + const db = Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect: SQLite.sqliteDialect }) + yield* db.execute({ sql: "CREATE TABLE items (id INTEGER PRIMARY KEY, note TEXT NOT NULL, enabled INTEGER NOT NULL, score REAL DEFAULT 7)" }) + return yield* body(db) + }).pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:" })))) + +it("runs and decodes SQLite writes and selects through an existing Effect SqlClient", async () => { + const seen = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values({ id: 1, note: "O'Reilly\\notes", enabled: true, score: null })) + return yield* db.run(SQLite.from(Items).select().where(($) => [$.enabled.eq(SQLite.param.bool("enabled"))]), { enabled: true }) + })) + expect(seen).toEqual([{ id: 1, note: "O'Reilly\\notes", enabled: true, score: null }]) +}) + +it("keeps query and union pagination when OFFSET has no LIMIT and branches have local limits", async () => { + const rows = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values([1, 2, 3].map((id) => ({ id, note: `${id}`, enabled: false, score: null })))) + const offset = yield* db.run(SQLite.from(Items).select("id").orderBy(["id", "asc"]).offset(1)) + const branch = (direction: "asc" | "desc") => SQLite.from(Items).select("id").orderBy(["id", direction]).limit(1) + const union = yield* db.run(SQLite.unionAll(branch("asc"), branch("desc")).orderBy(["id", "asc"]).offset(1)) + return { offset, union } + })) + expect(rows).toEqual({ offset: [{ id: 2 }, { id: 3 }], union: [{ id: 3 }] }) +}) + +it("refuses unsupported SQLite syntax while compiling, before sending SQL", () => { + const defaulted = SQLite.table("items", { id: SQLite.integer, note: SQLite.text }, { defaults: ["note"] }) + expect(() => SQLite.compileUnsafe(SQLite.insertInto(defaulted).values([{ id: 1, note: "a" }, { id: 2 }]))).toThrow(/DEFAULT/) + expect(() => SQLite.compileUnsafe(SQLite.insertInto(Items) + .values({ id: 1, note: "a", enabled: true, score: null }) + .onConflictDoNothing({ target: { constraint: "items_pkey" } }))).toThrow(/ON CONSTRAINT/) + expect(() => SQLite.compileUnsafe(SQLite.from(Items).select("id").distinctOn("id"))).toThrow(/DISTINCT ON/) + expect(() => SQLite.compileUnsafe(SQLite.from(Items).select("id").where(($) => [$.note.ilike("a%")]))).toThrow(/ILIKE/) + expect(() => SQLite.compileUnsafe(SQLite.from(Items).select("id").forUpdate())).toThrow(/row locks/) +}) + +it("runs INSERT SELECT with ON CONFLICT even when the source has no WHERE", async () => { + const rows = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values({ id: 1, note: "first", enabled: true, score: null })) + yield* db.run(SQLite.insertInto(Items).select(SQLite.from(Items).select()).onConflictDoNothing()) + return yield* db.run(SQLite.from(Items).select("id")) + })) + expect(rows).toEqual([{ id: 1 }]) +}) + +it("decodes SQLite aggregates, including SQL NULL for an empty aggregate", async () => { + const rows = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values([ + { id: 1, note: "A", enabled: true, score: 2 }, + { id: 2, note: "B", enabled: false, score: 4 }, + ])) + const totals = yield* db.run(SQLite.from(Items).select(($) => ({ n: SQLite.count(), enabled: SQLite.countIf($.enabled.eq(true)), total: SQLite.sum($.score), avg: SQLite.avg($.score), min: SQLite.min($.score), max: SQLite.max($.score) }))) + const empty = yield* db.run(SQLite.from(Items).select(($) => ({ n: SQLite.count(), sum: SQLite.sum($.score) })).where(($) => [$.id.eq(99)])) + return { totals, empty } + })) + expect(rows).toEqual({ totals: [{ n: 2, enabled: 1, total: 6, avg: 3, min: 2, max: 4 }], empty: [{ n: 0, sum: null }] }) +}) + +it("round-trips JSON, blobs and zoned timestamps, and runs SQLite scalar functions", async () => { + const Payloads = SQLite.table("payloads", { id: SQLite.integer, value: SQLite.json(Schema.Struct({ message: Schema.String })), bytes: SQLite.blob, at: SQLite.timestamp }) + const seen = await withDatabase((db) => Effect.gen(function* () { + yield* db.execute({ sql: "CREATE TABLE payloads (id INTEGER, value TEXT, bytes BLOB, at TEXT)" }) + const returned = yield* db.run(SQLite.insertInto(Payloads).values({ id: 1, value: { message: "__PARAM_id__\0😀" }, bytes: new Uint8Array([0, 127, 255]), at: DateTime.makeUnsafe("2026-10-01T10:00:00+02:00") }).returning()) + yield* db.run(SQLite.insertInto(Items).values({ id: 1, note: "HELLO", enabled: true, score: null })) + const scalar = yield* db.run(SQLite.from(Items).select(($) => ({ lower: SQLite.lower($.note), upper: SQLite.upper($.note), length: SQLite.length($.note), fallback: SQLite.coalesce($.score, SQLite.lit(9)) }))) + return { returned, scalar } + })) + expect(seen.returned[0]?.value).toEqual({ message: "__PARAM_id__\0😀" }) + expect(seen.returned[0]?.bytes).toEqual(new Uint8Array([0, 127, 255])) + expect(seen.returned.map((row) => DateTime.formatIso(row.at))).toEqual(["2026-10-01T08:00:00.000Z"]) + expect(seen.scalar).toEqual([{ lower: "hello", upper: "HELLO", length: 5, fallback: 9 }]) +}) + +it("binds repeated positional params independently and quotes reserved-marker literals safely", async () => { + const result = await withDatabase((db) => Effect.gen(function* () { + const note = "' OR 1=1 -- __PARAM_note__😀" + yield* db.run(SQLite.insertInto(Items).values({ id: 1, note, enabled: false, score: null })) + const query = SQLite.from(Items).select("id").where(($) => [$.note.eq(SQLite.param.string("note")), $.note.eq(SQLite.param.string("note"))]) + const compiled = yield* SQLite.compile(query, { note }) + const rows = yield* db.run(compiled) + const literal = yield* db.query({ sql: `SELECT ${SQLite.sqliteDialect.quoteString(note)} AS note` }) + return { parameters: compiled.parameters, rows, literal } + })) + expect(result.parameters).toEqual(["' OR 1=1 -- __PARAM_note__😀", "' OR 1=1 -- __PARAM_note__😀"]) + expect(result.rows).toEqual([{ id: 1 }]) + expect(result.literal).toEqual([{ note: "' OR 1=1 -- __PARAM_note__😀" }]) +}) + +it("compares timestamps with the text SQLite's own functions write, and reads zoneless text as UTC", async () => { + const Events = SQLite.table("events", { id: SQLite.integer, at: SQLite.timestamp }) + const rows = await withDatabase((db) => Effect.gen(function* () { + yield* db.execute({ sql: "CREATE TABLE events (id INTEGER, at TEXT NOT NULL)" }) + // Written by SQLite, not the builder: strftime's %f form, and CURRENT_TIMESTAMP-style text read back. + yield* db.execute({ sql: "INSERT INTO events VALUES (1, strftime('%Y-%m-%d %H:%M:%f', 'now')), (2, '2026-01-01 00:00:00')" }) + const recent = yield* db.run(SQLite.from(Events).select("id").where(($) => [$.at.gt(DateTime.makeUnsafe(Date.now() - 3_600_000))])) + const zoneless = yield* db.run(SQLite.from(Events).select("at").where(($) => [$.id.eq(2)])) + return { recent, zoneless } + })) + expect(rows.recent).toEqual([{ id: 1 }]) + // No zone in the text means UTC, whatever the host's time zone. + expect(rows.zoneless.map((row) => DateTime.formatIso(row.at))).toEqual(["2026-01-01T00:00:00.000Z"]) + const written = SQLite.compileUnsafe(SQLite.insertInto(Events).values({ id: 3, at: DateTime.makeUnsafe("2026-10-01T10:00:00.5+02:00") })) + expect(written.parameters).toContain("2026-10-01 08:00:00.500") +}) + +it("refuses a timestamp outside 0000-9999 instead of writing text that is no timestamp", () => { + const Events = SQLite.table("events", { id: SQLite.integer, at: SQLite.timestamp }) + const refused = (effect: Effect.Effect) => { + const exit = Effect.runSyncExit(effect) + return Exit.isFailure(exit) && /outside 0000-9999/.test(String(Cause.squash(exit.cause))) + } + const far = DateTime.makeUnsafe(new Date(8.64e15)) + expect(refused(SQLite.compile(SQLite.insertInto(Events).values({ id: 1, at: far })))).toBe(true) + expect(refused(SQLite.compile(SQLite.from(Events).select("id").where(($) => [$.at.gt(DateTime.makeUnsafe("+010000-01-01T00:00:00Z"))])))).toBe(true) + expect(refused(SQLite.compile(SQLite.from(Events).select("id").where(($) => [$.at.gt(SQLite.param.dateTime("t"))]), { t: far }))).toBe(true) + expect(() => SQLite.sqliteTimestampText(8.64e15)).toThrow(/outside 0000-9999/) +}) + +it("compares a fraction with an integer column on a profile spread from sqliteDialect", () => { + const profile = { ...SQLite.sqliteDialect, name: "my-sqlite" } + const compiled = SQLite.compileUnsafe(SQLite.from(Items).select("id").where(($) => [$.id.gt(9.75)]), {}, { dialect: profile }) + expect(compiled.parameters).toEqual([9.75]) +}) + +it("refuses an integer past 2^53 instead of decoding a different number", async () => { + const exit = await withDatabase((db) => Effect.gen(function* () { + // node:sqlite refuses an unsafe integer itself; D1 hands one over (see d1.test.ts), where the codec refuses it. + yield* db.execute({ sql: "INSERT INTO items VALUES (9007199254740993, 'big', 1, NULL)" }) + return yield* Effect.exit(db.run(SQLite.from(Items).select("id"))) + })) + expect(Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : exit.value).toMatch(/safe integer|too large to be represented/) +}) + +it("refuses U+0000 in text, which SQLite drivers would cut short", () => { + // node:sqlite returns `SELECT 'a' || char(0) || 'b'` as "a": the value would be silently truncated. + const refused = (exit: Exit.Exit) => Exit.isFailure(exit) && /U\+0000/.test(String(Cause.squash(exit.cause))) + const where = SQLite.from(Items).select("id").where(($) => [$.note.eq(SQLite.param.string("note"))]) + expect(refused(Effect.runSyncExit(SQLite.compile(where, { note: "a\0b" })))).toBe(true) + expect(refused(Effect.runSyncExit(SQLite.compile(SQLite.insertInto(Items).values({ id: 1, note: "a\0b", enabled: true, score: null }))))).toBe(true) + expect(refused(Effect.runSyncExit(SQLite.compile(SQLite.from(Items).select(() => ({ v: SQLite.lit("a\0b") })))))).toBe(true) +}) + +it("binds a fraction instead of writing decimal text SQLite would read as another double", async () => { + const value = 1.23456789e-95 + const rows = await withDatabase((db) => db.run(SQLite.from(Items).select(() => ({ v: SQLite.lit(value) })).limit(1).crossTenant())) + const compiled = SQLite.compileUnsafe(SQLite.from(Items).select(() => ({ v: SQLite.lit(value) }))) + expect(compiled.sql).not.toContain("e-95") + expect(compiled.parameters).toEqual([value]) + expect(rows).toEqual([]) +}) + +it("rejects a stored value that does not match the derived row schema", async () => { + const exit = await withDatabase((db) => Effect.gen(function* () { + yield* db.execute({ sql: "INSERT INTO items VALUES (1, 'bad boolean', 2, NULL)" }) + return yield* Effect.exit(db.run(SQLite.from(Items).select())) + })) + if (!Exit.isFailure(exit)) throw new Error("invalid database row was accepted") + expect(Cause.findError(exit.cause)).toMatchObject({ success: { _tag: "@maple-dev/effect-orm/CompiledQueryDecodeError" } }) +}) + +it("rolls back failed SQLite transactions and nested savepoints", async () => { + const ids = await withDatabase((db) => Effect.gen(function* () { + const insert = (id: number) => db.run(SQLite.insertInto(Items).values({ id, note: `${id}`, enabled: true, score: null })) + yield* db.transaction(Effect.gen(function* () { + yield* insert(1) + yield* Effect.result(db.transaction(Effect.andThen(insert(2), Effect.fail("inner")))) + yield* insert(3) + })) + yield* Effect.result(db.transaction(Effect.andThen(insert(4), Effect.fail("outer")))) + return yield* db.run(SQLite.from(Items).select("id").orderBy(["id", "asc"])) + })) + expect(ids).toEqual([{ id: 1 }, { id: 3 }]) +}) + +it("executes SQLite upserts, updates and deletes with RETURNING", async () => { + const rows = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values([{ id: 1, note: "first", enabled: true, score: 2 }, { id: 2, note: "second", enabled: false, score: null }])) + const upsert = yield* db.run(SQLite.insertInto(Items).values({ id: 1, note: "replacement", enabled: true, score: 3 }) + .onConflictDoUpdate({ target: ["id"], set: { note: "updated" } }).returning("id", "note")) + const update = yield* db.run(SQLite.update(Items).set({ note: "changed" }).where(($) => [$.id.eq(2)]).returning("note")) + const deleted = yield* db.run(SQLite.deleteFrom(Items).where(($) => [$.id.eq(2)]).returning("id")) + return { upsert, update, deleted } + })) + expect(rows).toEqual({ upsert: [{ id: 1, note: "updated" }], update: [{ note: "changed" }], deleted: [{ id: 2 }] }) +}) + +it("runs scoped CTEs, joins and derived-table queries with the same row decoder", async () => { + const rows = await withDatabase((db) => Effect.gen(function* () { + yield* db.run(SQLite.insertInto(Items).values({ id: 1, note: "one", enabled: true, score: null })) + const filtered = SQLite.from(Items).select("id", "note").where(($) => [$.id.eq(SQLite.param.int("id"))]) + const Recent = SQLite.table("recent", { id: SQLite.integer, note: SQLite.text }) + const cte = yield* db.run(SQLite.from(Recent).withCTE("recent", filtered).select(), { id: 1 }) + const joined = yield* db.run(SQLite.fromQuery(filtered, "filtered").innerJoin(Items, "i", (a, b) => a.id.eq(b.id)).select(($) => ({ id: $.id, note: $.i.note })), { id: 1 }) + return { cte, joined } + })) + expect(rows).toEqual({ cte: [{ id: 1, note: "one" }], joined: [{ id: 1, note: "one" }] }) +}) + +it("builds an external table from its columns, not from the definition's keys", () => { + const External = SQLite.table("ext", { external: true, columns: { id: SQLite.integer, org_id: SQLite.text }, tenantColumn: "org_id" }) + expect(Object.keys(External.columns)).toEqual(["id", "org_id"]) + expect(External.tenantColumn).toBe("org_id") + expect(SQLite.compileUnsafe(SQLite.from(External).select("id").where(($) => [$.org_id.eq("o")])).sql).toContain('"ext"."org_id" = ?') +}) diff --git a/src/sqlite/timestamp.ts b/src/sqlite/timestamp.ts new file mode 100644 index 0000000..ae1a048 --- /dev/null +++ b/src/sqlite/timestamp.ts @@ -0,0 +1,92 @@ +// SQLite timestamp text: the form `SQLite.timestamp` writes and reads. +import { DateTime, Schema, SchemaGetter } from "effect" +import { pgTimestampToIso } from "../pg/types" + +/** + * An instant as SQLite's own text form, `YYYY-MM-DD HH:MM:SS.SSS` in UTC: what + * `strftime('%Y-%m-%d %H:%M:%f')` and `datetime(..., 'subsec')` write. SQLite + * compares text by bytes, so a column only orders by time when every value has + * one form; this is the form SQLite's own functions produce (see `timestampNow`). + */ +export const sqliteTimestampText = (epochMillis: number): string => { + if (!inRange(epochMillis)) throw new RangeError(`${new Date(epochMillis).toISOString()} is outside 0000-9999, which SQLite's text timestamps cover`) + return textOf(epochMillis) +} + +/** The text, for an instant already checked to be in range. */ +const textOf = (epochMillis: number): string => { + const iso = new Date(epochMillis).toISOString() + return `${iso.slice(0, 10)} ${iso.slice(11, 23)}` +} + +/** Years SQLite's date functions and the text form cover: four digits, no sign. Past 9999, toISOString writes `+010000-…`. */ +const inRange = (epochMillis: number): boolean => { + const year = new Date(epochMillis).getUTCFullYear() + return year >= 0 && year <= 9999 +} +const rangeMessage = (epochMillis: number) => `year ${new Date(epochMillis).getUTCFullYear()} is outside 0000-9999, which SQLite's text timestamps cover` + +// Each check sits on the decoded side, so a value outside the range is refused before an encoder sees it. +const dateTimeInRange = Schema.makeFilter((value: DateTime.Utc) => (inRange(DateTime.toEpochMillis(value)) ? undefined : rangeMessage(DateTime.toEpochMillis(value))), { + title: "sqliteTimestampRange", +}) +const dateInRange = Schema.makeFilter( + (value: Date) => (Number.isNaN(value.getTime()) ? "an invalid Date" : inRange(value.getTime()) ? undefined : rangeMessage(value.getTime())), + { title: "sqliteTimestampRange" }, +) + +const isTimestampText = Schema.makeFilter( + (value: string) => { + const millis = Date.parse(pgTimestampToIso(value)) + if (Number.isNaN(millis)) return `\`${value}\` is not a timestamp: expected \`YYYY-MM-DD HH:MM:SS[.SSS]\` or ISO-8601` + return inRange(millis) ? undefined : rangeMessage(millis) + }, + { title: "sqliteTimestamp" }, +) + +/** Reads SQLite's form and ISO-8601 (zoneless text is UTC); writes SQLite's form. */ +export const SqliteTimestamp: Schema.Codec = Schema.String.pipe( + Schema.check(isTimestampText), + Schema.decodeTo(Schema.DateTimeUtc.pipe(Schema.check(dateTimeInRange)), { + decode: SchemaGetter.transform((value: string) => DateTime.makeUnsafe(pgTimestampToIso(value))), + encode: SchemaGetter.transform((value: DateTime.Utc) => textOf(DateTime.toEpochMillis(value))), + }), +) + +/** + * What a timestamp comparison or param accepts (a `DateTime.Utc`, a `Date`, + * timestamp text), written in SQLite's form after `adjust` (truncation to + * seconds, for `param.dateTimeSeconds`). + */ +export const sqliteTimestampLiteral = (adjust: (epochMillis: number) => number = (millis) => millis): Schema.Codec => + Schema.Union([ + Schema.String.pipe( + Schema.check(isTimestampText), + Schema.decodeTo(Schema.DateTimeUtc.pipe(Schema.check(dateTimeInRange)), { + decode: SchemaGetter.transform((value: string) => DateTime.makeUnsafe(pgTimestampToIso(value))), + encode: SchemaGetter.transform((value: DateTime.Utc) => textOf(adjust(DateTime.toEpochMillis(value)))), + }), + ), + Schema.String.pipe( + Schema.decodeTo(Schema.Date.pipe(Schema.check(dateInRange)), { + decode: SchemaGetter.transform((value: string) => new Date(pgTimestampToIso(value))), + encode: SchemaGetter.transform((value: Date) => textOf(adjust(value.getTime()))), + }), + ), + Schema.String.pipe( + Schema.check(isTimestampText), + Schema.decodeTo(Schema.String.pipe(Schema.check(isTimestampText)), { + decode: SchemaGetter.transform((value: string) => value), + encode: SchemaGetter.transform((value: string) => textOf(adjust(Date.parse(pgTimestampToIso(value))))), + }), + ), + ]) as Schema.Codec + +export const SqliteTimestampLiteral = sqliteTimestampLiteral() + +/** + * A `defaultExpr` for a `timestamp` column: the current time in the form the + * column writes. `CURRENT_TIMESTAMP` writes no milliseconds, so its values + * compare as earlier than the same instant written by the builder. + */ +export const timestampNow = "(strftime('%Y-%m-%d %H:%M:%f', 'now'))" diff --git a/src/sqlite/types.ts b/src/sqlite/types.ts new file mode 100644 index 0000000..9fb9990 --- /dev/null +++ b/src/sqlite/types.ts @@ -0,0 +1,49 @@ +import { Schema, SchemaGetter } from "effect" +import { brand, custom, type CHType } from "../ch/types" +import { SqliteTimestamp, SqliteTimestampLiteral } from "./timestamp" + +export { sqliteTimestampText } from "./timestamp" + +/** A SQLite storage type and the codec for its wire values. */ +export type SqliteType = CHType + +export const text = custom("text", Schema.String) +/** + * Past 2^53 a driver's number is no longer the stored integer (node:sqlite and + * D1 both read INTEGER as a JS number), so a value outside the safe range is a + * decode error, not a silently different number. Use a custom codec for a + * driver's bigint mode. + */ +const SafeInt = Schema.Number.check( + Schema.makeFilter((value: number) => (Number.isSafeInteger(value) ? undefined : `${value} is not a safe integer; SQLite integers past 2^53 lose precision as JS numbers`), { + title: "safeInteger", + }), +) +/** A JavaScript safe integer; see `SafeInt`. */ +export const integer = custom("integer", SafeInt) +export const real = custom("real", Schema.Finite) +/** SQLite stores booleans as 0/1. Other integers are not booleans. */ +export const boolean = custom("integer", Schema.BooleanFromBit) +/** An instant stored as SQLite timestamp text; see `sqliteTimestampText`. */ +export const timestamp = custom("text", SqliteTimestamp, SqliteTimestampLiteral) + +const bytes = Schema.Array(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 255 }))).pipe( + Schema.decodeTo(Schema.Uint8Array, { + decode: SchemaGetter.transform((value) => new Uint8Array(value)), + encode: SchemaGetter.transform((value) => Array.from(value)), + }), +) +/** Node sends Uint8Array; D1 can send an array of bytes. */ +export const blob = custom("blob", Schema.Union([Schema.Uint8Array, bytes])) +/** JSON is stored as TEXT and decoded through the caller's schema. */ +export const json = (schema: Schema.Codec) => custom("text", Schema.fromJsonString(schema)) + +export const nullable = (type: SqliteType): SqliteType<"Nullable", A | null, I | null> => ({ + _tag: "Nullable", + sql: type.sql, + schema: Schema.NullOr(type.schema), + literalSchema: Schema.NullOr(type.literalSchema), + element: type, +}) + +export { brand, custom } diff --git a/tests/__snapshots__/core-sql.test.ts.snap b/tests/__snapshots__/core-sql.test.ts.snap index fba9e28..943bc1f 100644 --- a/tests/__snapshots__/core-sql.test.ts.snap +++ b/tests/__snapshots__/core-sql.test.ts.snap @@ -240,6 +240,24 @@ SELECT } `; +exports[`core SQL (clickhouse) > ilike 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Status ILIKE 'PA%' + ORDER BY id ASC", +} +`; + exports[`core SQL (clickhouse) > inner-join 1`] = ` { "parameters": [], @@ -340,6 +358,42 @@ SELECT } `; +exports[`core SQL (clickhouse) > like-case 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Customer LIKE 'ACME' + ORDER BY id ASC", +} +`; + +exports[`core SQL (clickhouse) > like-escape 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id + FROM orders + WHERE orders.OrgId LIKE 'org\\\\_1' + AND orders.Note NOT LIKE 'it\\\\_s' + ORDER BY id ASC", +} +`; + exports[`core SQL (clickhouse) > limit-offset 1`] = ` { "parameters": [], @@ -435,7 +489,7 @@ SELECT FROM orders WHERE orders.OrgId = 'org_1' AND (orders.Customer LIKE '%e%' AND orders.Customer NOT LIKE 'i%') - AND (orders.Status ILIKE 'PA%' OR orders.Status IN ('open')) + AND (orders.Status LIKE 'pa%' OR orders.Status IN ('open')) AND orders.Id NOT IN (3) AND NOT (orders.Id = 2) ORDER BY id ASC", @@ -604,7 +658,7 @@ FORMAT JSON", } `; -exports[`core SQL (postgres) > and-or 1`] = ` +exports[`core SQL (d1) > and-or 1`] = ` { "parameters": [ "org_1", @@ -621,13 +675,13 @@ exports[`core SQL (postgres) > and-or 1`] = ` SELECT "orders"."Id" AS "id" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND (("orders"."Status" = $2 AND "orders"."Amount" > $3) OR "orders"."Customer" = $4) + WHERE "orders"."OrgId" = ? + AND (("orders"."Status" = ? AND "orders"."Amount" > ?) OR "orders"."Customer" = ?) ORDER BY "id" ASC", } `; -exports[`core SQL (postgres) > arithmetic 1`] = ` +exports[`core SQL (d1) > arithmetic 1`] = ` { "parameters": [ "org_1", @@ -646,12 +700,12 @@ SELECT "orders"."Amount" % 3 AS "remainder", "orders"."Amount" / 2 AS "divided" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND "orders"."Id" = $2", + WHERE "orders"."OrgId" = ? + AND "orders"."Id" = ?", } `; -exports[`core SQL (postgres) > comparisons 1`] = ` +exports[`core SQL (d1) > comparisons 1`] = ` { "parameters": [ "org_1", @@ -659,6 +713,7 @@ exports[`core SQL (postgres) > comparisons 1`] = ` 5, 10, 20, + 10, ], "sql": "WITH "orders" AS ( @@ -669,16 +724,16 @@ exports[`core SQL (postgres) > comparisons 1`] = ` SELECT "orders"."Id" AS "id" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND "orders"."Status" != $2 - AND "orders"."Amount" > $3 - AND "orders"."Amount" >= $4 - AND "orders"."Amount" < $5 - AND "orders"."Amount" <= $4", + WHERE "orders"."OrgId" = ? + AND "orders"."Status" != ? + AND "orders"."Amount" > ? + AND "orders"."Amount" >= ? + AND "orders"."Amount" < ? + AND "orders"."Amount" <= ?", } `; -exports[`core SQL (postgres) > cross-join 1`] = ` +exports[`core SQL (d1) > cross-join 1`] = ` { "parameters": [ "org_1", @@ -696,16 +751,17 @@ SELECT "c"."Name" AS "customer" FROM "orders" CROSS JOIN "customers" AS "c" - WHERE "orders"."OrgId" = $1 - AND "c"."OrgId" = $2 - AND "orders"."Id" <= $3 + WHERE "orders"."OrgId" = ? + AND "c"."OrgId" = ? + AND "orders"."Id" <= ? ORDER BY "id" ASC, "customer" ASC", } `; -exports[`core SQL (postgres) > cross-join-subquery 1`] = ` +exports[`core SQL (d1) > cross-join-subquery 1`] = ` { "parameters": [ + "org_1", "org_1", 2, ], @@ -728,14 +784,14 @@ SELECT SELECT count(*) AS "customers" FROM "customers" - WHERE "customers"."OrgId" = $1) AS "k" - WHERE "orders"."OrgId" = $1 - AND "orders"."Id" <= $2 + WHERE "customers"."OrgId" = ?) AS "k" + WHERE "orders"."OrgId" = ? + AND "orders"."Id" <= ? ORDER BY "id" ASC", } `; -exports[`core SQL (postgres) > cte 1`] = ` +exports[`core SQL (d1) > cte 1`] = ` { "parameters": [ "org_1", @@ -758,8 +814,8 @@ SELECT "orders"."Customer" AS "Customer", "orders"."Amount" AS "Amount" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND "orders"."Status" = $2 + WHERE "orders"."OrgId" = ? + AND "orders"."Status" = ? ) SELECT sum("paid"."Amount") AS "total" @@ -767,27 +823,7 @@ SELECT } `; -exports[`core SQL (postgres) > distinct 1`] = ` -{ - "parameters": [ - "org_1", - ], - "sql": "WITH "orders" AS ( - -), -"customers" AS ( - -) -SELECT DISTINCT ON ("customer") - "orders"."Customer" AS "customer", - "orders"."Id" AS "id" - FROM "orders" - WHERE "orders"."OrgId" = $1 - ORDER BY "customer" ASC, "id" DESC", -} -`; - -exports[`core SQL (postgres) > distinct-plain 1`] = ` +exports[`core SQL (d1) > distinct-plain 1`] = ` { "parameters": [ "org_1", @@ -801,12 +837,12 @@ exports[`core SQL (postgres) > distinct-plain 1`] = ` SELECT DISTINCT "orders"."Status" AS "status" FROM "orders" - WHERE "orders"."OrgId" = $1 + WHERE "orders"."OrgId" = ? ORDER BY "status" ASC", } `; -exports[`core SQL (postgres) > from-subquery 1`] = ` +exports[`core SQL (d1) > from-subquery 1`] = ` { "parameters": [ "org_1", @@ -826,14 +862,14 @@ SELECT "orders"."Customer" AS "customer", sum("orders"."Amount") AS "total" FROM "orders" - WHERE "orders"."OrgId" = $1 + WHERE "orders"."OrgId" = ? GROUP BY 1, 2) AS "t" - WHERE "t"."total" > $2 + WHERE "t"."total" > ? ORDER BY "total" DESC", } `; -exports[`core SQL (postgres) > group-having 1`] = ` +exports[`core SQL (d1) > group-having 1`] = ` { "parameters": [ "paid", @@ -848,19 +884,19 @@ exports[`core SQL (postgres) > group-having 1`] = ` SELECT "orders"."Customer" AS "customer", count(*) AS "orders", - count(*) FILTER (WHERE "orders"."Status" = $1) AS "paid", + count(*) FILTER (WHERE "orders"."Status" = ?) AS "paid", sum("orders"."Amount") AS "total", min("orders"."Amount") AS "smallest", max("orders"."Amount") AS "largest" FROM "orders" - WHERE "orders"."OrgId" = $2 + WHERE "orders"."OrgId" = ? GROUP BY 1 HAVING count(*) >= 2 ORDER BY "customer" ASC", } `; -exports[`core SQL (postgres) > inner-join 1`] = ` +exports[`core SQL (d1) > inner-join 1`] = ` { "parameters": [ "org_1", @@ -876,12 +912,12 @@ SELECT "c"."Tier" AS "tier" FROM "orders" INNER JOIN "customers" AS "c" ON ("orders"."Customer" = "c"."Name" AND "orders"."OrgId" = "c"."OrgId") - WHERE "orders"."OrgId" = $1 + WHERE "orders"."OrgId" = ? ORDER BY "id" ASC", } `; -exports[`core SQL (postgres) > is-not-null 1`] = ` +exports[`core SQL (d1) > is-not-null 1`] = ` { "parameters": [ "org_1", @@ -895,17 +931,19 @@ exports[`core SQL (postgres) > is-not-null 1`] = ` SELECT "orders"."Id" AS "id" FROM "orders" - WHERE "orders"."OrgId" = $1 + WHERE "orders"."OrgId" = ? AND "orders"."Note" IS NOT NULL ORDER BY "id" ASC", } `; -exports[`core SQL (postgres) > join-subqueries 1`] = ` +exports[`core SQL (d1) > join-subqueries 1`] = ` { "parameters": [ "org_1", "silver", + "org_1", + "org_1", ], "sql": "WITH "orders" AS ( @@ -928,8 +966,8 @@ SELECT "customers"."Name" AS "name", "customers"."Tier" AS "tier" FROM "customers" - WHERE "customers"."OrgId" = $1 - AND "customers"."Tier" != $2) AS "t" ON "customers"."Name" = "t"."name" + WHERE "customers"."OrgId" = ? + AND "customers"."Tier" != ?) AS "t" ON "customers"."Name" = "t"."name" LEFT JOIN (WITH "orders" AS ( ), @@ -940,14 +978,14 @@ SELECT "orders"."Customer" AS "customer", count(*) AS "orders" FROM "orders" - WHERE "orders"."OrgId" = $1 + WHERE "orders"."OrgId" = ? GROUP BY 1) AS "n" ON "customers"."Name" = "n"."customer" - WHERE "customers"."OrgId" = $1 + WHERE "customers"."OrgId" = ? ORDER BY "name" ASC", } `; -exports[`core SQL (postgres) > left-join 1`] = ` +exports[`core SQL (d1) > left-join 1`] = ` { "parameters": [ "org_1", @@ -964,16 +1002,17 @@ SELECT "c"."Tier" AS "tier" FROM "orders" LEFT JOIN "customers" AS "c" ON ("orders"."Customer" = "c"."Name" AND "orders"."OrgId" = "c"."OrgId") - WHERE "orders"."OrgId" = $1 - AND "orders"."Id" >= $2 + WHERE "orders"."OrgId" = ? + AND "orders"."Id" >= ? ORDER BY "id" ASC", } `; -exports[`core SQL (postgres) > limit-offset 1`] = ` +exports[`core SQL (d1) > like-case 1`] = ` { "parameters": [ "org_1", + "ACME", ], "sql": "WITH "orders" AS ( @@ -984,18 +1023,37 @@ exports[`core SQL (postgres) > limit-offset 1`] = ` SELECT "orders"."Id" AS "id" FROM "orders" - WHERE "orders"."OrgId" = $1 - ORDER BY "id" DESC - LIMIT 2 - OFFSET 1", + WHERE "orders"."OrgId" = ? + AND "orders"."Customer" LIKE ? ESCAPE '\\' + ORDER BY "id" ASC", } `; -exports[`core SQL (postgres) > locking 1`] = ` +exports[`core SQL (d1) > like-escape 1`] = ` +{ + "parameters": [ + "org\\_1", + "it\\_s", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" LIKE ? ESCAPE '\\' + AND "orders"."Note" NOT LIKE ? ESCAPE '\\' + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (d1) > limit-offset 1`] = ` { "parameters": [ "org_1", - 1, ], "sql": "WITH "orders" AS ( @@ -1006,19 +1064,21 @@ exports[`core SQL (postgres) > locking 1`] = ` SELECT "orders"."Id" AS "id" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND "orders"."Id" = $2 - FOR UPDATE SKIP LOCKED", + WHERE "orders"."OrgId" = ? + ORDER BY "id" DESC + LIMIT 2 + OFFSET 1", } `; -exports[`core SQL (postgres) > null-and-range 1`] = ` +exports[`core SQL (d1) > null-and-range 1`] = ` { "parameters": [ "org_1", 5, 20, 3, + 3, ], "sql": "WITH "orders" AS ( @@ -1029,15 +1089,15 @@ exports[`core SQL (postgres) > null-and-range 1`] = ` SELECT "orders"."Id" AS "id" FROM "orders" - WHERE "orders"."OrgId" = $1 + WHERE "orders"."OrgId" = ? AND "orders"."Note" IS NULL - AND "orders"."Amount" BETWEEN $2 AND $3 - AND "orders"."Id" NOT BETWEEN $4 AND $4 + AND "orders"."Amount" BETWEEN ? AND ? + AND "orders"."Id" NOT BETWEEN ? AND ? ORDER BY "id" ASC", } `; -exports[`core SQL (postgres) > nulls-and-literals 1`] = ` +exports[`core SQL (d1) > nulls-and-literals 1`] = ` { "parameters": [ "org_1", @@ -1056,19 +1116,19 @@ SELECT coalesce("orders"."Note", '-') AS "shown", 'x''y' AS "label" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND "orders"."Id" IN ($2, $3) + WHERE "orders"."OrgId" = ? + AND "orders"."Id" IN (?, ?) ORDER BY "id" ASC", } `; -exports[`core SQL (postgres) > params 1`] = ` +exports[`core SQL (d1) > params 1`] = ` { "parameters": [ "org_1", 7, 19.5, - "2026-01-01T00:00:00.000Z", + "2026-01-01 00:00:00.000", ], "sql": "WITH "orders" AS ( @@ -1080,21 +1140,21 @@ SELECT "orders"."Id" AS "id", "orders"."Created" AS "created" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND "orders"."Amount" >= $2 - AND "orders"."Amount" < $3::float8 - AND "orders"."Created" >= $4::timestamptz + WHERE "orders"."OrgId" = ? + AND "orders"."Amount" >= ? + AND "orders"."Amount" < ? + AND "orders"."Created" >= ? ORDER BY "id" ASC", } `; -exports[`core SQL (postgres) > patterns-and-lists 1`] = ` +exports[`core SQL (d1) > patterns-and-lists 1`] = ` { "parameters": [ "org_1", "%e%", "i%", - "PA%", + "pa%", "open", 3, 2, @@ -1108,16 +1168,16 @@ exports[`core SQL (postgres) > patterns-and-lists 1`] = ` SELECT "orders"."Id" AS "id" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND ("orders"."Customer" LIKE $2 AND "orders"."Customer" NOT LIKE $3) - AND ("orders"."Status" ILIKE $4 OR "orders"."Status" IN ($5)) - AND "orders"."Id" NOT IN ($6) - AND NOT ("orders"."Id" = $7) + WHERE "orders"."OrgId" = ? + AND ("orders"."Customer" LIKE ? ESCAPE '\\' AND "orders"."Customer" NOT LIKE ? ESCAPE '\\') + AND ("orders"."Status" LIKE ? ESCAPE '\\' OR "orders"."Status" IN (?)) + AND "orders"."Id" NOT IN (?) + AND NOT ("orders"."Id" = ?) ORDER BY "id" ASC", } `; -exports[`core SQL (postgres) > route-and-cross-tenant 1`] = ` +exports[`core SQL (d1) > route-and-cross-tenant 1`] = ` { "parameters": [], "sql": "WITH "orders" AS ( @@ -1132,7 +1192,7 @@ SELECT } `; -exports[`core SQL (postgres) > select-columns 1`] = ` +exports[`core SQL (d1) > select-columns 1`] = ` { "parameters": [ "org_1", @@ -1148,16 +1208,16 @@ SELECT "orders"."Id" AS "Id", "orders"."Customer" AS "Customer" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND "orders"."Status" = $2 + WHERE "orders"."OrgId" = ? + AND "orders"."Status" = ? ORDER BY "Id" ASC", } `; -exports[`core SQL (postgres) > selected-param 1`] = ` +exports[`core SQL (d1) > selected-param 1`] = ` { "parameters": [ - true, + 1, "org_1", 1, ], @@ -1169,23 +1229,25 @@ exports[`core SQL (postgres) > selected-param 1`] = ` ) SELECT "orders"."Id" AS "id", - $1::boolean AS "flagged" + ? AS "flagged" FROM "orders" - WHERE "orders"."OrgId" = $2 - AND "orders"."Id" = $3", + WHERE "orders"."OrgId" = ? + AND "orders"."Id" = ?", } `; -exports[`core SQL (postgres) > union 1`] = ` +exports[`core SQL (d1) > union 1`] = ` { "parameters": [ "org_1", "paid", + "org_1", "open", + "org_1", "void", ], "sql": "SELECT * FROM ( -( +SELECT * FROM ( WITH "orders" AS ( ), @@ -1196,12 +1258,12 @@ SELECT "orders"."Status" AS "status", sum("orders"."Amount") AS "total" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND "orders"."Status" = $2 + WHERE "orders"."OrgId" = ? + AND "orders"."Status" = ? GROUP BY 1 -) +) AS "__union_branch" UNION ALL -( +SELECT * FROM ( WITH "orders" AS ( ), @@ -1212,12 +1274,12 @@ SELECT "orders"."Status" AS "status", sum("orders"."Amount") AS "total" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND "orders"."Status" = $3 + WHERE "orders"."OrgId" = ? + AND "orders"."Status" = ? GROUP BY 1 -) +) AS "__union_branch" UNION ALL -( +SELECT * FROM ( WITH "orders" AS ( ), @@ -1228,10 +1290,10 @@ SELECT "orders"."Status" AS "status", sum("orders"."Amount") AS "total" FROM "orders" - WHERE "orders"."OrgId" = $1 - AND "orders"."Status" = $4 + WHERE "orders"."OrgId" = ? + AND "orders"."Status" = ? GROUP BY 1 -) +) AS "__union_branch" ) AS "__union" ORDER BY "total" DESC LIMIT 2 @@ -1239,10 +1301,10 @@ OFFSET 1", } `; -exports[`core SQL (postgres) > union-cross-tenant 1`] = ` +exports[`core SQL (d1) > union-cross-tenant 1`] = ` { "parameters": [], - "sql": "( + "sql": "SELECT * FROM ( WITH "orders" AS ( ), @@ -1252,9 +1314,9 @@ WITH "orders" AS ( SELECT count(*) AS "orders" FROM "orders" -) +) AS "__union_branch" UNION ALL -( +SELECT * FROM ( WITH "orders" AS ( ), @@ -1264,6 +1326,1405 @@ WITH "orders" AS ( SELECT count(*) AS "orders" FROM "orders" -)", +) AS "__union_branch"", +} +`; + +exports[`core SQL (postgres) > and-or 1`] = ` +{ + "parameters": [ + "org_1", + "paid", + 15, + "globex", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND (("orders"."Status" = $2 AND "orders"."Amount" > $3) OR "orders"."Customer" = $4) + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > arithmetic 1`] = ` +{ + "parameters": [ + "org_1", + 4, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Amount" + 1 AS "added", + "orders"."Amount" - 1 AS "subtracted", + "orders"."Amount" * 2 AS "multiplied", + "orders"."Amount" % 3 AS "remainder", + "orders"."Amount" / 2 AS "divided" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Id" = $2", +} +`; + +exports[`core SQL (postgres) > comparisons 1`] = ` +{ + "parameters": [ + "org_1", + "void", + 5, + 10, + 20, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" != $2 + AND "orders"."Amount" > $3 + AND "orders"."Amount" >= $4 + AND "orders"."Amount" < $5 + AND "orders"."Amount" <= $4", +} +`; + +exports[`core SQL (postgres) > cross-join 1`] = ` +{ + "parameters": [ + "org_1", + "org_1", + 2, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "c"."Name" AS "customer" + FROM "orders" + CROSS JOIN "customers" AS "c" + WHERE "orders"."OrgId" = $1 + AND "c"."OrgId" = $2 + AND "orders"."Id" <= $3 + ORDER BY "id" ASC, "customer" ASC", +} +`; + +exports[`core SQL (postgres) > cross-join-subquery 1`] = ` +{ + "parameters": [ + "org_1", + 2, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "k"."customers" AS "customers" + FROM "orders" + CROSS JOIN (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + count(*) AS "customers" + FROM "customers" + WHERE "customers"."OrgId" = $1) AS "k" + WHERE "orders"."OrgId" = $1 + AND "orders"."Id" <= $2 + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > cte 1`] = ` +{ + "parameters": [ + "org_1", + "paid", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +), +"paid" AS ( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Customer" AS "Customer", + "orders"."Amount" AS "Amount" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" = $2 +) +SELECT + sum("paid"."Amount") AS "total" + FROM "paid"", +} +`; + +exports[`core SQL (postgres) > distinct 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT DISTINCT ON ("customer") + "orders"."Customer" AS "customer", + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + ORDER BY "customer" ASC, "id" DESC", +} +`; + +exports[`core SQL (postgres) > distinct-plain 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT DISTINCT + "orders"."Status" AS "status" + FROM "orders" + WHERE "orders"."OrgId" = $1 + ORDER BY "status" ASC", +} +`; + +exports[`core SQL (postgres) > from-subquery 1`] = ` +{ + "parameters": [ + "org_1", + 6, + ], + "sql": "SELECT + "t"."customer" AS "customer", + "t"."total" AS "total" + FROM (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."OrgId" AS "OrgId", + "orders"."Customer" AS "customer", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = $1 + GROUP BY 1, 2) AS "t" + WHERE "t"."total" > $2 + ORDER BY "total" DESC", +} +`; + +exports[`core SQL (postgres) > group-having 1`] = ` +{ + "parameters": [ + "paid", + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Customer" AS "customer", + count(*) AS "orders", + count(*) FILTER (WHERE "orders"."Status" = $1) AS "paid", + sum("orders"."Amount") AS "total", + min("orders"."Amount") AS "smallest", + max("orders"."Amount") AS "largest" + FROM "orders" + WHERE "orders"."OrgId" = $2 + GROUP BY 1 + HAVING count(*) >= 2 + ORDER BY "customer" ASC", +} +`; + +exports[`core SQL (postgres) > ilike 1`] = ` +{ + "parameters": [ + "org_1", + "PA%", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" ILIKE $2 + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > inner-join 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "c"."Tier" AS "tier" + FROM "orders" + INNER JOIN "customers" AS "c" ON ("orders"."Customer" = "c"."Name" AND "orders"."OrgId" = "c"."OrgId") + WHERE "orders"."OrgId" = $1 + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > is-not-null 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Note" IS NOT NULL + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > join-subqueries 1`] = ` +{ + "parameters": [ + "org_1", + "silver", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "customers"."Name" AS "name", + "t"."tier" AS "tier", + "n"."orders" AS "orders" + FROM "customers" + INNER JOIN (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "customers"."Name" AS "name", + "customers"."Tier" AS "tier" + FROM "customers" + WHERE "customers"."OrgId" = $1 + AND "customers"."Tier" != $2) AS "t" ON "customers"."Name" = "t"."name" + LEFT JOIN (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Customer" AS "customer", + count(*) AS "orders" + FROM "orders" + WHERE "orders"."OrgId" = $1 + GROUP BY 1) AS "n" ON "customers"."Name" = "n"."customer" + WHERE "customers"."OrgId" = $1 + ORDER BY "name" ASC", +} +`; + +exports[`core SQL (postgres) > left-join 1`] = ` +{ + "parameters": [ + "org_1", + 3, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "c"."Tier" AS "tier" + FROM "orders" + LEFT JOIN "customers" AS "c" ON ("orders"."Customer" = "c"."Name" AND "orders"."OrgId" = "c"."OrgId") + WHERE "orders"."OrgId" = $1 + AND "orders"."Id" >= $2 + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > like-case 1`] = ` +{ + "parameters": [ + "org_1", + "ACME", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Customer" LIKE $2 + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > like-escape 1`] = ` +{ + "parameters": [ + "org\\_1", + "it\\_s", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" LIKE $1 + AND "orders"."Note" NOT LIKE $2 + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > limit-offset 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + ORDER BY "id" DESC + LIMIT 2 + OFFSET 1", +} +`; + +exports[`core SQL (postgres) > locking 1`] = ` +{ + "parameters": [ + "org_1", + 1, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Id" = $2 + FOR UPDATE SKIP LOCKED", +} +`; + +exports[`core SQL (postgres) > null-and-range 1`] = ` +{ + "parameters": [ + "org_1", + 5, + 20, + 3, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Note" IS NULL + AND "orders"."Amount" BETWEEN $2 AND $3 + AND "orders"."Id" NOT BETWEEN $4 AND $4 + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > nulls-and-literals 1`] = ` +{ + "parameters": [ + "org_1", + 2, + 4, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "orders"."Note" AS "note", + coalesce("orders"."Note", '-') AS "shown", + 'x''y' AS "label" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Id" IN ($2, $3) + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > params 1`] = ` +{ + "parameters": [ + "org_1", + 7, + 19.5, + "2026-01-01T00:00:00.000Z", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "orders"."Created" AS "created" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Amount" >= $2 + AND "orders"."Amount" < $3::float8 + AND "orders"."Created" >= $4::timestamptz + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > patterns-and-lists 1`] = ` +{ + "parameters": [ + "org_1", + "%e%", + "i%", + "pa%", + "open", + 3, + 2, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND ("orders"."Customer" LIKE $2 AND "orders"."Customer" NOT LIKE $3) + AND ("orders"."Status" LIKE $4 OR "orders"."Status" IN ($5)) + AND "orders"."Id" NOT IN ($6) + AND NOT ("orders"."Id" = $7) + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > route-and-cross-tenant 1`] = ` +{ + "parameters": [], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + count(*) AS "orders" + FROM "orders"", +} +`; + +exports[`core SQL (postgres) > select-columns 1`] = ` +{ + "parameters": [ + "org_1", + "paid", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "Id", + "orders"."Customer" AS "Customer" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" = $2 + ORDER BY "Id" ASC", +} +`; + +exports[`core SQL (postgres) > selected-param 1`] = ` +{ + "parameters": [ + true, + "org_1", + 1, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + $1::boolean AS "flagged" + FROM "orders" + WHERE "orders"."OrgId" = $2 + AND "orders"."Id" = $3", +} +`; + +exports[`core SQL (postgres) > union 1`] = ` +{ + "parameters": [ + "org_1", + "paid", + "open", + "void", + ], + "sql": "SELECT * FROM ( +( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Status" AS "status", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" = $2 + GROUP BY 1 +) +UNION ALL +( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Status" AS "status", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" = $3 + GROUP BY 1 +) +UNION ALL +( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Status" AS "status", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" = $4 + GROUP BY 1 +) +) AS "__union" +ORDER BY "total" DESC +LIMIT 2 +OFFSET 1", +} +`; + +exports[`core SQL (postgres) > union-cross-tenant 1`] = ` +{ + "parameters": [], + "sql": "( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + count(*) AS "orders" + FROM "orders" +) +UNION ALL +( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + count(*) AS "orders" + FROM "orders" +)", +} +`; + +exports[`core SQL (sqlite) > and-or 1`] = ` +{ + "parameters": [ + "org_1", + "paid", + 15, + "globex", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND (("orders"."Status" = ? AND "orders"."Amount" > ?) OR "orders"."Customer" = ?) + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (sqlite) > arithmetic 1`] = ` +{ + "parameters": [ + "org_1", + 4, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Amount" + 1 AS "added", + "orders"."Amount" - 1 AS "subtracted", + "orders"."Amount" * 2 AS "multiplied", + "orders"."Amount" % 3 AS "remainder", + "orders"."Amount" / 2 AS "divided" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Id" = ?", +} +`; + +exports[`core SQL (sqlite) > comparisons 1`] = ` +{ + "parameters": [ + "org_1", + "void", + 5, + 10, + 20, + 10, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Status" != ? + AND "orders"."Amount" > ? + AND "orders"."Amount" >= ? + AND "orders"."Amount" < ? + AND "orders"."Amount" <= ?", +} +`; + +exports[`core SQL (sqlite) > cross-join 1`] = ` +{ + "parameters": [ + "org_1", + "org_1", + 2, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "c"."Name" AS "customer" + FROM "orders" + CROSS JOIN "customers" AS "c" + WHERE "orders"."OrgId" = ? + AND "c"."OrgId" = ? + AND "orders"."Id" <= ? + ORDER BY "id" ASC, "customer" ASC", +} +`; + +exports[`core SQL (sqlite) > cross-join-subquery 1`] = ` +{ + "parameters": [ + "org_1", + "org_1", + 2, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "k"."customers" AS "customers" + FROM "orders" + CROSS JOIN (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + count(*) AS "customers" + FROM "customers" + WHERE "customers"."OrgId" = ?) AS "k" + WHERE "orders"."OrgId" = ? + AND "orders"."Id" <= ? + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (sqlite) > cte 1`] = ` +{ + "parameters": [ + "org_1", + "paid", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +), +"paid" AS ( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Customer" AS "Customer", + "orders"."Amount" AS "Amount" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Status" = ? +) +SELECT + sum("paid"."Amount") AS "total" + FROM "paid"", +} +`; + +exports[`core SQL (sqlite) > distinct-plain 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT DISTINCT + "orders"."Status" AS "status" + FROM "orders" + WHERE "orders"."OrgId" = ? + ORDER BY "status" ASC", +} +`; + +exports[`core SQL (sqlite) > from-subquery 1`] = ` +{ + "parameters": [ + "org_1", + 6, + ], + "sql": "SELECT + "t"."customer" AS "customer", + "t"."total" AS "total" + FROM (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."OrgId" AS "OrgId", + "orders"."Customer" AS "customer", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = ? + GROUP BY 1, 2) AS "t" + WHERE "t"."total" > ? + ORDER BY "total" DESC", +} +`; + +exports[`core SQL (sqlite) > group-having 1`] = ` +{ + "parameters": [ + "paid", + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Customer" AS "customer", + count(*) AS "orders", + count(*) FILTER (WHERE "orders"."Status" = ?) AS "paid", + sum("orders"."Amount") AS "total", + min("orders"."Amount") AS "smallest", + max("orders"."Amount") AS "largest" + FROM "orders" + WHERE "orders"."OrgId" = ? + GROUP BY 1 + HAVING count(*) >= 2 + ORDER BY "customer" ASC", +} +`; + +exports[`core SQL (sqlite) > inner-join 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "c"."Tier" AS "tier" + FROM "orders" + INNER JOIN "customers" AS "c" ON ("orders"."Customer" = "c"."Name" AND "orders"."OrgId" = "c"."OrgId") + WHERE "orders"."OrgId" = ? + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (sqlite) > is-not-null 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Note" IS NOT NULL + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (sqlite) > join-subqueries 1`] = ` +{ + "parameters": [ + "org_1", + "silver", + "org_1", + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "customers"."Name" AS "name", + "t"."tier" AS "tier", + "n"."orders" AS "orders" + FROM "customers" + INNER JOIN (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "customers"."Name" AS "name", + "customers"."Tier" AS "tier" + FROM "customers" + WHERE "customers"."OrgId" = ? + AND "customers"."Tier" != ?) AS "t" ON "customers"."Name" = "t"."name" + LEFT JOIN (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Customer" AS "customer", + count(*) AS "orders" + FROM "orders" + WHERE "orders"."OrgId" = ? + GROUP BY 1) AS "n" ON "customers"."Name" = "n"."customer" + WHERE "customers"."OrgId" = ? + ORDER BY "name" ASC", +} +`; + +exports[`core SQL (sqlite) > left-join 1`] = ` +{ + "parameters": [ + "org_1", + 3, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "c"."Tier" AS "tier" + FROM "orders" + LEFT JOIN "customers" AS "c" ON ("orders"."Customer" = "c"."Name" AND "orders"."OrgId" = "c"."OrgId") + WHERE "orders"."OrgId" = ? + AND "orders"."Id" >= ? + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (sqlite) > like-case 1`] = ` +{ + "parameters": [ + "org_1", + "ACME", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Customer" LIKE ? ESCAPE '\\' + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (sqlite) > like-escape 1`] = ` +{ + "parameters": [ + "org\\_1", + "it\\_s", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" LIKE ? ESCAPE '\\' + AND "orders"."Note" NOT LIKE ? ESCAPE '\\' + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (sqlite) > limit-offset 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = ? + ORDER BY "id" DESC + LIMIT 2 + OFFSET 1", +} +`; + +exports[`core SQL (sqlite) > null-and-range 1`] = ` +{ + "parameters": [ + "org_1", + 5, + 20, + 3, + 3, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Note" IS NULL + AND "orders"."Amount" BETWEEN ? AND ? + AND "orders"."Id" NOT BETWEEN ? AND ? + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (sqlite) > nulls-and-literals 1`] = ` +{ + "parameters": [ + "org_1", + 2, + 4, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "orders"."Note" AS "note", + coalesce("orders"."Note", '-') AS "shown", + 'x''y' AS "label" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Id" IN (?, ?) + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (sqlite) > params 1`] = ` +{ + "parameters": [ + "org_1", + 7, + 19.5, + "2026-01-01 00:00:00.000", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "orders"."Created" AS "created" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Amount" >= ? + AND "orders"."Amount" < ? + AND "orders"."Created" >= ? + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (sqlite) > patterns-and-lists 1`] = ` +{ + "parameters": [ + "org_1", + "%e%", + "i%", + "pa%", + "open", + 3, + 2, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND ("orders"."Customer" LIKE ? ESCAPE '\\' AND "orders"."Customer" NOT LIKE ? ESCAPE '\\') + AND ("orders"."Status" LIKE ? ESCAPE '\\' OR "orders"."Status" IN (?)) + AND "orders"."Id" NOT IN (?) + AND NOT ("orders"."Id" = ?) + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (sqlite) > route-and-cross-tenant 1`] = ` +{ + "parameters": [], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + count(*) AS "orders" + FROM "orders"", +} +`; + +exports[`core SQL (sqlite) > select-columns 1`] = ` +{ + "parameters": [ + "org_1", + "paid", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "Id", + "orders"."Customer" AS "Customer" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Status" = ? + ORDER BY "Id" ASC", +} +`; + +exports[`core SQL (sqlite) > selected-param 1`] = ` +{ + "parameters": [ + 1, + "org_1", + 1, + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + ? AS "flagged" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Id" = ?", +} +`; + +exports[`core SQL (sqlite) > union 1`] = ` +{ + "parameters": [ + "org_1", + "paid", + "org_1", + "open", + "org_1", + "void", + ], + "sql": "SELECT * FROM ( +SELECT * FROM ( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Status" AS "status", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Status" = ? + GROUP BY 1 +) AS "__union_branch" +UNION ALL +SELECT * FROM ( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Status" AS "status", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Status" = ? + GROUP BY 1 +) AS "__union_branch" +UNION ALL +SELECT * FROM ( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Status" AS "status", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = ? + AND "orders"."Status" = ? + GROUP BY 1 +) AS "__union_branch" +) AS "__union" +ORDER BY "total" DESC +LIMIT 2 +OFFSET 1", +} +`; + +exports[`core SQL (sqlite) > union-cross-tenant 1`] = ` +{ + "parameters": [], + "sql": "SELECT * FROM ( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + count(*) AS "orders" + FROM "orders" +) AS "__union_branch" +UNION ALL +SELECT * FROM ( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + count(*) AS "orders" + FROM "orders" +) AS "__union_branch"", } `; diff --git a/tests/core-cases.ts b/tests/core-cases.ts index 3698368..cbe809a 100644 --- a/tests/core-cases.ts +++ b/tests/core-cases.ts @@ -8,10 +8,11 @@ import { DateTime } from "effect" import * as CH from "@maple-dev/effect-orm/clickhouse" import * as PG from "@maple-dev/effect-orm/postgres" +import * as SQLite from "@maple-dev/effect-orm/sqlite" -export type DialectName = "clickhouse" | "postgres" +export type DialectName = "clickhouse" | "postgres" | "sqlite" | "d1" /** ClickHouse runs twice: `join_use_nulls=0` fills a missing join row with defaults. */ -export type Target = "clickhouse" | "clickhouse-join-nulls" | "postgres" +export type Target = "clickhouse" | "clickhouse-join-nulls" | "postgres" | "sqlite" | "d1" type Col = CH.CHType type Orders = { @@ -116,6 +117,12 @@ export const fixtureSql = (dialect: DialectName, table: keyof typeof fixtureColu const structure = columns.map((column) => `${column.name} ${column.clickhouse}`).join(", ") return `SELECT * FROM values('${structure.replaceAll("'", "\\'")}', ${tuples})` } + if (dialect === "sqlite" || dialect === "d1") { + // SQLite names VALUES columns column1, column2, …, and has no column list on a derived table. + // The fixture's timestamps are already SQLite's text form, `YYYY-MM-DD HH:MM:SS.SSS`. + const rows = fixtureRows[table].map((row) => `(${row.map(sqlValue).join(", ")})`).join(", ") + return `SELECT ${columns.map((column, i) => `column${i + 1} AS "${column.name}"`).join(", ")} FROM (VALUES ${rows})` + } const casts = columns.map((column, i) => `CAST("c${i}" AS ${column.postgres}) AS "${column.name}"`).join(", ") const names = columns.map((_, i) => `"c${i}"`).join(", ") return `SELECT ${casts} FROM (VALUES ${tuples}) AS v(${names})` @@ -189,9 +196,55 @@ export const postgresContext: CoreContext = { compileUnion: (union, params = {}) => PG.compileUnionUnsafe(union, params), } +const sqliteTables = { + orders: SQLite.table("orders", { + external: true, + tenantColumn: "OrgId", + columns: { + OrgId: SQLite.text, + Id: SQLite.integer, + Customer: SQLite.text, + Amount: SQLite.integer, + Status: SQLite.text, + Note: SQLite.nullable(SQLite.text), + Created: SQLite.timestamp, + }, + }), + customers: SQLite.table("customers", { external: true, columns: { OrgId: SQLite.text, Name: SQLite.text, Tier: SQLite.text }, tenantColumn: "OrgId" }), +} +const sqliteFn: CoreContext["fn"] = { + count: () => SQLite.count(), + countIf: (condition) => SQLite.countIf(condition), + sum: (expr) => SQLite.sum(expr), + min: (expr) => SQLite.min(expr), + max: (expr) => SQLite.max(expr), + coalesce: (expr, fallback) => SQLite.coalesce(expr, fallback), +} + +export const sqliteContext: CoreContext = { + dialect: "sqlite", + ...sqliteTables, + from: withFixtures("sqlite"), + types: { text: SQLite.text, int: SQLite.integer }, + fn: sqliteFn, + compile: (query, params = {}) => SQLite.compileUnsafe(query, params), + compileUnion: (union, params = {}) => SQLite.compileUnionUnsafe(union, params), +} + +/** D1 runs SQLite's SQL, with its own limits (100 bindings, no interactive transactions). */ +export const d1Context: CoreContext = { + ...sqliteContext, + dialect: "d1", + from: withFixtures("d1"), + compile: (query, params = {}) => SQLite.compileUnsafe(query, params, { dialect: SQLite.d1Dialect }), + compileUnion: (union, params = {}) => SQLite.compileUnionUnsafe(union, params, { dialect: SQLite.d1Dialect }), +} + export const contexts: Record = { clickhouse: clickhouseContext, postgres: postgresContext, + sqlite: sqliteContext, + d1: d1Context, } // Cases @@ -247,7 +300,7 @@ export const coreCases: readonly CoreCase[] = [ }, { id: "patterns-and-lists", - covers: [...e("like", "notLike", "ilike", "in_", "notIn"), ...c("and", "or"), "function:not"], + covers: [...e("like", "notLike", "in_", "notIn"), ...c("and", "or"), "function:not"], build: (ctx) => ctx.compile( ctx @@ -256,7 +309,7 @@ export const coreCases: readonly CoreCase[] = [ .where(($) => [ $.OrgId.eq(CH.param.string("orgId")), $.Customer.like("%e%").and($.Customer.notLike("i%")), - $.Status.ilike("PA%").or($.Status.in_("open")), + $.Status.like("pa%").or($.Status.in_("open")), $.Id.notIn(3), CH.not($.Id.eq(2)), ]) @@ -266,6 +319,38 @@ export const coreCases: readonly CoreCase[] = [ // acme (1, 2) and globex (3) match the patterns; 3 and 2 are excluded by notIn and not. expected: [{ id: 1 }], }, + { + id: "ilike", + covers: e("ilike"), + build: (ctx) => + ctx.compile( + orgOrders(ctx).select(($) => ({ id: $.Id })).where(($) => [$.Status.ilike("PA%")]).orderBy(["id", "asc"]), + org, + ), + expected: [{ id: 1 }, { id: 2 }], + rejects: { sqlite: /ILIKE/, d1: /ILIKE/ }, + }, + { + id: "like-escape", + covers: e("like"), + // `\_` is a literal underscore on every dialect: SQLite needs `ESCAPE '\'` for it. + build: (ctx) => + ctx.compile( + ctx.from(ctx.orders).select(($) => ({ id: $.Id })).where(($) => [$.OrgId.like("org\\_1"), $.Note.notLike("it\\_s")]).orderBy(["id", "asc"]), + {}, + ), + expected: [{ id: 1 }, { id: 4 }], + metadata: { tenantScope: "cross-tenant" }, + }, + { + id: "like-case", + covers: e("like"), + build: (ctx) => + ctx.compile(orgOrders(ctx).select(($) => ({ id: $.Id })).where(($) => [$.Customer.like("ACME")]).orderBy(["id", "asc"]), org), + // SQLite's LIKE ignores ASCII case; every other dialect's matches it exactly. Documented in docs/sqlite.md. + expected: [], + expectedBy: { sqlite: [{ id: 1 }, { id: 2 }], d1: [{ id: 1 }, { id: 2 }] }, + }, { id: "arithmetic", covers: e("add", "sub", "mul", "mod", "div"), @@ -283,11 +368,13 @@ export const coreCases: readonly CoreCase[] = [ .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Id.eq(4)]), org, ), - // `/` is float division on ClickHouse and integer division on two Postgres integers. + // `/` is float division on ClickHouse and integer division on two Postgres or SQLite integers. expectedBy: { clickhouse: [{ added: 8, subtracted: 6, multiplied: 14, remainder: 1, divided: 3.5 }], "clickhouse-join-nulls": [{ added: 8, subtracted: 6, multiplied: 14, remainder: 1, divided: 3.5 }], postgres: [{ added: 8, subtracted: 6, multiplied: 14, remainder: 1, divided: 3 }], + sqlite: [{ added: 8, subtracted: 6, multiplied: 14, remainder: 1, divided: 3 }], + d1: [{ added: 8, subtracted: 6, multiplied: 14, remainder: 1, divided: 3 }], }, }, { @@ -598,7 +685,7 @@ export const coreCases: readonly CoreCase[] = [ id: "format", covers: q("format"), format: "JSON", - rejects: { postgres: /no FORMAT clause/ }, + rejects: { postgres: /no FORMAT clause/, sqlite: /no FORMAT clause/, d1: /no FORMAT clause/ }, build: (ctx) => ctx.compile( orgOrders(ctx) @@ -613,7 +700,7 @@ export const coreCases: readonly CoreCase[] = [ id: "union-format", covers: u("format"), format: "JSON", - rejects: { postgres: /no FORMAT clause/ }, + rejects: { postgres: /no FORMAT clause/, sqlite: /no FORMAT clause/, d1: /no FORMAT clause/ }, build: (ctx) => { const one = (id: number) => orgOrders(ctx) @@ -674,6 +761,7 @@ export const coreCases: readonly CoreCase[] = [ .orderBy(["customer", "asc"], ["id", "desc"]), org, ), + rejects: { sqlite: /DISTINCT ON/, d1: /DISTINCT ON/ }, expected: [ { customer: "acme", id: 2 }, { customer: "globex", id: 3 }, @@ -690,7 +778,7 @@ export const coreCases: readonly CoreCase[] = [ { id: "locking", covers: q("forUpdate", "forNoKeyUpdate", "forShare", "forKeyShare"), - rejects: { clickhouse: /no row locks/ }, + rejects: { clickhouse: /no row locks/, sqlite: /no row locks/, d1: /no row locks/ }, build: (ctx) => { // Each strength compiles; the one sent is FOR UPDATE SKIP LOCKED. const base = orgOrders(ctx).select(($) => ({ id: $.Id })).where(($) => [$.Id.eq(1)]) @@ -705,4 +793,6 @@ export const coreCases: readonly CoreCase[] = [ export const coreSkips: Record> = { clickhouse: {}, postgres: {}, + sqlite: {}, + d1: {}, } diff --git a/tests/core-sql.test.ts b/tests/core-sql.test.ts index 676abf1..e430d37 100644 --- a/tests/core-sql.test.ts +++ b/tests/core-sql.test.ts @@ -9,7 +9,7 @@ const elideFixtures = (dialect: DialectName, sql: string) => .replaceAll(fixtureSql(dialect, "orders"), "") .replaceAll(fixtureSql(dialect, "customers"), "") -for (const dialect of ["clickhouse", "postgres"] as const) { +for (const dialect of ["clickhouse", "postgres", "sqlite", "d1"] as const) { describe(`core SQL (${dialect})`, () => { for (const fixture of coreCases) { it(fixture.id, () => { diff --git a/tests/core.sqlite.test.ts b/tests/core.sqlite.test.ts new file mode 100644 index 0000000..04d6852 --- /dev/null +++ b/tests/core.sqlite.test.ts @@ -0,0 +1,26 @@ +// The core builder suite on SQLite (node:sqlite, in-process) and D1 (a local +// workerd binding through Miniflare), so both run on every `vitest run`. +import { afterAll, describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { contexts, coreCases, coreSkips, expectedFor } from "./core-cases" +import { d1Database, sqliteDatabase } from "./sqlite-support" + +const databases = { sqlite: sqliteDatabase(), d1: d1Database() } +afterAll(() => Promise.all(Object.values(databases).map((db) => db.close()))) + +for (const dialect of ["sqlite", "d1"] as const) { + describe(`core builder suite on ${dialect}`, () => { + for (const fixture of coreCases) { + if (fixture.rejects?.[dialect] || Object.hasOwn(coreSkips[dialect], fixture.id)) continue + it.effect(fixture.id, () => + Effect.gen(function* () { + const compiled = fixture.build(contexts[dialect]) + if (fixture.metadata) expect(compiled).toMatchObject(fixture.metadata) + const wire = yield* Effect.promise(() => databases[dialect].query(compiled.sql, compiled.parameters)) + const rows = yield* compiled.decodeRows(wire) + expect(rows, compiled.sql).toEqual(expectedFor(fixture, dialect)) + }), + ) + } + }) +} diff --git a/tests/fuzz/dialects/index.ts b/tests/fuzz/dialects/index.ts index 166513a..b036402 100644 --- a/tests/fuzz/dialects/index.ts +++ b/tests/fuzz/dialects/index.ts @@ -1,9 +1,10 @@ // The dialect registry. Every fuzz suite iterates it; add a dialect here. import { clickhouse } from "./clickhouse" import { postgres } from "./postgres" +import { d1, sqlite } from "./sqlite" import type { FuzzDialect, FuzzTarget } from "./types" -export const dialects: ReadonlyArray = [clickhouse, postgres] +export const dialects: ReadonlyArray = [clickhouse, postgres, sqlite, d1] /** Every target, with the dialect whose SQL it runs. */ export const targets: ReadonlyArray<{ readonly dialect: FuzzDialect; readonly target: FuzzTarget }> = dialects.flatMap((dialect) => diff --git a/tests/fuzz/dialects/sqlite.ts b/tests/fuzz/dialects/sqlite.ts new file mode 100644 index 0000000..459aa62 --- /dev/null +++ b/tests/fuzz/dialects/sqlite.ts @@ -0,0 +1,150 @@ +// SQLite (node:sqlite, in-process) and D1 (a local workerd binding through +// Miniflare). D1 executes SQLite's SQL, so both share semantics and literals; +// they differ in the dialect they compile with and the database they run on. +import fc from "fast-check" +import { DateTime, Schema } from "effect" +import * as SQLite from "@maple-dev/effect-orm/sqlite" +import { d1Context, sqliteContext } from "../../core-cases" +import { d1Database, sqliteDatabase } from "../../sqlite-support" +import { finite, nastyString, nullOr, utc, type LiteralType } from "../values" +import { runWith, type DialectModule, type FuzzDialect, type Semantics, type StringRules, type WriteSupport } from "./types" + +const semantics: Semantics = { + missingJoinColumn: "null", + integerDivision: "truncate", + modulo: "integer", + // NULL is SQLite's smallest value. + nulls: { asc: "first", desc: "last" }, + emptyAggregate: "null", + // Documented SQLite behaviour (docs/sqlite.md); the builder writes LIKE as is. + likeIgnoresCase: true, +} + +// Literals + +// SQLite stores U+0000, but its drivers end text there, so the dialect refuses it. +const strings: StringRules = { nul: false, leadingBom: true } +// The years SQLite's text timestamps and date functions cover. +const MIN_TS = Date.UTC(0, 0, 1) +const MAX_TS = Date.UTC(9999, 11, 31, 23, 59, 59, 999) + +const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("") + +/** + * A double as SQLite SQL that is exact without parsing a decimal: SQLite reads + * some decimals (`1.23456789e-95`, as a literal or through json_extract) as a + * neighbouring double. The integer mantissa is exact, and multiplying or + * dividing by a power of two below 2^62 is exact while the result is a double. + */ +const exactReal = (value: number): string => { + if (value === 0) return `SELECT 0.0 AS "v"` + const view = new DataView(new ArrayBuffer(8)) + view.setFloat64(0, Math.abs(value)) + const bits = view.getBigUint64(0) + const biased = Number(bits >> 52n) + const fraction = bits & ((1n << 52n) - 1n) + // value = mantissa * 2^exponent + const mantissa = biased === 0 ? fraction : fraction | (1n << 52n) + let exponent = (biased === 0 ? 1 : biased) - 1075 + let sql = `CAST(${value < 0 ? "-" : ""}${mantissa} AS REAL)` + while (exponent !== 0) { + const step = Math.min(Math.abs(exponent), 62) + sql += `${exponent > 0 ? " * " : " / "}${2n ** BigInt(step)}` + exponent -= Math.sign(exponent) * step + } + return `SELECT ${sql} AS "v"` +} + +/** The reference row, read by SQLite's JSON functions rather than written by the builder. */ +const jsonSource = (read: (value: string) => string) => (json: string) => + `SELECT ${read(`json_extract('${json.replaceAll("'", "''")}', '$.v')`)} AS "v"` + +const literals = (dialect: string): ReadonlyArray => { + const nasty = nastyString(strings) + const literal = ( + id: string, + type: SQLite.SqliteType, + value: fc.Arbitrary, + options: { wire?: (value: unknown) => unknown; read?: (value: string) => string; source?: (value: unknown) => string; comparable?: boolean } = {}, + ): LiteralType => ({ + id: `${dialect}:${id}`, + type, + value, + wire: options.wire ?? ((v) => v), + source: (json, v) => (options.source ? options.source(v) : jsonSource(options.read ?? ((x) => x))(json)), + comparable: options.comparable ?? true, + }) + return [ + literal("text", SQLite.text, nasty), + literal("integer", SQLite.integer, fc.maxSafeInteger()), + literal("real", SQLite.real, finite, { source: (v) => exactReal(v as number) }), + literal("boolean", SQLite.boolean, fc.boolean()), + // The reference row is SQLite's own text: datetime() writes the date and time, so a builder + // value compares equal to what the database itself writes, not just to itself. + literal("timestamp", SQLite.timestamp, fc.integer({ min: MIN_TS, max: MAX_TS }).map(utc), { + source: (value) => { + const ms = DateTime.toEpochMillis(value as DateTime.Utc) + return `SELECT datetime(${Math.floor(ms / 1000)}, 'unixepoch') || '.' || printf('%03d', ${((ms % 1000) + 1000) % 1000}) AS "v"` + }, + }), + literal("blob", SQLite.blob, fc.uint8Array({ maxLength: 8 }), { wire: (value) => hex(value as Uint8Array), read: (v) => `unhex(${v})` }), + literal("json", SQLite.json(Schema.Struct({ message: Schema.String })), fc.record({ message: nasty }), { + wire: (value) => JSON.stringify(value), + comparable: false, + }), + literal("nullable(text)", SQLite.nullable(SQLite.text), nullOr(nasty)), + literal("nullable(integer)", SQLite.nullable(SQLite.integer), nullOr(fc.maxSafeInteger())), + ] +} + +const sqliteDb = sqliteDatabase() +const d1Db = d1Database() + +/** Everything but per-cell DEFAULT, which the dialect refuses (SQLite has none in VALUES). */ +const writes = (exec: (sql: string) => Promise): WriteSupport => ({ + insertSelect: true, + update: true, + delete: true, + onConflict: true, + returning: true, + defaultCells: false, + scratch: { + table: (name) => + SQLite.table(name, { Id: SQLite.integer, OrgId: SQLite.text, Name: SQLite.text, Note: SQLite.nullable(SQLite.text), Qty: SQLite.integer }, { defaults: ["Qty"] }), + create: (name) => [`CREATE TABLE "${name}" ("Id" integer PRIMARY KEY, "OrgId" text NOT NULL, "Name" text NOT NULL, "Note" text, "Qty" integer NOT NULL DEFAULT 5)`], + drop: (name) => `DROP TABLE IF EXISTS "${name}"`, + exec: async (sql) => { + await exec(sql) + }, + }, +}) + +export const sqlite: FuzzDialect = { + name: "sqlite", + module: SQLite as never, + context: sqliteContext, + strings, + ilike: false, + literals: literals("sqlite"), + writes: writes((sql) => sqliteDb.query(sql, [])), + targets: [{ name: "sqlite", available: true, semantics, run: (compiled) => runWith(compiled, sqliteDb.query) }], + close: sqliteDb.close, +} + +const d1Module: DialectModule = { + ...(SQLite as never as DialectModule), + compile: (query, params) => SQLite.compile(query, params, { dialect: SQLite.d1Dialect }), + compileUnion: (union, params) => SQLite.compileUnion(union, params, { dialect: SQLite.d1Dialect }), +} + +export const d1: FuzzDialect = { + name: "d1", + module: d1Module, + context: d1Context, + strings, + ilike: false, + literals: literals("d1"), + writes: writes((sql) => d1Db.query(sql, [])), + targets: [{ name: "d1", available: true, semantics, run: (compiled) => runWith(compiled, d1Db.query) }], + close: d1Db.close, +} diff --git a/tests/fuzz/regressions.ts b/tests/fuzz/regressions.ts index 5f0b92d..7d8823e 100644 --- a/tests/fuzz/regressions.ts +++ b/tests/fuzz/regressions.ts @@ -51,12 +51,21 @@ export const pinned: Record> = { { k: "or", style: "method", cs: [{ k: "and", style: "method", cs: [{ k: "const", v: true }, { k: "tenant", side: "orders" }] }, { k: "tenant", side: "orders" }] }, ], }, + // sqlite-like-no-default-escape: `\_` was a literal backslash on SQLite. + { + orgId: "org_2", + join: "left", + where: [{ k: "tenant", side: "orders" }], + shape: { k: "agg", keys: [], aggs: [["a0", { k: "countIf", c: { k: "like", op: "like", l: { k: "jcol", col: "OrgId" }, pattern: "org\\__" } }]], having: [], orderBy: [] }, + wrap: false, + crossTenant: false, + }, // `OrgId IN ('org_2')` pins the query to org_2, not to the orgId param: single-tenant, and right to read org_2. { ...rows({ k: "rows", select: [["id", { k: "col", col: "Id" }]], distinct: false, orderBy: [] }), where: [{ k: "in", neg: false, e: { k: "col", col: "OrgId" }, values: ["org_2"] }], }, - // pg-fraction-bound-to-integer. + // pg-fraction-bound-to-integer, and sqlite-integer-refuses-fraction. { ...rows({ k: "rows", select: [["id", { k: "col", col: "Id" }]], distinct: false, orderBy: [] }), where: [{ k: "cmp", op: "gt", l: { k: "col", col: "Amount" }, r: { k: "value", v: 9.75 } }], @@ -68,6 +77,8 @@ export const pinned: Record> = { "clickhouse:Array(Nullable(Int64)) compare": [[0, 4294967296]], "clickhouse:String": ["__PARAM_string_x__", "\\'", "\0"], "postgres:boolean select": [false], + // SQLite reads these decimals as a neighbouring double, as a literal or through json_extract. + ":real ": [1.23456789e-95, 5.464465248610941e-277, -1.415281131826549e-267, 0, 5e-324], "postgres:text[] select": [[]], "postgres:timestamptz": [DateTime.makeUnsafe(Date.UTC(100, 0, 1))], @@ -94,6 +105,8 @@ export const pinned: Record> = { { dialect: "clickhouse", column: "OrgId", site: "add", value: undefined }, { dialect: "clickhouse", column: "OrgId", site: "add", value: new Date(Number.NaN) }, ], + // A Date before year 0 reached a timestamp encoder that threw instead of failing. + "hostile values are refused, never thrown (sqlite)": [{ dialect: "sqlite", column: "Created", site: "gt", value: new Date("-000001-12-31T23:59:59.999Z") }], "hostile values are refused, never thrown (postgres)": [ { dialect: "postgres", column: "OrgId", site: "param.dateTime", value: "" }, { dialect: "postgres", column: "Created", site: "param.of", value: "2026-13-45 99:99:99" }, diff --git a/tests/package-consumer-declarations.mts b/tests/package-consumer-declarations.mts index 616c47b..e4a191d 100644 --- a/tests/package-consumer-declarations.mts +++ b/tests/package-consumer-declarations.mts @@ -47,3 +47,8 @@ export const Recent = CH.table("recent", { external: true, columns: { name: CH.s export const withRecent = CH.from(Recent).withCTE("recent", eventsByApp).select(($) => ({ name: $.name })) export const fromSub = CH.fromQuery(eventsByApp, "sub").select(($) => ({ name: $.name })) export const both = CH.unionAll(eventsByApp, CH.from(Events).select(($) => ({ name: $.name }))) + +import * as SQLite from "@maple-dev/effect-orm/sqlite" +export const SqliteItems = SQLite.table("sqlite_items", { id: SQLite.integer, label: SQLite.text }) +export const sqliteById = SQLite.from(SqliteItems).select().where(($) => [$.id.eq(SQLite.param.int("id"))]) +export const sqliteCompile = () => SQLite.compile(sqliteById, { id: 1 }) diff --git a/tests/package-consumer.mts b/tests/package-consumer.mts index 5360120..6c62a7b 100644 --- a/tests/package-consumer.mts +++ b/tests/package-consumer.mts @@ -91,3 +91,14 @@ assert.equal(typeof runCli, "function") assert.equal(typeof Db.fromSqlClient, "function") assert.equal(Db.isContention(new Db.DatabaseError({ message: "x", sql: "", reason: "SerializationError", cause: undefined })), true) + +// SQLite works from the tarball without installing any SQLite driver. +const SQLite = await import("@maple-dev/effect-orm/sqlite") +const sqliteRows = SQLite.table("consumer_rows", { id: SQLite.integer, note: SQLite.text }) +const sqliteQuery = SQLite.from(sqliteRows).select().where(($) => [$.id.eq(SQLite.param.int("id"))]) +const sqliteCompiled = await Effect.runPromise(SQLite.compile(sqliteQuery, { id: 1 })) +assert.deepEqual(sqliteCompiled.parameters, [1]) +assert.equal(sqliteCompiled.dialect, "sqlite") +const sqliteDecoded: ReadonlyArray<{ readonly id: number; readonly note: string }> = await Effect.runPromise(sqliteCompiled.decodeRows([{ id: 1, note: "sqlite" }])) +assert.deepEqual(sqliteDecoded, [{ id: 1, note: "sqlite" }]) +assert.equal(SQLite.compileUnsafe(sqliteQuery, { id: 1 }, { dialect: SQLite.d1Dialect }).dialect, "d1") diff --git a/tests/sqlite-support.ts b/tests/sqlite-support.ts new file mode 100644 index 0000000..3e74673 --- /dev/null +++ b/tests/sqlite-support.ts @@ -0,0 +1,62 @@ +// Raw SQLite and D1 connections for the core suite and the fuzzer: no Effect +// SqlClient in between, so what the database returns is what the query decodes. +import { DatabaseSync } from "node:sqlite" +import { Miniflare } from "miniflare" + +/** + * Retry what failed to reach workerd ("fetch failed": a dropped local socket + * under a soak's load), as the ClickHouse helper retries a dropped request. A + * database error is not retried: it reached the database and is the finding. + */ +export const retryTransport = async (run: () => Promise): Promise => { + for (let attempt = 1; ; attempt++) { + try { + return await run() + } catch (error) { + const message = error instanceof Error ? `${error.message} ${String((error as { cause?: unknown }).cause ?? "")}` : String(error) + if (attempt >= 5 || !/fetch failed|ECONNRESET|ECONNREFUSED|socket hang up/.test(message)) throw error + await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) + } + } +} + +export type RawQuery = (sql: string, parameters: ReadonlyArray) => Promise>> + +/** An in-memory SQLite (Node's built-in driver, the one `@effect/sql-sqlite-node` uses). */ +export const sqliteDatabase = (): { readonly query: RawQuery; readonly close: () => Promise } => { + const db = new DatabaseSync(":memory:") + return { + // node:sqlite rows have a null prototype; spread them so `toEqual` compares plain objects. + query: async (sql, parameters) => db.prepare(sql).all(...(parameters as Array)).map((row) => ({ ...row })), + close: async () => db.close(), + } +} + +/** A local D1 database in workerd, through the Workers binding an application gets. */ +export const d1Database = (): { readonly query: RawQuery; readonly close: () => Promise } => { + let started: Promise<{ readonly mf: Miniflare; readonly db: D1DatabaseLike }> | undefined + const start = () => + (started ??= (async () => { + const mf = new Miniflare({ + modules: true, + script: "export default { fetch() { return new Response('fuzz') } }", + compatibilityDate: "2026-07-01", + d1Databases: ["DB"], + }) + return { mf, db: (await mf.getD1Database("DB")) as unknown as D1DatabaseLike } + })()) + return { + query: async (sql, parameters) => { + const { db } = await start() + return retryTransport(async () => (await db.prepare(sql).bind(...parameters).all()).results) + }, + close: async () => { + if (started !== undefined) await (await started).mf.dispose() + started = undefined + }, + } +} + +interface D1DatabaseLike { + prepare(sql: string): { bind(...values: ReadonlyArray): { all(): Promise<{ results: Array> }> } } +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 7e1f75c..0815e4c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ expr: "./src/expr.ts", sql: "./src/sql/index.ts", postgres: "./src/postgres.ts", + sqlite: "./src/sqlite.ts", schema: "./src/schema.ts", tinybird: "./src/tinybird.ts", migrate: "./src/migrate.ts",