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.55.0",
"version": "5.56.0",
"description": "The Athenna database handler for SQL/NoSQL.",
"license": "MIT",
"author": "João Lenon <lenon@athenna.io>",
Expand Down
17 changes: 17 additions & 0 deletions src/database/builders/QueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,23 @@ export class QueryBuilder<
return this.driver.createOrUpdate(data)
}

/**
* Create a value, doing nothing if it would violate a unique constraint.
* The current query's where clauses are used to detect the conflict.
* Returns the created value, or `null` when it already existed.
*/
public async createOrIgnore(data?: Partial<T>): Promise<T> {
return this.driver.createOrIgnore(data)
}

/**
* Find the first value matching the current query or create it, never
* throwing on a concurrent unique violation. Always returns a value.
*/
public async createOrFirst(data?: Partial<T>): Promise<T> {
return this.driver.createOrFirst(data)
}

/**
* Update data in database.
*/
Expand Down
65 changes: 59 additions & 6 deletions src/database/drivers/BaseKnexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ export class BaseKnexDriver extends Driver<Knex, Knex.QueryBuilder> {
.then(([id]) => ids.push(data[index][this.primaryKey] || id))
})

await Promise.all(promises)
await this.runTranslatingErrors(() => Promise.all(promises))

return this.whereIn(this.primaryKey, ids).findMany()
}
Expand All @@ -475,24 +475,77 @@ export class BaseKnexDriver extends Driver<Knex, Knex.QueryBuilder> {
const preparedData = this.prepareInsert(data)

if (hasValue) {
await this.qb
.where(this.primaryKey, hasValue[this.primaryKey])
.limit(1)
.update(preparedData)
await this.runTranslatingErrors(() =>
this.qb
.where(this.primaryKey, hasValue[this.primaryKey])
.limit(1)
.update(preparedData)
)

return this.where(this.primaryKey, hasValue[this.primaryKey]).find()
}

return this.create(data)
}

/**
* Create a value in database, doing nothing if it would violate a unique
* (or exclusion) constraint — `ON CONFLICT DO NOTHING`/`INSERT IGNORE`. The
* current query's where clauses act as the conflict-find predicate. Returns
* the created value, or `null` when a matching row already exists and the
* insert was skipped.
*/
public async createOrIgnore<T = any>(data: Partial<T> = {}): Promise<T> {
if (Is.Array(data)) {
throw new WrongMethodException('createOrIgnore', 'createMany')
}

const existing = await this.qb.clone().first()

if (existing) {
return null
}

const preparedData = this.prepareInsert(data)

await this.runTranslatingErrors(() =>
this.qb.clone().insert(preparedData).onConflict().ignore()
)

return this.find()
}

/**
* Find the first value matching the current query or create it. The insert
* uses `ON CONFLICT DO NOTHING` so concurrent callers never throw a unique
* violation; the matching row (created here or by a racing writer) is then
* returned. Always returns a value.
*/
public async createOrFirst<T = any>(data: Partial<T> = {}): Promise<T> {
if (Is.Array(data)) {
throw new WrongMethodException('createOrFirst', 'createMany')
}

const existing = await this.qb.clone().first()

if (!existing) {
const preparedData = this.prepareInsert(data)

await this.runTranslatingErrors(() =>
this.qb.clone().insert(preparedData).onConflict().ignore()
)
}

return this.find()
}

