Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(typegen): generate types for columns with json_schema constraint #814

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
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
384 changes: 295 additions & 89 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"crypto-js": "^4.0.0",
"fastify": "^4.24.3",
"fastify-metrics": "^10.0.0",
"json-schema-to-typescript": "^15.0.2",
"pg": "^8.7.1",
"pg-connection-string": "^2.5.0",
"pg-format": "^1.0.4",
Expand Down
2 changes: 1 addition & 1 deletion src/server/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ if (PG_META_DB_SSL_ROOT_CERT) {
export const EXPORT_DOCS = process.env.PG_META_EXPORT_DOCS === 'true'
export const GENERATE_TYPES = process.env.PG_META_GENERATE_TYPES
export const GENERATE_TYPES_INCLUDED_SCHEMAS = GENERATE_TYPES
? (process.env.PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS?.split(',') ?? [])
? process.env.PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS?.split(',') ?? []
: []
export const GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS =
process.env.PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS === 'true'
Expand Down
51 changes: 47 additions & 4 deletions src/server/templates/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
PostgresView,
} from '../../lib/index.js'
import type { GeneratorMetadata } from '../../lib/generators.js'
import { generateTypeFromCheckConstraint } from '../utils.js'

export const apply = async ({
schemas,
Expand All @@ -26,10 +27,37 @@ export const apply = async ({
const columnsByTableId = Object.fromEntries<PostgresColumn[]>(
[...tables, ...foreignTables, ...views, ...materializedViews].map((t) => [t.id, []])
)
columns
.filter((c) => c.table_id in columnsByTableId)
.sort(({ name: a }, { name: b }) => a.localeCompare(b))
.forEach((c) => columnsByTableId[c.table_id].push(c))

const jsonSchemaTs: Record<string, any> = {}

await Promise.all(
columns
.filter((c) => c.table_id in columnsByTableId)
.sort(({ name: a }, { name: b }) => a.localeCompare(b))
.map(async (c) => {
// If the column is of type json/jsonb and has a check constraint that matches a JSON schema, we'll generate a type for it.
if (
['json', 'jsonb'].includes(c.format.toLowerCase()) &&
/json_matches_schema|jsonb_matches_schema/gi.test(c.check ?? '')
) {
const ts = await generateTypeFromCheckConstraint(c.check)
// The only mutation required on the column is to reference the correct type
c.format = `json_schema_Database['${c.schema}']['SchemaTypes']['${c.table}']['${c.name}']`

if (!jsonSchemaTs[c.schema]) {
jsonSchemaTs[c.schema] = {}
}

if (!jsonSchemaTs[c.schema][c.table]) {
jsonSchemaTs[c.schema][c.table] = {}
}

jsonSchemaTs[c.schema][c.table][c.name] = ts
}

columnsByTableId[c.table_id].push(c)
})
)

let output = `
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]
Expand Down Expand Up @@ -367,6 +395,19 @@ export type Database = {
)
}
}
SchemaTypes: {
${schemaTables
.filter((table) => jsonSchemaTs[schema.name][table.name])
.map(
(table) => `
${table.name}: {
${Object.entries(jsonSchemaTs[schema.name][table.name] ?? {}).map(
([columnName, ts]) => `${JSON.stringify(columnName)}: ${ts}`
)}
}
`
)}
}
CompositeTypes: {
${
schemaCompositeTypes.length === 0
Expand Down Expand Up @@ -529,6 +570,8 @@ const pgTypeToTsType = (
return 'string'
} else if (['json', 'jsonb'].includes(pgType)) {
return 'Json'
} else if (/^json_schema_/gi.test(pgType)) {
return pgType.replace(/json_schema_/, '')
} else if (pgType === 'void') {
return 'undefined'
} else if (pgType === 'record') {
Expand Down
27 changes: 27 additions & 0 deletions src/server/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pgcs from 'pg-connection-string'
import { FastifyRequest } from 'fastify'
import { compile } from 'json-schema-to-typescript'

export const extractRequestForLogging = (request: FastifyRequest) => {
let pg: string = 'unknown'
Expand Down Expand Up @@ -32,3 +33,29 @@ export function translateErrorToResponseCode(
}
return defaultResponseCode
}

export async function generateTypeFromCheckConstraint(
checkConstraints: string | null
): Promise<string> {
if (!checkConstraints) {
throw new Error('check constraint is empty')
}

if (typeof checkConstraints !== 'string') {
throw new Error('invalid input type')
}

const match = /jsonb?_matches_schema\(\'([\{|\[].*[\}|\]])/gms.exec(checkConstraints)
const extractedJsonStr = match ? match[1] : null
const jsonSchema = JSON.parse(extractedJsonStr ?? '{}')
const tsType = await compile(jsonSchema, 'Type', {
bannerComment: '',
style: {
singleQuote: true,
semi: false,
},
format: false
})

return tsType.replaceAll('export interface Type ', '')
}
1 change: 1 addition & 0 deletions test/db/00-init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ $$
select string_to_array($1.details, ' ');
$$ language sql stable;

create extension pg_jsonschema;
create extension postgres_fdw;
create server foreign_server foreign data wrapper postgres_fdw options (host 'localhost', port '5432', dbname 'postgres');
create user mapping for postgres server foreign_server options (user 'postgres', password 'postgres');
Expand Down
61 changes: 59 additions & 2 deletions test/db/01-memes.sql
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@


CREATE TABLE public.category (
id serial NOT NULL PRIMARY KEY,
name text NOT NULL
Expand Down Expand Up @@ -29,10 +27,69 @@ CREATE TABLE public.memes (
name text NOT NULL,
category INTEGER REFERENCES category(id),
metadata jsonb,
other_check_metadata jsonb,
json_metadata json,
free_metadata jsonb,
created_at TIMESTAMP NOT NULL,
status meme_status DEFAULT 'old'
);

ALTER TABLE public.memes ADD CONSTRAINT json_metadata_schema_check
CHECK (
(json_matches_schema('{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"popularity_score": {
"type": "integer"
},
"name": {
"type": "string"
}
},
"additionalProperties": false
}', json_metadata))
);

ALTER TABLE public.memes ADD CONSTRAINT metadata_schema_check
CHECK (
(jsonb_matches_schema('{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"popularity_score": {
"type": "integer"
},
"name": {
"type": "string"
},
"address": {
"type": "object",
"properties": {
"city": {
"type": "string"
},
"street": {
"type": "string"
}
},
"required": [
"city",
"street"
]
}
},
"required": [
"popoularity_score"
]
}', metadata))
);

ALTER TABLE public.memes ADD CONSTRAINT other_check_metadata_schema_check
CHECK (
(other_check_metadata <> '{}'::jsonb)
);

INSERT INTO public.memes (name, category, created_at) VALUES
('NO. Rage Face', 5, NOW()),
('"Not Bad" Obama Face', 5, NOW()),
Expand Down
9 changes: 9 additions & 0 deletions test/db/Dockerfile
Copy link
Author

Choose a reason for hiding this comment

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

Copied the implementation from the supabase/postgres Dockerfile.

Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
FROM supabase/postgres:14.1.0

COPY --chown=postgres:postgres --chmod=600 server.key server.crt /var/lib/postgresql/

ADD "https://github.com/supabase/pg_jsonschema/releases/download/v0.1.4/pg_jsonschema-v0.1.4-pg14-amd64-linux-gnu.deb" \
/tmp/pg_jsonschema.deb

RUN apt-get update && apt-get install -y --no-install-recommends \
/tmp/*.deb \
# Needed for anything using libcurl
# https://github.com/supabase/postgres/issues/573
ca-certificates
Loading