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
1 change: 1 addition & 0 deletions src/server/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const GENERATE_TYPES_SWIFT_ACCESS_CONTROL = process.env
.PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL
? (process.env.PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL as AccessControl)
: 'internal'
export const GENERATE_TYPES_BIGINT_AS = process.env.PG_META_GENERATE_TYPES_BIGINT_AS ?? 'number'

// json/jsonb/text types
export const VALID_UNNAMED_FUNCTION_ARG_TYPES = new Set([114, 3802, 25])
Expand Down
3 changes: 3 additions & 0 deletions src/server/routes/generators/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default async (fastify: FastifyInstance) => {
included_schemas?: string
detect_one_to_one_relationships?: string
postgrest_version?: string
bigint_as?: string
}
}>('/', async (request, reply) => {
const config = createConnectionConfig(request)
Expand All @@ -21,6 +22,7 @@ export default async (fastify: FastifyInstance) => {
request.query.included_schemas?.split(',').map((schema) => schema.trim()) ?? []
const detectOneToOneRelationships = request.query.detect_one_to_one_relationships === 'true'
const postgrestVersion = request.query.postgrest_version
const bigintAs = request.query.bigint_as ?? 'number'

const pgMeta: PostgresMeta = new PostgresMeta(config)
const { data: generatorMeta, error: generatorMetaError } = await getGeneratorMetadata(pgMeta, {
Expand All @@ -37,6 +39,7 @@ export default async (fastify: FastifyInstance) => {
...generatorMeta,
detectOneToOneRelationships,
postgrestVersion,
bigintAs,
})
})
}
3 changes: 2 additions & 1 deletion src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DEFAULT_POOL_CONFIG,
EXPORT_DOCS,
GENERATE_TYPES,
GENERATE_TYPES_BIGINT_AS,
GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS,
GENERATE_TYPES_INCLUDED_SCHEMAS,
GENERATE_TYPES_SWIFT_ACCESS_CONTROL,
Expand Down Expand Up @@ -136,7 +137,7 @@ async function getTypeOutput(): Promise<string | null> {

switch (GENERATE_TYPES?.toLowerCase()) {
case 'typescript':
return await applyTypescriptTemplate(config)
return await applyTypescriptTemplate({ ...config, bigintAs: GENERATE_TYPES_BIGINT_AS })
case 'swift':
return await applySwiftTemplate({
...config,
Expand Down
52 changes: 45 additions & 7 deletions src/server/templates/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ export const apply = async ({
types,
detectOneToOneRelationships,
postgrestVersion,
bigintAs = 'number',
}: GeneratorMetadata & {
detectOneToOneRelationships: boolean
postgrestVersion?: string
bigintAs?: string
}): Promise<string> => {
schemas.sort((a, b) => a.name.localeCompare(b.name))
relationships.sort(
Expand Down Expand Up @@ -279,6 +281,7 @@ export const apply = async ({
schemas,
tables,
views,
bigintAs,
})
}
return { name, type: tsType }
Expand Down Expand Up @@ -314,6 +317,7 @@ export const apply = async ({
schemas,
tables,
views,
bigintAs,
}
)
)
Expand All @@ -329,6 +333,7 @@ export const apply = async ({
schemas,
tables,
views,
bigintAs,
})
}

Expand Down Expand Up @@ -426,6 +431,7 @@ export const apply = async ({
schemas,
tables,
views,
bigintAs,
})
}
return { name, type: tsType, has_default }
Expand All @@ -445,6 +451,7 @@ export const apply = async ({
schemas,
tables,
views,
bigintAs,
})
}
return { name, type: tsType, has_default }
Expand All @@ -462,6 +469,7 @@ export const apply = async ({
schemas,
tables,
views,
bigintAs,
})
}
return { name, type: tsType, has_default }
Expand Down Expand Up @@ -503,6 +511,8 @@ export const apply = async ({
schemas: PostgresSchema[]
tables: PostgresTable[]
views: PostgresView[]
bigintAs: string
isWriteColumn?: boolean
}
) {
return `${JSON.stringify(column.name)}${column.is_optional ? '?' : ''}: ${generateNullableUnionTsType(pgTypeToTsType(schema, column.format, context), column.is_nullable)}`
Expand Down Expand Up @@ -539,7 +549,7 @@ export type Database = {
is_nullable: column.is_nullable,
is_optional: false,
},
{ types, schemas, tables, views }
{ types, schemas, tables, views, bigintAs }
)
),
...schemaFunctions
Expand All @@ -565,7 +575,7 @@ export type Database = {
column.is_identity ||
column.default_value !== null,
},
{ types, schemas, tables, views }
{ types, schemas, tables, views, bigintAs, isWriteColumn: true }
)
})}
}
Expand All @@ -583,7 +593,7 @@ export type Database = {
is_nullable: column.is_nullable,
is_optional: true,
},
{ types, schemas, tables, views }
{ types, schemas, tables, views, bigintAs, isWriteColumn: true }
)
})}
}
Expand Down Expand Up @@ -611,7 +621,7 @@ export type Database = {
is_nullable: column.is_nullable,
is_optional: false,
},
{ types, schemas, tables, views }
{ types, schemas, tables, views, bigintAs }
)
),
...schemaFunctions
Expand All @@ -637,7 +647,7 @@ export type Database = {
is_nullable: true,
is_optional: true,
},
{ types, schemas, tables, views }
{ types, schemas, tables, views, bigintAs, isWriteColumn: true }
)
})}
}
Expand All @@ -654,7 +664,7 @@ export type Database = {
is_nullable: true,
is_optional: true,
},
{ types, schemas, tables, views }
{ types, schemas, tables, views, bigintAs, isWriteColumn: true }
)
})}
}
Expand Down Expand Up @@ -725,6 +735,7 @@ export type Database = {
schemas,
tables,
views,
bigintAs,
}),
true
)}`
Expand Down Expand Up @@ -868,6 +879,22 @@ export const Constants = {
return output
}

// `bigintAs` is a pipe-delimited set of TypeScript representations for int8/numeric columns on
// Insert/Update, for example "number|bigint". Only `number` and `bigint` are accepted: `bigint`
// is the lossless write channel (postgrest-js serializes it to a JSON string), while `number`
// keeps the ergonomic path for small values. `string` is deliberately excluded, since an
// arbitrary non-numeric string would pass the type check and only fail at runtime in PostgREST.
// Unknown tokens are ignored, and an empty result falls back to "number".
const BIGINT_AS_REPRESENTATIONS = ['number', 'bigint']
export const bigintAsToTsType = (bigintAs: string): string => {
const representations = bigintAs
.split('|')
.map((value) => value.trim())
.filter((value) => BIGINT_AS_REPRESENTATIONS.includes(value))
const unique = [...new Set(representations)]
return unique.length > 0 ? unique.join(' | ') : 'number'
}

// TODO: Make this more robust. Currently doesn't handle range types - returns them as unknown.
export const pgTypeToTsType = (
schema: PostgresSchema,
Expand All @@ -877,17 +904,26 @@ export const pgTypeToTsType = (
schemas,
tables,
views,
bigintAs = 'number',
isWriteColumn = false,
}: {
types: PostgresType[]
schemas: PostgresSchema[]
tables: PostgresTable[]
views: PostgresView[]
bigintAs?: string
isWriteColumn?: boolean
}
): string => {
if (pgType === 'bool') {
return 'boolean'
} else if (['int2', 'int4', 'int8', 'float4', 'float8', 'numeric'].includes(pgType)) {
} else if (['int2', 'int4', 'float4', 'float8'].includes(pgType)) {
return 'number'
} else if (['int8', 'numeric'].includes(pgType)) {
// int8/numeric can exceed Number.MAX_SAFE_INTEGER. On reads they stay `number` to match the
// (lossy) JSON number PostgREST returns; cast to ::text for the exact value. On writes the
// column is widened to the `bigintAs` representations so callers can pass a lossless form.
return isWriteColumn ? bigintAsToTsType(bigintAs) : 'number'
} else if (
[
'bytea',
Expand Down Expand Up @@ -918,6 +954,8 @@ export const pgTypeToTsType = (
schemas,
tables,
views,
bigintAs,
isWriteColumn,
})})[]`
} else {
const enumTypes = types.filter((type) => type.name === pgType && type.enums.length > 0)
Expand Down
36 changes: 36 additions & 0 deletions test/server/typegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3647,6 +3647,42 @@ test('typegen: typescript w/ one-to-one relationships', async () => {
)
})

