Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,75 @@ jobs:
env:
# Prisma breaks when running tests in parallel
PJTG_SEQUENTIAL_TESTS: 1

e2e-typedsql:
name: E2E TypedSQL (${{ matrix.os }})
runs-on: ${{ matrix.os }}

strategy:
matrix:
include:
- os: ubuntu-latest
database_url: postgresql://prisma:prisma@localhost:5432/typedsql_test
- os: macos-latest
database_url: postgresql://localhost:5432/typedsql_test
- os: windows-latest
database_url: postgresql://postgres:root@localhost:5432/typedsql_test

steps:
- name: Uses LF line endings
run: |
git config --global core.autocrlf false
git config --global core.eol lf

- name: Checkout
uses: actions/checkout@v6

- name: Setup pnpm
uses: pnpm/action-setup@v6

- name: Setup node and restore cached dependencies
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

- name: Install packages
run: pnpm install --frozen-lockfile

- name: Build code
run: pnpm build

- name: Start PostgreSQL (Linux)
if: runner.os == 'Linux'

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why can't you just use docker compose for all 3 environments?

run: docker compose up -d --wait

- name: Start PostgreSQL (macOS)
if: runner.os == 'macOS'
run: |
brew services start postgresql@14
until "$(brew --prefix)/opt/postgresql@14/bin/pg_isready" -q; do sleep 1; done
"$(brew --prefix)/opt/postgresql@14/bin/createdb" typedsql_test

