diff --git a/package-lock.json b/package-lock.json index 63df2f2a..21649325 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/database", - "version": "5.54.0", + "version": "5.55.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/database", - "version": "5.54.0", + "version": "5.55.0", "license": "MIT", "dependencies": { "@faker-js/faker": "^8.4.1" diff --git a/package.json b/package.json index 9bdc2806..d68e7db7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/database", - "version": "5.54.0", + "version": "5.55.0", "description": "The Athenna database handler for SQL/NoSQL.", "license": "MIT", "author": "João Lenon ", diff --git a/src/constants/MetadataKeys.ts b/src/constants/MetadataKeys.ts index f0189ca7..9ea152a6 100644 --- a/src/constants/MetadataKeys.ts +++ b/src/constants/MetadataKeys.ts @@ -10,5 +10,7 @@ export const COLUMNS_KEY = 'database:columns:options' export const HAS_ONE_KEY = 'database:hasOne:options' export const HAS_MANY_KEY = 'database:hasMany:options' +export const HAS_ONE_THROUGH_KEY = 'database:hasOneThrough:options' +export const HAS_MANY_THROUGH_KEY = 'database:hasManyThrough:options' export const BELONGS_TO_KEY = 'database:belongsTo:options' export const BELONGS_TO_MANY_KEY = 'database:belongsToMany:options' diff --git a/src/helpers/Annotation.ts b/src/helpers/Annotation.ts index 48e37ff7..4451485a 100644 --- a/src/helpers/Annotation.ts +++ b/src/helpers/Annotation.ts @@ -11,6 +11,8 @@ import { COLUMNS_KEY, HAS_ONE_KEY, HAS_MANY_KEY, + HAS_ONE_THROUGH_KEY, + HAS_MANY_THROUGH_KEY, BELONGS_TO_KEY, BELONGS_TO_MANY_KEY } from '#src/constants/MetadataKeys' @@ -19,6 +21,8 @@ import type { ColumnOptions, HasOneOptions, HasManyOptions, + HasOneThroughOptions, + HasManyThroughOptions, BelongsToOptions, BelongsToManyOptions } from '#src/types' @@ -40,6 +44,8 @@ export class Annotation { return [ ...this.getHasOnesMeta(target), ...this.getHasManyMeta(target), + ...this.getHasOneThroughMeta(target), + ...this.getHasManyThroughMeta(target), ...this.getBelongsToMeta(target), ...this.getBelongsToManyMeta(target) ] @@ -69,6 +75,37 @@ export class Annotation { Reflect.defineMetadata(HAS_MANY_KEY, hasMany, target) } + public static getHasOneThroughMeta(target: any): HasOneThroughOptions[] { + return Reflect.getMetadata(HAS_ONE_THROUGH_KEY, target) || [] + } + + public static defineHasOneThroughMeta( + target: any, + options: HasOneThroughOptions + ) { + const hasOneThrough = Reflect.getMetadata(HAS_ONE_THROUGH_KEY, target) || [] + + hasOneThrough.push(options) + + Reflect.defineMetadata(HAS_ONE_THROUGH_KEY, hasOneThrough, target) + } + + public static getHasManyThroughMeta(target: any): HasManyThroughOptions[] { + return Reflect.getMetadata(HAS_MANY_THROUGH_KEY, target) || [] + } + + public static defineHasManyThroughMeta( + target: any, + options: HasManyThroughOptions + ) { + const hasManyThrough = + Reflect.getMetadata(HAS_MANY_THROUGH_KEY, target) || [] + + hasManyThrough.push(options) + + Reflect.defineMetadata(HAS_MANY_THROUGH_KEY, hasManyThrough, target) + } + public static getBelongsToMeta(target: any): BelongsToOptions[] { return Reflect.getMetadata(BELONGS_TO_KEY, target) || [] } diff --git a/src/index.ts b/src/index.ts index e86b1ac7..70864b59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,8 @@ export * from '#src/models/schemas/ModelSchema' export * from '#src/models/annotations/Column' export * from '#src/models/annotations/HasOne' export * from '#src/models/annotations/HasMany' +export * from '#src/models/annotations/HasOneThrough' +export * from '#src/models/annotations/HasManyThrough' export * from '#src/models/annotations/BelongsTo' export * from '#src/models/annotations/BelongsToMany' diff --git a/src/models/annotations/HasManyThrough.ts b/src/models/annotations/HasManyThrough.ts new file mode 100644 index 00000000..f9eae760 --- /dev/null +++ b/src/models/annotations/HasManyThrough.ts @@ -0,0 +1,66 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'reflect-metadata' + +import { debug } from '#src/debug' +import { Annotation } from '#src/helpers/Annotation' +import { Is, Options } from '@athenna/common' +import type { BaseModel } from '#src/models/BaseModel' +import type { HasManyThroughOptions } from '#src/types/relations/HasManyThroughOptions' + +/** + * Create has many through relation for model class. + */ +export function HasManyThrough< + T extends BaseModel = any, + R extends BaseModel = any, + H extends BaseModel = any +>( + model: (() => new () => R) | string, + through: (() => new () => H) | string, + options: Omit< + HasManyThroughOptions, + 'type' | 'model' | 'through' | 'property' + > = {} +) { + return (target: T, key: any) => { + const Target = target.constructor as typeof BaseModel + + options = Options.create(options, { + isIncluded: false, + inverse: false + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + options.type = 'hasManyThrough' + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + options.model = Is.String(model) + ? () => ioc.safeUse(`App/Models/${model}`).constructor + : model + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + options.through = Is.String(through) + ? () => ioc.safeUse(`App/Models/${through}`).constructor + : through + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + options.property = key + + debug( + 'registering hasManyThrough metadata for model %s: %o', + Target.name, + options + ) + + Annotation.defineHasManyThroughMeta(Target, options) + } +} diff --git a/src/models/annotations/HasOneThrough.ts b/src/models/annotations/HasOneThrough.ts new file mode 100644 index 00000000..692b4794 --- /dev/null +++ b/src/models/annotations/HasOneThrough.ts @@ -0,0 +1,66 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'reflect-metadata' + +import { debug } from '#src/debug' +import { Annotation } from '#src/helpers/Annotation' +import { Is, Options } from '@athenna/common' +import type { BaseModel } from '#src/models/BaseModel' +import type { HasOneThroughOptions } from '#src/types/relations/HasOneThroughOptions' + +/** + * Create has one through relation for model class. + */ +export function HasOneThrough< + T extends BaseModel = any, + R extends BaseModel = any, + H extends BaseModel = any +>( + model: (() => new () => R) | string, + through: (() => new () => H) | string, + options: Omit< + HasOneThroughOptions, + 'type' | 'model' | 'through' | 'property' + > = {} +) { + return (target: T, key: any) => { + const Target = target.constructor as typeof BaseModel + + options = Options.create(options, { + isIncluded: false, + inverse: false + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + options.type = 'hasOneThrough' + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + options.model = Is.String(model) + ? () => ioc.safeUse(`App/Models/${model}`).constructor + : model + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + options.through = Is.String(through) + ? () => ioc.safeUse(`App/Models/${through}`).constructor + : through + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + options.property = key + + debug( + 'registering hasOneThrough metadata for model %s: %o', + Target.name, + options + ) + + Annotation.defineHasOneThroughMeta(Target, options) + } +} diff --git a/src/models/builders/ModelQueryBuilder.ts b/src/models/builders/ModelQueryBuilder.ts index f659d47c..43229ae4 100644 --- a/src/models/builders/ModelQueryBuilder.ts +++ b/src/models/builders/ModelQueryBuilder.ts @@ -34,6 +34,8 @@ import { HasManyRelation } from '#src/models/relations/HasMany/HasManyRelation' import { NullableValueException } from '#src/exceptions/NullableValueException' import { BelongsToRelation } from '#src/models/relations/BelongsTo/BelongsToRelation' import { BelongsToManyRelation } from '#src/models/relations/BelongsToMany/BelongsToManyRelation' +import { HasOneThroughRelation } from '#src/models/relations/HasOneThrough/HasOneThroughRelation' +import { HasManyThroughRelation } from '#src/models/relations/HasManyThrough/HasManyThroughRelation' export class ModelQueryBuilder< M extends BaseModel = any, @@ -615,6 +617,10 @@ export class ModelQueryBuilder< return HasOneRelation.whereHas(this.Model, query, snapshot) case 'hasMany': return HasManyRelation.whereHas(this.Model, query, snapshot) + case 'hasOneThrough': + return HasOneThroughRelation.whereHas(this.Model, query, snapshot) + case 'hasManyThrough': + return HasManyThroughRelation.whereHas(this.Model, query, snapshot) case 'belongsTo': return BelongsToRelation.whereHas(this.Model, query, snapshot) case 'belongsToMany': @@ -659,6 +665,10 @@ export class ModelQueryBuilder< return HasOneRelation.whereHas(this.Model, query, snapshot) case 'hasMany': return HasManyRelation.whereHas(this.Model, query, snapshot) + case 'hasOneThrough': + return HasOneThroughRelation.whereHas(this.Model, query, snapshot) + case 'hasManyThrough': + return HasManyThroughRelation.whereHas(this.Model, query, snapshot) case 'belongsTo': return BelongsToRelation.whereHas(this.Model, query, snapshot) case 'belongsToMany': diff --git a/src/models/factories/ModelGenerator.ts b/src/models/factories/ModelGenerator.ts index 4ee81a2f..d1f9f7e6 100644 --- a/src/models/factories/ModelGenerator.ts +++ b/src/models/factories/ModelGenerator.ts @@ -14,6 +14,8 @@ import type { BaseModel } from '#src/models/BaseModel' import { ModelSchema } from '#src/models/schemas/ModelSchema' import { HasOneRelation } from '#src/models/relations/HasOne/HasOneRelation' import { HasManyRelation } from '#src/models/relations/HasMany/HasManyRelation' +import { HasOneThroughRelation } from '#src/models/relations/HasOneThrough/HasOneThroughRelation' +import { HasManyThroughRelation } from '#src/models/relations/HasManyThrough/HasManyThroughRelation' import { BelongsToRelation } from '#src/models/relations/BelongsTo/BelongsToRelation' import { BelongsToManyRelation } from '#src/models/relations/BelongsToMany/BelongsToManyRelation' @@ -105,6 +107,10 @@ export class ModelGenerator extends Macroable { return HasOneRelation.load(model, relation) case 'hasMany': return HasManyRelation.load(model, relation) + case 'hasOneThrough': + return HasOneThroughRelation.load(model, relation) + case 'hasManyThrough': + return HasManyThroughRelation.load(model, relation) case 'belongsTo': return BelongsToRelation.load(model, relation) case 'belongsToMany': @@ -147,6 +153,10 @@ export class ModelGenerator extends Macroable { return HasOneRelation.loadAll(models, relation) case 'hasMany': return HasManyRelation.loadAll(models, relation) + case 'hasOneThrough': + return HasOneThroughRelation.loadAll(models, relation) + case 'hasManyThrough': + return HasManyThroughRelation.loadAll(models, relation) case 'belongsTo': return BelongsToRelation.loadAll(models, relation) case 'belongsToMany': diff --git a/src/models/relations/HasManyThrough/HasManyThroughRelation.ts b/src/models/relations/HasManyThrough/HasManyThroughRelation.ts new file mode 100644 index 00000000..836343e2 --- /dev/null +++ b/src/models/relations/HasManyThrough/HasManyThroughRelation.ts @@ -0,0 +1,223 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { String } from '@athenna/common' +import type { BaseModel } from '#src/models/BaseModel' +import type { HasManyThroughOptions } from '#src/types' +import type { Driver } from '#src/database/drivers/Driver' + +export class HasManyThroughRelation { + /** + * Get the options with defined default values. + */ + public static options( + Parent: typeof BaseModel, + relation: HasManyThroughOptions + ): HasManyThroughOptions { + const Through = relation.through() + const Final = relation.model() + + relation.localKey = + relation.localKey || Parent.schema().getMainPrimaryKeyProperty() + relation.firstKey = + relation.firstKey || `${String.toCamelCase(Parent.name)}Id` + + if (relation.inverse) { + relation.secondLocalKey = + relation.secondLocalKey || `${String.toCamelCase(Final.name)}Id` + relation.secondKey = + relation.secondKey || Final.schema().getMainPrimaryKeyProperty() + } else { + relation.secondLocalKey = + relation.secondLocalKey || Through.schema().getMainPrimaryKeyProperty() + relation.secondKey = + relation.secondKey || `${String.toCamelCase(Through.name)}Id` + } + + return relation + } + + /** + * Load a has many through relation. + */ + public static async load( + model: BaseModel, + relation: HasManyThroughOptions + ): Promise { + this.options(model.constructor as typeof BaseModel, relation) + + const throughRows = await relation + .through() + .query() + .where(relation.firstKey as never, model[relation.localKey]) + .findMany() + + const linkValues = throughRows + .map(r => r[relation.secondLocalKey]) + .filter(v => v !== undefined && v !== null) + + if (!linkValues.length) { + model[relation.property] = [] + + return model + } + + model[relation.property] = await relation + .model() + .query() + .whereIn(relation.secondKey as never, linkValues) + .when(relation.withClosure, relation.withClosure) + .findMany() + + return model + } + + /** + * Load all models that has many through relation. + */ + public static async loadAll( + models: BaseModel[], + relation: HasManyThroughOptions + ): Promise { + if (!models.length) { + return models + } + + this.options(models[0].constructor as typeof BaseModel, relation) + + const Final = relation.model() + const finalPK = Final.schema().getMainPrimaryKeyProperty() + + const parentValues = models.map(m => m[relation.localKey]) + const throughRows = await relation + .through() + .query() + .whereIn(relation.firstKey as never, parentValues) + .findMany() + + const parentToLinks = new Map() + const allLinks: any[] = [] + + for (const t of throughRows) { + const link = t[relation.secondLocalKey] + + if (link === undefined || link === null) { + continue + } + + allLinks.push(link) + + const arr = parentToLinks.get(t[relation.firstKey]) || [] + + arr.push(link) + parentToLinks.set(t[relation.firstKey], arr) + } + + const finals = allLinks.length + ? await relation + .model() + .query() + .whereIn(relation.secondKey as never, allLinks) + .when(relation.withClosure, relation.withClosure) + .findMany() + : [] + + const linkToFinals = new Map() + + for (const f of finals) { + const arr = linkToFinals.get(f[relation.secondKey]) || [] + + arr.push(f) + linkToFinals.set(f[relation.secondKey], arr) + } + + return models.map(m => { + const links = parentToLinks.get(m[relation.localKey]) || [] + const collected: any[] = [] + const seen = new Set() + + for (const link of links) { + const matches = linkToFinals.get(link) || [] + + for (const f of matches) { + const pk = f[finalPK] + + if (seen.has(pk)) { + continue + } + + seen.add(pk) + collected.push(f) + } + } + + m[relation.property] = collected + + return m + }) + } + + /** + * Apply a where has relation to the query when the given model + * has many through relations. + */ + public static whereHas( + Model: typeof BaseModel, + query: Driver, + relation: HasManyThroughOptions + ) { + this.options(Model, relation) + + const Through = relation.through() + const Final = relation.model() + + const modelTable = Model.table() + const throughTable = Through.table() + const finalTable = Final.table() + + const modelLocal = + Model.schema().getColumnNameByProperty(relation.localKey) || + Model.schema().getMainPrimaryKeyName() + const throughFK = + Through.schema().getColumnNameByProperty(relation.firstKey) || + Through.schema().getColumnNameByProperty( + `${String.toCamelCase(Model.name)}Id` + ) + const throughLink = Through.schema().getColumnNameByProperty( + relation.secondLocalKey + ) + const finalLink = Final.schema().getColumnNameByProperty(relation.secondKey) + + let outerWhereRaw = `${throughTable}.${throughFK} = ${modelTable}.${modelLocal}` + + switch (Through.schema().getModelDriverName()) { + case 'sqlite': + case 'postgres': + outerWhereRaw = `"${throughTable}"."${throughFK}" = "${modelTable}"."${modelLocal}"` + } + + Through.query() + .setDriver(query, throughTable) + .whereRaw(outerWhereRaw) + .whereExists(innerQuery => { + let innerWhereRaw = `${finalTable}.${finalLink} = ${throughTable}.${throughLink}` + + switch (Final.schema().getModelDriverName()) { + case 'sqlite': + case 'postgres': + innerWhereRaw = `"${finalTable}"."${finalLink}" = "${throughTable}"."${throughLink}"` + } + + Final.query() + .setDriver(innerQuery, finalTable) + .whereRaw(innerWhereRaw) + .when(relation.closure, relation.closure) + }) + } +} diff --git a/src/models/relations/HasOneThrough/HasOneThroughRelation.ts b/src/models/relations/HasOneThrough/HasOneThroughRelation.ts new file mode 100644 index 00000000..6fea71f9 --- /dev/null +++ b/src/models/relations/HasOneThrough/HasOneThroughRelation.ts @@ -0,0 +1,64 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { BaseModel } from '#src/models/BaseModel' +import type { HasOneThroughOptions } from '#src/types' +import type { Driver } from '#src/database/drivers/Driver' +import { HasManyThroughRelation } from '#src/models/relations/HasManyThrough/HasManyThroughRelation' + +export class HasOneThroughRelation { + /** + * Load a has one through relation. + */ + public static async load( + model: BaseModel, + relation: HasOneThroughOptions + ): Promise { + await HasManyThroughRelation.load(model, relation as any) + + const arr = (model[relation.property] as any[]) || [] + + model[relation.property] = arr[0] ?? null + + return model + } + + /** + * Load all models that has one through relation. + */ + public static async loadAll( + models: BaseModel[], + relation: HasOneThroughOptions + ): Promise { + const result = await HasManyThroughRelation.loadAll( + models, + relation as any + ) + + return result.map(m => { + const arr = (m[relation.property] as any[]) || [] + + m[relation.property] = arr[0] ?? null + + return m + }) + } + + /** + * Apply a where has relation to the query when the given model + * has one through relation. + */ + public static whereHas( + Model: typeof BaseModel, + query: Driver, + relation: HasOneThroughOptions + ) { + return HasManyThroughRelation.whereHas(Model, query, relation as any) + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 6b1b6d35..7c2c7df3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,6 +18,8 @@ export * from '#src/types/columns/ColumnOptions' export * from '#src/types/relations/HasOneOptions' export * from '#src/types/relations/HasManyOptions' +export * from '#src/types/relations/HasOneThroughOptions' +export * from '#src/types/relations/HasManyThroughOptions' export * from '#src/types/relations/BelongsToOptions' export * from '#src/types/relations/BelongsToManyOptions' export * from '#src/types/relations/RelationOptions' diff --git a/src/types/relations/HasManyThroughOptions.ts b/src/types/relations/HasManyThroughOptions.ts new file mode 100644 index 00000000..ba69051d --- /dev/null +++ b/src/types/relations/HasManyThroughOptions.ts @@ -0,0 +1,130 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { ModelColumns } from '#src/types' +import type { BaseModel } from '#src/models/BaseModel' +import type { ModelQueryBuilder } from '#src/models/builders/ModelQueryBuilder' + +export type HasManyThroughOptions< + T extends BaseModel = any, + R extends BaseModel = any, + H extends BaseModel = any +> = { + /** + * The relation option type. + * + * @readonly + * @default 'hasManyThrough' + */ + type?: 'hasManyThrough' + + /** + * The closure that should be executed while + * querying the relation data from database. + * + * Used by `whereHas()` for the WHERE EXISTS subquery. + * + * @default undefined + */ + closure?: (query: ModelQueryBuilder) => any + + /** + * The closure provided to `with()` for eager loading. + * Kept separate from {@link closure} so that a `whereHas()` call + * on the same relation never overwrites the eager-load filter. + * + * @default undefined + */ + withClosure?: (query: ModelQueryBuilder) => any + + /** + * The property name in class of the relation. + * + * @readonly + * @default key + */ + property?: string + + /** + * The final relation model that is being referenced + * through the intermediate model. + * + * @readonly + */ + model?: () => typeof BaseModel + + /** + * The intermediate model used to traverse from the + * parent model to the final relation model. + * + * @readonly + */ + through?: () => typeof BaseModel + + /** + * Set if the model will be included when fetching + * data. + * If this option is true, you don't need to call + * methods like `with()` to eager load your relation. + * + * @default false + */ + isIncluded?: boolean + + /** + * Internal flag when `whereHas()` applies a constraint on this relation. + * Does not eager-load; use {@link isIncluded} / `with()` to load related rows. + * + * @default false + */ + isWhereHasIncluded?: boolean + + /** + * The column on the parent model that links to the + * intermediate model via {@link firstKey}. + * + * @default Model.schema().getMainPrimaryKeyProperty() + */ + localKey?: ModelColumns + + /** + * The column on the intermediate model that points + * back at the parent model. + * + * @default `${String.toCamelCase(Model.name)}Id` + */ + firstKey?: ModelColumns + + /** + * The column on the intermediate model that participates + * in the link to the final model. + * + * - Shape A (default): `Through.schema().getMainPrimaryKeyProperty()` + * - Shape B (`inverse: true`): `${String.toCamelCase(Final.name)}Id` + */ + secondLocalKey?: ModelColumns + + /** + * The column on the final model that participates + * in the link to the intermediate model. + * + * - Shape A (default): `${String.toCamelCase(Through.name)}Id` + * - Shape B (`inverse: true`): `Final.schema().getMainPrimaryKeyProperty()` + */ + secondKey?: ModelColumns + + /** + * Flip the defaults so the foreign key linking the + * intermediate model to the final model lives on the + * intermediate model (shape B). + * + * @default false + */ + inverse?: boolean +} diff --git a/src/types/relations/HasOneThroughOptions.ts b/src/types/relations/HasOneThroughOptions.ts new file mode 100644 index 00000000..c7b0481a --- /dev/null +++ b/src/types/relations/HasOneThroughOptions.ts @@ -0,0 +1,130 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { ModelColumns } from '#src/types' +import type { BaseModel } from '#src/models/BaseModel' +import type { ModelQueryBuilder } from '#src/models/builders/ModelQueryBuilder' + +export type HasOneThroughOptions< + T extends BaseModel = any, + R extends BaseModel = any, + H extends BaseModel = any +> = { + /** + * The relation option type. + * + * @readonly + * @default 'hasOneThrough' + */ + type?: 'hasOneThrough' + + /** + * The closure that should be executed while + * querying the relation data from database. + * + * Used by `whereHas()` for the WHERE EXISTS subquery. + * + * @default undefined + */ + closure?: (query: ModelQueryBuilder) => any + + /** + * The closure provided to `with()` for eager loading. + * Kept separate from {@link closure} so that a `whereHas()` call + * on the same relation never overwrites the eager-load filter. + * + * @default undefined + */ + withClosure?: (query: ModelQueryBuilder) => any + + /** + * The property name in class of the relation. + * + * @readonly + * @default key + */ + property?: string + + /** + * The final relation model that is being referenced + * through the intermediate model. + * + * @readonly + */ + model?: () => typeof BaseModel + + /** + * The intermediate model used to traverse from the + * parent model to the final relation model. + * + * @readonly + */ + through?: () => typeof BaseModel + + /** + * Set if the model will be included when fetching + * data. + * If this option is true, you don't need to call + * methods like `with()` to eager load your relation. + * + * @default false + */ + isIncluded?: boolean + + /** + * Internal flag when `whereHas()` applies a constraint on this relation. + * Does not eager-load; use {@link isIncluded} / `with()` to load related rows. + * + * @default false + */ + isWhereHasIncluded?: boolean + + /** + * The column on the parent model that links to the + * intermediate model via {@link firstKey}. + * + * @default Model.schema().getMainPrimaryKeyProperty() + */ + localKey?: ModelColumns + + /** + * The column on the intermediate model that points + * back at the parent model. + * + * @default `${String.toCamelCase(Model.name)}Id` + */ + firstKey?: ModelColumns + + /** + * The column on the intermediate model that participates + * in the link to the final model. + * + * - Shape A (default): `Through.schema().getMainPrimaryKeyProperty()` + * - Shape B (`inverse: true`): `${String.toCamelCase(Final.name)}Id` + */ + secondLocalKey?: ModelColumns + + /** + * The column on the final model that participates + * in the link to the intermediate model. + * + * - Shape A (default): `${String.toCamelCase(Through.name)}Id` + * - Shape B (`inverse: true`): `Final.schema().getMainPrimaryKeyProperty()` + */ + secondKey?: ModelColumns + + /** + * Flip the defaults so the foreign key linking the + * intermediate model to the final model lives on the + * intermediate model (shape B). + * + * @default false + */ + inverse?: boolean +} diff --git a/src/types/relations/RelationOptions.ts b/src/types/relations/RelationOptions.ts index 55cd8db4..4e5ca362 100644 --- a/src/types/relations/RelationOptions.ts +++ b/src/types/relations/RelationOptions.ts @@ -9,11 +9,15 @@ import type { HasOneOptions } from '#src/types/relations/HasOneOptions' import type { HasManyOptions } from '#src/types/relations/HasManyOptions' +import type { HasOneThroughOptions } from '#src/types/relations/HasOneThroughOptions' +import type { HasManyThroughOptions } from '#src/types/relations/HasManyThroughOptions' import type { BelongsToOptions } from '#src/types/relations/BelongsToOptions' import type { BelongsToManyOptions } from '#src/types/relations/BelongsToManyOptions' export type RelationOptions = | HasOneOptions | HasManyOptions + | HasOneThroughOptions + | HasManyThroughOptions | BelongsToOptions | BelongsToManyOptions diff --git a/tests/fixtures/migrations/2022_10_08_0000020_create_through_tables_postgres.ts b/tests/fixtures/migrations/2022_10_08_0000020_create_through_tables_postgres.ts new file mode 100644 index 00000000..576836b2 --- /dev/null +++ b/tests/fixtures/migrations/2022_10_08_0000020_create_through_tables_postgres.ts @@ -0,0 +1,65 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { DatabaseImpl, BaseMigration } from '#src' + +export class ThroughTablesMigration extends BaseMigration { + public static connection() { + return 'postgres-docker' + } + + public async up(db: DatabaseImpl) { + await db.createTable('regions', table => { + table.increments('id') + table.string('name') + }) + + await db.createTable('members', table => { + table.increments('id') + table.string('name') + table.integer('regionId').unsigned().references('id').inTable('regions') + }) + + await db.createTable('articles', table => { + table.increments('id') + table.string('title') + table.integer('memberId').unsigned().references('id').inTable('members') + }) + + await db.createTable('appointments', table => { + table.increments('id') + table.string('name') + }) + + await db.createTable('sales', table => { + table.increments('id') + table.integer('total') + }) + + await db.createTable('sale_items', table => { + table.increments('id') + table.integer('quantity').defaultTo(1) + table + .integer('appointmentId') + .unsigned() + .references('id') + .inTable('appointments') + table.integer('saleId').unsigned().references('id').inTable('sales') + }) + } + + public async down(db: DatabaseImpl) { + await db.dropTable('sale_items') + await db.dropTable('sales') + await db.dropTable('appointments') + await db.dropTable('articles') + await db.dropTable('members') + await db.dropTable('regions') + } +} diff --git a/tests/fixtures/models/e2e/Appointment.ts b/tests/fixtures/models/e2e/Appointment.ts new file mode 100644 index 00000000..2ee7d868 --- /dev/null +++ b/tests/fixtures/models/e2e/Appointment.ts @@ -0,0 +1,38 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Relation } from '#src/types' +import { BaseModel } from '#src/models/BaseModel' +import { Column } from '#src/models/annotations/Column' +import { HasMany } from '#src/models/annotations/HasMany' +import { Sale } from '#tests/fixtures/models/e2e/Sale' +import { SaleItem } from '#tests/fixtures/models/e2e/SaleItem' +import { HasOneThrough } from '#src/models/annotations/HasOneThrough' +import { HasManyThrough } from '#src/models/annotations/HasManyThrough' + +export class Appointment extends BaseModel { + public static connection() { + return 'postgres-docker' + } + + @Column() + public id: number + + @Column() + public name: string + + @HasMany(() => SaleItem) + public saleItems: Relation + + @HasOneThrough(() => Sale, () => SaleItem, { inverse: true }) + public sale: Relation + + @HasManyThrough(() => Sale, () => SaleItem, { inverse: true }) + public sales: Relation +} diff --git a/tests/fixtures/models/e2e/Article.ts b/tests/fixtures/models/e2e/Article.ts new file mode 100644 index 00000000..0769c9b6 --- /dev/null +++ b/tests/fixtures/models/e2e/Article.ts @@ -0,0 +1,26 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { BaseModel } from '#src/models/BaseModel' +import { Column } from '#src/models/annotations/Column' + +export class Article extends BaseModel { + public static connection() { + return 'postgres-docker' + } + + @Column() + public id: number + + @Column() + public title: string + + @Column() + public memberId: number +} diff --git a/tests/fixtures/models/e2e/Member.ts b/tests/fixtures/models/e2e/Member.ts new file mode 100644 index 00000000..c027ff40 --- /dev/null +++ b/tests/fixtures/models/e2e/Member.ts @@ -0,0 +1,32 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Relation } from '#src/types' +import { BaseModel } from '#src/models/BaseModel' +import { Column } from '#src/models/annotations/Column' +import { HasMany } from '#src/models/annotations/HasMany' +import { Article } from '#tests/fixtures/models/e2e/Article' + +export class Member extends BaseModel { + public static connection() { + return 'postgres-docker' + } + + @Column() + public id: number + + @Column() + public name: string + + @Column() + public regionId: number + + @HasMany(() => Article) + public articles: Relation +} diff --git a/tests/fixtures/models/e2e/Region.ts b/tests/fixtures/models/e2e/Region.ts new file mode 100644 index 00000000..808b3106 --- /dev/null +++ b/tests/fixtures/models/e2e/Region.ts @@ -0,0 +1,38 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Relation } from '#src/types' +import { BaseModel } from '#src/models/BaseModel' +import { Column } from '#src/models/annotations/Column' +import { HasMany } from '#src/models/annotations/HasMany' +import { Member } from '#tests/fixtures/models/e2e/Member' +import { Article } from '#tests/fixtures/models/e2e/Article' +import { HasOneThrough } from '#src/models/annotations/HasOneThrough' +import { HasManyThrough } from '#src/models/annotations/HasManyThrough' + +export class Region extends BaseModel { + public static connection() { + return 'postgres-docker' + } + + @Column() + public id: number + + @Column() + public name: string + + @HasMany(() => Member) + public members: Relation + + @HasManyThrough(() => Article, () => Member) + public articles: Relation + + @HasOneThrough(() => Article, () => Member) + public latestArticle: Relation
+} diff --git a/tests/fixtures/models/e2e/Sale.ts b/tests/fixtures/models/e2e/Sale.ts new file mode 100644 index 00000000..d3b829e4 --- /dev/null +++ b/tests/fixtures/models/e2e/Sale.ts @@ -0,0 +1,23 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { BaseModel } from '#src/models/BaseModel' +import { Column } from '#src/models/annotations/Column' + +export class Sale extends BaseModel { + public static connection() { + return 'postgres-docker' + } + + @Column() + public id: number + + @Column() + public total: number +} diff --git a/tests/fixtures/models/e2e/SaleItem.ts b/tests/fixtures/models/e2e/SaleItem.ts new file mode 100644 index 00000000..8a038426 --- /dev/null +++ b/tests/fixtures/models/e2e/SaleItem.ts @@ -0,0 +1,29 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { BaseModel } from '#src/models/BaseModel' +import { Column } from '#src/models/annotations/Column' + +export class SaleItem extends BaseModel { + public static connection() { + return 'postgres-docker' + } + + @Column() + public id: number + + @Column() + public quantity: number + + @Column() + public appointmentId: number + + @Column() + public saleId: number +} diff --git a/tests/unit/database/migrations/MigrationSourceTest.ts b/tests/unit/database/migrations/MigrationSourceTest.ts index a972285e..8712d5aa 100644 --- a/tests/unit/database/migrations/MigrationSourceTest.ts +++ b/tests/unit/database/migrations/MigrationSourceTest.ts @@ -39,7 +39,7 @@ export default class MigrationSourceTest { const migrations = await new MigrationSource('postgres-docker').getMigrations() - assert.lengthOf(migrations, 8) + assert.lengthOf(migrations, 9) assert.deepEqual(migrations[0].name, '2022_10_08_000000_create_uuid_function_postgres.ts') } diff --git a/tests/unit/models/relations/HasManyThrough/HasManyThroughRelationTest.ts b/tests/unit/models/relations/HasManyThrough/HasManyThroughRelationTest.ts new file mode 100644 index 00000000..070ed4b8 --- /dev/null +++ b/tests/unit/models/relations/HasManyThrough/HasManyThroughRelationTest.ts @@ -0,0 +1,198 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Path } from '@athenna/common' +import { Database } from '#src/facades/Database' +import { Sale } from '#tests/fixtures/models/e2e/Sale' +import { Region } from '#tests/fixtures/models/e2e/Region' +import { Article } from '#tests/fixtures/models/e2e/Article' +import { SaleItem } from '#tests/fixtures/models/e2e/SaleItem' +import { Appointment } from '#tests/fixtures/models/e2e/Appointment' +import { DatabaseProvider } from '#src/providers/DatabaseProvider' +import { Test, type Context, BeforeEach, AfterEach, Mock } from '@athenna/test' + +export default class HasManyThroughRelationTest { + @BeforeEach() + public async beforeEach() { + await Config.loadAll(Path.fixtures('config')) + + new DatabaseProvider().register() + + const pg = Database.connection('postgres-docker') + + Mock.when(Path, 'migrations').return(Path.fixtures('migrations')) + + await pg.revertMigrations() + await pg.runMigrations() + + // Shape A — Region (1) -> Members (1,2) -> Articles (1,2,3) + await pg.table('regions').create({ id: 1, name: 'South' }) + await pg.table('regions').create({ id: 2, name: 'North' }) + await pg.table('members').createMany([ + { id: 1, name: 'lenon', regionId: 1 }, + { id: 2, name: 'txsoura', regionId: 1 }, + { id: 3, name: 'orphan', regionId: 2 } + ]) + await pg.table('articles').createMany([ + { id: 1, title: 'first', memberId: 1 }, + { id: 2, title: 'second', memberId: 1 }, + { id: 3, title: 'third', memberId: 2 } + ]) + + // Shape B — Appointment (1) -> SaleItems (1,2,3) -> Sales (1,1,2) + // Appointment (2) -> no sale_items + await pg.table('appointments').createMany([ + { id: 1, name: 'a-1' }, + { id: 2, name: 'a-2' } + ]) + await pg.table('sales').createMany([ + { id: 1, total: 100 }, + { id: 2, total: 200 } + ]) + await pg.table('sale_items').createMany([ + { id: 1, quantity: 1, appointmentId: 1, saleId: 1 }, + { id: 2, quantity: 2, appointmentId: 1, saleId: 1 }, + { id: 3, quantity: 1, appointmentId: 1, saleId: 2 } + ]) + } + + @AfterEach() + public async afterEach() { + const pg = Database.connection('postgres-docker') + + await pg.revertMigrations() + + await Database.closeAll() + Config.clear() + ioc.reconstruct() + Mock.restoreAll() + } + + @Test() + public async shouldLoadHasManyThroughShapeAUsingWith({ assert }: Context) { + const region = await Region.query().with('articles').where('id', 1).find() + + assert.instanceOf(region, Region) + assert.lengthOf(region.articles, 3) + assert.instanceOf(region.articles[0], Article) + } + + @Test() + public async shouldLoadHasManyThroughShapeAUsingLoadInstance({ assert }: Context) { + const region = await Region.query().where('id', 1).find() + + await region.load('articles') + + assert.instanceOf(region.articles[0], Article) + assert.lengthOf(region.articles, 3) + } + + @Test() + public async shouldLoadHasManyThroughShapeAUsingFindMany({ assert }: Context) { + const regions = await Region.query().with('articles').orderBy('id').findMany() + + assert.lengthOf(regions, 2) + assert.lengthOf(regions[0].articles, 3) + assert.isEmpty(regions[1].articles) + } + + @Test() + public async shouldFilterUsingWhereHasShapeA({ assert }: Context) { + const regions = await Region.query() + .whereHas('articles', query => query.where('title', 'first')) + .findMany() + + assert.lengthOf(regions, 1) + assert.deepEqual(regions[0].id, 1) + } + + @Test() + public async shouldNotMatchWhereHasShapeAWhenClosureDoesNotMatch({ assert }: Context) { + const regions = await Region.query() + .whereHas('articles', query => query.where('title', 'does-not-exist')) + .findMany() + + assert.isEmpty(regions) + } + + @Test() + public async shouldLoadHasManyThroughShapeBUsingWith({ assert }: Context) { + const appointment = await Appointment.query().with('sales').where('id', 1).find() + + assert.instanceOf(appointment, Appointment) + assert.lengthOf(appointment.sales, 2) + assert.instanceOf(appointment.sales[0], Sale) + } + + @Test() + public async shouldDeduplicateFinalsInShapeBWhenManyThroughRowsPointAtTheSameFinal({ assert }: Context) { + const appointment = await Appointment.query().with('sales').where('id', 1).find() + + const ids = appointment.sales.map(s => s.id).sort() + + assert.deepEqual(ids, [1, 2]) + } + + @Test() + public async shouldLoadHasManyThroughShapeBUsingFindMany({ assert }: Context) { + const appointments = await Appointment.query().with('sales').orderBy('id').findMany() + + assert.lengthOf(appointments, 2) + assert.lengthOf(appointments[0].sales, 2) + assert.isEmpty(appointments[1].sales) + } + + @Test() + public async shouldFilterUsingWhereHasShapeB({ assert }: Context) { + const appointments = await Appointment.query() + .whereHas('sales', query => query.where('total', 100)) + .findMany() + + assert.lengthOf(appointments, 1) + assert.deepEqual(appointments[0].id, 1) + } + + @Test() + public async shouldNotMatchWhereHasShapeBWhenClosureDoesNotMatch({ assert }: Context) { + const appointments = await Appointment.query() + .whereHas('sales', query => query.where('total', 999)) + .findMany() + + assert.isEmpty(appointments) + } + + @Test() + public async shouldRespectExplicitlyPassedKeysAndNotApplyLaravelDefaults({ assert }: Context) { + const region = await Region.query().where('id', 1).find() + + await region.load('articles', query => query.select('id', 'title', 'memberId')) + + assert.lengthOf(region.articles, 3) + assert.containSubset(region.articles[0], { id: 1, title: 'first' }) + } + + @Test() + public async shouldSupportNestedWithThroughRelations({ assert }: Context) { + const appointment = await Appointment.query() + .with('saleItems') + .where('id', 1) + .find() + + assert.lengthOf(appointment.saleItems, 3) + assert.instanceOf(appointment.saleItems[0], SaleItem) + } + + @Test() + public async shouldReturnEmptyArrayWhenParentHasNoThroughRowsHasManyThrough({ assert }: Context) { + const appointment = await Appointment.query().with('sales').where('id', 2).find() + + assert.instanceOf(appointment, Appointment) + assert.isEmpty(appointment.sales) + } +} diff --git a/tests/unit/models/relations/HasOneThrough/HasOneThroughRelationTest.ts b/tests/unit/models/relations/HasOneThrough/HasOneThroughRelationTest.ts new file mode 100644 index 00000000..3a78e9da --- /dev/null +++ b/tests/unit/models/relations/HasOneThrough/HasOneThroughRelationTest.ts @@ -0,0 +1,146 @@ +/** + * @athenna/database + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Path } from '@athenna/common' +import { Database } from '#src/facades/Database' +import { Sale } from '#tests/fixtures/models/e2e/Sale' +import { Region } from '#tests/fixtures/models/e2e/Region' +import { Article } from '#tests/fixtures/models/e2e/Article' +import { Appointment } from '#tests/fixtures/models/e2e/Appointment' +import { DatabaseProvider } from '#src/providers/DatabaseProvider' +import { Test, type Context, BeforeEach, AfterEach, Mock } from '@athenna/test' + +export default class HasOneThroughRelationTest { + @BeforeEach() + public async beforeEach() { + await Config.loadAll(Path.fixtures('config')) + + new DatabaseProvider().register() + + const pg = Database.connection('postgres-docker') + + Mock.when(Path, 'migrations').return(Path.fixtures('migrations')) + + await pg.revertMigrations() + await pg.runMigrations() + + // Shape A + await pg.table('regions').createMany([ + { id: 1, name: 'South' }, + { id: 2, name: 'North' } + ]) + await pg.table('members').createMany([ + { id: 1, name: 'lenon', regionId: 1 } + ]) + await pg.table('articles').createMany([ + { id: 1, title: 'first', memberId: 1 }, + { id: 2, title: 'second', memberId: 1 } + ]) + + // Shape B + await pg.table('appointments').createMany([ + { id: 1, name: 'a-1' }, + { id: 2, name: 'a-2' } + ]) + await pg.table('sales').create({ id: 1, total: 100 }) + await pg.table('sale_items').createMany([ + { id: 1, quantity: 1, appointmentId: 1, saleId: 1 }, + { id: 2, quantity: 2, appointmentId: 1, saleId: 1 } + ]) + } + + @AfterEach() + public async afterEach() { + const pg = Database.connection('postgres-docker') + + await pg.revertMigrations() + + await Database.closeAll() + Config.clear() + ioc.reconstruct() + Mock.restoreAll() + } + + @Test() + public async shouldLoadHasOneThroughShapeAUsingFind({ assert }: Context) { + const region = await Region.query().with('latestArticle').where('id', 1).find() + + assert.instanceOf(region, Region) + assert.instanceOf(region.latestArticle, Article) + } + + @Test() + public async shouldLoadHasOneThroughShapeAUsingFindMany({ assert }: Context) { + const regions = await Region.query().with('latestArticle').orderBy('id').findMany() + + assert.lengthOf(regions, 2) + assert.instanceOf(regions[0].latestArticle, Article) + assert.isNull(regions[1].latestArticle) + } + + @Test() + public async shouldReturnNullWhenNoThroughRowExists({ assert }: Context) { + const region = await Region.query().with('latestArticle').where('id', 2).find() + + assert.instanceOf(region, Region) + assert.isNull(region.latestArticle) + } + + @Test() + public async shouldLoadHasOneThroughShapeBUsingFind({ assert }: Context) { + const appointment = await Appointment.query().with('sale').where('id', 1).find() + + assert.instanceOf(appointment, Appointment) + assert.instanceOf(appointment.sale, Sale) + assert.deepEqual(appointment.sale.id, 1) + } + + @Test() + public async shouldDeduplicateInShapeBHasOneThroughEvenWhenManyThroughRowsPointAtSameFinal({ assert }: Context) { + const appointment = await Appointment.query().with('sale').where('id', 1).find() + + assert.instanceOf(appointment.sale, Sale) + } + + @Test() + public async shouldLoadHasOneThroughShapeBUsingFindMany({ assert }: Context) { + const appointments = await Appointment.query().with('sale').orderBy('id').findMany() + + assert.lengthOf(appointments, 2) + assert.instanceOf(appointments[0].sale, Sale) + assert.isNull(appointments[1].sale) + } + + @Test() + public async shouldReturnNullWhenAppointmentHasNoSaleItems({ assert }: Context) { + const appointment = await Appointment.query().with('sale').where('id', 2).find() + + assert.instanceOf(appointment, Appointment) + assert.isNull(appointment.sale) + } + + @Test() + public async shouldFilterUsingWhereHasShapeB({ assert }: Context) { + const appointments = await Appointment.query() + .whereHas('sale', query => query.where('total', 100)) + .findMany() + + assert.lengthOf(appointments, 1) + assert.deepEqual(appointments[0].id, 1) + } + + @Test() + public async shouldLoadFromInstanceShapeA({ assert }: Context) { + const region = await Region.query().where('id', 1).find() + + await region.load('latestArticle') + + assert.instanceOf(region.latestArticle, Article) + } +} diff --git a/tests/unit/models/schemas/ModelSchemaTest.ts b/tests/unit/models/schemas/ModelSchemaTest.ts index ab997631..7220d6a6 100644 --- a/tests/unit/models/schemas/ModelSchemaTest.ts +++ b/tests/unit/models/schemas/ModelSchemaTest.ts @@ -537,7 +537,7 @@ export default class ModelSchemaTest { public profile: Profile } - const relation = new ModelSchema(User).getRelationByProperty('profile') + const relation = new ModelSchema(User).getRelationByProperty('profile') as any assert.isFalse(relation.isIncluded) assert.isUndefined(relation.closure) @@ -557,7 +557,7 @@ export default class ModelSchemaTest { } const schema = new ModelSchema(User) - const relation = schema.includeRelation('profile') + const relation = schema.includeRelation('profile') as any assert.isTrue(relation.isIncluded) assert.isUndefined(relation.closure) @@ -579,7 +579,7 @@ export default class ModelSchemaTest { } const schema = new ModelSchema(User) - const meta = schema.relations[0] + const meta = schema.relations[0] as any assert.throws(() => schema.includeRelation('not-found'), NotImplementedRelationException) assert.isFalse(meta.isIncluded)