schematic-pg 0.1.6 → 0.1.7

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 CHANGED
@@ -770,8 +770,8 @@ Every router exposes the same CRUD shape. Models with `@policy` attributes enfor
770
770
 
771
771
  | Method | Path | Handler | Validation |
772
772
  |--------|------|---------|------------|
773
- | `GET` | `/` | `findMany({ where: mergeWhere(queryWhere, policyWhere), orderBy, take, skip })` | Query params |
774
- | `GET` | `/{pk}` | `findUnique(mergeWhere(pk, policyWhere))` | Path params |
773
+ | `GET` | `/` | `findMany({ where: mergeWhere(queryWhere, policyWhere), orderBy, take, skip, include })` | Query params |
774
+ | `GET` | `/{pk}` | `findUnique(mergeWhere(pk, policyWhere), { include })` | Path params + query params |
775
775
  | `POST` | `/` | `create(body)` — policy check only | JSON body |
776
776
  | `PUT` | `/{pk}` | `update({ where: mergeWhere(pk, policyWhere), data })` | Path params + JSON body |
777
777
  | `DELETE` | `/{pk}` | `delete(mergeWhere(pk, policyWhere))` | Path params |
@@ -793,17 +793,43 @@ Query params use **API field names** (camelCase), not SQL column names:
793
793
  | `?limit=20` | `take: 20` (max 100) |
794
794
  | `?offset=40` | `skip: 40` |
795
795
  | `?sort=-createdAt` | `orderBy: { createdAt: 'desc' }` |
796
+ | `?include=profile,orders` | `include: { profile: true, orders: true }` |
797
+ | `?include=orders.products.product` | nested boolean includes |
796
798
 
797
799
  On models with `@policy`, user filters are combined with the policy row filter via `mergeWhere` (AND). A USER calling `GET /users?role=ADMIN` still only sees rows allowed by policy.
798
800
 
799
801
  ```bash
800
802
  curl "http://localhost:3000/products?category=books&limit=10"
801
803
  curl "http://localhost:3000/users?role=USER&isActive=true" -H "Authorization: Bearer $TOKEN"
804
+ curl "http://localhost:3000/users/USER_ID?include=profile,orders" -H "Authorization: Bearer $TOKEN"
802
805
  ```
803
806
 
807
+ ### Relation includes (`GET /`, `GET /{pk}`)
808
+
809
+ Load related models via the `include` query param. Paths are comma-separated; use dots for nesting:
810
+
811
+ ```bash
812
+ curl "http://localhost:3000/users?include=profile,orders"
813
+ curl "http://localhost:3000/users/USER_ID?include=orders.products.product"
814
+ ```
815
+
816
+ Each segment must name a relation field on the current model (or nested target model). Unknown relations return `400`. Maximum depth and path count are capped (see `MAX_INCLUDE_DEPTH` / `MAX_INCLUDE_PATHS` in the runtime).
817
+
818
+ Opt out of HTTP includes on a relation field with `@unincludeable`:
819
+
820
+ ```ts
821
+ orders: Order[] @unincludeable
822
+ ```
823
+
824
+ **v1 limits:**
825
+
826
+ - Boolean includes only — no nested `where`, `take`, or `skip` via URL (use the DB client or a custom route for that).
827
+ - `@policy` row filters apply to the **root** model only; included relations are not policy-filtered separately.
828
+ - `@omit` fields are stripped recursively on nested included objects in read responses.
829
+
804
830
  ### Response shaping (`@omit`)
805
831
 
806
- Mark sensitive stored fields with `@omit` to exclude them from generated route JSON responses (`GET`, `POST`, `PUT`, `DELETE`). The ORM client still returns full entities.
832
+ Mark sensitive stored fields with `@omit` to exclude them from generated route JSON responses. On read endpoints with `include`, omitted fields are stripped recursively on nested relation objects as well. Mutation responses (`POST`, `PUT`, `DELETE`) strip `@omit` fields on the root model only. The ORM client still returns full entities.
807
833
 
