From 0da1da96cf00d1f4da90aa3bd949c5494e1e07f6 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Sat, 22 Aug 2026 04:08:36 +0100 Subject: [PATCH] add relation query composition methods --- src/relationship/Relation.ts | 314 ++++++++++++++++++++++++++++++- tests/base/relationships.spec.ts | 15 ++ tests/base/typing.spec.ts | 5 + 3 files changed, 330 insertions(+), 4 deletions(-) diff --git a/src/relationship/Relation.ts b/src/relationship/Relation.ts index 6c96ba0..782624e 100644 --- a/src/relationship/Relation.ts +++ b/src/relationship/Relation.ts @@ -11,6 +11,7 @@ import type { QuerySchemaSelect, RelationAggregateInput, RelationMetadata, + RelationshipModelStatic, } from '../types' import type { LengthAwarePaginator, Paginator } from '../Paginator' @@ -168,8 +169,22 @@ export abstract class Relation { * @param where * @returns */ - public where(where: ModelWhereInput): this { - return this.constrain((query) => query.where(where as never)) + public where(where: ModelWhereInput): this + public where(callback: (query: QueryBuilder) => unknown): this + public where & string>( + column: TKey, + value: DatabaseValue, + ): this + public where( + whereOrColumn: ModelWhereInput | ((query: QueryBuilder) => unknown) | string, + value?: DatabaseValue, + ): this { + return this.constrain((query) => { + if (typeof whereOrColumn === 'function') return query.where(whereOrColumn as never) + if (value !== undefined) return query.where(whereOrColumn as never, value) + + return query.where(whereOrColumn as never) + }) } /** @@ -178,8 +193,22 @@ export abstract class Relation { * @param where * @returns */ - public orWhere(where: ModelWhereInput): this { - return this.constrain((query) => query.orWhere(where as never)) + public orWhere(where: ModelWhereInput): this + public orWhere(callback: (query: QueryBuilder) => unknown): this + public orWhere & string>( + column: TKey, + value: DatabaseValue, + ): this + public orWhere( + whereOrColumn: ModelWhereInput | ((query: QueryBuilder) => unknown) | string, + value?: DatabaseValue, + ): this { + return this.constrain((query) => { + if (typeof whereOrColumn === 'function') return query.orWhere(whereOrColumn as never) + if (value !== undefined) return query.orWhere(whereOrColumn as never, value) + + return query.orWhere(whereOrColumn as never) + }) } /** @@ -202,6 +231,68 @@ export abstract class Relation { return this.constrain((query) => query.orWhereNot(where as never)) } + /** + * Apply a callback when the supplied value is truthy. + * + * Keeping this on the relation avoids forcing callers to await `getQuery()` + * merely to compose optional constraints. + * + * @param value + * @param callback + * @param defaultCallback + * @returns + */ + public when( + value: TValue | (() => TValue), + callback: (relation: this, value: TValue) => unknown, + defaultCallback?: (relation: this, value: TValue) => unknown, + ): this { + const resolved = typeof value === 'function' ? (value as () => TValue)() : value + + if (resolved) callback(this, resolved) + else defaultCallback?.(this, resolved) + + return this + } + + /** + * Apply a callback when the supplied value is falsy. + * + * @param value + * @param callback + * @param defaultCallback + * @returns + */ + public unless( + value: TValue | (() => TValue), + callback: (relation: this, value: TValue) => unknown, + defaultCallback?: (relation: this, value: TValue) => unknown, + ): this { + const resolved = typeof value === 'function' ? (value as () => TValue)() : value + + if (!resolved) callback(this, resolved) + else defaultCallback?.(this, resolved) + + return this + } + + /** + * Pass the relation through a callback and preserve the chain. + * + * @param callback + * @returns + */ + public tap(callback: (relation: this) => unknown): this { + callback(this) + + return this + } + + /** Pass the relation into a callback and return its result. */ + public pipe(callback: (relation: this) => TResult): TResult { + return callback(this) + } + /** * Adds a null check for a key. * @@ -222,6 +313,26 @@ export abstract class Relation { return this.constrain((query) => query.whereNotNull(key)) } + /** + * Adds an OR null check for a key. + * + * @param key + * @returns + */ + public orWhereNull & string>(key: TKey): this { + return this.constrain((query) => query.orWhereNull(key)) + } + + /** + * Adds an OR not-null check for a key. + * + * @param key + * @returns + */ + public orWhereNotNull & string>(key: TKey): this { + return this.constrain((query) => query.orWhereNotNull(key)) + } + /** * Adds a between range clause for a key. * @@ -957,6 +1068,201 @@ export abstract class Relation { return this.constrain((query) => query.with(relations)) } + /** + * Add a relationship count/existence constraint to the related query. + * + * @param relation + * @param operator + * @param count + * @param callback + * @returns + */ + public has( + relation: string, + operator: '>=' | '>' | '=' | '!=' | '<=' | '<' = '>=', + count = 1, + callback?: (query: QueryBuilder) => unknown, + ): this { + return this.constrain((query) => query.has(relation, operator, count, callback)) + } + + /** + * Add an OR relationship count/existence constraint to the related query. + * + * @param relation + * @param operator + * @param count + * @returns + */ + public orHas( + relation: string, + operator: '>=' | '>' | '=' | '!=' | '<=' | '<' = '>=', + count = 1, + ): this { + return this.constrain((query) => query.orHas(relation, operator, count)) + } + + /** + * Require the related query's model to have a nested relationship. + * + * @param relation + * @param callback + * @param operator + * @param count + * @returns + */ + public whereHas( + relation: string, + callback?: (query: QueryBuilder) => unknown, + operator: '>=' | '>' | '=' | '!=' | '<=' | '<' = '>=', + count = 1, + ): this { + return this.constrain((query) => query.whereHas(relation, callback, operator, count)) + } + + /** + * Add an OR nested relationship constraint to the related query. + * + * @param relation + * @param callback + * @param operator + * @param count + * @returns + */ + public orWhereHas( + relation: string, + callback?: (query: QueryBuilder) => unknown, + operator: '>=' | '>' | '=' | '!=' | '<=' | '<' = '>=', + count = 1, + ): this { + return this.constrain((query) => query.orWhereHas(relation, callback, operator, count)) + } + + /** + * Require the related query's model not to have a nested relationship. + * + * @param relation + * @param callback + * @returns + */ + public doesntHave(relation: string, callback?: (query: QueryBuilder) => unknown): this { + return this.constrain((query) => query.doesntHave(relation, callback)) + } + + /** + * Add an OR nested relationship absence constraint. + * + * @param relation + * @returns + */ + public orDoesntHave(relation: string): this { + return this.constrain((query) => query.orDoesntHave(relation)) + } + + /** + * Require a constrained nested relationship to be absent. + * + * @param relation + * @param callback + * @returns + */ + public whereDoesntHave( + relation: string, + callback?: (query: QueryBuilder) => unknown, + ): this { + return this.constrain((query) => query.whereDoesntHave(relation, callback)) + } + + /** + * Add an OR constrained nested relationship absence clause. + * + * @param relation + * @param callback + * @returns + */ + public orWhereDoesntHave( + relation: string, + callback?: (query: QueryBuilder) => unknown, + ): this { + return this.constrain((query) => query.orWhereDoesntHave(relation, callback)) + } + + /** + * Add a constrained polymorphic nested relationship clause. + * + * @param relation + * @param types + * @param callback + * @param operator + * @param count + * @returns + */ + public whereHasMorph( + relation: string, + types: string | RelationshipModelStatic | Array, + callback?: (query: QueryBuilder, type: string) => unknown, + operator: '>=' | '>' | '=' | '!=' | '<=' | '<' = '>=', + count = 1, + ): this { + return this.constrain((query) => + query.whereHasMorph(relation, types, callback, operator, count), + ) + } + + /** + * Add an OR constrained polymorphic nested relationship clause. + * + * @param relation + * @param types + * @param callback + * @param operator + * @param count + * @returns + */ + public orWhereHasMorph( + relation: string, + types: string | RelationshipModelStatic | Array, + callback?: (query: QueryBuilder, type: string) => unknown, + operator: '>=' | '>' | '=' | '!=' | '<=' | '<' = '>=', + count = 1, + ): this { + return this.constrain((query) => + query.orWhereHasMorph(relation, types, callback, operator, count), + ) + } + + /** + * Require a constrained polymorphic nested relationship to be absent. + * + * @param relation + * @param types + * @param callback + * @returns + */ + public whereDoesntHaveMorph( + relation: string, + types: string | RelationshipModelStatic | Array, + callback?: (query: QueryBuilder, type: string) => unknown, + ): this { + return this.constrain((query) => query.whereDoesntHaveMorph(relation, types, callback)) + } + + /** + * Add an OR polymorphic nested relationship absence clause. + * + * @param relation + * @param types + * @param callback + * @returns + */ + public orWhereDoesntHaveMorph( + relation: string, + types: string | RelationshipModelStatic | Array, + callback?: (query: QueryBuilder, type: string) => unknown, + ): this { + return this.constrain((query) => query.orWhereDoesntHaveMorph(relation, types, callback)) + } + /** * Add relationship count aggregates to the related-model query. * diff --git a/tests/base/relationships.spec.ts b/tests/base/relationships.spec.ts index cc1f914..4534ce8 100644 --- a/tests/base/relationships.spec.ts +++ b/tests/base/relationships.spec.ts @@ -206,6 +206,21 @@ describe('Model relationships', () => { expect(posts.all()[0]?.getAttribute('title')).toBe('A') }) + it('forwards conditional and nested relationship constraints through morph-many relations', async () => { + const user = await User.query().findOrFail(1) + const tapped = vi.fn() + const comments = await user + .comments() + .when(true, (relation) => relation.where('body', 'Hi user')) + .unless(false, (relation) => relation.whereNotNull('id')) + .tap(tapped) + .whereHas('user', (query) => query.where({ email: 'jane@example.com' })) + .getResults() + + expect(tapped).toHaveBeenCalledOnce() + expect(comments.pluck('id').all()).toEqual([1000]) + }) + it('accepts a model constructor for morph-to resolution', async () => { const comment = await Comment.query().find(1000) expect(comment).not.toBeNull() diff --git a/tests/base/typing.spec.ts b/tests/base/typing.spec.ts index 32e3c14..97ea879 100644 --- a/tests/base/typing.spec.ts +++ b/tests/base/typing.spec.ts @@ -294,6 +294,8 @@ describe('adapter-first typing', () => { relation .where({ title: { contains: 'ArkORM' } }) + .where('title', 'ArkORM') + .where((query) => query.whereKey('authorId', 1)) .orWhere({ authorId: 1 }) .whereNot({ title: 'Draft' }) .whereNull('title') @@ -317,6 +319,9 @@ describe('adapter-first typing', () => { .offset(5) .limit(10) .forPage(2, 10) + .when(true, (query) => query.whereHas('comments')) + .unless(false, (query) => query.whereDoesntHave('comments')) + .tap((query) => query.orWhereHas('comments')) expectTypeOf(relation.withCount('comments')).toEqualTypeOf()