diff --git a/.changeset/mysql-dialect.md b/.changeset/mysql-dialect.md new file mode 100644 index 0000000..3233e1a --- /dev/null +++ b/.changeset/mysql-dialect.md @@ -0,0 +1,14 @@ +--- +"@maple-dev/effect-orm": minor +--- + +A new `/mysql` entry compiles the shared query builder for MySQL 8.0.17+ (`mysqlDialect`) and MariaDB 10.6+ (`mariadbDialect`), for `@effect/sql-mysql2` (#53). It writes backtick identifiers and positional `?` bindings (up to 65,535 per statement), escapes `\` in string literals, and writes `OFFSET` without `LIMIT` with MySQL's largest row count. It has MySQL column types and codecs for what mysql2 sends (`text`, `longtext`, `varchar(n)`, the integer types, `bigint` within 2^53, `double`, `decimal(p, s)` as exact text, `boolean`, `datetime` as `DATETIME(3)` in UTC, `date`, `blob`, `varbinary(n)`, `json(schema)`) and MySQL functions (`count`, `countDistinct`, `countIf`, `sum`, `avg`, `min`, `max`, `lower`, `upper`, `length`, `coalesce`), refused for another dialect in the type. `forUpdate` and `forShare` compile for MySQL, with MariaDB's `LOCK IN SHARE MODE`; the type refuses `forNoKeyUpdate` and `forKeyShare` there. RETURNING, ON CONFLICT, DISTINCT ON and ILIKE are refused at compile time. A transaction's isolation level and access mode go in the statement that starts it (`SET TRANSACTION …; START TRANSACTION READ ONLY`). `contains`, `startsWith` and `endsWith` compare bytes, so they stay case-sensitive under a case-insensitive collation. A JSON column is compared with `CAST(? AS JSON)` on MySQL. A selected small integer or numeric param is cast, as MySQL 8.4 can read it as an ORDER BY position inside a derived table. The fuzzer runs every property on MySQL 8.4 and MariaDB 11.4, over prepared statements and the text protocol Cloudflare Hyperdrive uses; see docs/mysql.md for what MySQL does differently. + +Breaking, for a custom `Dialect`: + +- `clauses.offsetRequiresLimit: true` is now `clauses.limitForOffset: "-1"`: the LIMIT to write, which MySQL needs to be its largest row count. +- `clauses.locking` is a `LockingSupport` (`{ strengths, of, shareSyntax? }`), not `true`: Postgres's is every strength with `of`. +- `likeIgnoresCase: true` is now `likeCase: "ascii-insensitive"`, beside `"sensitive"` (the default) and MySQL's `"collation"`. +- `integerComparesFraction: true` is now `fractionComparableIntegers`, the integer column types that compare with a fraction (SQLite's is `["integer"]`). + +New optional `Dialect` fields: `castType` (the CAST target for a selected param's column type), `concat`, `comparisonCast` and `clauses.selectedNumberCast`. `MysqlOnly` and `LockingSupport` are exported from every dialect entry. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07e1ad0..468f475 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,49 @@ jobs: EFFECT_ORM_CLICKHOUSE_USER: maple EFFECT_ORM_CLICKHOUSE_PASSWORD: maple run: bun run test:release + + mysql: + name: Builder / MySQL 8.4, MariaDB 11.4 + runs-on: ubuntu-latest + timeout-minutes: 15 + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: maple + MYSQL_DATABASE: maple + MYSQL_USER: maple + MYSQL_PASSWORD: maple + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -umaple -pmaple" + --health-interval 5s + --health-timeout 5s + --health-retries 30 + mariadb: + image: mariadb:11.4 + env: + MARIADB_ROOT_PASSWORD: maple + MARIADB_DATABASE: maple + MARIADB_USER: maple + MARIADB_PASSWORD: maple + ports: + - 3307:3306 + options: >- + --health-cmd "healthcheck.sh --connect --innodb_initialized" + --health-interval 5s + --health-timeout 5s + --health-retries 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - run: bun install --frozen-lockfile + - run: bun run build + - name: Live MySQL and MariaDB tests and fuzz properties + env: + EFFECT_ORM_MYSQL_URL: mysql://maple:maple@127.0.0.1:3306/maple + EFFECT_ORM_MARIADB_URL: mysql://maple:maple@127.0.0.1:3307/maple + run: bunx vitest run src/mysql tests/core.mysql.test.ts tests/fuzz -t "mysql|mariadb" diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index ff68dc5..76f3be6 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -53,3 +53,50 @@ jobs: FUZZ_SCALE: ${{ inputs.scale || '20' }} FUZZ_VERBOSE: "1" run: bun run fuzz + + soak-mysql: + name: Soak / MySQL 8.4, MariaDB 11.4 + runs-on: ubuntu-latest + timeout-minutes: 60 + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: maple + MYSQL_DATABASE: maple + MYSQL_USER: maple + MYSQL_PASSWORD: maple + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -umaple -pmaple" + --health-interval 5s + --health-timeout 5s + --health-retries 30 + mariadb: + image: mariadb:11.4 + env: + MARIADB_ROOT_PASSWORD: maple + MARIADB_DATABASE: maple + MARIADB_USER: maple + MARIADB_PASSWORD: maple + ports: + - 3307:3306 + options: >- + --health-cmd "healthcheck.sh --connect --innodb_initialized" + --health-interval 5s + --health-timeout 5s + --health-retries 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - run: bun install --frozen-lockfile + - name: Fuzz the MySQL and MariaDB targets + env: + EFFECT_ORM_MYSQL_URL: mysql://maple:maple@127.0.0.1:3306/maple + EFFECT_ORM_MARIADB_URL: mysql://maple:maple@127.0.0.1:3307/maple + FUZZ_SCALE: ${{ inputs.scale || '20' }} + FUZZ_VERBOSE: "1" + run: bun run fuzz -t "mysql|mariadb" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88d901c..9e5619a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,34 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 20 + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: maple + MYSQL_DATABASE: maple + MYSQL_USER: maple + MYSQL_PASSWORD: maple + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -umaple -pmaple" + --health-interval 5s + --health-timeout 5s + --health-retries 30 + mariadb: + image: mariadb:11.4 + env: + MARIADB_ROOT_PASSWORD: maple + MARIADB_DATABASE: maple + MARIADB_USER: maple + MARIADB_PASSWORD: maple + ports: + - 3307:3306 + options: >- + --health-cmd "healthcheck.sh --connect --innodb_initialized" + --health-interval 5s + --health-timeout 5s + --health-retries 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 @@ -52,3 +80,5 @@ jobs: EFFECT_ORM_CLICKHOUSE_URL: http://127.0.0.1:8123 EFFECT_ORM_CLICKHOUSE_USER: maple EFFECT_ORM_CLICKHOUSE_PASSWORD: maple + EFFECT_ORM_MYSQL_URL: mysql://maple:maple@127.0.0.1:3306/maple + EFFECT_ORM_MARIADB_URL: mysql://maple:maple@127.0.0.1:3307/maple diff --git a/README.md b/README.md index 0ff2228..9611c30 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,16 @@ > **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, Postgres and SQLite queries, result decoding, and reproducible benchmarks for Effect and TypeScript. +Type-safe ClickHouse, Postgres, SQLite and MySQL 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), which also writes SQLite table DDL. `effect-orm generate` writes SQLite migrations; effect-orm does not apply them yet. +MySQL 8 and MariaDB 10.6+ use it through the [`/mysql` entry](./docs/mysql.md), with +`@effect/sql-mysql2`, fuzzed on MySQL 8.4 and MariaDB 11.4. + [Read the documentation](https://effect-clickhouse.maple.dev) · [Getting started](./docs/getting-started.md) · [Recipes](./docs/recipes.md) · [Coming from Drizzle or Prisma](./docs/coming-from-drizzle-prisma.md) diff --git a/bun.lock b/bun.lock index 0693db5..59576f9 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@effect/platform-bun": "4.0.6", "@effect/sql-clickhouse": "4.0.6", "@effect/sql-d1": "4.0.6", + "@effect/sql-mysql2": "4.0.6", "@effect/sql-pglite": "4.0.6", "@effect/sql-sqlite-node": "4.0.6", "@effect/vitest": "4.0.6", @@ -20,6 +21,7 @@ "expect-type": "^1.3.0", "fast-check": "^4.9.0", "miniflare": "4.20260730.0", + "mysql2": "3.24.6", "tsdown": "^0.22.14", "typescript": "^7.0.2", "vitest": "^4.1.9", @@ -97,6 +99,8 @@ "@effect/sql-d1": ["@effect/sql-d1@4.0.6", "", { "dependencies": { "@cloudflare/workers-types": "^5.20261016.1" }, "peerDependencies": { "effect": "^4.0.6" } }, "sha512-W149Svo5pR+7eoKtTmMX3aFCJKQmvMdypKhMlFekKvy2UjY6h5Af8d+9YeBeOXkYtDQjxNs4l6iv7EqbF6XBbg=="], + "@effect/sql-mysql2": ["@effect/sql-mysql2@4.0.6", "", { "dependencies": { "mysql2": "^3.24.6" }, "peerDependencies": { "effect": "^4.0.6" } }, "sha512-eHB9zxelTGr5JWn0vbHyjN4segLOs0VeW65Ul2NisUfuEktiFhpJxHJW4uE7dXrAu7Xa359mA3fTajCCWXSFig=="], + "@effect/sql-pglite": ["@effect/sql-pglite@4.0.6", "", { "dependencies": { "@electric-sql/pglite": "^0.5.10" }, "peerDependencies": { "effect": "^4.0.6" } }, "sha512-pAKT7ZyY4Y6Nud3MlHXyFuLlH4pFUYpUeywtGWIcHOJtuA/8A8UvXijxrCkZ1dmLYThOqr7tWx5/mqN+lb8/EA=="], "@effect/sql-sqlite-node": ["@effect/sql-sqlite-node@4.0.6", "", { "peerDependencies": { "effect": "^4.0.6" } }, "sha512-mYWr1Ppoz3/XrY+ZBnYfdvZvdcAYJTO6VcWoNetBX5n2c0VKHqg9S8WO6EpgtV5WiR8/yHHmu3qkfAGHJmalcA=="], @@ -351,6 +355,8 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], @@ -389,16 +395,22 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + "get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], "hookable": ["hookable@6.1.2", "", {}, "sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg=="], "human-id": ["human-id@4.2.2", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-Z+8unD6em/MJwyrkWQ2M3wygRGI1+MztbutiNwk1DCuEPXOZRWGjtcMPZYP1exY9iNZMhd7ZOSMQSo9WYtiQRw=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], "import-without-cache": ["import-without-cache@0.4.1", "", {}, "sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A=="], + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], @@ -431,10 +443,18 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru.min": ["lru.min@1.1.5", "", {}, "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA=="], + "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=="], + "mysql2": ["mysql2@3.24.6", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "generate-function": "^2.3.1", "iconv-lite": "^0.7.3", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.5.1" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ=="], + + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + "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=="], @@ -461,6 +481,8 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.27.14", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.4", "yuku-ast": "^0.8.0", "yuku-codegen": "^0.8.0", "yuku-parser": "^0.8.0" }, "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@typescript/native-preview", "@volar/typescript", "typescript", "vue-tsc"] }, "sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "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=="], @@ -473,6 +495,8 @@ "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + "sql-escaper": ["sql-escaper@1.5.2", "", {}, "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], diff --git a/docs/README.md b/docs/README.md index 39539f4..86ec2bc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -108,6 +108,10 @@ The optional `@maple-dev/effect-orm/benchmark` entry point and bundled See [Benchmarking](./benchmarking.md) and the [agent playbook](./benchmark-agent.md). The SQL builders remain driver-free. +## MySQL and MariaDB + +[MySQL and MariaDB](./mysql.md) covers `@maple-dev/effect-orm/mysql`: its codecs and functions, `@effect/sql-mysql2` wiring (including Cloudflare Hyperdrive's text protocol), collations and what MySQL does differently. Tables are query schemas only for now. + ## SQLite and D1 [SQLite and D1](./sqlite.md) covers `@maple-dev/effect-orm/sqlite`, its codecs and functions, table DDL, Effect SQL drivers, tenant checks and transaction limits. `effect-orm generate` writes SQLite migrations; effect-orm does not apply them yet. diff --git a/docs/coming-from-drizzle-prisma.md b/docs/coming-from-drizzle-prisma.md index f8868de..761b88c 100644 --- a/docs/coming-from-drizzle-prisma.md +++ b/docs/coming-from-drizzle-prisma.md @@ -30,7 +30,7 @@ Two things change for every operation: | `$transaction(async (tx) => ...)` | `db.transaction(async (tx) => ...)` | `effect.pipe(Db.transaction())`; no `tx` handle, nesting is a savepoint ([Transactions](./database.md#transactions)). D1 has no interactive transactions: there `Db.transaction` fails with `TransactionUnsupported` | | `$transaction([a, b])` | `db.batch([a, b])` | Run them inside one `Db.transaction`. There is no single-round-trip batch, and no wrapper for D1's `batch` yet ([D1](./sqlite.md#execute-on-d1)) | | `$queryRaw`, `$executeRaw` | `` db.execute(sql`...`) `` | `` Db.query(Db.sql`...`, schema) ``, `` Db.execute(Db.sql`...`) ``; every `${value}` is bound | -| `SELECT ... FOR UPDATE` by `$queryRaw` | `.for("update")` | `.forUpdate()`, `.forNoKeyUpdate()`, `.forShare()` on Postgres ([Building queries](./queries.md)); SQLite has no row locks | +| `SELECT ... FOR UPDATE` by `$queryRaw` | `.for("update")` | `.forUpdate()`, `.forNoKeyUpdate()`, `.forShare()` on Postgres, `.forUpdate()` and `.forShare()` on MySQL ([Building queries](./queries.md)); SQLite has no row locks | | `P2002` (unique violation) | `DrizzleQueryError` and a `cause.code` check | `Db.refineConstraints` fails with a tagged `UniqueViolation` (and `ForeignKeyViolation`, `NotNullViolation`, `CheckViolation`) to catch by tag ([Constraint violations](./database.md#constraint-violations)) | | `cursor`, `skip: 1`, `take` | `where(gt(t.id, cursor))` | `Db.paginate(query, { orderBy, limit, after })`: a keyset page and the cursor for the next ([Pagination](./database.md#pagination)) | | `contains`, `mode: "insensitive"` | `ilike(t.c, "%x%")` | `$.c.contains("x")`, `startsWith`, `endsWith`, with `{ insensitive: true }`; user input is escaped, on every dialect ([Text search](./expressions.md#text-search)) | diff --git a/docs/database.md b/docs/database.md index 449b8e0..cffef23 100644 --- a/docs/database.md +++ b/docs/database.md @@ -80,7 +80,7 @@ Calling `withdraw(1, 30)` outside `transfer` does not compile: `requireTransacti | Export | What it is | | --- | --- | -| `fromSqlClient(sql, options)` | A `DatabaseApi` over a client. `options.dialect` is required, and types it: with `postgresDialect` it is a `DatabaseApi<"postgres">`, whose `run` refuses ClickHouse and SQLite functions in the type (`d1Dialect` and `sqliteDialect` give a `DatabaseApi<"sqlite">`, which also refuses row locks) ([Checked in the type](./params-and-compilation.md#checked-in-the-type)); `options.requireTenantScope` refuses unscoped queries ([Tenant scoping](./tenant-scoping.md#enforcing-it)) | +| `fromSqlClient(sql, options)` | A `DatabaseApi` over a client. `options.dialect` is required, and types it: with `postgresDialect` it is a `DatabaseApi<"postgres">`, whose `run` refuses ClickHouse and SQLite functions in the type (`d1Dialect` and `sqliteDialect` give a `DatabaseApi<"sqlite">`, which also refuses row locks; `mysqlDialect` and `mariadbDialect` a `DatabaseApi<"mysql">`) ([Checked in the type](./params-and-compilation.md#checked-in-the-type)); `options.requireTenantScope` refuses unscoped queries ([Tenant scoping](./tenant-scoping.md#enforcing-it)) | | `layerSqlClient(options)` | A `Database` layer over the `SqlClient` in context | | `Database` | The service | | `run(query, params?)` | Compile a query for the database's dialect, run it, decode its rows. A write first runs its table's [client-side defaults](./inserts.md#client-side-defaults) and requires what they need | diff --git a/docs/mysql.md b/docs/mysql.md new file mode 100644 index 0000000..2dc6275 --- /dev/null +++ b/docs/mysql.md @@ -0,0 +1,157 @@ +# MySQL and MariaDB + +Import `@maple-dev/effect-orm/mysql` for the shared query builder, MySQL codecs and functions. +`mysqlDialect` targets MySQL 8.0.17 and later (it casts to `DOUBLE`); `mariadbDialect` targets MariaDB 10.6 and later. +Both compile to backtick identifiers and positional `?` bindings, and the fuzzer runs every +property on MySQL 8.4 and MariaDB 11.4, over prepared statements and over the text protocol. +The package opens no connection: use the Effect `SqlClient` you already provide. + +## Define and compile a query + +```ts title="mysql-quickstart.ts" +import * as MySQL from "@maple-dev/effect-orm/mysql" + +export const Tours = MySQL.table("tours", { + id: MySQL.bigint, + org_id: MySQL.varchar(36), + title: MySQL.text, + starts_at: MySQL.datetime, +}, { tenantColumn: "org_id" }) + +export const upcoming = MySQL.from(Tours) + .select("id", "title", "starts_at") + .where(($) => [$.org_id.eq(MySQL.param.string("orgId")), $.starts_at.gt(MySQL.param.dateTime("after"))]) + .orderBy(["starts_at", "asc"]) + .limit(20) + +export const compiled = MySQL.compileUnsafe(upcoming, { orgId: "org_1", after: new Date("2026-01-01T00:00:00Z") }) +// compiled.sql uses `tours`.`org_id` = ?; compiled.tenantScope is "single-tenant". + +// MariaDB: pass its dialect, or provide it as CurrentDialect. +export const forMariadb = MySQL.compileUnsafe(upcoming, { orgId: "org_1", after: new Date() }, { dialect: MySQL.mariadbDialect }) +``` + +`table(name, columns, options?)` is a query schema only: it carries no DDL yet. + +## Execute through `@effect/sql-mysql2` + +Install `@effect/sql-mysql2` and give the pool `timezone: "Z"`, so a `DATETIME` is read and +written in UTC. With a `url`, put it in the URL (`?timezone=Z`), which mysql2 reads as an option. + +```ts title="mysql-client.ts" +import * as MysqlClient from "@effect/sql-mysql2/MysqlClient" +import { Layer, Redacted } from "effect" +import * as Db from "@maple-dev/effect-orm/database" +import { mysqlDialect } from "@maple-dev/effect-orm/mysql" + +export const MysqlLive = Db.layerSqlClient({ + dialect: mysqlDialect, + requireTenantScope: true, +}).pipe(Layer.provide(MysqlClient.layer({ url: Redacted.make("mysql://app:secret@localhost:3306/app?timezone=Z") }))) +``` + +For a proxy without prepared statements, such as Cloudflare Hyperdrive (as `@effect/sql-mysql2` +documents its `disablePreparedStatements` option), pass `disablePreparedStatements: true` to +`MysqlClient.layer`: mysql2 then writes each value into the statement, escaped with backslashes +(so the server must not run with `NO_BACKSLASH_ESCAPES`, see below). Both paths are fuzzed. In a +Worker, mysql2 also needs `disableEval: true`; `MysqlClient.layer` passes `poolConfig` through +only with `host`, `port`, `database`, `username` and `password`, not with `url`, so give those +and `poolConfig: { timezone: "Z", disableEval: true }`. Over the text protocol the server prints some `DOUBLE` results one ULP off +(93188483.89857809 reads back as …808); prepared statements send the exact bits. + +`Db.stream` reads through mysql2's row stream. Transactions and nested savepoints use the +client's `withTransaction`. `isolationLevel` (`read committed`, `repeatable read`, +`serializable`) and `accessMode` are applied by the statement that starts the transaction: +`SET TRANSACTION ISOLATION LEVEL …; START TRANSACTION READ ONLY`, since MySQL refuses +`SET TRANSACTION` inside a transaction. An isolation level therefore needs a client that runs +several statements in one; `@effect/sql-mysql2` does (it sets `multipleStatements`). An access +mode alone is one statement. `deferrable` is refused. + +## Types + +| Type | SQL | Decodes to | +| --- | --- | --- | +| `text`, `longtext`, `varchar(n)` | `text`, `longtext`, `varchar(n)` | `string` | +| `tinyint`, `smallint`, `int`, `intUnsigned` | the integer types | `number`, checked against the type's range | +| `bigint` | `bigint` | a safe JavaScript integer; a value past 2^53 is a decode error | +| `double` | `double` | `number` | +| `decimal(p, s)` | `decimal(p,s)` | the exact decimal text, as mysql2 sends it (`"12.5000"`) | +| `boolean` | `boolean` (`tinyint(1)`) | `boolean`, from 0/1; other integers fail | +| `datetime` | `datetime(3)` | `DateTime.Utc`, years 1000-9999 | +| `date` | `date` | `YYYY-MM-DD` text, a calendar date with no time zone | +| `blob`, `varbinary(n)` | `blob`, `varbinary(n)` | `Uint8Array` | +| `json(schema)` | `json` | the schema's type; MySQL sends JSON parsed, MariaDB as text. A string on the wire is read as JSON text, so a top-level JSON *string* (`"hello"`, quotes included) reads back as `hello` on MySQL: wrap such a value in an object or array | +| `nullable`, `custom`, `brand` | | type composition | + +`MysqlType` is the descriptor type and `MysqlInteger` the integer wire codec. `dateTimeNow` is a +`defaultExpr` for a `datetime` column: `UTC_TIMESTAMP(3)`, since `CURRENT_TIMESTAMP` is in the +session's time zone and the column is read as UTC. A `DATETIME` itself is stored as written, so +mysql2's `timezone: "Z"` is all reading and writing need. And `mysqlDateTimeText(epochMillis)` is the `DATETIME(3)` text of an +instant, for raw SQL and seeds. + +`date` refuses a day that does not exist. mysql2 also builds a `Date` for a year before 100 in +the 1900s (`0099-01-01` arrives as 1999), which the codec cannot tell from a real date; read +such dates as text with `dateStrings: ["DATE"]`. MariaDB's default `sql_mode` stores zero dates +(`2024-02-00`), which mysql2 turns into another day's `Date` before the codec sees it: run with +`NO_ZERO_IN_DATE,NO_ZERO_DATE`, or read DATE as text (mysql2's `dateStrings: ["DATE"]`). + +A JSON column is compared with `CAST(? AS JSON)` on MySQL, which compares documents; MariaDB +compares the text. + +An integer column compares with any number: `id.in_(-1)` on an `intUnsigned` column is false, +and `amount.gt(9.75)` compares numerically. Inserting a value outside the type is refused. + +With `supportBigNumbers` (which `@effect/sql-mysql2` sets), mysql2 sends a `BIGINT` or a JSON +integer past 2^53 as a string. `bigint` refuses it; inside `json(schema)` the schema sees a +string, so declare such a field as one. mysql2 can only keep a JSON integer exact where the runtime's +`JSON.parse` hands its reviver the source text (current V8, as in recent Node); elsewhere it is rounded before the schema +sees it. Store an integer that must stay exact as a JSON string. + +MySQL's own JSON parser reads some doubles up to one ULP off (`7.33277286497045e+62`), on every +path: a JSON column, `CAST(… AS JSON)` and `JSON_EXTRACT`. Store an exact number as `decimal` or +text. + +## Functions + +`count`, `countDistinct`, `countIf` (`COUNT(CASE WHEN … THEN 1 END)`), `sum`, `avg`, `min`, `max`, +`lower`, `upper`, `length` (`CHAR_LENGTH`: characters, not bytes) and `coalesce`. `sum` and `avg` +of integers are `DECIMAL`, which mysql2 sends as text; they decode to `number`. A function of +another dialect is refused in the type and at compile time. + +## What differs from the other dialects + +- **Text comparison follows the collation.** Under MySQL's default `utf8mb4_0900_ai_ci`, `=`, + `GROUP BY`, `DISTINCT` and `like` ignore case and accents (`'a' = 'Á'`), and under a `PAD SPACE` + collation such as `utf8mb4_bin` trailing spaces do not count (`'a' = 'a '`). Use a binary, + no-pad collation (`utf8mb4_0900_bin` on MySQL, `utf8mb4_nopad_bin` on MariaDB) where text must + match exactly; the fuzzer runs under these. A case-sensitive `contains`, `startsWith` or + `endsWith` does not depend on it: it compares bytes (`CAST(x AS BINARY) LIKE …`). + `{ insensitive: true }` compares `lower()` of both sides under the collation, so under an + accent-insensitive one it ignores accents too (`'é'` matches `'e'`). +- **`/` is DECIMAL division.** `7 / 2` is `3.5000`: four places past the dividend's, nine kept + internally. A comparison of a quotient sees the internal digits, and MySQL and MariaDB differ + there (`1/3*3 = 1` is false on MySQL 8.4, true on MariaDB 11.4). +- **Unsigned arithmetic does not go below zero.** On an `UNSIGNED` column, `id - 5` fails with + `BIGINT UNSIGNED value is out of range` when the result would be negative. Use a signed type, + or `CAST`. +- **Not supported, and refused at compile time:** `RETURNING`, `ON CONFLICT` (MySQL's + `ON DUPLICATE KEY UPDATE` reacts to every unique key, not one target), `DISTINCT ON`, `ILIKE`, + `FOR NO KEY UPDATE` and `FOR KEY SHARE`. MariaDB has no lock `OF` at all. +- **Row locks:** `forUpdate` and `forShare` with `skipLocked`, `noWait` and, on MySQL, `of`. + MariaDB writes a shared lock as `LOCK IN SHARE MODE`. +- **`NO_BACKSLASH_ESCAPES` is not supported.** String literals escape `\` with a backslash, as + MySQL reads them by default. With prepared statements bound values never enter the SQL text, + but over the text protocol (`disablePreparedStatements`) mysql2 escapes them with backslashes + too, so under `NO_BACKSLASH_ESCAPES` a quote in a value can end the string: an injection. Do not + run that mode on such a server. A literal containing the builder's reserved `__PARAM_` text is + written as a `CONCAT` of parts. +- **OFFSET without LIMIT** is written with MySQL's largest row count. +- **MariaDB 11.4 pages `DISTINCT` over a join wrongly.** It counts `OFFSET` over the join's rows + before `DISTINCT` removes their duplicates, so a page can repeat a row from the page before. + Page a de-duplicated join from a subquery (`fromQuery(query.distinct(), "d")`) or with `groupBy`. +- A selected integer literal, and a selected `param.int`, `param.float` or `param.bool`, is + written `CAST(… AS SIGNED)` (or `DOUBLE`): MySQL 8.4 replaces an alias with its expression + inside a merged derived table, and would read `ORDER BY e1` over `10 AS e1` as column 10, an + error, or over `1 AS e1` as the first column, silently. Over the text protocol a param is + written into the SQL, so it needs the cast too. A literal of 2^63 or more is left as it is: MySQL + reads it as a DECIMAL, no position. diff --git a/docs/params-and-compilation.md b/docs/params-and-compilation.md index 2edc5f7..1d397c0 100644 --- a/docs/params-and-compilation.md +++ b/docs/params-and-compilation.md @@ -89,8 +89,9 @@ read at run time, so the type cannot see it: under a `CurrentDialect` of another `options.dialect` too. The compile-time `QueryBuilderDefect` stays, for a caller past the types. **A clause only some dialects write.** A row lock (`forUpdate`, `forNoKeyUpdate`, `forShare`, -`forKeyShare`) adds a `RowLock` (`ClauseEntry<"FOR UPDATE", "postgres">`): only Postgres has row -locks, so compiling or running the query for ClickHouse, SQLite or D1 is a type error +`forKeyShare`) adds a `RowLock` (`ClauseEntry<"FOR UPDATE", "postgres" | "mysql">`): Postgres +writes every lock and MySQL `FOR UPDATE` and `FOR SHARE`, so compiling or running the query for +ClickHouse, SQLite or D1 (or `forNoKeyUpdate` for MySQL) is a type error (`unsupportedClause "FOR UPDATE has no meaning for the sqlite dialect"`). On SQLite and D1, claim a row with a conditional update instead (`update(…).set(…).where(($) => [$.status.eq("pending")]).returning(…)`), or keep the writer single (a Durable Object). As with functions, a dialect typed as a plain `Dialect` diff --git a/docs/queries.md b/docs/queries.md index 465c1d9..f116c94 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -175,7 +175,7 @@ when you are sending raw SQL somewhere that does not. ## Row locks On Postgres, `forUpdate`, `forNoKeyUpdate`, `forShare` and `forKeyShare` add a locking clause -after LIMIT. Each takes `{ skipLocked?, noWait?, of? }`. The usual job-queue claim: +after LIMIT; MySQL and MariaDB have `forUpdate` and `forShare` ([MySQL](./mysql.md)). Each takes `{ skipLocked?, noWait?, of? }`. The usual job-queue claim: ```ts PG.from(Jobs) @@ -191,7 +191,7 @@ A lock lasts until the transaction ends, so run the query inside `Database.trans are a `QueryBuilderDefect`, refused before anything is sent: `skipLocked` and `noWait` together; a qualified name in `of` (use the alias or `jobs`, not `public.jobs`); a lock on a query with DISTINCT, GROUP BY or HAVING, or on a `unionAll` branch, which Postgres refuses; and any lock on -ClickHouse, which has no row locks. +ClickHouse, SQLite or D1, which have no row locks. ## `withCTE` diff --git a/docs/reference.md b/docs/reference.md index 5551034..15bb91a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -35,7 +35,7 @@ uniformly, which some codebases prefer for exactly this reason. ## Query builder (every entry) -Everything in this section is exported, identically, from `/clickhouse`, `/postgres` and `/sqlite`. +Everything in this section is exported, identically, from `/clickhouse`, `/postgres`, `/sqlite` and `/mysql`. ### Query construction @@ -68,7 +68,7 @@ Tables come from the dialect's `table`: [`CH.table`](#tables-and-ddl) or | `limit(n)` / `offset(n)` | Rounded before emission | | `format(fmt)` | `"JSON"` \| `"JSONEachRow"` | | `distinct()` / `distinctOn(...aliases)` | `SELECT DISTINCT` / `SELECT DISTINCT ON (…)` | -| `forUpdate` / `forNoKeyUpdate` / `forShare` / `forKeyShare` | Postgres row locks; options `LockOptions` (`skipLocked`, `noWait`, `of`). Refused in the type when compiled or run for another dialect | +| `forUpdate` / `forNoKeyUpdate` / `forShare` / `forKeyShare` | Row locks; options `LockOptions` (`skipLocked`, `noWait`, `of`). Postgres writes all four, MySQL and MariaDB `forUpdate` and `forShare`; refused in the type when compiled or run for another dialect | | `innerJoin` / `leftJoin` / `crossJoin` | `(table, alias, on?)` | | `innerJoinQuery` / `leftJoinQuery` / `crossJoinQuery` | `(query, alias, on?)` | | `withCTE(name, query)` / `withCTE(name, sql, options?)` | Typed query derives scope; SQL form can declare `options.tenantScope` | @@ -117,7 +117,8 @@ to its own dialect; `options.dialect` overrides it. | `rawCompiledQuery` | `({ sql, tenantScope, reason, justification, rowSchema?, route?, dialect?, kind? }) => CompiledQuery` | `Dialect`, `DialectClauses` and `ParamStyle` describe a database: how identifiers and literals -are written, how params reach the server, and which clauses exist. Pass one as +are written, how params reach the server, and which clauses exist (`LockingSupport`: which row +locks). Pass one as `options.dialect`, or provide it as `CurrentDialect`, a `Context.Reference` that `compile` reads when no `options.dialect` is given. `Compile`, `CompileUnsafe`, `CompileUnion` and `CompileUnionUnsafe` are the types of the four compile @@ -243,8 +244,8 @@ to `compile`. `whenTrue`, or one whose name is a union, which the params object may leave out) and `ParamsIn` (the params an expression or argument list carries). Beside the params, the same channel carries `DialectEntry` (a built-in function of one dialect: `ClickHouseOnly`, -`PostgresOnly` or `SqliteOnly`; `DialectsIn

` reads the sets), `ClauseEntry` (a -clause only some dialects write: `RowLock` for the Postgres row locks) and `SourceEntry` (a column of source +`PostgresOnly`, `SqliteOnly` or `MysqlOnly`; `DialectsIn

` reads the sets), `ClauseEntry` (a +clause only some dialects write: `RowLock` for the row locks) and `SourceEntry` (a column of source `Name`; `SourceOf` is the entry of a ref, `BindSources` removes the ones a query provides). You rarely write them, but `tsc --declaration` names them when a package exports a query with a param or a `defineFn` helper, so they are public. @@ -694,3 +695,31 @@ drizzle-kit 1.0 writes and names it. the `CREATE TABLE` and `CREATE INDEX` statements. `effect-orm generate` with `dialect: "sqlite"` writes migrations for them; see [SQLite migrations](./migrations.md#sqlite). The `/migrate` runner does not apply SQLite migrations yet. + +## MySQL and MariaDB + +`@maple-dev/effect-orm/mysql` exports the shared query builder and the four compile functions. +`mysqlDialect` is the default; a `CurrentDialect` in context, or `options.dialect`, overrides it. +`mariadbDialect` writes MariaDB's row locks and union branches. See [MySQL and MariaDB](./mysql.md) +for driver wiring, collations and what differs. + +| Export | Purpose | +| --- | --- | +| `MysqlType` | A MySQL SQL type with its Effect codec | +| `text`, `longtext`, `varchar(n)` | Text | +| `tinyint`, `smallint`, `int`, `intUnsigned`, `bigint` | Integers in the type's range; `bigint` within 2^53 | +| `double` | A finite number | +| `decimal(p, s)` | Exact decimal text | +| `boolean` | A boolean stored as 0/1 | +| `datetime` | `DATETIME(3)`, an instant in UTC, years 1000-9999 | +| `date` | `YYYY-MM-DD` text | +| `blob`, `varbinary(n)` | Bytes | +| `json(schema)` | JSON decoded through the supplied schema | +| `mysqlDateTimeText(epochMillis)` | `DATETIME(3)` text for an instant | +| `dateTimeNow` | A `defaultExpr` for a `datetime` column | +| `nullable(type)`, `custom`, `brand` | Type composition | +| `count`, `countDistinct`, `countIf`, `sum`, `avg`, `min`, `max` | MySQL aggregates; the last four can return SQL NULL | +| `lower`, `upper`, `length`, `coalesce` | MySQL scalar functions | +| `table` | `(name, columns, TableOptions?) => Table`, a query schema with no DDL | +| `mysqlDialect`, `mariadbDialect` | The dialects | +| `MysqlInteger` | The integer wire codec: a number, or the numeric string mysql2 sends for a wide one | diff --git a/package.json b/package.json index 4520548..d1958e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@maple-dev/effect-orm", "version": "0.9.0", - "description": "Type-safe ClickHouse, Postgres and SQLite queries, result decoding, and reproducible benchmarks for Effect and TypeScript", + "description": "Type-safe ClickHouse, Postgres, SQLite and MySQL queries, result decoding, and reproducible benchmarks for Effect and TypeScript", "keywords": [ "clickhouse", "effect", @@ -11,7 +11,9 @@ "sql", "typescript", "sqlite", - "cloudflare-d1" + "cloudflare-d1", + "mysql", + "mariadb" ], "homepage": "https://effect-clickhouse.maple.dev", "bugs": { @@ -109,6 +111,11 @@ "types": "./dist/sqlite.d.mts", "import": "./dist/sqlite.mjs", "default": "./dist/sqlite.mjs" + }, + "./mysql": { + "types": "./dist/mysql.d.mts", + "import": "./dist/mysql.mjs", + "default": "./dist/mysql.mjs" } }, "publishConfig": { @@ -134,6 +141,7 @@ "@effect/platform-bun": "4.0.6", "@effect/sql-clickhouse": "4.0.6", "@effect/sql-d1": "4.0.6", + "@effect/sql-mysql2": "4.0.6", "@effect/sql-pglite": "4.0.6", "@effect/sql-sqlite-node": "4.0.6", "@effect/vitest": "4.0.6", @@ -143,6 +151,7 @@ "expect-type": "^1.3.0", "fast-check": "^4.9.0", "miniflare": "4.20260730.0", + "mysql2": "3.24.6", "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 0aa5ff8..24e24b3 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 three dialect entries is named in docs/reference.md, whose +// Every export of the four 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", "sqlite"] +const entries = ["clickhouse", "postgres", "sqlite", "mysql"] const exportsOf = (entry) => { const dts = join(root, `dist/${entry}.d.mts`) diff --git a/src/ch/compile.ts b/src/ch/compile.ts index 93768b1..8462318 100644 --- a/src/ch/compile.ts +++ b/src/ch/compile.ts @@ -61,11 +61,18 @@ export class CompiledQueryEncodeError extends Schema.TaggedError { if (lock === undefined) return undefined const dialect = currentDialect() - if (dialect.clauses.locking !== true) { + const locking = dialect.clauses.locking + if (locking === undefined) { return fail(new QueryBuilderDefect({ message: `CHQuery: FOR ${lock.strength} has no meaning for the ${dialect.name} dialect, which has no row locks`, }), "") } + if (!locking.strengths.includes(lock.strength)) { + return fail(new QueryBuilderDefect({ message: `CHQuery: FOR ${lock.strength} has no meaning for the ${dialect.name} dialect` }), "") + } + if (!locking.of && (lock.of?.length ?? 0) > 0) { + return fail(new QueryBuilderDefect({ message: `CHQuery: ${dialect.name} has no FOR ${lock.strength} OF; lock every table of the query` }), "") + } if (lock.skipLocked === true && lock.noWait === true) { return fail(new QueryBuilderDefect({ message: "CHQuery: a lock takes skipLocked or noWait, not both" }), "") } @@ -79,7 +86,13 @@ const lockClause = (lock: import("./query").LockClause | undefined): string | un } const of = lock.of !== undefined && lock.of.length > 0 ? ` OF ${lock.of.map(quoteIdent).join(", ")}` : "" const wait = lock.skipLocked === true ? " SKIP LOCKED" : lock.noWait === true ? " NOWAIT" : "" - return `FOR ${lock.strength}${of}${wait}` + return lock.strength === "SHARE" && locking.shareSyntax === "LOCK IN SHARE MODE" ? `LOCK IN SHARE MODE${wait}` : `FOR ${lock.strength}${of}${wait}` +} + +/** The LIMIT an OFFSET without one needs, where the dialect needs one. */ +const limitForOffset = (): SqlFragment | undefined => { + const limit = currentDialect().clauses.limitForOffset + return limit === undefined ? undefined : raw(limit) } /** `orderBy` takes `[column, direction]` tuples. A bare string is the natural @@ -1027,7 +1040,7 @@ function compileInner< } const fragment = expr.toFragment() const [sql, found] = track(() => compileSqlFragment(fragment)) - return { alias, sql: castSelectedParam(expr, sql), found, column: plainColumn(fragment) } + return { alias, sql: positionSafe(castSelectedParam(expr, sql)), found, column: plainColumn(fragment) } }) const where = presentWhere.map((c) => raw(rowFilter("WHERE", c))) const having = (state.havingFn ? state.havingFn($) : []) @@ -1074,8 +1087,8 @@ function compileInner< orderBy: state.orderByFn === undefined ? orderByClause(state.orderBySpecs).map(raw) : orderByExprs(state.orderByFn($)).map(raw), - limit: state.limitValue == null && state.offsetValue != null && currentDialect().clauses.offsetRequiresLimit === true - ? raw("-1") + limit: state.limitValue == null && state.offsetValue != null + ? limitForOffset() : rowCount("limit", state.limitValue), offset: rowCount("offset", state.offsetValue), format: options?.skipFormat ? undefined : formatClause(state.formatValue), @@ -1426,10 +1439,26 @@ function derivedLiteralSchema(schema: Schema.Codec): Schema.Codec { const sqlType = paramColumnTypeOf(expr)?.sql + const dialect = currentDialect() // Only a lowercase type name: that is how Postgres types are spelled, and a // ClickHouse type (`String`, `Nullable(…)`) handed to a Postgres query would // otherwise become a cast the server rejects. - return sqlType && /^[a-z_]/.test(sqlType) && currentDialect().params._tag === "bind" ? `CAST(${sql} AS ${sqlType})` : sql + if (!sqlType || !/^[a-z_]/.test(sqlType) || dialect.params._tag !== "bind") return sql + const target = dialect.castType === undefined ? sqlType : dialect.castType(sqlType) + return target === undefined ? sql : `CAST(${sql} AS ${target})` +} + +/** A selected integer below 2^63; a larger one is a DECIMAL to MySQL, and no position. */ +const isSmallInteger = (sql: string): boolean => /^\d{1,19}$/.test(sql) && BigInt(sql) <= 9223372036854775807n +const NUMERIC_PARAM = new RegExp(`^${PARAM_MARKER_PREFIX}(int|float|bool)_.*__$`) + +/** A selected number cast where the dialect could read a bare one as a column position (see `selectedNumberCast`). */ +const positionSafe = (sql: string): string => { + const cast = currentDialect().clauses.selectedNumberCast + if (cast === undefined) return sql + if (isSmallInteger(sql)) return `CAST(${sql} AS ${cast.integer})` + const kind = NUMERIC_PARAM.exec(sql)?.[1] + return kind === undefined ? sql : `CAST(${sql} AS ${kind === "float" ? cast.float : cast.integer})` } /** The aliases a query selects, read without compiling it; `undefined` before `select()`. */ @@ -1641,8 +1670,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" + } else if (state.outerOffsetValue != null && currentDialect().clauses.limitForOffset !== undefined) { + sql += `\nLIMIT ${currentDialect().clauses.limitForOffset}` } if (state.outerOffsetValue != null) { sql += `\nOFFSET ${compileSqlFragment(rowCount("offset", state.outerOffsetValue)!)}` diff --git a/src/ch/dialect.ts b/src/ch/dialect.ts index 8289d20..dbc1286 100644 --- a/src/ch/dialect.ts +++ b/src/ch/dialect.ts @@ -69,8 +69,17 @@ export interface DialectClauses { readonly parenthesizeUnionBranches: boolean /** SQLite preserves branch-local ORDER BY/LIMIT by selecting from each branch. */ readonly wrapUnionBranches?: boolean - /** SQLite requires LIMIT -1 before OFFSET when the caller omitted a limit. */ - readonly offsetRequiresLimit?: boolean + /** The LIMIT written before an OFFSET when the caller gave none, where + * OFFSET needs one: `-1` for SQLite, the largest row count for MySQL. */ + readonly limitForOffset?: string + /** + * The CAST targets for a selected number (a literal, or a numeric param the + * driver may write into the text), where the dialect could read a bare one as + * an ORDER BY position. MySQL 8.4 puts a merged derived table's select + * expressions in place of their aliases, so `10 AS e1 … ORDER BY e1` is + * `ORDER BY 10`: an unknown column, or, for `1`, silently the first one. + */ + readonly selectedNumberCast?: { readonly integer: string; readonly float: string } /** `RETURNING` after an INSERT. Absent means no: an insert with * `.returning()` fails to compile for the dialect. */ readonly returning?: boolean @@ -88,14 +97,24 @@ export interface DialectClauses { /** `SETTINGS` on an INSERT, UPDATE or DELETE (ClickHouse). Absent means * no: a write with `.settings()` fails to compile for the dialect. */ readonly writeSettings?: boolean - /** Row locking on a SELECT (`FOR UPDATE`, `FOR SHARE`, `SKIP LOCKED`). Absent - * means no: a query with `.forUpdate()` and the like fails to compile. */ - readonly locking?: boolean + /** Row locking on a SELECT (`FOR UPDATE`, `FOR SHARE`, `SKIP LOCKED`): the + * locks the dialect writes. Absent means none: a query with `.forUpdate()` + * and the like fails to compile. */ + readonly locking?: LockingSupport /** UPDATE is written `ALTER TABLE t UPDATE ... WHERE ...`, a ClickHouse * mutation, rather than `UPDATE t SET ... WHERE ...`. */ readonly alterTableUpdate?: boolean } +/** The row locks a dialect writes. */ +export interface LockingSupport { + readonly strengths: ReadonlyArray<"UPDATE" | "NO KEY UPDATE" | "SHARE" | "KEY SHARE"> + /** `OF name, ...` after the strength. */ + readonly of: boolean + /** How a shared lock is written: `FOR SHARE`, or MariaDB's `LOCK IN SHARE MODE`. */ + readonly shareSyntax?: "FOR SHARE" | "LOCK IN SHARE MODE" +} + /** A transaction isolation level. `read uncommitted` is left out: Postgres runs it as `read committed`. */ export type IsolationLevel = "read committed" | "repeatable read" | "serializable" @@ -193,6 +212,13 @@ export interface Dialect extends Omit ReadonlyArray + /** + * The CAST target for a column type, where it is not the type's own name + * (MySQL casts to `CHAR` or `SIGNED`, never `text` or `bigint`), or + * `undefined` for no cast. Used for a bound param selected on its own. + * Absent: a lowercase type name is its own target. + */ + readonly castType?: (sqlType: string) => string | undefined } /** ClickHouse, with params written into the SQL as literals. The default. */ @@ -259,9 +285,11 @@ const checkedSyntax = (dialect: Dialect): SqlSyntax => ({ dialect: dialect.name, ilike: dialect.ilike, likeEscape: dialect.likeEscape, - likeIgnoresCase: dialect.likeIgnoresCase, + likeCase: dialect.likeCase, inexactNumberText: dialect.inexactNumberText, - integerComparesFraction: dialect.integerComparesFraction, + fractionComparableIntegers: dialect.fractionComparableIntegers, + comparisonCast: dialect.comparisonCast, + concat: dialect.concat, 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 3cb20b5..b5b4fa5 100644 --- a/src/ch/expr.ts +++ b/src/ch/expr.ts @@ -189,7 +189,9 @@ type FunctionSetLabel = Set extends "clickhouse" ? "Postgres" : Set extends "sqlite" ? "SQLite" - : Set + : Set extends "mysql" + ? "MySQL" + : Set /** * `unknown` when the entries `P` can compile for a dialect whose function set @@ -219,12 +221,13 @@ export type DialectSatisfied = * `"both ClickHouse and Postgres"`, `"ClickHouse, Postgres and SQLite"`: the * built-in sets of a union, in a fixed order. A custom set is named as it is. */ -type JoinLabels = [Exclude] extends [never] +type JoinLabels = [Exclude] extends [never] ? JoinWithAnd< [ ...("clickhouse" extends Sets ? ["ClickHouse"] : []), ...("postgres" extends Sets ? ["Postgres"] : []), ...("sqlite" extends Sets ? ["SQLite"] : []), + ...("mysql" extends Sets ? ["MySQL"] : []), ] > : `both ${FunctionSetLabel}` @@ -233,7 +236,9 @@ type JoinWithAnd> = Labels extends readonly ? `both ${A} and ${B}` : Labels extends readonly [infer A extends string, infer B extends string, infer C extends string] ? `${A}, ${B} and ${C}` - : Labels[number] + : Labels extends readonly [infer A extends string, infer B extends string, infer C extends string, infer D extends string] + ? `${A}, ${B}, ${C} and ${D}` + : Labels[number] /** The value a param needs: every entry's type for that name, intersected * per entry (a `boolean` entry stays `boolean`, not `true & false`). */ @@ -536,7 +541,7 @@ const describeUntyped = (value: unknown): string => * column at all. */ const comparisonLiteral = (columnType: CHType, value: unknown, column: string): string => { - const literal = sqliteFraction(columnType, value) + const literal = comparesAsFraction(columnType, value) ? encodeLiteral(Schema.Finite, value, `column ${column}`) : encodeColumnLiteral(columnType, value, column) // Outside a compile, ClickHouse is the default dialect. @@ -555,17 +560,23 @@ const comparisonLiteral = (columnType: CHType, value: unknown, } /** - * 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. + * A fraction compared with an integer column of a dialect whose integers + * compare with one numerically (SQLite, MySQL): `Amount > 9.75` is a + * meaningful comparison, but the column's codec only encodes whole numbers, + * 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" && +const comparesAsFraction = (columnType: CHType, value: unknown): boolean => typeof value === "number" && Number.isFinite(value) && !Number.isInteger(value) && - activeSqlSyntax()?.integerComparesFraction === true + activeSqlSyntax()?.fractionComparableIntegers?.includes(columnType.sql) === true + +/** The CAST a value compared with a column of `columnType` gets, where the dialect asks for one. */ +const comparedAs = (columnType: CHType) => (sql: string): string => { + const cast = activeSqlSyntax()?.comparisonCast?.(columnType.sql) + return cast === undefined ? sql : `CAST(${sql} AS ${cast})` +} const POSTGRES_INTEGERS = new Set(["int2", "int4", "int8", "smallint", "integer", "bigint"]) @@ -639,7 +650,9 @@ const textPattern = (value: string | Expr, match: TextMatch, glob: : [LIKE_ESCAPE, "%", "_"].map((char) => [char, LIKE_ESCAPE + char] as const) let escaped = compile(value.toFragment()) for (const [char, replacement] of replacements) escaped = `replace(${escaped}, ${q(char)}, ${q(replacement)})` - return `(${[before && q(before), escaped, after && q(after)].filter(Boolean).join(" || ")})` + const parts = [before && q(before), escaped, after && q(after)].filter(Boolean) + const concat = activeSqlSyntax()?.concat + return concat === undefined ? `(${parts.join(" || ")})` : concat(parts) } return fail( new QueryBuilderError({ @@ -655,16 +668,20 @@ const textPattern = (value: string | Expr, match: TextMatch, glob: * insensitive. Where LIKE ignores case and there is no ILIKE (SQLite, whose * LIKE folds ASCII unless `PRAGMA case_sensitive_like` is on), a sensitive * match is a `GLOB` and an insensitive one a LIKE of `lower()` on both sides. + * Where LIKE follows the collation (MySQL), a sensitive match compares bytes. */ const textMatch = (fragment: SqlFragment, value: string | Expr, match: TextMatch, options?: TextMatchOptions): Condition => makeCond( known(() => { const insensitive = options?.insensitive === true - if (activeSqlSyntax()?.likeIgnoresCase !== true) { + const likeCase = activeSqlSyntax()?.likeCase ?? "sensitive" + if (likeCase === "sensitive") { return `${compile(fragment)} ${insensitive ? "ILIKE" : "LIKE"} ${textPattern(value, match, false)}${likeEscape()}` } - return insensitive - ? `lower(${compile(fragment)}) LIKE lower(${textPattern(value, match, false)})${likeEscape()}` + if (insensitive) return `lower(${compile(fragment)}) LIKE lower(${textPattern(value, match, false)})${likeEscape()}` + return likeCase === "collation" + ? // Only `%` is a wildcard here and every other character is escaped, so a byte-wise LIKE matches characters. + `CAST(${compile(fragment)} AS BINARY) LIKE CAST(${textPattern(value, match, false)} AS BINARY)${likeEscape()}` : `${compile(fragment)} GLOB ${textPattern(value, match, true)}` }), ) @@ -784,11 +801,14 @@ export function makeExpr( * no type to read fall back to guessing from the JS value. */ literal?: (value: unknown) => SqlFragment, + /** Wraps every operand compared with this expression: a column's `comparisonCast`. */ + operandCast?: (sql: string) => string, ): Expr { /** An operand: another expression as-is, a plain value through the codec. */ function operand(value: unknown): SqlFragment { if (value === null || value === undefined) return refusedNull(value) - return literal !== undefined && !isExprLike(value) ? literal(value) : toFragment(value) + const sql = literal !== undefined && !isExprLike(value) ? literal(value) : toFragment(value) + return operandCast === undefined ? sql : known(() => operandCast(compile(sql))) } // Keep operand rendering lazy so nested subqueries reach the owning compiler. @@ -816,7 +836,7 @@ export function makeExpr( 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") + ? fail(new QueryBuilderDefect({ message: `ILIKE is not supported by the ${activeSqlSyntax()?.dialect} dialect; use LIKE, which follows that dialect's own case rules, or contains(…, { insensitive: true })` }), "FALSE") : `${compile(fragment)} ILIKE ${likePattern(pattern)}`)), contains: (value, options) => textMatch(fragment, value, "contains", options), startsWith: (value, options) => textMatch(fragment, value, "startsWith", options), @@ -866,6 +886,8 @@ export const operandOf = (expr: Expr, value: unknown): SqlFragment => export function makeUntypedExpr( fragment: SqlFragment, literal?: (value: unknown) => SqlFragment, + /** Wraps every operand compared with this expression: a column's `comparisonCast`. */ + operandCast?: (sql: string) => string, ): Expr { return makeExpr(fragment, undefined, literal) } @@ -925,6 +947,7 @@ export function makeColumnRef known(() => comparisonLiteral(columnType, value, columnName ?? name)), + columnType === undefined ? undefined : comparedAs(columnType), ) // Without `sqlName`: a query reading this column as a subquery's output sees it under its alias. if (columnType !== undefined) { diff --git a/src/ch/functions/builtin.ts b/src/ch/functions/builtin.ts index 28128a6..eb3edbc 100644 --- a/src/ch/functions/builtin.ts +++ b/src/ch/functions/builtin.ts @@ -21,7 +21,7 @@ import { QueryBuilderDefect } from "../errors" import { type Condition, type DialectEntry, type Expr, makeCond, makeExpr, makeUntypedExpr, type ParamsIn, toFragment } from "../expr" import type { FnResult } from "../define-fn" -export type FunctionSet = "clickhouse" | "postgres" | "sqlite" +export type FunctionSet = "clickhouse" | "postgres" | "sqlite" | "mysql" export type BuiltinKind = "scalar" | "aggregate" | "window" /** The entry a built-in function of `Set` adds to its result: none for `portable`. */ @@ -32,9 +32,12 @@ export type ClickHouseOnly = DialectEntry<"clickhouse"> export type PostgresOnly = DialectEntry<"postgres"> /** What a SQLite function adds to its result's params: see `DialectEntry`. */ export type SqliteOnly = DialectEntry<"sqlite"> +/** What a MySQL / MariaDB function adds to its result's params: see `DialectEntry`. */ +export type MysqlOnly = DialectEntry<"mysql"> const setLabel: Record = { sqlite: "a SQLite function (from @maple-dev/effect-orm/sqlite)", + mysql: "a MySQL function (from @maple-dev/effect-orm/mysql)", clickhouse: "a ClickHouse function (from @maple-dev/effect-orm/clickhouse)", postgres: "a Postgres function (from @maple-dev/effect-orm/postgres)", } diff --git a/src/ch/query.ts b/src/ch/query.ts index 3e0cda5..8dff67c 100644 --- a/src/ch/query.ts +++ b/src/ch/query.ts @@ -122,11 +122,12 @@ export type InferOutput = { type OrderBySpec = [keyof Output & string, "asc" | "desc"] /** - * What a row lock adds to a query's params: only Postgres writes it, so - * `compile` and `run` for ClickHouse, SQLite or D1 refuse the query in the - * type (see `DialectSatisfied`), as the compile does at run time. + * What a row lock adds to a query's params. Postgres writes every lock and + * MySQL / MariaDB `FOR UPDATE` and `FOR SHARE`, so `compile` and `run` for + * another dialect refuse the query in the type (see `DialectSatisfied`), as + * the compile does at run time. */ -export type RowLock = ClauseEntry +export type RowLock = ClauseEntry /** How a locking clause waits for rows another transaction holds. */ export interface LockOptions { diff --git a/src/core.ts b/src/core.ts index 03189f2..fb1fd29 100644 --- a/src/core.ts +++ b/src/core.ts @@ -117,7 +117,7 @@ export { type SourceOf, type SourcesSatisfied, } from "./ch/expr" -export type { ClickHouseOnly, PostgresOnly, SqliteOnly } from "./ch/functions/builtin" +export type { ClickHouseOnly, MysqlOnly, PostgresOnly, SqliteOnly } from "./ch/functions/builtin" export { type CHInsert, @@ -183,6 +183,7 @@ export { type DialectClauses, type DialectTransactions, type IsolationLevel, + type LockingSupport, type ParamStyle, type TransactionSettings, } from "./ch/dialect" diff --git a/src/mysql.ts b/src/mysql.ts new file mode 100644 index 0000000..b390ee4 --- /dev/null +++ b/src/mysql.ts @@ -0,0 +1,50 @@ +// @maple-dev/effect-orm/mysql +// +// MySQL 8.0+ and MariaDB 10.6+ from one import: column types, MySQL functions, +// the query builder and a `compile` that defaults to the MySQL dialect. Pass +// (or provide) `mariadbDialect` for MariaDB. See docs/mysql.md. +import { + compileCHRaw, + compileUnion as compileUnionCH, + compileUnionUnsafe as compileUnionUnsafeCH, + type Compile, + type CompileUnion, + type CompileUnionUnsafe, + type CompileUnsafe, +} from "./ch/compile" +import { activeDialect, type Dialect } from "./ch/dialect" +import { compiled, compiledUnsafe } from "./ch/failure" +import { mysqlDialect } from "./mysql/dialect" +import { Effect } from "effect" + +export * from "./core" +export * from "./mysql/types" +export * from "./mysql/functions" +export { table, type TableOptions } from "./ch/table" +export { mysqlDialect, mariadbDialect } from "./mysql/dialect" +export { type Casing } from "./schema/casing" + +/** The dialect a `/mysql` compile writes for: `options.dialect`, else `CurrentDialect`, else MySQL. */ +const mysqlOptions = (options: O) => ({ + ...options, + dialect: options?.dialect ?? activeDialect() ?? mysqlDialect, +}) + +/** + * The shared compiler, for MySQL unless `options.dialect` or `CurrentDialect` + * says otherwise. The dialect is read when the Effect runs, from the fiber + * that runs it. Its type checks a query's functions and clauses against MySQL + * (see `DialectSatisfied`). + */ +// Typed through `any` and cast, as in `/postgres`: `Compile` is overloaded. +export const compile = ((query: any, params?: any, options?: any) => + compiled(() => compileCHRaw(query, params, mysqlOptions(options)))) as Compile<"mysql"> + +/** Compile immediately, throwing on a failed compile. */ +export const compileUnsafe = ((query: any, params?: any, options?: any) => + compiledUnsafe(() => compileCHRaw(query, params, mysqlOptions(options)))) as CompileUnsafe<"mysql"> + +export const compileUnion: CompileUnion<"mysql"> = (query, params, options) => + Effect.suspend(() => compileUnionCH(query, params, mysqlOptions(options))) +export const compileUnionUnsafe: CompileUnionUnsafe<"mysql"> = (query, params, options) => + compileUnionUnsafeCH(query, params, mysqlOptions(options)) diff --git a/src/mysql/datetime.ts b/src/mysql/datetime.ts new file mode 100644 index 0000000..8de5222 --- /dev/null +++ b/src/mysql/datetime.ts @@ -0,0 +1,28 @@ +// MySQL DATETIME(3) values: the text the builder writes and the wire forms it reads. +import { type DateTime, Schema } from "effect" +import { textTimestamp, toSeconds } from "../sql/text-timestamp" + +// The years DATETIME documents. +const datetime = textTimestamp({ minYear: 1000, maxYear: 9999, label: "MySQL's DATETIME", noun: "a DATETIME", title: "mysqlDateTime" }) + +/** An instant as DATETIME(3) text in UTC, `YYYY-MM-DD HH:MM:SS.SSS`. Throws outside 1000-9999. */ +export const mysqlDateTimeText = datetime.text + +/** + * A DATETIME read as UTC. mysql2 sends a `Date` (built in its `timezone` + * option, which must be `"Z"`), or the text with `dateStrings: true`. + * Encodes to the text, which binds and compares with a DATETIME column. + */ +export const MysqlDateTime = Schema.Union([datetime.codec, datetime.fromDate]) as unknown as Schema.Codec + +/** What a DATETIME comparison or param accepts (a `DateTime.Utc`, a `Date`, DATETIME text), written as the text. */ +export const MysqlDateTimeLiteral = datetime.literal() +/** The same, floored to whole seconds, for `param.dateTimeSeconds`. */ +export const MysqlDateTimeSecondsLiteral = datetime.literal(toSeconds) + +/** + * A `defaultExpr` for a `datetime` column: the current time in UTC, with + * milliseconds. `UTC_TIMESTAMP`, not `CURRENT_TIMESTAMP`, which is the + * session's time zone, while the column is read as UTC. + */ +export const dateTimeNow = "(UTC_TIMESTAMP(3))" diff --git a/src/mysql/dialect.ts b/src/mysql/dialect.ts new file mode 100644 index 0000000..f3656eb --- /dev/null +++ b/src/mysql/dialect.ts @@ -0,0 +1,158 @@ +import { DateTime } from "effect" +import type { Dialect, DialectTransactions, IsolationLevel, TransactionSettings } from "../ch/dialect" +import { QueryBuilderError } from "../ch/errors" +import { fail } from "../ch/failure" +import { PARAM_MARKER_PREFIX } from "../ch/param" +import { MysqlDateTimeLiteral, MysqlDateTimeSecondsLiteral, mysqlDateTimeText } from "./datetime" + +const hex = (value: Uint8Array): string => Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("") + +// MySQL reads `\` as an escape in a string literal unless the server runs with +// NO_BACKSLASH_ESCAPES, which this dialect does not support (docs/mysql.md). +const ESCAPES: Record = { "\\": "\\\\", "'": "\\'", "\0": "\\0", "\n": "\\n", "\r": "\\r", "\x1a": "\\Z" } + +const quoted = (part: string): string => `'${part.replace(/[\\'\0\n\r\x1a]/g, (char) => ESCAPES[char]!)}'` + +/** + * A quoted string. One containing the compiler's reserved param marker is a + * `CONCAT` of parts that split it (`CONCAT('…', '__PA', 'RAM_', '…')`), which + * keeps the marker out of the SQL text. Not adjacent literals (`'__PA' 'RAM_'`), + * which MariaDB's `replace()` reads differently from the same text written whole. + */ +const quoteString = (value: string): string => { + if (!value.includes(PARAM_MARKER_PREFIX)) return quoted(value) + const parts = value.split(PARAM_MARKER_PREFIX).flatMap((part, i) => (i === 0 ? [part] : [PARAM_MARKER_PREFIX.slice(0, 4), PARAM_MARKER_PREFIX.slice(4), part])) + return `CONCAT(${parts.map(quoted).join(", ")})` +} + +/** + * A finite number as a literal. MySQL reads `1.5` as an exact DECIMAL and + * `1.5e0` as a DOUBLE; a JS number is a double, so a fraction is written in + * exponent form and compares as the double it is. + */ +const numberLiteral = (value: number): string => { + const text = String(value) + return Number.isInteger(value) || /e/i.test(text) ? text : `${text}e0` +} + +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 ? "TRUE" : "FALSE" + if (typeof value === "number" && Number.isFinite(value)) return numberLiteral(value) + if (value instanceof Uint8Array) return `X'${hex(value)}'` + return fail(new QueryBuilderError({ code: "InvalidLiteral", message: `${context}: cannot write this value as a MySQL literal` }), "NULL") +} + +/** MySQL's CAST targets are not its column types: `CAST(? AS text)` is a syntax error. */ +const castType = (json: string | undefined) => (sqlType: string): string | undefined => { + if (/^(text|longtext|varchar\(\d+\))$/.test(sqlType)) return "CHAR" + if (INTEGERS.includes(sqlType) && sqlType !== "int unsigned") return "SIGNED" + if (sqlType === "int unsigned") return "UNSIGNED" + if (sqlType === "boolean") return "SIGNED" + if (sqlType === "double") return "DOUBLE" + if (sqlType === "datetime(3)") return "DATETIME(3)" + if (sqlType === "date") return "DATE" + if (/^decimal\(\d+,\d+\)$/.test(sqlType)) return sqlType.toUpperCase() + if (/^(blob|varbinary\(\d+\))$/.test(sqlType)) return "BINARY" + if (sqlType === "json") return json + return undefined +} + +/** The integer column types `/mysql` declares. */ +const INTEGERS = ["tinyint", "smallint", "int", "int unsigned", "bigint"] + +const isolation: Record = { + "read committed": "READ COMMITTED", + "repeatable read": "REPEATABLE READ", + serializable: "SERIALIZABLE", +} + +/** + * MySQL's SET TRANSACTION applies to the next transaction and is refused inside + * one, so an isolation level goes before START TRANSACTION, in the statement + * that begins it. That needs a client that runs several statements in one + * (`@effect/sql-mysql2` does: `multipleStatements`, through its unprepared path). + */ +const begin = (settings: TransactionSettings): string | undefined => { + if (settings.isolationLevel === undefined && settings.accessMode === undefined) return undefined + const mode = settings.accessMode === undefined ? "" : ` ${settings.accessMode.toUpperCase()}` + return settings.isolationLevel === undefined + ? `START TRANSACTION${mode}` + : `SET TRANSACTION ISOLATION LEVEL ${isolation[settings.isolationLevel]}; START TRANSACTION${mode}` +} + +const transactions: DialectTransactions = { + support: "full", + savepoints: true, + isolationLevels: ["read committed", "repeatable read", "serializable"], + accessModes: true, + deferrable: false, + setTransaction: () => undefined, + beginTransaction: begin, +} + +/** + * MySQL 8.0.17+ (for `CAST(… AS DOUBLE)`) with positional `?` bindings. Text compares by the column's (or + * the connection's) collation: under MySQL's default `utf8mb4_0900_ai_ci`, + * `=` and LIKE ignore case and accents. See docs/mysql.md. + */ +export const mysqlDialect: Dialect<"mysql"> = { + name: "mysql", + functions: "mysql", + ilike: false, + likeEscape: false, + likeCase: "collation", + fractionComparableIntegers: INTEGERS, + // A JSON column compared with a bound string compares a JSON string. + comparisonCast: (sqlType) => (sqlType === "json" ? "JSON" : undefined), + concat: (parts) => `CONCAT(${parts.join(", ")})`, + quoteIdent: (name) => `\`${name.replace(/`/g, "``")}\``, + quoteString, + literal, + dateTimeLiteral: (value) => { + try { + return `CAST(${quoteString(mysqlDateTimeText(DateTime.toEpochMillis(value)))} AS DATETIME(3))` + } catch (error) { + return fail(new QueryBuilderError({ code: "InvalidLiteral", message: (error as Error).message }), "NULL") + } + }, + params: { _tag: "bind", placeholder: () => "?", reuse: false, maxParameters: 65535 }, + clauses: { + format: false, + derivedTableAlias: true, + // GROUP BY looks a name up in FROM before the select list. + groupByAlias: false, + parenthesizeUnionBranches: true, + limitForOffset: "18446744073709551615", + // A merged derived table's `ORDER BY e1` reads `10 AS e1` as position 10. + selectedNumberCast: { integer: "SIGNED", float: "DOUBLE" }, + returning: false, + onConflict: false, + defaultInValues: true, + distinctOn: false, + locking: { strengths: ["UPDATE", "SHARE"], of: true }, + }, + paramCodecs: { + dateTime: MysqlDateTimeLiteral, + dateTimeSeconds: MysqlDateTimeSecondsLiteral, + }, + transactions, + castType: castType("JSON"), +} + +/** MariaDB 10.6+: MySQL's SQL, with `LOCK IN SHARE MODE` for a shared lock, no lock `OF`, JSON as text, and union branches as derived tables. */ +export const mariadbDialect: Dialect<"mysql"> = { + ...mysqlDialect, + name: "mariadb", + // JSON is LONGTEXT in MariaDB, with no JSON cast, and compares as text. + castType: castType("CHAR"), + comparisonCast: undefined, + clauses: { + ...mysqlDialect.clauses, + locking: { strengths: ["UPDATE", "SHARE"], of: false, shareSyntax: "LOCK IN SHARE MODE" }, + // A parenthesized branch cannot start with WITH; a derived table can. + parenthesizeUnionBranches: false, + wrapUnionBranches: true, + }, +} diff --git a/src/mysql/functions.ts b/src/mysql/functions.ts new file mode 100644 index 0000000..7b57492 --- /dev/null +++ b/src/mysql/functions.ts @@ -0,0 +1,43 @@ +import { Schema } from "effect" +import { makeExpr, type Condition, type Expr } from "../ch/expr" +import { schemaOf } from "../ch/define-fn" +import { builtins, type MysqlOnly } from "../ch/functions/builtin" +import { CHNumber } from "../ch/types" +import { MysqlInteger } from "./types" +import { compile } from "../sql/sql-fragment" + +// Each result carries `MysqlOnly`, as `builtins("mysql", …)` refuses them for +// another dialect at compile: the type refuses them there too. +const aggregate = builtins("mysql", "aggregate") +const scalar = builtins("mysql", "scalar") +const sql = (expr: Expr | Condition): string => compile(expr.toFragment()) +// SUM and AVG of integers are DECIMAL, which mysql2 sends as a string. +const nullableNumber = Schema.NullOr(CHNumber) +const integer = MysqlInteger + +export const count = (): Expr => makeExpr(aggregate.lazy(() => "COUNT(*)", "count"), integer) +export const countDistinct =

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

