Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/QueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5997,7 +5997,9 @@ export class QueryBuilder<TModel, TDelegate extends ModelQuerySchemaLike = Model
metadata.type === 'belongsTo' ||
metadata.type === 'belongsToMany' ||
metadata.type === 'hasOneThrough' ||
metadata.type === 'hasManyThrough'
metadata.type === 'hasManyThrough' ||
metadata.type === 'morphOne' ||
metadata.type === 'morphMany'
)
}

Expand Down
107 changes: 98 additions & 9 deletions src/adapters/KyselyDatabaseAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ import type {
HasOneRelationMetadata,
HasOneThroughRelationMetadata,
ModelStatic,
MorphManyRelationMetadata,
MorphOneRelationMetadata,
} from '../types'
import type {
AppliedMigrationsState,
Expand All @@ -79,12 +81,14 @@ import { str } from '@h3ravel/support'
type KyselyExecutor = Kysely<any> | Transaction<any>
type KyselyTableMapping = Record<string, string>
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
Expand Down Expand Up @@ -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<unknown> {
if (sourceTable === table) return sql`${sql.table(table)}`

return sql`${sql.table(sourceTable)} as ${sql.table(table)}`
}

private buildRelatedTargetFromRelation(
target: QueryTarget<any>,
relation: string,
): {
metadata: SqlRelationMetadata
relatedTarget: QueryTarget<any>
sourceTable: string
} {
const metadata = target.model?.getRelationMetadata(relation)
if (!metadata)
Expand All @@ -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.`,
Expand All @@ -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<any, any>,
modelName: metadata.relatedModel.name,
table: relatedMetadata.table,
table,
primaryKey: relatedMetadata.primaryKey,
columns: relatedMetadata.columns,
softDelete: relatedMetadata.softDelete,
Expand All @@ -1846,13 +1874,14 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter {
outerTarget: QueryTarget<any>,
relatedTarget: QueryTarget<any>,
metadata: BelongsToManyRelationMetadata,
sourceTable: string,
): { from: RawBuilder<unknown>; condition: RawBuilder<boolean> } {
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<boolean>`
${this.buildColumnReference(pivotTable, metadata.foreignPivotKey)}
=
Expand All @@ -1865,13 +1894,14 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter {
outerTarget: QueryTarget<any>,
relatedTarget: QueryTarget<any>,
metadata: ThroughRelationMetadata,
sourceTable: string,
): { from: RawBuilder<unknown>; condition: RawBuilder<boolean> } {
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<boolean>`
${this.buildColumnReference(throughTable, metadata.firstKey)}
=
Expand All @@ -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<any>, 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<any>,
relation: string,
Expand All @@ -1888,12 +1947,21 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter {
from: RawBuilder<unknown>
condition: RawBuilder<boolean>
} {
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,
Expand All @@ -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,
Expand All @@ -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<boolean>`
${this.buildColumnReference(relatedTable, this.mapColumn(relatedTarget, metadata.foreignKey))}
=
Expand All @@ -1924,9 +1997,25 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter {
}
}

if (metadata.type === 'morphOne' || metadata.type === 'morphMany') {
return {
relatedTarget,
from: relatedFrom,
condition: sql<boolean>`
${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<boolean>`
${this.buildColumnReference(relatedTable, this.mapColumn(relatedTarget, metadata.ownerKey))}
=
Expand Down
5 changes: 5 additions & 0 deletions tests/postgres/helpers/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
84 changes: 84 additions & 0 deletions tests/postgres/kysely-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading