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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mapPgError } from '../errors.js';
|
|
2
|
+
import { mapRows } from '../row-mapper.js';
|
|
3
|
+
import { WhereTranslator } from '../where-translator.js';
|
|
4
|
+
import { dedupeKeys, extractParentKeys, stitch } from './hydrator.js';
|
|
5
|
+
export async function loadIncludes(parentRows, plan, pool) {
|
|
6
|
+
for (const childPlan of plan.children) {
|
|
7
|
+
const relation = childPlan.relation;
|
|
8
|
+
if (!relation) {
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
const parentKeys = extractParentKeys(parentRows, relation);
|
|
12
|
+
if (parentKeys.length === 0) {
|
|
13
|
+
assignEmptyRelation(parentRows, relation);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const childRows = await fetchRelationRows(childPlan, parentKeys, pool);
|
|
17
|
+
await loadIncludes(childRows, childPlan, pool);
|
|
18
|
+
stitch(parentRows, childRows, relation);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function assignEmptyRelation(parentRows, relation) {
|
|
22
|
+
for (const parent of parentRows) {
|
|
23
|
+
parent[relation.name] = relation.unique ? null : [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function fetchRelationRows(node, parentKeys, pool) {
|
|
27
|
+
const relation = node.relation;
|
|
28
|
+
if (!relation) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const query = buildRelationSelect(node, parentKeys);
|
|
32
|
+
const rows = await executeQuery(pool, query.sql, query.params, node.model);
|
|
33
|
+
return mapRows(rows, node.model);
|
|
34
|
+
}
|
|
35
|
+
function buildRelationSelect(node, parentKeys) {
|
|
36
|
+
const relation = node.relation;
|
|
37
|
+
const foreignField = node.model.fieldByName.get(relation.foreignKey);
|
|
38
|
+
if (!foreignField) {
|
|
39
|
+
throw new Error(`Unknown foreign key field "${relation.foreignKey}" on model ${node.model.name}`);
|
|
40
|
+
}
|
|
41
|
+
const dedupedKeys = dedupeKeys(parentKeys);
|
|
42
|
+
const params = [dedupedKeys];
|
|
43
|
+
const whereTranslator = new WhereTranslator(node.model, 2);
|
|
44
|
+
const nestedWhere = whereTranslator.translate(node.where);
|
|
45
|
+
params.push(...nestedWhere.params);
|
|
46
|
+
const clauses = [`${foreignField.columnName} = ANY($1)`];
|
|
47
|
+
if (nestedWhere.sql) {
|
|
48
|
+
clauses.push(nestedWhere.sql);
|
|
49
|
+
}
|
|
50
|
+
const orderByClause = buildOrderByClause(node);
|
|
51
|
+
let sql = `SELECT * FROM ${node.model.quotedTableName} WHERE ${clauses.join(' AND ')}`;
|
|
52
|
+
if (orderByClause) {
|
|
53
|
+
sql += ` ${orderByClause}`;
|
|
54
|
+
}
|
|
55
|
+
if (node.take !== undefined) {
|
|
56
|
+
params.push(node.take);
|
|
57
|
+
sql += ` LIMIT $${params.length}`;
|
|
58
|
+
}
|
|
59
|
+
if (node.skip !== undefined) {
|
|
60
|
+
params.push(node.skip);
|
|
61
|
+
sql += ` OFFSET $${params.length}`;
|
|
62
|
+
}
|
|
63
|
+
return { sql, params };
|
|
64
|
+
}
|
|
65
|
+
function buildOrderByClause(plan) {
|
|
66
|
+
if (!plan.orderBy) {
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
const entries = Array.isArray(plan.orderBy) ? plan.orderBy : [plan.orderBy];
|
|
70
|
+
const parts = [];
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
for (const [fieldName, direction] of Object.entries(entry)) {
|
|
73
|
+
const field = plan.model.fieldByName.get(fieldName);
|
|
74
|
+
if (!field) {
|
|
75
|
+
throw new Error(`Unknown orderBy field "${fieldName}" on model ${plan.model.name}`);
|
|
76
|
+
}
|
|
77
|
+
parts.push(`${field.columnName} ${direction.toUpperCase()}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return parts.length > 0 ? `ORDER BY ${parts.join(', ')}` : '';
|
|
81
|
+
}
|
|
82
|
+
async function executeQuery(pool, sql, params, model) {
|
|
83
|
+
try {
|
|
84
|
+
const result = await pool.query(sql, params);
|
|
85
|
+
return result.rows;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
throw mapPgError(error, model.name, model.columnToField);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { RelationMeta } from '../model-meta.js';
|
|
2
|
+
export declare function stitch(parents: Record<string, unknown>[], children: Record<string, unknown>[], relation: RelationMeta): void;
|
|
3
|
+
export declare function dedupeKeys(keys: unknown[]): unknown[];
|
|
4
|
+
export declare function extractParentKeys(parents: Record<string, unknown>[], relation: RelationMeta): unknown[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function stitch(parents, children, relation) {
|
|
2
|
+
const childrenByForeignKey = bucketChildrenByForeignKey(children, relation);
|
|
3
|
+
for (const parent of parents) {
|
|
4
|
+
const localValue = parent[relation.localKey];
|
|
5
|
+
const bucket = localValue == null ? [] : (childrenByForeignKey.get(localValue) ?? []);
|
|
6
|
+
if (relation.unique) {
|
|
7
|
+
parent[relation.name] = bucket[0] ?? null;
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
parent[relation.name] = bucket;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function bucketChildrenByForeignKey(children, relation) {
|
|
14
|
+
const buckets = new Map();
|
|
15
|
+
for (const child of children) {
|
|
16
|
+
const foreignValue = child[relation.foreignKey];
|
|
17
|
+
if (foreignValue == null) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const bucket = buckets.get(foreignValue);
|
|
21
|
+
if (bucket) {
|
|
22
|
+
bucket.push(child);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
buckets.set(foreignValue, [child]);
|
|
26
|
+
}
|
|
27
|
+
return buckets;
|
|
28
|
+
}
|
|
29
|
+
export function dedupeKeys(keys) {
|
|
30
|
+
return [...new Set(keys.filter((key) => key != null))];
|
|
31
|
+
}
|
|
32
|
+
export function extractParentKeys(parents, relation) {
|
|
33
|
+
return dedupeKeys(parents.map((parent) => parent[relation.localKey]));
|
|
34
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Pool } from 'pg';
|
|
2
|
+
import type { ModelMeta } from '../model-meta.js';
|
|
3
|
+
import type { FindArgs } from '../query-builder.js';
|
|
4
|
+
import type { LoadNode } from './planner.js';
|
|
5
|
+
export declare function fetchRootWithJsonAgg<T extends Record<string, unknown>>(model: ModelMeta, plan: LoadNode, args: FindArgs, pool: Pool): Promise<T[]>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { mapPgError } from '../errors.js';
|
|
2
|
+
import { QueryBuilder } from '../query-builder.js';
|
|
3
|
+
import { mapRow } from '../row-mapper.js';
|
|
4
|
+
const ROOT_ALIAS = 'root';
|
|
5
|
+
export async function fetchRootWithJsonAgg(model, plan, args, pool) {
|
|
6
|
+
const query = buildJsonAggRootQuery(model, plan, args);
|
|
7
|
+
const rows = await executeQuery(pool, query.sql, query.params, model);
|
|
8
|
+
return rows.map((row) => hydrateJsonAggRow(row, model, plan));
|
|
9
|
+
}
|
|
10
|
+
function buildJsonAggRootQuery(model, plan, args) {
|
|
11
|
+
const builder = new QueryBuilder(model);
|
|
12
|
+
const baseQuery = builder.select(args);
|
|
13
|
+
const match = baseQuery.sql.match(/^SELECT \* FROM ([^\s]+)([\s\S]*)$/);
|
|
14
|
+
if (!match || plan.children.length === 0) {
|
|
15
|
+
return baseQuery;
|
|
16
|
+
}
|
|
17
|
+
const tableRef = match[1];
|
|
18
|
+
const rest = match[2] ?? '';
|
|
19
|
+
const lateralJoins = plan.children.map((child) => buildRootLateralJoin(model, child));
|
|
20
|
+
const sql = [
|
|
21
|
+
`SELECT ${ROOT_ALIAS}.*, ${lateralJoins.map((join) => join.select).join(', ')}`,
|
|
22
|
+
`FROM ${tableRef} ${ROOT_ALIAS}`,
|
|
23
|
+
lateralJoins.map((join) => join.join).join(' '),
|
|
24
|
+
rest.trim(),
|
|
25
|
+
]
|
|
26
|
+
.filter((part) => part.length > 0)
|
|
27
|
+
.join(' ');
|
|
28
|
+
return { sql, params: baseQuery.params };
|
|
29
|
+
}
|
|
30
|
+
function buildRootLateralJoin(parentModel, node) {
|
|
31
|
+
const relation = node.relation;
|
|
32
|
+
const lateralAlias = `${relation.name}_data`;
|
|
33
|
+
const expression = buildRelationJsonExpression(parentModel, node, ROOT_ALIAS);
|
|
34
|
+
return {
|
|
35
|
+
select: `${lateralAlias}.${relation.name}`,
|
|
36
|
+
join: `LEFT JOIN LATERAL (SELECT ${expression} AS ${relation.name}) ${lateralAlias} ON true`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function buildRelationJsonExpression(parentModel, node, parentAlias) {
|
|
40
|
+
const relation = node.relation;
|
|
41
|
+
const childAlias = `${relation.name}_row`;
|
|
42
|
+
const localField = parentModel.fieldByName.get(relation.localKey);
|
|
43
|
+
const foreignField = node.model.fieldByName.get(relation.foreignKey);
|
|
44
|
+
if (!localField || !foreignField) {
|
|
45
|
+
throw new Error(`Invalid relation "${relation.name}" between ${parentModel.name} and ${node.model.name}`);
|
|
46
|
+
}
|
|
47
|
+
const rowJson = buildRowJsonExpression(node, childAlias);
|
|
48
|
+
const whereClause = `${childAlias}.${foreignField.columnName} = ${parentAlias}.${localField.columnName}`;
|
|
49
|
+
if (relation.unique) {
|
|
50
|
+
return `(SELECT ${rowJson} FROM ${node.model.quotedTableName} ${childAlias} WHERE ${whereClause} LIMIT 1)`;
|
|
51
|
+
}
|
|
52
|
+
return `(SELECT COALESCE(json_agg(${rowJson}), '[]'::json) FROM ${node.model.quotedTableName} ${childAlias} WHERE ${whereClause})`;
|
|
53
|
+
}
|
|
54
|
+
function buildRowJsonExpression(node, rowAlias) {
|
|
55
|
+
if (node.children.length === 0) {
|
|
56
|
+
return `to_jsonb(${rowAlias})`;
|
|
57
|
+
}
|
|
58
|
+
const fieldPairs = node.model.fields.map((field) => `'${field.name}', ${rowAlias}.${field.columnName}`);
|
|
59
|
+
for (const child of node.children) {
|
|
60
|
+
const relation = child.relation;
|
|
61
|
+
fieldPairs.push(`'${relation.name}', ${buildRelationJsonExpression(node.model, child, rowAlias)}`);
|
|
62
|
+
}
|
|
63
|
+
return `json_build_object(${fieldPairs.join(', ')})`;
|
|
64
|
+
}
|
|
65
|
+
function hydrateJsonAggRow(row, model, plan) {
|
|
66
|
+
const mapped = mapRow(row, model);
|
|
67
|
+
for (const child of plan.children) {
|
|
68
|
+
const relation = child.relation;
|
|
69
|
+
mapped[relation.name] = hydrateRelationValue(row[relation.name], child, relation.unique);
|
|
70
|
+
}
|
|
71
|
+
return mapped;
|
|
72
|
+
}
|
|
73
|
+
function hydrateRelationValue(rawValue, plan, unique) {
|
|
74
|
+
if (rawValue == null) {
|
|
75
|
+
return unique ? null : [];
|
|
76
|
+
}
|
|
77
|
+
if (unique) {
|
|
78
|
+
return hydrateJsonObject(rawValue, plan);
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(rawValue)) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
return rawValue.map((entry) => hydrateJsonObject(entry, plan));
|
|
84
|
+
}
|
|
85
|
+
function hydrateJsonObject(value, plan) {
|
|
86
|
+
const mapped = mapRow(value, plan.model);
|
|
87
|
+
for (const child of plan.children) {
|
|
88
|
+
const relation = child.relation;
|
|
89
|
+
mapped[relation.name] = hydrateRelationValue(value[relation.name], child, relation.unique);
|
|
90
|
+
}
|
|
91
|
+
return mapped;
|
|
92
|
+
}
|
|
93
|
+
async function executeQuery(pool, sql, params, model) {
|
|
94
|
+
try {
|
|
95
|
+
const result = await pool.query(sql, params);
|
|
96
|
+
return result.rows;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
throw mapPgError(error, model.name, model.columnToField);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Pool } from 'pg';
|
|
2
|
+
import type { ModelMeta } from '../model-meta.js';
|
|
3
|
+
import type { FindArgs } from '../query-builder.js';
|
|
4
|
+
import type { IncludeInput, IncludeOptions } from './types.js';
|
|
5
|
+
export declare function fetchWithIncludes<T extends Record<string, unknown>>(model: ModelMeta, registry: Map<string, ModelMeta>, pool: Pool, rootArgs: FindArgs & {
|
|
6
|
+
include?: IncludeInput;
|
|
7
|
+
}, options?: IncludeOptions): Promise<T[]>;
|
|
8
|
+
export declare function attachIncludes<T extends Record<string, unknown>>(model: ModelMeta, registry: Map<string, ModelMeta>, pool: Pool, rootRows: T[], include: IncludeInput, rootArgs: FindArgs, options?: IncludeOptions): Promise<T[]>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { mapPgError } from '../errors.js';
|
|
2
|
+
import { QueryBuilder } from '../query-builder.js';
|
|
3
|
+
import { mapRows } from '../row-mapper.js';
|
|
4
|
+
import { loadIncludes } from './executor.js';
|
|
5
|
+
import { fetchRootWithJsonAgg } from './json-agg.js';
|
|
6
|
+
import { buildLoadPlan } from './planner.js';
|
|
7
|
+
export async function fetchWithIncludes(model, registry, pool, rootArgs, options = {}) {
|
|
8
|
+
if (!rootArgs.include) {
|
|
9
|
+
throw new Error('fetchWithIncludes requires include');
|
|
10
|
+
}
|
|
11
|
+
const plan = buildLoadPlan(model, rootArgs.include, registry, options);
|
|
12
|
+
plan.where = rootArgs.where;
|
|
13
|
+
plan.orderBy = rootArgs.orderBy;
|
|
14
|
+
plan.take = rootArgs.take;
|
|
15
|
+
plan.skip = rootArgs.skip;
|
|
16
|
+
if (plan.strategy === 'join') {
|
|
17
|
+
return fetchRootWithJsonAgg(model, plan, rootArgs, pool);
|
|
18
|
+
}
|
|
19
|
+
const rows = await executeRootSelect(model, pool, rootArgs);
|
|
20
|
+
const mapped = mapRows(rows, model);
|
|
21
|
+
await loadIncludes(mapped, plan, pool);
|
|
22
|
+
return mapped;
|
|
23
|
+
}
|
|
24
|
+
export async function attachIncludes(model, registry, pool, rootRows, include, rootArgs, options = {}) {
|
|
25
|
+
if (rootRows.length === 0) {
|
|
26
|
+
return rootRows;
|
|
27
|
+
}
|
|
28
|
+
const plan = buildLoadPlan(model, include, registry, options);
|
|
29
|
+
plan.where = rootArgs.where;
|
|
30
|
+
plan.orderBy = rootArgs.orderBy;
|
|
31
|
+
plan.take = rootArgs.take;
|
|
32
|
+
plan.skip = rootArgs.skip;
|
|
33
|
+
await loadIncludes(rootRows, plan, pool);
|
|
34
|
+
return rootRows;
|
|
35
|
+
}
|
|
36
|
+
async function executeRootSelect(model, pool, args) {
|
|
37
|
+
const builder = new QueryBuilder(model);
|
|
38
|
+
const query = builder.select(args);
|
|
39
|
+
try {
|
|
40
|
+
const result = await pool.query(query.sql, query.params);
|
|
41
|
+
return result.rows;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw mapPgError(error, model.name, model.columnToField);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ModelMeta } from '../model-meta.js';
|
|
2
|
+
import type { OrderByInput } from '../query-builder.js';
|
|
3
|
+
import type { WhereInput } from '../where-translator.js';
|
|
4
|
+
import type { IncludeInput, IncludeOptions, RelationLoadStrategy } from './types.js';
|
|
5
|
+
export interface LoadNode {
|
|
6
|
+
model: ModelMeta;
|
|
7
|
+
relation?: ModelMeta['relations'][number];
|
|
8
|
+
where?: WhereInput;
|
|
9
|
+
orderBy?: OrderByInput | OrderByInput[];
|
|
10
|
+
take?: number;
|
|
11
|
+
skip?: number;
|
|
12
|
+
children: LoadNode[];
|
|
13
|
+
strategy: RelationLoadStrategy;
|
|
14
|
+
}
|
|
15
|
+
export type ModelRegistry = Map<string, ModelMeta>;
|
|
16
|
+
export declare function buildLoadPlan(model: ModelMeta, include: IncludeInput | undefined, registry: ModelRegistry, options?: IncludeOptions, depth?: number): LoadNode;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { MAX_INCLUDE_DEPTH } from '../../constants.js';
|
|
2
|
+
export function buildLoadPlan(model, include, registry, options = {}, depth = 0) {
|
|
3
|
+
if (depth > MAX_INCLUDE_DEPTH) {
|
|
4
|
+
throw new Error(`Include depth exceeds maximum of ${MAX_INCLUDE_DEPTH} on model ${model.name}`);
|
|
5
|
+
}
|
|
6
|
+
const children = [];
|
|
7
|
+
if (include) {
|
|
8
|
+
for (const [relationName, relationInclude] of Object.entries(include)) {
|
|
9
|
+
const relation = model.relationByName.get(relationName);
|
|
10
|
+
if (!relation) {
|
|
11
|
+
throw new Error(`Unknown include relation "${relationName}" on model ${model.name}`);
|
|
12
|
+
}
|
|
13
|
+
const targetModel = registry.get(relation.targetModel);
|
|
14
|
+
if (!targetModel) {
|
|
15
|
+
throw new Error(`Unknown target model "${relation.targetModel}" for relation "${relationName}"`);
|
|
16
|
+
}
|
|
17
|
+
const relationArgs = normalizeRelationInclude(relationInclude);
|
|
18
|
+
const childPlan = buildLoadPlan(targetModel, relationArgs.include, registry, options, depth + 1);
|
|
19
|
+
childPlan.relation = relation;
|
|
20
|
+
childPlan.where = relationArgs.where;
|
|
21
|
+
childPlan.orderBy = relationArgs.orderBy;
|
|
22
|
+
childPlan.take = relationArgs.take;
|
|
23
|
+
childPlan.skip = relationArgs.skip;
|
|
24
|
+
children.push(childPlan);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
model,
|
|
29
|
+
children,
|
|
30
|
+
strategy: chooseStrategy(children, options),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function normalizeRelationInclude(include) {
|
|
34
|
+
if (typeof include === 'boolean') {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
return include;
|
|
38
|
+
}
|
|
39
|
+
function chooseStrategy(children, options) {
|
|
40
|
+
if (options.relationLoadStrategy) {
|
|
41
|
+
return options.relationLoadStrategy;
|
|
42
|
+
}
|
|
43
|
+
const hasManyChildren = children.filter((child) => child.relation?.kind === 'hasMany');
|
|
44
|
+
if (hasManyChildren.length >= 2) {
|
|
45
|
+
return 'split';
|
|
46
|
+
}
|
|
47
|
+
return 'split';
|
|
48
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OrderByInput } from '../query-builder.js';
|
|
2
|
+
import type { WhereInput } from '../where-translator.js';
|
|
3
|
+
export type RelationLoadStrategy = 'split' | 'join';
|
|
4
|
+
export interface RelationIncludeArgs {
|
|
5
|
+
where?: WhereInput;
|
|
6
|
+
orderBy?: OrderByInput | OrderByInput[];
|
|
7
|
+
take?: number;
|
|
8
|
+
skip?: number;
|
|
9
|
+
include?: IncludeInput;
|
|
10
|
+
}
|
|
11
|
+
export type IncludeInput = Record<string, boolean | RelationIncludeArgs>;
|
|
12
|
+
export interface IncludeOptions {
|
|
13
|
+
relationLoadStrategy?: RelationLoadStrategy;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import type { Pool } from 'pg';
|
|
2
|
+
import type { IncludeInput, IncludeOptions } from './include/types.js';
|
|
2
3
|
import type { ModelMeta } from './model-meta.js';
|
|
4
|
+
export type ModelRegistry = Map<string, ModelMeta>;
|
|
5
|
+
export interface SelectArgs<TWhere, TOrderBy> {
|
|
6
|
+
where?: TWhere;
|
|
7
|
+
orderBy?: TOrderBy | TOrderBy[];
|
|
8
|
+
take?: number;
|
|
9
|
+
skip?: number;
|
|
10
|
+
include?: IncludeInput;
|
|
11
|
+
relationLoadStrategy?: IncludeOptions['relationLoadStrategy'];
|
|
12
|
+
}
|
|
3
13
|
export interface ModelClient<T, TCreate, TUpdate, TWhere, TOrderBy> {
|
|
4
14
|
create(data: TCreate): Promise<T>;
|
|
5
|
-
findUnique(where: Record<string, unknown>): Promise<T | null>;
|
|
6
|
-
findFirst(args?:
|
|
7
|
-
|
|
8
|
-
orderBy?: TOrderBy;
|
|
9
|
-
}): Promise<T | null>;
|
|
10
|
-
findMany(args?: {
|
|
11
|
-
where?: TWhere;
|
|
12
|
-
orderBy?: TOrderBy | TOrderBy[];
|
|
13
|
-
take?: number;
|
|
14
|
-
skip?: number;
|
|
15
|
-
}): Promise<T[]>;
|
|
15
|
+
findUnique(where: Record<string, unknown>, args?: Omit<SelectArgs<TWhere, TOrderBy>, 'where'>): Promise<T | null>;
|
|
16
|
+
findFirst(args?: SelectArgs<TWhere, TOrderBy>): Promise<T | null>;
|
|
17
|
+
findMany(args?: SelectArgs<TWhere, TOrderBy>): Promise<T[]>;
|
|
16
18
|
count(args?: {
|
|
17
19
|
where?: TWhere;
|
|
18
20
|
}): Promise<number>;
|
|
@@ -33,4 +35,4 @@ export interface ModelClient<T, TCreate, TUpdate, TWhere, TOrderBy> {
|
|
|
33
35
|
count: number;
|
|
34
36
|
}>;
|
|
35
37
|
}
|
|
36
|
-
export declare function createModelClient<T, TCreate, TUpdate, TWhere, TOrderBy>(model: ModelMeta, pool: Pool): ModelClient<T, TCreate, TUpdate, TWhere, TOrderBy>;
|
|
38
|
+
export declare function createModelClient<T, TCreate, TUpdate, TWhere, TOrderBy>(model: ModelMeta, pool: Pool, registry?: ModelRegistry): ModelClient<T, TCreate, TUpdate, TWhere, TOrderBy>;
|
package/dist/db/model-client.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { mapPgError } from './errors.js';
|
|
2
|
+
import { fetchWithIncludes } from './include/load.js';
|
|
2
3
|
import { QueryBuilder } from './query-builder.js';
|
|
3
4
|
import { mapRow, mapRows } from './row-mapper.js';
|
|
4
|
-
export function createModelClient(model, pool) {
|
|
5
|
+
export function createModelClient(model, pool, registry) {
|
|
5
6
|
const builder = new QueryBuilder(model);
|
|
6
7
|
async function execute(sql, params) {
|
|
7
8
|
try {
|
|
@@ -12,35 +13,40 @@ export function createModelClient(model, pool) {
|
|
|
12
13
|
throw mapPgError(error, model.name, model.columnToField);
|
|
13
14
|
}
|
|
14
15
|
}
|
|
16
|
+
async function selectRows(args = {}) {
|
|
17
|
+
const findArgs = toFindArgs(args);
|
|
18
|
+
if (args.include && registry) {
|
|
19
|
+
return fetchWithIncludes(model, registry, pool, {
|
|
20
|
+
...findArgs,
|
|
21
|
+
include: args.include,
|
|
22
|
+
}, {
|
|
23
|
+
relationLoadStrategy: args.relationLoadStrategy,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const query = builder.select(findArgs);
|
|
27
|
+
const rows = await execute(query.sql, query.params);
|
|
28
|
+
return mapRows(rows, model);
|
|
29
|
+
}
|
|
15
30
|
return {
|
|
16
31
|
async create(data) {
|
|
17
32
|
const query = builder.insert(data);
|
|
18
33
|
const rows = await execute(query.sql, query.params);
|
|
19
34
|
return mapRow(rows[0], model);
|
|
20
35
|
},
|
|
21
|
-
async findUnique(where) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
},
|
|
26
|
-
async findFirst(args) {
|
|
27
|
-
const query = builder.select({
|
|
28
|
-
where: args?.where,
|
|
29
|
-
orderBy: args?.orderBy,
|
|
36
|
+
async findUnique(where, args = {}) {
|
|
37
|
+
const rows = await selectRows({
|
|
38
|
+
...args,
|
|
39
|
+
where: where,
|
|
30
40
|
take: 1,
|
|
31
41
|
});
|
|
32
|
-
|
|
33
|
-
return rows[0] ? mapRow(rows[0], model) : null;
|
|
42
|
+
return rows[0] ?? null;
|
|
34
43
|
},
|
|
35
|
-
async
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
42
|
-
const rows = await execute(query.sql, query.params);
|
|
43
|
-
return mapRows(rows, model);
|
|
44
|
+
async findFirst(args = {}) {
|
|
45
|
+
const rows = await selectRows({ ...args, take: 1 });
|
|
46
|
+
return rows[0] ?? null;
|
|
47
|
+
},
|
|
48
|
+
async findMany(args = {}) {
|
|
49
|
+
return selectRows(args);
|
|
44
50
|
},
|
|
45
51
|
async count(args) {
|
|
46
52
|
const query = builder.count({ where: args?.where });
|
|
@@ -81,3 +87,11 @@ export function createModelClient(model, pool) {
|
|
|
81
87
|
},
|
|
82
88
|
};
|
|
83
89
|
}
|
|
90
|
+
function toFindArgs(args) {
|
|
91
|
+
return {
|
|
92
|
+
where: args.where,
|
|
93
|
+
orderBy: args.orderBy,
|
|
94
|
+
take: args.take,
|
|
95
|
+
skip: args.skip,
|
|
96
|
+
};
|
|
97
|
+
}
|
package/dist/db/model-meta.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { Model, Schema, TypeExpr } from '../schema-dsl/ast.js';
|
|
2
|
+
export type RelationKind = 'belongsTo' | 'hasOne' | 'hasMany';
|
|
3
|
+
export interface RelationMeta {
|
|
4
|
+
name: string;
|
|
5
|
+
kind: RelationKind;
|
|
6
|
+
targetModel: string;
|
|
7
|
+
localKey: string;
|
|
8
|
+
foreignKey: string;
|
|
9
|
+
unique: boolean;
|
|
10
|
+
relationName?: string;
|
|
11
|
+
}
|
|
2
12
|
export interface FieldMeta {
|
|
3
13
|
name: string;
|
|
4
14
|
columnName: string;
|
|
@@ -20,6 +30,7 @@ export interface ModelMetaSnapshot {
|
|
|
20
30
|
fields: FieldMeta[];
|
|
21
31
|
fieldByName: Record<string, FieldMeta>;
|
|
22
32
|
columnToField: Record<string, string>;
|
|
33
|
+
relations: RelationMeta[];
|
|
23
34
|
}
|
|
24
35
|
export interface ModelMeta {
|
|
25
36
|
name: string;
|
|
@@ -29,6 +40,8 @@ export interface ModelMeta {
|
|
|
29
40
|
fields: FieldMeta[];
|
|
30
41
|
fieldByName: Map<string, FieldMeta>;
|
|
31
42
|
columnToField: Map<string, string>;
|
|
43
|
+
relations: RelationMeta[];
|
|
44
|
+
relationByName: Map<string, RelationMeta>;
|
|
32
45
|
}
|
|
33
46
|
export declare function buildModelMeta(model: Model, schema: Schema): ModelMeta;
|
|
34
47
|
export declare function buildModelMetaSnapshot(model: Model, schema: Schema): ModelMetaSnapshot;
|
package/dist/db/model-meta.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fieldHasAttribute, getModelNames, getPrimaryKey, getStoredFields, } from '../sql-generator/utils/ast-helpers.js';
|
|
2
|
+
import { buildRelations } from './utils/relations.js';
|
|
2
3
|
import { toColumnName, toTableName } from './utils/naming.js';
|
|
3
4
|
const NUMERIC_TYPES = new Set(['INTEGER', 'SERIAL', 'SMALLINT', 'DECIMAL']);
|
|
4
5
|
const STRING_TYPES = new Set(['UUID', 'VARCHAR', 'TEXT']);
|
|
@@ -14,6 +15,7 @@ export function buildModelMetaSnapshot(model, schema) {
|
|
|
14
15
|
const fields = storedFields.map((field) => toFieldMeta(field, enumNames, primaryKeyFields));
|
|
15
16
|
const fieldByName = Object.fromEntries(fields.map((field) => [field.name, field]));
|
|
16
17
|
const columnToField = Object.fromEntries(fields.map((field) => [field.columnName, field.name]));
|
|
18
|
+
const relations = buildRelations(model, schema);
|
|
17
19
|
return {
|
|
18
20
|
name: model.name,
|
|
19
21
|
tableName: toTableName(model.name),
|
|
@@ -22,11 +24,15 @@ export function buildModelMetaSnapshot(model, schema) {
|
|
|
22
24
|
fields,
|
|
23
25
|
fieldByName,
|
|
24
26
|
columnToField,
|
|
27
|
+
relations,
|
|
25
28
|
};
|
|
26
29
|
}
|
|
27
30
|
export function hydrateModelMeta(snapshot) {
|
|
31
|
+
const relations = snapshot.relations ?? [];
|
|
28
32
|
return {
|
|
29
33
|
...snapshot,
|
|
34
|
+
relations,
|
|
35
|
+
relationByName: new Map(relations.map((relation) => [relation.name, relation])),
|
|
30
36
|
fieldByName: new Map(Object.entries(snapshot.fieldByName)),
|
|
31
37
|
columnToField: new Map(Object.entries(snapshot.columnToField)),
|
|
32
38
|
};
|
|
@@ -10,6 +10,8 @@ export declare class TypeGenerator {
|
|
|
10
10
|
private generateUpdateInput;
|
|
11
11
|
private generateWhereInput;
|
|
12
12
|
private generateOrderByInput;
|
|
13
|
+
private generateIncludeArgs;
|
|
14
|
+
private generateInclude;
|
|
13
15
|
private toTsType;
|
|
14
16
|
private mapTypeExpr;
|
|
15
17
|
private isFilterable;
|
|
@@ -7,12 +7,16 @@ export class TypeGenerator {
|
|
|
7
7
|
}
|
|
8
8
|
generate() {
|
|
9
9
|
const enumBlocks = this.schema.enums.map((enumDef) => this.generateEnumType(enumDef.name, enumDef.values));
|
|
10
|
+
const includeArgsBlocks = this.schema.models.map((model) => this.generateIncludeArgs(model));
|
|
11
|
+
const includeBlocks = this.schema.models.map((model) => this.generateInclude(model));
|
|
10
12
|
const modelBlocks = this.schema.models.flatMap((model) => this.generateModelTypes(model));
|
|
11
13
|
return [
|
|
12
14
|
'// Auto-generated by TypeGenerator. Do not edit manually.',
|
|
13
15
|
'',
|
|
14
16
|
...enumBlocks,
|
|
15
17
|
...modelBlocks,
|
|
18
|
+
...includeArgsBlocks,
|
|
19
|
+
...includeBlocks,
|
|
16
20
|
].join('\n');
|
|
17
21
|
}
|
|
18
22
|
generateEnumType(name, values) {
|
|
@@ -62,6 +66,18 @@ export class TypeGenerator {
|
|
|
62
66
|
const lines = fields.map((field) => ` ${field.name}?: 'asc' | 'desc';`);
|
|
63
67
|
return `export interface ${modelName}OrderByInput {\n${lines.join('\n')}\n}\n`;
|
|
64
68
|
}
|
|
69
|
+
generateIncludeArgs(model) {
|
|
70
|
+
return `export interface ${model.name}IncludeArgs {\n where?: ${model.name}WhereInput;\n orderBy?: ${model.name}OrderByInput | ${model.name}OrderByInput[];\n take?: number;\n skip?: number;\n include?: ${model.name}Include;\n}\n`;
|
|
71
|
+
}
|
|
72
|
+
generateInclude(model) {
|
|
73
|
+
const modelNames = getModelNames(this.schema);
|
|
74
|
+
const relationFields = model.fields.filter((field) => modelNames.has(field.type.name));
|
|
75
|
+
if (relationFields.length === 0) {
|
|
76
|
+
return `export interface ${model.name}Include {}\n`;
|
|
77
|
+
}
|
|
78
|
+
const lines = relationFields.map((field) => ` ${field.name}?: boolean | ${field.type.name}IncludeArgs;`);
|
|
79
|
+
return `export interface ${model.name}Include {\n${lines.join('\n')}\n}\n`;
|
|
80
|
+
}
|
|
65
81
|
toTsType(field, mode) {
|
|
66
82
|
const base = this.mapTypeExpr(field.type);
|
|
67
83
|
if (field.type.optional) {
|