Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- Auto-escape all ReScript keywords in generated record field names.
- Add automatic parsing of PostgreSQL check constraints to generate ReScript polyvariant types for enumeration-style constraints. Supports both `column IN (value1, value2, ...)` and `column = ANY (ARRAY[value1, value2, ...])` patterns with string and integer values.
- Add top-level literal inference for SELECT queries. When a query returns literal values with aliases (e.g., `SELECT 'success' as status, 42 as code`), PgTyped now automatically infers specific polyvariant types like `[#"success"]` and `[#42]` instead of generic `string` and `int` types. This provides better type safety and autocompletion. Also works with UNION queries where literals are consistent across all branches.
- Remove dependency on `@rescript/core` since it's not really used.

# 2.6.0
Expand Down
72 changes: 72 additions & 0 deletions RESCRIPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,78 @@ let books = await client->GetBooksByStatus.many({

This feature works seamlessly with both separate SQL files and SQL-in-ReScript modes.

## Literal Type Inference

`pgtyped-rescript` automatically infers specific polyvariant types for literal values in your SQL queries, providing enhanced type safety and better development experience.

### How It Works

When your SQL queries return literal values with aliases, `pgtyped-rescript` generates specific polyvariant types instead of generic `string`, `int`, etc. This works for both simple SELECT queries and UNION queries where literals are consistent across all branches.

### Examples

**Simple literals:**

```sql
/* @name getStatus */
SELECT
'success' as status,
200 as code,
'active' as state
```

Generated ReScript type:

```rescript
type getStatusResult = {
status: [#"success"],
code: [#200],
state: [#"active"],
}
```

**Union queries with consistent literals:**

```sql
/* @name getDocumentStatus */
SELECT 'draft' as status, 1 as version
UNION ALL
SELECT 'published' as status, 2 as version
```

Generated ReScript type:

```rescript
type getDocumentStatusResult = {
status: [#"draft" | #"published"],
version: [#1 | #2],
}
```

**SQL-in-ReScript example:**

```rescript
let getOrderStatus = %sql.many(`
SELECT
'pending' as status,
0 as priority
UNION ALL
SELECT
'shipped' as status,
1 as priority
`)

// Returns: array<{status: [#"pending" | #"shipped"], priority: [#0 | #1]}>
let statuses = await client->getOrderStatus()
```

### Smart Inference Rules

- **Consistent literals**: Only infers polyvariants when all literal values for the same alias are actual literals (not expressions)
- **Context-aware**: Handles nested queries correctly, only inferring from the top-level SELECT
- **Duplicate handling**: Automatically deduplicates identical literals in UNION queries
- **Mixed expressions**: Falls back to generic types when mixing literals with expressions (e.g., `'draft' || 'suffix'`)

## API

### `PgTyped`
Expand Down
4 changes: 1 addition & 3 deletions packages/cli/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export async function queryToTypeDeclarations(
if (typeError || hasAnonymousColumns) {
// tslint:disable:no-console
if (typeError) {
console.error('Error in query. Details: %o', typeData);
console.error(`Error in query "${queryName}". Details: %o`, typeData);
if (config.failOnError) {
throw new Error(
`Query "${queryName}" is invalid. Can't generate types.`,
Expand Down Expand Up @@ -147,8 +147,6 @@ export async function queryToTypeDeclarations(
return `#"${v.value}"`;
case 'integer':
return `#${v.value}`;
case 'float':
return `#${v.value}`;
}
})
.join(' | ')}]`;
Expand Down
2 changes: 0 additions & 2 deletions packages/cli/src/parseRescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ export function parseCode(
.toString(),
);

content.reverse();

const queries: Array<string> = [];
let unnamedQueriesCount = 0;

Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/res/PgTyped.res
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,12 @@ module Pg = {
}

module IR = {
type t
type t = private {
queryName: option<string>,
statement: string,
usedParamSet: dict<bool>,
params: array<Js.Json.t>, // This can be more thoroughly typed if wanted
}
}

module PreparedStatement = {
Expand Down
2 changes: 1 addition & 1 deletion packages/example/src/books/BookServiceParams2__sql.res
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type query1Query = {
result: query1Result,
}

%%private(let query1IR: IR.t = %raw(`{"usedParamSet":{"notification":true},"params":[{"name":"notification","required":false,"transform":{"type":"pick_tuple","keys":[{"name":"payload","required":false},{"name":"user_id","required":false},{"name":"type","required":false}]},"locs":[{"a":58,"b":70}]}],"statement":"INSERT INTO notifications (payload, user_id, type) VALUES :notification"}`))
%%private(let query1IR: IR.t = %raw(`{"queryName":"Query1","usedParamSet":{"notification":true},"params":[{"name":"notification","required":false,"transform":{"type":"pick_tuple","keys":[{"name":"payload","required":false},{"name":"user_id","required":false},{"name":"type","required":false}]},"locs":[{"a":58,"b":70}]}],"statement":"INSERT INTO notifications (payload, user_id, type) VALUES :notification"}`))

/**
Runnable query:
Expand Down
2 changes: 1 addition & 1 deletion packages/example/src/books/BookServiceParams__sql.res
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type query1Query = {
result: query1Result,
}

%%private(let query1IR: IR.t = %raw(`{"usedParamSet":{"notification":true},"params":[{"name":"notification","required":false,"transform":{"type":"pick_tuple","keys":[{"name":"payload","required":false},{"name":"user_id","required":false},{"name":"type","required":false}]},"locs":[{"a":58,"b":70}]}],"statement":"INSERT INTO notifications (payload, user_id, type) VALUES :notification"}`))
%%private(let query1IR: IR.t = %raw(`{"queryName":"Query1","usedParamSet":{"notification":true},"params":[{"name":"notification","required":false,"transform":{"type":"pick_tuple","keys":[{"name":"payload","required":false},{"name":"user_id","required":false},{"name":"type","required":false}]},"locs":[{"a":58,"b":70}]}],"statement":"INSERT INTO notifications (payload, user_id, type) VALUES :notification"}`))

/**
Runnable query:
Expand Down
44 changes: 22 additions & 22 deletions packages/example/src/books/BookService__sql.gen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,63 +11,63 @@ export type category = "novel" | "science-fiction" | "thriller";

export type categoryArray = category[];

/** 'BooksByAuthor' parameters type */
export type booksByAuthorParams = { readonly authorName: string };
/** 'FindBookById' parameters type */
export type findBookByIdParams = { readonly id?: number };

/** 'BooksByAuthor' return type */
export type booksByAuthorResult = {
/** 'FindBookById' return type */
export type findBookByIdResult = {
readonly author_id: (undefined | number);
readonly categories: (undefined | categoryArray);
readonly id: number;
readonly name: (undefined | string);
readonly rank: (undefined | number)
};

/** 'BooksByAuthor' query type */
export type booksByAuthorQuery = { readonly params: booksByAuthorParams; readonly result: booksByAuthorResult };
/** 'FindBookById' query type */
export type findBookByIdQuery = { readonly params: findBookByIdParams; readonly result: findBookByIdResult };

/** 'FindBookById' parameters type */
export type findBookByIdParams = { readonly id?: number };
/** 'BooksByAuthor' parameters type */
export type booksByAuthorParams = { readonly authorName: string };

/** 'FindBookById' return type */
export type findBookByIdResult = {
/** 'BooksByAuthor' return type */
export type booksByAuthorResult = {
readonly author_id: (undefined | number);
readonly categories: (undefined | categoryArray);
readonly id: number;
readonly name: (undefined | string);
readonly rank: (undefined | number)
};

/** 'FindBookById' query type */
export type findBookByIdQuery = { readonly params: findBookByIdParams; readonly result: findBookByIdResult };
/** 'BooksByAuthor' query type */
export type booksByAuthorQuery = { readonly params: booksByAuthorParams; readonly result: booksByAuthorResult };

/** Returns an array of all matched results. */
export const BooksByAuthor_many: (_1:PgTyped_Pg_Client_t, _2:booksByAuthorParams) => Promise<booksByAuthorResult[]> = BookService__sqlJS.BooksByAuthor.many as any;
export const FindBookById_many: (_1:PgTyped_Pg_Client_t, _2:findBookByIdParams) => Promise<findBookByIdResult[]> = BookService__sqlJS.FindBookById.many as any;

/** Returns exactly 1 result. Returns `None` if more or less than exactly 1 result is returned. */
export const BooksByAuthor_one: (_1:PgTyped_Pg_Client_t, _2:booksByAuthorParams) => Promise<(undefined | booksByAuthorResult)> = BookService__sqlJS.BooksByAuthor.one as any;
export const FindBookById_one: (_1:PgTyped_Pg_Client_t, _2:findBookByIdParams) => Promise<(undefined | findBookByIdResult)> = BookService__sqlJS.FindBookById.one as any;

/** Returns exactly 1 result. Raises `Exn.t` (with an optionally provided `errorMessage`) if more or less than exactly 1 result is returned. */
export const BooksByAuthor_expectOne: (_1:PgTyped_Pg_Client_t, _2:booksByAuthorParams, errorMessage:(undefined | string)) => Promise<booksByAuthorResult> = BookService__sqlJS.BooksByAuthor.expectOne as any;
export const FindBookById_expectOne: (_1:PgTyped_Pg_Client_t, _2:findBookByIdParams, errorMessage:(undefined | string)) => Promise<findBookByIdResult> = BookService__sqlJS.FindBookById.expectOne as any;

/** Executes the query, but ignores whatever is returned by it. */
export const BooksByAuthor_execute: (_1:PgTyped_Pg_Client_t, _2:booksByAuthorParams) => Promise<void> = BookService__sqlJS.BooksByAuthor.execute as any;
export const FindBookById_execute: (_1:PgTyped_Pg_Client_t, _2:findBookByIdParams) => Promise<void> = BookService__sqlJS.FindBookById.execute as any;

export const booksByAuthor: (params:booksByAuthorParams, client:PgTyped_Pg_Client_t) => Promise<booksByAuthorResult[]> = BookService__sqlJS.booksByAuthor as any;
export const findBookById: (params:findBookByIdParams, client:PgTyped_Pg_Client_t) => Promise<findBookByIdResult[]> = BookService__sqlJS.findBookById as any;

/** Returns an array of all matched results. */
export const FindBookById_many: (_1:PgTyped_Pg_Client_t, _2:findBookByIdParams) => Promise<findBookByIdResult[]> = BookService__sqlJS.FindBookById.many as any;
export const BooksByAuthor_many: (_1:PgTyped_Pg_Client_t, _2:booksByAuthorParams) => Promise<booksByAuthorResult[]> = BookService__sqlJS.BooksByAuthor.many as any;

/** Returns exactly 1 result. Returns `None` if more or less than exactly 1 result is returned. */
export const FindBookById_one: (_1:PgTyped_Pg_Client_t, _2:findBookByIdParams) => Promise<(undefined | findBookByIdResult)> = BookService__sqlJS.FindBookById.one as any;
export const BooksByAuthor_one: (_1:PgTyped_Pg_Client_t, _2:booksByAuthorParams) => Promise<(undefined | booksByAuthorResult)> = BookService__sqlJS.BooksByAuthor.one as any;

/** Returns exactly 1 result. Raises `Exn.t` (with an optionally provided `errorMessage`) if more or less than exactly 1 result is returned. */
export const FindBookById_expectOne: (_1:PgTyped_Pg_Client_t, _2:findBookByIdParams, errorMessage:(undefined | string)) => Promise<findBookByIdResult> = BookService__sqlJS.FindBookById.expectOne as any;
export const BooksByAuthor_expectOne: (_1:PgTyped_Pg_Client_t, _2:booksByAuthorParams, errorMessage:(undefined | string)) => Promise<booksByAuthorResult> = BookService__sqlJS.BooksByAuthor.expectOne as any;

/** Executes the query, but ignores whatever is returned by it. */
export const FindBookById_execute: (_1:PgTyped_Pg_Client_t, _2:findBookByIdParams) => Promise<void> = BookService__sqlJS.FindBookById.execute as any;
export const BooksByAuthor_execute: (_1:PgTyped_Pg_Client_t, _2:booksByAuthorParams) => Promise<void> = BookService__sqlJS.BooksByAuthor.execute as any;

export const findBookById: (params:findBookByIdParams, client:PgTyped_Pg_Client_t) => Promise<findBookByIdResult[]> = BookService__sqlJS.findBookById as any;
export const booksByAuthor: (params:booksByAuthorParams, client:PgTyped_Pg_Client_t) => Promise<booksByAuthorResult[]> = BookService__sqlJS.booksByAuthor as any;

export const FindBookById: {
/** Returns exactly 1 result. Raises `Exn.t` (with an optionally provided `errorMessage`) if more or less than exactly 1 result is returned. */
Expand Down
Loading