/**
* Update a value in database.
*/
public async update<T = any>(data: Partial<T>): Promise<T | T[]> {
const preparedData = this.prepareInsert(data)

await this.qb.clone().update(preparedData)
await this.runTranslatingErrors(() => this.qb.clone().update(preparedData))

const result = await this.findMany()

Expand Down
43 changes: 43 additions & 0 deletions src/database/drivers/Driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { EmptyValueException } from '#src/exceptions/EmptyValueException'
import type { Direction, ConnectionOptions, Operations } from '#src/types'
import { EmptyColumnException } from '#src/exceptions/EmptyColumnException'
import { NotFoundDataException } from '#src/exceptions/NotFoundDataException'
import type { ConstraintViolationException } from '#src/exceptions/ConstraintViolationException'

export abstract class Driver<Client = any, QB = any> {
/**
Expand Down Expand Up @@ -250,6 +251,36 @@ export abstract class Driver<Client = any, QB = any> {
}
}

/**
* Translate a driver-specific error into a normalized Athenna constraint
* violation exception. Each concrete driver overrides this with the error
* codes/shapes of its own database. Returns `null` when the error is not a
* recognized constraint violation, so the original error is rethrown as-is.
*/
public parseError(_error: any): ConstraintViolationException | null {
return null
}

/**
* Run a database operation translating any recognized driver error into a
* normalized Athenna constraint violation exception. Unknown errors are
* rethrown untouched. This is the single boundary where driver-specific
* error shapes are normalized, so every write goes through it.
*/
public async runTranslatingErrors<T = any>(operation: () => any): Promise<T> {
try {
return await operation()
} catch (error) {
const parsed = this.parseError(error)

if (parsed) {
throw parsed
}

throw error
}
}

/**
* Connect to database.
*/
Expand Down Expand Up @@ -526,6 +557,18 @@ export abstract class Driver<Client = any, QB = any> {
*/
public abstract createOrUpdate<T = any>(data?: Partial<T>): Promise<T>

/**
* Create data, doing nothing if it would violate a unique constraint.
* Returns the created value, or `null` when a matching row already exists.
*/
public abstract createOrIgnore<T = any>(data?: Partial<T>): Promise<T>

/**
* Find the first value matching the current query or create it, without
* throwing on a concurrent unique violation.
*/
public abstract createOrFirst<T = any>(data?: Partial<T>): Promise<T>

/**
* Update a value in database.
*/
Expand Down
34 changes: 34 additions & 0 deletions src/database/drivers/FakeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,40 @@ export class FakeDriver {
return data as T
}

/**
* Create data, doing nothing on a unique conflict.
*/
public static async createOrIgnore<T = any>(
data: Partial<T> = {}
): Promise<T> {
return data as T
}

/**
* Find the first value matching the current query or create it.
*/
public static async createOrFirst<T = any>(
data: Partial<T> = {}
): Promise<T> {
return data as T
}

/**
* Translate a driver error into a normalized constraint violation.
*/
public static parseError(): any {
return null
}

/**
* Run an operation translating any recognized driver error.
*/
public static async runTranslatingErrors<T = any>(
operation: () => any
): Promise<T> {
return operation()
}

/**
* Update a value in database.
*/
Expand Down
60 changes: 60 additions & 0 deletions src/database/drivers/MongoDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { EmptyValueException } from '#src/exceptions/EmptyValueException'
import type { ConnectionOptions, Direction, Operations } from '#src/types'
import { EmptyColumnException } from '#src/exceptions/EmptyColumnException'
import { WrongMethodException } from '#src/exceptions/WrongMethodException'
import { UniqueViolationException } from '#src/exceptions/UniqueViolationException'
import { MONGO_OPERATIONS_DICTIONARY } from '#src/constants/MongoOperationsDictionary'
import { NotConnectedDatabaseException } from '#src/exceptions/NotConnectedDatabaseException'
import { NotImplementedMethodException } from '#src/exceptions/NotImplementedMethodException'
Expand Down Expand Up @@ -719,6 +720,65 @@ export class MongoDriver extends Driver<Connection, Collection> {
return this.create(data)
}

/**
* Create a value, doing nothing if a matching document already exists.
* Returns the created value, or `null` when it already existed.
*/
public async createOrIgnore<T = any>(data: Partial<T> = {}): Promise<T> {
await this.client.asPromise()

const pipeline = this.createPipeline()

const hasValue = (
await this.qb.aggregate(pipeline, { session: this.session }).toArray()
)[0]

if (hasValue) {
return null
}

return this.runTranslatingErrors(() => this.create(data))
}

/**
* Find the first document matching the current query or create it.
*/
public async createOrFirst<T = any>(data: Partial<T> = {}): Promise<T> {
await this.client.asPromise()

const pipeline = this.createPipeline()

const hasValue = (
await this.qb.aggregate(pipeline, { session: this.session }).toArray()
)[0]

if (hasValue) {
return hasValue as T
}

return this.runTranslatingErrors(() => this.create(data))
}

/**
* Translate a MongoDB duplicate-key error (code 11000) into a normalized
* Athenna unique violation exception.
*/
public parseError(error: any) {
if (error?.code !== 11000 && error?.code !== 11001) {
return null
}

const columns = error?.keyPattern
? Object.keys(error.keyPattern)
: undefined

return new UniqueViolationException({
columns,
driver: 'mongo',
raw: error
})
}

/**
* Update a value in database.
*/
Expand Down
60 changes: 60 additions & 0 deletions src/database/drivers/MySqlDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import type { ConnectionOptions } from '#src/types/ConnectionOptions'
import { BaseKnexDriver } from '#src/database/drivers/BaseKnexDriver'
import { WrongMethodException } from '#src/exceptions/WrongMethodException'
import { EmptyColumnException } from '#src/exceptions/EmptyColumnException'
import { CheckViolationException } from '#src/exceptions/CheckViolationException'
import { UniqueViolationException } from '#src/exceptions/UniqueViolationException'
import { NotNullViolationException } from '#src/exceptions/NotNullViolationException'
import { ForeignKeyViolationException } from '#src/exceptions/ForeignKeyViolationException'

export class MySqlDriver extends BaseKnexDriver {
/**
Expand Down Expand Up @@ -290,4 +294,60 @@ export class MySqlDriver extends BaseKnexDriver {
value
}
}

/**
* Translate a MySQL error (`errno`) into a normalized Athenna constraint
* violation exception. MySQL errors are less structured than PostgreSQL,
* so the offending column/key is parsed from the message when possible.
*
* @see https://dev.mysql.com/doc/mysql-errors/en/server-error-reference.html
*/
public parseError(error: any) {
const errno = error?.errno
const code = error?.code
const message = error?.message ?? ''
const driver = 'mysql'

if (errno === 1062 || code === 'ER_DUP_ENTRY') {
const match = /for key '([^']+)'/.exec(message)

return new UniqueViolationException({
constraint: match?.[1],
driver,
raw: error
})
}

if (errno === 1048 || code === 'ER_BAD_NULL_ERROR') {
const match = /Column '([^']+)'/.exec(message)

return new NotNullViolationException({
column: match?.[1],
driver,
raw: error
})
}

if (errno === 1451 || errno === 1452) {
const match = /CONSTRAINT `([^`]+)`/.exec(message)

return new ForeignKeyViolationException({
constraint: match?.[1],
driver,
raw: error
})
}

if (errno === 3819 || code === 'ER_CHECK_CONSTRAINT_VIOLATED') {
const match = /constraint '([^']+)'/.exec(message)

return new CheckViolationException({
constraint: match?.[1],
driver,
raw: error
})
}

return null
}
}
Loading
Loading