diff --git a/docs/src/guide/relationships.md b/docs/src/guide/relationships.md index a7d79fa..cd0f6c4 100644 --- a/docs/src/guide/relationships.md +++ b/docs/src/guide/relationships.md @@ -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`. diff --git a/packages/plugin-clear-router/package.json b/packages/plugin-clear-router/package.json index 63b5b69..42bdcec 100644 --- a/packages/plugin-clear-router/package.json +++ b/packages/plugin-clear-router/package.json @@ -58,4 +58,4 @@ "parasito": "^0.1.8" }, "packageManager": "pnpm@10.0.0" -} \ No newline at end of file +} diff --git a/src/QueryBuilder.ts b/src/QueryBuilder.ts index 4f68bf6..dbd0170 100644 --- a/src/QueryBuilder.ts +++ b/src/QueryBuilder.ts @@ -334,6 +334,10 @@ export class QueryBuilder(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 = @@ -384,6 +396,42 @@ export class QueryBuilder((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). @@ -3571,8 +3619,8 @@ export class QueryBuilder, ): QueryCondition | undefined | null { if ( - relatedQuery.hasRelationFilters() || relatedQuery.hasRelationAggregates() || relatedQuery.queryRelationLoads || relatedQuery.querySelect || @@ -5983,7 +6031,13 @@ export class QueryBuilder { protected override casts = { unitPrice: AsMoney } as unknown as Record } +/** Raw stored attributes; the borrowed `user` schema has no `unitPrice`. */ +const raw = (product: Product | null | undefined) => + product?.getRawAttributes() as Record | undefined + describe('non-idempotent set-cast round-trips through save()', () => { beforeEach(() => { configureArkormRuntime(createCoreClient(), { naming: { case: 'camel' } }) @@ -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) }) @@ -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) }) @@ -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) }) }) diff --git a/tests/postgres/kysely-adapter.spec.ts b/tests/postgres/kysely-adapter.spec.ts index 5e70a68..ffb6a9f 100644 --- a/tests/postgres/kysely-adapter.spec.ts +++ b/tests/postgres/kysely-adapter.spec.ts @@ -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)