diff --git a/package-lock.json b/package-lock.json index 5cce906..5bd5c1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/database", - "version": "5.58.0", + "version": "5.59.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/database", - "version": "5.58.0", + "version": "5.59.0", "license": "MIT", "dependencies": { "@faker-js/faker": "^8.4.1" diff --git a/package.json b/package.json index de68a20..78a97f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/database", - "version": "5.58.0", + "version": "5.59.0", "description": "The Athenna database handler for SQL/NoSQL.", "license": "MIT", "author": "João Lenon ", diff --git a/src/database/drivers/BaseKnexDriver.ts b/src/database/drivers/BaseKnexDriver.ts index 79ca462..1df4d0a 100644 --- a/src/database/drivers/BaseKnexDriver.ts +++ b/src/database/drivers/BaseKnexDriver.ts @@ -417,15 +417,31 @@ export class BaseKnexDriver extends Driver { page = { page, limit, resourceUrl } } - const [{ count }] = await this.qb + const countQuery = this.qb .clone() .clearOrder() .clearSelect() .count({ count: '*' }) - const data = await this.offset(page.page * page.limit) - .limit(page.limit) - .findMany() + this.offset(page.page * page.limit).limit(page.limit) + + /** + * Inside a transaction both queries share a single connection, + * so they run sequentially to keep the dispatch order explicit. + * Sqlite also runs sequentially: with `:memory:` databases each + * pool connection holds a different (empty) database, so the + * queries can't be split across two connections. + */ + const isSqlite = this.client.client?.driverName?.includes('sqlite') + + if (this.client.isTransaction || isSqlite) { + const [{ count }] = await countQuery + const data = await this.findMany() + + return Exec.pagination(data, Number(count), page) + } + + const [[{ count }], data] = await Promise.all([countQuery, this.findMany()]) return Exec.pagination(data, Number(count), page) } diff --git a/src/database/drivers/MongoDriver.ts b/src/database/drivers/MongoDriver.ts index b004788..6a70e3f 100644 --- a/src/database/drivers/MongoDriver.ts +++ b/src/database/drivers/MongoDriver.ts @@ -648,17 +648,28 @@ export class MongoDriver extends Driver { pipeline.push({ $group: { [this.primaryKey]: null, count: { $sum: 1 } } }) pipeline.push({ $project: { [this.primaryKey]: 0, count: 1 } }) - const result = await this.qb - .aggregate(pipeline, { session: this.session }) - .toArray() + this.offset(page.page * page.limit).limit(page.limit) - const count = result[0]?.count || 0 + /** + * MongoDB sessions don't support concurrent operations, + * so inside a transaction the queries run sequentially. + */ + if (this.session) { + const result = await this.qb + .aggregate(pipeline, { session: this.session }) + .toArray() - const data = await this.offset(page.page * page.limit) - .limit(page.limit) - .findMany() + const data = await this.findMany() + + return Exec.pagination(data, result[0]?.count || 0, page) + } + + const [result, data] = await Promise.all([ + this.qb.aggregate(pipeline).toArray(), + this.findMany() + ]) - return Exec.pagination(data, count, page) + return Exec.pagination(data, result[0]?.count || 0, page) } /** diff --git a/src/helpers/Annotation.ts b/src/helpers/Annotation.ts index 569ee99..1b38311 100644 --- a/src/helpers/Annotation.ts +++ b/src/helpers/Annotation.ts @@ -32,6 +32,48 @@ import type { } from '#src/types' export class Annotation { + /** + * Cache of the parsed metadata per model class. Reading and + * merging the reflect-metadata entries on every `Model.schema()` + * call is expensive, and the metadata only changes when a + * `define*Meta()` method runs, which invalidates the entry. + */ + private static metaCache = new WeakMap< + any, + { + columns: ColumnOptions[] + relations: RelationOptions[] + hooks: ModelHookOptions[] + } + >() + + /** + * Get the columns, relations and hooks metadata of the model, + * cached per model class. + * + * The returned arrays and objects are shared: callers that need + * to mutate them (per-query state) must copy them first. + */ + public static getMeta(target: any) { + let meta = this.metaCache.get(target) + + if (!meta) { + meta = { + columns: this.getColumnsMeta(target), + relations: this.getRelationsMeta(target), + hooks: this.getHooksMeta(target) + } + + this.metaCache.set(target, meta) + } + + return meta + } + + private static invalidateMeta(target: any) { + this.metaCache.delete(target) + } + public static getColumnsMeta(target: any): ColumnOptions[] { return Reflect.getMetadata(COLUMNS_KEY, target) || [] } @@ -42,6 +84,7 @@ export class Annotation { columns.push(options) Reflect.defineMetadata(COLUMNS_KEY, columns, target) + this.invalidateMeta(target) } /** @@ -74,6 +117,7 @@ export class Annotation { hooks.push(options) Reflect.defineMetadata(HOOKS_KEY, hooks, target) + this.invalidateMeta(target) } public static getRelationsMeta(target: any): RelationOptions[] { @@ -97,6 +141,7 @@ export class Annotation { hasOne.push(options) Reflect.defineMetadata(HAS_ONE_KEY, hasOne, target) + this.invalidateMeta(target) } public static getHasManyMeta(target: any): HasManyOptions[] { @@ -109,6 +154,7 @@ export class Annotation { hasMany.push(options) Reflect.defineMetadata(HAS_MANY_KEY, hasMany, target) + this.invalidateMeta(target) } public static getHasOneThroughMeta(target: any): HasOneThroughOptions[] { @@ -124,6 +170,7 @@ export class Annotation { hasOneThrough.push(options) Reflect.defineMetadata(HAS_ONE_THROUGH_KEY, hasOneThrough, target) + this.invalidateMeta(target) } public static getHasManyThroughMeta(target: any): HasManyThroughOptions[] { @@ -140,6 +187,7 @@ export class Annotation { hasManyThrough.push(options) Reflect.defineMetadata(HAS_MANY_THROUGH_KEY, hasManyThrough, target) + this.invalidateMeta(target) } public static getBelongsToMeta(target: any): BelongsToOptions[] { @@ -152,6 +200,7 @@ export class Annotation { belongsTo.push(options) Reflect.defineMetadata(BELONGS_TO_KEY, belongsTo, target) + this.invalidateMeta(target) } public static getBelongsToManyMeta(target: any): BelongsToManyOptions[] { @@ -167,5 +216,6 @@ export class Annotation { belongsToMany.push(options) Reflect.defineMetadata(BELONGS_TO_MANY_KEY, belongsToMany, target) + this.invalidateMeta(target) } } diff --git a/src/models/BaseModel.ts b/src/models/BaseModel.ts index 0992fe0..0d929e9 100644 --- a/src/models/BaseModel.ts +++ b/src/models/BaseModel.ts @@ -536,14 +536,17 @@ export class BaseModel { private [ORIGINAL_SYMBOL]?: Record /** - * Set the original model values by deep copying - * the model state. + * Set the original model values by copying the model state. + * + * Loaded relations are skipped before any copy happens and + * primitives are stored by value: only object values (json + * columns, dates) need a deep copy to keep the snapshot + * decoupled from in-place mutations. */ public setOriginal() { this[ORIGINAL_SYMBOL] = {} - const copied = Json.copy(this) - Object.keys(copied).forEach(key => { + Object.keys(this).forEach(key => { const value = this[key] if ( @@ -558,7 +561,8 @@ export class BaseModel { return } - this[ORIGINAL_SYMBOL][key] = copied[key] + this[ORIGINAL_SYMBOL][key] = + typeof value === 'object' && value !== null ? Json.copy(value) : value }) return this diff --git a/src/models/builders/ModelQueryBuilder.ts b/src/models/builders/ModelQueryBuilder.ts index 163d678..a74004c 100644 --- a/src/models/builders/ModelQueryBuilder.ts +++ b/src/models/builders/ModelQueryBuilder.ts @@ -611,7 +611,7 @@ export class ModelQueryBuilder< parsed[updatedAt.name] = date } - await this.validateUnique(parsed, true) + await this.validateUnique(parsed) const updated = await super.update(parsed) @@ -1587,43 +1587,37 @@ export class ModelQueryBuilder< /** * Verify that columns with isUnique property * can be created in database. + * + * One `exists()` per unique column, all running concurrently. + * The old update path (`findMany().length > 1`, falling through + * to `exists()`) flagged a conflict in exactly the same cases — + * whenever the value exists in any row — while also hydrating + * full models for nothing. */ - private async validateUnique(data: any, isUpdate = false) { + private async validateUnique(data: any) { if (!this.isToValidateUnique) { return } const records = {} + const columns = this.schema + .getAllUniqueColumns() + .filter(column => data[column.name] !== undefined) - for (const column of this.schema.getAllUniqueColumns()) { - const value = data[column.name] - - if (value === undefined) { - continue - } + await Promise.all( + columns.map(async column => { + const value = data[column.name] - if (isUpdate) { - const data = await this.Model.query() + const isDuplicated = await this.Model.query() .withoutHooks() .where(column.name as never, value) - .findMany() + .exists() - if (data.length > 1) { + if (isDuplicated) { records[column.property] = value - - continue } - } - - const isDuplicated = await this.Model.query() - .withoutHooks() - .where(column.name as never, value) - .exists() - - if (isDuplicated) { - records[column.property] = value - } - } + }) + ) if (!Is.Empty(records)) { throw new UniqueValueException(records) diff --git a/src/models/factories/ModelGenerator.ts b/src/models/factories/ModelGenerator.ts index d1f9f7e..a7811c7 100644 --- a/src/models/factories/ModelGenerator.ts +++ b/src/models/factories/ModelGenerator.ts @@ -61,7 +61,7 @@ export class ModelGenerator extends Macroable { return [] } - const models = await Promise.all(data.map(d => this.instantiateOne(d))) + const models = data.map(d => this.instantiateOne(d)) return this.includeRelationsOfAll(models) } @@ -122,6 +122,9 @@ export class ModelGenerator extends Macroable { /** * Include all relations to one model. + * + * Relations load concurrently: each one queries and writes + * only its own `relation.property` on the model. */ private async includeRelations(model: M) { const relations = this.schema.getIncludedRelations() @@ -130,13 +133,9 @@ export class ModelGenerator extends Macroable { return model.setOriginal() } - for (const relation of relations) { - model = await this.includeRelation(model, relation) - } - - if (!model) { - return undefined - } + await Promise.all( + relations.map(relation => this.includeRelation(model, relation)) + ) return model.setOriginal() } @@ -168,6 +167,9 @@ export class ModelGenerator extends Macroable { /** * Include all relations for all models. + * + * Relations load concurrently: each one queries and writes + * only its own `relation.property` on the models. */ private async includeRelationsOfAll(models: M[]) { const relations = this.schema.getIncludedRelations() @@ -176,9 +178,9 @@ export class ModelGenerator extends Macroable { return models.map(model => model.setOriginal()) } - for (const relation of relations) { - models = await this.includeRelationOfAll(models, relation) - } + await Promise.all( + relations.map(relation => this.includeRelationOfAll(models, relation)) + ) return models.map(model => model.setOriginal()) } diff --git a/src/models/relations/BelongsTo/BelongsToRelation.ts b/src/models/relations/BelongsTo/BelongsToRelation.ts index e846171..424dcb7 100644 --- a/src/models/relations/BelongsTo/BelongsToRelation.ts +++ b/src/models/relations/BelongsTo/BelongsToRelation.ts @@ -55,7 +55,9 @@ export class BelongsToRelation { ): Promise { this.options(relation) - const foreignValues = models.map(model => model[relation.foreignKey]) + const foreignValues = [ + ...new Set(models.map(model => model[relation.foreignKey])) + ] const results = await relation .model() .query() diff --git a/src/models/relations/BelongsToMany/BelongsToManyRelation.ts b/src/models/relations/BelongsToMany/BelongsToManyRelation.ts index ef84aab..8fd3852 100644 --- a/src/models/relations/BelongsToMany/BelongsToManyRelation.ts +++ b/src/models/relations/BelongsToMany/BelongsToManyRelation.ts @@ -87,7 +87,9 @@ export class BelongsToManyRelation { const results = await relation .model() .query() - .whereIn(relation.relationPrimaryKey as never, relationForeignKey) + .whereIn(relation.relationPrimaryKey as never, [ + ...new Set(relationForeignKey) + ]) .when(relation.withClosure, relation.withClosure) .findMany() diff --git a/src/models/relations/HasManyThrough/HasManyThroughRelation.ts b/src/models/relations/HasManyThrough/HasManyThroughRelation.ts index 836343e..9b734ae 100644 --- a/src/models/relations/HasManyThrough/HasManyThroughRelation.ts +++ b/src/models/relations/HasManyThrough/HasManyThroughRelation.ts @@ -123,7 +123,7 @@ export class HasManyThroughRelation { ? await relation .model() .query() - .whereIn(relation.secondKey as never, allLinks) + .whereIn(relation.secondKey as never, [...new Set(allLinks)]) .when(relation.withClosure, relation.withClosure) .findMany() : [] diff --git a/src/models/schemas/ModelSchema.ts b/src/models/schemas/ModelSchema.ts index 348ce1e..946cca2 100644 --- a/src/models/schemas/ModelSchema.ts +++ b/src/models/schemas/ModelSchema.ts @@ -18,7 +18,7 @@ import type { import { Database } from '#src/facades/Database' import { Annotation } from '#src/helpers/Annotation' import type { BaseModel } from '#src/models/BaseModel' -import { Json, Options, Macroable } from '@athenna/common' +import { Options, Macroable } from '@athenna/common' import type { ModelQueryBuilder } from '#src/models/builders/ModelQueryBuilder' import { NotImplementedRelationException } from '#src/exceptions/NotImplementedRelationException' @@ -47,12 +47,42 @@ export class ModelSchema extends Macroable { */ private Model: typeof BaseModel + /** + * O(1) column lookup indexes. Rebuilt whenever a column + * name is mutated after construction (mongo `_id` case). + */ + private columnsByName: Map + private columnsByProperty: Map + public constructor(model: any) { super() this.Model = model - this.columns = Json.copy(Annotation.getColumnsMeta(model)) - this.relations = Json.copy(Annotation.getRelationsMeta(model)) - this.hooks = Annotation.getHooksMeta(model) + + /** + * Schemas carry per-query state (`isIncluded`, `withClosure`, + * the mongo `_id` rename), so each instance gets its own copy + * of the cached metadata. A shallow copy per object is enough: + * only top-level option fields are ever mutated. + */ + const meta = Annotation.getMeta(model) + + this.columns = meta.columns.map(column => ({ ...column })) + this.relations = meta.relations.map(relation => ({ ...relation })) + this.hooks = meta.hooks + this.buildColumnIndexes() + } + + /** + * Build the column lookup maps from the columns array. + */ + private buildColumnIndexes() { + this.columnsByName = new Map() + this.columnsByProperty = new Map() + + this.columns.forEach(column => { + this.columnsByName.set(column.name, column) + this.columnsByProperty.set(column.property, column) + }) } /** @@ -139,6 +169,7 @@ export class ModelSchema extends Macroable { if (options) { if (!options.hasSetName && this.getModelDriverName() === 'mongo') { options.name = '_id' + this.buildColumnIndexes() } } } @@ -287,7 +318,7 @@ export class ModelSchema extends Macroable { * Get the column options by the column database name. */ public getColumnByName(column: string | ModelColumns): ColumnOptions { - return this.columns.find(c => c.name === column) + return this.columnsByName.get(column as string) } /** @@ -316,7 +347,7 @@ export class ModelSchema extends Macroable { public getColumnByProperty( property: string | ModelColumns ): ColumnOptions { - return this.columns.find(c => c.property === property) + return this.columnsByProperty.get(property as string) } /** diff --git a/tests/performance/bench-hydration.ts b/tests/performance/bench-hydration.ts new file mode 100644 index 0000000..44b4731 --- /dev/null +++ b/tests/performance/bench-hydration.ts @@ -0,0 +1,100 @@ +/** + * Benchmark temporário da etapa 2 — hidratação de models. + * Uso: node --import=@athenna/tsconfig bench-hydration.ts + */ +import { BaseModel } from '#src/models/BaseModel' +import { Column } from '#src/models/annotations/Column' +import { ModelGenerator } from '#src/models/factories/ModelGenerator' + +class Bench extends BaseModel { + @Column() public c0: number + @Column() public c1: number + @Column() public c2: number + @Column() public c3: number + @Column() public c4: number + @Column() public c5: number + @Column() public c6: number + @Column() public c7: number + @Column() public c8: number + @Column() public c9: number + @Column() public c10: number + @Column() public c11: number + @Column() public c12: number + @Column() public c13: number + @Column() public c14: number + @Column() public c15: number + @Column() public c16: number + @Column() public c17: number + @Column() public c18: number + @Column() public c19: number +} + +const ROWS = 5000 +const row = (i: number) => { + const r: any = {} + for (let c = 0; c < 20; c++) r[`c${c}`] = i + c + return r +} +const data = Array.from({ length: ROWS }, (_, i) => row(i)) + +// warmup +await new ModelGenerator(Bench as any, Bench.schema()).generateMany(data) + +const runs: number[] = [] +for (let i = 0; i < 5; i++) { + const gen = new ModelGenerator(Bench as any, Bench.schema()) + const start = performance.now() + await gen.generateMany(data) + runs.push(performance.now() - start) +} + +console.log( + `generateMany ${ROWS}x20: median ${runs.sort((a, b) => a - b)[2].toFixed(1)}ms | runs: ${runs.map(r => r.toFixed(1)).join(', ')}` +) + +let start = performance.now() +const schema = Bench.schema() +for (let i = 0; i < 100_000; i++) schema.getColumnByName('c19') +console.log(`getColumnByName x100k: ${(performance.now() - start).toFixed(1)}ms`) + +start = performance.now() +for (let i = 0; i < 1000; i++) Bench.schema() +console.log(`schema() x1000: ${(performance.now() - start).toFixed(1)}ms`) + +// --- benchmark direto do setOriginal --- +class Child extends BaseModel { + @Column() public id: number + @Column() public name: string +} + +class Parent extends BaseModel { + @Column() public id: number + @Column() public name: string + @Column() public meta: any + @Column() public createdAt: Date +} + +const makeParent = (i: number, children: number) => { + const p = new Parent() + p.id = i + p.name = `parent-${i}` + p.meta = { tags: ['a', 'b', 'c'], nested: { x: 1, y: 2 } } + p.createdAt = new Date() + ;(p as any).children = Array.from({ length: children }, (_, c) => { + const ch = new Child() + ch.id = c + ch.name = `child-${c}` + ch.setOriginal() + return ch + }) + return p +} + +for (const children of [0, 10]) { + const parents = Array.from({ length: 5000 }, (_, i) => makeParent(i, children)) + const start = performance.now() + parents.forEach(p => p.setOriginal()) + console.log( + `setOriginal x5000 (json+date cols, ${children} filhos carregados): ${(performance.now() - start).toFixed(1)}ms` + ) +} diff --git a/tests/unit/models/factories/ModelGeneratorTest.ts b/tests/unit/models/factories/ModelGeneratorTest.ts index 9bb0502..89fbbcd 100644 --- a/tests/unit/models/factories/ModelGeneratorTest.ts +++ b/tests/unit/models/factories/ModelGeneratorTest.ts @@ -117,11 +117,13 @@ export default class ModelGeneratorTest { @HasOne(() => Profile) public profile: Profile } - Mock.when(HasOneRelation, 'load').resolve({ - id: '1', - profile: { userId: '1' }, - setOriginal: () => ({ id: '1', profile: { userId: '1' } }) - }) + Mock.when(HasOneRelation, 'load') + .get() + .callsFake(async (model: any) => { + model.profile = { userId: '1' } + + return model + }) const schema = User.schema() schema.relations[0].isIncluded = true @@ -169,9 +171,13 @@ export default class ModelGeneratorTest { @HasOne(() => Profile) public profile: Profile } - Mock.when(HasOneRelation, 'loadAll').resolve([ - { id: '1', profile: { userId: '1' }, setOriginal: () => ({ id: '1', profile: { userId: '1' } }) } - ]) + Mock.when(HasOneRelation, 'loadAll') + .get() + .callsFake(async (models: any[]) => { + models.forEach(model => (model.profile = { userId: '1' })) + + return models + }) const schema = User.schema() schema.relations[0].isIncluded = true