diff --git a/README.md b/README.md index db969d96..1d51b715 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ ZenStack v3 allows you to define database-evaluated computed fields with the fol postCount: (eb) => eb .selectFrom('Post') - .whereRef('Post.authorId', '=', 'User.id') + .whereRef('Post.authorId', '=', 'id') .select(({ fn }) => fn.countAll().as('postCount') ), diff --git a/package.json b/package.json index 4c0f1bd1..71298a73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "description": "ZenStack", "packageManager": "pnpm@10.12.1", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index fbe9b308..3d7f9d1c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "type": "module", "author": { "name": "ZenStack Team" diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index f8cb8e33..a0b8aeb5 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 927e6e23..9021b8b9 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/dialects/sql.js/package.json b/packages/dialects/sql.js/package.json index 350f8d1b..898a2341 100644 --- a/packages/dialects/sql.js/package.json +++ b/packages/dialects/sql.js/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/kysely-sql-js", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "description": "Kysely dialect for sql.js", "type": "module", "scripts": { diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 0017d81a..0518a6f9 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "type": "module", "private": true, "license": "MIT" diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index c6ed6b0b..cfb4d730 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack", "publisher": "zenstack", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "displayName": "ZenStack Language Tools", "description": "VSCode extension for ZenStack ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index f8e4c48a..0c2458f0 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/language", "description": "ZenStack ZModel language specification", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "license": "MIT", "author": "ZenStack Team", "files": [ diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 4ddb2fb2..d6a694d2 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/runtime", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "description": "ZenStack Runtime", "type": "module", "scripts": { diff --git a/packages/runtime/src/client/crud/dialects/base.ts b/packages/runtime/src/client/crud/dialects/base.ts index 34d3ffd3..5c84e5ff 100644 --- a/packages/runtime/src/client/crud/dialects/base.ts +++ b/packages/runtime/src/client/crud/dialects/base.ts @@ -47,13 +47,13 @@ export abstract class BaseCrudDialect { // #region common query builders - buildSelectModel(eb: ExpressionBuilder, model: string) { + buildSelectModel(eb: ExpressionBuilder, model: string, modelAlias: string) { const modelDef = requireModel(this.schema, model); - let result = eb.selectFrom(model); + let result = eb.selectFrom(model === modelAlias ? model : `${model} as ${modelAlias}`); // join all delegate bases let joinBase = modelDef.baseModel; while (joinBase) { - result = this.buildDelegateJoin(model, joinBase, result); + result = this.buildDelegateJoin(model, modelAlias, joinBase, result); joinBase = requireModel(this.schema, joinBase).baseModel; } return result; @@ -63,12 +63,13 @@ export abstract class BaseCrudDialect { model: GetModels, args: FindArgs, true>, query: SelectQueryBuilder, + modelAlias: string, ) { let result = query; // where if (args.where) { - result = result.where((eb) => this.buildFilter(eb, model, model, args?.where)); + result = result.where((eb) => this.buildFilter(eb, model, modelAlias, args?.where)); } // skip && take @@ -85,7 +86,7 @@ export abstract class BaseCrudDialect { result = this.buildOrderBy( result, model, - model, + modelAlias, args.orderBy, skip !== undefined || take !== undefined, negateOrderBy, @@ -95,14 +96,14 @@ export abstract class BaseCrudDialect { if ('distinct' in args && (args as any).distinct) { const distinct = ensureArray((args as any).distinct) as string[]; if (this.supportsDistinctOn) { - result = result.distinctOn(distinct.map((f) => sql.ref(`${model}.${f}`))); + result = result.distinctOn(distinct.map((f) => sql.ref(`${modelAlias}.${f}`))); } else { throw new QueryError(`"distinct" is not supported by "${this.schema.provider.type}" provider`); } } if (args.cursor) { - result = this.buildCursorFilter(model, result, args.cursor, args.orderBy, negateOrderBy); + result = this.buildCursorFilter(model, result, args.cursor, args.orderBy, negateOrderBy, modelAlias); } return result; } @@ -172,13 +173,15 @@ export abstract class BaseCrudDialect { cursor: FindArgs, true>['cursor'], orderBy: FindArgs, true>['orderBy'], negateOrderBy: boolean, + modelAlias: string, ) { const _orderBy = orderBy ?? makeDefaultOrderBy(this.schema, model); const orderByItems = ensureArray(_orderBy).flatMap((obj) => Object.entries(obj)); const eb = expressionBuilder(); - const cursorFilter = this.buildFilter(eb, model, model, cursor); + const subQueryAlias = `${model}$cursor$sub`; + const cursorFilter = this.buildFilter(eb, model, subQueryAlias, cursor); let result = query; const filters: ExpressionWrapper[] = []; @@ -192,9 +195,11 @@ export abstract class BaseCrudDialect { const op = j === i ? (_order === 'asc' ? '>=' : '<=') : '='; andFilters.push( eb( - eb.ref(`${model}.${field}`), + eb.ref(`${modelAlias}.${field}`), op, - eb.selectFrom(model).select(`${model}.${field}`).where(cursorFilter), + this.buildSelectModel(eb, model, subQueryAlias) + .select(`${subQueryAlias}.${field}`) + .where(cursorFilter), ), ); } @@ -341,18 +346,22 @@ export abstract class BaseCrudDialect { private buildToManyRelationFilter( eb: ExpressionBuilder, model: string, - table: string, + modelAlias: string, field: string, fieldDef: FieldDef, payload: any, ) { // null check needs to be converted to fk "is null" checks if (payload === null) { - return eb(sql.ref(`${table}.${field}`), 'is', null); + return eb(sql.ref(`${modelAlias}.${field}`), 'is', null); } const relationModel = fieldDef.type; + // evaluating the filter involves creating an inner select, + // give it an alias to avoid conflict + const relationFilterSelectAlias = `${modelAlias}$${field}$filter`; + const buildPkFkWhereRefs = (eb: ExpressionBuilder) => { const m2m = getManyToManyRelation(this.schema, model, field); if (m2m) { @@ -360,7 +369,7 @@ export abstract class BaseCrudDialect { const modelIdField = getIdFields(this.schema, model)[0]!; const relationIdField = getIdFields(this.schema, relationModel)[0]!; return eb( - sql.ref(`${relationModel}.${relationIdField}`), + sql.ref(`${relationFilterSelectAlias}.${relationIdField}`), 'in', eb .selectFrom(m2m.joinTable) @@ -368,7 +377,7 @@ export abstract class BaseCrudDialect { .whereRef( sql.ref(`${m2m.joinTable}.${m2m.parentFkName}`), '=', - sql.ref(`${table}.${modelIdField}`), + sql.ref(`${modelAlias}.${modelIdField}`), ), ); } else { @@ -380,13 +389,13 @@ export abstract class BaseCrudDialect { result = this.and( eb, result, - eb(sql.ref(`${table}.${fk}`), '=', sql.ref(`${relationModel}.${pk}`)), + eb(sql.ref(`${modelAlias}.${fk}`), '=', sql.ref(`${relationFilterSelectAlias}.${pk}`)), ); } else { result = this.and( eb, result, - eb(sql.ref(`${table}.${pk}`), '=', sql.ref(`${relationModel}.${fk}`)), + eb(sql.ref(`${modelAlias}.${pk}`), '=', sql.ref(`${relationFilterSelectAlias}.${fk}`)), ); } } @@ -407,10 +416,12 @@ export abstract class BaseCrudDialect { eb, result, eb( - this.buildSelectModel(eb, relationModel) + this.buildSelectModel(eb, relationModel, relationFilterSelectAlias) .select((eb1) => eb1.fn.count(eb1.lit(1)).as('$count')) .where(buildPkFkWhereRefs(eb)) - .where((eb1) => this.buildFilter(eb1, relationModel, relationModel, subPayload)), + .where((eb1) => + this.buildFilter(eb1, relationModel, relationFilterSelectAlias, subPayload), + ), '>', 0, ), @@ -423,11 +434,13 @@ export abstract class BaseCrudDialect { eb, result, eb( - this.buildSelectModel(eb, relationModel) + this.buildSelectModel(eb, relationModel, relationFilterSelectAlias) .select((eb1) => eb1.fn.count(eb1.lit(1)).as('$count')) .where(buildPkFkWhereRefs(eb)) .where((eb1) => - eb1.not(this.buildFilter(eb1, relationModel, relationModel, subPayload)), + eb1.not( + this.buildFilter(eb1, relationModel, relationFilterSelectAlias, subPayload), + ), ), '=', 0, @@ -441,10 +454,12 @@ export abstract class BaseCrudDialect { eb, result, eb( - this.buildSelectModel(eb, relationModel) + this.buildSelectModel(eb, relationModel, relationFilterSelectAlias) .select((eb1) => eb1.fn.count(eb1.lit(1)).as('$count')) .where(buildPkFkWhereRefs(eb)) - .where((eb1) => this.buildFilter(eb1, relationModel, relationModel, subPayload)), + .where((eb1) => + this.buildFilter(eb1, relationModel, relationFilterSelectAlias, subPayload), + ), '=', 0, ), @@ -874,8 +889,9 @@ export abstract class BaseCrudDialect { ); const sort = this.negateSort(value._count, negated); result = result.orderBy((eb) => { - let subQuery = this.buildSelectModel(eb, relationModel); - const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, relationModel); + const subQueryAlias = `${modelAlias}$orderBy$${field}$count`; + let subQuery = this.buildSelectModel(eb, relationModel, subQueryAlias); + const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, subQueryAlias); subQuery = subQuery.where(() => this.and( eb, @@ -909,7 +925,8 @@ export abstract class BaseCrudDialect { buildSelectAllFields( model: string, query: SelectQueryBuilder, - omit?: Record, + omit: Record | undefined, + modelAlias: string, ) { const modelDef = requireModel(this.schema, model); let result = query; @@ -921,13 +938,13 @@ export abstract class BaseCrudDialect { if (omit?.[field] === true) { continue; } - result = this.buildSelectField(result, model, model, field); + result = this.buildSelectField(result, model, modelAlias, field); } // select all fields from delegate descendants and pack into a JSON field `$delegate$Model` const descendants = getDelegateDescendantModels(this.schema, model); for (const subModel of descendants) { - result = this.buildDelegateJoin(model, subModel.name, result); + result = this.buildDelegateJoin(model, modelAlias, subModel.name, result); result = result.select((eb) => { const jsonObject: Record> = {}; for (const field of Object.keys(subModel.fields)) { @@ -964,11 +981,16 @@ export abstract class BaseCrudDialect { } } - buildDelegateJoin(thisModel: string, otherModel: string, query: SelectQueryBuilder) { + buildDelegateJoin( + thisModel: string, + thisModelAlias: string, + otherModelAlias: string, + query: SelectQueryBuilder, + ) { const idFields = getIdFields(this.schema, thisModel); - query = query.leftJoin(otherModel, (qb) => { + query = query.leftJoin(otherModelAlias, (qb) => { for (const idField of idFields) { - qb = qb.onRef(`${thisModel}.${idField}`, '=', `${otherModel}.${idField}`); + qb = qb.onRef(`${thisModelAlias}.${idField}`, '=', `${otherModelAlias}.${idField}`); } return qb; }); diff --git a/packages/runtime/src/client/crud/dialects/postgresql.ts b/packages/runtime/src/client/crud/dialects/postgresql.ts index 6d10fc12..d3e6d48f 100644 --- a/packages/runtime/src/client/crud/dialects/postgresql.ts +++ b/packages/runtime/src/client/crud/dialects/postgresql.ts @@ -82,16 +82,22 @@ export class PostgresCrudDialect extends BaseCrudDiale // however if there're filter/orderBy/take/skip, // we need to build a subquery to handle them before aggregation + + // give sub query an alias to avoid conflict with parent scope + // (e.g., for cases like self-relation) + const subQueryAlias = `${relationModel}$${relationField}$sub`; + result = eb.selectFrom(() => { - let subQuery = this.buildSelectModel(eb, relationModel); + let subQuery = this.buildSelectModel(eb, relationModel, subQueryAlias); subQuery = this.buildSelectAllFields( relationModel, subQuery, typeof payload === 'object' ? payload?.omit : undefined, + subQueryAlias, ); if (payload && typeof payload === 'object') { - subQuery = this.buildFilterSortTake(relationModel, payload, subQuery); + subQuery = this.buildFilterSortTake(relationModel, payload, subQuery, subQueryAlias); } // add join conditions @@ -106,7 +112,7 @@ export class PostgresCrudDialect extends BaseCrudDiale invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field'); subQuery = subQuery.where( eb( - eb.ref(`${relationModel}.${relationIds[0]}`), + eb.ref(`${subQueryAlias}.${relationIds[0]}`), 'in', eb .selectFrom(m2m.joinTable) @@ -119,7 +125,7 @@ export class PostgresCrudDialect extends BaseCrudDiale ), ); } else { - const joinPairs = buildJoinPairs(this.schema, model, parentName, relationField, relationModel); + const joinPairs = buildJoinPairs(this.schema, model, parentName, relationField, subQueryAlias); subQuery = subQuery.where((eb) => this.and(eb, ...joinPairs.map(([left, right]) => eb(sql.ref(left), '=', sql.ref(right)))), ); @@ -130,6 +136,7 @@ export class PostgresCrudDialect extends BaseCrudDiale result = this.buildRelationObjectSelect( relationModel, + joinTableName, relationField, relationFieldDef, result, @@ -149,6 +156,7 @@ export class PostgresCrudDialect extends BaseCrudDiale private buildRelationObjectSelect( relationModel: string, + relationModelAlias: string, relationField: string, relationFieldDef: FieldDef, qb: SelectQueryBuilder, @@ -156,7 +164,14 @@ export class PostgresCrudDialect extends BaseCrudDiale parentName: string, ) { qb = qb.select((eb) => { - const objArgs = this.buildRelationObjectArgs(relationModel, relationField, eb, payload, parentName); + const objArgs = this.buildRelationObjectArgs( + relationModel, + relationModelAlias, + relationField, + eb, + payload, + parentName, + ); if (relationFieldDef.array) { return eb.fn @@ -172,6 +187,7 @@ export class PostgresCrudDialect extends BaseCrudDiale private buildRelationObjectArgs( relationModel: string, + relationModelAlias: string, relationField: string, eb: ExpressionBuilder, payload: true | FindArgs, true>, @@ -202,7 +218,10 @@ export class PostgresCrudDialect extends BaseCrudDiale ...Object.entries(relationModelDef.fields) .filter(([, value]) => !value.relation) .filter(([name]) => !(typeof payload === 'object' && (payload.omit as any)?.[name] === true)) - .map(([field]) => [sql.lit(field), this.fieldRef(relationModel, field, eb, undefined, false)]) + .map(([field]) => [ + sql.lit(field), + this.fieldRef(relationModel, field, eb, relationModelAlias, false), + ]) .flatMap((v) => v), ); } else if (payload.select) { diff --git a/packages/runtime/src/client/crud/dialects/sqlite.ts b/packages/runtime/src/client/crud/dialects/sqlite.ts index 747337fe..c119c883 100644 --- a/packages/runtime/src/client/crud/dialects/sqlite.ts +++ b/packages/runtime/src/client/crud/dialects/sqlite.ts @@ -76,17 +76,21 @@ export class SqliteCrudDialect extends BaseCrudDialect const subQueryName = `${parentAlias}$${relationField}`; let tbl = eb.selectFrom(() => { - let subQuery = this.buildSelectModel(eb, relationModel); + // give sub query an alias to avoid conflict with parent scope + // (e.g., for cases like self-relation) + const subQueryAlias = `${parentAlias}$${relationField}$sub`; + let subQuery = this.buildSelectModel(eb, relationModel, subQueryAlias); subQuery = this.buildSelectAllFields( relationModel, subQuery, typeof payload === 'object' ? payload?.omit : undefined, + subQueryAlias, ); if (payload && typeof payload === 'object') { // take care of where, orderBy, skip, take, cursor, and distinct - subQuery = this.buildFilterSortTake(relationModel, payload, subQuery); + subQuery = this.buildFilterSortTake(relationModel, payload, subQuery, subQueryAlias); } // join conditions @@ -100,7 +104,7 @@ export class SqliteCrudDialect extends BaseCrudDialect invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field'); subQuery = subQuery.where( eb( - eb.ref(`${relationModel}.${relationIds[0]}`), + eb.ref(`${subQueryAlias}.${relationIds[0]}`), 'in', eb .selectFrom(m2m.joinTable) @@ -113,10 +117,10 @@ export class SqliteCrudDialect extends BaseCrudDialect keyPairs.forEach(({ fk, pk }) => { if (ownedByModel) { // the parent model owns the fk - subQuery = subQuery.whereRef(`${relationModel}.${pk}`, '=', `${parentAlias}.${fk}`); + subQuery = subQuery.whereRef(`${subQueryAlias}.${pk}`, '=', `${parentAlias}.${fk}`); } else { // the relation side owns the fk - subQuery = subQuery.whereRef(`${relationModel}.${fk}`, '=', `${parentAlias}.${pk}`); + subQuery = subQuery.whereRef(`${subQueryAlias}.${fk}`, '=', `${parentAlias}.${pk}`); } }); } @@ -158,7 +162,7 @@ export class SqliteCrudDialect extends BaseCrudDialect .map(([field, value]) => { if (field === '_count') { const subJson = this.buildCountJson( - relationModel as GetModels, + relationModel, eb, `${parentAlias}$${relationField}`, value, @@ -168,7 +172,7 @@ export class SqliteCrudDialect extends BaseCrudDialect const fieldDef = requireField(this.schema, relationModel, field); if (fieldDef.relation) { const subJson = this.buildRelationJSON( - relationModel as GetModels, + relationModel, eb, field, `${parentAlias}$${relationField}`, @@ -194,7 +198,7 @@ export class SqliteCrudDialect extends BaseCrudDialect .filter(([, value]) => value) .map(([field, value]) => { const subJson = this.buildRelationJSON( - relationModel as GetModels, + relationModel, eb, field, `${parentAlias}$${relationField}`, diff --git a/packages/runtime/src/client/crud/operations/aggregate.ts b/packages/runtime/src/client/crud/operations/aggregate.ts index 13cb8b8e..fe111481 100644 --- a/packages/runtime/src/client/crud/operations/aggregate.ts +++ b/packages/runtime/src/client/crud/operations/aggregate.ts @@ -18,7 +18,7 @@ export class AggregateOperationHandler extends BaseOpe // table and where let subQuery = this.dialect - .buildSelectModel(eb as ExpressionBuilder, this.model) + .buildSelectModel(eb as ExpressionBuilder, this.model, this.model) .where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, parsedArgs?.where)); // select fields: collect fields from aggregation body diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 80954f67..04a27a00 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -144,10 +144,10 @@ export abstract class BaseOperationHandler { args: FindArgs, true> | undefined, ): Promise { // table - let query = this.dialect.buildSelectModel(expressionBuilder(), model); + let query = this.dialect.buildSelectModel(expressionBuilder(), model, model); if (args) { - query = this.dialect.buildFilterSortTake(model, args, query); + query = this.dialect.buildFilterSortTake(model, args, query, model); } // select @@ -156,7 +156,7 @@ export abstract class BaseOperationHandler { query = this.buildFieldSelection(model, query, args.select, model); } else { // include all scalar fields except those in omit - query = this.dialect.buildSelectAllFields(model, query, (args as any)?.omit); + query = this.dialect.buildSelectAllFields(model, query, (args as any)?.omit, model); } // include @@ -484,7 +484,12 @@ export abstract class BaseOperationHandler { field: rightField, entity: rightEntity, }, - ].sort((a, b) => a.model.localeCompare(b.model)); + ].sort((a, b) => + // the implement m2m join table's "A", "B" fk fields' order is determined + // by model name's sort order, and when identical (for self-relations), + // field name's sort order + a.model !== b.model ? a.model.localeCompare(b.model) : a.field.localeCompare(b.field), + ); const firstIds = getIdFields(this.schema, sortedRecords[0]!.model); const secondIds = getIdFields(this.schema, sortedRecords[1]!.model); @@ -1281,7 +1286,7 @@ export abstract class BaseOperationHandler { ), 'in', this.dialect - .buildSelectModel(eb, filterModel) + .buildSelectModel(eb, filterModel, filterModel) .where(this.dialect.buildFilter(eb, filterModel, filterModel, where)) .select(this.buildIdFieldRefs(kysely, filterModel)) .$if(limit !== undefined, (qb) => qb.limit(limit!)), @@ -1985,7 +1990,7 @@ export abstract class BaseOperationHandler { ), 'in', this.dialect - .buildSelectModel(eb, filterModel) + .buildSelectModel(eb, filterModel, filterModel) .where((eb) => this.dialect.buildFilter(eb, filterModel, filterModel, where)) .select(this.buildIdFieldRefs(kysely, filterModel)) .$if(limit !== undefined, (qb) => qb.limit(limit!)), diff --git a/packages/runtime/src/client/crud/operations/count.ts b/packages/runtime/src/client/crud/operations/count.ts index 8c11af3a..9c321d98 100644 --- a/packages/runtime/src/client/crud/operations/count.ts +++ b/packages/runtime/src/client/crud/operations/count.ts @@ -16,7 +16,7 @@ export class CountOperationHandler extends BaseOperati // nested query for filtering and pagination let subQuery = this.dialect - .buildSelectModel(eb as ExpressionBuilder, this.model) + .buildSelectModel(eb as ExpressionBuilder, this.model, this.model) .where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, parsedArgs?.where)); if (parsedArgs?.select && typeof parsedArgs.select === 'object') { diff --git a/packages/runtime/src/client/query-utils.ts b/packages/runtime/src/client/query-utils.ts index ff690fe1..6f961029 100644 --- a/packages/runtime/src/client/query-utils.ts +++ b/packages/runtime/src/client/query-utils.ts @@ -178,7 +178,7 @@ export function buildFieldRef( if (!computer) { throw new QueryError(`Computed field "${field}" implementation not provided for model "${model}"`); } - return computer(eb); + return computer(eb, { currentModel: modelAlias }); } } @@ -231,11 +231,23 @@ export function getManyToManyRelation(schema: SchemaDef, model: string, field: s // - join table is named _To, unless an explicit name is provided by `@relation` // - foreign keys are named A and B (based on the order of the model) const sortedModelNames = [model, fieldDef.type].sort(); + + let orderedFK: [string, string]; + if (model !== fieldDef.type) { + // not a self-relation, model name's sort order determines fk order + orderedFK = sortedModelNames[0] === model ? ['A', 'B'] : ['B', 'A']; + } else { + // for self-relations, since model names are identical, relation field name's + // sort order determines fk order + const sortedFieldNames = [field, oppositeFieldDef.name].sort(); + orderedFK = sortedFieldNames[0] === field ? ['A', 'B'] : ['B', 'A']; + } + return { - parentFkName: sortedModelNames[0] === model ? 'A' : 'B', + parentFkName: orderedFK[0], otherModel: fieldDef.type, otherField: fieldDef.relation.opposite, - otherFkName: sortedModelNames[0] === fieldDef.type ? 'A' : 'B', + otherFkName: orderedFK[1], joinTable: fieldDef.relation.name ? `_${fieldDef.relation.name}` : `_${sortedModelNames[0]}To${sortedModelNames[1]}`, diff --git a/packages/runtime/test/client-api/computed-fields.test.ts b/packages/runtime/test/client-api/computed-fields.test.ts index 85897452..8ae18a91 100644 --- a/packages/runtime/test/client-api/computed-fields.test.ts +++ b/packages/runtime/test/client-api/computed-fields.test.ts @@ -1,3 +1,4 @@ +import { sql } from 'kysely'; import { afterEach, describe, expect, it } from 'vitest'; import { createTestClient } from '../utils'; @@ -236,10 +237,10 @@ model Post { dbName: TEST_DB, computedFields: { User: { - postCount: (eb: any) => + postCount: (eb: any, context: { currentModel: string }) => eb .selectFrom('Post') - .whereRef('Post.authorId', '=', 'User.id') + .whereRef('Post.authorId', '=', sql.ref(`${context.currentModel}.id`)) .select(() => eb.fn.countAll().as('count')), }, }, diff --git a/packages/runtime/test/client-api/relation.test.ts b/packages/runtime/test/client-api/relation.test.ts deleted file mode 100644 index eeb7a5f3..00000000 --- a/packages/runtime/test/client-api/relation.test.ts +++ /dev/null @@ -1,762 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createTestClient } from '../utils'; - -const TEST_DB = 'client-api-relation-test'; - -describe.each([ - { - provider: 'sqlite' as const, - }, - { provider: 'postgresql' as const }, -])('Relation tests for $provider', ({ provider }) => { - let client: any; - - afterEach(async () => { - await client?.$disconnect(); - }); - - it('works with unnamed one-to-one relation', async () => { - client = await createTestClient( - ` - model User { - id Int @id @default(autoincrement()) - name String - profile Profile? - } - - model Profile { - id Int @id @default(autoincrement()) - age Int - user User @relation(fields: [userId], references: [id]) - userId Int @unique - } - `, - { - provider, - dbName: TEST_DB, - }, - ); - - await expect( - client.user.create({ - data: { - name: 'User', - profile: { create: { age: 20 } }, - }, - include: { profile: true }, - }), - ).resolves.toMatchObject({ - name: 'User', - profile: { age: 20 }, - }); - }); - - it('works with named one-to-one relation', async () => { - client = await createTestClient( - ` - model User { - id Int @id @default(autoincrement()) - name String - profile1 Profile? @relation('profile1') - profile2 Profile? @relation('profile2') - } - - model Profile { - id Int @id @default(autoincrement()) - age Int - user1 User? @relation('profile1', fields: [userId1], references: [id]) - user2 User? @relation('profile2', fields: [userId2], references: [id]) - userId1 Int? @unique - userId2 Int? @unique - } - `, - { - provider, - dbName: TEST_DB, - }, - ); - - await expect( - client.user.create({ - data: { - name: 'User', - profile1: { create: { age: 20 } }, - profile2: { create: { age: 21 } }, - }, - include: { profile1: true, profile2: true }, - }), - ).resolves.toMatchObject({ - name: 'User', - profile1: { age: 20 }, - profile2: { age: 21 }, - }); - }); - - it('works with unnamed one-to-many relation', async () => { - client = await createTestClient( - ` - model User { - id Int @id @default(autoincrement()) - name String - posts Post[] - } - - model Post { - id Int @id @default(autoincrement()) - title String - user User @relation(fields: [userId], references: [id]) - userId Int - } - `, - { - provider, - dbName: TEST_DB, - }, - ); - - await expect( - client.user.create({ - data: { - name: 'User', - posts: { - create: [{ title: 'Post 1' }, { title: 'Post 2' }], - }, - }, - include: { posts: true }, - }), - ).resolves.toMatchObject({ - name: 'User', - posts: [expect.objectContaining({ title: 'Post 1' }), expect.objectContaining({ title: 'Post 2' })], - }); - }); - - it('works with named one-to-many relation', async () => { - client = await createTestClient( - ` - model User { - id Int @id @default(autoincrement()) - name String - posts1 Post[] @relation('userPosts1') - posts2 Post[] @relation('userPosts2') - } - - model Post { - id Int @id @default(autoincrement()) - title String - user1 User? @relation('userPosts1', fields: [userId1], references: [id]) - user2 User? @relation('userPosts2', fields: [userId2], references: [id]) - userId1 Int? - userId2 Int? - } - `, - { - provider, - dbName: TEST_DB, - }, - ); - - await expect( - client.user.create({ - data: { - name: 'User', - posts1: { - create: [{ title: 'Post 1' }, { title: 'Post 2' }], - }, - posts2: { - create: [{ title: 'Post 3' }, { title: 'Post 4' }], - }, - }, - include: { posts1: true, posts2: true }, - }), - ).resolves.toMatchObject({ - name: 'User', - posts1: [expect.objectContaining({ title: 'Post 1' }), expect.objectContaining({ title: 'Post 2' })], - posts2: [expect.objectContaining({ title: 'Post 3' }), expect.objectContaining({ title: 'Post 4' })], - }); - }); - - it('works with explicit many-to-many relation', async () => { - client = await createTestClient( - ` - model User { - id Int @id @default(autoincrement()) - name String - tags UserTag[] - } - - model Tag { - id Int @id @default(autoincrement()) - name String - users UserTag[] - } - - model UserTag { - id Int @id @default(autoincrement()) - userId Int - tagId Int - user User @relation(fields: [userId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) - @@unique([userId, tagId]) - } - `, - { - provider, - dbName: TEST_DB, - }, - ); - - await client.user.create({ data: { id: 1, name: 'User1' } }); - await client.user.create({ data: { id: 2, name: 'User2' } }); - await client.tag.create({ data: { id: 1, name: 'Tag1' } }); - await client.tag.create({ data: { id: 2, name: 'Tag2' } }); - - await client.userTag.create({ data: { userId: 1, tagId: 1 } }); - await client.userTag.create({ data: { userId: 1, tagId: 2 } }); - await client.userTag.create({ data: { userId: 2, tagId: 1 } }); - - await expect( - client.user.findMany({ - include: { tags: { include: { tag: true } } }, - }), - ).resolves.toMatchObject([ - expect.objectContaining({ - name: 'User1', - tags: [ - expect.objectContaining({ - tag: expect.objectContaining({ name: 'Tag1' }), - }), - expect.objectContaining({ - tag: expect.objectContaining({ name: 'Tag2' }), - }), - ], - }), - expect.objectContaining({ - name: 'User2', - tags: [ - expect.objectContaining({ - tag: expect.objectContaining({ name: 'Tag1' }), - }), - ], - }), - ]); - }); - - describe.each([{ relationName: undefined }, { relationName: 'myM2M' }])( - 'Implicit many-to-many relation (relation: $relationName)', - ({ relationName }) => { - beforeEach(async () => { - client = await createTestClient( - ` - model User { - id Int @id @default(autoincrement()) - name String - profile Profile? - tags Tag[] ${relationName ? `@relation("${relationName}")` : ''} - } - - model Tag { - id Int @id @default(autoincrement()) - name String - users User[] ${relationName ? `@relation("${relationName}")` : ''} - } - - model Profile { - id Int @id @default(autoincrement()) - age Int - user User @relation(fields: [userId], references: [id]) - userId Int @unique - } - `, - { - provider, - dbName: provider === 'postgresql' ? TEST_DB : undefined, - usePrismaPush: true, - }, - ); - }); - - it('works with find', async () => { - await client.user.create({ - data: { - id: 1, - name: 'User1', - tags: { - create: [ - { id: 1, name: 'Tag1' }, - { id: 2, name: 'Tag2' }, - ], - }, - profile: { - create: { - id: 1, - age: 20, - }, - }, - }, - }); - - await client.user.create({ - data: { - id: 2, - name: 'User2', - }, - }); - - // include without filter - await expect( - client.user.findFirst({ - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ name: 'Tag1' }), expect.objectContaining({ name: 'Tag2' })], - }); - - await expect( - client.profile.findFirst({ - include: { - user: { - include: { tags: true }, - }, - }, - }), - ).resolves.toMatchObject({ - user: expect.objectContaining({ - tags: [expect.objectContaining({ name: 'Tag1' }), expect.objectContaining({ name: 'Tag2' })], - }), - }); - - await expect( - client.user.findUnique({ - where: { id: 2 }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [], - }); - - // include with filter - await expect( - client.user.findFirst({ - where: { id: 1 }, - include: { tags: { where: { name: 'Tag1' } } }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ name: 'Tag1' })], - }); - - // filter with m2m - await expect( - client.user.findMany({ - where: { tags: { some: { name: 'Tag1' } } }, - }), - ).resolves.toEqual([ - expect.objectContaining({ - name: 'User1', - }), - ]); - await expect( - client.user.findMany({ - where: { tags: { none: { name: 'Tag1' } } }, - }), - ).resolves.toEqual([ - expect.objectContaining({ - name: 'User2', - }), - ]); - }); - - it('works with create', async () => { - // create - await expect( - client.user.create({ - data: { - id: 1, - name: 'User1', - tags: { - create: [ - { - id: 1, - name: 'Tag1', - }, - { - id: 2, - name: 'Tag2', - }, - ], - }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ name: 'Tag1' }), expect.objectContaining({ name: 'Tag2' })], - }); - - // connect - await expect( - client.user.create({ - data: { - id: 2, - name: 'User2', - tags: { connect: { id: 1 } }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ name: 'Tag1' })], - }); - - // connectOrCreate - await expect( - client.user.create({ - data: { - id: 3, - name: 'User3', - tags: { - connectOrCreate: { - where: { id: 1 }, - create: { id: 1, name: 'Tag1' }, - }, - }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 1, name: 'Tag1' })], - }); - - await expect( - client.user.create({ - data: { - id: 4, - name: 'User4', - tags: { - connectOrCreate: { - where: { id: 3 }, - create: { id: 3, name: 'Tag3' }, - }, - }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 3, name: 'Tag3' })], - }); - }); - - it('works with update', async () => { - // create - await client.user.create({ - data: { - id: 1, - name: 'User1', - tags: { - create: [ - { - id: 1, - name: 'Tag1', - }, - ], - }, - }, - include: { tags: true }, - }); - - // create - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - create: [ - { - id: 2, - name: 'Tag2', - }, - ], - }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 1 }), expect.objectContaining({ id: 2 })], - }); - - await client.tag.create({ - data: { - id: 3, - name: 'Tag3', - }, - }); - - // connect - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { connect: { id: 3 } } }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [ - expect.objectContaining({ id: 1 }), - expect.objectContaining({ id: 2 }), - expect.objectContaining({ id: 3 }), - ], - }); - // connecting a connected entity is no-op - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { connect: { id: 3 } } }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [ - expect.objectContaining({ id: 1 }), - expect.objectContaining({ id: 2 }), - expect.objectContaining({ id: 3 }), - ], - }); - - // disconnect - not found - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { disconnect: { id: 3, name: 'not found' } }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [ - expect.objectContaining({ id: 1 }), - expect.objectContaining({ id: 2 }), - expect.objectContaining({ id: 3 }), - ], - }); - - // disconnect - found - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { disconnect: { id: 3 } } }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 1 }), expect.objectContaining({ id: 2 })], - }); - - await expect( - client.$qbRaw - .selectFrom(relationName ? `_${relationName}` : '_TagToUser') - .selectAll() - .where('B', '=', 1) // user id - .where('A', '=', 3) // tag id - .execute(), - ).resolves.toHaveLength(0); - - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { set: [{ id: 2 }, { id: 3 }] } }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 2 }), expect.objectContaining({ id: 3 })], - }); - - // update - not found - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - update: { - where: { id: 1 }, - data: { name: 'Tag1-updated' }, - }, - }, - }, - }), - ).toBeRejectedNotFound(); - - // update - found - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - update: { - where: { id: 2 }, - data: { name: 'Tag2-updated' }, - }, - }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: expect.arrayContaining([ - expect.objectContaining({ - id: 2, - name: 'Tag2-updated', - }), - expect.objectContaining({ id: 3, name: 'Tag3' }), - ]), - }); - - // updateMany - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - updateMany: { - where: { id: { not: 2 } }, - data: { name: 'Tag3-updated' }, - }, - }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [ - expect.objectContaining({ - id: 2, - name: 'Tag2-updated', - }), - expect.objectContaining({ - id: 3, - name: 'Tag3-updated', - }), - ], - }); - - await expect(client.tag.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ - name: 'Tag1', - }); - - // upsert - update - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - upsert: { - where: { id: 3 }, - create: { id: 3, name: 'Tag4' }, - update: { name: 'Tag3-updated-1' }, - }, - }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [ - expect.objectContaining({ - id: 2, - name: 'Tag2-updated', - }), - expect.objectContaining({ - id: 3, - name: 'Tag3-updated-1', - }), - ], - }); - - // upsert - create - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - upsert: { - where: { id: 4 }, - create: { id: 4, name: 'Tag4' }, - update: { name: 'Tag4' }, - }, - }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: expect.arrayContaining([expect.objectContaining({ id: 4, name: 'Tag4' })]), - }); - - // delete - not found - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { delete: { id: 1 } } }, - }), - ).toBeRejectedNotFound(); - - // delete - found - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { delete: { id: 2 } } }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 3 }), expect.objectContaining({ id: 4 })], - }); - await expect(client.tag.findUnique({ where: { id: 2 } })).toResolveNull(); - - // deleteMany - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { deleteMany: { id: { in: [1, 2, 3] } } }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 4 })], - }); - await expect(client.tag.findUnique({ where: { id: 3 } })).toResolveNull(); - await expect(client.tag.findUnique({ where: { id: 1 } })).toResolveTruthy(); - }); - - it('works with delete', async () => { - await client.user.create({ - data: { - id: 1, - name: 'User1', - tags: { - create: [ - { id: 1, name: 'Tag1' }, - { id: 2, name: 'Tag2' }, - ], - }, - }, - }); - - // cascade from tag - await client.tag.delete({ - where: { id: 1 }, - }); - await expect( - client.user.findUnique({ - where: { id: 1 }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 2 })], - }); - - // cascade from user - await client.user.delete({ - where: { id: 1 }, - }); - await expect( - client.tag.findUnique({ - where: { id: 2 }, - include: { users: true }, - }), - ).resolves.toMatchObject({ - users: [], - }); - }); - }, - ); -}); diff --git a/packages/runtime/test/client-api/relation/many-to-many.test.ts b/packages/runtime/test/client-api/relation/many-to-many.test.ts new file mode 100644 index 00000000..c951387e --- /dev/null +++ b/packages/runtime/test/client-api/relation/many-to-many.test.ts @@ -0,0 +1,603 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createTestClient } from '../../utils'; + +const TEST_DB = 'client-api-relation-test-many-to-many'; + +describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( + 'Many-to-many relation tests for $provider', + ({ provider }) => { + let client: any; + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('works with explicit many-to-many relation', async () => { + client = await createTestClient( + ` + model User { + id Int @id @default(autoincrement()) + name String + tags UserTag[] + } + + model Tag { + id Int @id @default(autoincrement()) + name String + users UserTag[] + } + + model UserTag { + id Int @id @default(autoincrement()) + userId Int + tagId Int + user User @relation(fields: [userId], references: [id]) + tag Tag @relation(fields: [tagId], references: [id]) + @@unique([userId, tagId]) + } + `, + { + provider, + dbName: TEST_DB, + }, + ); + + await client.user.create({ data: { id: 1, name: 'User1' } }); + await client.user.create({ data: { id: 2, name: 'User2' } }); + await client.tag.create({ data: { id: 1, name: 'Tag1' } }); + await client.tag.create({ data: { id: 2, name: 'Tag2' } }); + + await client.userTag.create({ data: { userId: 1, tagId: 1 } }); + await client.userTag.create({ data: { userId: 1, tagId: 2 } }); + await client.userTag.create({ data: { userId: 2, tagId: 1 } }); + + await expect( + client.user.findMany({ + include: { tags: { include: { tag: true } } }, + }), + ).resolves.toMatchObject([ + expect.objectContaining({ + name: 'User1', + tags: [ + expect.objectContaining({ + tag: expect.objectContaining({ name: 'Tag1' }), + }), + expect.objectContaining({ + tag: expect.objectContaining({ name: 'Tag2' }), + }), + ], + }), + expect.objectContaining({ + name: 'User2', + tags: [ + expect.objectContaining({ + tag: expect.objectContaining({ name: 'Tag1' }), + }), + ], + }), + ]); + }); + + describe.each([{ relationName: undefined }, { relationName: 'myM2M' }])( + 'Implicit many-to-many relation (relation: $relationName)', + ({ relationName }) => { + beforeEach(async () => { + client = await createTestClient( + ` + model User { + id Int @id @default(autoincrement()) + name String + profile Profile? + tags Tag[] ${relationName ? `@relation("${relationName}")` : ''} + } + + model Tag { + id Int @id @default(autoincrement()) + name String + users User[] ${relationName ? `@relation("${relationName}")` : ''} + } + + model Profile { + id Int @id @default(autoincrement()) + age Int + user User @relation(fields: [userId], references: [id]) + userId Int @unique + } + `, + { + provider, + dbName: provider === 'postgresql' ? TEST_DB : undefined, + usePrismaPush: true, + }, + ); + }); + + it('works with find', async () => { + await client.user.create({ + data: { + id: 1, + name: 'User1', + tags: { + create: [ + { id: 1, name: 'Tag1' }, + { id: 2, name: 'Tag2' }, + ], + }, + profile: { + create: { + id: 1, + age: 20, + }, + }, + }, + }); + + await client.user.create({ + data: { + id: 2, + name: 'User2', + }, + }); + + // include without filter + await expect( + client.user.findFirst({ + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ name: 'Tag1' }), expect.objectContaining({ name: 'Tag2' })], + }); + + await expect( + client.profile.findFirst({ + include: { + user: { + include: { tags: true }, + }, + }, + }), + ).resolves.toMatchObject({ + user: expect.objectContaining({ + tags: [ + expect.objectContaining({ name: 'Tag1' }), + expect.objectContaining({ name: 'Tag2' }), + ], + }), + }); + + await expect( + client.user.findUnique({ + where: { id: 2 }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [], + }); + + // include with filter + await expect( + client.user.findFirst({ + where: { id: 1 }, + include: { tags: { where: { name: 'Tag1' } } }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ name: 'Tag1' })], + }); + + // filter with m2m + await expect( + client.user.findMany({ + where: { tags: { some: { name: 'Tag1' } } }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + name: 'User1', + }), + ]); + await expect( + client.user.findMany({ + where: { tags: { none: { name: 'Tag1' } } }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + name: 'User2', + }), + ]); + }); + + it('works with create', async () => { + // create + await expect( + client.user.create({ + data: { + id: 1, + name: 'User1', + tags: { + create: [ + { + id: 1, + name: 'Tag1', + }, + { + id: 2, + name: 'Tag2', + }, + ], + }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ name: 'Tag1' }), expect.objectContaining({ name: 'Tag2' })], + }); + + // connect + await expect( + client.user.create({ + data: { + id: 2, + name: 'User2', + tags: { connect: { id: 1 } }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ name: 'Tag1' })], + }); + + // connectOrCreate + await expect( + client.user.create({ + data: { + id: 3, + name: 'User3', + tags: { + connectOrCreate: { + where: { id: 1 }, + create: { id: 1, name: 'Tag1' }, + }, + }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 1, name: 'Tag1' })], + }); + + await expect( + client.user.create({ + data: { + id: 4, + name: 'User4', + tags: { + connectOrCreate: { + where: { id: 3 }, + create: { id: 3, name: 'Tag3' }, + }, + }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 3, name: 'Tag3' })], + }); + }); + + it('works with update', async () => { + // create + await client.user.create({ + data: { + id: 1, + name: 'User1', + tags: { + create: [ + { + id: 1, + name: 'Tag1', + }, + ], + }, + }, + include: { tags: true }, + }); + + // create + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { + create: [ + { + id: 2, + name: 'Tag2', + }, + ], + }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 1 }), expect.objectContaining({ id: 2 })], + }); + + await client.tag.create({ + data: { + id: 3, + name: 'Tag3', + }, + }); + + // connect + await expect( + client.user.update({ + where: { id: 1 }, + data: { tags: { connect: { id: 3 } } }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [ + expect.objectContaining({ id: 1 }), + expect.objectContaining({ id: 2 }), + expect.objectContaining({ id: 3 }), + ], + }); + // connecting a connected entity is no-op + await expect( + client.user.update({ + where: { id: 1 }, + data: { tags: { connect: { id: 3 } } }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [ + expect.objectContaining({ id: 1 }), + expect.objectContaining({ id: 2 }), + expect.objectContaining({ id: 3 }), + ], + }); + + // disconnect - not found + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { disconnect: { id: 3, name: 'not found' } }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [ + expect.objectContaining({ id: 1 }), + expect.objectContaining({ id: 2 }), + expect.objectContaining({ id: 3 }), + ], + }); + + // disconnect - found + await expect( + client.user.update({ + where: { id: 1 }, + data: { tags: { disconnect: { id: 3 } } }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 1 }), expect.objectContaining({ id: 2 })], + }); + + await expect( + client.$qbRaw + .selectFrom(relationName ? `_${relationName}` : '_TagToUser') + .selectAll() + .where('B', '=', 1) // user id + .where('A', '=', 3) // tag id + .execute(), + ).resolves.toHaveLength(0); + + await expect( + client.user.update({ + where: { id: 1 }, + data: { tags: { set: [{ id: 2 }, { id: 3 }] } }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 2 }), expect.objectContaining({ id: 3 })], + }); + + // update - not found + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { + update: { + where: { id: 1 }, + data: { name: 'Tag1-updated' }, + }, + }, + }, + }), + ).toBeRejectedNotFound(); + + // update - found + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { + update: { + where: { id: 2 }, + data: { name: 'Tag2-updated' }, + }, + }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: expect.arrayContaining([ + expect.objectContaining({ + id: 2, + name: 'Tag2-updated', + }), + expect.objectContaining({ id: 3, name: 'Tag3' }), + ]), + }); + + // updateMany + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { + updateMany: { + where: { id: { not: 2 } }, + data: { name: 'Tag3-updated' }, + }, + }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [ + expect.objectContaining({ + id: 2, + name: 'Tag2-updated', + }), + expect.objectContaining({ + id: 3, + name: 'Tag3-updated', + }), + ], + }); + + await expect(client.tag.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ + name: 'Tag1', + }); + + // upsert - update + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { + upsert: { + where: { id: 3 }, + create: { id: 3, name: 'Tag4' }, + update: { name: 'Tag3-updated-1' }, + }, + }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [ + expect.objectContaining({ + id: 2, + name: 'Tag2-updated', + }), + expect.objectContaining({ + id: 3, + name: 'Tag3-updated-1', + }), + ], + }); + + // upsert - create + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { + upsert: { + where: { id: 4 }, + create: { id: 4, name: 'Tag4' }, + update: { name: 'Tag4' }, + }, + }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: expect.arrayContaining([expect.objectContaining({ id: 4, name: 'Tag4' })]), + }); + + // delete - not found + await expect( + client.user.update({ + where: { id: 1 }, + data: { tags: { delete: { id: 1 } } }, + }), + ).toBeRejectedNotFound(); + + // delete - found + await expect( + client.user.update({ + where: { id: 1 }, + data: { tags: { delete: { id: 2 } } }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 3 }), expect.objectContaining({ id: 4 })], + }); + await expect(client.tag.findUnique({ where: { id: 2 } })).toResolveNull(); + + // deleteMany + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { deleteMany: { id: { in: [1, 2, 3] } } }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 4 })], + }); + await expect(client.tag.findUnique({ where: { id: 3 } })).toResolveNull(); + await expect(client.tag.findUnique({ where: { id: 1 } })).toResolveTruthy(); + }); + + it('works with delete', async () => { + await client.user.create({ + data: { + id: 1, + name: 'User1', + tags: { + create: [ + { id: 1, name: 'Tag1' }, + { id: 2, name: 'Tag2' }, + ], + }, + }, + }); + + // cascade from tag + await client.tag.delete({ + where: { id: 1 }, + }); + await expect( + client.user.findUnique({ + where: { id: 1 }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 2 })], + }); + + // cascade from user + await client.user.delete({ + where: { id: 1 }, + }); + await expect( + client.tag.findUnique({ + where: { id: 2 }, + include: { users: true }, + }), + ).resolves.toMatchObject({ + users: [], + }); + }); + }, + ); + }, +); diff --git a/packages/runtime/test/client-api/relation/one-to-many.test.ts b/packages/runtime/test/client-api/relation/one-to-many.test.ts new file mode 100644 index 00000000..656c5daa --- /dev/null +++ b/packages/runtime/test/client-api/relation/one-to-many.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createTestClient } from '../../utils'; + +const TEST_DB = 'client-api-relation-test-one-to-many'; + +describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( + 'One-to-many relation tests for $provider', + ({ provider }) => { + let client: any; + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('works with unnamed one-to-many relation', async () => { + client = await createTestClient( + ` + model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + } + + model Post { + id Int @id @default(autoincrement()) + title String + user User @relation(fields: [userId], references: [id]) + userId Int + } + `, + { + provider, + dbName: TEST_DB, + }, + ); + + await expect( + client.user.create({ + data: { + name: 'User', + posts: { + create: [{ title: 'Post 1' }, { title: 'Post 2' }], + }, + }, + include: { posts: true }, + }), + ).resolves.toMatchObject({ + name: 'User', + posts: [expect.objectContaining({ title: 'Post 1' }), expect.objectContaining({ title: 'Post 2' })], + }); + }); + + it('works with named one-to-many relation', async () => { + client = await createTestClient( + ` + model User { + id Int @id @default(autoincrement()) + name String + posts1 Post[] @relation('userPosts1') + posts2 Post[] @relation('userPosts2') + } + + model Post { + id Int @id @default(autoincrement()) + title String + user1 User? @relation('userPosts1', fields: [userId1], references: [id]) + user2 User? @relation('userPosts2', fields: [userId2], references: [id]) + userId1 Int? + userId2 Int? + } + `, + { + provider, + dbName: TEST_DB, + }, + ); + + await expect( + client.user.create({ + data: { + name: 'User', + posts1: { + create: [{ title: 'Post 1' }, { title: 'Post 2' }], + }, + posts2: { + create: [{ title: 'Post 3' }, { title: 'Post 4' }], + }, + }, + include: { posts1: true, posts2: true }, + }), + ).resolves.toMatchObject({ + name: 'User', + posts1: [expect.objectContaining({ title: 'Post 1' }), expect.objectContaining({ title: 'Post 2' })], + posts2: [expect.objectContaining({ title: 'Post 3' }), expect.objectContaining({ title: 'Post 4' })], + }); + }); + }, +); diff --git a/packages/runtime/test/client-api/relation/one-to-one.test.ts b/packages/runtime/test/client-api/relation/one-to-one.test.ts new file mode 100644 index 00000000..b1e80562 --- /dev/null +++ b/packages/runtime/test/client-api/relation/one-to-one.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createTestClient } from '../../utils'; + +const TEST_DB = 'client-api-relation-test-one-to-one'; + +describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( + 'One-to-one relation tests for $provider', + ({ provider }) => { + let client: any; + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('works with unnamed one-to-one relation', async () => { + client = await createTestClient( + ` + model User { + id Int @id @default(autoincrement()) + name String + profile Profile? + } + + model Profile { + id Int @id @default(autoincrement()) + age Int + user User @relation(fields: [userId], references: [id]) + userId Int @unique + } + `, + { + provider, + dbName: TEST_DB, + }, + ); + + await expect( + client.user.create({ + data: { + name: 'User', + profile: { create: { age: 20 } }, + }, + include: { profile: true }, + }), + ).resolves.toMatchObject({ + name: 'User', + profile: { age: 20 }, + }); + }); + + it('works with named one-to-one relation', async () => { + client = await createTestClient( + ` + model User { + id Int @id @default(autoincrement()) + name String + profile1 Profile? @relation('profile1') + profile2 Profile? @relation('profile2') + } + + model Profile { + id Int @id @default(autoincrement()) + age Int + user1 User? @relation('profile1', fields: [userId1], references: [id]) + user2 User? @relation('profile2', fields: [userId2], references: [id]) + userId1 Int? @unique + userId2 Int? @unique + } + `, + { + provider, + dbName: TEST_DB, + }, + ); + + await expect( + client.user.create({ + data: { + name: 'User', + profile1: { create: { age: 20 } }, + profile2: { create: { age: 21 } }, + }, + include: { profile1: true, profile2: true }, + }), + ).resolves.toMatchObject({ + name: 'User', + profile1: { age: 20 }, + profile2: { age: 21 }, + }); + }); + }, +); diff --git a/packages/runtime/test/client-api/relation/self-relation.test.ts b/packages/runtime/test/client-api/relation/self-relation.test.ts new file mode 100644 index 00000000..f85c20c4 --- /dev/null +++ b/packages/runtime/test/client-api/relation/self-relation.test.ts @@ -0,0 +1,757 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createTestClient } from '../../utils'; + +const TEST_DB = 'client-api-relation-test-self-relation'; + +describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( + 'Self relation tests for $provider', + ({ provider }) => { + let client: any; + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('works with one-to-one self relation', async () => { + client = await createTestClient( + ` + model User { + id Int @id @default(autoincrement()) + name String + spouse User? @relation("Marriage", fields: [spouseId], references: [id]) + marriedTo User? @relation("Marriage") + spouseId Int? @unique + } + `, + { + provider, + dbName: TEST_DB, + usePrismaPush: true, + }, + ); + + // Create first user + const alice = await client.user.create({ + data: { name: 'Alice' }, + }); + + // Create second user and establish marriage relationship + await expect( + client.user.create({ + data: { + name: 'Bob', + spouse: { connect: { id: alice.id } }, + }, + include: { spouse: true }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + spouse: { name: 'Alice' }, + }); + + // Verify the reverse relationship + await expect( + client.user.findUnique({ + where: { id: alice.id }, + include: { marriedTo: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + marriedTo: { name: 'Bob' }, + }); + + // Test creating with nested create + await expect( + client.user.create({ + data: { + name: 'Charlie', + spouse: { + create: { name: 'Diana' }, + }, + }, + include: { spouse: true }, + }), + ).resolves.toMatchObject({ + name: 'Charlie', + spouse: { name: 'Diana' }, + }); + + // Verify Diana is married to Charlie + await expect( + client.user.findFirst({ + where: { name: 'Diana' }, + include: { marriedTo: true }, + }), + ).resolves.toMatchObject({ + name: 'Diana', + marriedTo: { name: 'Charlie' }, + }); + + // Test disconnecting relationship + const bob = await client.user.findFirst({ + where: { name: 'Bob' }, + }); + + await expect( + client.user.update({ + where: { id: bob!.id }, + data: { + spouse: { disconnect: true }, + }, + include: { spouse: true, marriedTo: true }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + spouse: null, + marriedTo: null, + }); + + // Verify Alice is also disconnected + await expect( + client.user.findUnique({ + where: { id: alice.id }, + include: { spouse: true, marriedTo: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + spouse: null, + marriedTo: null, + }); + }); + + it('works with one-to-many self relation', async () => { + client = await createTestClient( + ` + model Category { + id Int @id @default(autoincrement()) + name String + parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id]) + children Category[] @relation("CategoryHierarchy") + parentId Int? + } + `, + { + provider, + dbName: TEST_DB, + usePrismaPush: true, + }, + ); + + // Create parent category + const parent = await client.category.create({ + data: { + name: 'Electronics', + }, + }); + + // Create children with parent + await expect( + client.category.create({ + data: { + name: 'Smartphones', + parent: { connect: { id: parent.id } }, + }, + include: { parent: true }, + }), + ).resolves.toMatchObject({ + name: 'Smartphones', + parent: { name: 'Electronics' }, + }); + + // Create child using nested create + await expect( + client.category.create({ + data: { + name: 'Gaming', + children: { + create: [{ name: 'Console Games' }, { name: 'PC Games' }], + }, + }, + include: { children: true }, + }), + ).resolves.toMatchObject({ + name: 'Gaming', + children: [ + expect.objectContaining({ name: 'Console Games' }), + expect.objectContaining({ name: 'PC Games' }), + ], + }); + + // Query with full hierarchy + await expect( + client.category.findFirst({ + where: { name: 'Electronics' }, + include: { + children: { + include: { parent: true }, + }, + }, + }), + ).resolves.toMatchObject({ + name: 'Electronics', + children: [ + expect.objectContaining({ + name: 'Smartphones', + parent: expect.objectContaining({ name: 'Electronics' }), + }), + ], + }); + + // Test relation manipulation with update - move child to different parent + const gaming = await client.category.findFirst({ where: { name: 'Gaming' } }); + const smartphone = await client.category.findFirst({ where: { name: 'Smartphones' } }); + + await expect( + client.category.update({ + where: { id: smartphone.id }, + data: { + parent: { connect: { id: gaming.id } }, + }, + include: { parent: true }, + }), + ).resolves.toMatchObject({ + name: 'Smartphones', + parent: { name: 'Gaming' }, + }); + + // Test update to disconnect parent (make orphan) + await expect( + client.category.update({ + where: { id: smartphone.id }, + data: { + parent: { disconnect: true }, + }, + include: { parent: true }, + }), + ).resolves.toMatchObject({ + name: 'Smartphones', + parent: null, + }); + + // Test update to add new children to existing parent + const newChild = await client.category.create({ data: { name: 'Accessories' } }); + + await expect( + client.category.update({ + where: { id: parent.id }, + data: { + children: { connect: { id: newChild.id } }, + }, + include: { children: true }, + }), + ).resolves.toMatchObject({ + name: 'Electronics', + children: expect.arrayContaining([expect.objectContaining({ name: 'Accessories' })]), + }); + + // Test nested relation delete - delete specific children via update + const consoleGames = await client.category.findFirst({ where: { name: 'Console Games' } }); + + await expect( + client.category.update({ + where: { id: gaming.id }, + data: { + children: { + delete: { id: consoleGames.id }, + }, + }, + include: { children: true }, + }), + ).resolves.toMatchObject({ + name: 'Gaming', + children: [expect.objectContaining({ name: 'PC Games' })], + }); + + // Verify the deleted child no longer exists + await expect(client.category.findFirst({ where: { id: consoleGames.id } })).resolves.toBeNull(); + + // Test nested delete with multiple children + await expect( + client.category.update({ + where: { id: gaming.id }, + data: { + children: { + deleteMany: { + name: { startsWith: 'PC' }, + }, + }, + }, + include: { children: true }, + }), + ).resolves.toMatchObject({ + name: 'Gaming', + children: [], + }); + + // Test update with nested delete using where condition + await expect( + client.category.update({ + where: { id: parent.id }, + data: { + children: { + deleteMany: { + name: 'Accessories', + }, + }, + }, + include: { children: true }, + }), + ).resolves.toMatchObject({ + name: 'Electronics', + children: [], + }); + }); + + it('works with many-to-many self relation', async () => { + client = await createTestClient( + ` + model User { + id Int @id @default(autoincrement()) + name String + following User[] @relation("UserFollows") + followers User[] @relation("UserFollows") + } + `, + { + provider, + dbName: provider === 'postgresql' ? TEST_DB : undefined, + usePrismaPush: true, + }, + ); + + // Create users + const user1 = await client.user.create({ data: { name: 'Alice' } }); + const user2 = await client.user.create({ data: { name: 'Bob' } }); + const user3 = await client.user.create({ data: { name: 'Charlie' } }); + + // Alice follows Bob and Charlie + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + connect: [{ id: user2.id }, { id: user3.id }], + }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: [expect.objectContaining({ name: 'Bob' }), expect.objectContaining({ name: 'Charlie' })], + }); + + // Bob follows Charlie + await client.user.update({ + where: { id: user2.id }, + data: { + following: { connect: { id: user3.id } }, + }, + }); + + // Check Bob's followers (should include Alice) + await expect( + client.user.findUnique({ + where: { id: user2.id }, + include: { followers: true }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + followers: [expect.objectContaining({ name: 'Alice' })], + }); + + // Check Charlie's followers (should include Alice and Bob) + await expect( + client.user.findUnique({ + where: { id: user3.id }, + include: { followers: true }, + }), + ).resolves.toMatchObject({ + name: 'Charlie', + followers: [expect.objectContaining({ name: 'Alice' }), expect.objectContaining({ name: 'Bob' })], + }); + + // Test filtering with self relation + await expect( + client.user.findMany({ + where: { + followers: { + some: { name: 'Alice' }, + }, + }, + }), + ).resolves.toEqual([ + expect.objectContaining({ name: 'Bob' }), + expect.objectContaining({ name: 'Charlie' }), + ]); + + // Test disconnect operation + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + disconnect: { id: user2.id }, + }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: [expect.objectContaining({ name: 'Charlie' })], + }); + + // Verify Bob no longer has Alice as follower + await expect( + client.user.findUnique({ + where: { id: user2.id }, + include: { followers: true }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + followers: [], + }); + + // Test set operation (replace all following) + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + set: [{ id: user2.id }], + }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: [expect.objectContaining({ name: 'Bob' })], + }); + + // Verify Charlie no longer has Alice as follower after set + await expect( + client.user.findUnique({ + where: { id: user3.id }, + include: { followers: true }, + }), + ).resolves.toMatchObject({ + name: 'Charlie', + followers: [expect.objectContaining({ name: 'Bob' })], + }); + + // Test connectOrCreate with existing user + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + connectOrCreate: { + where: { id: user3.id }, + create: { name: 'Charlie' }, + }, + }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: [expect.objectContaining({ name: 'Bob' }), expect.objectContaining({ name: 'Charlie' })], + }); + + // Test connectOrCreate with new user + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + connectOrCreate: { + where: { id: 999 }, + create: { name: 'David' }, + }, + }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: expect.arrayContaining([ + expect.objectContaining({ name: 'Bob' }), + expect.objectContaining({ name: 'Charlie' }), + expect.objectContaining({ name: 'David' }), + ]), + }); + + // Test create operation within update + await expect( + client.user.update({ + where: { id: user2.id }, + data: { + following: { + create: { name: 'Eve' }, + }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + following: expect.arrayContaining([ + expect.objectContaining({ name: 'Charlie' }), + expect.objectContaining({ name: 'Eve' }), + ]), + }); + + // Test deleteMany operation (disconnect and delete) + const davidUser = await client.user.findFirst({ where: { name: 'David' } }); + const eveUser = await client.user.findFirst({ where: { name: 'Eve' } }); + + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + deleteMany: { + name: { in: ['David', 'Eve'] }, + }, + }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: [expect.objectContaining({ name: 'Bob' }), expect.objectContaining({ name: 'Charlie' })], + }); + + // Verify David was deleted from database + await expect(client.user.findUnique({ where: { id: davidUser!.id } })).toResolveNull(); + await expect(client.user.findUnique({ where: { id: eveUser!.id } })).toResolveTruthy(); + }); + + it('works with explicit self-referencing many-to-many', async () => { + client = await createTestClient( + ` + model User { + id Int @id @default(autoincrement()) + name String + followingRelations UserFollow[] @relation("Follower") + followerRelations UserFollow[] @relation("Following") + } + + model UserFollow { + id Int @id @default(autoincrement()) + follower User @relation("Follower", fields: [followerId], references: [id]) + following User @relation("Following", fields: [followingId], references: [id]) + followerId Int + followingId Int + createdAt DateTime @default(now()) + @@unique([followerId, followingId]) + } + `, + { + provider, + dbName: TEST_DB, + }, + ); + + const user1 = await client.user.create({ data: { name: 'Alice' } }); + const user2 = await client.user.create({ data: { name: 'Bob' } }); + + // Create follow relationship + await client.userFollow.create({ + data: { + followerId: user1.id, + followingId: user2.id, + }, + }); + + // Query following relationships + await expect( + client.user.findUnique({ + where: { id: user1.id }, + include: { + followingRelations: { + include: { following: true }, + }, + }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + followingRelations: [ + expect.objectContaining({ + following: expect.objectContaining({ name: 'Bob' }), + }), + ], + }); + + // Query follower relationships + await expect( + client.user.findUnique({ + where: { id: user2.id }, + include: { + followerRelations: { + include: { follower: true }, + }, + }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + followerRelations: [ + expect.objectContaining({ + follower: expect.objectContaining({ name: 'Alice' }), + }), + ], + }); + }); + + it('works with multiple self relations on same model', async () => { + client = await createTestClient( + ` + model Person { + id Int @id @default(autoincrement()) + name String + manager Person? @relation("Management", fields: [managerId], references: [id]) + reports Person[] @relation("Management") + managerId Int? + + mentor Person? @relation("Mentorship", fields: [mentorId], references: [id]) + mentees Person[] @relation("Mentorship") + mentorId Int? + } + `, + { + provider, + usePrismaPush: true, + dbName: TEST_DB, + }, + ); + + // Create CEO + const ceo = await client.person.create({ + data: { name: 'CEO' }, + }); + + // Create manager who reports to CEO and is also a mentor + const manager = await client.person.create({ + data: { + name: 'Manager', + manager: { connect: { id: ceo.id } }, + }, + }); + + // Create employee who reports to manager and is mentored by CEO + await expect( + client.person.create({ + data: { + name: 'Employee', + manager: { connect: { id: manager.id } }, + mentor: { connect: { id: ceo.id } }, + }, + include: { + manager: true, + mentor: true, + }, + }), + ).resolves.toMatchObject({ + name: 'Employee', + manager: { name: 'Manager' }, + mentor: { name: 'CEO' }, + }); + + // Check CEO's reports and mentees + await expect( + client.person.findUnique({ + where: { id: ceo.id }, + include: { + reports: true, + mentees: true, + }, + }), + ).resolves.toMatchObject({ + name: 'CEO', + reports: [expect.objectContaining({ name: 'Manager' })], + mentees: [expect.objectContaining({ name: 'Employee' })], + }); + }); + + it('works with deep self relation queries', async () => { + client = await createTestClient( + ` + model Comment { + id Int @id @default(autoincrement()) + content String + parent Comment? @relation("CommentThread", fields: [parentId], references: [id]) + replies Comment[] @relation("CommentThread") + parentId Int? + } + `, + { + provider, + usePrismaPush: true, + dbName: TEST_DB, + }, + ); + + // Create nested comment thread + const topComment = await client.comment.create({ + data: { + content: 'Top level comment', + replies: { + create: [ + { + content: 'First reply', + replies: { + create: [{ content: 'Nested reply 1' }, { content: 'Nested reply 2' }], + }, + }, + { content: 'Second reply' }, + ], + }, + }, + include: { + replies: { + include: { + replies: true, + }, + }, + }, + }); + + expect(topComment).toMatchObject({ + content: 'Top level comment', + replies: [ + expect.objectContaining({ + content: 'First reply', + replies: [ + expect.objectContaining({ content: 'Nested reply 1' }), + expect.objectContaining({ content: 'Nested reply 2' }), + ], + }), + expect.objectContaining({ + content: 'Second reply', + replies: [], + }), + ], + }); + + // Query from nested comment up the chain + const nestedReply = await client.comment.findFirst({ + where: { content: 'Nested reply 1' }, + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }); + + expect(nestedReply).toMatchObject({ + content: 'Nested reply 1', + parent: expect.objectContaining({ + content: 'First reply', + parent: expect.objectContaining({ + content: 'Top level comment', + }), + }), + }); + }); + }, +); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index d3421714..400c0cac 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index d113a3b9..7484e700 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -352,7 +352,24 @@ export class TsSchemaGenerator { field.name, undefined, undefined, - [], + [ + // parameter: `context: { currentModel: string }` + ts.factory.createParameterDeclaration( + undefined, + undefined, + '_context', + undefined, + ts.factory.createTypeLiteralNode([ + ts.factory.createPropertySignature( + undefined, + 'currentModel', + undefined, + ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + ), + ]), + undefined, + ), + ], ts.factory.createTypeReferenceNode('OperandExpression', [ ts.factory.createTypeReferenceNode(this.mapFieldTypeToTSType(field.type)), ]), diff --git a/packages/tanstack-query/package.json b/packages/tanstack-query/package.json index feafc259..789a8fc1 100644 --- a/packages/tanstack-query/package.json +++ b/packages/tanstack-query/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tanstack-query", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "description": "", "main": "index.js", "type": "module", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index d3145d2e..4bfa24a9 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 56d956a5..58de9be4 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "private": true, "license": "MIT" } diff --git a/packages/vitest-config/package.json b/packages/vitest-config/package.json index 151d29c1..0c739b29 100644 --- a/packages/vitest-config/package.json +++ b/packages/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "private": true, "license": "MIT", "exports": { diff --git a/packages/zod/package.json b/packages/zod/package.json index 616d6e64..376c279a 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "description": "", "type": "module", "main": "index.js", diff --git a/samples/blog/main.ts b/samples/blog/main.ts index 46cb754e..cbae0423 100644 --- a/samples/blog/main.ts +++ b/samples/blog/main.ts @@ -1,17 +1,18 @@ import { ZenStackClient } from '@zenstackhq/runtime'; import SQLite from 'better-sqlite3'; -import { SqliteDialect } from 'kysely'; +import { sql, SqliteDialect } from 'kysely'; import { schema } from './zenstack/schema'; async function main() { const db = new ZenStackClient(schema, { dialect: new SqliteDialect({ database: new SQLite('./zenstack/dev.db') }), + log: ['query'], computedFields: { User: { - postCount: (eb) => + postCount: (eb, { currentModel }) => eb .selectFrom('Post') - .whereRef('Post.authorId', '=', 'User.id') + .whereRef('Post.authorId', '=', sql.ref(`${currentModel}.id`)) .select(({ fn }) => fn.countAll().as('postCount')), }, }, diff --git a/samples/blog/package.json b/samples/blog/package.json index 77fdf5cd..a298cd96 100644 --- a/samples/blog/package.json +++ b/samples/blog/package.json @@ -1,6 +1,6 @@ { "name": "sample-blog", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "description": "", "main": "index.js", "scripts": { diff --git a/samples/blog/zenstack/schema.ts b/samples/blog/zenstack/schema.ts index 64515be7..95f2e4a8 100644 --- a/samples/blog/zenstack/schema.ts +++ b/samples/blog/zenstack/schema.ts @@ -75,7 +75,9 @@ export const schema = { email: { type: "String" } }, computedFields: { - postCount(): OperandExpression { + postCount(_context: { + currentModel: string; + }): OperandExpression { throw new Error("This is a stub for computed field"); } } diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 3b0bfe5f..43651930 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.0.0-alpha.28", + "version": "3.0.0-alpha.29", "private": true, "type": "module", "scripts": {