schematic-pg 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -19
- package/dist/api/middleware/validate.d.ts +10 -0
- package/dist/api/middleware/validate.js +3 -0
- package/dist/api/utils/list-query.d.ts +16 -0
- package/dist/api/utils/list-query.js +99 -0
- package/dist/api/utils/omit-fields.d.ts +2 -0
- package/dist/api/utils/omit-fields.js +16 -0
- package/dist/api-generator/route-generator.d.ts +2 -0
- package/dist/api-generator/route-generator.js +59 -25
- package/dist/api-generator/utils/api-fields.d.ts +8 -0
- package/dist/api-generator/utils/api-fields.js +25 -0
- package/dist/api-generator/utils/filter-operators.d.ts +14 -0
- package/dist/api-generator/utils/filter-operators.js +92 -0
- package/dist/api-generator/zod-schema-generator.d.ts +2 -0
- package/dist/api-generator/zod-schema-generator.js +55 -0
- package/dist/cli/init.js +27 -1
- package/dist/cli/templates.d.ts +1 -0
- package/dist/cli/templates.js +10 -2
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +5 -0
- package/dist/db/db-client-generator.js +14 -5
- package/dist/db/include/executor.d.ts +3 -0
- package/dist/db/include/executor.js +90 -0
- package/dist/db/include/hydrator.d.ts +4 -0
- package/dist/db/include/hydrator.js +34 -0
- package/dist/db/include/json-agg.d.ts +5 -0
- package/dist/db/include/json-agg.js +101 -0
- package/dist/db/include/load.d.ts +8 -0
- package/dist/db/include/load.js +46 -0
- package/dist/db/include/planner.d.ts +16 -0
- package/dist/db/include/planner.js +48 -0
- package/dist/db/include/types.d.ts +14 -0
- package/dist/db/include/types.js +1 -0
- package/dist/db/model-client.d.ts +14 -12
- package/dist/db/model-client.js +35 -21
- package/dist/db/model-meta.d.ts +13 -0
- package/dist/db/model-meta.js +6 -0
- package/dist/db/type-generator.d.ts +2 -0
- package/dist/db/type-generator.js +16 -0
- package/dist/db/utils/relations.d.ts +3 -0
- package/dist/db/utils/relations.js +95 -0
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { PACKAGE_NAME } from '../constants.js';
|
|
|
2
2
|
import { getPrimaryKey } from '../sql-generator/utils/ast-helpers.js';
|
|
3
3
|
import { getClientExportName } from '../db/type-generator.js';
|
|
4
4
|
import { toRouteBasePath, toRouteFileName, toRouteImportName } from '../api/utils/route-naming.js';
|
|
5
|
+
import { toModelConstantPrefix } from './utils/api-fields.js';
|
|
5
6
|
import { hasPolicies } from './utils/policy.js';
|
|
6
7
|
export class RouteGenerator {
|
|
7
8
|
model;
|
|
@@ -19,13 +20,17 @@ export class RouteGenerator {
|
|
|
19
20
|
const pathParams = primaryKey.fields.map((field) => `:${field}`).join('/');
|
|
20
21
|
const whereFromParams = primaryKey.fields.map((field) => `${field}: params.${field}`).join(', ');
|
|
21
22
|
const paramSchemaName = `${this.model.name}ParamSchema`;
|
|
23
|
+
const listQuerySchemaName = `${this.model.name}ListQuerySchema`;
|
|
22
24
|
const modelHasPolicies = hasPolicies(this.model);
|
|
25
|
+
const constantPrefix = toModelConstantPrefix(this.model.name);
|
|
23
26
|
return [
|
|
24
27
|
'// Auto-generated by RouteGenerator. Do not edit manually.',
|
|
25
28
|
"import { Hono } from 'hono';",
|
|
26
29
|
`import type { AppEnv } from '${PACKAGE_NAME}/api/types';`,
|
|
27
|
-
`import { validateJson, validateParam } from '${PACKAGE_NAME}/api/middleware/validate';`,
|
|
30
|
+
`import { validateJson, validateParam, validateQuery } from '${PACKAGE_NAME}/api/middleware/validate';`,
|
|
28
31
|
`import { notFoundResponse } from '${PACKAGE_NAME}/api/middleware/errors';`,
|
|
32
|
+
`import { buildListQuery } from '${PACKAGE_NAME}/api/utils/list-query';`,
|
|
33
|
+
`import { omitFields, omitFieldsMany } from '${PACKAGE_NAME}/api/utils/omit-fields';`,
|
|
29
34
|
...(modelHasPolicies
|
|
30
35
|
? [
|
|
31
36
|
`import { assertPolicy, mergeWhere, resolvePolicyWhere } from '${PACKAGE_NAME}/api/auth/policy';`,
|
|
@@ -35,46 +40,75 @@ export class RouteGenerator {
|
|
|
35
40
|
` ${this.model.name}CreateSchema,`,
|
|
36
41
|
` ${this.model.name}UpdateSchema,`,
|
|
37
42
|
` ${paramSchemaName},`,
|
|
43
|
+
` ${listQuerySchemaName},`,
|
|
44
|
+
` ${constantPrefix}_LIST_QUERY_FIELDS,`,
|
|
45
|
+
` ${constantPrefix}_OMIT_FIELDS,`,
|
|
46
|
+
` ${constantPrefix}_SORTABLE_FIELDS,`,
|
|
38
47
|
`} from '../schemas/validation.js';`,
|
|
39
48
|
'',
|
|
40
49
|
'const router = new Hono<AppEnv>();',
|
|
41
50
|
'',
|
|
42
|
-
...this.generateListRoute(clientKey, modelHasPolicies),
|
|
51
|
+
...this.generateListRoute(clientKey, modelHasPolicies, listQuerySchemaName, constantPrefix),
|
|
43
52
|
'',
|
|
44
|
-
...this.generateGetRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies),
|
|
53
|
+
...this.generateGetRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix),
|
|
45
54
|
'',
|
|
46
|
-
...this.generateCreateRoute(clientKey, modelHasPolicies),
|
|
55
|
+
...this.generateCreateRoute(clientKey, modelHasPolicies, constantPrefix),
|
|
47
56
|
'',
|
|
48
|
-
...this.generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies),
|
|
57
|
+
...this.generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix),
|
|
49
58
|
'',
|
|
50
|
-
...this.generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies),
|
|
59
|
+
...this.generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix),
|
|
51
60
|
'',
|
|
52
61
|
'export default router;',
|
|
53
62
|
'',
|
|
54
63
|
].join('\n');
|
|
55
64
|
}
|
|
56
|
-
|
|
65
|
+
jsonRow(variableName, constantPrefix, statusCode) {
|
|
66
|
+
const payload = `omitFields(${variableName}, ${constantPrefix}_OMIT_FIELDS)`;
|
|
67
|
+
if (statusCode === undefined) {
|
|
68
|
+
return `c.json(${payload})`;
|
|
69
|
+
}
|
|
70
|
+
return `c.json(${payload}, ${statusCode})`;
|
|
71
|
+
}
|
|
72
|
+
jsonRows(variableName, constantPrefix) {
|
|
73
|
+
return `c.json(omitFieldsMany(${variableName}, ${constantPrefix}_OMIT_FIELDS))`;
|
|
74
|
+
}
|
|
75
|
+
generateListRoute(clientKey, modelHasPolicies, listQuerySchemaName, constantPrefix) {
|
|
76
|
+
const listQueryBlock = [
|
|
77
|
+
` const query = c.req.valid('query');`,
|
|
78
|
+
` const { where, orderBy, take, skip } = buildListQuery(`,
|
|
79
|
+
` query,`,
|
|
80
|
+
` ${constantPrefix}_LIST_QUERY_FIELDS,`,
|
|
81
|
+
` ${constantPrefix}_SORTABLE_FIELDS,`,
|
|
82
|
+
` );`,
|
|
83
|
+
];
|
|
57
84
|
if (!modelHasPolicies) {
|
|
58
85
|
return [
|
|
59
|
-
|
|
86
|
+
`router.get('/', validateQuery(${listQuerySchemaName}), async (c) => {`,
|
|
60
87
|
' const db = c.get(\'db\');',
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
...listQueryBlock,
|
|
89
|
+
` const rows = await db.${clientKey}.findMany({ where, orderBy, take, skip });`,
|
|
90
|
+
` return ${this.jsonRows('rows', constantPrefix)};`,
|
|
63
91
|
'});',
|
|
64
92
|
];
|
|
65
93
|
}
|
|
66
94
|
return [
|
|
67
|
-
|
|
95
|
+
`router.get('/', validateQuery(${listQuerySchemaName}), async (c) => {`,
|
|
68
96
|
' const db = c.get(\'db\');',
|
|
69
97
|
' const auth = c.get(\'auth\');',
|
|
70
98
|
` const policy = assertPolicy('${this.model.name}', auth.role, 'select');`,
|
|
71
99
|
' const policyWhere = resolvePolicyWhere(policy, auth);',
|
|
72
|
-
|
|
73
|
-
|
|
100
|
+
...listQueryBlock,
|
|
101
|
+
` const rows = await db.${clientKey}.findMany({`,
|
|
102
|
+
' where: mergeWhere(where, policyWhere),',
|
|
103
|
+
' orderBy,',
|
|
104
|
+
' take,',
|
|
105
|
+
' skip,',
|
|
106
|
+
' });',
|
|
107
|
+
` return ${this.jsonRows('rows', constantPrefix)};`,
|
|
74
108
|
'});',
|
|
75
109
|
];
|
|
76
110
|
}
|
|
77
|
-
generateGetRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies) {
|
|
111
|
+
generateGetRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix) {
|
|
78
112
|
if (!modelHasPolicies) {
|
|
79
113
|
return [
|
|
80
114
|
`router.get('/${pathParams}', validateParam(${paramSchemaName}), async (c) => {`,
|
|
@@ -84,7 +118,7 @@ export class RouteGenerator {
|
|
|
84
118
|
' if (!row) {',
|
|
85
119
|
' return notFoundResponse(c);',
|
|
86
120
|
' }',
|
|
87
|
-
|
|
121
|
+
` return ${this.jsonRow('row', constantPrefix)};`,
|
|
88
122
|
'});',
|
|
89
123
|
];
|
|
90
124
|
}
|
|
@@ -99,18 +133,18 @@ export class RouteGenerator {
|
|
|
99
133
|
' if (!row) {',
|
|
100
134
|
' return notFoundResponse(c);',
|
|
101
135
|
' }',
|
|
102
|
-
|
|
136
|
+
` return ${this.jsonRow('row', constantPrefix)};`,
|
|
103
137
|
'});',
|
|
104
138
|
];
|
|
105
139
|
}
|
|
106
|
-
generateCreateRoute(clientKey, modelHasPolicies) {
|
|
140
|
+
generateCreateRoute(clientKey, modelHasPolicies, constantPrefix) {
|
|
107
141
|
if (!modelHasPolicies) {
|
|
108
142
|
return [
|
|
109
143
|
`router.post('/', validateJson(${this.model.name}CreateSchema), async (c) => {`,
|
|
110
144
|
' const db = c.get(\'db\');',
|
|
111
145
|
' const body = c.req.valid(\'json\');',
|
|
112
146
|
` const row = await db.${clientKey}.create(body);`,
|
|
113
|
-
|
|
147
|
+
` return ${this.jsonRow('row', constantPrefix, 201)};`,
|
|
114
148
|
'});',
|
|
115
149
|
];
|
|
116
150
|
}
|
|
@@ -121,11 +155,11 @@ export class RouteGenerator {
|
|
|
121
155
|
` assertPolicy('${this.model.name}', auth.role, 'insert');`,
|
|
122
156
|
' const body = c.req.valid(\'json\');',
|
|
123
157
|
` const row = await db.${clientKey}.create(body);`,
|
|
124
|
-
|
|
158
|
+
` return ${this.jsonRow('row', constantPrefix, 201)};`,
|
|
125
159
|
'});',
|
|
126
160
|
];
|
|
127
161
|
}
|
|
128
|
-
generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies) {
|
|
162
|
+
generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix) {
|
|
129
163
|
if (!modelHasPolicies) {
|
|
130
164
|
return [
|
|
131
165
|
`router.put('/${pathParams}', validateParam(${paramSchemaName}), validateJson(${this.model.name}UpdateSchema), async (c) => {`,
|
|
@@ -133,7 +167,7 @@ export class RouteGenerator {
|
|
|
133
167
|
' const params = c.req.valid(\'param\');',
|
|
134
168
|
' const body = c.req.valid(\'json\');',
|
|
135
169
|
` const row = await db.${clientKey}.update({ where: { ${whereFromParams} }, data: body });`,
|
|
136
|
-
|
|
170
|
+
` return ${this.jsonRow('row', constantPrefix)};`,
|
|
137
171
|
'});',
|
|
138
172
|
];
|
|
139
173
|
}
|
|
@@ -146,18 +180,18 @@ export class RouteGenerator {
|
|
|
146
180
|
' const params = c.req.valid(\'param\');',
|
|
147
181
|
' const body = c.req.valid(\'json\');',
|
|
148
182
|
` const row = await db.${clientKey}.update({ where: mergeWhere({ ${whereFromParams} }, policyWhere), data: body });`,
|
|
149
|
-
|
|
183
|
+
` return ${this.jsonRow('row', constantPrefix)};`,
|
|
150
184
|
'});',
|
|
151
185
|
];
|
|
152
186
|
}
|
|
153
|
-
generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies) {
|
|
187
|
+
generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix) {
|
|
154
188
|
if (!modelHasPolicies) {
|
|
155
189
|
return [
|
|
156
190
|
`router.delete('/${pathParams}', validateParam(${paramSchemaName}), async (c) => {`,
|
|
157
191
|
' const db = c.get(\'db\');',
|
|
158
192
|
' const params = c.req.valid(\'param\');',
|
|
159
193
|
` const row = await db.${clientKey}.delete({ ${whereFromParams} });`,
|
|
160
|
-
|
|
194
|
+
` return ${this.jsonRow('row', constantPrefix)};`,
|
|
161
195
|
'});',
|
|
162
196
|
];
|
|
163
197
|
}
|
|
@@ -169,7 +203,7 @@ export class RouteGenerator {
|
|
|
169
203
|
' const policyWhere = resolvePolicyWhere(policy, auth);',
|
|
170
204
|
' const params = c.req.valid(\'param\');',
|
|
171
205
|
` const row = await db.${clientKey}.delete(mergeWhere({ ${whereFromParams} }, policyWhere));`,
|
|
172
|
-
|
|
206
|
+
` return ${this.jsonRow('row', constantPrefix)};`,
|
|
173
207
|
'});',
|
|
174
208
|
];
|
|
175
209
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Field, Model, Schema } from '../../schema-dsl/ast.js';
|
|
2
|
+
export declare function isStoredScalarField(field: Field, schema: Schema): boolean;
|
|
3
|
+
export declare function isUnfilterable(field: Field): boolean;
|
|
4
|
+
export declare function isOmitted(field: Field): boolean;
|
|
5
|
+
export declare function getFilterableFields(model: Model, schema: Schema): Field[];
|
|
6
|
+
export declare function getOmittedFields(model: Model, schema: Schema): Field[];
|
|
7
|
+
export declare function getSortableFieldNames(model: Model, schema: Schema): string[];
|
|
8
|
+
export declare function toModelConstantPrefix(modelName: string): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { fieldHasAttribute, getModelNames, getStoredFields, } from '../../sql-generator/utils/ast-helpers.js';
|
|
2
|
+
export function isStoredScalarField(field, schema) {
|
|
3
|
+
const modelNames = getModelNames(schema);
|
|
4
|
+
return !modelNames.has(field.type.name);
|
|
5
|
+
}
|
|
6
|
+
export function isUnfilterable(field) {
|
|
7
|
+
return fieldHasAttribute(field, 'unfilterable') || fieldHasAttribute(field, 'omit');
|
|
8
|
+
}
|
|
9
|
+
export function isOmitted(field) {
|
|
10
|
+
return fieldHasAttribute(field, 'omit');
|
|
11
|
+
}
|
|
12
|
+
export function getFilterableFields(model, schema) {
|
|
13
|
+
return getStoredFields(model, getModelNames(schema)).filter((field) => isStoredScalarField(field, schema) && !isUnfilterable(field));
|
|
14
|
+
}
|
|
15
|
+
export function getOmittedFields(model, schema) {
|
|
16
|
+
return getStoredFields(model, getModelNames(schema)).filter((field) => isStoredScalarField(field, schema) && isOmitted(field));
|
|
17
|
+
}
|
|
18
|
+
export function getSortableFieldNames(model, schema) {
|
|
19
|
+
return getStoredFields(model, getModelNames(schema))
|
|
20
|
+
.filter((field) => isStoredScalarField(field, schema))
|
|
21
|
+
.map((field) => field.name);
|
|
22
|
+
}
|
|
23
|
+
export function toModelConstantPrefix(modelName) {
|
|
24
|
+
return modelName.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Field, Schema, TypeExpr } from '../../schema-dsl/ast.js';
|
|
2
|
+
export type FilterFieldKind = 'string' | 'enum' | 'numeric' | 'boolean' | 'timestamp' | 'json' | 'other';
|
|
3
|
+
export type FilterOperator = 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'gt' | 'gte' | 'lt' | 'lte' | 'in';
|
|
4
|
+
export interface FilterFieldMeta {
|
|
5
|
+
name: string;
|
|
6
|
+
kind: FilterFieldKind;
|
|
7
|
+
operators: FilterOperator[];
|
|
8
|
+
enumValues?: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function getFilterFieldKind(field: Field, schema: Schema): FilterFieldKind;
|
|
11
|
+
export declare function getFilterOperators(kind: FilterFieldKind): FilterOperator[];
|
|
12
|
+
export declare function buildFilterFieldMeta(field: Field, schema: Schema): FilterFieldMeta;
|
|
13
|
+
export declare function queryParamKey(fieldName: string, operator: FilterOperator): string;
|
|
14
|
+
export declare function toFilterZodType(type: TypeExpr, field: Field, schema: Schema, operator: FilterOperator, coerce?: boolean): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const STRING_TYPES = new Set(['UUID', 'VARCHAR', 'TEXT']);
|
|
2
|
+
const NUMERIC_TYPES = new Set(['INTEGER', 'SERIAL', 'SMALLINT', 'DECIMAL']);
|
|
3
|
+
export function getFilterFieldKind(field, schema) {
|
|
4
|
+
const typeName = field.type.name;
|
|
5
|
+
if (schema.enums.some((enumDef) => enumDef.name === typeName)) {
|
|
6
|
+
return 'enum';
|
|
7
|
+
}
|
|
8
|
+
if (STRING_TYPES.has(typeName)) {
|
|
9
|
+
return 'string';
|
|
10
|
+
}
|
|
11
|
+
if (NUMERIC_TYPES.has(typeName)) {
|
|
12
|
+
return 'numeric';
|
|
13
|
+
}
|
|
14
|
+
if (typeName === 'BOOLEAN') {
|
|
15
|
+
return 'boolean';
|
|
16
|
+
}
|
|
17
|
+
if (typeName === 'TIMESTAMP') {
|
|
18
|
+
return 'timestamp';
|
|
19
|
+
}
|
|
20
|
+
if (typeName === 'JSONB' || typeName === 'POINT' || (field.type.array && typeName === 'TEXT')) {
|
|
21
|
+
return 'json';
|
|
22
|
+
}
|
|
23
|
+
return 'other';
|
|
24
|
+
}
|
|
25
|
+
export function getFilterOperators(kind) {
|
|
26
|
+
switch (kind) {
|
|
27
|
+
case 'string':
|
|
28
|
+
return ['equals', 'contains', 'startsWith', 'endsWith'];
|
|
29
|
+
case 'enum':
|
|
30
|
+
return ['equals', 'in'];
|
|
31
|
+
case 'numeric':
|
|
32
|
+
return ['equals', 'gt', 'gte', 'lt', 'lte'];
|
|
33
|
+
case 'boolean':
|
|
34
|
+
case 'timestamp':
|
|
35
|
+
case 'json':
|
|
36
|
+
case 'other':
|
|
37
|
+
return ['equals'];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function buildFilterFieldMeta(field, schema) {
|
|
41
|
+
const kind = getFilterFieldKind(field, schema);
|
|
42
|
+
const operators = getFilterOperators(kind);
|
|
43
|
+
const enumDef = schema.enums.find((entry) => entry.name === field.type.name);
|
|
44
|
+
return {
|
|
45
|
+
name: field.name,
|
|
46
|
+
kind,
|
|
47
|
+
operators,
|
|
48
|
+
...(enumDef ? { enumValues: enumDef.values } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function queryParamKey(fieldName, operator) {
|
|
52
|
+
if (operator === 'equals') {
|
|
53
|
+
return fieldName;
|
|
54
|
+
}
|
|
55
|
+
return `${fieldName}_${operator}`;
|
|
56
|
+
}
|
|
57
|
+
export function toFilterZodType(type, field, schema, operator, coerce = false) {
|
|
58
|
+
const prefix = coerce ? 'z.coerce.' : 'z.';
|
|
59
|
+
const enumType = schema.enums.find((enumDef) => enumDef.name === type.name);
|
|
60
|
+
if (enumType) {
|
|
61
|
+
if (operator === 'in') {
|
|
62
|
+
return `${prefix}string()`;
|
|
63
|
+
}
|
|
64
|
+
const values = enumType.values.map((value) => `'${value}'`).join(', ');
|
|
65
|
+
return `z.enum([${values}])`;
|
|
66
|
+
}
|
|
67
|
+
if (type.array && type.name === 'TEXT') {
|
|
68
|
+
return `${prefix}string()`;
|
|
69
|
+
}
|
|
70
|
+
switch (type.name) {
|
|
71
|
+
case 'UUID':
|
|
72
|
+
case 'VARCHAR':
|
|
73
|
+
case 'TEXT':
|
|
74
|
+
return `${prefix}string()`;
|
|
75
|
+
case 'INTEGER':
|
|
76
|
+
case 'SERIAL':
|
|
77
|
+
case 'SMALLINT':
|
|
78
|
+
return `${prefix}number().int()`;
|
|
79
|
+
case 'BOOLEAN':
|
|
80
|
+
return `${prefix}boolean()`;
|
|
81
|
+
case 'TIMESTAMP':
|
|
82
|
+
return 'z.coerce.date()';
|
|
83
|
+
case 'DECIMAL':
|
|
84
|
+
return `${prefix}string()`;
|
|
85
|
+
case 'JSONB':
|
|
86
|
+
return 'z.string()';
|
|
87
|
+
case 'POINT':
|
|
88
|
+
return 'z.string()';
|
|
89
|
+
default:
|
|
90
|
+
return 'z.string()';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -4,6 +4,8 @@ export declare class ZodSchemaGenerator {
|
|
|
4
4
|
constructor(schema: Schema);
|
|
5
5
|
generate(): string;
|
|
6
6
|
private generateModelSchemas;
|
|
7
|
+
private generateListQuerySchemas;
|
|
8
|
+
private generateListQueryFieldLines;
|
|
7
9
|
private generateObjectField;
|
|
8
10
|
private generateParamField;
|
|
9
11
|
private toParamZodType;
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { fieldHasAttribute, getFieldAttribute, getModelNames, getOptionalKvPair, getPrimaryKey, getStoredFields, } from '../sql-generator/utils/ast-helpers.js';
|
|
2
|
+
import { getFilterableFields, getOmittedFields, getSortableFieldNames, toModelConstantPrefix, } from './utils/api-fields.js';
|
|
3
|
+
import { buildFilterFieldMeta, queryParamKey, toFilterZodType, } from './utils/filter-operators.js';
|
|
2
4
|
export class ZodSchemaGenerator {
|
|
3
5
|
schema;
|
|
4
6
|
constructor(schema) {
|
|
5
7
|
this.schema = schema;
|
|
6
8
|
}
|
|
7
9
|
generate() {
|
|
10
|
+
const typeImports = this.schema.models.map((model) => model.name).join(',\n ');
|
|
8
11
|
const modelBlocks = this.schema.models.flatMap((model) => this.generateModelSchemas(model));
|
|
9
12
|
return [
|
|
10
13
|
'// Auto-generated by ZodSchemaGenerator. Do not edit manually.',
|
|
11
14
|
"import { z } from 'zod';",
|
|
15
|
+
`import type {\n ${typeImports},\n} from '../db-types.js';`,
|
|
12
16
|
'',
|
|
13
17
|
...modelBlocks,
|
|
14
18
|
].join('\n');
|
|
@@ -35,8 +39,59 @@ export class ZodSchemaGenerator {
|
|
|
35
39
|
if (paramLines.length > 0) {
|
|
36
40
|
blocks.push(`export const ${model.name}ParamSchema = z.object({`, ...paramLines.map((line) => ` ${line},`), `});\n`);
|
|
37
41
|
}
|
|
42
|
+
blocks.push(...this.generateListQuerySchemas(model));
|
|
38
43
|
return blocks;
|
|
39
44
|
}
|
|
45
|
+
generateListQuerySchemas(model) {
|
|
46
|
+
const prefix = toModelConstantPrefix(model.name);
|
|
47
|
+
const filterableFields = getFilterableFields(model, this.schema);
|
|
48
|
+
const sortableFields = getSortableFieldNames(model, this.schema);
|
|
49
|
+
const omittedFields = getOmittedFields(model, this.schema);
|
|
50
|
+
const filterFieldMeta = filterableFields.map((field) => buildFilterFieldMeta(field, this.schema));
|
|
51
|
+
const queryLines = filterableFields.flatMap((field) => this.generateListQueryFieldLines(field, filterFieldMeta.find((meta) => meta.name === field.name)));
|
|
52
|
+
queryLines.push('limit: z.coerce.number().int().min(1).max(100).optional(),', 'offset: z.coerce.number().int().min(0).optional(),', 'sort: z.string().optional(),');
|
|
53
|
+
const sortableLiteral = sortableFields.map((field) => `'${field}'`).join(', ');
|
|
54
|
+
const listQueryFieldsJson = JSON.stringify(filterFieldMeta, null, 2);
|
|
55
|
+
const omitFieldNames = omittedFields.map((field) => field.name);
|
|
56
|
+
const omitFieldsJson = JSON.stringify(omitFieldNames);
|
|
57
|
+
const responseType = omittedFields.length === 0
|
|
58
|
+
? `export type ${model.name}Response = ${model.name};`
|
|
59
|
+
: `export type ${model.name}Response = Omit<${model.name}, ${omitFieldNames.map((name) => `'${name}'`).join(' | ')}>;`;
|
|
60
|
+
return [
|
|
61
|
+
`export const ${prefix}_SORTABLE_FIELDS = [${sortableLiteral}] as const;`,
|
|
62
|
+
`export const ${prefix}_LIST_QUERY_FIELDS = ${listQueryFieldsJson} as const;`,
|
|
63
|
+
`export const ${prefix}_OMIT_FIELDS = ${omitFieldsJson} as const;`,
|
|
64
|
+
responseType + '\n',
|
|
65
|
+
`export const ${model.name}ListQuerySchema = z`,
|
|
66
|
+
' .object({',
|
|
67
|
+
...queryLines.map((line) => ` ${line}`),
|
|
68
|
+
' })',
|
|
69
|
+
' .superRefine((data, ctx) => {',
|
|
70
|
+
' if (data.sort === undefined) {',
|
|
71
|
+
' return;',
|
|
72
|
+
' }',
|
|
73
|
+
' const descending = data.sort.startsWith(\'-\');',
|
|
74
|
+
' const field = descending ? data.sort.slice(1) : data.sort;',
|
|
75
|
+
` if (!(${prefix}_SORTABLE_FIELDS as readonly string[]).includes(field)) {`,
|
|
76
|
+
' ctx.addIssue({',
|
|
77
|
+
' code: \'custom\',',
|
|
78
|
+
' message: `Invalid sort field "${field}"`,',
|
|
79
|
+
' path: [\'sort\'],',
|
|
80
|
+
' });',
|
|
81
|
+
' }',
|
|
82
|
+
' });\n',
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
generateListQueryFieldLines(field, meta) {
|
|
86
|
+
const lines = [];
|
|
87
|
+
for (const operator of meta.operators) {
|
|
88
|
+
const key = queryParamKey(field.name, operator);
|
|
89
|
+
const useCoerce = operator !== 'equals' || field.type.name !== 'BOOLEAN';
|
|
90
|
+
const zodType = toFilterZodType(field.type, field, this.schema, operator, useCoerce);
|
|
91
|
+
lines.push(`${key}: ${zodType}.optional(),`);
|
|
92
|
+
}
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
|
40
95
|
generateObjectField(field, mode) {
|
|
41
96
|
const zodType = this.toZodType(field, mode);
|
|
42
97
|
const fieldName = `${field.name}: ${zodType}`;
|
package/dist/cli/init.js
CHANGED
|
@@ -3,10 +3,11 @@ import { PACKAGE_NAME } from '../constants.js';
|
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { mkdir, readdir, writeFile } from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { APP_SCHEMA_TEMPLATE, createPackageJsonTemplate, DOCKER_COMPOSE_TEMPLATE, ENV_TEMPLATE, HEALTH_ROUTE_TEMPLATE, TSCONFIG_TEMPLATE, } from './templates.js';
|
|
6
|
+
import { APP_SCHEMA_TEMPLATE, createPackageJsonTemplate, DOCKER_COMPOSE_TEMPLATE, ENV_TEMPLATE, GITIGNORE_TEMPLATE, HEALTH_ROUTE_TEMPLATE, TSCONFIG_TEMPLATE, } from './templates.js';
|
|
7
7
|
const INIT_FILES = [
|
|
8
8
|
{ relativePath: 'app.schema', content: APP_SCHEMA_TEMPLATE },
|
|
9
9
|
{ relativePath: '.env', content: ENV_TEMPLATE },
|
|
10
|
+
{ relativePath: '.gitignore', content: GITIGNORE_TEMPLATE },
|
|
10
11
|
{ relativePath: 'docker-compose.yml', content: DOCKER_COMPOSE_TEMPLATE },
|
|
11
12
|
{ relativePath: 'tsconfig.json', content: TSCONFIG_TEMPLATE },
|
|
12
13
|
{ relativePath: 'src/routes/health.ts', content: HEALTH_ROUTE_TEMPLATE },
|
|
@@ -35,6 +36,23 @@ function runNpmInstall(cwd) {
|
|
|
35
36
|
});
|
|
36
37
|
});
|
|
37
38
|
}
|
|
39
|
+
function runGitInit(cwd) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const child = spawn('git', ['init'], {
|
|
42
|
+
cwd,
|
|
43
|
+
stdio: 'inherit',
|
|
44
|
+
shell: process.platform === 'win32',
|
|
45
|
+
});
|
|
46
|
+
child.on('error', reject);
|
|
47
|
+
child.on('close', (code) => {
|
|
48
|
+
if (code === 0) {
|
|
49
|
+
resolve();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
reject(new Error(`git init failed with exit code ${code}`));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
38
56
|
export async function runInit(args) {
|
|
39
57
|
const skipInstall = args.includes('--skip-install');
|
|
40
58
|
const targetDir = resolveTargetDir(args);
|
|
@@ -68,6 +86,14 @@ export async function runInit(args) {
|
|
|
68
86
|
console.log('\nRunning npm install...');
|
|
69
87
|
await runNpmInstall(targetDir);
|
|
70
88
|
}
|
|
89
|
+
const gitDir = path.join(targetDir, '.git');
|
|
90
|
+
if (existsSync(gitDir)) {
|
|
91
|
+
console.log('\nSkipped git init (already a repository)');
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
console.log('\nRunning git init...');
|
|
95
|
+
await runGitInit(targetDir);
|
|
96
|
+
}
|
|
71
97
|
console.log('\nNext steps:');
|
|
72
98
|
if (targetDir !== process.cwd()) {
|
|
73
99
|
console.log(` cd ${targetDir}`);
|
package/dist/cli/templates.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare const APP_SCHEMA_TEMPLATE = "models {\n model User {\n id: UUID @id @default(gen_random_uuid())\n email: VARCHAR(255) @unique\n name: VARCHAR(150)\n createdAt: TIMESTAMP @default(now())\n }\n}\n";
|
|
2
2
|
export declare const ENV_TEMPLATE = "DATABASE_URL=postgresql://postgrest:postgrest@localhost:5432/postgrest\nJWT_SECRET=\nJWT_ROLE_CLAIM=role\nJWT_USER_ID_CLAIM=sub\n";
|
|
3
|
+
export declare const GITIGNORE_TEMPLATE = "node_modules/\ndist/\n.env\ndocker_data/\n.DS_Store\n*.log\nnpm-debug.log*\n";
|
|
3
4
|
export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgis/postgis:16-3.4\n container_name: schematic-pg-postgres\n restart: unless-stopped\n ports:\n - \"5432:5432\"\n environment:\n POSTGRES_USER: postgrest\n POSTGRES_PASSWORD: postgrest\n POSTGRES_DB: postgrest\n volumes:\n - ./docker_data/postgres:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgrest -d postgrest\"]\n interval: 5s\n timeout: 5s\n retries: 5\n";
|
|
4
5
|
export declare const TSCONFIG_TEMPLATE = "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"strict\": true,\n \"esModuleInterop\": true,\n \"skipLibCheck\": true,\n \"outDir\": \"dist\",\n \"rootDir\": \".\"\n },\n \"include\": [\"generated/**/*\", \"src/**/*\"]\n}\n";
|
|
5
6
|
export declare const HEALTH_ROUTE_TEMPLATE = "import { Hono } from 'hono';\nimport type { AppEnv } from 'schematic-pg/api/types';\n\nconst router = new Hono<AppEnv>();\nrouter.get('/', (c) => c.json({ ok: true }));\nexport default router;\n";
|
package/dist/cli/templates.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PACKAGE_NAME } from '../constants.js';
|
|
1
|
+
import { PACKAGE_NAME, PACKAGE_VERSION } from '../constants.js';
|
|
2
2
|
export const APP_SCHEMA_TEMPLATE = `models {
|
|
3
3
|
model User {
|
|
4
4
|
id: UUID @id @default(gen_random_uuid())
|
|
@@ -13,6 +13,14 @@ JWT_SECRET=
|
|
|
13
13
|
JWT_ROLE_CLAIM=role
|
|
14
14
|
JWT_USER_ID_CLAIM=sub
|
|
15
15
|
`;
|
|
16
|
+
export const GITIGNORE_TEMPLATE = `node_modules/
|
|
17
|
+
dist/
|
|
18
|
+
.env
|
|
19
|
+
docker_data/
|
|
20
|
+
.DS_Store
|
|
21
|
+
*.log
|
|
22
|
+
npm-debug.log*
|
|
23
|
+
`;
|
|
16
24
|
export const DOCKER_COMPOSE_TEMPLATE = `services:
|
|
17
25
|
postgres:
|
|
18
26
|
image: postgis/postgis:16-3.4
|
|
@@ -70,7 +78,7 @@ export function createPackageJsonTemplate(projectName) {
|
|
|
70
78
|
'@hono/zod-validator': '^0.8.0',
|
|
71
79
|
hono: '^4.12.27',
|
|
72
80
|
pg: '^8.22.0',
|
|
73
|
-
[PACKAGE_NAME]:
|
|
81
|
+
[PACKAGE_NAME]: `^${PACKAGE_VERSION}`,
|
|
74
82
|
zod: '^4.4.3',
|
|
75
83
|
},
|
|
76
84
|
devDependencies: {
|
package/dist/constants.d.ts
CHANGED
package/dist/constants.js
CHANGED
|
@@ -14,9 +14,18 @@ export class DbClientGenerator {
|
|
|
14
14
|
`${model.name}UpdateInput`,
|
|
15
15
|
`${model.name}WhereInput`,
|
|
16
16
|
`${model.name}OrderByInput`,
|
|
17
|
+
`${model.name}Include`,
|
|
17
18
|
])
|
|
18
19
|
.join(',\n ');
|
|
19
20
|
const clientEntries = this.schema.models.map((model) => this.generateClientEntry(model.name));
|
|
21
|
+
const metaHydrations = this.schema.models.map((model) => {
|
|
22
|
+
const clientKey = getClientExportName(model.name);
|
|
23
|
+
return ` const ${clientKey}Meta = hydrateModelMeta(${clientKey}ModelMeta);`;
|
|
24
|
+
});
|
|
25
|
+
const registryEntries = this.schema.models.map((model) => {
|
|
26
|
+
const clientKey = getClientExportName(model.name);
|
|
27
|
+
return ` ['${model.name}', ${clientKey}Meta]`;
|
|
28
|
+
});
|
|
20
29
|
return [
|
|
21
30
|
'// Auto-generated by DbClientGenerator. Do not edit manually.',
|
|
22
31
|
"import type { Pool } from 'pg';",
|
|
@@ -26,10 +35,10 @@ export class DbClientGenerator {
|
|
|
26
35
|
`import {\n ${this.schema.models.map((model) => `${getClientExportName(model.name)}ModelMeta`).join(',\n ')},\n} from './db-model-meta.js';`,
|
|
27
36
|
'',
|
|
28
37
|
'export function createDbClient(pool: Pool) {',
|
|
29
|
-
...
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
...metaHydrations,
|
|
39
|
+
` const modelRegistry = new Map([`,
|
|
40
|
+
...registryEntries.map((entry) => ` ${entry},`),
|
|
41
|
+
' ]);',
|
|
33
42
|
'',
|
|
34
43
|
' return {',
|
|
35
44
|
clientEntries.map((entry) => ` ${entry},`).join('\n'),
|
|
@@ -56,7 +65,7 @@ export class DbClientGenerator {
|
|
|
56
65
|
}
|
|
57
66
|
generateClientEntry(modelName) {
|
|
58
67
|
const clientKey = getClientExportName(modelName);
|
|
59
|
-
return `${clientKey}: createModelClient<${modelName}, ${modelName}CreateInput, ${modelName}UpdateInput, ${modelName}WhereInput, ${modelName}OrderByInput>(${clientKey}Meta, pool)`;
|
|
68
|
+
return `${clientKey}: createModelClient<${modelName}, ${modelName}CreateInput, ${modelName}UpdateInput, ${modelName}WhereInput, ${modelName}OrderByInput>(${clientKey}Meta, pool, modelRegistry)`;
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
71
|
export function generateDbClientFiles(schema) {
|