diff --git a/packages/runtime/src/client/crud/dialects/base.ts b/packages/runtime/src/client/crud/dialects/base.ts index 5c84e5ff..8afec156 100644 --- a/packages/runtime/src/client/crud/dialects/base.ts +++ b/packages/runtime/src/client/crud/dialects/base.ts @@ -2,7 +2,7 @@ import { invariant, isPlainObject } from '@zenstackhq/common-helpers'; import type { Expression, ExpressionBuilder, ExpressionWrapper, SqlBool, ValueNode } from 'kysely'; import { expressionBuilder, sql, type SelectQueryBuilder } from 'kysely'; import { match, P } from 'ts-pattern'; -import type { BuiltinType, DataSourceProviderType, FieldDef, GetModels, SchemaDef } from '../../../schema'; +import type { BuiltinType, DataSourceProviderType, FieldDef, GetModels, ModelDef, SchemaDef } from '../../../schema'; import { enumerate } from '../../../utils/enumerate'; import type { OrArray } from '../../../utils/type-utils'; import { AGGREGATE_OPERATORS, DELEGATE_JOINED_FIELD_PREFIX, LOGICAL_COMBINATORS } from '../../constants'; @@ -963,6 +963,31 @@ export abstract class BaseCrudDialect { return result; } + protected buildModelSelect( + eb: ExpressionBuilder, + model: GetModels, + subQueryAlias: string, + payload: true | FindArgs, true>, + selectAllFields: boolean, + ) { + let subQuery = this.buildSelectModel(eb, model, subQueryAlias); + + if (selectAllFields) { + subQuery = this.buildSelectAllFields( + model, + subQuery, + typeof payload === 'object' ? payload?.omit : undefined, + subQueryAlias, + ); + } + + if (payload && typeof payload === 'object') { + subQuery = this.buildFilterSortTake(model, payload, subQuery, subQueryAlias); + } + + return subQuery; + } + buildSelectField( query: SelectQueryBuilder, model: string, @@ -1115,6 +1140,35 @@ export abstract class BaseCrudDialect { return buildFieldRef(this.schema, model, field, this.options, eb, modelAlias, inlineComputedField); } + protected canJoinWithoutNestedSelect( + modelDef: ModelDef, + payload: boolean | FindArgs, true>, + ) { + if (modelDef.computedFields) { + // computed fields requires explicit select + return false; + } + + if (modelDef.baseModel || modelDef.isDelegate) { + // delegate models require upward/downward joins + return false; + } + + if ( + typeof payload === 'object' && + (payload.orderBy || + payload.skip !== undefined || + payload.take !== undefined || + payload.cursor || + (payload as any).distinct) + ) { + // ordering/pagination/distinct needs to be handled before joining + return false; + } + + return true; + } + // #endregion // #region abstract methods diff --git a/packages/runtime/src/client/crud/dialects/postgresql.ts b/packages/runtime/src/client/crud/dialects/postgresql.ts index 2c8af180..191c590d 100644 --- a/packages/runtime/src/client/crud/dialects/postgresql.ts +++ b/packages/runtime/src/client/crud/dialects/postgresql.ts @@ -58,127 +58,151 @@ export class PostgresCrudDialect extends BaseCrudDiale parentAlias: string, payload: true | FindArgs, true>, ): SelectQueryBuilder { - const joinedQuery = this.buildRelationJSON(model, query, relationField, parentAlias, payload); - - return joinedQuery.select(`${parentAlias}$${relationField}.$t as ${relationField}`); + const relationResultName = `${parentAlias}$${relationField}`; + const joinedQuery = this.buildRelationJSON( + model, + query, + relationField, + parentAlias, + payload, + relationResultName, + ); + return joinedQuery.select(`${relationResultName}.$data as ${relationField}`); } private buildRelationJSON( model: string, qb: SelectQueryBuilder, relationField: string, - parentName: string, + parentAlias: string, payload: true | FindArgs, true>, + resultName: string, ) { const relationFieldDef = requireField(this.schema, model, relationField); const relationModel = relationFieldDef.type as GetModels; return qb.leftJoinLateral( (eb) => { - const joinTableName = `${parentName}$${relationField}`; - - // simple select by default - let result = eb.selectFrom(`${relationModel} as ${joinTableName}`); + const relationSelectName = `${resultName}$sub`; + const relationModelDef = requireModel(this.schema, relationModel); - // however if there're filter/orderBy/take/skip, - // we need to build a subquery to handle them before aggregation + let tbl: SelectQueryBuilder; - // give sub query an alias to avoid conflict with parent scope - // (e.g., for cases like self-relation) - const subQueryAlias = `${relationModel}$${relationField}$sub`; + if (this.canJoinWithoutNestedSelect(relationModelDef, payload)) { + // build join directly + tbl = this.buildModelSelect(eb, relationModel, relationSelectName, payload, false); - result = eb.selectFrom(() => { - let subQuery = this.buildSelectModel(eb, relationModel, subQueryAlias); - subQuery = this.buildSelectAllFields( + // parent join filter + tbl = this.buildRelationJoinFilter( + tbl, + model, + relationField, relationModel, - subQuery, - typeof payload === 'object' ? payload?.omit : undefined, - subQueryAlias, + relationSelectName, + parentAlias, ); - - if (payload && typeof payload === 'object') { - subQuery = this.buildFilterSortTake(relationModel, payload, subQuery, subQueryAlias); - } - - // add join conditions - - const m2m = getManyToManyRelation(this.schema, model, relationField); - - if (m2m) { - // many-to-many relation - const parentIds = getIdFields(this.schema, model); - const relationIds = getIdFields(this.schema, relationModel); - invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field'); - invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field'); - subQuery = subQuery.where( - eb( - eb.ref(`${subQueryAlias}.${relationIds[0]}`), - 'in', - eb - .selectFrom(m2m.joinTable) - .select(`${m2m.joinTable}.${m2m.otherFkName}`) - .whereRef( - `${parentName}.${parentIds[0]}`, - '=', - `${m2m.joinTable}.${m2m.parentFkName}`, - ), - ), + } else { + // join with a nested query + tbl = eb.selectFrom(() => { + let subQuery = this.buildModelSelect( + eb, + relationModel, + `${relationSelectName}$t`, + payload, + true, ); - } else { - 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)))), + + // parent join filter + subQuery = this.buildRelationJoinFilter( + subQuery, + model, + relationField, + relationModel, + `${relationSelectName}$t`, + parentAlias, ); - } - return subQuery.as(joinTableName); - }); + return subQuery.as(relationSelectName); + }); + } - result = this.buildRelationObjectSelect( + // select relation result + tbl = this.buildRelationObjectSelect( relationModel, - joinTableName, - relationField, + relationSelectName, relationFieldDef, - result, + tbl, payload, - parentName, + resultName, ); // add nested joins for each relation - result = this.buildRelationJoins(relationModel, relationField, result, payload, parentName); + tbl = this.buildRelationJoins(tbl, relationModel, relationSelectName, payload, resultName); // alias the join table - return result.as(joinTableName); + return tbl.as(resultName); }, (join) => join.onTrue(), ); } + private buildRelationJoinFilter( + query: SelectQueryBuilder, + model: string, + relationField: string, + relationModel: GetModels, + relationModelAlias: string, + parentAlias: string, + ) { + const m2m = getManyToManyRelation(this.schema, model, relationField); + if (m2m) { + // many-to-many relation + const parentIds = getIdFields(this.schema, model); + const relationIds = getIdFields(this.schema, relationModel); + invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field'); + invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field'); + query = query.where((eb) => + eb( + eb.ref(`${relationModelAlias}.${relationIds[0]}`), + 'in', + eb + .selectFrom(m2m.joinTable) + .select(`${m2m.joinTable}.${m2m.otherFkName}`) + .whereRef(`${parentAlias}.${parentIds[0]}`, '=', `${m2m.joinTable}.${m2m.parentFkName}`), + ), + ); + } else { + const joinPairs = buildJoinPairs(this.schema, model, parentAlias, relationField, relationModelAlias); + query = query.where((eb) => + this.and(eb, ...joinPairs.map(([left, right]) => eb(sql.ref(left), '=', sql.ref(right)))), + ); + } + return query; + } + private buildRelationObjectSelect( relationModel: string, relationModelAlias: string, - relationField: string, relationFieldDef: FieldDef, qb: SelectQueryBuilder, payload: true | FindArgs, true>, - parentName: string, + parentResultName: string, ) { qb = qb.select((eb) => { const objArgs = this.buildRelationObjectArgs( relationModel, relationModelAlias, - relationField, eb, payload, - parentName, + parentResultName, ); if (relationFieldDef.array) { return eb.fn .coalesce(sql`jsonb_agg(jsonb_build_object(${sql.join(objArgs)}))`, sql`'[]'::jsonb`) - .as('$t'); + .as('$data'); } else { - return sql`jsonb_build_object(${sql.join(objArgs)})`.as('$t'); + return sql`jsonb_build_object(${sql.join(objArgs)})`.as('$data'); } }); @@ -188,17 +212,15 @@ export class PostgresCrudDialect extends BaseCrudDiale private buildRelationObjectArgs( relationModel: string, relationModelAlias: string, - relationField: string, eb: ExpressionBuilder, payload: true | FindArgs, true>, - parentAlias: string, + parentResultName: string, ) { const relationModelDef = requireModel(this.schema, relationModel); const objArgs: Array< string | ExpressionWrapper | SelectQueryBuilder | RawBuilder > = []; - // TODO: descendant JSON shouldn't be joined and selected if none of its fields are selected const descendantModels = getDelegateDescendantModels(this.schema, relationModel); if (descendantModels.length > 0) { // select all JSONs built from delegate descendants @@ -234,7 +256,7 @@ export class PostgresCrudDialect extends BaseCrudDiale const subJson = this.buildCountJson( relationModel as GetModels, eb, - `${parentAlias}$${relationField}`, + relationModelAlias, value, ); return [sql.lit(field), subJson]; @@ -242,7 +264,7 @@ export class PostgresCrudDialect extends BaseCrudDiale const fieldDef = requireField(this.schema, relationModel, field); const fieldValue = fieldDef.relation ? // reference the synthesized JSON field - eb.ref(`${parentAlias}$${relationField}$${field}.$t`) + eb.ref(`${parentResultName}$${field}.$data`) : // reference a plain field this.fieldRef(relationModel, field, eb, undefined, false); return [sql.lit(field), fieldValue]; @@ -260,7 +282,7 @@ export class PostgresCrudDialect extends BaseCrudDiale .map(([field]) => [ sql.lit(field), // reference the synthesized JSON field - eb.ref(`${parentAlias}$${relationField}$${field}.$t`), + eb.ref(`${parentResultName}$${field}.$data`), ]) .flatMap((v) => v), ); @@ -269,13 +291,13 @@ export class PostgresCrudDialect extends BaseCrudDiale } private buildRelationJoins( + query: SelectQueryBuilder, relationModel: string, - relationField: string, - qb: SelectQueryBuilder, + relationModelAlias: string, payload: true | FindArgs, true>, - parentName: string, + parentResultName: string, ) { - let result = qb; + let result = query; if (typeof payload === 'object') { const selectInclude = payload.include ?? payload.select; if (selectInclude && typeof selectInclude === 'object') { @@ -287,8 +309,9 @@ export class PostgresCrudDialect extends BaseCrudDiale relationModel, result, field, - `${parentName}$${relationField}`, + relationModelAlias, value, + `${parentResultName}$${field}`, ); }); } diff --git a/packages/runtime/src/client/crud/dialects/sqlite.ts b/packages/runtime/src/client/crud/dialects/sqlite.ts index dd677361..34ece56e 100644 --- a/packages/runtime/src/client/crud/dialects/sqlite.ts +++ b/packages/runtime/src/client/crud/dialects/sqlite.ts @@ -74,64 +74,39 @@ export class SqliteCrudDialect extends BaseCrudDialect const relationModelDef = requireModel(this.schema, relationModel); const subQueryName = `${parentAlias}$${relationField}`; + let tbl: SelectQueryBuilder; - let tbl = eb.selectFrom(() => { - // 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); + if (this.canJoinWithoutNestedSelect(relationModelDef, payload)) { + // join without needing a nested select on relation model + tbl = this.buildModelSelect(eb, relationModel, subQueryName, payload, false); - 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, subQueryAlias); - } + // add parent join filter + tbl = this.buildRelationJoinFilter(tbl, model, relationField, subQueryName, parentAlias); + } else { + // need to make a nested select on relation model + tbl = eb.selectFrom(() => { + // nested query name + const selectModelAlias = `${parentAlias}$${relationField}$sub`; - // join conditions + // select all fields + let selectModelQuery = this.buildModelSelect(eb, relationModel, selectModelAlias, payload, true); - const m2m = getManyToManyRelation(this.schema, model, relationField); - if (m2m) { - // many-to-many relation - const parentIds = getIdFields(this.schema, model); - const relationIds = getIdFields(this.schema, relationModel); - invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field'); - invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field'); - subQuery = subQuery.where( - eb( - eb.ref(`${subQueryAlias}.${relationIds[0]}`), - 'in', - eb - .selectFrom(m2m.joinTable) - .select(`${m2m.joinTable}.${m2m.otherFkName}`) - .whereRef(`${parentAlias}.${parentIds[0]}`, '=', `${m2m.joinTable}.${m2m.parentFkName}`), - ), + // add parent join filter + selectModelQuery = this.buildRelationJoinFilter( + selectModelQuery, + model, + relationField, + selectModelAlias, + parentAlias, ); - } else { - const { keyPairs, ownedByModel } = getRelationForeignKeyFieldPairs(this.schema, model, relationField); - keyPairs.forEach(({ fk, pk }) => { - if (ownedByModel) { - // the parent model owns the fk - subQuery = subQuery.whereRef(`${subQueryAlias}.${pk}`, '=', `${parentAlias}.${fk}`); - } else { - // the relation side owns the fk - subQuery = subQuery.whereRef(`${subQueryAlias}.${fk}`, '=', `${parentAlias}.${pk}`); - } - }); - } - return subQuery.as(subQueryName); - }); + return selectModelQuery.as(subQueryName); + }); + } tbl = tbl.select(() => { type ArgsType = Expression | RawBuilder | SelectQueryBuilder; const objArgs: ArgsType[] = []; - // TODO: descendant JSON shouldn't be joined and selected if none of its fields are selected const descendantModels = getDelegateDescendantModels(this.schema, relationModel); if (descendantModels.length > 0) { // select all JSONs built from delegate descendants @@ -151,7 +126,10 @@ export class SqliteCrudDialect extends BaseCrudDialect ...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, subQueryName, false), + ]) .flatMap((v) => v), ); } else if (payload.select) { @@ -182,7 +160,7 @@ export class SqliteCrudDialect extends BaseCrudDialect } else { return [ sql.lit(field), - this.fieldRef(relationModel, field, eb, undefined, false) as ArgsType, + this.fieldRef(relationModel, field, eb, subQueryName, false) as ArgsType, ]; } } @@ -213,15 +191,65 @@ export class SqliteCrudDialect extends BaseCrudDialect if (relationFieldDef.array) { return eb.fn .coalesce(sql`json_group_array(json_object(${sql.join(objArgs)}))`, sql`json_array()`) - .as('$t'); + .as('$data'); } else { - return sql`json_object(${sql.join(objArgs)})`.as('$t'); + return sql`json_object(${sql.join(objArgs)})`.as('$data'); } }); return tbl; } + private buildRelationJoinFilter( + selectModelQuery: SelectQueryBuilder, + model: string, + relationField: string, + relationModelAlias: string, + parentAlias: string, + ) { + const fieldDef = requireField(this.schema, model, relationField); + const relationModel = fieldDef.type as GetModels; + + const m2m = getManyToManyRelation(this.schema, model, relationField); + if (m2m) { + // many-to-many relation + const parentIds = getIdFields(this.schema, model); + const relationIds = getIdFields(this.schema, relationModel); + invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field'); + invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field'); + selectModelQuery = selectModelQuery.where((eb) => + eb( + eb.ref(`${relationModelAlias}.${relationIds[0]}`), + 'in', + eb + .selectFrom(m2m.joinTable) + .select(`${m2m.joinTable}.${m2m.otherFkName}`) + .whereRef(`${parentAlias}.${parentIds[0]}`, '=', `${m2m.joinTable}.${m2m.parentFkName}`), + ), + ); + } else { + const { keyPairs, ownedByModel } = getRelationForeignKeyFieldPairs(this.schema, model, relationField); + keyPairs.forEach(({ fk, pk }) => { + if (ownedByModel) { + // the parent model owns the fk + selectModelQuery = selectModelQuery.whereRef( + `${relationModelAlias}.${pk}`, + '=', + `${parentAlias}.${fk}`, + ); + } else { + // the relation side owns the fk + selectModelQuery = selectModelQuery.whereRef( + `${relationModelAlias}.${fk}`, + '=', + `${parentAlias}.${pk}`, + ); + } + }); + } + return selectModelQuery; + } + override buildSkipTake( query: SelectQueryBuilder, skip: number | undefined, diff --git a/packages/runtime/src/client/executor/name-mapper.ts b/packages/runtime/src/client/executor/name-mapper.ts index bb057e40..cc8163c1 100644 --- a/packages/runtime/src/client/executor/name-mapper.ts +++ b/packages/runtime/src/client/executor/name-mapper.ts @@ -351,8 +351,9 @@ export class QueryNameMapper extends OperationNodeTransformer { // inner transformations will map column names const modelName = innerNode.table.identifier.name; const mappedName = this.mapTableName(modelName); + const finalAlias = alias ?? (mappedName !== modelName ? modelName : undefined); return { - node: this.wrapAlias(TableNode.create(mappedName), alias ?? modelName), + node: this.wrapAlias(TableNode.create(mappedName), finalAlias), scope: { alias: alias ?? modelName, model: modelName,