808
834
  ```ts
809
835
  passwordHash: VARCHAR(255) @omit @unfilterable @default("")
@@ -0,0 +1,6 @@
1
+ import type { IncludeInput } from '../../db/include/types.js';
2
+ export interface IncludableRelationTree {
3
+ [relationName: string]: IncludableRelationTree;
4
+ }
5
+ export declare function validateIncludePaths(raw: string, tree: IncludableRelationTree): string | undefined;
6
+ export declare function parseIncludeQuery(raw: string, tree: IncludableRelationTree): IncludeInput;
@@ -0,0 +1,75 @@
1
+ import { MAX_INCLUDE_DEPTH, MAX_INCLUDE_PATHS } from '../../constants.js';
2
+ export function validateIncludePaths(raw, tree) {
3
+ const segments = splitIncludePaths(raw);
4
+ if (segments.some((segment) => segment.length === 0)) {
5
+ return 'Include paths cannot contain empty relation segments';
6
+ }
7
+ if (segments.length === 0) {
8
+ return 'Include parameter must contain at least one relation path';
9
+ }
10
+ if (segments.length > MAX_INCLUDE_PATHS) {
11
+ return `Include parameter exceeds maximum of ${MAX_INCLUDE_PATHS} paths`;
12
+ }
13
+ for (const segment of segments) {
14
+ const parts = segment.split('.');
15
+ if (parts.length > MAX_INCLUDE_DEPTH) {
16
+ return `Include path "${segment}" exceeds maximum depth of ${MAX_INCLUDE_DEPTH}`;
17
+ }
18
+ let currentTree = tree;
19
+ for (const part of parts) {
20
+ if (!part) {
21
+ return 'Include paths cannot contain empty relation segments';
22
+ }
23
+ const nextTree = currentTree[part];
24
+ if (!nextTree) {
25
+ return `Unknown include relation "${part}" in path "${segment}"`;
26
+ }
27
+ currentTree = nextTree;
28
+ }
29
+ }
30
+ return undefined;
31
+ }
32
+ export function parseIncludeQuery(raw, tree) {
33
+ const error = validateIncludePaths(raw, tree);
34
+ if (error) {
35
+ throw new Error(error);
36
+ }
37
+ const root = {};
38
+ for (const segment of splitIncludePaths(raw)) {
39
+ mergeIncludePath(root, segment.split('.'));
40
+ }
41
+ return toIncludeInput(root);
42
+ }
43
+ function splitIncludePaths(raw) {
44
+ return raw.split(',').map((segment) => segment.trim());
45
+ }
46
+ function mergeIncludePath(root, parts) {
47
+ let current = root;
48
+ for (let index = 0; index < parts.length; index += 1) {
49
+ const part = parts[index];
50
+ const isLeaf = index === parts.length - 1;
51
+ if (!current[part]) {
52
+ current[part] = {};
53
+ }
54
+ if (isLeaf) {
55
+ continue;
56
+ }
57
+ if (!current[part].include) {
58
+ current[part].include = {};
59
+ }
60
+ current = current[part].include;
61
+ }
62
+ }
63
+ function toIncludeInput(nodes) {
64
+ const include = {};
65
+ for (const [relationName, node] of Object.entries(nodes)) {
66
+ if (node.include && Object.keys(node.include).length > 0) {
67
+ include[relationName] = {
68
+ include: toIncludeInput(node.include),
69
+ };
70
+ continue;
71
+ }
72
+ include[relationName] = true;
73
+ }
74
+ return include;
75
+ }
@@ -0,0 +1,12 @@
1
+ import type { FilterFieldMeta } from './list-query.js';
2
+ import { buildListQuery } from './list-query.js';
3
+ import type { IncludeInput } from '../../db/include/types.js';
4
+ import type { IncludableRelationTree } from './include-query.js';
5
+ export interface ReadQueryResult {
6
+ where: ReturnType<typeof buildListQuery>['where'];
7
+ orderBy?: ReturnType<typeof buildListQuery>['orderBy'];
8
+ take?: number;
9
+ skip?: number;
10
+ include?: IncludeInput;
11
+ }
12
+ export declare function buildReadQuery(query: Record<string, unknown>, fields: readonly FilterFieldMeta[], sortableFields: readonly string[], includableRelations: IncludableRelationTree): ReadQueryResult;
@@ -0,0 +1,10 @@
1
+ import { buildListQuery } from './list-query.js';
2
+ import { parseIncludeQuery } from './include-query.js';
3
+ export function buildReadQuery(query, fields, sortableFields, includableRelations) {
4
+ const { where, orderBy, take, skip } = buildListQuery(query, fields, sortableFields);
5
+ const result = { where, orderBy, take, skip };
6
+ if (typeof query.include === 'string' && query.include.length > 0) {
7
+ result.include = parseIncludeQuery(query.include, includableRelations);
8
+ }
9
+ return result;
10
+ }
@@ -0,0 +1,2 @@
1
+ export declare function shapeResponse<T extends Record<string, unknown>>(row: T, modelName: string, omitByModel: Readonly<Record<string, readonly string[]>>, relationTargets: Readonly<Record<string, Readonly<Record<string, string>>>>): T;
2
+ export declare function shapeResponseMany<T extends Record<string, unknown>>(rows: T[], modelName: string, omitByModel: Readonly<Record<string, readonly string[]>>, relationTargets: Readonly<Record<string, Readonly<Record<string, string>>>>): T[];
@@ -0,0 +1,28 @@
1
+ export function shapeResponse(row, modelName, omitByModel, relationTargets) {
2
+ const omitted = omitByModel[modelName] ?? [];
3
+ const relations = relationTargets[modelName] ?? {};
4
+ const result = { ...row };
5
+ for (const field of omitted) {
6
+ delete result[field];
7
+ }
8
+ for (const [relationName, targetModel] of Object.entries(relations)) {
9
+ if (!(relationName in result)) {
10
+ continue;
11
+ }
12
+ const value = result[relationName];
13
+ if (value === null || value === undefined) {
14
+ continue;
15
+ }
16
+ if (Array.isArray(value)) {
17
+ result[relationName] = value.map((entry) => shapeResponse(entry, targetModel, omitByModel, relationTargets));
18
+ continue;
19
+ }
20
+ if (typeof value === 'object') {
21
+ result[relationName] = shapeResponse(value, targetModel, omitByModel, relationTargets);
22
+ }
23
+ }
24
+ return result;
25
+ }
26
+ export function shapeResponseMany(rows, modelName, omitByModel, relationTargets) {
27
+ return rows.map((row) => shapeResponse(row, modelName, omitByModel, relationTargets));
28
+ }
@@ -6,6 +6,7 @@ export declare class RouteGenerator {
6
6
  generate(): string;
7
7
  private jsonRow;
8
8
  private jsonRows;
9
+ private mutationJsonRow;
9
10
  private generateListRoute;
10
11
  private generateGetRoute;
11
12
  private generateCreateRoute;
@@ -21,6 +21,7 @@ export class RouteGenerator {
21
21
  const whereFromParams = primaryKey.fields.map((field) => `${field}: params.${field}`).join(', ');
22
22
  const paramSchemaName = `${this.model.name}ParamSchema`;
23
23
  const listQuerySchemaName = `${this.model.name}ListQuerySchema`;
24
+ const getQuerySchemaName = `${this.model.name}GetQuerySchema`;
24
25
  const modelHasPolicies = hasPolicies(this.model);
25
26
  const constantPrefix = toModelConstantPrefix(this.model.name);
26
27
  return [
@@ -29,8 +30,10 @@ export class RouteGenerator {
29
30
  `import type { AppEnv } from '${PACKAGE_NAME}/api/types';`,
30
31
  `import { validateJson, validateParam, validateQuery } from '${PACKAGE_NAME}/api/middleware/validate';`,
31
32
  `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';`,
