diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f576664..610dbe9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,14 +11,55 @@ jobs: - uses: actions/checkout@v4 - uses: sqlc-dev/setup-sqlc@v4 with: - sqlc-version: '1.24.0' + sqlc-version: '1.31.1' - uses: actions/setup-node@v4 + with: + node-version: 20 - run: wget https://github.com/bytecodealliance/javy/releases/download/v1.2.0/javy-x86_64-linux-v1.2.0.gz - run: gzip -d javy-x86_64-linux-v1.2.0.gz - run: chmod +x javy-x86_64-linux-v1.2.0 - - run: npm install + - run: npm ci - run: npx tsc --noEmit + - run: npm test - run: npx esbuild --bundle src/app.ts --tree-shaking=true --format=esm --target=es2020 --outfile=out.js - run: ./javy-x86_64-linux-v1.2.0 compile out.js -o examples/plugin.wasm - run: sqlc -f sqlc.dev.yaml diff - working-directory: examples \ No newline at end of file + working-directory: examples + - uses: actions/upload-artifact@v4 + with: + name: plugin.wasm + path: examples/plugin.wasm + + # Generates test/postgres with the database analyzer (the way PostgreSQL + # itself names types) and runs the generated functions with postgres.js. + postgres: + needs: build + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10 + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + steps: + - uses: actions/checkout@v4 + - uses: sqlc-dev/setup-sqlc@v4 + with: + sqlc-version: '1.31.1' + - uses: actions/setup-node@v4 + with: + node-version: 20 + - uses: actions/download-artifact@v4 + with: + name: plugin.wasm + path: examples + - run: npm ci + - run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f test/postgres/schema.sql + - run: sqlc diff + working-directory: test/postgres + - run: npm run test:postgres diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..13753e4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: release +# A tag vX.Y.Z publishes the plugin as a release asset; pin it in sqlc.yaml by +# its URL and the sha256 printed in the release notes. +on: + push: + tags: + - 'v*' +permissions: + contents: write +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: wget https://github.com/bytecodealliance/javy/releases/download/v1.2.0/javy-x86_64-linux-v1.2.0.gz + - run: gzip -d javy-x86_64-linux-v1.2.0.gz + - run: chmod +x javy-x86_64-linux-v1.2.0 + - run: npm ci + - run: npx tsc --noEmit + - run: npm test + - run: npx esbuild --bundle src/app.ts --tree-shaking=true --format=esm --target=es2020 --outfile=out.js + - run: ./javy-x86_64-linux-v1.2.0 compile out.js -o sqlc-gen-typescript.wasm + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + sha=$(sha256sum sqlc-gen-typescript.wasm | cut -d' ' -f1) + url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/sqlc-gen-typescript.wasm" + { + echo '```yaml' + echo 'plugins:' + echo '- name: ts' + echo ' wasm:' + echo " url: $url" + echo " sha256: $sha" + echo '```' + } > notes.md + gh release create "$TAG" sqlc-gen-typescript.wasm --title "$TAG" --notes-file notes.md diff --git a/.gitignore b/.gitignore index cb3c3cb..0a36b7c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ out.js *.wasm javy tests +test/out diff --git a/README.md b/README.md index 5798b73..34e1a68 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,53 @@ # sqlc-gen-typescript +> [!NOTE] +> **Wind-Growth fork** of [sqlc-dev/sqlc-gen-typescript](https://github.com/sqlc-dev/sqlc-gen-typescript) +> (upstream `main`, which is 0.1.3 plus a `DO NOT EDIT` header and a null-row check). +> It fixes the PostgreSQL gaps that made generated code silently wrong when +> sqlc's [database analyzer](https://docs.sqlc.dev/en/latest/howto/generate.html#enhanced-analysis-with-managed-databases) +> is on. The output shape is unchanged: `xArgs` / `xRow` interfaces, camelCase +> fields, `null`, rows returned directly. +> +> - **Type names.** The analyzer names types the way PostgreSQL's `format_type` +> prints them (`integer`, `boolean`, `timestamp(3) with time zone`, +> `numeric(12,2)`); upstream only knew the `pg_type` names (`int4`, `bool`, +> `timestamptz`), so every such parameter and computed column fell back to +> `string`. postgres.js then sends any string given for a boolean parameter as +> `false`. Both spellings now map to the same TypeScript type, for the +> `postgres` and `pg` drivers. +> - **Enums** become string literal unions (`"open" | "closed"`), arrays of them +> included. +> - **Array parameters** (`any(sqlc.arg(ids)::uuid[])`) are typed by their +> element type through the same table, so `integer[]` is `number[]` rather +> than `string[]`; postgres.js serialises a JavaScript array for them. +> - **Date and timestamp parameters** (`postgres` driver) take `Date | string`, +> which is what postgres.js accepts; both go through `new Date(x)`, so a +> timestamp keeps milliseconds only. Cast through text to keep microseconds. +> - **Query text is escaped** in the emitted template literal: a backslash, a +> backtick or `${` in a query reaches the database as written. Upstream turned +> `'\%'` into `'%'` and produced invalid TypeScript for a backtick. +> - Each query file is emitted once rather than once per query. +> +> Not fixed here, because the plugin does not decide it: sqlc's analysis marks +> a `::text`-cast column non-nullable, and a string given to a `jsonb` +> parameter is a JSON string value. +> +> Pin a release (the release notes carry the snippet): +> +> ```yaml +> plugins: +> - name: ts +> wasm: +> url: https://github.com/Wind-Growth/sqlc-gen-typescript/releases/download//sqlc-gen-typescript.wasm +> sha256: +> ``` +> +> Tests: `npm test` (code generator, no database) and `npm run test:postgres` +> (needs `DATABASE_URL` with `test/postgres/schema.sql` applied and +> `examples/plugin.wasm` built; regenerate with `sqlc generate` in +> `test/postgres`). CI runs both against PostgreSQL 17. A `vX.Y.Z` tag publishes +> the WASM as a release asset. + > [!CAUTION] > Here be dragons! This plugin is still in early access. Expect breaking changes, missing functionality, and sub-optimal output. Please report all issues and errors. Good luck! diff --git a/package-lock.json b/package-lock.json index e0e0d13..20fb0a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,13 @@ "license": "ISC", "dependencies": { "@bufbuild/protobuf": "^1.4.2", - "javy": "^0.1.2" + "javy": "^0.1.2", + "pg": "^8.11.3" }, "devDependencies": { + "@types/node": "^20.19.43", "esbuild": "^0.19.5", + "postgres": "^3.4.9", "typescript": "^5.2.2" } }, @@ -374,6 +377,16 @@ "node": ">=12" } }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/esbuild": { "version": "0.19.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.5.tgz", @@ -416,6 +429,157 @@ "resolved": "https://registry.npmjs.org/javy/-/javy-0.1.2.tgz", "integrity": "sha512-z6Z+CV13SXBGxmY5UseWsYNVVR4SWPfOixKGluWi1Dpn594+M2JaGPBrFIIMnFFfi7kJzHCkcTn7CrhJ7JGKsA==" }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -428,6 +592,22 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/package.json b/package.json index d54e070..0dd7564 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,16 @@ "description": "", "main": "app.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "build": "tsc --noEmit && esbuild --bundle src/app.ts --tree-shaking=true --format=esm --target=es2020 --outfile=out.js", + "test": "esbuild --bundle test/codegen.test.ts --platform=node --format=cjs --target=node20 --alias:javy/fs=./test/javy-fs.ts --log-level=warning --outfile=test/out/codegen.test.cjs && node --test test/out/codegen.test.cjs", + "test:postgres": "esbuild --bundle test/postgres/postgres.test.ts --platform=node --format=esm --target=node20 --alias:javy/fs=./test/javy-fs.ts --external:postgres --log-level=warning --outfile=test/out/postgres.test.mjs && node --test test/out/postgres.test.mjs" }, "author": "", "license": "ISC", "devDependencies": { + "@types/node": "^20.19.43", "esbuild": "^0.19.5", + "postgres": "^3.4.9", "typescript": "^5.2.2" }, "dependencies": { diff --git a/src/app.ts b/src/app.ts index e3a077c..4a9c568 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,35 +3,9 @@ // // @ts-expect-error import { readFileSync, writeFileSync, STDIO } from "javy/fs"; -import { - EmitHint, - FunctionDeclaration, - NewLineKind, - TypeNode, - ScriptKind, - ScriptTarget, - SyntaxKind, - Node, - NodeFlags, - createPrinter, - createSourceFile, - factory, -} from "typescript"; -import { - GenerateRequest, - GenerateResponse, - Parameter, - Column, - File, - Query, -} from "./gen/plugin/codegen_pb"; - -import { argName, colName } from "./drivers/utlis"; -import { Driver as Sqlite3Driver } from "./drivers/better-sqlite3"; -import { Driver as PgDriver } from "./drivers/pg"; -import { Driver as PostgresDriver } from "./drivers/postgres"; -import { Mysql2Options, Driver as MysqlDriver } from "./drivers/mysql2"; +import { GenerateRequest, GenerateResponse } from "./gen/plugin/codegen_pb"; +import { codegen } from "./codegen"; // Read input from stdin const input = readInput(); @@ -40,263 +14,12 @@ const result = codegen(input); // Write the result to stdout writeOutput(result); -interface Options { - runtime?: string; - driver?: string; - mysql2?: Mysql2Options -} - -interface Driver { - preamble: (queries: Query[]) => Node[]; - columnType: (c?: Column) => TypeNode; - execDecl: ( - name: string, - text: string, - iface: string | undefined, - params: Parameter[] - ) => Node; - execlastidDecl: ( - name: string, - text: string, - iface: string | undefined, - params: Parameter[] - ) => Node; - manyDecl: ( - name: string, - text: string, - argIface: string | undefined, - returnIface: string, - params: Parameter[], - columns: Column[] - ) => Node; - oneDecl: ( - name: string, - text: string, - argIface: string | undefined, - returnIface: string, - params: Parameter[], - columns: Column[] - ) => Node; -} - -function createNodeGenerator(options: Options): Driver { - switch (options.driver) { - case "mysql2": { - return new MysqlDriver(options.mysql2); - } - case "pg": { - return new PgDriver(); - } - case "postgres": { - return new PostgresDriver(); - } - case "better-sqlite3": { - return new Sqlite3Driver(); - } - } - throw new Error(`unknown driver: ${options.driver}`); -} - -function codegen(input: GenerateRequest): GenerateResponse { - let files = []; - let options: Options = {}; - - if (input.pluginOptions.length > 0) { - const text = new TextDecoder().decode(input.pluginOptions); - options = JSON.parse(text) as Options; - } - - const driver = createNodeGenerator(options); - - // TODO: Verify options, parse them from protobuf honestly - - const querymap = new Map(); - - for (const query of input.queries) { - if (!querymap.has(query.filename)) { - querymap.set(query.filename, []); - } - const qs = querymap.get(query.filename); - qs?.push(query); - } - - for (const [filename, queries] of querymap.entries()) { - const nodes = driver.preamble(queries); - - for (const query of queries) { - const colmap = new Map(); - for (let column of query.columns) { - if (!column.name) { - continue; - } - const count = colmap.get(column.name) || 0; - if (count > 0) { - column.name = `${column.name}_${count + 1}`; - } - colmap.set(column.name, count + 1); - } - - const lowerName = query.name[0].toLowerCase() + query.name.slice(1); - const textName = `${lowerName}Query`; - - nodes.push( - queryDecl( - textName, - `-- name: ${query.name} ${query.cmd} -${query.text}` - ) - ); - - let argIface = undefined; - let returnIface = undefined; - if (query.params.length > 0) { - argIface = `${query.name}Args`; - nodes.push(argsDecl(argIface, driver, query.params)); - } - if (query.columns.length > 0) { - returnIface = `${query.name}Row`; - nodes.push(rowDecl(returnIface, driver, query.columns)); - } - - switch (query.cmd) { - case ":exec": { - nodes.push( - driver.execDecl(lowerName, textName, argIface, query.params) - ); - break; - } - case ":execlastid": { - nodes.push( - driver.execlastidDecl(lowerName, textName, argIface, query.params) - ); - break; - } - case ":one": { - nodes.push( - driver.oneDecl( - lowerName, - textName, - argIface, - returnIface ?? "void", - query.params, - query.columns - ) - ); - break; - } - case ":many": { - nodes.push( - driver.manyDecl( - lowerName, - textName, - argIface, - returnIface ?? "void", - query.params, - query.columns - ) - ); - break; - } - } - if (nodes) { - files.push( - new File({ - name: `${filename.replace(".", "_")}.ts`, - contents: new TextEncoder().encode(printNode(nodes)), - }) - ); - } - } - } - - return new GenerateResponse({ - files: files, - }); -} - // Read input from stdin function readInput(): GenerateRequest { const buffer = readFileSync(STDIO.Stdin); return GenerateRequest.fromBinary(buffer); } -function queryDecl(name: string, sql: string) { - return factory.createVariableStatement( - [factory.createToken(SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - factory.createIdentifier(name), - undefined, - undefined, - factory.createNoSubstitutionTemplateLiteral(sql, sql) - ), - ], - NodeFlags.Const //| NodeFlags.Constant | NodeFlags.Constant - ) - ); -} - -function argsDecl( - name: string, - driver: Driver, - params: Parameter[] -) { - return factory.createInterfaceDeclaration( - [factory.createToken(SyntaxKind.ExportKeyword)], - factory.createIdentifier(name), - undefined, - undefined, - params.map((param, i) => - factory.createPropertySignature( - undefined, - factory.createIdentifier(argName(i, param.column)), - undefined, - driver.columnType(param.column) - ) - ) - ); -} - -function rowDecl( - name: string, - driver: Driver, - columns: Column[] -) { - return factory.createInterfaceDeclaration( - [factory.createToken(SyntaxKind.ExportKeyword)], - factory.createIdentifier(name), - undefined, - undefined, - columns.map((column, i) => - factory.createPropertySignature( - undefined, - factory.createIdentifier(colName(i, column)), - undefined, - driver.columnType(column) - ) - ) - ); -} - -function printNode(nodes: Node[]): string { - // https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API#creating-and-printing-a-typescript-ast - const resultFile = createSourceFile( - "file.ts", - "", - ScriptTarget.Latest, - /*setParentNodes*/ false, - ScriptKind.TS - ); - const printer = createPrinter({ newLine: NewLineKind.LineFeed }); - let output = "// Code generated by sqlc. DO NOT EDIT.\n\n"; - for (let node of nodes) { - output += printer.printNode(EmitHint.Unspecified, node, resultFile); - output += "\n\n"; - } - return output; -} - // Write output to stdout function writeOutput(output: GenerateResponse) { const encodedOutput = output.toBinary(); diff --git a/src/codegen.ts b/src/codegen.ts new file mode 100644 index 0000000..7b6e47c --- /dev/null +++ b/src/codegen.ts @@ -0,0 +1,288 @@ +import { + EmitHint, + FunctionDeclaration, + NewLineKind, + TypeNode, + ScriptKind, + ScriptTarget, + SyntaxKind, + Node, + NodeFlags, + createPrinter, + createSourceFile, + factory, +} from "typescript"; + +import { + GenerateRequest, + GenerateResponse, + Parameter, + Column, + File, + Query, +} from "./gen/plugin/codegen_pb"; + +import { argName, colName, Enums } from "./drivers/utlis"; +import { Driver as Sqlite3Driver } from "./drivers/better-sqlite3"; +import { Driver as PgDriver } from "./drivers/pg"; +import { Driver as PostgresDriver } from "./drivers/postgres"; +import { Mysql2Options, Driver as MysqlDriver } from "./drivers/mysql2"; + +interface Options { + runtime?: string; + driver?: string; + mysql2?: Mysql2Options +} + +interface Driver { + preamble: (queries: Query[]) => Node[]; + columnType: (c?: Column, param?: boolean) => TypeNode; + execDecl: ( + name: string, + text: string, + iface: string | undefined, + params: Parameter[] + ) => Node; + execlastidDecl: ( + name: string, + text: string, + iface: string | undefined, + params: Parameter[] + ) => Node; + manyDecl: ( + name: string, + text: string, + argIface: string | undefined, + returnIface: string, + params: Parameter[], + columns: Column[] + ) => Node; + oneDecl: ( + name: string, + text: string, + argIface: string | undefined, + returnIface: string, + params: Parameter[], + columns: Column[] + ) => Node; +} + +function createNodeGenerator(options: Options, enums: Enums): Driver { + switch (options.driver) { + case "mysql2": { + return new MysqlDriver(options.mysql2); + } + case "pg": { + return new PgDriver(enums); + } + case "postgres": { + return new PostgresDriver(enums); + } + case "better-sqlite3": { + return new Sqlite3Driver(); + } + } + throw new Error(`unknown driver: ${options.driver}`); +} + +export function codegen(input: GenerateRequest): GenerateResponse { + let files = []; + let options: Options = {}; + + if (input.pluginOptions.length > 0) { + const text = new TextDecoder().decode(input.pluginOptions); + options = JSON.parse(text) as Options; + } + + const driver = createNodeGenerator(options, new Enums(input.catalog)); + + // TODO: Verify options, parse them from protobuf honestly + + const querymap = new Map(); + + for (const query of input.queries) { + if (!querymap.has(query.filename)) { + querymap.set(query.filename, []); + } + const qs = querymap.get(query.filename); + qs?.push(query); + } + + for (const [filename, queries] of querymap.entries()) { + const nodes = driver.preamble(queries); + + for (const query of queries) { + const colmap = new Map(); + for (let column of query.columns) { + if (!column.name) { + continue; + } + const count = colmap.get(column.name) || 0; + if (count > 0) { + column.name = `${column.name}_${count + 1}`; + } + colmap.set(column.name, count + 1); + } + + const lowerName = query.name[0].toLowerCase() + query.name.slice(1); + const textName = `${lowerName}Query`; + + nodes.push( + queryDecl( + textName, + `-- name: ${query.name} ${query.cmd} +${query.text}` + ) + ); + + let argIface = undefined; + let returnIface = undefined; + if (query.params.length > 0) { + argIface = `${query.name}Args`; + nodes.push(argsDecl(argIface, driver, query.params)); + } + if (query.columns.length > 0) { + returnIface = `${query.name}Row`; + nodes.push(rowDecl(returnIface, driver, query.columns)); + } + + switch (query.cmd) { + case ":exec": { + nodes.push( + driver.execDecl(lowerName, textName, argIface, query.params) + ); + break; + } + case ":execlastid": { + nodes.push( + driver.execlastidDecl(lowerName, textName, argIface, query.params) + ); + break; + } + case ":one": { + nodes.push( + driver.oneDecl( + lowerName, + textName, + argIface, + returnIface ?? "void", + query.params, + query.columns + ) + ); + break; + } + case ":many": { + nodes.push( + driver.manyDecl( + lowerName, + textName, + argIface, + returnIface ?? "void", + query.params, + query.columns + ) + ); + break; + } + } + } + files.push( + new File({ + name: `${filename.replace(".", "_")}.ts`, + contents: new TextEncoder().encode(printNode(nodes)), + }) + ); + } + + return new GenerateResponse({ + files: files, + }); +} + +// The query is emitted as a template literal. Escape what a template literal +// would interpret, so that `\%`, `\\`, a backtick or `${` reach the database +// exactly as written in the query file. +function templateRaw(text: string): string { + return text + .replace(/\\/g, "\\\\") + .replace(/`/g, "\\`") + .replace(/\$\{/g, "\\${"); +} + +function queryDecl(name: string, sql: string) { + return factory.createVariableStatement( + [factory.createToken(SyntaxKind.ExportKeyword)], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier(name), + undefined, + undefined, + factory.createNoSubstitutionTemplateLiteral(sql, templateRaw(sql)) + ), + ], + NodeFlags.Const //| NodeFlags.Constant | NodeFlags.Constant + ) + ); +} + +function argsDecl( + name: string, + driver: Driver, + params: Parameter[] +) { + return factory.createInterfaceDeclaration( + [factory.createToken(SyntaxKind.ExportKeyword)], + factory.createIdentifier(name), + undefined, + undefined, + params.map((param, i) => + factory.createPropertySignature( + undefined, + factory.createIdentifier(argName(i, param.column)), + undefined, + driver.columnType(param.column, true) + ) + ) + ); +} + +function rowDecl( + name: string, + driver: Driver, + columns: Column[] +) { + return factory.createInterfaceDeclaration( + [factory.createToken(SyntaxKind.ExportKeyword)], + factory.createIdentifier(name), + undefined, + undefined, + columns.map((column, i) => + factory.createPropertySignature( + undefined, + factory.createIdentifier(colName(i, column)), + undefined, + driver.columnType(column) + ) + ) + ); +} + +function printNode(nodes: Node[]): string { + // https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API#creating-and-printing-a-typescript-ast + const resultFile = createSourceFile( + "file.ts", + "", + ScriptTarget.Latest, + /*setParentNodes*/ false, + ScriptKind.TS + ); + const printer = createPrinter({ newLine: NewLineKind.LineFeed }); + let output = "// Code generated by sqlc. DO NOT EDIT.\n\n"; + for (let node of nodes) { + output += printer.printNode(EmitHint.Unspecified, node, resultFile); + output += "\n\n"; + } + return output; +} diff --git a/src/drivers/pg.ts b/src/drivers/pg.ts index 95828cf..082d3a9 100644 --- a/src/drivers/pg.ts +++ b/src/drivers/pg.ts @@ -8,7 +8,7 @@ import { } from "typescript"; import { Parameter, Column, Query } from "../gen/plugin/codegen_pb"; -import { argName, colName } from "./utlis"; +import { argName, colName, enumType, Enums, nullable, postgresTypeName } from "./utlis"; function funcParamsDecl(iface: string | undefined, params: Parameter[]) { let funcParams = [ @@ -45,16 +45,17 @@ function funcParamsDecl(iface: string | undefined, params: Parameter[]) { } export class Driver { + private enums: Enums; + + constructor(enums?: Enums) { + this.enums = enums ?? new Enums(); + } + columnType(column?: Column): TypeNode { if (column === undefined || column.type === undefined) { return factory.createKeywordTypeNode(SyntaxKind.AnyKeyword); } - // Some of the type names have the `pgcatalog.` prefix. Remove this. - let typeName = column.type.name; - const pgCatalog = "pg_catalog."; - if (typeName.startsWith(pgCatalog)) { - typeName = typeName.slice(pgCatalog.length); - } + const typeName = postgresTypeName(column.type); let typ: TypeNode = factory.createKeywordTypeNode(SyntaxKind.StringKeyword); switch (typeName) { case "aclitem": { @@ -186,6 +187,10 @@ export class Driver { // string break; } + case "numeric": { + // string + break; + } case "oid": { typ = factory.createKeywordTypeNode(SyntaxKind.NumberKeyword); break; @@ -319,6 +324,13 @@ export class Driver { // string break; } + default: { + const vals = this.enums.lookup(column.type); + if (vals) { + typ = enumType(vals); + } + break; + } } if (column.isArray || column.arrayDims > 0) { let dims = Math.max(column.arrayDims || 1); @@ -329,10 +341,7 @@ export class Driver { if (column.notNull) { return typ; } - return factory.createUnionTypeNode([ - typ, - factory.createLiteralTypeNode(factory.createNull()), - ]); + return nullable(typ); } preamble(queries: Query[]) { diff --git a/src/drivers/postgres.ts b/src/drivers/postgres.ts index 68d23f2..5a0835a 100644 --- a/src/drivers/postgres.ts +++ b/src/drivers/postgres.ts @@ -7,7 +7,7 @@ import { } from "typescript"; import { Parameter, Column } from "../gen/plugin/codegen_pb"; -import { argName, colName } from "./utlis"; +import { argName, colName, enumType, Enums, nullable, postgresTypeName } from "./utlis"; import { log } from "../logger"; function funcParamsDecl(iface: string | undefined, params: Parameter[]) { @@ -44,17 +44,35 @@ function funcParamsDecl(iface: string | undefined, params: Parameter[]) { return funcParams; } +// postgres.js parses date and timestamp columns into a Date. A parameter +// accepts a Date or a string, both serialised through `new Date(x)`, so a +// date-only string is UTC midnight and a timestamp keeps milliseconds only. +function dateType(param: boolean): TypeNode { + const date = factory.createTypeReferenceNode( + factory.createIdentifier("Date"), + undefined + ); + if (!param) { + return date; + } + return factory.createUnionTypeNode([ + date, + factory.createKeywordTypeNode(SyntaxKind.StringKeyword), + ]); +} + export class Driver { - columnType(column?: Column): TypeNode { + private enums: Enums; + + constructor(enums?: Enums) { + this.enums = enums ?? new Enums(); + } + + columnType(column?: Column, param = false): TypeNode { if (column === undefined || column.type === undefined) { return factory.createKeywordTypeNode(SyntaxKind.AnyKeyword); } - // Some of the type names have the `pgcatalog.` prefix. Remove this. - let typeName = column.type.name; - const pgCatalog = "pg_catalog."; - if (typeName.startsWith(pgCatalog)) { - typeName = typeName.slice(pgCatalog.length); - } + const typeName = postgresTypeName(column.type); let typ: TypeNode = factory.createKeywordTypeNode(SyntaxKind.StringKeyword); switch (typeName) { case "aclitem": { @@ -102,10 +120,7 @@ export class Driver { break; } case "date": { - typ = factory.createTypeReferenceNode( - factory.createIdentifier("Date"), - undefined - ); + typ = dateType(param); break; } case "float4": { @@ -164,6 +179,10 @@ export class Driver { // string break; } + case "numeric": { + // string + break; + } case "oid": { typ = factory.createKeywordTypeNode(SyntaxKind.NumberKeyword); break; @@ -233,17 +252,11 @@ export class Driver { break; } case "timestamp": { - typ = factory.createTypeReferenceNode( - factory.createIdentifier("Date"), - undefined - ); + typ = dateType(param); break; } case "timestamptz": { - typ = factory.createTypeReferenceNode( - factory.createIdentifier("Date"), - undefined - ); + typ = dateType(param); break; } case "tsquery": { @@ -279,7 +292,12 @@ export class Driver { break; } default: { - log(`unknown type ${column.type?.name}`); + const vals = this.enums.lookup(column.type); + if (vals) { + typ = enumType(vals); + } else { + log(`unknown type ${column.type?.name}`); + } break; } } @@ -292,10 +310,7 @@ export class Driver { if (column.notNull) { return typ; } - return factory.createUnionTypeNode([ - typ, - factory.createLiteralTypeNode(factory.createNull()), - ]); + return nullable(typ); } preamble(queries: unknown) { diff --git a/src/drivers/utlis.ts b/src/drivers/utlis.ts index 870b421..7745b72 100644 --- a/src/drivers/utlis.ts +++ b/src/drivers/utlis.ts @@ -1,4 +1,6 @@ -import { Column } from "../gen/plugin/codegen_pb"; +import { factory, isUnionTypeNode, SyntaxKind, TypeNode } from "typescript"; + +import { Catalog, Column, Identifier } from "../gen/plugin/codegen_pb"; // https://stackoverflow.com/questions/40710628/how-to-convert-snake-case-to-camelcase export function fieldName( @@ -22,3 +24,82 @@ export function argName(index: number, column?: Column): string { export function colName(index: number, column?: Column): string { return fieldName("col", index, column); } + +// sqlc's own catalog names PostgreSQL types the way pg_type does ("int4", +// "timestamptz"), but its database analyzer names them the way format_type +// prints them ("integer", "timestamp(3) with time zone", "numeric(12,2)"). +// The drivers' type tables use the pg_type names; map the others onto them. +const postgresTypeNames: { [name: string]: string } = { + smallint: "int2", + integer: "int4", + int: "int4", + bigint: "int8", + real: "float4", + "double precision": "float8", + boolean: "bool", + decimal: "numeric", + character: "bpchar", + "character varying": "varchar", + "bit varying": "varbit", + "timestamp without time zone": "timestamp", + "timestamp with time zone": "timestamptz", + "time without time zone": "time", + "time with time zone": "timetz", +}; + +export function postgresTypeName(type?: Identifier): string { + let name = type?.name ?? ""; + const pgCatalog = "pg_catalog."; + if (name.startsWith(pgCatalog)) { + name = name.slice(pgCatalog.length); + } + // Drop type modifiers: "character varying(255)", "timestamp(3) with time zone". + name = name.replace(/\(\s*\d+(\s*,\s*\d+)?\s*\)/g, "").replace(/\s+/g, " ").trim(); + return postgresTypeNames[name] ?? name; +} + +// Enum types declared in the schema, by "schema.name" and, for the default +// schema, by bare name: the analyzer names a type on the search path without +// its schema, and quotes a name that needs it. +export class Enums { + private vals = new Map(); + + constructor(catalog?: Catalog) { + const defaultSchema = catalog?.defaultSchema || "public"; + for (const schema of catalog?.schemas ?? []) { + for (const e of schema.enums) { + this.vals.set(`${schema.name}.${e.name}`, e.vals); + if (schema.name === defaultSchema) { + this.vals.set(e.name, e.vals); + } + } + } + } + + lookup(type?: Identifier): string[] | undefined { + if (!type) { + return undefined; + } + const name = (type.schema ? `${type.schema}.${type.name}` : type.name).replace(/"/g, ""); + return this.vals.get(name); + } +} + +export function enumType(vals: string[]): TypeNode { + if (vals.length === 0) { + return factory.createKeywordTypeNode(SyntaxKind.StringKeyword); + } + return factory.createUnionTypeNode( + vals.map((val) => factory.createLiteralTypeNode(factory.createStringLiteral(val))) + ); +} + +// `T | null`, flattening a union so that it prints as `Date | string | null` +// rather than `(Date | string) | null`. +export function nullable(typ: TypeNode): TypeNode { + const types = isUnionTypeNode(typ) ? [...typ.types] : [typ]; + return factory.createUnionTypeNode([ + ...types, + factory.createLiteralTypeNode(factory.createNull()), + ]); +} diff --git a/test/codegen.test.ts b/test/codegen.test.ts new file mode 100644 index 0000000..c28e9a6 --- /dev/null +++ b/test/codegen.test.ts @@ -0,0 +1,202 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + createSourceFile, + isNoSubstitutionTemplateLiteral, + isVariableStatement, + ScriptTarget, +} from "typescript"; + +import { codegen } from "../src/codegen"; +import { + Catalog, + Column, + Enum, + GenerateRequest, + Identifier, + Parameter, + Query, + Schema, +} from "../src/gen/plugin/codegen_pb"; + +function generate( + queries: Query[], + catalog?: Catalog, + driver = "postgres" +): string[] { + const response = codegen( + new GenerateRequest({ + catalog, + queries, + pluginOptions: new TextEncoder().encode( + JSON.stringify({ runtime: "node", driver }) + ), + }) + ); + return response.files.map((f) => new TextDecoder().decode(f.contents)); +} + +function column(name: string, type: string, fields: Partial = {}) { + return new Column({ + name, + type: new Identifier({ name: type }), + notNull: true, + ...fields, + }); +} + +function query(fields: Partial) { + return new Query({ name: "Q", cmd: ":one", filename: "query.sql", text: "select 1", ...fields }); +} + +function params(...columns: Column[]) { + return columns.map((c, i) => new Parameter({ number: i + 1, column: c })); +} + +// The cooked text of the exported query constant, as JavaScript reads it. +function queryText(file: string, name: string): string { + const source = createSourceFile("out.ts", file, ScriptTarget.Latest); + for (const statement of source.statements) { + if (!isVariableStatement(statement)) { + continue; + } + const decl = statement.declarationList.declarations[0]; + if (decl.name.getText(source) === name && decl.initializer && isNoSubstitutionTemplateLiteral(decl.initializer)) { + return decl.initializer.text; + } + } + throw new Error(`${name} not found`); +} + +test("analyzer type names map like catalog type names", () => { + // The database analyzer names types with format_type, modifiers included. + const [file] = generate([ + query({ + columns: [ + column("a", "integer"), + column("b", "smallint"), + column("c", "bigint"), + column("d", "boolean"), + column("e", "double precision"), + column("f", "real"), + column("g", "numeric(12,2)"), + column("h", "pg_catalog.timestamptz"), + column("i", "timestamp(3) with time zone"), + column("j", "character varying(40)"), + column("k", "int4"), + column("l", "pg_catalog.bool"), + ], + }), + ]); + assert.match(file, /a: number;/); + assert.match(file, /b: number;/); + assert.match(file, /c: string;/); + assert.match(file, /d: boolean;/); + assert.match(file, /e: number;/); + assert.match(file, /f: number;/); + assert.match(file, /g: string;/); + assert.match(file, /h: Date;/); + assert.match(file, /i: Date;/); + assert.match(file, /j: string;/); + assert.match(file, /k: number;/); + assert.match(file, /l: boolean;/); +}); + +test("parameters are typed by the same table", () => { + const [file] = generate([ + query({ + params: params( + column("count", "integer"), + column("active", "boolean"), + column("maybe", "integer", { notNull: false }) + ), + }), + ]); + assert.match(file, /count: number;/); + assert.match(file, /active: boolean;/); + assert.match(file, /maybe: number \| null;/); +}); + +test("date and timestamp parameters take a Date or a string", () => { + const [file] = generate([ + query({ + params: params( + column("day", "date"), + column("at", "pg_catalog.timestamptz", { notNull: false }) + ), + columns: [column("day", "date")], + }), + ]); + assert.match(file, /interface QArgs \{\n\s+day: Date \| string;\n\s+at: Date \| string \| null;/); + assert.match(file, /interface QRow \{\n\s+day: Date;/); +}); + +test("array parameters and columns", () => { + const [file] = generate([ + query({ + params: params( + column("ids", "uuid", { isArray: true, arrayDims: 1 }), + column("counts", "integer", { isArray: true, arrayDims: 1 }) + ), + columns: [column("grid", "integer", { isArray: true, arrayDims: 2 })], + }), + ]); + assert.match(file, /ids: string\[\];/); + assert.match(file, /counts: number\[\];/); + assert.match(file, /grid: number\[\]\[\];/); +}); + +test("enum types become string literal unions", () => { + const catalog = new Catalog({ + defaultSchema: "public", + schemas: [ + new Schema({ name: "public", enums: [new Enum({ name: "status", vals: ["open", "closed"] })] }), + new Schema({ name: "auth", enums: [new Enum({ name: "level", vals: ["aal1", "aal2"] })] }), + ], + }); + const [file] = generate( + [ + query({ + params: params(column("statuses", "status", { isArray: true, arrayDims: 1 })), + columns: [ + column("status", "status"), + column("maybe", "public.status", { notNull: false }), + column("level", "auth.level"), + column("catalog", "", { type: new Identifier({ schema: "auth", name: "level" }) }), + ], + }), + ], + catalog + ); + assert.match(file, /statuses: \("open" \| "closed"\)\[\];/); + assert.match(file, /status: "open" \| "closed";/); + assert.match(file, /maybe: "open" \| "closed" \| null;/); + assert.match(file, /level: "aal1" \| "aal2";/); + assert.match(file, /catalog: "aal1" \| "aal2";/); +}); + +test("query text reaches JavaScript exactly as written", () => { + const text = "select '\\%' as a, '\\\\' as b, '`' as c, '${x}' as d, $1::text as e"; + const [file] = generate([query({ name: "Escapes", text })]); + assert.equal(queryText(file, "escapesQuery"), `-- name: Escapes :one\n${text}`); +}); + +test("one file per query file", () => { + const files = generate([ + query({ name: "A" }), + query({ name: "B" }), + query({ name: "C", filename: "other.sql" }), + ]); + assert.equal(files.length, 2); + assert.match(files[0], /aQuery[\s\S]*bQuery/); +}); + +test("the pg driver uses the same type names", () => { + const [file] = generate( + [query({ params: params(column("n", "integer"), column("b", "boolean")) })], + undefined, + "pg" + ); + assert.match(file, /n: number;/); + assert.match(file, /b: boolean;/); +}); diff --git a/test/javy-fs.ts b/test/javy-fs.ts new file mode 100644 index 0000000..02e089c --- /dev/null +++ b/test/javy-fs.ts @@ -0,0 +1,13 @@ +// Stands in for javy's fs module when the code generator runs under Node in +// tests; only the logger writes through it. +export const STDIO = { Stdin: 0, Stdout: 1, Stderr: 2 }; + +export function readFileSync(fd: number): Uint8Array { + throw new Error(`readFileSync(${fd}) is not available in tests`); +} + +export function writeFileSync(fd: number, buffer: Uint8Array) { + if (fd === STDIO.Stderr) { + process.stderr.write(buffer); + } +} diff --git a/test/postgres/gen/query_sql.ts b/test/postgres/gen/query_sql.ts new file mode 100644 index 0000000..e577a56 --- /dev/null +++ b/test/postgres/gen/query_sql.ts @@ -0,0 +1,150 @@ +// Code generated by sqlc. DO NOT EDIT. + +import { Sql } from "postgres"; + +export const createTicketQuery = `-- name: CreateTicket :exec +insert into ticket (id, title, status, urgent, seats, weight, price, due, created_at, label, tags, meta) +values ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + $10, $11, $12 +)`; + +export interface CreateTicketArgs { + id: string; + title: string; + status: "open" | "closed"; + urgent: boolean; + seats: number; + weight: number | null; + price: string; + due: Date | string | null; + createdAt: Date | string; + label: string | null; + tags: string[]; + meta: any | null; +} + +export async function createTicket(sql: Sql, args: CreateTicketArgs): Promise { + await sql.unsafe(createTicketQuery, [args.id, args.title, args.status, args.urgent, args.seats, args.weight, args.price, args.due, args.createdAt, args.label, args.tags, args.meta]); +} + +export const getTicketQuery = `-- name: GetTicket :one +select id, title, status, urgent, seats, weight, price, due, created_at, label, tags, meta, + seats > 2 as crowded +from ticket +where id = $1`; + +export interface GetTicketArgs { + id: string; +} + +export interface GetTicketRow { + id: string; + title: string; + status: "open" | "closed"; + urgent: boolean; + seats: number; + weight: number | null; + price: string; + due: Date | null; + createdAt: Date; + label: string | null; + tags: string[]; + meta: any | null; + crowded: boolean; +} + +export async function getTicket(sql: Sql, args: GetTicketArgs): Promise { + const rows = await sql.unsafe(getTicketQuery, [args.id]).values(); + if (rows.length !== 1) { + return null; + } + const row = rows[0]; + if (!row) { + return null; + } + return { + id: row[0], + title: row[1], + status: row[2], + urgent: row[3], + seats: row[4], + weight: row[5], + price: row[6], + due: row[7], + createdAt: row[8], + label: row[9], + tags: row[10], + meta: row[11], + crowded: row[12] + }; +} + +export const listTicketsQuery = `-- name: ListTickets :many +select id from ticket +where id = any($1::uuid[]) + and urgent = $2 + and seats >= $3 + and status = any($4::ticket_status[]) +order by title`; + +export interface ListTicketsArgs { + ids: string[]; + urgent: boolean; + minSeats: number; + statuses: ("open" | "closed")[]; +} + +export interface ListTicketsRow { + id: string; +} + +export async function listTickets(sql: Sql, args: ListTicketsArgs): Promise { + return (await sql.unsafe(listTicketsQuery, [args.ids, args.urgent, args.minSeats, args.statuses]).values()).map(row => ({ + id: row[0] + })); +} + +export const searchTicketsQuery = `-- name: SearchTickets :many +select id from ticket +where title like '%' || replace($1, '%', '\\%') || '%' escape '\\' +order by title`; + +export interface SearchTicketsArgs { + term: string; +} + +export interface SearchTicketsRow { + id: string; +} + +export async function searchTickets(sql: Sql, args: SearchTicketsArgs): Promise { + return (await sql.unsafe(searchTicketsQuery, [args.term]).values()).map(row => ({ + id: row[0] + })); +} + +export const countWithBackslashQuery = `-- name: CountWithBackslash :one +select length('a\\b')::integer as len, '\`\${x}\`' as raw`; + +export interface CountWithBackslashRow { + len: number; + raw: string; +} + +export async function countWithBackslash(sql: Sql): Promise { + const rows = await sql.unsafe(countWithBackslashQuery, []).values(); + if (rows.length !== 1) { + return null; + } + const row = rows[0]; + if (!row) { + return null; + } + return { + len: row[0], + raw: row[1] + }; +} + diff --git a/test/postgres/postgres.test.ts b/test/postgres/postgres.test.ts new file mode 100644 index 0000000..bd2fd9e --- /dev/null +++ b/test/postgres/postgres.test.ts @@ -0,0 +1,113 @@ +// Runs the code generated in gen/ (sqlc with the database analyzer, see +// sqlc.yaml) against the database in DATABASE_URL, which has schema.sql. +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import postgres from "postgres"; + +import { + countWithBackslash, + createTicket, + getTicket, + listTickets, + searchTickets, +} from "./gen/query_sql"; + +const sql = postgres(process.env.DATABASE_URL ?? "", { + prepare: false, + max: 1, + onnotice: () => {}, +}); + +const a = "00000000-0000-4000-8000-00000000000a"; +const b = "00000000-0000-4000-8000-00000000000b"; + +before(async () => { + await sql`truncate ticket`; + await createTicket(sql, { + id: a, + title: "100% sure", + status: "open", + urgent: true, + seats: 3, + weight: 1.5, + price: "10.50", + due: "2026-10-21", + createdAt: new Date("2026-10-21T10:00:00.123Z"), + label: null, + tags: ["x", "y"], + meta: { k: 1 }, + }); + await createTicket(sql, { + id: b, + title: "plain", + status: "closed", + urgent: false, + seats: 1, + weight: null, + price: "1", + due: null, + createdAt: "2026-10-21T10:00:00Z", + label: "l", + tags: [], + meta: null, + }); +}); + +after(async () => { + await sql.end(); +}); + +test("booleans, integers and enums round-trip", async () => { + const row = await getTicket(sql, { id: a }); + assert.ok(row); + assert.equal(row.urgent, true); + assert.equal(row.seats, 3); + assert.equal(row.crowded, true); + assert.equal(row.status, "open"); + assert.equal(row.price, "10.50"); + assert.deepEqual(row.tags, ["x", "y"]); + assert.deepEqual(row.meta, { k: 1 }); + assert.equal(row.due?.toISOString(), "2026-10-21T00:00:00.000Z"); + assert.equal(row.createdAt.toISOString(), "2026-10-21T10:00:00.123Z"); + + const other = await getTicket(sql, { id: b }); + assert.ok(other); + assert.equal(other.urgent, false); + assert.equal(other.crowded, false); + assert.equal(other.due, null); +}); + +test("array parameters", async () => { + assert.deepEqual( + await listTickets(sql, { ids: [a, b], urgent: true, minSeats: 2, statuses: ["open", "closed"] }), + [{ id: a }] + ); + assert.deepEqual( + await listTickets(sql, { ids: [a, b], urgent: false, minSeats: 0, statuses: ["closed"] }), + [{ id: b }] + ); +}); + +test("backslashes in a query reach the database", async () => { + assert.deepEqual(await searchTickets(sql, { term: "%" }), [{ id: a }]); + assert.deepEqual(await countWithBackslash(sql), { len: 3, raw: "`${x}`" }); +}); + +// Compile-time checks: tsc --noEmit fails if the generated types loosen. +async function types() { + const row = await getTicket(sql, { id: a }); + if (row) { + const urgent: boolean = row.urgent; + const seats: number = row.seats; + const status: "open" | "closed" = row.status; + const crowded: boolean = row.crowded; + void [urgent, seats, status, crowded]; + } + // @ts-expect-error a boolean parameter takes a boolean, not its text + await listTickets(sql, { ids: [], urgent: "true", minSeats: 0, statuses: [] }); + // @ts-expect-error an integer parameter takes a number + await listTickets(sql, { ids: [], urgent: true, minSeats: "0", statuses: [] }); + // @ts-expect-error an enum parameter takes one of its values + await listTickets(sql, { ids: [], urgent: true, minSeats: 0, statuses: ["pending"] }); +} +void types; diff --git a/test/postgres/query.sql b/test/postgres/query.sql new file mode 100644 index 0000000..0358dc0 --- /dev/null +++ b/test/postgres/query.sql @@ -0,0 +1,29 @@ +-- name: CreateTicket :exec +insert into ticket (id, title, status, urgent, seats, weight, price, due, created_at, label, tags, meta) +values ( + sqlc.arg(id), sqlc.arg(title), sqlc.arg(status), sqlc.arg(urgent), sqlc.arg(seats), + sqlc.narg(weight), sqlc.arg(price), sqlc.narg(due), sqlc.arg(created_at), + sqlc.narg(label), sqlc.arg(tags), sqlc.narg(meta) +); + +-- name: GetTicket :one +select id, title, status, urgent, seats, weight, price, due, created_at, label, tags, meta, + seats > 2 as crowded +from ticket +where id = $1; + +-- name: ListTickets :many +select id from ticket +where id = any(sqlc.arg(ids)::uuid[]) + and urgent = sqlc.arg(urgent) + and seats >= sqlc.arg(min_seats) + and status = any(sqlc.arg(statuses)::ticket_status[]) +order by title; + +-- name: SearchTickets :many +select id from ticket +where title like '%' || replace(sqlc.arg(term), '%', '\%') || '%' escape '\' +order by title; + +-- name: CountWithBackslash :one +select length('a\b')::integer as len, '`${x}`' as raw; diff --git a/test/postgres/schema.sql b/test/postgres/schema.sql new file mode 100644 index 0000000..b802915 --- /dev/null +++ b/test/postgres/schema.sql @@ -0,0 +1,16 @@ +CREATE TYPE ticket_status AS ENUM ('open', 'closed'); + +CREATE TABLE ticket ( + id uuid PRIMARY KEY, + title text NOT NULL, + status ticket_status NOT NULL, + urgent boolean NOT NULL, + seats integer NOT NULL, + weight double precision, + price numeric(12, 2) NOT NULL, + due date, + created_at timestamp(3) with time zone NOT NULL, + label character varying(40), + tags text[] NOT NULL, + meta jsonb +); diff --git a/test/postgres/sqlc.yaml b/test/postgres/sqlc.yaml new file mode 100644 index 0000000..aefdf8b --- /dev/null +++ b/test/postgres/sqlc.yaml @@ -0,0 +1,19 @@ +version: "2" +plugins: + - name: ts + wasm: + url: file://../../examples/plugin.wasm +sql: + - engine: postgresql + schema: schema.sql + queries: query.sql + database: + uri: ${DATABASE_URL} + analyzer: + database: true + codegen: + - out: gen + plugin: ts + options: + runtime: node + driver: postgres diff --git a/tsconfig.json b/tsconfig.json index 6c4c841..9b35db2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -108,5 +108,6 @@ }, "include": [ "src/*.ts", + "test/**/*.ts", ] }