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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <lenon@athenna.io>",
Expand Down
24 changes: 20 additions & 4 deletions src/database/drivers/BaseKnexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,15 +417,31 @@ export class BaseKnexDriver extends Driver<Knex, Knex.QueryBuilder> {
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)
}
Expand Down
27 changes: 19 additions & 8 deletions src/database/drivers/MongoDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,17 +648,28 @@ export class MongoDriver extends Driver<Connection, Collection> {
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)
}

/**
Expand Down
50 changes: 50 additions & 0 deletions src/helpers/Annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) || []
}
Expand All @@ -42,6 +84,7 @@ export class Annotation {
columns.push(options)

Reflect.defineMetadata(COLUMNS_KEY, columns, target)
this.invalidateMeta(target)
}

/**
Expand Down Expand Up @@ -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[] {
Expand All @@ -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[] {
Expand All @@ -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[] {
Expand All @@ -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[] {
Expand All @@ -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[] {
Expand All @@ -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[] {
Expand All @@ -167,5 +216,6 @@ export class Annotation {
belongsToMany.push(options)

Reflect.defineMetadata(BELONGS_TO_MANY_KEY, belongsToMany, target)
this.invalidateMeta(target)
}
}
14 changes: 9 additions & 5 deletions src/models/BaseModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,14 +536,17 @@ export class BaseModel {
private [ORIGINAL_SYMBOL]?: Record<string, any>

/**
* 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 (
Expand All @@ -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
Expand Down
44 changes: 19 additions & 25 deletions src/models/builders/ModelQueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
24 changes: 13 additions & 11 deletions src/models/factories/ModelGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export class ModelGenerator<M extends BaseModel = any> 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)
}
Expand Down Expand Up @@ -122,6 +122,9 @@ export class ModelGenerator<M extends BaseModel = any> 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()
Expand All @@ -130,13 +133,9 @@ export class ModelGenerator<M extends BaseModel = any> 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()
}
Expand Down Expand Up @@ -168,6 +167,9 @@ export class ModelGenerator<M extends BaseModel = any> 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()
Expand All @@ -176,9 +178,9 @@ export class ModelGenerator<M extends BaseModel = any> 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())
}
Expand Down
4 changes: 3 additions & 1 deletion src/models/relations/BelongsTo/BelongsToRelation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ export class BelongsToRelation {
): Promise<any[]> {
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()
Expand Down
4 changes: 3 additions & 1 deletion src/models/relations/BelongsToMany/BelongsToManyRelation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading
Loading