@rebasepro/server-postgres 0.17.3 → 0.18.1
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/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/BranchService-ByLfM_Tt.js +360 -0
- package/dist/BranchService-ByLfM_Tt.js.map +1 -0
- package/dist/__vite-browser-external-BnuHet1e.js +46 -0
- package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
- package/dist/arg-Dni7MzLB.js +87 -0
- package/dist/arg-Dni7MzLB.js.map +1 -0
- package/dist/auth-users-columns-D2LBFrMH.js +116 -0
- package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/backup-cli-oM9WQVlV.js +408 -0
- package/dist/backup-cli-oM9WQVlV.js.map +1 -0
- package/dist/backup-service-Br3tqwsy.js +715 -0
- package/dist/backup-service-Br3tqwsy.js.map +1 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors-DxN4yzdH.js +657 -0
- package/dist/cli-errors-DxN4yzdH.js.map +1 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/cli-output-CNdMql-L.js +47 -0
- package/dist/cli-output-CNdMql-L.js.map +1 -0
- package/dist/cli.js +2020 -0
- package/dist/cli.js.map +1 -0
- package/dist/collection-index-BHgT_TKd.js +276 -0
- package/dist/collection-index-BHgT_TKd.js.map +1 -0
- package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
- package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
- package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
- package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/doctor-D7HWmGzs.js +974 -0
- package/dist/doctor-D7HWmGzs.js.map +1 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
- package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
- package/dist/ensure-tables-CIojK-Rx.js +650 -0
- package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
- package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +7423 -1133
- package/dist/index.es.js.map +1 -1
- package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
- package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
- package/dist/policy-drift-kNJ6X2Uj.js +313 -0
- package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
- package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
- package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
- package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
- package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor-cli.js +129 -0
- package/dist/schema/doctor-cli.js.map +1 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -1
- package/dist/schema/generate-drizzle-schema.js +24 -0
- package/dist/schema/generate-drizzle-schema.js.map +1 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl.js +138 -0
- package/dist/schema/generate-postgres-ddl.js.map +1 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/schema/introspect-db.js +866 -0
- package/dist/schema/introspect-db.js.map +1 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/source-Br7L7GOI.js +410 -0
- package/dist/source-Br7L7GOI.js.map +1 -0
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/dist/vector-index-DkWxm2de.js +309 -0
- package/dist/vector-index-DkWxm2de.js.map +1 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- package/src/websocket.ts +0 -855
|
@@ -1,978 +0,0 @@
|
|
|
1
|
-
import { and, eq, inArray, notInArray, or, sql, SQL, getTableName as drizzleTableName } from "drizzle-orm";
|
|
2
|
-
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
-
import { DrizzleClient } from "../interfaces";
|
|
4
|
-
import { CollectionConfig, FilterValues, OrderByTuple, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
|
|
5
|
-
import { getTableName, resolveCollectionRelations, findRelation, fieldKeyForColumn } from "@rebasepro/common";
|
|
6
|
-
import { hasForeignKeyOnTarget, isManyToMany, type ResolvedVia } from "@rebasepro/types";
|
|
7
|
-
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
8
|
-
import {
|
|
9
|
-
getCollectionByPath,
|
|
10
|
-
getTableForCollection,
|
|
11
|
-
relationMisconfigured,
|
|
12
|
-
requirePrimaryKeys,
|
|
13
|
-
parseIdValues,
|
|
14
|
-
buildCompositeId,
|
|
15
|
-
joinsOnNaturalKey,
|
|
16
|
-
sourceKeyField,
|
|
17
|
-
type PrimaryKeyInfo
|
|
18
|
-
} from "./collection-helpers";
|
|
19
|
-
import { parseDataFromServer } from "../data-transformer";
|
|
20
|
-
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
21
|
-
import { ApiError, logger } from "@rebasepro/server";
|
|
22
|
-
import type { NestedPathHop } from "./nested-path";
|
|
23
|
-
import {
|
|
24
|
-
applyJunctionMembership,
|
|
25
|
-
bindJoinPathJunction,
|
|
26
|
-
bindThroughJunction,
|
|
27
|
-
removeJunctionLink
|
|
28
|
-
} from "./junction-writes";
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The ids in a to-many relation write, whatever shape the caller sent.
|
|
32
|
-
*
|
|
33
|
-
* A membership list is written as either the related rows (`[{ id: 1 }]`, what
|
|
34
|
-
* the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
|
|
35
|
-
* what anyone writing the API by hand sends). Only the first was read, via a
|
|
36
|
-
* blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
|
|
37
|
-
* on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
|
|
38
|
-
* and on a string-keyed one it did not surface at all — `String(undefined)`
|
|
39
|
-
* wrote a junction row pointing at the literal `"undefined"`, which no read
|
|
40
|
-
* would ever match. Both shapes are accepted here, in one place, because both
|
|
41
|
-
* call sites had the same assumption.
|
|
42
|
-
*
|
|
43
|
-
* An element that carries no key is refused rather than skipped: dropping it
|
|
44
|
-
* would silently write a shorter membership list than the caller asked for.
|
|
45
|
-
*/
|
|
46
|
-
function relationTargetIds(value: unknown, relationName: string, collectionSlug: string): (string | number)[] {
|
|
47
|
-
if (!Array.isArray(value)) return [];
|
|
48
|
-
|
|
49
|
-
return value.map((element, index) => {
|
|
50
|
-
if (typeof element === "string" || typeof element === "number") return element;
|
|
51
|
-
if (element && typeof element === "object") {
|
|
52
|
-
const id = (element as { id?: unknown }).id;
|
|
53
|
-
if (typeof id === "string" || typeof id === "number") return id;
|
|
54
|
-
}
|
|
55
|
-
throw new Error(
|
|
56
|
-
`Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. ` +
|
|
57
|
-
"Pass either the related rows (`[{ id: … }]`) or their keys (`[1, 2]`), not " +
|
|
58
|
-
`${element === null ? "null" : typeof element}.`
|
|
59
|
-
);
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Typed wrapper for Drizzle dynamic query innerJoin.
|
|
65
|
-
* Drizzle's `$dynamic()` queries lose the `innerJoin` method from
|
|
66
|
-
* their static type, but it exists at runtime. This helper bridges
|
|
67
|
-
* the gap with a single confined cast.
|
|
68
|
-
*/
|
|
69
|
-
function applyDynamicJoin<T>(query: T, joinTable: PgTable, condition: SQL): T {
|
|
70
|
-
return (query as unknown as { innerJoin(t: PgTable, c: SQL): T }).innerJoin(joinTable, condition) as T;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Typed wrapper for DrizzleConditionBuilder.buildRelationQuery on dynamic queries.
|
|
75
|
-
* The method returns a widened generic that doesn't reassign cleanly;
|
|
76
|
-
* this helper confines the cast.
|
|
77
|
-
*/
|
|
78
|
-
function applyDynamicRelationQuery<T>(
|
|
79
|
-
query: T,
|
|
80
|
-
...args: Parameters<typeof DrizzleConditionBuilder.buildRelationQuery>
|
|
81
|
-
): T {
|
|
82
|
-
return DrizzleConditionBuilder.buildRelationQuery(...args) as unknown as T;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Service for handling all relation-related operations.
|
|
87
|
-
* Handles fetching, updating, and managing row relations.
|
|
88
|
-
*/
|
|
89
|
-
/**
|
|
90
|
-
* A related record resolved by {@link RelationService}: the target row's
|
|
91
|
-
* values plus the identity (`id`) and originating collection (`path`)
|
|
92
|
-
* needed to build relation references. Internal to the postgres driver —
|
|
93
|
-
* flattened to plain rows at the fetch-service boundary.
|
|
94
|
-
*/
|
|
95
|
-
export interface RelatedRow<M extends Record<string, unknown> = Record<string, unknown>> {
|
|
96
|
-
id: string | number;
|
|
97
|
-
path: string;
|
|
98
|
-
values: M;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export class RelationService {
|
|
102
|
-
constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) { }
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* One target row, as the {@link RelatedRow} everything here returns.
|
|
106
|
-
*
|
|
107
|
-
* Eight sites built this by hand, which is how the address came to be the
|
|
108
|
-
* target's first key column in all eight — one edit, eight places to miss.
|
|
109
|
-
*
|
|
110
|
-
* `resolveNested` is the one thing they did not agree on, and the
|
|
111
|
-
* disagreement was invisible: the single-parent fetches pass `db` and
|
|
112
|
-
* `registry` to `parseDataFromServer`, so the target's *own* relations get
|
|
113
|
-
* resolved too, while the batch paths deliberately do not — a query per
|
|
114
|
-
* target row is the N+1 the batching exists to avoid. Naming the parameter
|
|
115
|
-
* makes that a decision rather than a difference between two call sites
|
|
116
|
-
* nobody was comparing.
|
|
117
|
-
*/
|
|
118
|
-
private async toRelatedRow<M extends Record<string, unknown>>(
|
|
119
|
-
targetRow: Record<string, unknown>,
|
|
120
|
-
targetCollection: CollectionConfig,
|
|
121
|
-
targetPks: PrimaryKeyInfo[],
|
|
122
|
-
options?: { resolveNested?: boolean }
|
|
123
|
-
): Promise<RelatedRow<M>> {
|
|
124
|
-
const values = options?.resolveNested
|
|
125
|
-
? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry)
|
|
126
|
-
: await parseDataFromServer(targetRow, targetCollection);
|
|
127
|
-
|
|
128
|
-
return {
|
|
129
|
-
// The whole key: a composite target addressed by its first column
|
|
130
|
-
// names every row that shares it.
|
|
131
|
-
id: buildCompositeId(targetRow, targetPks),
|
|
132
|
-
path: targetCollection.slug,
|
|
133
|
-
values: values as M
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* A WHERE matching any of `parentIds`, by the whole key.
|
|
139
|
-
*
|
|
140
|
-
* A single key is an `IN (…)`. A composite one cannot be: matching
|
|
141
|
-
* `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that
|
|
142
|
-
* share their first column each receive the other's relations. It becomes
|
|
143
|
-
* an OR of ANDs — one exact address per parent — which Postgres indexes the
|
|
144
|
-
* same way it would a multi-column key lookup.
|
|
145
|
-
*/
|
|
146
|
-
private parentKeyCondition(
|
|
147
|
-
parentTable: PgTable,
|
|
148
|
-
parentPks: PrimaryKeyInfo[],
|
|
149
|
-
parentIds: (string | number)[]
|
|
150
|
-
): SQL {
|
|
151
|
-
const columnFor = (fieldName: string) => {
|
|
152
|
-
const col = parentTable[fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
153
|
-
if (!col) throw new Error(`Key column '${fieldName}' not found in parent table`);
|
|
154
|
-
return col;
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
if (parentPks.length === 1) {
|
|
158
|
-
const values = parentIds.map(id => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
|
|
159
|
-
return inArray(columnFor(parentPks[0].fieldName), values);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const perParent = parentIds.map(id => {
|
|
163
|
-
const values = parseIdValues(id, parentPks);
|
|
164
|
-
return and(...parentPks.map(pk => eq(columnFor(pk.fieldName), values[pk.fieldName])));
|
|
165
|
-
});
|
|
166
|
-
return or(...perParent) as SQL;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Reject a relation that cannot express a composite-keyed parent.
|
|
171
|
-
*
|
|
172
|
-
* `localKey` and `foreignKeyOnTarget` are single column names: one column
|
|
173
|
-
* cannot reference a two-column key, so such a relation has no correct
|
|
174
|
-
* reading. Left alone it would silently match on the first key column and
|
|
175
|
-
* hand a tenant's rows to its neighbour — say so instead.
|
|
176
|
-
*/
|
|
177
|
-
private assertSingleKeyAddressable(
|
|
178
|
-
parentCollection: CollectionConfig,
|
|
179
|
-
parentPks: PrimaryKeyInfo[],
|
|
180
|
-
via: string,
|
|
181
|
-
relation?: ResolvedRelation
|
|
182
|
-
): void {
|
|
183
|
-
// A `sourceKey` names the single column the link actually points at, so
|
|
184
|
-
// the parent's key can be as wide as it likes — nothing here references
|
|
185
|
-
// it. That is the one way out of this error, and the message below now
|
|
186
|
-
// has to say so.
|
|
187
|
-
if (relation && hasForeignKeyOnTarget(relation) && relation.sourceKey) return;
|
|
188
|
-
|
|
189
|
-
if (parentPks.length > 1) {
|
|
190
|
-
throw new Error(
|
|
191
|
-
`Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but ` +
|
|
192
|
-
`'${parentCollection.slug}' is keyed on ${parentPks.map(k => `'${k.fieldName}'`).join(" + ")}. ` +
|
|
193
|
-
`One column cannot reference a composite key — give the relation a \`sourceKey\` naming ` +
|
|
194
|
-
`a single unique column to point at, or express it with \`joinPath\`, whose ` +
|
|
195
|
-
`\`on.from\`/\`on.to\` take every key column.`
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* What the target's foreign key holds, for each of these parent rows.
|
|
202
|
-
*
|
|
203
|
-
* Ordinarily the parent's id, and then this is free. When the relation
|
|
204
|
-
* declares a `sourceKey` the two are different values, and the mapping
|
|
205
|
-
* between them lives in the source table — so it costs one SELECT, issued
|
|
206
|
-
* once for the whole batch rather than per parent.
|
|
207
|
-
*
|
|
208
|
-
* Both directions come back because both are needed and deriving one from
|
|
209
|
-
* the other by hand is how a batch loader ends up attributing a child to the
|
|
210
|
-
* wrong parent: reads translate id → key to build the WHERE, and then
|
|
211
|
-
* translate key → id to attribute each row that comes back.
|
|
212
|
-
*/
|
|
213
|
-
/**
|
|
214
|
-
* The value a related row's foreign key must hold to belong to this parent.
|
|
215
|
-
*
|
|
216
|
-
* `undefined` when the parent's source key is null — which is not an error
|
|
217
|
-
* here, only in the callers that were about to write it. Exposed for
|
|
218
|
-
* {@link PersistService}, which stamps this onto a child created under a
|
|
219
|
-
* nested path and would otherwise write the id and lose the row.
|
|
220
|
-
*/
|
|
221
|
-
async parentKeyValue(
|
|
222
|
-
parentCollection: CollectionConfig,
|
|
223
|
-
relation: ResolvedHasOne | ResolvedHasMany,
|
|
224
|
-
parentId: string | number,
|
|
225
|
-
db: DrizzleClient = this.db
|
|
226
|
-
): Promise<string | number | undefined> {
|
|
227
|
-
const { keyByParentId } = await this.resolveSourceKeys(parentCollection, relation, [parentId], db);
|
|
228
|
-
return keyByParentId.get(String(parentId));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Shared with {@link RelationWriteService}: a write needs the same source
|
|
233
|
-
* key a read does, and resolving it twice is how the two would disagree.
|
|
234
|
-
*/
|
|
235
|
-
async resolveSourceKeys(
|
|
236
|
-
parentCollection: CollectionConfig,
|
|
237
|
-
relation: ResolvedHasOne | ResolvedHasMany,
|
|
238
|
-
parentIds: (string | number)[],
|
|
239
|
-
// Writes pass their transaction: reading the source key on the pool
|
|
240
|
-
// while the same transaction is holding an uncommitted change to it
|
|
241
|
-
// would translate the id against a stale value.
|
|
242
|
-
db: DrizzleClient = this.db
|
|
243
|
-
): Promise<{ keyByParentId: Map<string, string | number>; parentIdByKey: Map<string, string | number> }> {
|
|
244
|
-
const keyByParentId = new Map<string, string | number>();
|
|
245
|
-
const parentIdByKey = new Map<string, string | number>();
|
|
246
|
-
|
|
247
|
-
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
248
|
-
|
|
249
|
-
if (!joinsOnNaturalKey(relation, parentCollection, this.registry)) {
|
|
250
|
-
// The ordinary case, and it must stay free of a round-trip: the
|
|
251
|
-
// key IS the id. Parsed, not passed through — an id arrives as the
|
|
252
|
-
// string from a URL, and comparing "7" against an integer column is
|
|
253
|
-
// how this returns nothing at all.
|
|
254
|
-
for (const id of parentIds) {
|
|
255
|
-
const value = parseIdValues(id, parentPks)[parentPks[0].fieldName];
|
|
256
|
-
keyByParentId.set(String(id), value);
|
|
257
|
-
parentIdByKey.set(String(value), id);
|
|
258
|
-
}
|
|
259
|
-
return { keyByParentId, parentIdByKey };
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const field = sourceKeyField(relation, parentCollection, this.registry);
|
|
263
|
-
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
264
|
-
const keyColumn = parentTable[field as keyof typeof parentTable] as AnyPgColumn;
|
|
265
|
-
if (!keyColumn) {
|
|
266
|
-
throw new Error(
|
|
267
|
-
`\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not a column on ` +
|
|
268
|
-
`'${parentCollection.slug}'. It names a column on the source table, not on the target.`
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const rows = await db
|
|
273
|
-
.select()
|
|
274
|
-
.from(parentTable)
|
|
275
|
-
.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
276
|
-
|
|
277
|
-
for (const row of rows as Array<Record<string, unknown>>) {
|
|
278
|
-
const keyValue = row[field] as string | number | null;
|
|
279
|
-
if (keyValue === null || keyValue === undefined) continue;
|
|
280
|
-
const parentId = buildCompositeId(row, parentPks);
|
|
281
|
-
keyByParentId.set(String(parentId), keyValue);
|
|
282
|
-
// A duplicate here means the source key is not unique, which makes
|
|
283
|
-
// "which parent does this child belong to" unanswerable. Refuse
|
|
284
|
-
// rather than pick the last one seen.
|
|
285
|
-
const existing = parentIdByKey.get(String(keyValue));
|
|
286
|
-
if (existing !== undefined && String(existing) !== String(parentId)) {
|
|
287
|
-
throw new Error(
|
|
288
|
-
`\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not unique on ` +
|
|
289
|
-
`'${parentCollection.slug}': rows '${existing}' and '${parentId}' both hold ` +
|
|
290
|
-
`'${keyValue}'. Add a unique constraint — a link that addresses more than one source ` +
|
|
291
|
-
`row cannot say which one a related row belongs to.`
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
parentIdByKey.set(String(keyValue), parentId);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
return { keyByParentId, parentIdByKey };
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Fetch rows related to a parent row through a specific relation
|
|
302
|
-
*/
|
|
303
|
-
async fetchRelatedEntities<M extends Record<string, unknown>>(
|
|
304
|
-
parentCollectionPath: string,
|
|
305
|
-
parentId: string | number,
|
|
306
|
-
relationKey: string,
|
|
307
|
-
options: {
|
|
308
|
-
filter?: FilterValues<Extract<keyof M, string>>;
|
|
309
|
-
orderBy?: string | OrderByTuple[];
|
|
310
|
-
order?: "desc" | "asc";
|
|
311
|
-
limit?: number;
|
|
312
|
-
startAfter?: Record<string, unknown>;
|
|
313
|
-
searchString?: string;
|
|
314
|
-
databaseId?: string;
|
|
315
|
-
} = {}
|
|
316
|
-
): Promise<RelatedRow<M>[]> {
|
|
317
|
-
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
318
|
-
const resolvedRelations = resolveCollectionRelations(parentCollection);
|
|
319
|
-
const relation = findRelation(resolvedRelations, relationKey);
|
|
320
|
-
|
|
321
|
-
if (!relation) {
|
|
322
|
-
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
323
|
-
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
return this.fetchEntitiesUsingJoins<M>(parentCollection, parentId, relation, options);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Fetch rows using join paths for complex relations
|
|
331
|
-
*/
|
|
332
|
-
async fetchEntitiesUsingJoins<M extends Record<string, unknown>>(
|
|
333
|
-
parentCollection: CollectionConfig,
|
|
334
|
-
parentId: string | number,
|
|
335
|
-
relation: ResolvedRelation,
|
|
336
|
-
options: {
|
|
337
|
-
filter?: FilterValues<Extract<keyof M, string>>;
|
|
338
|
-
orderBy?: string | OrderByTuple[];
|
|
339
|
-
order?: "desc" | "asc";
|
|
340
|
-
limit?: number;
|
|
341
|
-
startAfter?: Record<string, unknown>;
|
|
342
|
-
searchString?: string;
|
|
343
|
-
databaseId?: string;
|
|
344
|
-
} = {}
|
|
345
|
-
): Promise<RelatedRow<M>[]> {
|
|
346
|
-
const targetCollection = relation.target();
|
|
347
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
348
|
-
const idInfo = requirePrimaryKeys(targetCollection, this.registry);
|
|
349
|
-
const idField = targetTable[idInfo[0].fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
350
|
-
|
|
351
|
-
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
352
|
-
const parentIdInfo = parentPks[0];
|
|
353
|
-
const parsedParentIdObj = parseIdValues(parentId, parentPks);
|
|
354
|
-
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
355
|
-
const parentTable = this.registry.getTable(getTableName(parentCollection));
|
|
356
|
-
if (!parentTable) throw new Error("Parent table not found");
|
|
357
|
-
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
358
|
-
|
|
359
|
-
// Handle join path relations
|
|
360
|
-
if (relation.kind === "via") {
|
|
361
|
-
let query = this.db.select().from(parentTable).$dynamic();
|
|
362
|
-
let currentTable = parentTable;
|
|
363
|
-
|
|
364
|
-
// Apply each join in the path
|
|
365
|
-
for (const join of relation.joinPath) {
|
|
366
|
-
const joinTable = this.registry.getTable(join.table);
|
|
367
|
-
if (!joinTable) {
|
|
368
|
-
throw new Error(`Join table not found: ${join.table}`);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
|
|
372
|
-
const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
|
|
373
|
-
|
|
374
|
-
const fromParts = fromColumn.split(".");
|
|
375
|
-
const toParts = toColumn.split(".");
|
|
376
|
-
|
|
377
|
-
const fromColName = fromParts[fromParts.length - 1];
|
|
378
|
-
const toColName = toParts[toParts.length - 1];
|
|
379
|
-
|
|
380
|
-
const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
|
|
381
|
-
const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
|
|
382
|
-
|
|
383
|
-
if (!fromCol || !toCol) {
|
|
384
|
-
throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
388
|
-
currentTable = joinTable;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Add where condition for the parent row
|
|
392
|
-
const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
393
|
-
query = query.where(eq(parentIdField, parsedParentId));
|
|
394
|
-
|
|
395
|
-
if (options.limit) {
|
|
396
|
-
query = query.limit(options.limit);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const results = await query;
|
|
400
|
-
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
401
|
-
|
|
402
|
-
// Process results
|
|
403
|
-
const rows: RelatedRow<M>[] = [];
|
|
404
|
-
for (const row of results as Array<Record<string, unknown>>) {
|
|
405
|
-
const targetRow = (row[targetTableName] as Record<string, unknown>) || row;
|
|
406
|
-
rows.push(await this.toRelatedRow<M>(targetRow, targetCollection, idInfo, { resolveNested: true }));
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
return rows;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Handle other relation types.
|
|
413
|
-
//
|
|
414
|
-
// The query builder compares the target's foreign key against a value,
|
|
415
|
-
// and for a link on a natural key that value is not the id in the URL.
|
|
416
|
-
// Resolved first, before anything is built, so a parent that reaches
|
|
417
|
-
// nothing costs one statement rather than two.
|
|
418
|
-
const matchValue = hasForeignKeyOnTarget(relation)
|
|
419
|
-
? (await this.resolveSourceKeys(parentCollection, relation, [parentId]))
|
|
420
|
-
.keyByParentId.get(String(parentId))
|
|
421
|
-
: parsedParentId;
|
|
422
|
-
|
|
423
|
-
// A parent whose source key is null reaches nothing: NULL never equals
|
|
424
|
-
// a foreign key. Say so with an empty list rather than an `= NULL`.
|
|
425
|
-
if (matchValue === undefined) return [];
|
|
426
|
-
|
|
427
|
-
let query = this.db.select().from(targetTable).$dynamic();
|
|
428
|
-
|
|
429
|
-
// Build additional filter conditions
|
|
430
|
-
const additionalFilters: SQL[] = [];
|
|
431
|
-
|
|
432
|
-
// Handle search conditions if searchString is provided
|
|
433
|
-
if (options.searchString) {
|
|
434
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
435
|
-
options.searchString,
|
|
436
|
-
targetCollection.properties,
|
|
437
|
-
targetTable,
|
|
438
|
-
targetCollection
|
|
439
|
-
);
|
|
440
|
-
|
|
441
|
-
if (searchConditions.length === 0) {
|
|
442
|
-
// No searchable fields found, return empty results
|
|
443
|
-
return [];
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const searchCombined = DrizzleConditionBuilder.combineConditionsWithOr(searchConditions);
|
|
447
|
-
if (searchCombined) {
|
|
448
|
-
additionalFilters.push(searchCombined);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Use unified relation query builder
|
|
453
|
-
query = applyDynamicRelationQuery(
|
|
454
|
-
query,
|
|
455
|
-
query,
|
|
456
|
-
relation,
|
|
457
|
-
matchValue,
|
|
458
|
-
targetTable,
|
|
459
|
-
parentTable,
|
|
460
|
-
parentIdCol,
|
|
461
|
-
idField,
|
|
462
|
-
this.registry,
|
|
463
|
-
additionalFilters
|
|
464
|
-
);
|
|
465
|
-
|
|
466
|
-
if (options.limit) {
|
|
467
|
-
query = query.limit(options.limit);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
const results = await query;
|
|
471
|
-
|
|
472
|
-
// Process results - ensure results is iterable
|
|
473
|
-
if (!results || !Array.isArray(results)) {
|
|
474
|
-
return [];
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
const rows: RelatedRow<M>[] = [];
|
|
478
|
-
for (const row of results) {
|
|
479
|
-
const targetRow = row[getTableName(targetCollection)] || row;
|
|
480
|
-
rows.push(await this.toRelatedRow<M>(targetRow as Record<string, unknown>, targetCollection, idInfo, { resolveNested: true }));
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
return rows;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Count related rows for a parent row
|
|
488
|
-
*/
|
|
489
|
-
async countRelatedEntities<M extends Record<string, unknown>>(
|
|
490
|
-
parentCollectionPath: string,
|
|
491
|
-
parentId: string | number,
|
|
492
|
-
relationKey: string,
|
|
493
|
-
options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
|
|
494
|
-
): Promise<number> {
|
|
495
|
-
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
496
|
-
const resolvedRelations = resolveCollectionRelations(parentCollection);
|
|
497
|
-
const relation = findRelation(resolvedRelations, relationKey);
|
|
498
|
-
if (!relation) {
|
|
499
|
-
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
500
|
-
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
return this.countRelatedRows(parentCollection, parentId, relation, []);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/**
|
|
507
|
-
* Count the target rows a parent reaches through `relation`, narrowed by
|
|
508
|
-
* `additionalFilters` (conditions on the target table).
|
|
509
|
-
*
|
|
510
|
-
* Shared by the public count and by {@link isRelated}, so "how many children
|
|
511
|
-
* does this parent have" and "is this row one of them" are answered by the
|
|
512
|
-
* same join — a membership test that reconstructed the join separately would
|
|
513
|
-
* be free to disagree with the listing it is supposed to gate.
|
|
514
|
-
*/
|
|
515
|
-
private async countRelatedRows(
|
|
516
|
-
parentCollection: CollectionConfig,
|
|
517
|
-
parentId: string | number,
|
|
518
|
-
relation: ResolvedRelation,
|
|
519
|
-
additionalFilters: SQL[]
|
|
520
|
-
): Promise<number> {
|
|
521
|
-
const targetCollection = relation.target();
|
|
522
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
523
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
524
|
-
const targetIdInfo = targetPks[0];
|
|
525
|
-
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
526
|
-
|
|
527
|
-
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
528
|
-
const parentIdInfo = parentPks[0];
|
|
529
|
-
const parsedParentIdObj = parseIdValues(parentId, parentPks);
|
|
530
|
-
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
531
|
-
const parentTable = this.registry.getTable(getTableName(parentCollection));
|
|
532
|
-
if (!parentTable) throw new Error("Parent table not found");
|
|
533
|
-
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
534
|
-
|
|
535
|
-
// Same translation the listing does, and it has to be: `isRelated`
|
|
536
|
-
// gates writes on this count, so a count built from a different value
|
|
537
|
-
// than the read would authorise rows the read never returned.
|
|
538
|
-
const matchValue = hasForeignKeyOnTarget(relation)
|
|
539
|
-
? (await this.resolveSourceKeys(parentCollection, relation, [parentId]))
|
|
540
|
-
.keyByParentId.get(String(parentId))
|
|
541
|
-
: parsedParentId;
|
|
542
|
-
|
|
543
|
-
if (matchValue === undefined) return 0;
|
|
544
|
-
|
|
545
|
-
// Start count with distinct to avoid duplicates from junction tables
|
|
546
|
-
let query = this.db.select({ count: sql<number>`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
|
|
547
|
-
|
|
548
|
-
// Use unified count query builder from DrizzleConditionBuilder
|
|
549
|
-
query = DrizzleConditionBuilder.buildRelationCountQuery(
|
|
550
|
-
query,
|
|
551
|
-
relation,
|
|
552
|
-
matchValue,
|
|
553
|
-
targetTable,
|
|
554
|
-
parentTable,
|
|
555
|
-
parentIdCol,
|
|
556
|
-
targetIdField,
|
|
557
|
-
this.registry,
|
|
558
|
-
additionalFilters
|
|
559
|
-
);
|
|
560
|
-
|
|
561
|
-
const result = await query;
|
|
562
|
-
return Number(result[0]?.count || 0);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* Whether `targetId` is actually reachable from the parent named in `hop`.
|
|
567
|
-
*
|
|
568
|
-
* A nested address like `authors/1/posts/43` used to resolve to the target
|
|
569
|
-
* collection and then match on the primary key alone, so the parent segment
|
|
570
|
-
* decided nothing: the row came back, and was updated or deleted, whoever it
|
|
571
|
-
* belonged to. Reads, updates and deletes now all gate on this.
|
|
572
|
-
*/
|
|
573
|
-
async isRelated(hop: NestedPathHop, targetId: string | number): Promise<boolean> {
|
|
574
|
-
const targetTable = getTableForCollection(hop.targetCollection, this.registry);
|
|
575
|
-
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
576
|
-
const parsedTargetId = parseIdValues(targetId, targetPks);
|
|
577
|
-
|
|
578
|
-
const identity: SQL[] = targetPks.map(pk => {
|
|
579
|
-
const column = targetTable[pk.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
580
|
-
if (!column) {
|
|
581
|
-
throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
|
|
582
|
-
}
|
|
583
|
-
return eq(column, parsedTargetId[pk.fieldName]);
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
return (await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity)) > 0;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
591
|
-
*/
|
|
592
|
-
async batchFetchRelatedEntities(
|
|
593
|
-
parentCollectionPath: string,
|
|
594
|
-
parentIds: (string | number)[],
|
|
595
|
-
_relationKey: string,
|
|
596
|
-
relation: ResolvedRelation
|
|
597
|
-
): Promise<Map<string, RelatedRow<Record<string, unknown>>>> {
|
|
598
|
-
if (parentIds.length === 0) return new Map();
|
|
599
|
-
|
|
600
|
-
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
601
|
-
const targetCollection = relation.target();
|
|
602
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
603
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
604
|
-
const targetIdInfo = targetPks[0];
|
|
605
|
-
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
606
|
-
|
|
607
|
-
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
608
|
-
const parentIdInfo = parentPks[0];
|
|
609
|
-
const parentTable = this.registry.getTable(getTableName(parentCollection));
|
|
610
|
-
if (!parentTable) throw new Error("Parent table not found");
|
|
611
|
-
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
612
|
-
|
|
613
|
-
// Parse all parent IDs once
|
|
614
|
-
const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
615
|
-
|
|
616
|
-
// Handle join path relations with batching
|
|
617
|
-
if (relation.kind === "via") {
|
|
618
|
-
let query = this.db.select().from(parentTable).$dynamic();
|
|
619
|
-
let currentTable = parentTable;
|
|
620
|
-
|
|
621
|
-
// Apply each join in the path
|
|
622
|
-
for (const join of relation.joinPath) {
|
|
623
|
-
const joinTable = this.registry.getTable(join.table);
|
|
624
|
-
if (!joinTable) {
|
|
625
|
-
throw new Error(`Join table not found: ${join.table}`);
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
|
|
629
|
-
const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
|
|
630
|
-
|
|
631
|
-
const fromParts = fromColumn.split(".");
|
|
632
|
-
const toParts = toColumn.split(".");
|
|
633
|
-
|
|
634
|
-
const fromColName = fromParts[fromParts.length - 1];
|
|
635
|
-
const toColName = toParts[toParts.length - 1];
|
|
636
|
-
|
|
637
|
-
const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
|
|
638
|
-
const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
|
|
639
|
-
|
|
640
|
-
if (!fromCol || !toCol) {
|
|
641
|
-
throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
645
|
-
currentTable = joinTable;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// Match every parent at once, each by its whole key.
|
|
649
|
-
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
650
|
-
|
|
651
|
-
const results = await query;
|
|
652
|
-
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
653
|
-
const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
|
|
654
|
-
|
|
655
|
-
// Group by the parent's address — the same token the caller looks
|
|
656
|
-
// results up by, derived the same way on both sides.
|
|
657
|
-
for (const row of results as Array<Record<string, unknown>>) {
|
|
658
|
-
const parentRow = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
|
|
659
|
-
const targetRow = (row[targetTableName] || row) as Record<string, unknown>;
|
|
660
|
-
|
|
661
|
-
resultMap.set(
|
|
662
|
-
buildCompositeId(parentRow, parentPks),
|
|
663
|
-
await this.toRelatedRow(targetRow, targetCollection, targetPks)
|
|
664
|
-
);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
return resultMap;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// Handle owning relations with proper FK-based batching.
|
|
671
|
-
// For owning relations, parent rows hold the FK (e.g. posts.author_id).
|
|
672
|
-
// We need to:
|
|
673
|
-
// 1. Fetch FK values from the parent table in a single query
|
|
674
|
-
// 2. Query the target table with unique FK values
|
|
675
|
-
// 3. Map results back to parent rows via their FK values
|
|
676
|
-
if (relation.kind === "belongsTo") {
|
|
677
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
678
|
-
// `localKey` is the column; the generated table is keyed by the wire
|
|
679
|
-
// name, so `author_id` finds nothing where the key is `authorId`.
|
|
680
|
-
const localKeyField = fieldKeyForColumn(parentCollection, relation.localKey);
|
|
681
|
-
const localKeyCol = parentTable[localKeyField as keyof typeof parentTable] as AnyPgColumn;
|
|
682
|
-
if (!localKeyCol) {
|
|
683
|
-
throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
// Step 1: Fetch all FK values from parent table in ONE query
|
|
687
|
-
const fkRows = await this.db
|
|
688
|
-
.select({
|
|
689
|
-
parentId: parentIdCol,
|
|
690
|
-
fkValue: localKeyCol
|
|
691
|
-
})
|
|
692
|
-
.from(parentTable)
|
|
693
|
-
.where(inArray(parentIdCol, parsedParentIds));
|
|
694
|
-
|
|
695
|
-
// Build parentId → fkValue mapping and collect unique FK values
|
|
696
|
-
const parentToFk = new Map<string, string | number>();
|
|
697
|
-
const uniqueFkValues: (string | number)[] = [];
|
|
698
|
-
const seenFks = new Set<string>();
|
|
699
|
-
|
|
700
|
-
for (const row of fkRows as Array<{ parentId: string | number; fkValue: string | number | null }>) {
|
|
701
|
-
if (row.fkValue == null) continue;
|
|
702
|
-
parentToFk.set(String(row.parentId), row.fkValue);
|
|
703
|
-
const fkStr = String(row.fkValue);
|
|
704
|
-
if (!seenFks.has(fkStr)) {
|
|
705
|
-
seenFks.add(fkStr);
|
|
706
|
-
uniqueFkValues.push(row.fkValue);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
if (uniqueFkValues.length === 0) return new Map();
|
|
711
|
-
|
|
712
|
-
// Step 2: Fetch all target rows in ONE query
|
|
713
|
-
const targetResults = await this.db
|
|
714
|
-
.select()
|
|
715
|
-
.from(targetTable)
|
|
716
|
-
.where(inArray(targetIdField, uniqueFkValues));
|
|
717
|
-
|
|
718
|
-
// Index target rows by their ID
|
|
719
|
-
const targetById = new Map<string, Record<string, unknown>>();
|
|
720
|
-
for (const row of targetResults as Array<Record<string, unknown>>) {
|
|
721
|
-
const tid = String(row[targetIdInfo.fieldName]);
|
|
722
|
-
targetById.set(tid, row);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Step 3: Map back to parent rows
|
|
726
|
-
const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
|
|
727
|
-
for (const [parentIdStr, fkValue] of parentToFk) {
|
|
728
|
-
const targetRow = targetById.get(String(fkValue));
|
|
729
|
-
if (targetRow) {
|
|
730
|
-
resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
return resultMap;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Handle inverse relation types with batching. The parent is named by a
|
|
738
|
-
// single FK column on the target, so a composite-keyed parent has no
|
|
739
|
-
// correct reading here either.
|
|
740
|
-
this.assertSingleKeyAddressable(
|
|
741
|
-
parentCollection,
|
|
742
|
-
parentPks,
|
|
743
|
-
hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName,
|
|
744
|
-
relation
|
|
745
|
-
);
|
|
746
|
-
|
|
747
|
-
// One lookup for the whole batch, both directions: the WHERE is built
|
|
748
|
-
// from the source-key values, and each row that comes back carries one
|
|
749
|
-
// of those values rather than a parent id.
|
|
750
|
-
const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation)
|
|
751
|
-
? await this.resolveSourceKeys(parentCollection, relation, parentIds)
|
|
752
|
-
: { keyByParentId: new Map<string, string | number>(), parentIdByKey: new Map<string, string | number>() };
|
|
753
|
-
|
|
754
|
-
const matchValues = hasForeignKeyOnTarget(relation)
|
|
755
|
-
? [...keyByParentId.values()]
|
|
756
|
-
: parsedParentIds;
|
|
757
|
-
if (matchValues.length === 0) return new Map();
|
|
758
|
-
|
|
759
|
-
let query = this.db.select().from(targetTable).$dynamic();
|
|
760
|
-
|
|
761
|
-
// Build the relation query with ALL parent IDs
|
|
762
|
-
query = applyDynamicRelationQuery(
|
|
763
|
-
query,
|
|
764
|
-
query,
|
|
765
|
-
relation,
|
|
766
|
-
matchValues, // Pass array instead of single ID
|
|
767
|
-
targetTable,
|
|
768
|
-
parentTable,
|
|
769
|
-
parentIdCol,
|
|
770
|
-
targetIdField,
|
|
771
|
-
this.registry,
|
|
772
|
-
[]
|
|
773
|
-
);
|
|
774
|
-
|
|
775
|
-
const results = await query;
|
|
776
|
-
const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
|
|
777
|
-
|
|
778
|
-
// Map results back to parent rows
|
|
779
|
-
for (const row of results as Array<Record<string, unknown>>) {
|
|
780
|
-
const targetRow = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
|
|
781
|
-
|
|
782
|
-
// The parent's key is on the target row, in the relation's own
|
|
783
|
-
// column. There used to be a second branch here that guessed the
|
|
784
|
-
// column by appending `_id` to `inverseRelationName`, reached only
|
|
785
|
-
// when the foreign key had not been resolved — which cannot happen
|
|
786
|
-
// now that resolution fills it in.
|
|
787
|
-
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
788
|
-
|
|
789
|
-
// Keyed by string throughout: Drizzle returns a numeric column as a
|
|
790
|
-
// number or a string depending on the column type and the driver.
|
|
791
|
-
const foreignKeyValue = targetRow[
|
|
792
|
-
fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)
|
|
793
|
-
] as string | number | undefined;
|
|
794
|
-
if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
|
|
795
|
-
|
|
796
|
-
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
797
|
-
if (parentId !== undefined) {
|
|
798
|
-
resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
return resultMap;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/**
|
|
806
|
-
* Batch fetch many-cardinality related rows for multiple parent rows.
|
|
807
|
-
* Returns a Map<parentId, RelatedRow[]> instead of Map<parentId, RelatedRow>.
|
|
808
|
-
* Uses a single SQL query with IN clause to avoid N+1.
|
|
809
|
-
*/
|
|
810
|
-
async batchFetchRelatedEntitiesMany(
|
|
811
|
-
parentCollectionPath: string,
|
|
812
|
-
parentIds: (string | number)[],
|
|
813
|
-
_relationKey: string,
|
|
814
|
-
relation: ResolvedRelation
|
|
815
|
-
): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>> {
|
|
816
|
-
if (parentIds.length === 0) return new Map();
|
|
817
|
-
|
|
818
|
-
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
819
|
-
const targetCollection = relation.target();
|
|
820
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
821
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
822
|
-
const targetIdInfo = targetPks[0];
|
|
823
|
-
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
824
|
-
|
|
825
|
-
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
826
|
-
const parentIdInfo = parentPks[0];
|
|
827
|
-
const parentTable = this.registry.getTable(getTableName(parentCollection));
|
|
828
|
-
if (!parentTable) throw new Error("Parent table not found");
|
|
829
|
-
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
830
|
-
|
|
831
|
-
const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
832
|
-
|
|
833
|
-
// Handle join path relations (many-to-many through junction tables)
|
|
834
|
-
if (relation.kind === "via") {
|
|
835
|
-
let query = this.db.select().from(parentTable).$dynamic();
|
|
836
|
-
let currentTable = parentTable;
|
|
837
|
-
|
|
838
|
-
for (const join of relation.joinPath) {
|
|
839
|
-
const joinTable = this.registry.getTable(join.table);
|
|
840
|
-
if (!joinTable) throw new Error(`Join table not found: ${join.table}`);
|
|
841
|
-
|
|
842
|
-
const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
|
|
843
|
-
const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
|
|
844
|
-
const fromColName = fromColumn.split(".").pop()!;
|
|
845
|
-
const toColName = toColumn.split(".").pop()!;
|
|
846
|
-
|
|
847
|
-
const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
|
|
848
|
-
const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
|
|
849
|
-
if (!fromCol || !toCol) throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
|
|
850
|
-
|
|
851
|
-
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
852
|
-
currentTable = joinTable;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
856
|
-
|
|
857
|
-
const results = await query;
|
|
858
|
-
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
859
|
-
const resultMap = new Map<string, RelatedRow<Record<string, unknown>>[]>();
|
|
860
|
-
|
|
861
|
-
for (const row of results as Array<Record<string, unknown>>) {
|
|
862
|
-
const parentRow = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
|
|
863
|
-
const targetRow = (row[targetTableName] || row) as Record<string, unknown>;
|
|
864
|
-
const parentId = buildCompositeId(parentRow, parentPks);
|
|
865
|
-
const arr = resultMap.get(parentId) || [];
|
|
866
|
-
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
867
|
-
resultMap.set(parentId, arr);
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
return resultMap;
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
// Handle many-to-many owning relations with junction table (relation.through)
|
|
874
|
-
// This is the standard path for posts→tags style relations where
|
|
875
|
-
// sanitizeRelation populated the `through` config.
|
|
876
|
-
if (relation.kind === "manyToMany") {
|
|
877
|
-
// The junction names its parent with one column, so the same
|
|
878
|
-
// single-key limit applies as for a direct foreign key.
|
|
879
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
880
|
-
|
|
881
|
-
// An empty map here is a lie a caller cannot see through: `posts/1/tags`
|
|
882
|
-
// answering `[]` is exactly what a post with no tags looks like. That
|
|
883
|
-
// is the failure `assertRelationsResolve` was written to make
|
|
884
|
-
// impossible at boot; this says the same thing if one gets past it.
|
|
885
|
-
const { table: junctionTable, parentColumn: sourceJunctionCol, targetColumn: targetJunctionCol } =
|
|
886
|
-
bindThroughJunction(
|
|
887
|
-
this.registry,
|
|
888
|
-
relation.through,
|
|
889
|
-
`${parentCollection.slug}.${relation.relationName}`
|
|
890
|
-
);
|
|
891
|
-
|
|
892
|
-
// SELECT target.*, junction.sourceColumn FROM junction
|
|
893
|
-
// INNER JOIN target ON junction.targetColumn = target.id
|
|
894
|
-
// WHERE junction.sourceColumn IN (parentIds)
|
|
895
|
-
const query = this.db
|
|
896
|
-
.select()
|
|
897
|
-
.from(junctionTable)
|
|
898
|
-
.innerJoin(targetTable, eq(targetJunctionCol, targetIdField))
|
|
899
|
-
.where(inArray(sourceJunctionCol, parsedParentIds));
|
|
900
|
-
|
|
901
|
-
const results = await query;
|
|
902
|
-
const resultMap = new Map<string, RelatedRow<Record<string, unknown>>[]>();
|
|
903
|
-
const targetTableName = getTableName(targetCollection);
|
|
904
|
-
|
|
905
|
-
for (const row of results as Array<Record<string, unknown>>) {
|
|
906
|
-
// The junction table data is namespaced under its table name
|
|
907
|
-
const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
|
|
908
|
-
const targetData = (row[targetTableName] || row) as Record<string, unknown>;
|
|
909
|
-
|
|
910
|
-
const parentId = String(junctionData[relation.through.sourceColumn]);
|
|
911
|
-
const arr = resultMap.get(parentId) || [];
|
|
912
|
-
arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
|
|
913
|
-
resultMap.set(parentId, arr);
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
return resultMap;
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
// Handle FK-based relations (one-to-many inverse). One column on the
|
|
920
|
-
// target names the parent, so a composite-keyed parent cannot be named.
|
|
921
|
-
this.assertSingleKeyAddressable(
|
|
922
|
-
parentCollection,
|
|
923
|
-
parentPks,
|
|
924
|
-
hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName,
|
|
925
|
-
relation
|
|
926
|
-
);
|
|
927
|
-
|
|
928
|
-
const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation)
|
|
929
|
-
? await this.resolveSourceKeys(parentCollection, relation, parentIds)
|
|
930
|
-
: { keyByParentId: new Map<string, string | number>(), parentIdByKey: new Map<string, string | number>() };
|
|
931
|
-
|
|
932
|
-
const matchValues = hasForeignKeyOnTarget(relation)
|
|
933
|
-
? [...keyByParentId.values()]
|
|
934
|
-
: parsedParentIds;
|
|
935
|
-
if (matchValues.length === 0) return new Map();
|
|
936
|
-
|
|
937
|
-
let query = this.db.select().from(targetTable).$dynamic();
|
|
938
|
-
|
|
939
|
-
query = applyDynamicRelationQuery(
|
|
940
|
-
query,
|
|
941
|
-
query,
|
|
942
|
-
relation,
|
|
943
|
-
matchValues,
|
|
944
|
-
targetTable,
|
|
945
|
-
parentTable,
|
|
946
|
-
parentIdCol,
|
|
947
|
-
targetIdField,
|
|
948
|
-
this.registry,
|
|
949
|
-
[]
|
|
950
|
-
);
|
|
951
|
-
|
|
952
|
-
const results = await query;
|
|
953
|
-
const resultMap = new Map<string, RelatedRow<Record<string, unknown>>[]>();
|
|
954
|
-
|
|
955
|
-
for (const row of results as Array<Record<string, unknown>>) {
|
|
956
|
-
const targetRow = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
|
|
957
|
-
|
|
958
|
-
// Junction-backed relations returned earlier in this method, so
|
|
959
|
-
// what reaches here names the parent with a column on the target.
|
|
960
|
-
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
961
|
-
|
|
962
|
-
const foreignKeyValue = targetRow[
|
|
963
|
-
fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)
|
|
964
|
-
] as string | number | undefined;
|
|
965
|
-
if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
|
|
966
|
-
|
|
967
|
-
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
968
|
-
if (parentId !== undefined) {
|
|
969
|
-
const key = String(parentId);
|
|
970
|
-
const arr = resultMap.get(key) || [];
|
|
971
|
-
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
972
|
-
resultMap.set(key, arr);
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
return resultMap;
|
|
977
|
-
}
|
|
978
|
-
}
|