33
+ `import { buildReadQuery } from '${PACKAGE_NAME}/api/utils/read-query';`,
34
+ `import { parseIncludeQuery } from '${PACKAGE_NAME}/api/utils/include-query';`,
35
+ `import { omitFields } from '${PACKAGE_NAME}/api/utils/omit-fields';`,
36
+ `import { shapeResponse, shapeResponseMany } from '${PACKAGE_NAME}/api/utils/response-shape';`,
34
37
  ...(modelHasPolicies
35
38
  ? [
36
39
  `import { assertPolicy, mergeWhere, resolvePolicyWhere } from '${PACKAGE_NAME}/api/auth/policy';`,
@@ -41,16 +44,20 @@ export class RouteGenerator {
41
44
  ` ${this.model.name}UpdateSchema,`,
42
45
  ` ${paramSchemaName},`,
43
46
  ` ${listQuerySchemaName},`,
47
+ ` ${getQuerySchemaName},`,
44
48
  ` ${constantPrefix}_LIST_QUERY_FIELDS,`,
49
+ ` ${constantPrefix}_INCLUDABLE_RELATIONS,`,
45
50
  ` ${constantPrefix}_OMIT_FIELDS,`,
46
51
  ` ${constantPrefix}_SORTABLE_FIELDS,`,
52
+ ` API_OMIT_FIELDS_BY_MODEL,`,
53
+ ` API_RELATION_TARGETS,`,
47
54
  `} from '../schemas/validation.js';`,
48
55
  '',
49
56
  'const router = new Hono<AppEnv>();',
50
57
  '',
51
58
  ...this.generateListRoute(clientKey, modelHasPolicies, listQuerySchemaName, constantPrefix),
52
59
  '',
53
- ...this.generateGetRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix),
60
+ ...this.generateGetRoute(clientKey, pathParams, paramSchemaName, getQuerySchemaName, whereFromParams, modelHasPolicies, constantPrefix),
54
61
  '',
55
62
  ...this.generateCreateRoute(clientKey, modelHasPolicies, constantPrefix),
56
63
  '',
@@ -62,32 +69,45 @@ export class RouteGenerator {
62
69
  '',
63
70
  ].join('\n');
64
71
  }
65
- jsonRow(variableName, constantPrefix, statusCode) {
66
- const payload = `omitFields(${variableName}, ${constantPrefix}_OMIT_FIELDS)`;
72
+ jsonRow(variableName, modelName, constantPrefix, statusCode) {
73
+ const payload = `shapeResponse(${variableName}, '${modelName}', API_OMIT_FIELDS_BY_MODEL, API_RELATION_TARGETS)`;
67
74
  if (statusCode === undefined) {
68
75
  return `c.json(${payload})`;
69
76
  }
70
77
  return `c.json(${payload}, ${statusCode})`;
71
78
  }
72
- jsonRows(variableName, constantPrefix) {
73
- return `c.json(omitFieldsMany(${variableName}, ${constantPrefix}_OMIT_FIELDS))`;
79
+ jsonRows(variableName, modelName) {
80
+ return `c.json(shapeResponseMany(${variableName}, '${modelName}', API_OMIT_FIELDS_BY_MODEL, API_RELATION_TARGETS))`;
81
+ }
82
+ mutationJsonRow(variableName, constantPrefix, statusCode) {
83
+ const payload = `omitFields(${variableName}, ${constantPrefix}_OMIT_FIELDS)`;
84
+ if (statusCode === undefined) {
85
+ return `c.json(${payload})`;
86
+ }
87
+ return `c.json(${payload}, ${statusCode})`;
74
88
  }
75
89
  generateListRoute(clientKey, modelHasPolicies, listQuerySchemaName, constantPrefix) {
76
90
  const listQueryBlock = [
77
91
  ` const query = c.req.valid('query');`,
78
- ` const { where, orderBy, take, skip } = buildListQuery(`,
92
+ ` const { where, orderBy, take, skip, include } = buildReadQuery(`,
79
93
  ` query,`,
80
94
  ` ${constantPrefix}_LIST_QUERY_FIELDS,`,
81
95
  ` ${constantPrefix}_SORTABLE_FIELDS,`,
96
+ ` ${constantPrefix}_INCLUDABLE_RELATIONS,`,
82
97
  ` );`,
83
98
  ];
99
+ const findManyArgs = ['where', 'orderBy', 'take', 'skip', 'include']
100
+ .map((key) => ` ${key},`)
101
+ .join('\n');
84
102
  if (!modelHasPolicies) {
85
103
  return [
86
104
  `router.get('/', validateQuery(${listQuerySchemaName}), async (c) => {`,
87
105
  ' const db = c.get(\'db\');',
88
106
  ...listQueryBlock,
89
- ` const rows = await db.${clientKey}.findMany({ where, orderBy, take, skip });`,
90
- ` return ${this.jsonRows('rows', constantPrefix)};`,
107
+ ` const rows = await db.${clientKey}.findMany({`,
108
+ findManyArgs,
109
+ ' });',
110
+ ` return ${this.jsonRows('rows', this.model.name)};`,
91
111
  '});',
92
112
  ];
93
113
  }
@@ -103,37 +123,46 @@ export class RouteGenerator {
103
123
  ' orderBy,',
104
124
  ' take,',
105
125
  ' skip,',
126
+ ' include,',
106
127
  ' });',
107
- ` return ${this.jsonRows('rows', constantPrefix)};`,
128
+ ` return ${this.jsonRows('rows', this.model.name)};`,
108
129
  '});',
109
130
  ];
110
131
  }
111
- generateGetRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix) {
132
+ generateGetRoute(clientKey, pathParams, paramSchemaName, getQuerySchemaName, whereFromParams, modelHasPolicies, constantPrefix) {
133
+ const includeBlock = [
134
+ ' const query = c.req.valid(\'query\');',
135
+ ` const include = query.include`,
136
+ ` ? parseIncludeQuery(query.include, ${constantPrefix}_INCLUDABLE_RELATIONS)`,
137
+ ' : undefined;',
138
+ ];
112
139
  if (!modelHasPolicies) {
113
140
  return [
114
- `router.get('/${pathParams}', validateParam(${paramSchemaName}), async (c) => {`,
141
+ `router.get('/${pathParams}', validateParam(${paramSchemaName}), validateQuery(${getQuerySchemaName}), async (c) => {`,
115
142
  ' const db = c.get(\'db\');',
116
143
  ' const params = c.req.valid(\'param\');',
117
- ` const row = await db.${clientKey}.findUnique({ ${whereFromParams} });`,
144
+ ...includeBlock,
145
+ ` const row = await db.${clientKey}.findUnique({ ${whereFromParams} }, { include });`,
118
146
  ' if (!row) {',
119
147
  ' return notFoundResponse(c);',
120
148
  ' }',
121
- ` return ${this.jsonRow('row', constantPrefix)};`,
149
+ ` return ${this.jsonRow('row', this.model.name, constantPrefix)};`,
122
150
  '});',
123
151
  ];
124
152
  }
125
153
  return [
126
- `router.get('/${pathParams}', validateParam(${paramSchemaName}), async (c) => {`,
154
+ `router.get('/${pathParams}', validateParam(${paramSchemaName}), validateQuery(${getQuerySchemaName}), async (c) => {`,
127
155
  ' const db = c.get(\'db\');',
128
156
  ' const auth = c.get(\'auth\');',
129
157
  ` const policy = assertPolicy('${this.model.name}', auth.role, 'select');`,
130
158
  ' const policyWhere = resolvePolicyWhere(policy, auth);',
131
159
  ' const params = c.req.valid(\'param\');',
132
- ` const row = await db.${clientKey}.findUnique(mergeWhere({ ${whereFromParams} }, policyWhere));`,
160
+ ...includeBlock,
161
+ ` const row = await db.${clientKey}.findUnique(mergeWhere({ ${whereFromParams} }, policyWhere), { include });`,
133
162
  ' if (!row) {',
134
163
  ' return notFoundResponse(c);',
135
164
  ' }',
136
- ` return ${this.jsonRow('row', constantPrefix)};`,
165
+ ` return ${this.jsonRow('row', this.model.name, constantPrefix)};`,
137
166
  '});',
138
167
  ];
139
168
  }
@@ -144,7 +173,7 @@ export class RouteGenerator {
144
173
  ' const db = c.get(\'db\');',
145
174
  ' const body = c.req.valid(\'json\');',
146
175
  ` const row = await db.${clientKey}.create(body);`,
147
- ` return ${this.jsonRow('row', constantPrefix, 201)};`,
176
+ ` return ${this.mutationJsonRow('row', constantPrefix, 201)};`,
148
177
  '});',
149
178
  ];
150
179
  }
@@ -155,7 +184,7 @@ export class RouteGenerator {
155
184
  ` assertPolicy('${this.model.name}', auth.role, 'insert');`,
156
185
  ' const body = c.req.valid(\'json\');',
157
186
  ` const row = await db.${clientKey}.create(body);`,
158
- ` return ${this.jsonRow('row', constantPrefix, 201)};`,
187
+ ` return ${this.mutationJsonRow('row', constantPrefix, 201)};`,
159
188
  '});',
160
189
  ];
161
190
  }
@@ -167,7 +196,7 @@ export class RouteGenerator {
167
196
  ' const params = c.req.valid(\'param\');',
168
197
  ' const body = c.req.valid(\'json\');',
169
198
  ` const row = await db.${clientKey}.update({ where: { ${whereFromParams} }, data: body });`,
170
- ` return ${this.jsonRow('row', constantPrefix)};`,
199
+ ` return ${this.mutationJsonRow('row', constantPrefix)};`,
171
200
  '});',
172
201
  ];
173
202
  }
