diff --git a/src/QueryBuilder.ts b/src/QueryBuilder.ts index a14d962..4f68bf6 100644 --- a/src/QueryBuilder.ts +++ b/src/QueryBuilder.ts @@ -5997,7 +5997,9 @@ export class QueryBuilder | Transaction type KyselyTableMapping = Record type ThroughRelationMetadata = HasOneThroughRelationMetadata | HasManyThroughRelationMetadata +type MorphChildRelationMetadata = MorphOneRelationMetadata | MorphManyRelationMetadata type SqlRelationMetadata = | HasManyRelationMetadata | HasOneRelationMetadata | BelongsToRelationMetadata | BelongsToManyRelationMetadata | ThroughRelationMetadata + | MorphChildRelationMetadata type EagerLoadableModel = { getAttribute: (key: string) => unknown setLoadedRelation: (name: string, value: unknown) => void @@ -1783,12 +1787,28 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter { return sql`${sql.table(table)}.${sql.id(column)}` } + /** + * Renders the relation's source table, aliasing it when a self-referential + * relation would otherwise put the same name on both sides of a correlated + * subquery. + * + * @param sourceTable + * @param table + * @returns + */ + private buildTableSource(sourceTable: string, table: string): RawBuilder { + if (sourceTable === table) return sql`${sql.table(table)}` + + return sql`${sql.table(sourceTable)} as ${sql.table(table)}` + } + private buildRelatedTargetFromRelation( target: QueryTarget, relation: string, ): { metadata: SqlRelationMetadata relatedTarget: QueryTarget + sourceTable: string } { const metadata = target.model?.getRelationMetadata(relation) if (!metadata) @@ -1807,7 +1827,9 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter { metadata.type !== 'belongsTo' && metadata.type !== 'belongsToMany' && metadata.type !== 'hasOneThrough' && - metadata.type !== 'hasManyThrough' + metadata.type !== 'hasManyThrough' && + metadata.type !== 'morphOne' && + metadata.type !== 'morphMany' ) { throw new UnsupportedAdapterFeatureException( `Relation [${relation}] is not supported for SQL-backed relation execution by the Kysely adapter yet.`, @@ -1824,13 +1846,19 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter { } const relatedMetadata = metadata.relatedModel.getModelMetadata() + const sourceTable = this.resolveMappedTable(relatedMetadata.table) + // A relation onto the model's own table reads the outer row's columns + // unless the inner side is given a name of its own. + const selfReferential = sourceTable === this.resolveTable(target) + const table = selfReferential ? `${sourceTable}_arkorm_self` : relatedMetadata.table return { metadata, + sourceTable, relatedTarget: { model: metadata.relatedModel as unknown as ModelStatic, modelName: metadata.relatedModel.name, - table: relatedMetadata.table, + table, primaryKey: relatedMetadata.primaryKey, columns: relatedMetadata.columns, softDelete: relatedMetadata.softDelete, @@ -1846,13 +1874,14 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter { outerTarget: QueryTarget, relatedTarget: QueryTarget, metadata: BelongsToManyRelationMetadata, + sourceTable: string, ): { from: RawBuilder; condition: RawBuilder } { const outerTable = this.resolveTable(outerTarget) const relatedTable = this.resolveTable(relatedTarget) const pivotTable = this.resolveMappedTable(metadata.throughTable) return { - from: sql`${sql.table(relatedTable)} inner join ${sql.table(pivotTable)} on ${this.buildColumnReference(relatedTable, this.mapColumn(relatedTarget, metadata.relatedKey))} = ${this.buildColumnReference(pivotTable, metadata.relatedPivotKey)}`, + from: sql`${this.buildTableSource(sourceTable, relatedTable)} inner join ${sql.table(pivotTable)} on ${this.buildColumnReference(relatedTable, this.mapColumn(relatedTarget, metadata.relatedKey))} = ${this.buildColumnReference(pivotTable, metadata.relatedPivotKey)}`, condition: sql` ${this.buildColumnReference(pivotTable, metadata.foreignPivotKey)} = @@ -1865,13 +1894,14 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter { outerTarget: QueryTarget, relatedTarget: QueryTarget, metadata: ThroughRelationMetadata, + sourceTable: string, ): { from: RawBuilder; condition: RawBuilder } { const outerTable = this.resolveTable(outerTarget) const relatedTable = this.resolveTable(relatedTarget) const throughTable = this.resolveMappedTable(metadata.throughTable) return { - from: sql`${sql.table(relatedTable)} inner join ${sql.table(throughTable)} on ${this.buildColumnReference(relatedTable, this.mapColumn(relatedTarget, metadata.secondKey))} = ${this.buildColumnReference(throughTable, metadata.secondLocalKey)}`, + from: sql`${this.buildTableSource(sourceTable, relatedTable)} inner join ${sql.table(throughTable)} on ${this.buildColumnReference(relatedTable, this.mapColumn(relatedTarget, metadata.secondKey))} = ${this.buildColumnReference(throughTable, metadata.secondLocalKey)}`, condition: sql` ${this.buildColumnReference(throughTable, metadata.firstKey)} = @@ -1880,6 +1910,35 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter { } } + /** + * The value a polymorphic child stores in its type column for this parent: the + * parent model's class name, matching what the relationship writes on create + * and what the set-based eager loader groups parents by. + * + * @param target + * @param relation + * @returns + */ + private resolveMorphType(target: QueryTarget, relation: string): string { + const morphType = target.modelName ?? target.model?.name + + if (!morphType) + throw new UnsupportedAdapterFeatureException( + `Relation [${relation}] needs a named parent model to resolve its polymorphic type.`, + { + operation: 'adapter.relation.metadata', + model: target.modelName, + relation, + meta: { + feature: 'relationFilters', + relationType: 'morph', + }, + }, + ) + + return morphType + } + private buildRelatedJoinCondition( outerTarget: QueryTarget, relation: string, @@ -1888,12 +1947,21 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter { from: RawBuilder condition: RawBuilder } { - const { metadata, relatedTarget } = this.buildRelatedTargetFromRelation(outerTarget, relation) + const { metadata, relatedTarget, sourceTable } = this.buildRelatedTargetFromRelation( + outerTarget, + relation, + ) const outerTable = this.resolveTable(outerTarget) const relatedTable = this.resolveTable(relatedTarget) + const relatedFrom = this.buildTableSource(sourceTable, relatedTable) if (metadata.type === 'belongsToMany') { - const joinSource = this.buildBelongsToManyJoinSource(outerTarget, relatedTarget, metadata) + const joinSource = this.buildBelongsToManyJoinSource( + outerTarget, + relatedTarget, + metadata, + sourceTable, + ) return { relatedTarget, @@ -1903,7 +1971,12 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter { } if (metadata.type === 'hasOneThrough' || metadata.type === 'hasManyThrough') { - const joinSource = this.buildThroughJoinSource(outerTarget, relatedTarget, metadata) + const joinSource = this.buildThroughJoinSource( + outerTarget, + relatedTarget, + metadata, + sourceTable, + ) return { relatedTarget, @@ -1915,7 +1988,7 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter { if (metadata.type === 'hasMany' || metadata.type === 'hasOne') { return { relatedTarget, - from: sql`${sql.table(relatedTable)}`, + from: relatedFrom, condition: sql` ${this.buildColumnReference(relatedTable, this.mapColumn(relatedTarget, metadata.foreignKey))} = @@ -1924,9 +1997,25 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter { } } + if (metadata.type === 'morphOne' || metadata.type === 'morphMany') { + return { + relatedTarget, + from: relatedFrom, + condition: sql` + ${this.buildColumnReference(relatedTable, this.mapColumn(relatedTarget, metadata.morphIdColumn))} + = + ${this.buildColumnReference(outerTable, this.mapColumn(outerTarget, metadata.localKey))} + and + ${this.buildColumnReference(relatedTable, this.mapColumn(relatedTarget, metadata.morphTypeColumn))} + = + ${this.resolveMorphType(outerTarget, relation)} + `, + } + } + return { relatedTarget, - from: sql`${sql.table(relatedTable)}`, + from: relatedFrom, condition: sql` ${this.buildColumnReference(relatedTable, this.mapColumn(relatedTarget, metadata.ownerKey))} = diff --git a/tests/postgres/helpers/fixtures.ts b/tests/postgres/helpers/fixtures.ts index 138a53c..5e5b819 100644 --- a/tests/postgres/helpers/fixtures.ts +++ b/tests/postgres/helpers/fixtures.ts @@ -65,6 +65,11 @@ export class DbPost extends Model { public comments() { return this.morphMany(DbComment, 'commentable') } + + /** Self-referential: every post sharing this post's author, itself included. */ + public siblings() { + return this.hasMany(DbPost, 'userId', 'userId') + } } export class DbRole extends Model { diff --git a/tests/postgres/kysely-adapter.spec.ts b/tests/postgres/kysely-adapter.spec.ts index 932ce55..5e70a68 100644 --- a/tests/postgres/kysely-adapter.spec.ts +++ b/tests/postgres/kysely-adapter.spec.ts @@ -663,6 +663,90 @@ describe('PostgreSQL Kysely adapter', () => { expect(users.data.all()[2]?.getAttribute('activeAuthor')).toBe(false) }) + it('compiles polymorphic relation filters and aggregates into SQL', async () => { + setPostgresModelAdapter(kyselyAdapter) + executedQueries.length = 0 + + // Both seeded comments carry commentableId 1 and differ only by type, so a + // missing discriminator would attribute each one to both parents. + const commentedUsers = await DbUser.query().whereHas('comments').orderBy({ id: 'asc' }).get() + expect(commentedUsers.all().map((user) => user.getAttribute('id'))).toEqual([1]) + + const commentedPosts = await DbPost.query().whereHas('comments').orderBy({ id: 'asc' }).get() + expect(commentedPosts.all().map((post) => post.getAttribute('id'))).toEqual([1]) + + const postCounts = await DbPost.query().withCount('comments').orderBy({ id: 'asc' }).get() + expect(postCounts.all().map((post) => post.getAttribute('commentsCount'))).toEqual([1, 0, 0]) + + const userCounts = await DbUser.query().withCount('comments').orderBy({ id: 'asc' }).get() + expect(userCounts.all().map((user) => user.getAttribute('commentsCount'))).toEqual([1, 0]) + + const morphOneExists = await DbUser.query() + .withExists('primaryComment') + .orderBy({ id: 'asc' }) + .get() + expect(morphOneExists.all().map((user) => user.getAttribute('primaryCommentExists'))).toEqual([ + true, + false, + ]) + + const normalizedSql = executedQueries.join('\n').replace(/\s+/g, ' ') + expect(normalizedSql).toContain('"comments"."commentableType" = $') + expect(normalizedSql).not.toContain('select * from "comments" where "commentableId" = $') + }) + + it('constrains polymorphic relation filters with their callback', async () => { + setPostgresModelAdapter(kyselyAdapter) + + const matching = await DbPost.query() + .whereHas('comments', (query) => query.where({ body: 'Hi post' })) + .get() + expect(matching.all().map((post) => post.getAttribute('id'))).toEqual([1]) + + // The body belongs to the DbPost comment, so no user may match it. + const mismatched = await DbUser.query() + .whereHas('comments', (query) => query.where({ body: 'Hi post' })) + .get() + expect(mismatched.all()).toHaveLength(0) + + const counted = await DbPost.query() + .withCount({ 'comments as postComments': (query) => query.where({ body: 'Hi user' }) }) + .orderBy({ id: 'asc' }) + .get() + expect(counted.all().map((post) => post.getAttribute('postComments'))).toEqual([0, 0, 0]) + }) + + it('paginates polymorphic relation filters in the database', async () => { + setPostgresModelAdapter(kyselyAdapter) + executedQueries.length = 0 + + const page = await DbPost.query().withCount('comments').orderBy({ id: 'asc' }).paginate(2, 1) + + expect(page.meta.total).toBe(3) + expect(page.data.all().map((post) => post.getAttribute('id'))).toEqual([1, 2]) + + const normalizedSql = executedQueries.join('\n').replace(/\s+/g, ' ') + expect(normalizedSql).toContain('limit $') + expect(normalizedSql).toContain('offset $') + expect(normalizedSql).toContain('select count(*)::int as count from "posts"') + }) + + it('aliases self-referential relations so the inner rows stay distinct', async () => { + setPostgresModelAdapter(kyselyAdapter) + executedQueries.length = 0 + + // Posts 1 and 2 share an author, post 3 stands alone. Without an alias the + // correlated subquery compares the inner row to itself and counts every row. + const counted = await DbPost.query().withCount('siblings').orderBy({ id: 'asc' }).get() + expect(counted.all().map((post) => post.getAttribute('siblingsCount'))).toEqual([2, 2, 1]) + + const filtered = await DbPost.query().has('siblings', '>=', 2).orderBy({ id: 'asc' }).get() + expect(filtered.all().map((post) => post.getAttribute('id'))).toEqual([1, 2]) + + const normalizedSql = executedQueries.join('\n').replace(/\s+/g, ' ') + expect(normalizedSql).toContain('from "posts" as "posts_arkorm_self"') + }) + it('executes eager loading through the Kysely adapter relationLoads path', async () => { setPostgresModelAdapter(kyselyAdapter)