Skip to content

Commit 7241c07

Browse files
committed
feat: add typescript typegen endpoint
1 parent 38c325f commit 7241c07

File tree

2 files changed

+201
-0
lines changed

2 files changed

+201
-0
lines changed
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { FastifyInstance } from 'fastify'
2+
import prettier from 'prettier'
3+
import { PostgresMeta, PostgresType } from '../../../lib'
4+
import { DEFAULT_POOL_CONFIG } from '../../constants'
5+
import { extractRequestForLogging } from '../../utils'
6+
7+
export default async (fastify: FastifyInstance) => {
8+
fastify.get<{
9+
Headers: { pg: string }
10+
Querystring: {
11+
excluded_schemas?: string
12+
}
13+
}>('/', async (request, reply) => {
14+
const connectionString = request.headers.pg
15+
const excludedSchemas =
16+
request.query.excluded_schemas?.split(',').map((schema) => schema.trim()) ?? []
17+
18+
const pgMeta: PostgresMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString })
19+
const { data: schemas, error: schemasError } = await pgMeta.schemas.list()
20+
const { data: tables, error: tablesError } = await pgMeta.tables.list()
21+
const { data: functions, error: functionsError } = await pgMeta.functions.list()
22+
const { data: types, error: typesError } = await pgMeta.types.list({
23+
includeSystemSchemas: true,
24+
})
25+
await pgMeta.end()
26+
27+
if (schemasError) {
28+
request.log.error({ error: schemasError, request: extractRequestForLogging(request) })
29+
reply.code(500)
30+
return { error: schemasError.message }
31+
}
32+
if (tablesError) {
33+
request.log.error({ error: tablesError, request: extractRequestForLogging(request) })
34+
reply.code(500)
35+
return { error: tablesError.message }
36+
}
37+
if (functionsError) {
38+
request.log.error({ error: functionsError, request: extractRequestForLogging(request) })
39+
reply.code(500)
40+
return { error: functionsError.message }
41+
}
42+
if (typesError) {
43+
request.log.error({ error: typesError, request: extractRequestForLogging(request) })
44+
reply.code(500)
45+
return { error: typesError.message }
46+
}
47+
48+
let output = `
49+
export type Json = string | number | boolean | null | { [key: string]: Json } | Json[]
50+
51+
export interface Database {
52+
${schemas
53+
.filter(({ name }) => !excludedSchemas.includes(name))
54+
.map(
55+
(schema) =>
56+
`${JSON.stringify(schema.name)}: {
57+
Tables: {
58+
${tables
59+
.filter((table) => table.schema === schema.name)
60+
.map(
61+
(table) => `${JSON.stringify(table.name)}: {
62+
Row: {
63+
${table.columns.map(
64+
(column) =>
65+
`${JSON.stringify(column.name)}: ${pgTypeToTsType(column.format, types)} ${
66+
column.is_nullable ? '| null' : ''
67+
}`
68+
)}
69+
}
70+
Insert: {
71+
${table.columns.map((column) => {
72+
let output = JSON.stringify(column.name)
73+
74+
if (column.identity_generation === 'ALWAYS') {
75+
return `${output}?: never`
76+
}
77+
78+
if (
79+
column.is_nullable ||
80+
column.is_identity ||
81+
column.default_value !== null
82+
) {
83+
output += '?:'
84+
} else {
85+
output += ':'
86+
}
87+
88+
output += pgTypeToTsType(column.format, types)
89+
90+
if (column.is_nullable) {
91+
output += '| null'
92+
}
93+
94+
return output
95+
})}
96+
}
97+
Update: {
98+
${table.columns.map((column) => {
99+
let output = JSON.stringify(column.name)
100+
101+
if (column.identity_generation === 'ALWAYS') {
102+
return `${output}?: never`
103+
}
104+
105+
output += `?: ${pgTypeToTsType(column.format, types)}`
106+
107+
if (column.is_nullable) {
108+
output += '| null'
109+
}
110+
111+
return output
112+
})}
113+
}
114+
}`
115+
)}
116+
}
117+
Functions: {
118+
${functions
119+
.filter(
120+
(function_) =>
121+
function_.schema === schema.name && function_.return_type !== 'trigger'
122+
)
123+
.map(
124+
(function_) => `${JSON.stringify(function_.name)}: {
125+
Args: ${(() => {
126+
if (function_.argument_types === '') {
127+
return 'Record<PropertyKey, never>'
128+
}
129+
130+
const splitArgs = function_.argument_types.split(',').map((arg) => arg.trim())
131+
if (splitArgs.some((arg) => arg.includes('"') || !arg.includes(' '))) {
132+
return 'Record<string, unknown>'
133+
}
134+
135+
const argsNameAndType = splitArgs.map((arg) => {
136+
const [name, ...rest] = arg.split(' ')
137+
const type = types.find((_type) => _type.format === rest.join(' '))
138+
if (!type) {
139+
return { name, type: 'unknown' }
140+
}
141+
return { name, type: pgTypeToTsType(type.name, types) }
142+
})
143+
144+
return `{ ${argsNameAndType.map(
145+
({ name, type }) => `${JSON.stringify(name)}: ${type}`
146+
)} }`
147+
})()}
148+
Returns: ${pgTypeToTsType(function_.return_type, types)}
149+
}`
150+
)}
151+
}
152+
}`
153+
)}
154+
}`
155+
156+
output = prettier.format(output, {
157+
parser: 'typescript',
158+
})
159+
return output
160+
})
161+
}
162+
163+
// TODO: Make this more robust. Currently doesn't handle composite types - returns them as unknown.
164+
const pgTypeToTsType = (pgType: string, types: PostgresType[]): string => {
165+
if (pgType === 'bool') {
166+
return 'boolean'
167+
} else if (['int2', 'int4', 'int8', 'float4', 'float8', 'numeric'].includes(pgType)) {
168+
return 'number'
169+
} else if (
170+
[
171+
'bytea',
172+
'bpchar',
173+
'varchar',
174+
'date',
175+
'text',
176+
'time',
177+
'timetz',
178+
'timestamp',
179+
'timestamptz',
180+
'uuid',
181+
].includes(pgType)
182+
) {
183+
return 'string'
184+
} else if (['json', 'jsonb'].includes(pgType)) {
185+
return 'Json'
186+
} else if (pgType === 'void') {
187+
return 'undefined'
188+
} else if (pgType === 'record') {
189+
return 'Record<string, unknown>[]'
190+
} else if (pgType.startsWith('_')) {
191+
return pgTypeToTsType(pgType.substring(1), types) + '[]'
192+
} else {
193+
const type = types.find((type) => type.name === pgType && type.enums.length > 0)
194+
if (type) {
195+
return type.enums.map((variant) => JSON.stringify(variant)).join('|')
196+
}
197+
198+
return 'unknown'
199+
}
200+
}

src/server/routes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ export default async (fastify: FastifyInstance) => {
3838
fastify.register(require('./triggers'), { prefix: '/triggers' })
3939
fastify.register(require('./types'), { prefix: '/types' })
4040
fastify.register(require('./views'), { prefix: '/views' })
41+
fastify.register(require('./generators/typescript'), { prefix: '/generators/typescript' })
4142
}

0 commit comments

Comments
 (0)