orchid-orm 0.0.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.
@@ -0,0 +1,278 @@
1
+ import {
2
+ addQueryOn,
3
+ getQueryAs,
4
+ HasOneNestedInsert,
5
+ HasOneNestedUpdate,
6
+ HasOneRelation,
7
+ InsertQueryData,
8
+ isQueryReturnsAll,
9
+ JoinCallback,
10
+ Query,
11
+ QueryBase,
12
+ WhereArg,
13
+ WhereResult,
14
+ } from 'pqb';
15
+ import { Model } from '../model';
16
+ import {
17
+ RelationData,
18
+ RelationInfo,
19
+ RelationThunkBase,
20
+ RelationThunks,
21
+ } from './relations';
22
+ import { getSourceRelation, getThroughRelation } from './utils';
23
+
24
+ export interface HasOne extends RelationThunkBase {
25
+ type: 'hasOne';
26
+ returns: 'one';
27
+ options: HasOneRelation['options'];
28
+ }
29
+
30
+ export type HasOneInfo<
31
+ T extends Model,
32
+ Relations extends RelationThunks,
33
+ Relation extends HasOne,
34
+ > = {
35
+ params: Relation['options'] extends { primaryKey: string }
36
+ ? Record<
37
+ Relation['options']['primaryKey'],
38
+ T['columns']['shape'][Relation['options']['primaryKey']]['type']
39
+ >
40
+ : Relation['options'] extends { through: string }
41
+ ? RelationInfo<
42
+ T,
43
+ Relations,
44
+ Relations[Relation['options']['through']]
45
+ >['params']
46
+ : never;
47
+ populate: Relation['options'] extends { foreignKey: string }
48
+ ? Relation['options']['foreignKey']
49
+ : never;
50
+ chainedCreate: Relation['options'] extends { primaryKey: string }
51
+ ? true
52
+ : false;
53
+ chainedDelete: true;
54
+ };
55
+
56
+ export const makeHasOneMethod = (
57
+ model: Query,
58
+ relation: HasOne,
59
+ relationName: string,
60
+ query: Query,
61
+ ): RelationData => {
62
+ if (relation.options.required) {
63
+ query._take();
64
+ } else {
65
+ query._takeOptional();
66
+ }
67
+
68
+ if ('through' in relation.options) {
69
+ const { through, source } = relation.options;
70
+
71
+ type ModelWithQueryMethod = Record<
72
+ string,
73
+ (params: Record<string, unknown>) => Query
74
+ >;
75
+
76
+ const throughRelation = getThroughRelation(model, through);
77
+ const sourceRelation = getSourceRelation(throughRelation, source);
78
+ const sourceQuery = sourceRelation
79
+ .joinQuery(throughRelation.query, sourceRelation.query)
80
+ .as(relationName);
81
+
82
+ const whereExistsCallback = () => sourceQuery;
83
+
84
+ return {
85
+ returns: 'one',
86
+ method: (params: Record<string, unknown>) => {
87
+ const throughQuery = (model as unknown as ModelWithQueryMethod)[
88
+ through
89
+ ](params);
90
+
91
+ return query.whereExists<Query, Query>(
92
+ throughQuery,
93
+ whereExistsCallback as unknown as JoinCallback<Query, Query>,
94
+ );
95
+ },
96
+ nestedInsert: undefined,
97
+ nestedUpdate: undefined,
98
+ joinQuery(fromQuery, toQuery) {
99
+ return toQuery.whereExists<Query, Query>(
100
+ throughRelation.joinQuery(fromQuery, throughRelation.query),
101
+ (() => {
102
+ const as = getQueryAs(toQuery);
103
+ return sourceRelation.joinQuery(
104
+ throughRelation.query,
105
+ sourceRelation.query.as(as),
106
+ );
107
+ }) as unknown as JoinCallback<Query, Query>,
108
+ );
109
+ },
110
+ reverseJoin(fromQuery, toQuery) {
111
+ return fromQuery.whereExists<Query, Query>(
112
+ throughRelation.joinQuery(fromQuery, throughRelation.query),
113
+ (() => {
114
+ const as = getQueryAs(toQuery);
115
+ return sourceRelation.joinQuery(
116
+ throughRelation.query,
117
+ sourceRelation.query.as(as),
118
+ );
119
+ }) as unknown as JoinCallback<Query, Query>,
120
+ );
121
+ },
122
+ primaryKey: sourceRelation.primaryKey,
123
+ };
124
+ }
125
+
126
+ const { primaryKey, foreignKey } = relation.options;
127
+
128
+ const fromQuerySelect = [{ selectAs: { [foreignKey]: primaryKey } }];
129
+
130
+ return {
131
+ returns: 'one',
132
+ method: (params: Record<string, unknown>) => {
133
+ const values = { [foreignKey]: params[primaryKey] };
134
+ return query.where(values)._defaults(values);
135
+ },
136
+ nestedInsert: (async (q, data) => {
137
+ const connect = data.filter(
138
+ (
139
+ item,
140
+ ): item is [
141
+ Record<string, unknown>,
142
+ (
143
+ | {
144
+ connect: WhereArg<QueryBase>;
145
+ }
146
+ | {
147
+ connectOrCreate: {
148
+ where: WhereArg<QueryBase>;
149
+ create: Record<string, unknown>;
150
+ };
151
+ }
152
+ ),
153
+ ] => Boolean(item[1].connect || item[1].connectOrCreate),
154
+ );
155
+
156
+ const t = query.transacting(q);
157
+
158
+ let connected: number[];
159
+ if (connect.length) {
160
+ connected = await Promise.all(
161
+ connect.map(([selfData, item]) => {
162
+ const data = { [foreignKey]: selfData[primaryKey] };
163
+ return 'connect' in item
164
+ ? (
165
+ t.where(item.connect) as WhereResult<Query> & {
166
+ hasSelect: false;
167
+ }
168
+ )._updateOrThrow(data)
169
+ : (
170
+ t.where(item.connectOrCreate.where) as WhereResult<Query> & {
171
+ hasSelect: false;
172
+ }
173
+ )._update(data);
174
+ }),
175
+ );
176
+ } else {
177
+ connected = [];
178
+ }
179
+
180
+ let connectedI = 0;
181
+ const create = data.filter(
182
+ (
183
+ item,
184
+ ): item is [
185
+ Record<string, unknown>,
186
+ (
187
+ | { create: Record<string, unknown> }
188
+ | {
189
+ connectOrCreate: {
190
+ where: WhereArg<QueryBase>;
191
+ create: Record<string, unknown>;
192
+ };
193
+ }
194
+ ),
195
+ ] => {
196
+ if (item[1].connectOrCreate) {
197
+ return !connected[connectedI++];
198
+ }
199
+ return Boolean(item[1].create);
200
+ },
201
+ );
202
+
203
+ if (create.length) {
204
+ await t._count()._createMany(
205
+ create.map(([selfData, item]) => ({
206
+ [foreignKey]: selfData[primaryKey],
207
+ ...('create' in item ? item.create : item.connectOrCreate.create),
208
+ })),
209
+ );
210
+ }
211
+ }) as HasOneNestedInsert,
212
+ nestedUpdate: (async (q, data, params) => {
213
+ if (
214
+ (params.set || params.create || params.upsert) &&
215
+ isQueryReturnsAll(q)
216
+ ) {
217
+ const key = params.set ? 'set' : params.create ? 'create' : 'upsert';
218
+ throw new Error(`\`${key}\` option is not allowed in a batch update`);
219
+ }
220
+
221
+ const t = query.transacting(q);
222
+ const ids = data.map((item) => item[primaryKey]);
223
+ const currentRelationsQuery = t.where({
224
+ [foreignKey]: { in: ids },
225
+ });
226
+
227
+ if (params.create || params.disconnect || params.set) {
228
+ await currentRelationsQuery._update({ [foreignKey]: null });
229
+
230
+ if (params.create) {
231
+ await t._count()._create({
232
+ ...params.create,
233
+ [foreignKey]: data[0][primaryKey],
234
+ });
235
+ }
236
+ if (params.set) {
237
+ await t
238
+ ._where<Query>(params.set)
239
+ ._update({ [foreignKey]: data[0][primaryKey] });
240
+ }
241
+ } else if (params.update) {
242
+ await currentRelationsQuery._update<WhereResult<Query>>(params.update);
243
+ } else if (params.delete) {
244
+ await currentRelationsQuery._delete();
245
+ } else if (params.upsert) {
246
+ const { update, create } = params.upsert;
247
+ const updatedIds: unknown[] = await currentRelationsQuery
248
+ ._pluck(foreignKey)
249
+ ._update<WhereResult<Query & { hasSelect: true }>>(update);
250
+
251
+ if (updatedIds.length < ids.length) {
252
+ await t.createMany(
253
+ ids
254
+ .filter((id) => !updatedIds.includes(id))
255
+ .map((id) => ({
256
+ ...create,
257
+ [foreignKey]: id,
258
+ })),
259
+ );
260
+ }
261
+ }
262
+ }) as HasOneNestedUpdate,
263
+ joinQuery(fromQuery, toQuery) {
264
+ return addQueryOn(toQuery, fromQuery, toQuery, foreignKey, primaryKey);
265
+ },
266
+ reverseJoin(fromQuery, toQuery) {
267
+ return addQueryOn(fromQuery, toQuery, fromQuery, primaryKey, foreignKey);
268
+ },
269
+ primaryKey,
270
+ modifyRelatedQuery(relationQuery) {
271
+ return (query) => {
272
+ const fromQuery = query.clone();
273
+ fromQuery.query.select = fromQuerySelect;
274
+ (relationQuery.query as InsertQueryData).fromQuery = fromQuery;
275
+ };
276
+ },
277
+ };
278
+ };
@@ -0,0 +1,37 @@
1
+ import { db } from '../test-utils/test-db';
2
+ import { expectSql } from '../test-utils/test-utils';
3
+
4
+ describe('relations', () => {
5
+ it('should select multiple relations', () => {
6
+ const query = db.user.select({
7
+ profile: (q) => q.profile.where({ bio: 'bio' }),
8
+ messages: (q) => q.messages.where({ text: 'text' }),
9
+ });
10
+
11
+ expectSql(
12
+ query.toSql(),
13
+ `
14
+ SELECT
15
+ (
16
+ SELECT row_to_json("t".*)
17
+ FROM (
18
+ SELECT * FROM "profile"
19
+ WHERE "profile"."bio" = $1
20
+ AND "profile"."userId" = "user"."id"
21
+ LIMIT $2
22
+ ) AS "t"
23
+ ) AS "profile",
24
+ (
25
+ SELECT COALESCE(json_agg(row_to_json("t".*)), '[]')
26
+ FROM (
27
+ SELECT * FROM "message" AS "messages"
28
+ WHERE "messages"."text" = $3
29
+ AND "messages"."authorId" = "user"."id"
30
+ ) AS "t"
31
+ ) AS "messages"
32
+ FROM "user"
33
+ `,
34
+ ['bio', 1, 'text'],
35
+ );
36
+ });
37
+ });
@@ -0,0 +1,316 @@
1
+ import { BelongsTo, BelongsToInfo, makeBelongsToMethod } from './belongsTo';
2
+ import { HasOne, HasOneInfo, makeHasOneMethod } from './hasOne';
3
+ import { DbModel, Model, ModelClass, ModelClasses } from '../model';
4
+ import { OrchidORM } from '../orm';
5
+ import {
6
+ Query,
7
+ QueryWithTable,
8
+ RelationQuery,
9
+ SetQueryReturnsAll,
10
+ SetQueryReturnsOne,
11
+ SetQueryReturnsOneOptional,
12
+ BaseRelation,
13
+ defaultsKey,
14
+ relationQueryKey,
15
+ EmptyObject,
16
+ } from 'pqb';
17
+ import { HasMany, HasManyInfo, makeHasManyMethod } from './hasMany';
18
+ import {
19
+ HasAndBelongsToMany,
20
+ HasAndBelongsToManyInfo,
21
+ makeHasAndBelongsToManyMethod,
22
+ } from './hasAndBelongsToMany';
23
+ import { getSourceRelation, getThroughRelation } from './utils';
24
+
25
+ export interface RelationThunkBase {
26
+ type: string;
27
+ returns: 'one' | 'many';
28
+ fn(): ModelClass;
29
+ options: BaseRelation['options'];
30
+ }
31
+
32
+ export type RelationThunk = BelongsTo | HasOne | HasMany | HasAndBelongsToMany;
33
+
34
+ export type RelationThunks = Record<string, RelationThunk>;
35
+
36
+ export type RelationData = {
37
+ returns: 'one' | 'many';
38
+ method(params: Record<string, unknown>): Query;
39
+ nestedInsert: BaseRelation['nestedInsert'];
40
+ nestedUpdate: BaseRelation['nestedUpdate'];
41
+ joinQuery(fromQuery: Query, toQuery: Query): Query;
42
+ reverseJoin(fromQuery: Query, toQuery: Query): Query;
43
+ primaryKey: string;
44
+ modifyRelatedQuery?(relatedQuery: Query): (query: Query) => void;
45
+ };
46
+
47
+ export type Relation<
48
+ T extends Model,
49
+ Relations extends RelationThunks,
50
+ K extends keyof Relations,
51
+ M extends Query = DbModel<ReturnType<Relations[K]['fn']>>,
52
+ Info extends RelationInfo = RelationInfo<T, Relations, Relations[K]>,
53
+ > = {
54
+ type: Relations[K]['type'];
55
+ returns: Relations[K]['returns'];
56
+ key: K;
57
+ model: M;
58
+ query: M;
59
+ joinQuery(fromQuery: Query, toQuery: Query): Query;
60
+ defaults: Info['populate'];
61
+ nestedCreateQuery: [Info['populate']] extends [never]
62
+ ? M
63
+ : M & {
64
+ [defaultsKey]: Record<Info['populate'], true>;
65
+ };
66
+ nestedInsert: BaseRelation['nestedInsert'];
67
+ nestedUpdate: BaseRelation['nestedUpdate'];
68
+ primaryKey: string;
69
+ options: Relations[K]['options'];
70
+ };
71
+
72
+ export type RelationScopeOrModel<Relation extends RelationThunkBase> =
73
+ Relation['options']['scope'] extends (q: Query) => Query
74
+ ? ReturnType<Relation['options']['scope']>
75
+ : DbModel<ReturnType<Relation['fn']>>;
76
+
77
+ export type RelationInfo<
78
+ T extends Model = Model,
79
+ Relations extends RelationThunks = RelationThunks,
80
+ Relation extends RelationThunk = RelationThunk,
81
+ > = Relation extends BelongsTo
82
+ ? BelongsToInfo<T, Relation>
83
+ : Relation extends HasOne
84
+ ? HasOneInfo<T, Relations, Relation>
85
+ : Relation extends HasMany
86
+ ? HasManyInfo<T, Relations, Relation>
87
+ : Relation extends HasAndBelongsToMany
88
+ ? HasAndBelongsToManyInfo<T, Relation>
89
+ : never;
90
+
91
+ export type MapRelation<
92
+ T extends Model,
93
+ Relations extends RelationThunks,
94
+ RelationName extends keyof Relations,
95
+ Relation extends RelationThunk = Relations[RelationName],
96
+ RelatedQuery extends Query = RelationScopeOrModel<Relation>,
97
+ Info extends {
98
+ params: Record<string, unknown>;
99
+ populate: string;
100
+ chainedCreate: boolean;
101
+ chainedDelete: boolean;
102
+ } = RelationInfo<T, Relations, Relation>,
103
+ > = RelationQuery<
104
+ RelationName,
105
+ Info['params'],
106
+ Info['populate'],
107
+ Relation['returns'] extends 'one'
108
+ ? Relation['options']['required'] extends true
109
+ ? SetQueryReturnsOne<RelatedQuery>
110
+ : SetQueryReturnsOneOptional<RelatedQuery>
111
+ : SetQueryReturnsAll<RelatedQuery>,
112
+ Relation['options']['required'] extends boolean
113
+ ? Relation['options']['required']
114
+ : false,
115
+ Info['chainedCreate'],
116
+ Info['chainedDelete']
117
+ >;
118
+
119
+ export type MapRelations<T extends Model> = 'relations' extends keyof T
120
+ ? T['relations'] extends RelationThunks
121
+ ? {
122
+ [K in keyof T['relations']]: MapRelation<T, T['relations'], K>;
123
+ }
124
+ : EmptyObject
125
+ : EmptyObject;
126
+
127
+ type ApplyRelationData = {
128
+ relationName: string;
129
+ relation: RelationThunk;
130
+ dbModel: DbModel<ModelClass>;
131
+ otherDbModel: DbModel<ModelClass>;
132
+ };
133
+
134
+ type DelayedRelations = Map<Query, Record<string, ApplyRelationData[]>>;
135
+
136
+ export const applyRelations = (
137
+ qb: Query,
138
+ models: Record<string, Model>,
139
+ result: OrchidORM<ModelClasses>,
140
+ ) => {
141
+ const modelsEntries = Object.entries(models);
142
+
143
+ const delayedRelations: DelayedRelations = new Map();
144
+
145
+ for (const modelName in models) {
146
+ const model = models[modelName] as Model & {
147
+ relations?: RelationThunks;
148
+ };
149
+ if (!('relations' in model) || typeof model.relations !== 'object')
150
+ continue;
151
+
152
+ const dbModel = result[modelName];
153
+ for (const relationName in model.relations) {
154
+ const relation = model.relations[relationName];
155
+ const otherModelClass = relation.fn();
156
+ const otherModel = modelsEntries.find(
157
+ (pair) => pair[1] instanceof otherModelClass,
158
+ );
159
+ if (!otherModel) {
160
+ throw new Error(`Cannot find model for class ${otherModelClass.name}`);
161
+ }
162
+ const otherModelName = otherModel[0];
163
+ const otherDbModel = result[otherModelName];
164
+ if (!otherDbModel)
165
+ throw new Error(`Cannot find model by name ${otherModelName}`);
166
+
167
+ const data: ApplyRelationData = {
168
+ relationName,
169
+ relation,
170
+ dbModel,
171
+ otherDbModel,
172
+ };
173
+
174
+ const options = relation.options as { through?: string; source?: string };
175
+ if (
176
+ typeof options.through === 'string' &&
177
+ typeof options.source === 'string'
178
+ ) {
179
+ const throughRelation = getThroughRelation(dbModel, options.through);
180
+ if (!throughRelation) {
181
+ delayRelation(delayedRelations, dbModel, options.through, data);
182
+ continue;
183
+ }
184
+
185
+ const sourceRelation = getSourceRelation(
186
+ throughRelation,
187
+ options.source,
188
+ );
189
+ if (!sourceRelation) {
190
+ delayRelation(
191
+ delayedRelations,
192
+ throughRelation.model,
193
+ options.source,
194
+ data,
195
+ );
196
+ continue;
197
+ }
198
+ }
199
+
200
+ applyRelation(qb, data, delayedRelations);
201
+ }
202
+ }
203
+ };
204
+
205
+ const delayRelation = (
206
+ delayedRelations: DelayedRelations,
207
+ model: Query,
208
+ relationName: string,
209
+ data: ApplyRelationData,
210
+ ) => {
211
+ let modelRelations = delayedRelations.get(model);
212
+ if (!modelRelations) {
213
+ modelRelations = {};
214
+ delayedRelations.set(model, modelRelations);
215
+ }
216
+ if (modelRelations[relationName]) {
217
+ modelRelations[relationName].push(data);
218
+ } else {
219
+ modelRelations[relationName] = [data];
220
+ }
221
+ };
222
+
223
+ const applyRelation = (
224
+ qb: Query,
225
+ { relationName, relation, dbModel, otherDbModel }: ApplyRelationData,
226
+ delayedRelations: DelayedRelations,
227
+ ) => {
228
+ const query = (
229
+ relation.options.scope
230
+ ? relation.options.scope(otherDbModel)
231
+ : (otherDbModel as unknown as QueryWithTable)
232
+ ).as(relationName);
233
+
234
+ const definedAs = (query as unknown as { definedAs?: string }).definedAs;
235
+ if (!definedAs) {
236
+ throw new Error(
237
+ `Model for table ${query.table} is not attached to db instance`,
238
+ );
239
+ }
240
+
241
+ const { type } = relation;
242
+ let data;
243
+ if (type === 'belongsTo') {
244
+ data = makeBelongsToMethod(relation, query);
245
+ } else if (type === 'hasOne') {
246
+ data = makeHasOneMethod(dbModel, relation, relationName, query);
247
+ } else if (type === 'hasMany') {
248
+ data = makeHasManyMethod(dbModel, relation, relationName, query);
249
+ } else if (type === 'hasAndBelongsToMany') {
250
+ data = makeHasAndBelongsToManyMethod(dbModel, qb, relation, query);
251
+ } else {
252
+ throw new Error(`Unknown relation type ${type}`);
253
+ }
254
+
255
+ if (data.returns === 'one') {
256
+ query._take();
257
+ }
258
+
259
+ makeRelationQuery(dbModel, definedAs, relationName, data);
260
+
261
+ (dbModel.relations as Record<string, unknown>)[relationName] = {
262
+ type,
263
+ key: relationName,
264
+ model: otherDbModel,
265
+ query,
266
+ nestedInsert: data.nestedInsert,
267
+ nestedUpdate: data.nestedUpdate,
268
+ joinQuery: data.joinQuery,
269
+ primaryKey: data.primaryKey,
270
+ options: relation.options,
271
+ };
272
+
273
+ const modelRelations = delayedRelations.get(dbModel);
274
+ if (!modelRelations) return;
275
+
276
+ modelRelations[relationName]?.forEach((data) => {
277
+ applyRelation(qb, data, delayedRelations);
278
+ });
279
+ };
280
+
281
+ const makeRelationQuery = (
282
+ model: Query,
283
+ definedAs: string,
284
+ relationName: string,
285
+ data: RelationData,
286
+ ) => {
287
+ Object.defineProperty(model, relationName, {
288
+ get() {
289
+ const toModel = this.db[definedAs].as(relationName) as Query;
290
+
291
+ if (data.returns === 'one') {
292
+ toModel._take();
293
+ }
294
+
295
+ const query = this.isSubQuery
296
+ ? toModel
297
+ : toModel._whereExists(data.reverseJoin(this, toModel), (q) => q);
298
+
299
+ query.query[relationQueryKey] = {
300
+ relationName,
301
+ sourceQuery: this,
302
+ relationQuery: toModel,
303
+ joinQuery: data.joinQuery,
304
+ };
305
+
306
+ const setQuery = data.modifyRelatedQuery?.(query);
307
+ setQuery?.(this);
308
+
309
+ return new Proxy(data.method, {
310
+ get(_, prop) {
311
+ return (query as unknown as Record<string, unknown>)[prop as string];
312
+ },
313
+ }) as unknown as RelationQuery;
314
+ },
315
+ });
316
+ };
@@ -0,0 +1,12 @@
1
+ import { Query, Relation } from 'pqb';
2
+
3
+ export const getThroughRelation = (model: Query, through: string) => {
4
+ return (model.relations as Record<string, Relation>)[through];
5
+ };
6
+
7
+ export const getSourceRelation = (
8
+ throughRelation: Relation,
9
+ source: string,
10
+ ) => {
11
+ return (throughRelation.model.relations as Record<string, Relation>)[source];
12
+ };