Skip to content

Commit b4321e7

Browse files
authored
feat: many-to-many relations (#15)
* feat: many-to-many relation (sqlite only) * postgres support
1 parent 7537e1e commit b4321e7

File tree

26 files changed

+1807
-368
lines changed

26 files changed

+1807
-368
lines changed

TODO.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@
3636
- [x] Sorting
3737
- [x] Pagination
3838
- [x] Distinct
39-
- [ ] Update
39+
- [x] Update
4040
- [x] Input validation
4141
- [x] Top-level
4242
- [x] Nested to-many
4343
- [x] Nested to-one
4444
- [x] Incremental update for numeric fields
4545
- [x] Array update
4646
- [x] Upsert
47+
- [ ] Implement with "on conflict"
4748
- [x] Delete
4849
- [x] Aggregation
4950
- [x] Count
@@ -54,22 +55,23 @@
5455
- [x] Computed fields
5556
- [ ] Prisma client extension
5657
- [ ] Misc
58+
- [ ] Cache validation schemas
5759
- [ ] Compound ID
5860
- [ ] Cross field comparison
59-
- [ ] Many-to-many relation
60-
- [ ] Cache validation schemas
61+
- [x] Many-to-many relation
62+
- [ ] Empty AND/OR/NOT behavior
6163
- [?] Logging
62-
- [ ] Error system
64+
- [?] Error system
6365
- [x] Custom table name
6466
- [x] Custom field name
65-
- [ ] Empty AND/OR/NOT behavior
6667
- [?] Strict undefined check
6768
- [ ] Access Policy
6869
- [ ] Short-circuit pre-create check for scalar-field only policies
70+
- [ ] Inject "replace into"
71+
- [ ] Inject "on conflict do update"
6972
- [ ] Polymorphism
7073
- [x] Migration
7174
- [ ] Databases
7275
- [x] SQLite
7376
- [x] PostgreSQL
7477
- [ ] Schema
75-
- [ ] MySQL

packages/cli/src/actions/generate.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { isPlugin, LiteralExpr, type Model } from '@zenstackhq/language/ast';
22
import type { CliGenerator } from '@zenstackhq/runtime/client';
3-
import { TsSchemaGenerator } from '@zenstackhq/sdk';
3+
import { PrismaSchemaGenerator, TsSchemaGenerator } from '@zenstackhq/sdk';
44
import colors from 'colors';
55
import fs from 'node:fs';
66
import path from 'node:path';
77
import invariant from 'tiny-invariant';
8-
import { PrismaSchemaGenerator } from '../prisma/prisma-schema-generator';
98
import { getSchemaFile, loadSchemaDocument } from './action-utils';
109

1110
type Options = {

packages/cli/test/ts-schema-gen.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';
44

55
describe('TypeScript schema generation tests', () => {
66
it('generates correct data models', async () => {
7-
const schema = await generateTsSchema(`
7+
const { schema } = await generateTsSchema(`
88
model User {
99
id String @id @default(uuid())
1010
name String

packages/runtime/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
"@types/tmp": "^0.2.6",
114114
"@zenstackhq/language": "workspace:*",
115115
"@zenstackhq/testtools": "workspace:*",
116+
"@zenstackhq/sdk": "workspace:*",
116117
"tmp": "^0.2.3"
117118
}
118119
}

packages/runtime/src/client/client-impl.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
DefaultConnectionProvider,
3+
DefaultQueryExecutor,
34
Kysely,
45
Log,
56
PostgresDialect,
@@ -47,6 +48,7 @@ export const ZenStackClient = function <Schema extends SchemaDef>(
4748

4849
export class ClientImpl<Schema extends SchemaDef> {
4950
private kysely: ToKysely<Schema>;
51+
private kyselyRaw: ToKysely<any>;
5052
public readonly $options: ClientOptions<Schema>;
5153
public readonly $schema: Schema;
5254
readonly kyselyProps: KyselyProps;
@@ -77,6 +79,7 @@ export class ClientImpl<Schema extends SchemaDef> {
7779
new DefaultConnectionProvider(baseClient.kyselyProps.driver)
7880
),
7981
};
82+
this.kyselyRaw = baseClient.kyselyRaw;
8083
} else {
8184
const dialect = this.getKyselyDialect();
8285
const driver = new ZenStackDriver(
@@ -103,6 +106,17 @@ export class ClientImpl<Schema extends SchemaDef> {
103106
driver,
104107
executor,
105108
};
109+
110+
// raw kysely instance with default executor
111+
this.kyselyRaw = new Kysely({
112+
...this.kyselyProps,
113+
executor: new DefaultQueryExecutor(
114+
compiler,
115+
adapter,
116+
connectionProvider,
117+
[]
118+
),
119+
});
106120
}
107121

108122
this.kysely = new Kysely(this.kyselyProps);
@@ -114,6 +128,10 @@ export class ClientImpl<Schema extends SchemaDef> {
114128
return this.kysely;
115129
}
116130

131+
public get $qbRaw() {
132+
return this.kyselyRaw;
133+
}
134+
117135
private getKyselyDialect() {
118136
return match(this.schema.provider.type)
119137
.with('sqlite', () => this.makeSqliteKyselyDialect())

packages/runtime/src/client/contract.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ export type ClientContract<Schema extends SchemaDef> = {
3737
*/
3838
readonly $qb: ToKysely<Schema>;
3939

40+
/**
41+
* The raw Kysely query builder without any ZenStack enhancements.
42+
*/
43+
readonly $qbRaw: ToKysely<any>;
44+
4045
/**
4146
* Starts a transaction.
4247
*/

packages/runtime/src/client/crud/dialects/base.ts

Lines changed: 66 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import {
3232
buildFieldRef,
3333
buildJoinPairs,
3434
getField,
35+
getIdFields,
36+
getManyToManyRelation,
3537
getRelationForeignKeyFieldPairs,
3638
isEnum,
3739
makeDefaultOrderBy,
@@ -68,18 +70,18 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
6870
eb: ExpressionBuilder<any, any>,
6971
model: string,
7072
modelAlias: string,
71-
where: object | undefined
73+
where: boolean | object | undefined
7274
) {
73-
let result = this.true(eb);
74-
75-
if (where === undefined) {
76-
return result;
75+
if (where === true || where === undefined) {
76+
return this.true(eb);
7777
}
7878

79-
if (where === null || typeof where !== 'object') {
80-
throw new InternalError('impossible null as filter');
79+
if (where === false) {
80+
return this.false(eb);
8181
}
8282

83+
let result = this.true(eb);
84+
8385
for (const [key, payload] of Object.entries(where)) {
8486
if (payload === undefined) {
8587
continue;
@@ -148,7 +150,12 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
148150
}
149151

150152
// call expression builder and combine the results
151-
if ('$expr' in where && typeof where['$expr'] === 'function') {
153+
if (
154+
typeof where === 'object' &&
155+
where !== null &&
156+
'$expr' in where &&
157+
typeof where['$expr'] === 'function'
158+
) {
152159
result = this.and(eb, result, where['$expr'](eb));
153160
}
154161

@@ -356,45 +363,67 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
356363
fieldDef: FieldDef,
357364
payload: any
358365
) {
359-
const relationModel = fieldDef.type;
360-
361-
const relationKeyPairs = getRelationForeignKeyFieldPairs(
362-
this.schema,
363-
model,
364-
field
365-
);
366-
367366
// null check needs to be converted to fk "is null" checks
368367
if (payload === null) {
369368
return eb(sql.ref(`${table}.${field}`), 'is', null);
370369
}
371370

371+
const relationModel = fieldDef.type;
372+
372373
const buildPkFkWhereRefs = (eb: ExpressionBuilder<any, any>) => {
373-
let r = this.true(eb);
374-
for (const { fk, pk } of relationKeyPairs.keyPairs) {
375-
if (relationKeyPairs.ownedByModel) {
376-
r = this.and(
377-
eb,
378-
r,
379-
eb(
380-
sql.ref(`${table}.${fk}`),
381-
'=',
382-
sql.ref(`${relationModel}.${pk}`)
383-
)
384-
);
385-
} else {
386-
r = this.and(
387-
eb,
388-
r,
389-
eb(
390-
sql.ref(`${table}.${pk}`),
374+
const m2m = getManyToManyRelation(this.schema, model, field);
375+
if (m2m) {
376+
// many-to-many relation
377+
const modelIdField = getIdFields(this.schema, model)[0]!;
378+
const relationIdField = getIdFields(
379+
this.schema,
380+
relationModel
381+
)[0]!;
382+
return eb(
383+
sql.ref(`${relationModel}.${relationIdField}`),
384+
'in',
385+
eb
386+
.selectFrom(m2m.joinTable)
387+
.select(`${m2m.joinTable}.${m2m.otherFkName}`)
388+
.whereRef(
389+
sql.ref(`${m2m.joinTable}.${m2m.parentFkName}`),
391390
'=',
392-
sql.ref(`${relationModel}.${fk}`)
391+
sql.ref(`${table}.${modelIdField}`)
393392
)
394-
);
393+
);
394+
} else {
395+
const relationKeyPairs = getRelationForeignKeyFieldPairs(
396+
this.schema,
397+
model,
398+
field
399+
);
400+
401+
let result = this.true(eb);
402+
for (const { fk, pk } of relationKeyPairs.keyPairs) {
403+
if (relationKeyPairs.ownedByModel) {
404+
result = this.and(
405+
eb,
406+
result,
407+
eb(
408+
sql.ref(`${table}.${fk}`),
409+
'=',
410+
sql.ref(`${relationModel}.${pk}`)
411+
)
412+
);
413+
} else {
414+
result = this.and(
415+
eb,
416+
result,
417+
eb(
418+
sql.ref(`${table}.${pk}`),
419+
'=',
420+
sql.ref(`${relationModel}.${fk}`)
421+
)
422+
);
423+
}
395424
}
425+
return result;
396426
}
397-
return r;
398427
};
399428

400429
let result = this.true(eb);

packages/runtime/src/client/crud/dialects/postgresql.ts

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import {
66
type RawBuilder,
77
type SelectQueryBuilder,
88
} from 'kysely';
9+
import invariant from 'tiny-invariant';
910
import { match } from 'ts-pattern';
1011
import type { SchemaDef } from '../../../schema';
1112
import type { BuiltinType, FieldDef, GetModels } from '../../../schema/schema';
1213
import type { FindArgs } from '../../crud-types';
1314
import {
1415
buildFieldRef,
1516
buildJoinPairs,
17+
getIdFields,
18+
getManyToManyRelation,
1619
requireField,
1720
requireModel,
1821
} from '../../query-utils';
@@ -129,21 +132,61 @@ export class PostgresCrudDialect<
129132
}
130133

131134
// add join conditions
132-
const joinPairs = buildJoinPairs(
135+
136+
const m2m = getManyToManyRelation(
133137
this.schema,
134138
model,
135-
parentName,
136-
relationField,
137-
relationModel
139+
relationField
138140
);
139-
subQuery = subQuery.where((eb) =>
140-
this.and(
141-
eb,
142-
...joinPairs.map(([left, right]) =>
143-
eb(sql.ref(left), '=', sql.ref(right))
141+
142+
if (m2m) {
143+
// many-to-many relation
144+
const parentIds = getIdFields(this.schema, model);
145+
const relationIds = getIdFields(
146+
this.schema,
147+
relationModel
148+
);
149+
invariant(
150+
parentIds.length === 1,
151+
'many-to-many relation must have exactly one id field'
152+
);
153+
invariant(
154+
relationIds.length === 1,
155+
'many-to-many relation must have exactly one id field'
156+
);
157+
subQuery = subQuery.where(
158+
eb(
159+
eb.ref(`${relationModel}.${relationIds[0]}`),
160+
'in',
161+
eb
162+
.selectFrom(m2m.joinTable)
163+
.select(
164+
`${m2m.joinTable}.${m2m.otherFkName}`
165+
)
166+
.whereRef(
167+
`${parentName}.${parentIds[0]}`,
168+
'=',
169+
`${m2m.joinTable}.${m2m.parentFkName}`
170+
)
144171
)
145-
)
146-
);
172+
);
173+
} else {
174+
const joinPairs = buildJoinPairs(
175+
this.schema,
176+
model,
177+
parentName,
178+
relationField,
179+
relationModel
180+
);
181+
subQuery = subQuery.where((eb) =>
182+
this.and(
183+
eb,
184+
...joinPairs.map(([left, right]) =>
185+
eb(sql.ref(left), '=', sql.ref(right))
186+
)
187+
)
188+
);
189+
}
147190

148191
return subQuery.as(joinTableName);
149192
});

0 commit comments

Comments
 (0)