@@ -180,7 +209,7 @@ export class RouteGenerator {
180
209
  ' const params = c.req.valid(\'param\');',
181
210
  ' const body = c.req.valid(\'json\');',
182
211
  ` const row = await db.${clientKey}.update({ where: mergeWhere({ ${whereFromParams} }, policyWhere), data: body });`,
183
- ` return ${this.jsonRow('row', constantPrefix)};`,
212
+ ` return ${this.mutationJsonRow('row', constantPrefix)};`,
184
213
  '});',
185
214
  ];
186
215
  }
@@ -191,7 +220,7 @@ export class RouteGenerator {
191
220
  ' const db = c.get(\'db\');',
192
221
  ' const params = c.req.valid(\'param\');',
193
222
  ` const row = await db.${clientKey}.delete({ ${whereFromParams} });`,
194
- ` return ${this.jsonRow('row', constantPrefix)};`,
223
+ ` return ${this.mutationJsonRow('row', constantPrefix)};`,
195
224
  '});',
196
225
  ];
197
226
  }
@@ -203,7 +232,7 @@ export class RouteGenerator {
203
232
  ' const policyWhere = resolvePolicyWhere(policy, auth);',
204
233
  ' const params = c.req.valid(\'param\');',
205
234
  ` const row = await db.${clientKey}.delete(mergeWhere({ ${whereFromParams} }, policyWhere));`,
206
- ` return ${this.jsonRow('row', constantPrefix)};`,
235
+ ` return ${this.mutationJsonRow('row', constantPrefix)};`,
207
236
  '});',
208
237
  ];
