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
314 changes: 310 additions & 4 deletions src/relationship/Relation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
QuerySchemaSelect,
RelationAggregateInput,
RelationMetadata,
RelationshipModelStatic,
} from '../types'
import type { LengthAwarePaginator, Paginator } from '../Paginator'

Expand Down Expand Up @@ -168,8 +169,22 @@ export abstract class Relation<TModel> {
* @param where
* @returns
*/
public where(where: ModelWhereInput<TModel>): this {
return this.constrain((query) => query.where(where as never))
public where(where: ModelWhereInput<TModel>): this
public where(callback: (query: QueryBuilder<TModel>) => unknown): this
public where<TKey extends keyof ModelAttributes<TModel> & string>(
column: TKey,
value: DatabaseValue,
): this
public where(
whereOrColumn: ModelWhereInput<TModel> | ((query: QueryBuilder<TModel>) => 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)
})
}

/**
Expand All @@ -178,8 +193,22 @@ export abstract class Relation<TModel> {
* @param where
* @returns
*/
public orWhere(where: ModelWhereInput<TModel>): this {
return this.constrain((query) => query.orWhere(where as never))
public orWhere(where: ModelWhereInput<TModel>): this
public orWhere(callback: (query: QueryBuilder<TModel>) => unknown): this
public orWhere<TKey extends keyof ModelAttributes<TModel> & string>(
column: TKey,
value: DatabaseValue,
): this
public orWhere(
whereOrColumn: ModelWhereInput<TModel> | ((query: QueryBuilder<TModel>) => 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)
})
}

/**
Expand All @@ -202,6 +231,68 @@ export abstract class Relation<TModel> {
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<TValue>(
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<TValue>(
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<TResult>(callback: (relation: this) => TResult): TResult {
return callback(this)
}

/**
* Adds a null check for a key.
*
Expand All @@ -222,6 +313,26 @@ export abstract class Relation<TModel> {
return this.constrain((query) => query.whereNotNull(key))
}

/**
* Adds an OR null check for a key.
*
* @param key
* @returns
*/
public orWhereNull<TKey extends keyof ModelAttributes<TModel> & 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<TKey extends keyof ModelAttributes<TModel> & string>(key: TKey): this {
return this.constrain((query) => query.orWhereNotNull(key))
}

/**
* Adds a between range clause for a key.
*
Expand Down Expand Up @@ -957,6 +1068,201 @@ export abstract class Relation<TModel> {
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<any, any>) => 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<any, any>) => 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<any, any>) => 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<any, any>) => 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<any, any>) => 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<any, any>) => 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<TRelated = any>(
relation: string,
types: string | RelationshipModelStatic | Array<string | RelationshipModelStatic>,
callback?: (query: QueryBuilder<TRelated, any>, 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<TRelated = any>(
relation: string,
types: string | RelationshipModelStatic | Array<string | RelationshipModelStatic>,
callback?: (query: QueryBuilder<TRelated, any>, 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<TRelated = any>(
relation: string,
types: string | RelationshipModelStatic | Array<string | RelationshipModelStatic>,
callback?: (query: QueryBuilder<TRelated, any>, 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<TRelated = any>(
relation: string,
types: string | RelationshipModelStatic | Array<string | RelationshipModelStatic>,
callback?: (query: QueryBuilder<TRelated, any>, type: string) => unknown,
): this {
return this.constrain((query) => query.orWhereDoesntHaveMorph(relation, types, callback))
}

/**
* Add relationship count aggregates to the related-model query.
*
Expand Down
15 changes: 15 additions & 0 deletions tests/base/relationships.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions tests/base/typing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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<typeof relation>()

Expand Down
Loading