(condition: Condition

): Expr => + makeExpr(aggregate.lazy(() => `COUNT(CASE WHEN ${sql(condition)} THEN 1 END)`, "count"), integer) +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 characters (`CHAR_LENGTH`; MySQL's `LENGTH` counts bytes). */ +export const length =

(expr: Expr): Expr => + makeExpr(scalar.lazy(() => `CHAR_LENGTH(${sql(expr)})`, "length"), Schema.NullOr(integer)) +export const coalesce = (first: Expr, fallback: Expr): Expr => + makeExpr(scalar.lazy(() => `COALESCE(${sql(first)}, ${sql(fallback)})`, "coalesce"), schemaOf(fallback)) diff --git a/src/mysql/mysql.test-d.ts b/src/mysql/mysql.test-d.ts new file mode 100644 index 0000000..f18e2bf --- /dev/null +++ b/src/mysql/mysql.test-d.ts @@ -0,0 +1,27 @@ +import type { DateTime, Effect } from "effect" +import { expectTypeOf } from "expect-type" +import * as PG from "../postgres" +import * as MySQL from "../mysql" + +const Items = MySQL.table("items", { id: MySQL.bigint, note: MySQL.text, at: MySQL.nullable(MySQL.datetime), price: MySQL.decimal(10, 2) }) +const query = MySQL.from(Items).select().where(($) => [$.id.eq(MySQL.param.int("id"))]) +const compiled = MySQL.compileUnsafe(query, { id: 1 }) +expectTypeOf>>().toEqualTypeOf< + ReadonlyArray<{ readonly id: number; readonly note: string; readonly at: DateTime.Utc | null; readonly price: string }> +>() +const effect = MySQL.compile(query, { id: 1 }) +expectTypeOf>().toEqualTypeOf() +// @ts-expect-error required params remain required through the MySQL compile wrapper +MySQL.compile(query, {}) +// @ts-expect-error a bigint column cannot take a string +MySQL.insertInto(Items).values({ id: "1", note: "n", at: null, price: "1.00" }) + +// MySQL writes FOR UPDATE and FOR SHARE, not Postgres's weaker locks. +MySQL.compileUnsafe(MySQL.from(Items).select("id").forUpdate({ skipLocked: true })) +MySQL.compileUnsafe(MySQL.from(Items).select("id").forShare()) +// @ts-expect-error FOR NO KEY UPDATE has no meaning for the mysql dialect +MySQL.compileUnsafe(MySQL.from(Items).select("id").forNoKeyUpdate()) +// @ts-expect-error a Postgres function cannot compile for MySQL +MySQL.compileUnsafe(MySQL.from(Items).select(($) => ({ n: PG.count(), id: $.id }))) +// @ts-expect-error a MySQL function cannot compile for Postgres +PG.compileUnsafe(MySQL.from(Items).select(() => ({ n: MySQL.count() }))) diff --git a/src/mysql/mysql.test.ts b/src/mysql/mysql.test.ts new file mode 100644 index 0000000..7cf20eb --- /dev/null +++ b/src/mysql/mysql.test.ts @@ -0,0 +1,199 @@ +import * as MysqlClient from "@effect/sql-mysql2/MysqlClient" +import { DateTime, Effect, Redacted, Schema, Stream } from "effect" +import * as SqlClient from "effect/sql/SqlClient" +import { describe, expect, it } from "vitest" +import * as Db from "../database" +import * as MySQL from "../mysql" + +const Items = MySQL.table("items", { + id: MySQL.bigint, + note: MySQL.text, + enabled: MySQL.boolean, + score: MySQL.nullable(MySQL.double), + at: MySQL.datetime, +}) + +describe("the MySQL dialect", () => { + it("writes backtick identifiers and positional placeholders", () => { + const compiled = MySQL.compileUnsafe( + MySQL.from(Items).select("id", "note").where(($) => [$.note.eq(MySQL.param.string("note")), $.id.gt(MySQL.param.int("id"))]), + { note: "a", id: 1 }, + ) + expect(compiled.sql).toContain("FROM `items`") + expect(compiled.sql).toContain("`items`.`note` = ?") + expect(compiled.parameters).toEqual(["a", 1]) + }) + + it("escapes backslashes in literals and keeps the param marker out of the SQL text", () => { + const compiled = MySQL.compileUnsafe(MySQL.from(Items).select(() => ({ v: MySQL.lit("a\\b'\n__PARAM_x") }))) + expect(compiled.sql).toContain(`CONCAT('a\\\\b\\'\\n', '__PA', 'RAM_', 'x')`) + expect(compiled.sql).not.toContain("__PARAM_") + }) + + it("casts a selected small integer, or a numeric param, so ORDER BY cannot read it as a position", () => { + const sql = MySQL.compileUnsafe( + MySQL.from(Items).select(() => ({ one: MySQL.lit(1), big: MySQL.lit(1e20), n: MySQL.param.int("n"), f: MySQL.param.float("f"), s: MySQL.param.string("s") })), + { n: 1, f: 1, s: "1" }, + ).sql + expect(sql).toContain("CAST(1 AS SIGNED) AS `one`") + // Past 2^63 a literal is a DECIMAL to MySQL, and no position: a cast would overflow it. + expect(sql).toContain("100000000000000000000 AS `big`") + expect(sql).toContain("CAST(? AS SIGNED) AS `n`") + expect(sql).toContain("CAST(? AS DOUBLE) AS `f`") + expect(sql).toContain("? AS `s`") + }) + + it("compares a JSON column with CAST(? AS JSON) on MySQL, and as text on MariaDB", () => { + const Docs = MySQL.table("docs", { id: MySQL.int, body: MySQL.json(Schema.Struct({ a: Schema.Number })) }) + const query = MySQL.from(Docs).select("id").where(($) => [$.body.eq({ a: 1 })]) + expect(MySQL.compileUnsafe(query).sql).toContain("`docs`.`body` = CAST(? AS JSON)") + expect(MySQL.compileUnsafe(query, {}, { dialect: MySQL.mariadbDialect }).sql).toContain("`docs`.`body` = ?") + }) + + it("writes OFFSET with the largest LIMIT", () => { + const compiled = MySQL.compileUnsafe(MySQL.from(Items).select("id").where(($) => [$.score.gt(MySQL.lit(0.5))]).offset(2)) + expect(compiled.sql).toContain("`items`.`score` > 0.5") + expect(compiled.sql).toContain("LIMIT 18446744073709551615") + }) + + it("casts a selected param to MySQL's CAST types, and JSON to CHAR on MariaDB", () => { + const Docs = MySQL.table("docs", { id: MySQL.int, body: MySQL.json(Schema.Struct({ a: Schema.Number })) }) + const query = MySQL.from(Docs).select(() => ({ n: MySQL.param.of(MySQL.bigint, "n"), body: MySQL.param.of(Docs.columns.body, "body") })) + expect(MySQL.compileUnsafe(query, { n: 1, body: { a: 1 } }).sql).toMatch(/CAST\(\? AS SIGNED\)[\s\S]*CAST\(\? AS JSON\)/) + expect(MySQL.compileUnsafe(query, { n: 1, body: { a: 1 } }, { dialect: MySQL.mariadbDialect }).sql).toMatch(/CAST\(\? AS CHAR\) AS `body`/) + }) + + it("writes text matches that do not depend on the collation", () => { + const sql = MySQL.compileUnsafe(MySQL.from(Items).select("id").where(($) => [$.note.contains("50%"), $.note.startsWith("a", { insensitive: true })])).sql + expect(sql).toContain("CAST(`items`.`note` AS BINARY) LIKE CAST(? AS BINARY)") + expect(sql).toContain("lower(`items`.`note`) LIKE lower(?)") + const param = MySQL.compileUnsafe(MySQL.from(Items).select("id").where(($) => [$.note.contains(MySQL.param.string("q"))]), { q: "x" }).sql + expect(param).toContain("CONCAT('%', replace(") + }) + + it("writes MySQL's and MariaDB's row locks, and refuses the Postgres-only ones", () => { + const base = MySQL.from(Items).select("id") + expect(MySQL.compileUnsafe(base.forUpdate({ skipLocked: true })).sql).toMatch(/FOR UPDATE SKIP LOCKED$/) + expect(MySQL.compileUnsafe(base.forShare({ of: ["items"] })).sql).toMatch(/FOR SHARE OF `items`$/) + expect(MySQL.compileUnsafe(base.forShare({ noWait: true }), {}, { dialect: MySQL.mariadbDialect }).sql).toMatch(/LOCK IN SHARE MODE NOWAIT$/) + expect(() => MySQL.compileUnsafe(base.forShare({ of: ["items"] }), {}, { dialect: MySQL.mariadbDialect })).toThrow(/no FOR SHARE OF/) + // @ts-expect-error -- FOR NO KEY UPDATE has no meaning for the mysql dialect + expect(() => MySQL.compileUnsafe(base.forNoKeyUpdate())).toThrow(/FOR NO KEY UPDATE/) + }) + + it("refuses what MySQL does not have while compiling, before sending SQL", () => { + expect(() => MySQL.compileUnsafe(MySQL.from(Items).select("id").distinctOn("id"))).toThrow(/DISTINCT ON/) + expect(() => MySQL.compileUnsafe(MySQL.from(Items).select("id").where(($) => [$.note.ilike("a%")]))).toThrow(/ILIKE/) + const row = { id: 1, note: "a", enabled: true, score: null, at: DateTime.makeUnsafe(0) } + expect(() => MySQL.compileUnsafe(MySQL.insertInto(Items).values(row).returning("id"))).toThrow(/RETURNING/) + expect(() => MySQL.compileUnsafe(MySQL.insertInto(Items).values(row).onConflictDoNothing())).toThrow(/ON CONFLICT/) + }) + + it("starts a transaction with its settings before START TRANSACTION", () => { + const begin = MySQL.mysqlDialect.transactions!.beginTransaction! + expect(begin({})).toBeUndefined() + expect(begin({ accessMode: "read only" })).toBe("START TRANSACTION READ ONLY") + expect(begin({ isolationLevel: "serializable", accessMode: "read write" })).toBe("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION READ WRITE") + }) + + it("refuses a timestamp on a day that does not exist, which Date.parse would move on", () => { + const query = MySQL.from(Items).select("id").where(($) => [$.at.gt(MySQL.param.dateTime("t"))]) + expect(() => MySQL.compileUnsafe(query, { t: "2023-02-30 10:00:00" })).toThrow() + expect(MySQL.compileUnsafe(query, { t: "2023-02-28 10:00:00" }).parameters).toEqual(["2023-02-28 10:00:00.000"]) + }) + + it("refuses a DATETIME outside 1000-9999", () => { + expect(() => MySQL.compileUnsafe(MySQL.from(Items).select("id").where(($) => [$.at.gt(DateTime.makeUnsafe(Date.UTC(999, 0, 1)))]))).toThrow(/1000-9999/) + }) +}) + +// Live: the Database layer over @effect/sql-mysql2, against each server whose URL is set. +const servers = [ + ["mysql", process.env.EFFECT_ORM_MYSQL_URL, MySQL.mysqlDialect], + ["mariadb", process.env.EFFECT_ORM_MARIADB_URL, MySQL.mariadbDialect], +] as const + +for (const [name, url, dialect] of servers) { + describe.skipIf(url === undefined)(`Database on ${name} through @effect/sql-mysql2`, () => { + const table = `items_${name}_${process.pid}` + const T = MySQL.table(table, { id: MySQL.bigint, note: MySQL.text, enabled: MySQL.boolean, score: MySQL.nullable(MySQL.double), at: MySQL.datetime }) + const client = (prepared: boolean) => + MysqlClient.layer({ url: Redacted.make(`${url}${url?.includes("?") ? "&" : "?"}timezone=Z`), disablePreparedStatements: !prepared }) + const run = (prepared: boolean, body: (db: Db.DatabaseApi<"mysql">) => Effect.Effect) => + Effect.runPromise( + Effect.gen(function* () { + const db = Db.fromSqlClient(yield* SqlClient.SqlClient, { dialect }) + yield* db.execute({ sql: `DROP TABLE IF EXISTS \`${table}\`` }) + yield* db.execute({ sql: `DROP TABLE IF EXISTS \`${table}_docs\`` }) + yield* db.execute({ sql: `CREATE TABLE \`${table}\` (id BIGINT PRIMARY KEY, note TEXT NOT NULL, enabled BOOLEAN NOT NULL, score DOUBLE, at DATETIME(3) NOT NULL)` }) + const drop = (name: string) => Effect.orDie(db.execute({ sql: `DROP TABLE IF EXISTS \`${name}\`` })) + return yield* body(db).pipe(Effect.ensuring(Effect.andThen(drop(table), drop(`${table}_docs`)))) + }).pipe(Effect.provide(client(prepared))), + ) + const at = DateTime.makeUnsafe("2026-10-01T10:00:00.123Z") + + for (const prepared of [true, false]) { + it(`runs, streams and aggregates (${prepared ? "prepared" : "text protocol"})`, async () => { + const seen = await run(prepared, (db) => + Effect.gen(function* () { + yield* db.run(MySQL.insertInto(T).values([ + { id: 1, note: "O'Reilly\\notes", enabled: true, score: 1.5, at }, + { id: 2, note: "😀", enabled: false, score: null, at }, + ])) + const rows = yield* db.run(MySQL.from(T).select().where(($) => [$.enabled.eq(MySQL.param.bool("on"))]), { on: true }) + const streamed = yield* Stream.runCollect(db.stream(MySQL.from(T).select("id").orderBy(["id", "asc"]))) + const totals = yield* db.run(MySQL.from(T).select(($) => ({ n: MySQL.count(), total: MySQL.sum($.id), avg: MySQL.avg($.score) }))) + return { rows, streamed: Array.from(streamed), totals } + }), + ) + expect(seen).toEqual({ + rows: [{ id: 1, note: "O'Reilly\\notes", enabled: true, score: 1.5, at }], + streamed: [{ id: 1 }, { id: 2 }], + totals: [{ n: 2, total: 3, avg: 1.5 }], + }) + }) + } + + for (const prepared of [true, false]) { + it(`orders by a selected number and compares JSON (${prepared ? "prepared" : "text protocol"})`, async () => { + const seen = await run(prepared, (db) => + Effect.gen(function* () { + yield* db.run(MySQL.insertInto(T).values([1, 2].map((id) => ({ id, note: `${id}`, enabled: true, score: null, at })))) + // The text protocol writes the param into the SQL: MySQL would read a bare `10` in the merged + // derived table's ORDER BY as column position 10 (an error), and `1` as the first column. + const inner = MySQL.from(T).select(($) => ({ id: $.id, n: MySQL.param.int("n") })).orderBy(["n", "asc"], ["id", "desc"]) + const rows = yield* db.run(MySQL.fromQuery(inner, "w").select("id", "n"), { n: 10 }) + // A derived table's order is not the outer query's (MariaDB drops it), so compare the rows. + const ordered = [...rows].sort((a, b) => a.id - b.id) + const Docs = MySQL.table(`${table}_docs`, { id: MySQL.int, body: MySQL.json(Schema.Struct({ a: Schema.Number })) }) + yield* db.execute({ sql: `CREATE TABLE \`${table}_docs\` (id INT, body JSON)` }) + yield* db.run(MySQL.insertInto(Docs).values({ id: 1, body: { a: 1 } })) + const found = yield* db.run(MySQL.from(Docs).select("id").where(($) => [$.body.eq({ a: 1 })])) + // A literal holding the reserved marker is a CONCAT; MariaDB's replace() misreads adjacent literals. + yield* db.run(MySQL.insertInto(T).values({ id: 3, note: "a__PARAM_b", enabled: true, score: null, at })) + const marker = yield* db.run(MySQL.from(T).select("id").where(($) => [$.note.contains(MySQL.lit("__PARAM_")), $.note.startsWith(MySQL.lit("A__param_"), { insensitive: true })])) + return { ordered, found, marker } + }), + ) + expect(seen).toEqual({ ordered: [{ id: 1, n: 10 }, { id: 2, n: 10 }], found: [{ id: 1 }], marker: [{ id: 3 }] }) + }) + } + + // SET TRANSACTION and START TRANSACTION go as one statement: a READ ONLY insert failing proves both ran. + it("starts a transaction with its settings and rolls back a failed one", async () => { + const seen = await run(true, (db) => + Effect.gen(function* () { + const readOnly = yield* db + .transaction(db.run(MySQL.insertInto(T).values({ id: 4, note: "no", enabled: true, score: null, at })), { isolationLevel: "serializable", accessMode: "read only" }) + .pipe(Effect.flip, Effect.map((error) => String(error))) + const aborted = yield* db + .transaction(Effect.andThen(db.run(MySQL.insertInto(T).values({ id: 3, note: "gone", enabled: true, score: null, at })), Effect.fail("abort" as const))) + .pipe(Effect.flip) + const left = yield* db.run(MySQL.from(T).select("id")) + return { readOnly: readOnly.includes("Cannot execute statement in a READ ONLY transaction"), aborted, left } + }), + ) + expect(seen).toEqual({ readOnly: true, aborted: "abort", left: [] }) + }) + }) +} diff --git a/src/mysql/types.ts b/src/mysql/types.ts new file mode 100644 index 0000000..150323c --- /dev/null +++ b/src/mysql/types.ts @@ -0,0 +1,144 @@ +// MySQL / MariaDB column types. +// +// The ClickHouse descriptor (`CHType`) with MySQL's SQL type names and codecs +// for what mysql2 sends: integers as numbers (a BIGINT past 2^53 as a string +// with `supportBigNumbers`), DECIMAL as a string, TINYINT(1) as 0/1, DATETIME +// as a `Date` or text, binary as a `Buffer`, and JSON parsed (MySQL) or as +// text (MariaDB, where JSON is LONGTEXT). +import { Schema, SchemaGetter } from "effect" +import { brand, custom, type CHType } from "../ch/types" +import { MysqlDateTime, MysqlDateTimeLiteral } from "./datetime" + +export { dateTimeNow, mysqlDateTimeText } from "./datetime" + +/** A MySQL column type. */ +export type MysqlType = CHType + +/** An integer as mysql2 sends one: a number, or the numeric string it sends for a wide one. */ +export const MysqlInteger: Schema.Codec = Schema.Union([Schema.Int, Schema.FiniteFromString.pipe(Schema.decodeTo(Schema.Int))]) as unknown as Schema.Codec< + number, + number | string +> + +const integer = (minimum: number, maximum: number): Schema.Codec => + MysqlInteger.pipe(Schema.decodeTo(Schema.Number.check(Schema.isBetween({ minimum, maximum })))) as unknown as Schema.Codec + +const SafeInteger = MysqlInteger.pipe( + Schema.decodeTo( + Schema.Number.check( + Schema.makeFilter((value: number) => (Number.isSafeInteger(value) ? undefined : `${value} is not a safe integer; a BIGINT past 2^53 loses precision as a JS number`), { + title: "safeInteger", + }), + ), + ), +) as unknown as Schema.Codec + +export const varchar = (length: N) => custom(`varchar(${length})` as `varchar(${N})`, Schema.String) +export const text = custom("text", Schema.String) +export const longtext = custom("longtext", Schema.String) + +// A comparison may name any integer (`Id IN (-1)` on an unsigned column is +// false, not an error); a value outside the type is refused only on write. +const anyInteger = Schema.Int as unknown as Schema.Codec +export const tinyint = custom("tinyint", integer(-128, 127), anyInteger) +export const smallint = custom("smallint", integer(-32768, 32767), anyInteger) +export const int = custom("int", integer(-2147483648, 2147483647), anyInteger) +export const intUnsigned = custom("int unsigned", integer(0, 4294967295), anyInteger) +/** A BIGINT within JavaScript's safe integers; a wider value is a decode error, not a different number. */ +export const bigint = custom("bigint", SafeInteger) +export const double = custom("double", Schema.Union([Schema.Finite, Schema.FiniteFromString]) as unknown as Schema.Codec) +const decimalText = Schema.String.check( + Schema.makeFilter((value: string) => (/^-?\d+(\.\d+)?$/.test(value) ? undefined : `\`${value}\` is not a decimal number`), { title: "mysqlDecimal" }), +) +/** + * DECIMAL(p, s) as its exact decimal text: mysql2 sends DECIMAL as a string, and + * a JS number would round it. A number (mysql2's `decimalNumbers`) is read as its text. + */ +export const decimal = (precision: P, scale: S) => + custom( + `decimal(${precision},${scale})` as `decimal(${P},${S})`, + Schema.Union([ + decimalText, + Schema.Finite.pipe( + Schema.decodeTo(decimalText, { + decode: SchemaGetter.transform((value: number) => String(value)), + encode: SchemaGetter.transform((value: string) => Number(value)), + }), + ), + ]) as unknown as Schema.Codec, + ) +/** TINYINT(1), as MySQL spells BOOLEAN. Other integers are not booleans. */ +export const boolean = custom("boolean", Schema.Union([Schema.BooleanFromBit, Schema.Boolean]) as unknown as Schema.Codec) + +/** DATETIME(3), an instant read and written in UTC. Give mysql2 `timezone: "Z"` (docs/mysql.md). */ +export const datetime = custom("datetime(3)", MysqlDateTime, MysqlDateTimeLiteral) + +/** A real calendar date: `2024-02-30` and MariaDB's zero dates are not. */ +const isCalendarDate = (value: string): boolean => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false + const date = new Date(`${value}T00:00:00.000Z`) + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value +} +const dateText = Schema.String.check( + Schema.makeFilter((value: string) => (isCalendarDate(value) ? undefined : `\`${value}\` is not a DATE: expected a calendar date \`YYYY-MM-DD\``), { title: "mysqlDate" }), +) +const validDate = Schema.Date.check( + Schema.makeFilter((value: Date) => (Number.isNaN(value.getTime()) ? "an invalid Date (a zero date?)" : undefined), { title: "mysqlDate" }), +) +/** + * DATE, a calendar date with no time zone, as `YYYY-MM-DD` text. A `Date` from + * mysql2 (with `timezone: "Z"`) is read in UTC. mysql2 builds a `Date` from a + * MariaDB zero-in-date (`2024-02-00`) as another day, so read DATE as text + * (`dateStrings: ["DATE"]`) or run with NO_ZERO_IN_DATE (docs/mysql.md). + */ +export const date = custom( + "date", + Schema.Union([ + dateText, + validDate.pipe( + Schema.decodeTo(dateText, { + decode: SchemaGetter.transform((value: Date) => value.toISOString().slice(0, 10)), + encode: SchemaGetter.transform((value: string) => new Date(`${value}T00:00:00.000Z`)), + }), + ), + ]) as unknown as Schema.Codec, +) + +const bytes = Schema.Uint8Array.pipe( + Schema.decodeTo(Schema.Uint8Array, { + // A Buffer is a Uint8Array; copy it so a row holds a plain one. + decode: SchemaGetter.transform((value: Uint8Array) => new Uint8Array(value)), + encode: SchemaGetter.transform((value: Uint8Array) => value), + }), +) +export const blob = custom("blob", bytes) +export const varbinary = (length: N) => custom(`varbinary(${length})` as `varbinary(${N})`, bytes) + +/** + * JSON, decoded through the caller's schema. MySQL's JSON arrives parsed and + * MariaDB's as text; a string on the wire is read as JSON text, so a top-level + * JSON string value is not supported on MySQL. Writes send the text. + */ +export const json = (schema: Schema.Codec) => + custom( + "json", + Schema.Union([ + Schema.fromJsonString(schema), + Schema.Unknown.pipe( + Schema.decodeTo(schema as Schema.Codec, { + decode: SchemaGetter.transform((value: unknown) => value), + encode: SchemaGetter.transform((value: unknown) => JSON.stringify(value)), + }), + ), + ]) as unknown as Schema.Codec, + ) + +export const nullable = (type: MysqlType): MysqlType<"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/src/pg/dialect.ts b/src/pg/dialect.ts index 04ba5bf..382a4d1 100644 --- a/src/pg/dialect.ts +++ b/src/pg/dialect.ts @@ -116,7 +116,7 @@ export const postgresDialect: Dialect<"postgres"> = { parenthesizeUnionBranches: true, returning: true, onConflict: true, - locking: true, + locking: { strengths: ["UPDATE", "NO KEY UPDATE", "SHARE", "KEY SHARE"], of: true }, }, paramCodecs: { bool: Schema.Boolean, diff --git a/src/sql/sql-syntax.ts b/src/sql/sql-syntax.ts index 702d464..6e893f9 100644 --- a/src/sql/sql-syntax.ts +++ b/src/sql/sql-syntax.ts @@ -21,22 +21,34 @@ export interface SqlSyntax { */ readonly likeEscape?: boolean /** - * True when LIKE ignores (ASCII) case by default (SQLite), so a - * case-sensitive text match cannot be a LIKE. `contains` and friends write - * GLOB there instead. + * What decides LIKE's case rules. `sensitive` (the default): `LIKE` is + * case-sensitive. `ascii-insensitive` (SQLite): LIKE folds ASCII, so a + * case-sensitive text match is a GLOB. `collation` (MySQL): LIKE follows a + * collation that may ignore case and accents, so a case-sensitive text match + * compares bytes (`CAST(x AS BINARY) LIKE …`). `contains` and friends write + * `lower()` on both sides for an insensitive match where there is no ILIKE. */ - readonly likeIgnoresCase?: boolean + readonly likeCase?: "sensitive" | "ascii-insensitive" | "collation" /** * 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. + * The integer column types (`integer`, `bigint`, …) that compare with a + * fraction numerically (SQLite's type affinity, MySQL's numeric comparison), + * so `integer > 9.75` writes the fraction instead of refusing it through the + * column's whole-number codec. */ - readonly integerComparesFraction?: boolean + readonly fractionComparableIntegers?: ReadonlyArray + /** + * The CAST a value compared with a column of SQL type `sqlType` needs, or + * `undefined` for none: MySQL compares a JSON column with a bound string as + * a JSON string, so the value is `CAST(? AS JSON)`. + */ + readonly comparisonCast?: (sqlType: string) => string | undefined + /** Text concatenation of SQL operands, where it is not `||` (MySQL, whose `||` is OR). */ + readonly concat?: (parts: ReadonlyArray) => string /** 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/sql/text-timestamp.ts b/src/sql/text-timestamp.ts new file mode 100644 index 0000000..745e1ca --- /dev/null +++ b/src/sql/text-timestamp.ts @@ -0,0 +1,119 @@ +// Timestamps stored as `YYYY-MM-DD HH:MM:SS.SSS` UTC text: SQLite's `timestamp` +// and MySQL's `DATETIME(3)`. One factory, so the two differ only in the years +// they cover and how they name themselves in an error. +import { DateTime, Schema, SchemaGetter } from "effect" +import { pgTimestampToIso } from "../pg/types" + +export interface TextTimestampRules { + /** The earliest and latest year the type covers. */ + readonly minYear: number + readonly maxYear: number + /** What the type is called in an error: "SQLite's text timestamps", "MySQL's DATETIME". */ + readonly label: string + /** What a bad value is called: `` `x` is not a timestamp ``. */ + readonly noun: string + /** The schema check titles. */ + readonly title: string +} + +/** The text of an instant already checked to be in range. */ +export const timestampText = (epochMillis: number): string => { + const iso = new Date(epochMillis).toISOString() + return `${iso.slice(0, 10)} ${iso.slice(11, 23)}` +} + +/** + * Whether the calendar date that text starts with exists. `Date.parse` moves an + * impossible one on (`2023-02-30` is March 2), so it is checked before parsing. + */ +const realCalendarDate = (value: string): boolean => { + const date = /^\s*(\d{4})-(\d{2})-(\d{2})/.exec(value) + if (date === null) return true + const [year, month, day] = [Number(date[1]), Number(date[2]), Number(date[3])] + return month >= 1 && month <= 12 && day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate() +} + +export const textTimestamp = (rules: TextTimestampRules) => { + const span = `${String(rules.minYear).padStart(4, "0")}-${rules.maxYear}` + const inRange = (epochMillis: number): boolean => { + const year = new Date(epochMillis).getUTCFullYear() + return year >= rules.minYear && year <= rules.maxYear + } + const rangeMessage = (epochMillis: number) => `year ${new Date(epochMillis).getUTCFullYear()} is outside ${span}, which ${rules.label} cover${rules.label.endsWith("s") ? "" : "s"}` + + /** The text of an instant; throws outside the years the type covers. */ + const text = (epochMillis: number): string => { + if (!inRange(epochMillis)) throw new RangeError(`${new Date(epochMillis).toISOString()} is outside ${span}, which ${rules.label} cover${rules.label.endsWith("s") ? "" : "s"}`) + return timestampText(epochMillis) + } + + // 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: `${rules.title}Range`, + }) + const dateInRange = Schema.makeFilter( + (value: Date) => (Number.isNaN(value.getTime()) ? "an invalid Date" : inRange(value.getTime()) ? undefined : rangeMessage(value.getTime())), + { title: `${rules.title}Range` }, + ) + const isText = Schema.makeFilter( + (value: string) => { + const millis = realCalendarDate(value) ? Date.parse(pgTimestampToIso(value)) : Number.NaN + if (Number.isNaN(millis)) return `\`${value}\` is not ${rules.noun}: expected \`YYYY-MM-DD HH:MM:SS[.SSS]\` or ISO-8601` + return inRange(millis) ? undefined : rangeMessage(millis) + }, + { title: rules.title }, + ) + + /** Reads the text and ISO-8601 (zoneless text is UTC); writes the text. */ + const codec: Schema.Codec = Schema.String.pipe( + Schema.check(isText), + Schema.decodeTo(Schema.DateTimeUtc.pipe(Schema.check(dateTimeInRange)), { + decode: SchemaGetter.transform((value: string) => DateTime.makeUnsafe(pgTimestampToIso(value))), + encode: SchemaGetter.transform((value: DateTime.Utc) => timestampText(DateTime.toEpochMillis(value))), + }), + ) + + /** An in-range `Date`, as a driver sends one. */ + const fromDate: Schema.Codec = Schema.Date.pipe( + Schema.check(dateInRange), + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transform((value: Date) => DateTime.makeUnsafe(value.getTime())), + encode: SchemaGetter.transform((value: DateTime.Utc) => new Date(DateTime.toEpochMillis(value))), + }), + ) + + /** + * What a comparison or param accepts (a `DateTime.Utc`, a `Date`, the + * text), written as the text after `adjust` (truncation to seconds, for + * `param.dateTimeSeconds`). + */ + const literal = (adjust: (epochMillis: number) => number = (millis) => millis): Schema.Codec => + Schema.Union([ + Schema.String.pipe( + Schema.check(isText), + Schema.decodeTo(Schema.DateTimeUtc.pipe(Schema.check(dateTimeInRange)), { + decode: SchemaGetter.transform((value: string) => DateTime.makeUnsafe(pgTimestampToIso(value))), + encode: SchemaGetter.transform((value: DateTime.Utc) => timestampText(adjust(DateTime.toEpochMillis(value)))), + }), + ), + Schema.String.pipe( + Schema.check(Schema.makeFilter((value: string) => (realCalendarDate(value) ? undefined : `\`${value}\` is not a calendar date`), { title: rules.title })), + Schema.decodeTo(Schema.Date.pipe(Schema.check(dateInRange)), { + decode: SchemaGetter.transform((value: string) => new Date(pgTimestampToIso(value))), + encode: SchemaGetter.transform((value: Date) => timestampText(adjust(value.getTime()))), + }), + ), + Schema.String.pipe( + Schema.check(isText), + Schema.decodeTo(Schema.String.pipe(Schema.check(isText)), { + decode: SchemaGetter.transform((value: string) => value), + encode: SchemaGetter.transform((value: string) => timestampText(adjust(Date.parse(pgTimestampToIso(value))))), + }), + ), + ]) as Schema.Codec + + return { text, codec, fromDate, literal } +} + +/** Floors an instant to whole seconds, for `param.dateTimeSeconds`. */ +export const toSeconds = (epochMillis: number): number => Math.floor(epochMillis / 1000) * 1000 diff --git a/src/sqlite/dialect.ts b/src/sqlite/dialect.ts index 886fc8d..dc50528 100644 --- a/src/sqlite/dialect.ts +++ b/src/sqlite/dialect.ts @@ -3,6 +3,7 @@ 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 { toSeconds } from "../sql/text-timestamp" import { SqliteTimestampLiteral, sqliteTimestampLiteral, sqliteTimestampText } from "./timestamp" const hex = (value: Uint8Array): string => @@ -35,9 +36,9 @@ export const sqliteDialect: Dialect<"sqlite"> = { functions: "sqlite", ilike: false, likeEscape: true, - likeIgnoresCase: true, + likeCase: "ascii-insensitive", inexactNumberText: true, - integerComparesFraction: true, + fractionComparableIntegers: ["integer"], quoteIdent: (name) => `"${name.replace(/"/g, '""')}"`, quoteString, literal, @@ -55,7 +56,7 @@ export const sqliteDialect: Dialect<"sqlite"> = { groupByAlias: false, parenthesizeUnionBranches: false, wrapUnionBranches: true, - offsetRequiresLimit: true, + limitForOffset: "-1", returning: true, onConflict: true, onConflictConstraint: false, @@ -65,7 +66,7 @@ export const sqliteDialect: Dialect<"sqlite"> = { }, paramCodecs: { dateTime: SqliteTimestampLiteral, - dateTimeSeconds: sqliteTimestampLiteral((millis) => Math.floor(millis / 1000) * 1000), + dateTimeSeconds: sqliteTimestampLiteral(toSeconds), }, transactions: { support: "full", diff --git a/src/sqlite/timestamp.ts b/src/sqlite/timestamp.ts index ae1a048..14aa306 100644 --- a/src/sqlite/timestamp.ts +++ b/src/sqlite/timestamp.ts @@ -1,6 +1,9 @@ // SQLite timestamp text: the form `SQLite.timestamp` writes and reads. -import { DateTime, Schema, SchemaGetter } from "effect" -import { pgTimestampToIso } from "../pg/types" +import type { DateTime, Schema } from "effect" +import { textTimestamp } from "../sql/text-timestamp" + +// Years SQLite's date functions and the text form cover: four digits, no sign. Past 9999, toISOString writes `+010000-…`. +const sqlite = textTimestamp({ minYear: 0, maxYear: 9999, label: "SQLite's text timestamps", noun: "a timestamp", title: "sqliteTimestamp" }) /** * An instant as SQLite's own text form, `YYYY-MM-DD HH:MM:SS.SSS` in UTC: what @@ -8,79 +11,17 @@ import { pgTimestampToIso } from "../pg/types" * 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" }, -) +export const sqliteTimestampText = sqlite.text /** 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))), - }), -) +export const SqliteTimestamp: Schema.Codec = sqlite.codec /** * 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 = sqlite.literal export const SqliteTimestampLiteral = sqliteTimestampLiteral() diff --git a/tests/__snapshots__/core-sql.test.ts.snap b/tests/__snapshots__/core-sql.test.ts.snap index 71ba73d..e14439e 100644 --- a/tests/__snapshots__/core-sql.test.ts.snap +++ b/tests/__snapshots__/core-sql.test.ts.snap @@ -1518,6 +1518,1602 @@ SELECT } `; +exports[`core SQL (mariadb) > 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 (mariadb) > 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 (mariadb) > 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 (mariadb) > 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 (mariadb) > 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 (mariadb) > 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 (mariadb) > 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 (mariadb) > 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 (mariadb) > group-having 1`] = ` +{ + "parameters": [ + "paid", + "org_1", + ], + "sql": "WITH \`orders\` AS ( + +), +\`customers\` AS ( + +) +SELECT + \`orders\`.\`Customer\` AS \`customer\`, + COUNT(*) AS \`orders\`, + COUNT(CASE WHEN \`orders\`.\`Status\` = ? THEN 1 END) 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 (mariadb) > 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 (mariadb) > 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 (mariadb) > 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 (mariadb) > 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 (mariadb) > left-join-not-null 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\` >= ? + AND \`c\`.\`Tier\` IS NOT NULL + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mariadb) > 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 ? + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mariadb) > 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 ? + AND \`orders\`.\`Note\` NOT LIKE ? + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mariadb) > 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 (mariadb) > locking-update-share 1`] = ` +{ + "parameters": [ + "org_1", + 1, + ], + "sql": "WITH \`orders\` AS ( + +), +\`customers\` AS ( + +) +SELECT + \`orders\`.\`Id\` AS \`id\` + FROM \`orders\` + WHERE \`orders\`.\`OrgId\` = ? + AND \`orders\`.\`Id\` = ? + LOCK IN SHARE MODE NOWAIT", +} +`; + +exports[`core SQL (mariadb) > 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 (mariadb) > 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 (mariadb) > 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 (mariadb) > 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 ? AND \`orders\`.\`Customer\` NOT LIKE ?) + AND (\`orders\`.\`Status\` LIKE ? OR \`orders\`.\`Status\` IN (?)) + AND \`orders\`.\`Id\` NOT IN (?) + AND NOT (\`orders\`.\`Id\` = ?) + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mariadb) > pipe-data-last 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 + LIMIT 2", +} +`; + +exports[`core SQL (mariadb) > route-and-cross-tenant 1`] = ` +{ + "parameters": [], + "sql": "WITH \`orders\` AS ( + +), +\`customers\` AS ( + +) +SELECT + COUNT(*) AS \`orders\` + FROM \`orders\`", +} +`; + +exports[`core SQL (mariadb) > 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 (mariadb) > selected-param 1`] = ` +{ + "parameters": [ + 1, + "org_1", + 1, + ], + "sql": "WITH \`orders\` AS ( + +), +\`customers\` AS ( + +) +SELECT + \`orders\`.\`Id\` AS \`id\`, + CAST(? AS SIGNED) AS \`flagged\` + FROM \`orders\` + WHERE \`orders\`.\`OrgId\` = ? + AND \`orders\`.\`Id\` = ?", +} +`; + +exports[`core SQL (mariadb) > text-match 1`] = ` +{ + "parameters": [ + "%50\\%%", + "a\\_%", + "%\\\\dir", + "%[x]%", + "%o?", + ], + "sql": "WITH \`texts\` AS ( +SELECT 1 AS \`Id\`, CAST('50% off' AS CHAR) AS \`Body\` UNION ALL SELECT 2 AS \`Id\`, CAST('500 off' AS CHAR) AS \`Body\` UNION ALL SELECT 3 AS \`Id\`, CAST('a_b' AS CHAR) AS \`Body\` UNION ALL SELECT 4 AS \`Id\`, CAST('aXb' AS CHAR) AS \`Body\` UNION ALL SELECT 5 AS \`Id\`, CAST('C:\\\\dir' AS CHAR) AS \`Body\` UNION ALL SELECT 6 AS \`Id\`, CAST('C:dir' AS CHAR) AS \`Body\` UNION ALL SELECT 7 AS \`Id\`, CAST('A_B' AS CHAR) AS \`Body\` UNION ALL SELECT 8 AS \`Id\`, CAST('[x]' AS CHAR) AS \`Body\` UNION ALL SELECT 9 AS \`Id\`, CAST('x' AS CHAR) AS \`Body\` UNION ALL SELECT 10 AS \`Id\`, CAST('who?' AS CHAR) AS \`Body\` UNION ALL SELECT 11 AS \`Id\`, CAST('whom' AS CHAR) AS \`Body\` +) +SELECT + \`texts\`.\`Id\` AS \`id\` + FROM \`texts\` + WHERE (CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(? AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(? AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(? AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(? AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(? AS BINARY)) + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mariadb) > text-match-insensitive 1`] = ` +{ + "parameters": [ + "%AXB%", + "A\\_%", + "%\\% OFF", + "%[X]%", + ], + "sql": "WITH \`texts\` AS ( +SELECT 1 AS \`Id\`, CAST('50% off' AS CHAR) AS \`Body\` UNION ALL SELECT 2 AS \`Id\`, CAST('500 off' AS CHAR) AS \`Body\` UNION ALL SELECT 3 AS \`Id\`, CAST('a_b' AS CHAR) AS \`Body\` UNION ALL SELECT 4 AS \`Id\`, CAST('aXb' AS CHAR) AS \`Body\` UNION ALL SELECT 5 AS \`Id\`, CAST('C:\\\\dir' AS CHAR) AS \`Body\` UNION ALL SELECT 6 AS \`Id\`, CAST('C:dir' AS CHAR) AS \`Body\` UNION ALL SELECT 7 AS \`Id\`, CAST('A_B' AS CHAR) AS \`Body\` UNION ALL SELECT 8 AS \`Id\`, CAST('[x]' AS CHAR) AS \`Body\` UNION ALL SELECT 9 AS \`Id\`, CAST('x' AS CHAR) AS \`Body\` UNION ALL SELECT 10 AS \`Id\`, CAST('who?' AS CHAR) AS \`Body\` UNION ALL SELECT 11 AS \`Id\`, CAST('whom' AS CHAR) AS \`Body\` +) +SELECT + \`texts\`.\`Id\` AS \`id\` + FROM \`texts\` + WHERE (lower(\`texts\`.\`Body\`) LIKE lower(?) OR lower(\`texts\`.\`Body\`) LIKE lower(?) OR lower(\`texts\`.\`Body\`) LIKE lower(?) OR lower(\`texts\`.\`Body\`) LIKE lower(?)) + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mariadb) > text-match-param 1`] = ` +{ + "parameters": [ + "0%", + "_b", + "C:\\", + "[x]", + "o?", + ], + "sql": "WITH \`texts\` AS ( +SELECT 1 AS \`Id\`, CAST('50% off' AS CHAR) AS \`Body\` UNION ALL SELECT 2 AS \`Id\`, CAST('500 off' AS CHAR) AS \`Body\` UNION ALL SELECT 3 AS \`Id\`, CAST('a_b' AS CHAR) AS \`Body\` UNION ALL SELECT 4 AS \`Id\`, CAST('aXb' AS CHAR) AS \`Body\` UNION ALL SELECT 5 AS \`Id\`, CAST('C:\\\\dir' AS CHAR) AS \`Body\` UNION ALL SELECT 6 AS \`Id\`, CAST('C:dir' AS CHAR) AS \`Body\` UNION ALL SELECT 7 AS \`Id\`, CAST('A_B' AS CHAR) AS \`Body\` UNION ALL SELECT 8 AS \`Id\`, CAST('[x]' AS CHAR) AS \`Body\` UNION ALL SELECT 9 AS \`Id\`, CAST('x' AS CHAR) AS \`Body\` UNION ALL SELECT 10 AS \`Id\`, CAST('who?' AS CHAR) AS \`Body\` UNION ALL SELECT 11 AS \`Id\`, CAST('whom' AS CHAR) AS \`Body\` +) +SELECT + \`texts\`.\`Id\` AS \`id\` + FROM \`texts\` + WHERE (CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(CONCAT('%', replace(replace(replace(?, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_'), '%') AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(CONCAT('%', replace(replace(replace(?, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_')) AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(CONCAT(replace(replace(replace(?, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_'), '%') AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(CONCAT('%', replace(replace(replace(?, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_'), '%') AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(CONCAT('%', replace(replace(replace(?, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_')) AS BINARY)) + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mariadb) > 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 (mariadb) > 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\`", +} +`; + +exports[`core SQL (mysql) > 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 (mysql) > 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 (mysql) > 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 (mysql) > 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 (mysql) > 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 (mysql) > 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 (mysql) > 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 (mysql) > 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 (mysql) > group-having 1`] = ` +{ + "parameters": [ + "paid", + "org_1", + ], + "sql": "WITH \`orders\` AS ( + +), +\`customers\` AS ( + +) +SELECT + \`orders\`.\`Customer\` AS \`customer\`, + COUNT(*) AS \`orders\`, + COUNT(CASE WHEN \`orders\`.\`Status\` = ? THEN 1 END) 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 (mysql) > 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 (mysql) > 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 (mysql) > 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 (mysql) > 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 (mysql) > left-join-not-null 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\` >= ? + AND \`c\`.\`Tier\` IS NOT NULL + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mysql) > 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 ? + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mysql) > 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 ? + AND \`orders\`.\`Note\` NOT LIKE ? + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mysql) > 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 (mysql) > locking-update-share 1`] = ` +{ + "parameters": [ + "org_1", + 1, + ], + "sql": "WITH \`orders\` AS ( + +), +\`customers\` AS ( + +) +SELECT + \`orders\`.\`Id\` AS \`id\` + FROM \`orders\` + WHERE \`orders\`.\`OrgId\` = ? + AND \`orders\`.\`Id\` = ? + FOR SHARE NOWAIT", +} +`; + +exports[`core SQL (mysql) > 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 (mysql) > 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 (mysql) > 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 (mysql) > 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 ? AND \`orders\`.\`Customer\` NOT LIKE ?) + AND (\`orders\`.\`Status\` LIKE ? OR \`orders\`.\`Status\` IN (?)) + AND \`orders\`.\`Id\` NOT IN (?) + AND NOT (\`orders\`.\`Id\` = ?) + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mysql) > pipe-data-last 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 + LIMIT 2", +} +`; + +exports[`core SQL (mysql) > route-and-cross-tenant 1`] = ` +{ + "parameters": [], + "sql": "WITH \`orders\` AS ( + +), +\`customers\` AS ( + +) +SELECT + COUNT(*) AS \`orders\` + FROM \`orders\`", +} +`; + +exports[`core SQL (mysql) > 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 (mysql) > selected-param 1`] = ` +{ + "parameters": [ + 1, + "org_1", + 1, + ], + "sql": "WITH \`orders\` AS ( + +), +\`customers\` AS ( + +) +SELECT + \`orders\`.\`Id\` AS \`id\`, + CAST(? AS SIGNED) AS \`flagged\` + FROM \`orders\` + WHERE \`orders\`.\`OrgId\` = ? + AND \`orders\`.\`Id\` = ?", +} +`; + +exports[`core SQL (mysql) > text-match 1`] = ` +{ + "parameters": [ + "%50\\%%", + "a\\_%", + "%\\\\dir", + "%[x]%", + "%o?", + ], + "sql": "WITH \`texts\` AS ( +SELECT 1 AS \`Id\`, CAST('50% off' AS CHAR) AS \`Body\` UNION ALL SELECT 2 AS \`Id\`, CAST('500 off' AS CHAR) AS \`Body\` UNION ALL SELECT 3 AS \`Id\`, CAST('a_b' AS CHAR) AS \`Body\` UNION ALL SELECT 4 AS \`Id\`, CAST('aXb' AS CHAR) AS \`Body\` UNION ALL SELECT 5 AS \`Id\`, CAST('C:\\\\dir' AS CHAR) AS \`Body\` UNION ALL SELECT 6 AS \`Id\`, CAST('C:dir' AS CHAR) AS \`Body\` UNION ALL SELECT 7 AS \`Id\`, CAST('A_B' AS CHAR) AS \`Body\` UNION ALL SELECT 8 AS \`Id\`, CAST('[x]' AS CHAR) AS \`Body\` UNION ALL SELECT 9 AS \`Id\`, CAST('x' AS CHAR) AS \`Body\` UNION ALL SELECT 10 AS \`Id\`, CAST('who?' AS CHAR) AS \`Body\` UNION ALL SELECT 11 AS \`Id\`, CAST('whom' AS CHAR) AS \`Body\` +) +SELECT + \`texts\`.\`Id\` AS \`id\` + FROM \`texts\` + WHERE (CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(? AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(? AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(? AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(? AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(? AS BINARY)) + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mysql) > text-match-insensitive 1`] = ` +{ + "parameters": [ + "%AXB%", + "A\\_%", + "%\\% OFF", + "%[X]%", + ], + "sql": "WITH \`texts\` AS ( +SELECT 1 AS \`Id\`, CAST('50% off' AS CHAR) AS \`Body\` UNION ALL SELECT 2 AS \`Id\`, CAST('500 off' AS CHAR) AS \`Body\` UNION ALL SELECT 3 AS \`Id\`, CAST('a_b' AS CHAR) AS \`Body\` UNION ALL SELECT 4 AS \`Id\`, CAST('aXb' AS CHAR) AS \`Body\` UNION ALL SELECT 5 AS \`Id\`, CAST('C:\\\\dir' AS CHAR) AS \`Body\` UNION ALL SELECT 6 AS \`Id\`, CAST('C:dir' AS CHAR) AS \`Body\` UNION ALL SELECT 7 AS \`Id\`, CAST('A_B' AS CHAR) AS \`Body\` UNION ALL SELECT 8 AS \`Id\`, CAST('[x]' AS CHAR) AS \`Body\` UNION ALL SELECT 9 AS \`Id\`, CAST('x' AS CHAR) AS \`Body\` UNION ALL SELECT 10 AS \`Id\`, CAST('who?' AS CHAR) AS \`Body\` UNION ALL SELECT 11 AS \`Id\`, CAST('whom' AS CHAR) AS \`Body\` +) +SELECT + \`texts\`.\`Id\` AS \`id\` + FROM \`texts\` + WHERE (lower(\`texts\`.\`Body\`) LIKE lower(?) OR lower(\`texts\`.\`Body\`) LIKE lower(?) OR lower(\`texts\`.\`Body\`) LIKE lower(?) OR lower(\`texts\`.\`Body\`) LIKE lower(?)) + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mysql) > text-match-param 1`] = ` +{ + "parameters": [ + "0%", + "_b", + "C:\\", + "[x]", + "o?", + ], + "sql": "WITH \`texts\` AS ( +SELECT 1 AS \`Id\`, CAST('50% off' AS CHAR) AS \`Body\` UNION ALL SELECT 2 AS \`Id\`, CAST('500 off' AS CHAR) AS \`Body\` UNION ALL SELECT 3 AS \`Id\`, CAST('a_b' AS CHAR) AS \`Body\` UNION ALL SELECT 4 AS \`Id\`, CAST('aXb' AS CHAR) AS \`Body\` UNION ALL SELECT 5 AS \`Id\`, CAST('C:\\\\dir' AS CHAR) AS \`Body\` UNION ALL SELECT 6 AS \`Id\`, CAST('C:dir' AS CHAR) AS \`Body\` UNION ALL SELECT 7 AS \`Id\`, CAST('A_B' AS CHAR) AS \`Body\` UNION ALL SELECT 8 AS \`Id\`, CAST('[x]' AS CHAR) AS \`Body\` UNION ALL SELECT 9 AS \`Id\`, CAST('x' AS CHAR) AS \`Body\` UNION ALL SELECT 10 AS \`Id\`, CAST('who?' AS CHAR) AS \`Body\` UNION ALL SELECT 11 AS \`Id\`, CAST('whom' AS CHAR) AS \`Body\` +) +SELECT + \`texts\`.\`Id\` AS \`id\` + FROM \`texts\` + WHERE (CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(CONCAT('%', replace(replace(replace(?, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_'), '%') AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(CONCAT('%', replace(replace(replace(?, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_')) AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(CONCAT(replace(replace(replace(?, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_'), '%') AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(CONCAT('%', replace(replace(replace(?, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_'), '%') AS BINARY) OR CAST(\`texts\`.\`Body\` AS BINARY) LIKE CAST(CONCAT('%', replace(replace(replace(?, '\\\\', '\\\\\\\\'), '%', '\\\\%'), '_', '\\\\_')) AS BINARY)) + ORDER BY \`id\` ASC", +} +`; + +exports[`core SQL (mysql) > union 1`] = ` +{ + "parameters": [ + "org_1", + "paid", + "org_1", + "open", + "org_1", + "void", + ], + "sql": "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 +) +UNION ALL +( +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 +) +UNION ALL +( +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\` +ORDER BY \`total\` DESC +LIMIT 2 +OFFSET 1", +} +`; + +exports[`core SQL (mysql) > 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 (postgres) > and-or 1`] = ` { "parameters": [ @@ -2013,6 +3609,27 @@ SELECT } `; +exports[`core SQL (postgres) > locking-update-share 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 SHARE NOWAIT", +} +`; + exports[`core SQL (postgres) > null-and-range 1`] = ` { "parameters": [ diff --git a/tests/core-cases.ts b/tests/core-cases.ts index 71063aa..de25401 100644 --- a/tests/core-cases.ts +++ b/tests/core-cases.ts @@ -9,10 +9,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" +import * as MySQL from "@maple-dev/effect-orm/mysql" -export type DialectName = "clickhouse" | "postgres" | "sqlite" | "d1" +export type DialectName = "clickhouse" | "postgres" | "sqlite" | "d1" | "mysql" | "mariadb" /** ClickHouse runs twice: `join_use_nulls=0` fills a missing join row with defaults. */ -export type Target = "clickhouse" | "clickhouse-join-nulls" | "postgres" | "sqlite" | "d1" +export type Target = "clickhouse" | "clickhouse-join-nulls" | "postgres" | "sqlite" | "d1" | "mysql" | "mariadb" type Col = CH.CHType type Orders = { @@ -70,21 +71,22 @@ interface FixtureColumn { readonly name: string readonly clickhouse: string readonly postgres: string + readonly mysql: string } const fixtureColumns = { orders: [ - { name: "OrgId", clickhouse: "String", postgres: "text" }, - { name: "Id", clickhouse: "UInt32", postgres: "int4" }, - { name: "Customer", clickhouse: "String", postgres: "text" }, - { name: "Amount", clickhouse: "Int64", postgres: "int8" }, - { name: "Status", clickhouse: "String", postgres: "text" }, - { name: "Note", clickhouse: "Nullable(String)", postgres: "text" }, - { name: "Created", clickhouse: "DateTime64(3, 'UTC')", postgres: "timestamptz" }, + { name: "OrgId", clickhouse: "String", postgres: "text", mysql: "CHAR" }, + { name: "Id", clickhouse: "UInt32", postgres: "int4", mysql: "SIGNED" }, + { name: "Customer", clickhouse: "String", postgres: "text", mysql: "CHAR" }, + { name: "Amount", clickhouse: "Int64", postgres: "int8", mysql: "SIGNED" }, + { name: "Status", clickhouse: "String", postgres: "text", mysql: "CHAR" }, + { name: "Note", clickhouse: "Nullable(String)", postgres: "text", mysql: "CHAR" }, + { name: "Created", clickhouse: "DateTime64(3, 'UTC')", postgres: "timestamptz", mysql: "DATETIME(3)" }, ], customers: [ - { name: "OrgId", clickhouse: "String", postgres: "text" }, - { name: "Name", clickhouse: "String", postgres: "text" }, - { name: "Tier", clickhouse: "String", postgres: "text" }, + { name: "OrgId", clickhouse: "String", postgres: "text", mysql: "CHAR" }, + { name: "Name", clickhouse: "String", postgres: "text", mysql: "CHAR" }, + { name: "Tier", clickhouse: "String", postgres: "text", mysql: "CHAR" }, ], } satisfies Record @@ -123,6 +125,12 @@ export const fixtureSql = (dialect: DialectName, table: keyof typeof fixtureColu 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})` } + if (dialect === "mysql" || dialect === "mariadb") { + // MySQL's VALUES rows are `ROW(…)` and MariaDB's are not; UNION ALL reads the same on both. + return fixtureRows[table] + .map((row) => `SELECT ${row.map((value, i) => `CAST(${sqlValue(value)} AS ${columns[i]!.mysql}) AS \`${columns[i]!.name}\``).join(", ")}`) + .join(" UNION ALL ") + } 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})` @@ -240,11 +248,56 @@ export const d1Context: CoreContext = { compileUnion: (union, params = {}) => SQLite.compileUnionUnsafe(union, params, { dialect: SQLite.d1Dialect }), } +const mysqlTables = { + orders: MySQL.table( + "orders", + { + OrgId: MySQL.text, + Id: MySQL.int, + Customer: MySQL.text, + Amount: MySQL.bigint, + Status: MySQL.text, + Note: MySQL.nullable(MySQL.text), + Created: MySQL.datetime, + }, + { tenantColumn: "OrgId" }, + ), + customers: MySQL.table("customers", { OrgId: MySQL.text, Name: MySQL.text, Tier: MySQL.text }, { tenantColumn: "OrgId" }), +} + +export const mysqlContext: CoreContext = { + dialect: "mysql", + ...mysqlTables, + from: withFixtures("mysql"), + types: { text: MySQL.text, int: MySQL.bigint }, + fn: { + count: () => MySQL.count(), + countIf: (condition) => MySQL.countIf(condition), + sum: (expr) => MySQL.sum(expr), + min: (expr) => MySQL.min(expr), + max: (expr) => MySQL.max(expr), + coalesce: (expr, fallback) => MySQL.coalesce(expr, fallback), + }, + compile: (query, params = {}) => MySQL.compileUnsafe(query, params), + compileUnion: (union, params = {}) => MySQL.compileUnionUnsafe(union, params), +} + +/** MariaDB runs MySQL's SQL, with its own row-lock syntax. */ +export const mariadbContext: CoreContext = { + ...mysqlContext, + dialect: "mariadb", + from: withFixtures("mariadb"), + compile: (query, params = {}) => MySQL.compileUnsafe(query, params, { dialect: MySQL.mariadbDialect }), + compileUnion: (union, params = {}) => MySQL.compileUnionUnsafe(union, params, { dialect: MySQL.mariadbDialect }), +} + export const contexts: Record = { clickhouse: clickhouseContext, postgres: postgresContext, sqlite: sqliteContext, d1: d1Context, + mysql: mysqlContext, + mariadb: mariadbContext, } // Cases @@ -285,17 +338,22 @@ const texts = (ctx: CoreContext) => { ? CH.table("texts", { external: true, columns: { Id: CH.uint32, Body: CH.string } }) : ctx.dialect === "postgres" ? PG.table("texts", { external: true, columns: { Id: PG.int4, Body: PG.text } }) - : SQLite.table("texts", { external: true, columns: { Id: SQLite.integer, Body: SQLite.text } }) - // A ClickHouse string literal escapes a backslash; a Postgres or SQLite one does not. + : ctx.dialect === "mysql" || ctx.dialect === "mariadb" + ? MySQL.table("texts", { Id: MySQL.int, Body: MySQL.text }) + : SQLite.table("texts", { external: true, columns: { Id: SQLite.integer, Body: SQLite.text } }) + const mysql = ctx.dialect === "mysql" || ctx.dialect === "mariadb" + // A ClickHouse or MySQL string literal escapes a backslash; a Postgres or SQLite one does not. const literal = (text: string) => - `'${(ctx.dialect === "clickhouse" ? text.replaceAll("\\", "\\\\") : text).replaceAll("'", "''")}'` + `'${(ctx.dialect === "clickhouse" || mysql ? text.replaceAll("\\", "\\\\") : text).replaceAll("'", "''")}'` const tuples = textRows.map(([id, body]) => `(${id}, ${literal(body)})`).join(", ") const body = ctx.dialect === "clickhouse" ? `SELECT * FROM values('Id UInt32, Body String', ${tuples})` - : ctx.dialect === "sqlite" || ctx.dialect === "d1" - ? `SELECT column1 AS "Id", column2 AS "Body" FROM (VALUES ${tuples})` - : `SELECT CAST("c0" AS int4) AS "Id", CAST("c1" AS text) AS "Body" FROM (VALUES ${tuples}) AS v("c0", "c1")` + : mysql + ? textRows.map(([id, body]) => `SELECT ${id} AS \`Id\`, CAST(${literal(body)} AS CHAR) AS \`Body\``).join(" UNION ALL ") + : ctx.dialect === "sqlite" || ctx.dialect === "d1" + ? `SELECT column1 AS "Id", column2 AS "Body" FROM (VALUES ${tuples})` + : `SELECT CAST("c0" AS int4) AS "Id", CAST("c1" AS text) AS "Body" FROM (VALUES ${tuples}) AS v("c0", "c1")` return CH.from(table as CH.Table<"texts", { readonly Id: Col; readonly Body: Col }>).withCTE("texts", body) } @@ -367,7 +425,7 @@ export const coreCases: readonly CoreCase[] = [ org, ), expected: [{ id: 1 }, { id: 2 }], - rejects: { sqlite: /ILIKE/, d1: /ILIKE/ }, + rejects: { sqlite: /ILIKE/, d1: /ILIKE/, mysql: /ILIKE/, mariadb: /ILIKE/ }, }, { id: "like-escape", @@ -414,6 +472,9 @@ export const coreCases: readonly CoreCase[] = [ 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 }], + // MySQL's `/` is DECIMAL division, four places past the dividend's. + mysql: [{ added: 8, subtracted: 6, multiplied: 14, remainder: 1, divided: 3.5 }], + mariadb: [{ added: 8, subtracted: 6, multiplied: 14, remainder: 1, divided: 3.5 }], }, }, { @@ -768,7 +829,7 @@ export const coreCases: readonly CoreCase[] = [ id: "format", covers: q("format"), format: "JSON", - rejects: { postgres: /no FORMAT clause/, sqlite: /no FORMAT clause/, d1: /no FORMAT clause/ }, + rejects: { postgres: /no FORMAT clause/, sqlite: /no FORMAT clause/, d1: /no FORMAT clause/, mysql: /no FORMAT clause/, mariadb: /no FORMAT clause/ }, build: (ctx) => ctx.compile( orgOrders(ctx) @@ -783,7 +844,7 @@ export const coreCases: readonly CoreCase[] = [ id: "union-format", covers: u("format"), format: "JSON", - rejects: { postgres: /no FORMAT clause/, sqlite: /no FORMAT clause/, d1: /no FORMAT clause/ }, + rejects: { postgres: /no FORMAT clause/, sqlite: /no FORMAT clause/, d1: /no FORMAT clause/, mysql: /no FORMAT clause/, mariadb: /no FORMAT clause/ }, build: (ctx) => { const one = (id: number) => orgOrders(ctx) @@ -844,7 +905,7 @@ export const coreCases: readonly CoreCase[] = [ .orderBy(["customer", "asc"], ["id", "desc"]), org, ), - rejects: { sqlite: /DISTINCT ON/, d1: /DISTINCT ON/ }, + rejects: { sqlite: /DISTINCT ON/, d1: /DISTINCT ON/, mysql: /DISTINCT ON/, mariadb: /DISTINCT ON/ }, expected: [ { customer: "acme", id: 2 }, { customer: "globex", id: 3 }, @@ -861,7 +922,7 @@ export const coreCases: readonly CoreCase[] = [ { id: "locking", covers: q("forUpdate", "forNoKeyUpdate", "forShare", "forKeyShare"), - rejects: { clickhouse: /no row locks/, sqlite: /no row locks/, d1: /no row locks/ }, + rejects: { clickhouse: /no row locks/, sqlite: /no row locks/, d1: /no row locks/, mysql: /FOR NO KEY UPDATE/, mariadb: /FOR NO KEY UPDATE/ }, build: (ctx) => { // Each strength compiles; the one sent is FOR UPDATE SKIP LOCKED. const base = orgOrders(ctx).select(($) => ({ id: $.Id })).where(($) => [$.Id.eq(1)]) @@ -870,6 +931,18 @@ export const coreCases: readonly CoreCase[] = [ }, expected: [{ id: 1 }], }, + { + id: "locking-update-share", + covers: q("forUpdate", "forShare"), + rejects: { clickhouse: /no row locks/, sqlite: /no row locks/, d1: /no row locks/ }, + build: (ctx) => { + // The two strengths MySQL and MariaDB have; the one sent is FOR SHARE NOWAIT. + const base = orgOrders(ctx).select(($) => ({ id: $.Id })).where(($) => [$.Id.eq(1)]) + ctx.compile(base.forUpdate({ skipLocked: true }), org) + return ctx.compile(base.forShare({ noWait: true }), org) + }, + expected: [{ id: 1 }], + }, { id: "text-match", covers: e("contains", "startsWith", "endsWith"), @@ -942,4 +1015,6 @@ export const coreSkips: Record> = { postgres: {}, sqlite: {}, d1: {}, + mysql: {}, + mariadb: {}, } diff --git a/tests/core-sql.test.ts b/tests/core-sql.test.ts index e430d37..5635107 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", "sqlite", "d1"] as const) { +for (const dialect of ["clickhouse", "postgres", "sqlite", "d1", "mysql", "mariadb"] as const) { describe(`core SQL (${dialect})`, () => { for (const fixture of coreCases) { it(fixture.id, () => { diff --git a/tests/core.mysql.test.ts b/tests/core.mysql.test.ts new file mode 100644 index 0000000..4bc558b --- /dev/null +++ b/tests/core.mysql.test.ts @@ -0,0 +1,27 @@ +// The core builder suite on MySQL 8.4 and MariaDB 11.4, live when +// EFFECT_ORM_MYSQL_URL / EFFECT_ORM_MARIADB_URL are set (CI runs both). +import { afterAll, describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { contexts, coreCases, coreSkips, expectedFor } from "./core-cases" +import { mariadbServer, mysqlDatabase, mysqlServer } from "./mysql-support" + +const servers = { mysql: mysqlServer, mariadb: mariadbServer } +const databases = { mysql: mysqlDatabase(mysqlServer), mariadb: mysqlDatabase(mariadbServer) } +afterAll(() => Promise.all(Object.values(databases).map((db) => db.close()))) + +for (const dialect of ["mysql", "mariadb"] as const) { + describe.skipIf(servers[dialect].url === undefined)(`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/clickhouse.ts b/tests/fuzz/dialects/clickhouse.ts index 4225924..32d11c0 100644 --- a/tests/fuzz/dialects/clickhouse.ts +++ b/tests/fuzz/dialects/clickhouse.ts @@ -60,6 +60,7 @@ const runClickHouse = async (compiled: CH.CompiledQuery, settings: Record = [clickhouse, postgres, sqlite, d1] +export const dialects: ReadonlyArray = [clickhouse, postgres, sqlite, d1, mysql, mariadb] /** 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/mysql.ts b/tests/fuzz/dialects/mysql.ts new file mode 100644 index 0000000..b2e22c4 --- /dev/null +++ b/tests/fuzz/dialects/mysql.ts @@ -0,0 +1,177 @@ +// MySQL 8.4 and MariaDB 11.4: live only when EFFECT_ORM_MYSQL_URL / +// EFFECT_ORM_MARIADB_URL are set. Both run MySQL's SQL through mysql2 with a +// case-sensitive, no-pad collation (see ../../mysql-support.ts), so text +// compares by code point as on the other targets. +import fc from "fast-check" +import { Arbitrary, Schema } from "effect" +import * as MySQL from "@maple-dev/effect-orm/mysql" +import { mariadbContext, mysqlContext } from "../../core-cases" +import { mariadbServer, mysqlDatabase, mysqlServer, type MysqlServer } from "../../mysql-support" +import { dateTimeWire, exactDouble, finite, int32, jsonPayload, jsonPayloadSchema, 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", + // `/` is DECIMAL division: four places past the dividend's, nine kept internally. + integerDivision: "decimal", + // MOD truncates towards zero, as JS `%` does, on integers and on DECIMALs. + quotients: "internal", + modulo: "truncate", + // NULL is MySQL's smallest value. + nulls: { asc: "first", desc: "last" }, + emptyAggregate: "null", + // Under the binary collation the targets use (docs/mysql.md). + likeIgnoresCase: false, +} + +// Literals + +const strings: StringRules = { nul: true, leadingBom: true } +const MIN_DT = Date.UTC(1000, 0, 1) +const MAX_DT = Date.UTC(9999, 11, 31, 23, 59, 59, 999) + +/** A MySQL string literal for raw reference SQL: the server reads `\` as an escape. */ +const sqlText = (text: string) => `'${text.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\0/g, "\\0")}'` + +/** The reference row, read from JSON by the server's own JSON functions rather than written by the builder. */ +const jsonSource = (read: (extracted: string) => string) => (json: string) => + `SELECT ${read(`JSON_EXTRACT(${sqlText(json)}, '$.v')`)} AS \`v\`` +const unquoted = (cast: string) => jsonSource((v) => `CAST(JSON_UNQUOTE(${v}) AS ${cast})`) + +/** A JSON null as a typed SQL NULL (JSON_UNQUOTE of one is the text `null`). */ +const orNull = (source: (json: string) => string, cast: string) => (json: string, value: unknown) => + value === null ? `SELECT CAST(NULL AS ${cast}) AS \`v\`` : source(json) + +const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("") +/** DECIMAL(18,4) text as MySQL prints it: always four places. */ +const decimalText = fc.bigInt({ min: -(10n ** 18n) + 1n, max: 10n ** 18n - 1n }).map((mantissa) => { + const sign = mantissa < 0n ? "-" : "" + const digits = (mantissa < 0n ? -mantissa : mantissa).toString().padStart(5, "0") + return `${sign}${digits.slice(0, -4)}.${digits.slice(-4)}` +}) +const dateText = fc.integer({ min: MIN_DT, max: Date.UTC(9999, 11, 31) }).map((ms) => new Date(ms).toISOString().slice(0, 10)) + +/** + * MySQL's JSON parser reads a double up to one ULP off (`7.33277286497045e+62` + * comes back `…044e+62`), on every path: a JSON column, CAST, JSON_EXTRACT. + * The server's limit, not the builder's: JSON numbers compare at 12 significant digits, and a tiny double can be several ULPs off. + */ +const jsonAt12Digits = (value: unknown): unknown => { + if (value === null || typeof value !== "object" || !("values" in value)) return value + const payload = value as { values: ReadonlyArray } + return { ...payload, values: payload.values.map((n) => (n === null ? n : Number(n.toPrecision(12)))) } +} + +/** + * mysql2 with `supportBigNumbers` (as `@effect/sql-mysql2` sets it) reads a + * JSON integer past 2^53 as a string, so a payload number decoded as `number` + * must be below it (docs/mysql.md). Every double from 2^53 up is such an integer. + */ +const jsonDriverNumbers = jsonPayload(strings).pipe( + Arbitrary.filter((payload) => payload.values.every((n) => n === null || Math.abs(n) < 2 ** 53)), +) + +const literals = (dialect: string): ReadonlyArray => { + const nasty = nastyString(strings) + const literal = ( + id: string, + type: MySQL.MysqlType, + value: LiteralType["value"], + source: (json: string, value: unknown) => string, + options: { wire?: (value: unknown) => unknown; comparable?: boolean; normalizeValue?: (value: unknown) => unknown } = {}, + ): LiteralType => ({ + id: `${dialect}:${id}`, + type, + value, + wire: options.wire ?? ((v) => v), + source, + comparable: options.comparable ?? true, + normalizeValue: options.normalizeValue, + }) + const text = unquoted("CHAR") + const signed = unquoted("SIGNED") + return [ + literal("text", MySQL.text, nasty, text), + literal("longtext", MySQL.longtext, nasty, text), + literal("varchar(64)", MySQL.varchar(64), nasty, text), + literal("brand(text)", MySQL.brand(MySQL.text, Schema.String.pipe(Schema.brand("FuzzText"))), nasty, text), + literal("custom(int)", MySQL.custom("int", MySQL.int.schema), int32, signed), + literal("tinyint", MySQL.tinyint, fc.integer({ min: -128, max: 127 }), signed), + literal("smallint", MySQL.smallint, fc.integer({ min: -32768, max: 32767 }), signed), + literal("int", MySQL.int, int32, signed), + literal("int unsigned", MySQL.intUnsigned, fc.nat({ max: 2 ** 32 - 1 }), unquoted("UNSIGNED")), + literal("bigint", MySQL.bigint, fc.maxSafeInteger(), signed), + // MySQL's JSON parser reads some doubles one ULP off; the reference is built from the bits. + literal("double", MySQL.double, finite, (_, v) => `SELECT ${exactDouble(v as number, "DOUBLE")} AS \`v\``), + literal("decimal(18,4)", MySQL.decimal(18, 4), decimalText, unquoted("DECIMAL(18,4)"), { wire: (v) => v }), + // The reference value is 0/1, the integer MySQL stores for a boolean. + literal("boolean", MySQL.boolean, Arbitrary.schema(Schema.Boolean), signed, { wire: (v) => (v ? 1 : 0) }), + literal("datetime(3)", MySQL.datetime, fc.integer({ min: MIN_DT, max: MAX_DT }).map(utc), unquoted("DATETIME(3)"), { wire: dateTimeWire(true) }), + literal("date", MySQL.date, dateText, unquoted("DATE")), + literal("blob", MySQL.blob, fc.uint8Array({ maxLength: 8 }), jsonSource((v) => `UNHEX(JSON_UNQUOTE(${v}))`), { wire: (v) => hex(v as Uint8Array) }), + literal("json", MySQL.json(jsonPayloadSchema), jsonDriverNumbers, jsonSource((v) => v), { + comparable: false, + normalizeValue: dialect === "mysql" ? jsonAt12Digits : undefined, + }), + literal("nullable(text)", MySQL.nullable(MySQL.text), nullOr(nasty), orNull(text, "CHAR")), + literal("nullable(bigint)", MySQL.nullable(MySQL.bigint), nullOr(fc.maxSafeInteger()), orNull(signed, "SIGNED")), + ] +} + +/** The query-table form the suites call (`{ external: true, columns }`), on `/mysql`'s `table`. */ +const module = (dialect: MySQL.Dialect<"mysql">): DialectModule => ({ + ...(MySQL as never as DialectModule), + table: (name, definition) => MySQL.table(name, definition.columns, { tenantColumn: definition.tenantColumn }) as never, + compile: (query, params) => MySQL.compile(query, params, { dialect }), + compileUnion: (union, params) => MySQL.compileUnion(union, params, { dialect }), +}) + +const writes = (server: MysqlServer, exec: (sql: string) => Promise): WriteSupport => ({ + insertSelect: true, + update: true, + delete: true, + onConflict: false, + returning: false, + defaultCells: true, + scratch: { + table: (name) => + MySQL.table(name, { Id: MySQL.bigint, OrgId: MySQL.text, Name: MySQL.text, Note: MySQL.nullable(MySQL.text), Qty: MySQL.bigint }, { defaults: ["Qty"] }), + create: (name) => [ + `CREATE TABLE \`${name}\` (\`Id\` bigint PRIMARY KEY, \`OrgId\` text NOT NULL, \`Name\` text NOT NULL, \`Note\` text, \`Qty\` bigint NOT NULL DEFAULT 5) DEFAULT CHARSET = utf8mb4 COLLATE = ${server.collation}`, + ], + drop: (name) => `DROP TABLE IF EXISTS \`${name}\``, + exec: async (sql) => { + await exec(sql) + }, + }, +}) + +const fuzzDialect = (server: MysqlServer, dialect: MySQL.Dialect<"mysql">, context: typeof mysqlContext): FuzzDialect => { + const db = mysqlDatabase(server) + const text = mysqlDatabase(server, "text") + return { + name: server.name, + module: module(dialect), + context, + strings, + ilike: false, + checkPlaceholders: (sql, count) => { + const used = sql.split("?").length - 1 + return used === count ? undefined : `${used} placeholders for ${count} parameters` + }, + literals: literals(server.name), + writes: writes(server, (sql) => db.query(sql, [])), + targets: [ + { name: server.name, available: server.url !== undefined, semantics, run: (compiled) => runWith(compiled, db.query) }, + // `disablePreparedStatements`, as Cloudflare Hyperdrive needs: mysql2 writes the values into the SQL, + // and the server prints each value as text, some doubles one ULP off. + { name: `${server.name}-text`, available: server.url !== undefined, semantics, approximateDoubles: true, run: (compiled) => runWith(compiled, text.query) }, + ], + close: async () => { + await Promise.all([db.close(), text.close()]) + }, + } +} + +export const mysql = fuzzDialect(mysqlServer, MySQL.mysqlDialect, mysqlContext) +export const mariadb = fuzzDialect(mariadbServer, MySQL.mariadbDialect, mariadbContext) diff --git a/tests/fuzz/dialects/postgres.ts b/tests/fuzz/dialects/postgres.ts index 0227b35..5da7dcb 100644 --- a/tests/fuzz/dialects/postgres.ts +++ b/tests/fuzz/dialects/postgres.ts @@ -113,6 +113,7 @@ export const postgres: FuzzDialect = { semantics: { missingJoinColumn: "null", integerDivision: "truncate", + quotients: "shown", modulo: "truncate", nulls: { asc: "last", desc: "first" }, emptyAggregate: "null", diff --git a/tests/fuzz/dialects/sqlite.ts b/tests/fuzz/dialects/sqlite.ts index 70aa0ee..4d1a395 100644 --- a/tests/fuzz/dialects/sqlite.ts +++ b/tests/fuzz/dialects/sqlite.ts @@ -6,12 +6,13 @@ import { Arbitrary, 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, jsonPayload, jsonPayloadSchema, nastyString, nullOr, utc, type LiteralType } from "../values" +import { exactDouble, finite, jsonPayload, jsonPayloadSchema, 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", + quotients: "shown", modulo: "integer", // NULL is SQLite's smallest value. nulls: { asc: "first", desc: "last" }, @@ -30,31 +31,6 @@ 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"` @@ -79,7 +55,7 @@ const literals = (dialect: string): ReadonlyArray => { literal("brand(text)", SQLite.brand(SQLite.text, Schema.String.pipe(Schema.brand("FuzzText"))), nasty), literal("custom(text)", SQLite.custom("text", Schema.String), nasty), literal("integer", SQLite.integer, fc.maxSafeInteger()), - literal("real", SQLite.real, finite, { source: (v) => exactReal(v as number) }), + literal("real", SQLite.real, finite, { source: (v) => `SELECT ${exactDouble(v as number, "REAL")} AS "v"` }), literal("boolean", SQLite.boolean, Arbitrary.schema(SQLite.boolean.schema)), // 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. diff --git a/tests/fuzz/dialects/types.ts b/tests/fuzz/dialects/types.ts index 51ef8d8..2d10e5f 100644 --- a/tests/fuzz/dialects/types.ts +++ b/tests/fuzz/dialects/types.ts @@ -45,8 +45,18 @@ export const runWith = async ( export interface Semantics { /** A column of an unmatched LEFT JOIN row: SQL NULL, or the column type's default (`''`, `0`). */ readonly missingJoinColumn: "null" | "default" - /** `/` over two integers: truncating integer division, or float division. */ - readonly integerDivision: "truncate" | "float" + /** + * `/` over two integers: truncating integer division, float division, or + * MySQL's DECIMAL division (four places past the dividend's scale shown, + * nine kept for the rest of the expression). + */ + readonly integerDivision: "truncate" | "float" | "decimal" + /** + * What a comparison or a SUM of a quotient sees: the value it shows, or + * digits kept internally past it (MySQL's DECIMAL, where MySQL and MariaDB + * disagree on `1/3*3 = 1`). The model declines the latter. + */ + readonly quotients: "shown" | "internal" /** * `%`: `truncate` is JS `%` on any operands; `clickhouse` declines what ClickHouse computes its own * way (signed with unsigned, floats); `integer` casts both operands to integers first (SQLite). @@ -66,6 +76,12 @@ export interface FuzzTarget { /** False when the database is not reachable here (an env var is unset): the target's live tests skip. */ readonly available: boolean readonly semantics: Semantics + /** + * Whether a double result can come back one ULP off: MySQL's text protocol + * prints some (`93188483.89857809` reads back `…808`). The literal suite then + * accepts a double within a few ULPs of the expected one on this target. + */ + readonly approximateDoubles?: boolean readonly run: (compiled: CH.CompiledQuery) => Promise } diff --git a/tests/fuzz/fixtures.ts b/tests/fuzz/fixtures.ts index 0bd2012..65651dc 100644 --- a/tests/fuzz/fixtures.ts +++ b/tests/fuzz/fixtures.ts @@ -59,7 +59,10 @@ export const fixtureSource = (dialect: string, data: Fixture, table: keyof Fixtu const select = (row: Order | Customer | undefined) => defs.map(([name, ch, pg]) => { const raw = row === undefined ? null : (row as unknown as Record)[name] const v = name === "Created" && raw !== null ? wireDateTime(raw as number, true) : raw - const type = dialect === "clickhouse" ? ch : dialect === "postgres" ? pg : name === "Id" || name === "Amount" ? "INTEGER" : "TEXT" + const mysql = dialect === "mysql" || dialect === "mariadb" + const type = dialect === "clickhouse" ? ch : dialect === "postgres" ? pg + : mysql ? (name === "Id" || name === "Amount" ? "SIGNED" : name === "Created" ? "DATETIME(3)" : "CHAR") + : name === "Id" || name === "Amount" ? "INTEGER" : "TEXT" // An empty relation still needs valid values for non-nullable ClickHouse casts. const empty = row === undefined && dialect === "clickhouse" ? (name === "Note" ? null : name === "Created" ? "2026-01-01 00:00:00.000" : type.startsWith("U") || type === "Int64" ? 0 : "") : v return `CAST(${value(empty)} AS ${type}) AS "${name}"` @@ -88,7 +91,7 @@ export const fixtureSource = (dialect: string, data: Fixture, table: keyof Fixtu }).join(", ")})`).join(", ") return `SELECT ${defs.map(([name], i) => `column${i + 1} AS "${name}"`).join(", ")} FROM (VALUES ${tuples})` } - return data[table].length === 0 ? `SELECT ${select(undefined)} WHERE 1 = 0` : data[table].map((row) => `SELECT ${select(row)}`).join(" UNION ALL ") + return data[table].length === 0 ? `SELECT ${select(undefined)}${dialect === "mysql" || dialect === "mariadb" ? " FROM DUAL" : ""} WHERE 1 = 0` : data[table].map((row) => `SELECT ${select(row)}`).join(" UNION ALL ") } export const fixtureContext = (dialect: FuzzDialect, data: Fixture): CoreContext => ({ diff --git a/tests/fuzz/known-issues.ts b/tests/fuzz/known-issues.ts index cb553bc..42224bc 100644 --- a/tests/fuzz/known-issues.ts +++ b/tests/fuzz/known-issues.ts @@ -72,6 +72,15 @@ const planIssues: ReadonlyArray> = [{ // not know its server version, so this skips every ClickHouse target: delete it once CI drops 26.2. reason: "ClickHouse 26.2 computes a column % a negative power-of-two constant wrongly", matches: (input) => input.executed && input.target.startsWith("clickhouse") && negativePowerOfTwoModulo(input.plan), +}, { + id: "mariadb-distinct-join-offset", + // Server bug, not the builder's: MariaDB 11.4 counts OFFSET over a join's rows before DISTINCT + // removes their duplicates, so `SELECT DISTINCT o.x FROM o JOIN c … LIMIT 10 OFFSET 1`, one + // distinct row from two joined, returns it. MySQL returns none. The builder could write such a + // query as `SELECT * FROM (SELECT DISTINCT …) d … OFFSET 1`, which MariaDB answers correctly. + reason: "MariaDB applies OFFSET before DISTINCT over a join", + matches: ({ executed, target, plan }) => + executed && target.startsWith("mariadb") && plan.join !== undefined && plan.shape.k === "rows" && plan.shape.distinct && (plan.shape.offset ?? 0) > 0, }] export const knownPlanIssue = (input: PlanInput): string | undefined => firstMatch(planIssues, input) diff --git a/tests/fuzz/literal-inventory.ts b/tests/fuzz/literal-inventory.ts index e7d2bf9..fceee94 100644 --- a/tests/fuzz/literal-inventory.ts +++ b/tests/fuzz/literal-inventory.ts @@ -18,6 +18,11 @@ export const literalCoverage = { text: "text", integer: "integer", real: "real", boolean: "boolean", timestamp: "timestamp", blob: "blob", json: "json", nullable: "nullable(text)", brand: "brand(text)", custom: "custom(text)", }, + mysql: { + text: "text", longtext: "longtext", varchar: "varchar(64)", tinyint: "tinyint", smallint: "smallint", int: "int", + intUnsigned: "int unsigned", bigint: "bigint", double: "double", decimal: "decimal(18,4)", boolean: "boolean", + datetime: "datetime(3)", date: "date", blob: "blob", json: "json", nullable: "nullable(text)", brand: "brand(text)", custom: "custom(int)", + }, } as const export const literalExemptions = { @@ -37,4 +42,10 @@ export const literalExemptions = { sqliteTimestampText: "Timestamp formatting helper, not a descriptor.", timestampNow: "SQL default expression, not a descriptor.", }, + mysql: { + dateTimeNow: "SQL default expression, not a descriptor.", + mysqlDateTimeText: "DATETIME formatting helper, not a descriptor.", + varbinary: "The blob codec under a length-bounded SQL type; blob covers it.", + MysqlInteger: "Wire codec; the integer literal cases exercise it with database-generated wire values.", + }, } as const diff --git a/tests/fuzz/literals.fuzz.test.ts b/tests/fuzz/literals.fuzz.test.ts index 2b7b7a1..940783a 100644 --- a/tests/fuzz/literals.fuzz.test.ts +++ b/tests/fuzz/literals.fuzz.test.ts @@ -46,7 +46,12 @@ const roundTrip = async (dialect: FuzzDialect, target: FuzzTarget, literal: Lite const expected = path === "select" ? { v: value, p: value } : { v: value } const canonical = (row: unknown) => normalize(literal.normalizeValue === undefined ? row : Object.fromEntries(Object.entries(row as Record).map(([key, value]) => [key, literal.normalizeValue!(value)]))) - expect(outcome.rows.map(canonical)).toEqual([canonical(expected)]) + // A target whose doubles can be one ULP off (`approximateDoubles`) gets the expected value for a near one. + const near = (row: unknown) => target.approximateDoubles !== true ? row : Object.fromEntries(Object.entries(row as Record).map(([key, got]) => { + const want = (expected as Record)[key] + return [key, typeof got === "number" && typeof want === "number" && Math.abs(got - want) <= Math.abs(want) * 4 * Number.EPSILON ? want : got] + })) + expect(outcome.rows.map((row) => canonical(near(row)))).toEqual([canonical(expected)]) }) } diff --git a/tests/fuzz/model.ts b/tests/fuzz/model.ts index 4cf1338..1f61dbe 100644 --- a/tests/fuzz/model.ts +++ b/tests/fuzz/model.ts @@ -109,6 +109,10 @@ const evalExpr = (env: Env, row: Row, e: Expr): V => { return evalExpr(env, row, e.e) ?? evalExpr(env, row, e.fallback) case "arith": { if (!parsed.has(e)) return evalExpr(env, row, asParsed(e)) + if (env.sem.integerDivision === "decimal") { + const d = evalDecimal(env, row, e) + return d === null ? null : decimalNumber(d) + } const l = evalExpr(env, row, e.l) as number | null const r = typeof e.r === "number" ? e.r : (evalExpr(env, row, e.r) as number | null) if (l === null || r === null) return null @@ -139,6 +143,72 @@ const evalExpr = (env: Env, row: Row, e: Expr): V => { } } +// MySQL DECIMAL arithmetic, as the server's decimal library does it. An +// integer is exact with no fraction digits. A value is kept to `frac` digits +// internally and shown rounded half away from zero to its `scale`: `/` shows +// four places more than its dividend, `*` the sum of the scales, `+` `-` `%` +// the larger. `+` `-` `*` `%` are exact; `/` truncates its quotient at whole +// 9-digit words past both operands' digits and the four (so `94 / 44` is held +// as 2.136363636). A comparison or a SUM sees the internal digits +// (`Semantics.quotients`); those are declined (`hasDivision`). +interface Decimal { + /** The value times 10^frac. */ + readonly units: bigint + readonly frac: number + readonly scale: number +} + +const hasDivision = (e: Expr): boolean => + e.k === "arith" ? e.op === "div" || hasDivision(e.l) || (typeof e.r !== "number" && hasDivision(e.r)) : e.k === "coalesce" ? hasDivision(e.e) || hasDivision(e.fallback) : false + +const pow10 = (n: number): bigint => 10n ** BigInt(n) +const at = (d: Decimal, frac: number): bigint => d.units * pow10(frac - d.frac) + +const evalDecimal = (env: Env, row: Row, e: Expr | number): Decimal | null => { + if (typeof e === "number") return { units: BigInt(e), frac: 0, scale: 0 } + if (e.k !== "arith") { + const v = evalExpr(env, row, e) + if (v === null) return null + if (typeof v !== "number" || !Number.isInteger(v)) skip("a MySQL DECIMAL operand that is not an integer") + return { units: BigInt(v as number), frac: 0, scale: 0 } + } + // An argument (`a.sub(b.add(1))`) is its own chain: parse it as `evalExpr` does. + if (!parsed.has(e)) return evalDecimal(env, row, asParsed(e)) + const l = evalDecimal(env, row, e.l) + const r = evalDecimal(env, row, e.r) + if (l === null || r === null) return null + const frac = Math.max(l.frac, r.frac) + const scale = Math.max(l.scale, r.scale) + switch (e.op) { + case "add": + return { units: at(l, frac) + at(r, frac), frac, scale } + case "sub": + return { units: at(l, frac) - at(r, frac), frac, scale } + case "mul": + return { units: l.units * r.units, frac: l.frac + r.frac, scale: Math.min(l.scale + r.scale, 30) } + case "div": { + if (r.units === 0n) skip("division by zero") + const digits = Math.ceil((l.frac + r.frac + 4) / 9) * 9 + // (l.units / 10^l.frac) / (r.units / 10^r.frac), truncated at `digits`; BigInt division truncates. + return { units: (l.units * pow10(r.frac + digits)) / (r.units * pow10(l.frac)), frac: digits, scale: Math.min(l.scale + 4, 30) } + } + case "mod": + if (r.units === 0n) skip("division by zero") + // BigInt % truncates and takes the dividend's sign, as MOD does. + return { units: at(l, frac) % at(r, frac), frac, scale } + } +} + +/** The value as the server shows it: rounded half away from zero to its scale. */ +const decimalNumber = (d: Decimal): number => { + const negative = d.units < 0n + const abs = negative ? -d.units : d.units + const shown = d.scale >= d.frac ? abs * pow10(d.scale - d.frac) : (abs + 5n * pow10(d.frac - d.scale - 1)) / pow10(d.frac - d.scale) + const digits = shown.toString().padStart(d.scale + 1, "0") + const text = d.scale === 0 ? digits : `${digits.slice(0, -d.scale)}.${digits.slice(-d.scale)}` + return Number(`${negative ? "-" : ""}${text}`) +} + /** Strings compare by UTF-8 bytes on every target (ClickHouse always, Postgres under the C collation). */ const encoder = new TextEncoder() const compareStrings = (a: string, b: string): number => { @@ -204,6 +274,9 @@ const evalCond = (env: Env, row: Row, c: Cond): Bool => { return c.op === "isNull" ? isNull : !isNull } case "cmp": { + if (env.sem.quotients === "internal" && (hasDivision(c.l) || (c.r.k === "expr" && hasDivision(c.r.e)))) { + skip("MySQL compares a DECIMAL quotient at nine places, not the four it shows") + } const l = evalExpr(env, row, c.l) const r = evalOperand(env, row, c.r) if (l === null || r === null) return null @@ -232,6 +305,7 @@ const evalCond = (env: Env, row: Row, c: Cond): Bool => { return c.op === "notLike" ? !matched : matched } case "between": { + if (env.sem.quotients === "internal" && hasDivision(c.e)) skip("MySQL compares a DECIMAL quotient at nine places, not the four it shows") const e = evalExpr(env, row, c.e) const lo = evalOperand(env, row, c.lo) const hi = evalOperand(env, row, c.hi) @@ -241,6 +315,7 @@ const evalCond = (env: Env, row: Row, c: Cond): Bool => { case "in": { // An empty list is written `1 = 0` / `1 = 1`: constant, whatever the left side is. if (c.values.length === 0) return c.neg + if (env.sem.quotients === "internal" && hasDivision(c.e)) skip("MySQL compares a DECIMAL quotient at nine places, not the four it shows") const e = evalExpr(env, row, c.e) if (e === null) return null const found = c.values.some((v) => compare(e, v) === 0) @@ -295,6 +370,7 @@ const evalAgg = (env: Env, rows: ReadonlyArray, agg: Agg, global: boolean): default: { const values = rows.map((row) => evalExpr(env, row, agg.e)).filter((v): v is number => v !== null) if (values.length === 0) return global && env.sem.emptyAggregate === "unmodelled" ? skip("aggregate over no rows") : null + if (agg.k === "sum" && env.sem.quotients === "internal" && hasDivision(agg.e)) skip("MySQL sums DECIMAL quotients at nine places, not the four each shows") if (agg.k === "sum") return values.reduce((a, b) => a + b, 0) return agg.k === "min" ? Math.min(...values) : Math.max(...values) } diff --git a/tests/fuzz/registry.fuzz.test.ts b/tests/fuzz/registry.fuzz.test.ts index 86b4dba..41c0207 100644 --- a/tests/fuzz/registry.fuzz.test.ts +++ b/tests/fuzz/registry.fuzz.test.ts @@ -10,9 +10,11 @@ import { dialects } from "./dialects" import * as ClickHouseTypes from "../../src/ch/types" import * as PostgresTypes from "../../src/pg/types" import * as SqliteTypes from "../../src/sqlite/types" +import * as MysqlTypes from "../../src/mysql/types" import * as ClickHouse from "@maple-dev/effect-orm/clickhouse" import * as Postgres from "@maple-dev/effect-orm/postgres" import * as Sqlite from "@maple-dev/effect-orm/sqlite" +import * as Mysql from "@maple-dev/effect-orm/mysql" import { literalCoverage, literalExemptions } from "./literal-inventory" const sourceFiles = (dir: string): Array => @@ -37,6 +39,7 @@ for (const [name, types, entry] of [ ["clickhouse", ClickHouseTypes, ClickHouse], ["postgres", PostgresTypes, Postgres], ["sqlite", SqliteTypes, Sqlite], + ["mysql", MysqlTypes, Mysql], ] as const) { it(`every ${name} type export has a literal property or a reason`, () => { const covered = literalCoverage[name] @@ -45,7 +48,7 @@ for (const [name, types, entry] of [ expect([...Object.keys(covered), ...Object.keys(exempt)].sort()).toEqual(publicNames.sort()) expect(Object.keys(covered).filter((key) => key in exempt)).toEqual([]) for (const reason of Object.values(exempt)) expect(reason.trim().length).toBeGreaterThan(0) - for (const dialect of dialects.filter((d) => d.name === name || (name === "sqlite" && d.name === "d1"))) { + for (const dialect of dialects.filter((d) => d.name === name || (name === "sqlite" && d.name === "d1") || (name === "mysql" && d.name === "mariadb"))) { const ids = dialect.literals.map((literal) => literal.id) expect(new Set(ids).size).toBe(ids.length) for (const id of Object.values(covered)) expect(ids).toContain(`${dialect.name}:${id}`) diff --git a/tests/fuzz/regressions.ts b/tests/fuzz/regressions.ts index 3416d64..7c257c7 100644 --- a/tests/fuzz/regressions.ts +++ b/tests/fuzz/regressions.ts @@ -70,10 +70,30 @@ export const pinned: Record> = { ...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 } }], }, + // mysql-order-by-integer-alias: MySQL 8.4 merges a derived table and reads `10 AS e1 … ORDER BY e1` + // as ORDER BY position 10 (unknown column), and `1 AS e1` as the first column, silently. + { + ...rows({ + k: "rows", + select: [["id", { k: "col", col: "Id" }], ["e0", { k: "num", v: 1 }], ["e1", { k: "num", v: 10 }]], + distinct: false, + orderBy: [["e0", "asc"], ["e1", "asc"], ["id", "desc"]], + }), + wrap: "all", + }, ] satisfies ReadonlyArray, // ClickHouse 26.2 must page a DISTINCT query over generated rows correctly. "generated fixtures: query results agree": [{ + // MySQL truncates a quotient at whole 9-digit words: `99 % ((94 / 44) / -2)` is 0.72727274, not …73. + fixture: { orders: [{ OrgId: "org_1", Id: 1, Customer: "acme", Amount: 0, Status: "paid", Note: null, Created: Date.UTC(2026, 0, 1) }], customers: [] }, + plan: rows({ + k: "rows", + select: [["id", { k: "col", col: "Id" }], ["e0", { k: "arith", op: "mod", l: { k: "num", v: 99 }, r: { k: "arith", op: "div", l: { k: "arith", op: "div", l: { k: "num", v: 94 }, r: { k: "num", v: 44 } }, r: -2 } }]], + distinct: false, + orderBy: [], + }), + }, { fixture: { orders: [1, 2].map((Id) => ({ OrgId: "org_1", Id, Customer: "acme", Amount: 0, Status: "paid", Note: null, Created: Date.UTC(2026, 0, 1) })), customers: [], @@ -148,6 +168,16 @@ export const pinned: Record> = { orderBy: [["e0", "asc"], ["id", "asc"]], crossTenant: false, }, + // An argument chain under DECIMAL division: `6 * ((5 - 2) / Id)` is 9 where Id = 2, not `6 * (5 - 2 / Id)`. + { + orgId: "org_1", + branches: [ + rows({ k: "rows", select: [["id", { k: "col", col: "Id" }], ["e0", { k: "arith", op: "mul", l: { k: "num", v: 6 }, r: { k: "arith", op: "div", l: { k: "arith", op: "sub", l: { k: "num", v: 5 }, r: { k: "num", v: 2 } }, r: { k: "col", col: "Id" } } }]], distinct: false, orderBy: [] }), + rows({ k: "rows", select: [["id", { k: "col", col: "Id" }], ["e0", { k: "arith", op: "div", l: { k: "num", v: 38 }, r: { k: "num", v: 10 } }]], distinct: false, orderBy: [] }), + ], + orderBy: [["e0", "asc"], ["id", "asc"]], + crossTenant: false, + }, ] satisfies ReadonlyArray, // Hostile values. diff --git a/tests/fuzz/values.ts b/tests/fuzz/values.ts index 0b8c2af..ed96bd9 100644 --- a/tests/fuzz/values.ts +++ b/tests/fuzz/values.ts @@ -137,3 +137,29 @@ export const jsonPayload = (rules: StringRules) => Arbitrary.schema(jsonPayloadS Arbitrary.filter((value) => wellFormed(value) && [value.message, ...value.meta.tags].every((s) => (rules.nul || !s.includes("\0")) && (rules.leadingBom || !s.startsWith("\ufeff")))), ) + +/** + * A double as SQL that is exact without parsing a decimal: SQLite and MySQL + * read some decimals (`1.23456789e-95`, as a literal or through their JSON + * functions) 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. `cast` is the dialect's double type (`REAL`, `DOUBLE`). + */ +export const exactDouble = (value: number, cast: string): string => { + if (value === 0) return `CAST(0 AS ${cast})` + 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 ${cast})` + while (exponent !== 0) { + const step = Math.min(Math.abs(exponent), 62) + sql += `${exponent > 0 ? " * " : " / "}${2n ** BigInt(step)}` + exponent -= Math.sign(exponent) * step + } + return sql +} diff --git a/tests/mysql-support.ts b/tests/mysql-support.ts new file mode 100644 index 0000000..a5b63ee --- /dev/null +++ b/tests/mysql-support.ts @@ -0,0 +1,54 @@ +// Raw MySQL and MariaDB connections for the core suite and the fuzzer: mysql2 +// with no Effect SqlClient in between, configured as docs/mysql.md tells an +// application to (UTC, `supportBigNumbers`), and statements sent prepared, as +// `@effect/sql-mysql2` sends them by default, or over the text protocol, as it +// does with `disablePreparedStatements` (Cloudflare Hyperdrive). Live only when +// EFFECT_ORM_MYSQL_URL / EFFECT_ORM_MARIADB_URL are set. +import mysql from "mysql2/promise" + +export type RawQuery = (sql: string, parameters: ReadonlyArray) => Promise>> + +export interface MysqlServer { + readonly name: "mysql" | "mariadb" + readonly url: string | undefined + /** + * A case-sensitive collation without PAD SPACE, so text compares by code + * point as on the other dialects ('a' <> 'A', 'a' <> 'a '). docs/mysql.md. + */ + readonly collation: string +} + +export const mysqlServer: MysqlServer = { name: "mysql", url: process.env.EFFECT_ORM_MYSQL_URL, collation: "utf8mb4_0900_bin" } +export const mariadbServer: MysqlServer = { name: "mariadb", url: process.env.EFFECT_ORM_MARIADB_URL, collation: "utf8mb4_nopad_bin" } + +export const mysqlDatabase = ( + server: MysqlServer, + protocol: "prepared" | "text" = "prepared", +): { readonly query: RawQuery; readonly close: () => Promise } => { + let started: Promise | undefined + const start = () => + (started ??= (async () => { + const connection = await mysql.createConnection({ + uri: server.url!, + timezone: "Z", + supportBigNumbers: true, + // The server allows ~16k prepared statements in all; a soak sends far more distinct ones. + maxPreparedStatements: 64, + }) + await connection.query(`SET NAMES utf8mb4 COLLATE ${server.collation}`) + await connection.query("SET time_zone = '+00:00'") + return connection + })()) + return { + query: async (sql, parameters) => { + const connection = await start() + // The text protocol has mysql2 write each value into the SQL, escaped, as the Effect client's `query` path does. + const [rows] = protocol === "prepared" ? await connection.execute(sql, parameters as Array) : await connection.query(sql, parameters as Array) + return Array.isArray(rows) ? (rows as Array>) : [] + }, + close: async () => { + if (started !== undefined) await (await started).end() + started = undefined + }, + } +} diff --git a/tests/package-consumer-declarations.mts b/tests/package-consumer-declarations.mts index 9f03352..0b5aefc 100644 --- a/tests/package-consumer-declarations.mts +++ b/tests/package-consumer-declarations.mts @@ -56,6 +56,10 @@ export const sessionNotNull = CH.from(Events) .select(($) => ({ sessionId: $.s.id })) export const sessionFilter = (accessor: CH.JoinedColumnAccessor) => accessor.s.id.isNotNull() +import * as MySQL from "@maple-dev/effect-orm/mysql" +export const MysqlItems = MySQL.table("mysql_items", { id: MySQL.bigint, label: MySQL.text, at: MySQL.datetime }) +export const mysqlById = MySQL.from(MysqlItems).select().where(($) => [$.id.eq(MySQL.param.int("id"))]) +export const mysqlCompile = () => MySQL.compile(mysqlById, { id: 1 }) 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"))]) diff --git a/tests/package-consumer.mts b/tests/package-consumer.mts index b6ffc29..944852b 100644 --- a/tests/package-consumer.mts +++ b/tests/package-consumer.mts @@ -106,6 +106,14 @@ 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") +const MySQL = await import("@maple-dev/effect-orm/mysql") +const mysqlRows = MySQL.table("consumer_rows", { id: MySQL.bigint, note: MySQL.text }) +const mysqlQuery = MySQL.from(mysqlRows).select().where(($) => [$.id.eq(MySQL.param.int("id"))]) +const mysqlCompiled = await Effect.runPromise(MySQL.compile(mysqlQuery, { id: 1 })) +assert.deepEqual(mysqlCompiled.parameters, [1]) +assert.equal(mysqlCompiled.dialect, "mysql") +assert.match(mysqlCompiled.sql, /`consumer_rows`\.`id` = \?/) +assert.equal(MySQL.compileUnsafe(mysqlQuery, { id: 1 }, { dialect: MySQL.mariadbDialect }).dialect, "mariadb") const sqliteLinks = SQLite.table("consumer_links", { columns: { id: SQLite.text, orgId: SQLite.column(SQLite.nullable(SQLite.text), { name: "org_id" }) }, primaryKey: ["id"], diff --git a/tsdown.config.ts b/tsdown.config.ts index 9a365dc..07d0c21 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ sql: "./src/sql/index.ts", postgres: "./src/postgres.ts", sqlite: "./src/sqlite.ts", + mysql: "./src/mysql.ts", schema: "./src/schema.ts", tinybird: "./src/tinybird.ts", migrate: "./src/migrate.ts",