209
238
  }
@@ -1,8 +1,14 @@
1
1
  import type { Field, Model, Schema } from '../../schema-dsl/ast.js';
2
+ import type { IncludableRelationTree } from '../../api/utils/include-query.js';
2
3
  export declare function isStoredScalarField(field: Field, schema: Schema): boolean;
4
+ export declare function isRelationField(field: Field, schema: Schema): boolean;
3
5
  export declare function isUnfilterable(field: Field): boolean;
6
+ export declare function isUnincludeable(field: Field): boolean;
4
7
  export declare function isOmitted(field: Field): boolean;
5
8
  export declare function getFilterableFields(model: Model, schema: Schema): Field[];
6
9
  export declare function getOmittedFields(model: Model, schema: Schema): Field[];
10
+ export declare function getIncludableRelationFields(model: Model, schema: Schema): Field[];
11
+ export declare function buildIncludableRelationTree(model: Model, schema: Schema, visited?: Set<string>): IncludableRelationTree;
12
+ export declare function buildRelationTargets(model: Model, schema: Schema): Record<string, string>;
7
13
  export declare function getSortableFieldNames(model: Model, schema: Schema): string[];
8
14
  export declare function toModelConstantPrefix(modelName: string): string;
@@ -3,9 +3,15 @@ export function isStoredScalarField(field, schema) {
3
3
  const modelNames = getModelNames(schema);
4
4
  return !modelNames.has(field.type.name);
5
5
  }