- name: Start PostgreSQL (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$svc = (Get-Service postgresql*).Name
Start-Service $svc
$pgVer = $svc -replace 'postgresql-x64-', ''
$env:PGPASSWORD = 'root'
& "C:\Program Files\PostgreSQL\$pgVer\bin\psql.exe" -U postgres -c "CREATE DATABASE typedsql_test;"

- name: Push database schema
run: pnpm prisma db push --config test/pg-schemas/prisma.config.ts --schema test/pg-schemas/typedsql.prisma
env:
DATABASE_URL: ${{ matrix.database_url }}

- name: Generate TypedSQL types
run: pnpm prisma generate --sql --config test/pg-schemas/prisma.config.ts --schema test/pg-schemas/typedsql.prisma
env:
DATABASE_URL: ${{ matrix.database_url }}

- name: Verify TypedSQL types
run: pnpm tsd -f test/types/typedsql-pg.test-d.ts -t . --show-diff
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
- [Typing `String` Fields (Enums)](#typing-string-fields-enums)
- [Advanced Typing](#advanced-typing)
- [Examples](#examples)
- [TypedSQL Support](#typedsql-support)
- [Model-Based Annotations](#model-based-annotations)
- [SQL-File Annotations for Complex Queries](#sql-file-annotations-for-complex-queries)
- [Validating Types at Runtime](#validating-types-at-runtime)
- [How It Works](#how-it-works)
- [Limitations](#limitations)
Expand Down Expand Up @@ -266,6 +269,74 @@ Important:

<br />

## TypedSQL Support

[Prisma's TypedSQL](https://www.prisma.io/docs/orm/prisma-client/using-raw-sql/typedsql) feature generates strongly-typed wrappers for raw SQL queries. When a query returns `Json` columns, Prisma types them as `$runtime.JsonValue`. This generator replaces those with your annotated types, giving you full type safety down to raw SQL.

Enable TypedSQL in your schema and add this generator as usual:

```prisma
generator client {
provider = "prisma-client"
previewFeatures = ["typedSql"]
}

generator json {
provider = "prisma-json-types-generator"
}
```

### Model-Based Annotations

For queries that return columns that map directly to a model field, the existing `/// [Type]` and `/// ![Type]` annotations in your schema are used automatically. No extra configuration is needed.

```prisma
model Order {
id Int @id
/// ![number]
meta Json
}
```

```sql
-- prisma/sql/getOrderMeta.sql
SELECT id, meta FROM "Order"
```

After `prisma generate`, the `getOrderMeta.Result.meta` type is replaced with `(number)` instead of `$runtime.JsonValue`.

### SQL-File Annotations for Complex Queries

Real-world SQL queries often don't map 1:1 to a single model — they use column aliases, CTEs, JOINs, or computed expressions. In those cases, use a `-- @pjt-type` comment at the top of the `.sql` file to annotate individual result columns:

```sql
-- prisma/sql/getOrderStats.sql

-- @pjt-type order_meta ![number]
-- @pjt-type summary [OrderSummary]

WITH latest AS (
SELECT
id,
meta AS order_meta,
stats_col AS summary
FROM "Order"
WHERE created_at > NOW() - INTERVAL '30 days'
)
SELECT * FROM latest
```

The annotation syntax is identical to schema doc-comments:

| Annotation | Result type |
| :--- | :--- |
| `-- @pjt-type col [MyType]` | `PrismaJson.MyType` |
| `-- @pjt-type col ![number \| string]` | `(number \| string)` |

SQL-file annotations take priority over model-based ones for that query, so you can also use them to override a model-level annotation for a specific query.

<br />

## Validating Types at Runtime

This generator provides compile-time type safety, not runtime validation. You can, however, share types from a runtime validation library like Zod to create a single source of truth for your data structures.
Expand Down
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
services:
postgres:
image: postgres:alpine
environment:
POSTGRES_USER: prisma
POSTGRES_PASSWORD: prisma
POSTGRES_DB: typedsql_test
ports:
- '5432:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U prisma']
interval: 5s
timeout: 5s
retries: 5
127 changes: 127 additions & 0 deletions src/handler/typedsql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { SqlQueryOutput } from '@prisma/generator';
import ts from 'typescript';
import type { PrismaJsonTypesGeneratorConfig } from '../util/config';
import { createType } from '../util/create-signature';
import type { DeclarationWriter } from '../util/declaration-writer';

/**
* Parses `-- @pjt-type column_name annotation` comments from a SQL source string.
*
* These per-file annotations let users type JSON columns in complex queries (joins,
* CTEs, column aliases) that don't map 1:1 to a Prisma model field. SQL-file
* annotations take priority over model-based column docs for the query that contains
* them.
*
* The `annotation` must follow the same syntax as Prisma schema doc-comments:
* - `[TypeName]` – references `<namespace>.TypeName`
* - `![InlineType]` – uses the inline type `(InlineType)` literally
*
* @example
* ```sql
* -- @pjt-type field_alias ![number]
* SELECT id, field AS field_alias FROM "Model"
* ```
*/
export function parseSqlAnnotations(sqlContent: string): Map<string, string> {
const annotations = new Map<string, string>();
for (const line of sqlContent.split('\n')) {
const match = line.trim().match(/^--\s*@pjt-type\s+(\S+)\s+(.+?)\s*$/);
if (match) {
annotations.set(match[1]!, match[2]!);
}
}
return annotations;
}

/**
* Handles a TypedSQL query file by replacing JSON column types with annotated types.
*
* TypedSQL files are TypeScript modules generated by Prisma that represent typed SQL
* queries. Each file exports a factory function and a namespace with `Parameters` and
* `Result` types. This handler replaces `$runtime.JsonValue` types in the `Result` type
* with user-annotated types from the Prisma schema.
*/
export function handleTypedSqlFile(
tsSource: ts.SourceFile,
writer: DeclarationWriter,
query: SqlQueryOutput,
columnDocs: Map<string, string | undefined>,
config: PrismaJsonTypesGeneratorConfig
) {
// Only process json and json-array typed columns
const jsonColumns = query.resultColumns.filter(
(col) => col.typ === 'json' || col.typ === 'json-array'
);

if (jsonColumns.length === 0) return;

// Build a map of column name → replacement info
const columnsToReplace = new Map<
string,
{ newType: string; nullable: boolean; isArray: boolean }
>();

for (const col of jsonColumns) {
const documentation = columnDocs.get(col.name);

// No annotation and allowAny is set — skip replacing
if (!documentation && config.allowAny) continue;

const newType = createType(documentation, config);
columnsToReplace.set(col.name, {
newType,
nullable: col.nullable,
isArray: col.typ === 'json-array'
});
}

if (columnsToReplace.size === 0) return;

// Traverse the top-level namespace declaration matching the query name
tsSource.forEachChild((child) => {
if (child.kind !== ts.SyntaxKind.ModuleDeclaration) return;

const ns = child as ts.ModuleDeclaration;
if (ns.name.getText() !== query.name) return;

const body = ns.body;
if (!body || body.kind !== ts.SyntaxKind.ModuleBlock) return;

for (const stmt of (body as ts.ModuleBlock).statements) {
if (stmt.kind !== ts.SyntaxKind.TypeAliasDeclaration) continue;

const typeAlias = stmt as ts.TypeAliasDeclaration;
if (typeAlias.name.getText() !== 'Result') continue;
if (typeAlias.type.kind !== ts.SyntaxKind.TypeLiteral) continue;

const typeLiteral = typeAlias.type as ts.TypeLiteralNode;

for (const member of typeLiteral.members) {
if (member.kind !== ts.SyntaxKind.PropertySignature) continue;

const prop = member as ts.PropertySignature;
const propName = prop.name?.getText();

if (!propName || !columnsToReplace.has(propName)) continue;

const { newType, nullable, isArray } = columnsToReplace.get(propName)!;
const typeNode = prop.type;

if (!typeNode) continue;

let replacement: string;
if (isArray) {
replacement = `${newType}[]`;
} else if (nullable) {
replacement = `${newType} | null`;
} else {
replacement = newType;
}

// Use getStart() (not pos) to exclude leading trivia so the
// space between the colon and the type is preserved.
writer.replace(typeNode.getStart(tsSource), typeNode.end, replacement);
}
}
});
}
Loading
Loading