test('typegen: typescript w/ bigintAs defaults to number', async () => {
const { body } = await app.inject({ method: 'GET', path: '/generators/typescript' })
// Back-compat: without the flag, int8 and numeric generate as `number` on reads and writes.
expect(body).toContain('"user-id": number') // int8, Row
expect(body).toContain('"user-id"?: number') // int8, Insert/Update
expect(body).toContain('days_since_event: number | null') // numeric (computed), Row
})

test('typegen: typescript w/ bigint_as=number|bigint widens writes only', async () => {
const { body } = await app.inject({
method: 'GET',
path: '/generators/typescript',
query: { bigint_as: 'number|bigint' },
})
// int8/numeric can exceed Number.MAX_SAFE_INTEGER. Insert/Update widen to the union so callers
// can pass a lossless BigInt (postgrest-js serializes it to a JSON string).
expect(body).toContain('"user-id"?: number | bigint') // int8, Update
// Reads stay `number`: PostgREST returns int8 as a lossy JSON number; cast to ::text for the exact value.
expect(body).toContain('"user-id": number') // int8, Row unchanged
expect(body).toContain('days_since_event: number | null') // numeric (computed), Row unchanged
})

test('typegen: typescript w/ bigint_as=bigint widens writes only', async () => {
const { body } = await app.inject({
method: 'GET',
path: '/generators/typescript',
query: { bigint_as: 'bigint' },
})
// `bigint` is the lossless write channel: postgrest-js serializes a BigInt to a JSON string,
// so values past Number.MAX_SAFE_INTEGER go in intact. It still only widens writes.
expect(body).toContain('"user-id"?: bigint') // int8, Update
// Reads stay `number`: PostgREST returns int8 as a lossy JSON number; cast to ::text for the exact value.
expect(body).toContain('"user-id": number') // int8, Row unchanged
expect(body).toContain('days_since_event: number | null') // numeric (computed), Row unchanged
})

test('typegen: typescript w/ postgrestVersion', async () => {
const { body } = await app.inject({
method: 'GET',
Expand Down