6
+ export function isRelationField(field, schema) {
7
+ return getModelNames(schema).has(field.type.name);
8
+ }
6
9
  export function isUnfilterable(field) {
7
10
  return fieldHasAttribute(field, 'unfilterable') || fieldHasAttribute(field, 'omit');
8
11
  }
12
+ export function isUnincludeable(field) {
13
+ return fieldHasAttribute(field, 'unincludeable');
14
+ }
9
15
  export function isOmitted(field) {
10
16
  return fieldHasAttribute(field, 'omit');
11
17
  }
@@ -15,6 +21,32 @@ export function getFilterableFields(model, schema) {
15
21
  export function getOmittedFields(model, schema) {
16
22
  return getStoredFields(model, getModelNames(schema)).filter((field) => isStoredScalarField(field, schema) && isOmitted(field));
17
23
  }
24
+ export function getIncludableRelationFields(model, schema) {
25
+ return model.fields.filter((field) => isRelationField(field, schema) && !isUnincludeable(field));
26
+ }
27
+ export function buildIncludableRelationTree(model, schema, visited = new Set()) {
28
+ if (visited.has(model.name)) {
29
+ return {};
30
+ }
31
+ const nextVisited = new Set(visited);
32
+ nextVisited.add(model.name);
33
+ const tree = {};
34
+ for (const field of getIncludableRelationFields(model, schema)) {
35
+ const targetModel = schema.models.find((candidate) => candidate.name === field.type.name);
36
+ if (!targetModel) {
37
+ continue;
38
+ }
39
+ tree[field.name] = buildIncludableRelationTree(targetModel, schema, nextVisited);
40
+ }
41
+ return tree;
42
+ }
43
+ export function buildRelationTargets(model, schema) {
44
+ const targets = {};
45
+ for (const field of getIncludableRelationFields(model, schema)) {
46
+ targets[field.name] = field.type.name;
47
+ }
48
+ return targets;
49
+ }
18
50
  export function getSortableFieldNames(model, schema) {
19
51
  return getStoredFields(model, getModelNames(schema))
20
52
  .filter((field) => isStoredScalarField(field, schema))
@@ -3,8 +3,11 @@ export declare class ZodSchemaGenerator {
3
3
  private readonly schema;
4
4
  constructor(schema: Schema);
5
5
  generate(): string;
6
+ private generateGlobalMetadata;
6
7
  private generateModelSchemas;
7
8
  private generateListQuerySchemas;
9
+ private generateIncludeRefinement;
10
+ private generateReadQueryRefinement;
8
11
  private generateListQueryFieldLines;
9
12
  private generateObjectField;
10
13
  private generateParamField;
@@ -1,5 +1,6 @@
1
+ import { PACKAGE_NAME } from '../constants.js';
1
2
  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 { buildIncludableRelationTree, buildRelationTargets, getFilterableFields, getOmittedFields, getSortableFieldNames, toModelConstantPrefix, } from './utils/api-fields.js';
3
4
  import { buildFilterFieldMeta, queryParamKey, toFilterZodType, } from './utils/filter-operators.js';
4
5
  export class ZodSchemaGenerator {
5
6
  schema;
@@ -12,9 +13,26 @@ export class ZodSchemaGenerator {
12
13
  return [
13
14
  '// Auto-generated by ZodSchemaGenerator. Do not edit manually.',
14
15
  "import { z } from 'zod';",
16
+ `import { validateIncludePaths } from '${PACKAGE_NAME}/api/utils/include-query';`,
15
17
  `import type {\n ${typeImports},\n} from '../db-types.js';`,
16
18
  '',
17
19
  ...modelBlocks,
20
+ this.generateGlobalMetadata(),
21
+ ].join('\n');
22
+ }
23
+ generateGlobalMetadata() {
24
+ const omitByModel = Object.fromEntries(this.schema.models.map((model) => [
25
+ model.name,
26
+ getOmittedFields(model, this.schema).map((field) => field.name),
27
+ ]));
28
+ const relationTargets = Object.fromEntries(this.schema.models.map((model) => [
29
+ model.name,
30
+ buildRelationTargets(model, this.schema),
31
+ ]));
32
+ return [
33
+ `export const API_OMIT_FIELDS_BY_MODEL = ${JSON.stringify(omitByModel, null, 2)} as const;`,
34
+ `export const API_RELATION_TARGETS = ${JSON.stringify(relationTargets, null, 2)} as const;`,
35
+ '',
18
36
  ].join('\n');
19
37
  }
20
38
  generateModelSchemas(model) {
@@ -49,39 +67,79 @@ export class ZodSchemaGenerator {
49
67
  const omittedFields = getOmittedFields(model, this.schema);
50
68
  const filterFieldMeta = filterableFields.map((field) => buildFilterFieldMeta(field, this.schema));
51
69
  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(),');
70
+ queryLines.push('limit: z.coerce.number().int().min(1).max(100).optional(),', 'offset: z.coerce.number().int().min(0).optional(),', 'sort: z.string().optional(),', 'include: z.string().optional(),');
53
71
  const sortableLiteral = sortableFields.map((field) => `'${field}'`).join(', ');
54
72
  const listQueryFieldsJson = JSON.stringify(filterFieldMeta, null, 2);
55
73
  const omitFieldNames = omittedFields.map((field) => field.name);
56
74
  const omitFieldsJson = JSON.stringify(omitFieldNames);
75
+ const includableRelationsJson = JSON.stringify(buildIncludableRelationTree(model, this.schema), null, 2);
57
76
  const responseType = omittedFields.length === 0
58
77
  ? `export type ${model.name}Response = ${model.name};`
59
78
  : `export type ${model.name}Response = Omit<${model.name}, ${omitFieldNames.map((name) => `'${name}'`).join(' | ')}>;`;
60
79
  return [
61
80
  `export const ${prefix}_SORTABLE_FIELDS = [${sortableLiteral}] as const;`,
62
81
  `export const ${prefix}_LIST_QUERY_FIELDS = ${listQueryFieldsJson} as const;`,
82
+ `export const ${prefix}_INCLUDABLE_RELATIONS = ${includableRelationsJson} as const;`,
63
83
  `export const ${prefix}_OMIT_FIELDS = ${omitFieldsJson} as const;`,
64
84
  responseType + '\n',
85
+ `export const ${model.name}GetQuerySchema = z`,
86
+ ' .object({',
87
+ ' include: z.string().optional(),',
88
+ ' })',
89
+ ...this.generateIncludeRefinement(prefix),
90
+ '\n',
65
91
  `export const ${model.name}ListQuerySchema = z`,
66
92
  ' .object({',
67
93
  ...queryLines.map((line) => ` ${line}`),
68
94
  ' })',
95
+ ...this.generateReadQueryRefinement(prefix),
96
+ '\n',
97
+ ];
98
+ }
99
+ generateIncludeRefinement(prefix) {
100
+ return [
69
101
  ' .superRefine((data, ctx) => {',
70
- ' if (data.sort === undefined) {',
102
+ ' if (data.include === undefined) {',
71
103
  ' return;',
72
104
  ' }',
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)) {`,
105
+ ` const includeError = validateIncludePaths(data.include, ${prefix}_INCLUDABLE_RELATIONS);`,
106
+ ' if (includeError) {',
76
107
  ' ctx.addIssue({',
77
108
  ' code: \'custom\',',
78
- ' message: `Invalid sort field "${field}"`,',
79
- ' path: [\'sort\'],',
109
+ ' message: includeError,',
110
+ ' path: [\'include\'],',
80
111
  ' });',
81
112
  ' }',
82
113
  ' });\n',
83
114
  ];
84
115
  }
116
+ generateReadQueryRefinement(prefix) {
117
+ return [
118
+ ' .superRefine((data, ctx) => {',
119
+ ' if (data.sort !== undefined) {',
120
+ ' const descending = data.sort.startsWith(\'-\');',
121
+ ' const field = descending ? data.sort.slice(1) : data.sort;',
122
+ ` if (!(${prefix}_SORTABLE_FIELDS as readonly string[]).includes(field)) {`,
123
+ ' ctx.addIssue({',
124
+ ' code: \'custom\',',
125
+ ' message: `Invalid sort field "${field}"`,',
126
+ ' path: [\'sort\'],',
127
+ ' });',
128
+ ' }',
129
+ ' }',
130
+ ' if (data.include !== undefined) {',
131
+ ` const includeError = validateIncludePaths(data.include, ${prefix}_INCLUDABLE_RELATIONS);`,
132
+ ' if (includeError) {',
133
+ ' ctx.addIssue({',
134
+ ' code: \'custom\',',
135
+ ' message: includeError,',
136
+ ' path: [\'include\'],',
137
+ ' });',
138
+ ' }',
139
+ ' }',
140
+ ' });\n',
141
+ ];
142
+ }
85
143
  generateListQueryFieldLines(field, meta) {
86
144
  const lines = [];
87
145
  for (const operator of meta.operators) {
@@ -1,7 +1,7 @@
1
1
  export declare const APP_SCHEMA_TEMPLATE = "extensions {\n\n}\n\nenums {\n\n}\n\nmodels {\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
3
  export declare const GITIGNORE_TEMPLATE = "node_modules/\ndist/\n.env\ndocker_data/\n.DS_Store\n*.log\nnpm-debug.log*\n";
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
+ export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgis/postgis:16-3.4\n container_name: schematic-pg\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";
5
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";
6
6
  export declare const MAKEFILE_TEMPLATE = ".PHONY: dev\n\ndev:\n\tdocker compose up -d --wait\n\tnpx schematic-pg dev\n";
7
7
  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";
@@ -32,7 +32,7 @@ npm-debug.log*
32
32
  export const DOCKER_COMPOSE_TEMPLATE = `services:
33
33
  postgres:
34
34
  image: postgis/postgis:16-3.4
35
- container_name: schematic-pg-postgres
35
+ container_name: schematic-pg
36
36
  restart: unless-stopped
37
37
  ports:
38
38
  - "5432:5432"
@@ -1,3 +1,4 @@
1
1
  export declare const PACKAGE_NAME = "schematic-pg";
2
2
  export declare const PACKAGE_VERSION: string;
3
3
  export declare const MAX_INCLUDE_DEPTH = 10;
4
+ export declare const MAX_INCLUDE_PATHS = 10;
package/dist/constants.js CHANGED
@@ -4,3 +4,4 @@ const { version } = require('../package.json');
4
4
  export const PACKAGE_NAME = 'schematic-pg';
5
5
  export const PACKAGE_VERSION = version;
6
6
  export const MAX_INCLUDE_DEPTH = 10;
7
+ export const MAX_INCLUDE_PATHS = 10;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Single-file backend framework for PostgreSQL and Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",