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
24 changes: 24 additions & 0 deletions docs/src/guide/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,30 @@ await Activity.query()
.get()
```

Relation filters can be nested and grouped. A `whereHas(...)` inside another
relation's callback constrains the related rows, and `whereHas(...)` /
`orWhereHas(...)` inside a `where(...)` group stay in that group. On SQL-backed
adapters both compile to correlated subqueries:

```ts
// Users with a post that has an approved comment
await User.query()
.whereHas('posts', (posts) =>
posts.whereHas('comments', (comments) => comments.whereKey('approved', true)),
)
.get()

// Active users who are admins or own a published post
await User.query()
.where({ isActive: true })
.where((query) =>
query
.where({ role: 'admin' })
.orWhereHas('posts', (posts) => posts.whereKey('published', true)),
)
.get()
```

Use `loadCount(...)` when you already have a model instance and want to attach
relationship counts without reloading the related records. Count attributes use
the same names as `withCount(...)`, such as `postsCount`.
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-clear-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,4 @@
"parasito": "^0.1.8"
},
"packageManager": "pnpm@10.0.0"
}
}
66 changes: 60 additions & 6 deletions src/QueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,10 @@ export class QueryBuilder<TModel, TDelegate extends ModelQuerySchemaLike = Model
/**
* Appends a pre-built structured condition, first flushing any Prisma-like
* legacy where into the structured representation so the two combine correctly.
*
* @param boolean
* @param condition
* @returns
*/
private appendStructuredWhere(boolean: 'AND' | 'OR', condition: QueryCondition): this {
if (this.legacyWhere) {
Expand All @@ -351,6 +355,10 @@ export class QueryBuilder<TModel, TDelegate extends ModelQuerySchemaLike = Model
* Appends a boolean {@link Expression} as a where predicate. When the query is
* using the Prisma-like legacy where representation, the expression is merged in
* as a structured condition alongside it.
*
* @param boolean
* @param expression
* @returns
*/
private appendExpressionCondition(boolean: 'AND' | 'OR', expression: Expression): this {
const condition: QueryExpressionCondition = {
Expand All @@ -363,6 +371,10 @@ export class QueryBuilder<TModel, TDelegate extends ModelQuerySchemaLike = Model

/**
* Resolve a callback into a parenthesized group condition and append it.
*
* @param boolean
* @param callback
* @returns
*/
private appendNestedWhere(
boolean: 'AND' | 'OR',
Expand All @@ -371,7 +383,7 @@ export class QueryBuilder<TModel, TDelegate extends ModelQuerySchemaLike = Model
const nested = new QueryBuilder<TModel, TDelegate>(this.model, this.adapter)
callback(nested)

const condition = nested.getNestedWhereCondition()
const condition = nested.withRelationConditions(nested.getNestedWhereCondition(), 'query.where')
if (!condition) return this

const grouped: QueryCondition =
Expand All @@ -384,6 +396,42 @@ export class QueryBuilder<TModel, TDelegate extends ModelQuerySchemaLike = Model
return this
}

/**
* Fold this builder's relation filters into `condition`, in the order and
* with the and/or they were added with, so they can be carried inside a
* where group or another relation's constraint.
*
* @param condition
* @param operation Names the caller in the exception raised when a filter
* cannot be expressed as a condition.
* @returns
*/
private withRelationConditions(
condition: QueryCondition | undefined,
operation: string,
): QueryCondition | undefined {
if (!this.hasRelationFilters()) return condition

const specs = this.tryBuildRelationFilterSpecs()
if (!specs) {
throw new UnsupportedAdapterFeatureException(
'Relation filters inside a where group or relation constraint could not be compiled into conditions.',
{ operation, model: this.model.name, meta: { feature: 'relationFilters' } },
)
}

return specs.reduce<QueryCondition | undefined>((combined, spec) => {
const relation: QueryCondition = { type: 'relation', filter: { ...spec, boolean: undefined } }
if (!combined) return relation

return {
type: 'group',
operator: spec.boolean === 'OR' ? 'or' : 'and',
conditions: [combined, relation],
}
}, condition)
}

/**
* Returns the user-authored where condition for nesting, excluding any
* soft-delete predicate (the parent query owns that).
Expand Down Expand Up @@ -3571,8 +3619,8 @@ export class QueryBuilder<TModel, TDelegate extends ModelQuerySchemaLike = Model
}

/**
* Updates records matching the current query constraints with the
* specified data and returns the updated record(s) as model instance(s).
* Updates the first record matching the current query constraints and
* returns it as a model. Use {@link updateFrom} to update every match.
*
* @param data
* @returns
Expand Down Expand Up @@ -3615,7 +3663,8 @@ export class QueryBuilder<TModel, TDelegate extends ModelQuerySchemaLike = Model
}

/**
* Update records using update-many semantics when available.
* Update every record matching the current query constraints and return
* the number of affected rows.
*
* @param data
* @returns
Expand Down Expand Up @@ -5969,7 +6018,6 @@ export class QueryBuilder<TModel, TDelegate extends ModelQuerySchemaLike = Model
relatedQuery: QueryBuilder<any, any>,
): QueryCondition | undefined | null {
if (
relatedQuery.hasRelationFilters() ||
relatedQuery.hasRelationAggregates() ||
relatedQuery.queryRelationLoads ||
relatedQuery.querySelect ||
Expand All @@ -5983,7 +6031,13 @@ export class QueryBuilder<TModel, TDelegate extends ModelQuerySchemaLike = Model
return null
}

return relatedQuery.buildQueryWhereCondition(false)
const where = relatedQuery.buildQueryWhereCondition(false)
if (where === null || !relatedQuery.hasRelationFilters()) return where

// Nested relation filters become conditions on the related table.
if (!relatedQuery.tryBuildRelationFilterSpecs()) return null

return relatedQuery.withRelationConditions(where, 'query.relations')
}

private isSqlRelationFeatureMetadata(
Expand Down
7 changes: 5 additions & 2 deletions src/adapters/KyselyDatabaseAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
DeleteSpec,
InsertManySpec,
InsertSpec,
PartitionedSelectSpec,
QueryColumnComparisonCondition,
QueryComparisonCondition,
QueryCondition,
Expand All @@ -28,7 +29,6 @@ import type {
QueryJsonCondition,
QueryNotCondition,
QueryOrderBy,
PartitionedSelectSpec,
QueryRawCondition,
QuerySelectColumn,
QueryTarget,
Expand All @@ -43,7 +43,6 @@ import type {
UpdateSpec,
UpsertSpec,
} from '../types/adapter'
import type { ExpressionNode, JsonExpressionNode } from '../types/expression'
import type {
AdapterQueryInspection,
BelongsToManyRelationMetadata,
Expand All @@ -67,6 +66,7 @@ import type {
SchemaPrimaryKey,
SchemaUniqueConstraint,
} from '../types/migrations'
import type { ExpressionNode, JsonExpressionNode } from '../types/expression'

import { ArkormException } from '../Exceptions/ArkormException'
import { Pool } from 'pg'
Expand Down Expand Up @@ -1731,6 +1731,9 @@ export class KyselyDatabaseAdapter implements DatabaseAdapter {

if (condition.type === 'exists') return this.buildExistsCondition(condition)

if (condition.type === 'relation')
return this.buildRelationFilterExpression(target, condition.filter)

if (condition.type === 'full-text') return this.buildFullTextCondition(target, condition)

if (condition.type === 'json') return this.buildJsonCondition(target, condition)
Expand Down
10 changes: 10 additions & 0 deletions src/types/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,15 @@ export interface QueryExistsCondition {
query: SelectSpec
}

/**
* A relation filter (`whereHas` and friends) placed inside a where tree, so it
* can sit in a group or inside another relation's constraint.
*/
export interface QueryRelationCondition {
type: 'relation'
filter: RelationFilterSpec
}

export interface QueryFullTextCondition {
type: 'full-text'
columns: string[]
Expand Down Expand Up @@ -191,6 +200,7 @@ export type QueryCondition =
| QueryTimeCondition
| QueryDayCondition
| QueryExistsCondition
| QueryRelationCondition
| QueryFullTextCondition
| QueryJsonCondition
| QueryExpressionCondition
Expand Down
12 changes: 8 additions & 4 deletions tests/base/cast-set-symmetry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class Product extends Model<'user'> {
protected override casts = { unitPrice: AsMoney } as unknown as Record<string, never>
}

/** Raw stored attributes; the borrowed `user` schema has no `unitPrice`. */
const raw = (product: Product | null | undefined) =>
product?.getRawAttributes() as Record<string, unknown> | undefined

describe('non-idempotent set-cast round-trips through save()', () => {
beforeEach(() => {
configureArkormRuntime(createCoreClient(), { naming: { case: 'camel' } })
Expand All @@ -35,13 +39,13 @@ describe('non-idempotent set-cast round-trips through save()', () => {
product.fill({ unitPrice: 100 } as never)

// Before save: stored as minor units, read back as major units.
expect(product.getRawAttributes().unitPrice).toBe(10000)
expect(raw(product)?.unitPrice).toBe(10000)
expect(product.getAttribute('unitPrice')).toBe(100)

await product.save()

// After save: the set-cast must not be applied a second time.
expect(product.getRawAttributes().unitPrice).toBe(10000)
expect(raw(product)?.unitPrice).toBe(10000)
expect(product.getAttribute('unitPrice')).toBe(100)
})

Expand All @@ -54,7 +58,7 @@ describe('non-idempotent set-cast round-trips through save()', () => {
.where({ id: product.getAttribute('id') } as never)
.first()

expect(fresh?.getRawAttributes().unitPrice).toBe(10000)
expect(raw(fresh)?.unitPrice).toBe(10000)
expect(fresh?.getAttribute('unitPrice')).toBe(100)
})

Expand All @@ -66,7 +70,7 @@ describe('non-idempotent set-cast round-trips through save()', () => {
product.setAttribute('unitPrice', 250)
await product.save()

expect(product.getRawAttributes().unitPrice).toBe(25000)
expect(raw(product)?.unitPrice).toBe(25000)
expect(product.getAttribute('unitPrice')).toBe(250)
})
})
50 changes: 50 additions & 0 deletions tests/postgres/kysely-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,56 @@ describe('PostgreSQL Kysely adapter', () => {
expect(normalizedSql).toContain('avg("posts"."id")::double precision')
})

it('keeps relation filters placed inside a where group', async () => {
setPostgresModelAdapter(kyselyAdapter)

// Without the group's relation filter this would match user 2.
const none = await DbUser.query()
.where({ id: 2 })
.where((query) => query.whereHas('posts', (posts) => posts.where({ title: 'A' })))
.get()
expect(none.all()).toHaveLength(0)

const either = await DbUser.query()
.where((query) =>
query.where({ id: 2 }).orWhereHas('posts', (posts) => posts.where({ title: 'A' })),
)
.orderBy({ id: 'asc' })
.get()
expect(either.all().map((user) => user.getAttribute('id'))).toEqual([1, 2])
})

it('compiles relation filters nested inside a relation constraint into SQL', async () => {
setPostgresModelAdapter(kyselyAdapter)
executedQueries.length = 0

const commentedAuthors = await DbUser.query()
.whereHas('posts', (posts) =>
posts.whereHas('comments', (comments) => comments.where({ body: 'Hi post' })),
)
.get()
expect(commentedAuthors.all().map((user) => user.getAttribute('id'))).toEqual([1])

const nobody = await DbUser.query()
.whereHas('posts', (posts) =>
posts.whereHas('comments', (comments) => comments.where({ body: 'missing' })),
)
.get()
expect(nobody.all()).toHaveLength(0)

// One statement per query: the nested filter runs in the database, not by
// loading users and filtering them in memory.
const normalizedSql = executedQueries.join('\n').replace(/\s+/g, ' ')
expect(executedQueries).toHaveLength(2)
expect(normalizedSql).toContain('from "comments"')
expect(
DbUser.query()
.whereHas('posts', (posts) => posts.whereHas('comments'))
.toSql()
.replace(/\s+/g, ' '),
).toContain('from "comments"')
})

it('orders paginated queries by a constrained relation aggregate alias', async () => {
setPostgresModelAdapter(kyselyAdapter)

Expand Down
Loading