diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a007a43..d9c7d2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: - name: Build run: | make generate + make validate - uses: actions/upload-artifact@v7 with: name: plugin diff --git a/Makefile b/Makefile index 5bbe894..42f3684 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,11 @@ -.PHONY: clean generate +.PHONY: clean generate validate generate: examples/plugin.wasm examples/sqlc.dev.yaml cd examples && sqlc -f sqlc.dev.yaml generate +validate: node_modules + npm test + # https://github.com/bytecodealliance/javy/releases/tag/v7.0.1 examples/plugin.wasm: out.js bin/javy ./bin/javy build out.js --codegen source=omitted -o examples/plugin.wasm diff --git a/examples/bun-mysql2/src/main.ts b/examples/bun-mysql2/src/main.ts index c3dd59a..de34756 100644 --- a/examples/bun-mysql2/src/main.ts +++ b/examples/bun-mysql2/src/main.ts @@ -33,7 +33,11 @@ async function main() { console.log(authors); // Get that author - const seal = await getAuthor(client, { id: authors[0].id }); + const author = authors[0]; + if (!author) { + throw new Error("author not found"); + } + const seal = await getAuthor(client, { id: author.id }); if (seal === null) { throw new Error("seal not found"); } diff --git a/examples/bun-pg/bun.lockb b/examples/bun-pg/bun.lockb index ba155ae..6bb8c82 100755 Binary files a/examples/bun-pg/bun.lockb and b/examples/bun-pg/bun.lockb differ diff --git a/examples/bun-pg/package.json b/examples/bun-pg/package.json index c264672..4906371 100644 --- a/examples/bun-pg/package.json +++ b/examples/bun-pg/package.json @@ -3,6 +3,7 @@ "module": "src/main.ts", "type": "module", "devDependencies": { + "@types/pg": "^8.10.9", "bun-types": "latest" }, "peerDependencies": { diff --git a/examples/bun-pg/src/db/query_sql.ts b/examples/bun-pg/src/db/query_sql.ts index 525d4a4..0b57676 100644 --- a/examples/bun-pg/src/db/query_sql.ts +++ b/examples/bun-pg/src/db/query_sql.ts @@ -30,6 +30,9 @@ export async function getAuthor(client: Client, args: GetAuthorArgs): Promise=18" } }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -664,6 +687,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index dd26e6d..2bd505e 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,12 @@ "description": "", "main": "app.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "tsc --noEmit && node --test" }, "author": "", "license": "ISC", "devDependencies": { + "@types/pg": "^8.10.9", "esbuild": "^0.28.1", "typescript": "^6.0.3" }, diff --git a/src/app.ts b/src/app.ts index 8c0a74f..d3ddcc5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -138,7 +138,7 @@ function codegen(input: GenerateRequest): GenerateResponse { colmap.set(column.name, count + 1); } - const lowerName = query.name[0].toLowerCase() + query.name.slice(1); + const lowerName = query.name.charAt(0).toLowerCase() + query.name.slice(1); const textName = `${lowerName}Query`; nodes.push( diff --git a/src/drivers/pg.ts b/src/drivers/pg.ts index a110a21..c57b870 100644 --- a/src/drivers/pg.ts +++ b/src/drivers/pg.ts @@ -626,6 +626,17 @@ export class Driver { NodeFlags.TypeExcludesFlags ) ), + factory.createIfStatement( + factory.createPrefixUnaryExpression( + SyntaxKind.ExclamationToken, + factory.createIdentifier("row") + ), + factory.createBlock( + [factory.createReturnStatement(factory.createNull())], + true + ), + undefined + ), factory.createReturnStatement( factory.createObjectLiteralExpression( columns.map((col, i) => diff --git a/test/pg-one.test.mjs b/test/pg-one.test.mjs new file mode 100644 index 0000000..ef41f62 --- /dev/null +++ b/test/pg-one.test.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import test from "node:test"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const generatedFiles = [ + "examples/node-pg/src/db/query_sql.ts", + "examples/bun-pg/src/db/query_sql.ts", +].map((path) => resolve(root, path)); +const tsc = resolve(root, "node_modules/.bin/tsc"); + +function relativePath(path) { + return relative(root, path); +} + +test("generated pg :one queries guard row access", () => { + let totalOneQueryCount = 0; + + for (const generatedFile of generatedFiles) { + assert.ok( + existsSync(generatedFile), + `Missing generated pg output: ${generatedFile}` + ); + + const source = readFileSync(generatedFile, "utf8"); + const oneQueryCount = [...source.matchAll(/-- name:\s+\w+\s+:one\b/g)] + .length; + + assert.ok( + oneQueryCount > 0, + `Expected at least one generated pg :one query to validate in ${relativePath(generatedFile)}.` + ); + + const guardedRowAccessCount = [ + ...source.matchAll( + /if \(result\.rows\.length !== 1\) \{\n\s+return null;\n\s+\}\n\s+const row = result\.rows\[0\];\n\s+if \(!row\) \{\n\s+return null;\n\s+\}/g + ), + ].length; + + assert.equal( + guardedRowAccessCount, + oneQueryCount, + `${relativePath(generatedFile)} must guard \`result.rows[0]\` after the exact row-count guard for every :one query.` + ); + + assert.doesNotMatch( + source, + /const row = result\.rows\[0\]!;/, + `${relativePath(generatedFile)} must not use non-null row assertions.` + ); + + totalOneQueryCount += oneQueryCount; + } + + assert.ok(totalOneQueryCount > 0, "Expected at least one pg :one query."); +}); + +test("generated pg output type-checks with strict noUncheckedIndexedAccess", () => { + assert.ok( + existsSync(tsc), + "Missing TypeScript compiler. Install project dependencies before running validation." + ); + + const result = spawnSync( + tsc, + [ + "--ignoreConfig", + "--strict", + "--noUncheckedIndexedAccess", + "--module", + "commonjs", + "--target", + "es2020", + "--esModuleInterop", + "--skipLibCheck", + "--noEmit", + ...generatedFiles, + ], + { cwd: root, encoding: "utf8" } + ); + + assert.ifError(result.error); + assert.equal( + result.status, + 0, + [ + "Generated pg output failed strict noUncheckedIndexedAccess type-check.", + result.stdout, + result.stderr, + ] + .filter(Boolean) + .join("\n") + ); +});