diff --git a/package-lock.json b/package-lock.json index 2164932..d807a2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/database", - "version": "5.55.0", + "version": "5.56.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/database", - "version": "5.55.0", + "version": "5.56.0", "license": "MIT", "dependencies": { "@faker-js/faker": "^8.4.1" diff --git a/package.json b/package.json index d68e7db..0a94b08 100644 --- a/package.json +++ b/package.json @@ -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 ", diff --git a/src/database/builders/QueryBuilder.ts b/src/database/builders/QueryBuilder.ts index 161d639..37a7ec3 100644 --- a/src/database/builders/QueryBuilder.ts +++ b/src/database/builders/QueryBuilder.ts @@ -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): Promise { + 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): Promise { + return this.driver.createOrFirst(data) + } + /** * Update data in database. */ diff --git a/src/database/drivers/BaseKnexDriver.ts b/src/database/drivers/BaseKnexDriver.ts index 246ae36..79ca462 100644 --- a/src/database/drivers/BaseKnexDriver.ts +++ b/src/database/drivers/BaseKnexDriver.ts @@ -461,7 +461,7 @@ export class BaseKnexDriver extends Driver { .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() } @@ -475,10 +475,12 @@ export class BaseKnexDriver extends Driver { 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() } @@ -486,13 +488,64 @@ export class BaseKnexDriver extends Driver { 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(data: Partial = {}): Promise { + 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(data: Partial = {}): Promise { + 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(data: Partial): Promise { const preparedData = this.prepareInsert(data) - await this.qb.clone().update(preparedData) + await this.runTranslatingErrors(() => this.qb.clone().update(preparedData)) const result = await this.findMany() diff --git a/src/database/drivers/Driver.ts b/src/database/drivers/Driver.ts index dd451a9..cb3a0a7 100644 --- a/src/database/drivers/Driver.ts +++ b/src/database/drivers/Driver.ts @@ -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 { /** @@ -250,6 +251,36 @@ export abstract class Driver { } } + /** + * 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(operation: () => any): Promise { + try { + return await operation() + } catch (error) { + const parsed = this.parseError(error) + + if (parsed) { + throw parsed + } + + throw error + } + } + /** * Connect to database. */ @@ -526,6 +557,18 @@ export abstract class Driver { */ public abstract createOrUpdate(data?: Partial): Promise + /** + * 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(data?: Partial): Promise + + /** + * Find the first value matching the current query or create it, without + * throwing on a concurrent unique violation. + */ + public abstract createOrFirst(data?: Partial): Promise + /** * Update a value in database. */ diff --git a/src/database/drivers/FakeDriver.ts b/src/database/drivers/FakeDriver.ts index 6db49e3..f7586dd 100644 --- a/src/database/drivers/FakeDriver.ts +++ b/src/database/drivers/FakeDriver.ts @@ -475,6 +475,40 @@ export class FakeDriver { return data as T } + /** + * Create data, doing nothing on a unique conflict. + */ + public static async createOrIgnore( + data: Partial = {} + ): Promise { + return data as T + } + + /** + * Find the first value matching the current query or create it. + */ + public static async createOrFirst( + data: Partial = {} + ): Promise { + 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( + operation: () => any + ): Promise { + return operation() + } + /** * Update a value in database. */ diff --git a/src/database/drivers/MongoDriver.ts b/src/database/drivers/MongoDriver.ts index 8d3b68e..b004788 100644 --- a/src/database/drivers/MongoDriver.ts +++ b/src/database/drivers/MongoDriver.ts @@ -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' @@ -719,6 +720,65 @@ export class MongoDriver extends Driver { 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(data: Partial = {}): Promise { + 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(data: Partial = {}): Promise { + 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. */ diff --git a/src/database/drivers/MySqlDriver.ts b/src/database/drivers/MySqlDriver.ts index 7de1111..b297b74 100644 --- a/src/database/drivers/MySqlDriver.ts +++ b/src/database/drivers/MySqlDriver.ts @@ -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 { /** @@ -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 + } } diff --git a/src/database/drivers/PostgresDriver.ts b/src/database/drivers/PostgresDriver.ts index 7c30c15..cce2732 100644 --- a/src/database/drivers/PostgresDriver.ts +++ b/src/database/drivers/PostgresDriver.ts @@ -16,6 +16,10 @@ import { BaseKnexDriver } from '#src/database/drivers/BaseKnexDriver' import type { ConnectionOptions } from '#src/types/ConnectionOptions' 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 PostgresDriver extends BaseKnexDriver { /** @@ -273,4 +277,71 @@ export class PostgresDriver extends BaseKnexDriver { return operators[operator] || operator } + + /** + * Translate a PostgreSQL error (SQLSTATE codes) into a normalized Athenna + * constraint violation exception. + * + * @see https://www.postgresql.org/docs/current/errcodes-appendix.html + */ + public parseError(error: any) { + const code = error?.code + + if (!code) { + return null + } + + const table = error.table + const driver = 'postgres' + + /** + * Both unique and foreign key violations expose the offending columns in + * the `detail` field, e.g. `Key (avatar_id, section_id)=(...) already + * exists.` + */ + const columnsFromDetail = () => { + const match = /\(([^)]+)\)=/.exec(error.detail ?? '') + + if (!match) { + return undefined + } + + return match[1].split(',').map(column => column.trim()) + } + + switch (code) { + case '23505': + return new UniqueViolationException({ + table, + constraint: error.constraint, + columns: columnsFromDetail(), + driver, + raw: error + }) + case '23502': + return new NotNullViolationException({ + table, + column: error.column, + driver, + raw: error + }) + case '23503': + return new ForeignKeyViolationException({ + table, + constraint: error.constraint, + column: columnsFromDetail()?.[0], + driver, + raw: error + }) + case '23514': + return new CheckViolationException({ + table, + constraint: error.constraint, + driver, + raw: error + }) + default: + return null + } + } } diff --git a/src/database/drivers/SqliteDriver.ts b/src/database/drivers/SqliteDriver.ts index 0e8e761..29f1c6a 100644 --- a/src/database/drivers/SqliteDriver.ts +++ b/src/database/drivers/SqliteDriver.ts @@ -16,6 +16,10 @@ import { BaseKnexDriver } from '#src/database/drivers/BaseKnexDriver' import type { ConnectionOptions } from '#src/types/ConnectionOptions' 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 SqliteDriver extends BaseKnexDriver { /** @@ -354,4 +358,86 @@ export class SqliteDriver extends BaseKnexDriver { return this } + + /** + * Translate a SQLite error into a normalized Athenna constraint violation + * exception. SQLite exposes extended result codes (e.g. + * `SQLITE_CONSTRAINT_UNIQUE`) and a message like + * `UNIQUE constraint failed: table.column`. + * + * @see https://www.sqlite.org/rescode.html + */ + public parseError(error: any) { + const code = error?.code ?? '' + const message = error?.message ?? '' + const driver = 'sqlite' + + /** + * Parses `table.column[, table.column]` lists out of the failure message. + */ + const parseColumns = () => { + const match = /constraint failed:\s*(.+)$/i.exec(message) + + if (!match) { + return { table: undefined, columns: undefined } + } + + const refs = match[1].split(',').map(ref => ref.trim()) + const columns = refs.map(ref => ref.split('.').pop()) + const table = refs[0]?.includes('.') ? refs[0].split('.')[0] : undefined + + return { table, columns } + } + + if ( + code === 'SQLITE_CONSTRAINT_UNIQUE' || + code === 'SQLITE_CONSTRAINT_PRIMARYKEY' || + /UNIQUE constraint failed/i.test(message) + ) { + const { table, columns } = parseColumns() + + return new UniqueViolationException({ + table, + columns, + driver, + raw: error + }) + } + + if ( + code === 'SQLITE_CONSTRAINT_NOTNULL' || + /NOT NULL constraint failed/i.test(message) + ) { + const { table, columns } = parseColumns() + + return new NotNullViolationException({ + table, + column: columns?.[0], + driver, + raw: error + }) + } + + if ( + code === 'SQLITE_CONSTRAINT_FOREIGNKEY' || + /FOREIGN KEY constraint failed/i.test(message) + ) { + return new ForeignKeyViolationException({ driver, raw: error }) + } + + if ( + code === 'SQLITE_CONSTRAINT_CHECK' || + /CHECK constraint failed/i.test(message) + ) { + const match = /CHECK constraint failed:\s*(.+)$/i.exec(message) + + return new CheckViolationException({ + constraint: match?.[1]?.trim(), + driver, + raw: error + }) + } + + return null + } } diff --git a/src/exceptions/CheckViolationException.ts b/src/exceptions/CheckViolationException.ts new file mode 100644 index 0000000..2851fac --- /dev/null +++ b/src/exceptions/CheckViolationException.ts @@ -0,0 +1,44 @@ +/** + * @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 { ConstraintViolationException } from '#src/exceptions/ConstraintViolationException' + +export type CheckViolationMeta = { + table?: string + constraint?: string + driver?: string + raw?: any +} + +export class CheckViolationException extends ConstraintViolationException { + /** + * The name of the violated check constraint, when known. + */ + public constraint?: string + + public constructor(meta: CheckViolationMeta = {}) { + const { table, constraint, driver, raw } = meta + + super({ + status: 422, + code: 'E_CHECK_VIOLATION', + message: `Check constraint${ + constraint ? ` "${constraint}"` : '' + } violated${table ? ` on table "${table}"` : ''}.`, + help: `The persisted value does not satisfy the check constraint ${ + constraint ? `"${constraint}"` : '' + }. Provide a value that respects the constraint definition.` + }) + + this.table = table + this.constraint = constraint + this.driver = driver + this.raw = raw + } +} diff --git a/src/exceptions/ConstraintViolationException.ts b/src/exceptions/ConstraintViolationException.ts new file mode 100644 index 0000000..d45bad8 --- /dev/null +++ b/src/exceptions/ConstraintViolationException.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 { Exception } from '@athenna/common' + +/** + * Base class for all database constraint violations that are translated from + * a driver-specific error into a normalized Athenna exception. Catch this to + * handle any constraint violation regardless of the underlying database. + */ +export class ConstraintViolationException extends Exception { + /** + * The table where the violation happened, when the driver exposes it. + */ + public table?: string + + /** + * The driver that produced the original error (e.g. `postgres`). + */ + public driver?: string + + /** + * The original, untranslated error thrown by the database client. + */ + public raw?: any +} diff --git a/src/exceptions/ForeignKeyViolationException.ts b/src/exceptions/ForeignKeyViolationException.ts new file mode 100644 index 0000000..c27a9e5 --- /dev/null +++ b/src/exceptions/ForeignKeyViolationException.ts @@ -0,0 +1,51 @@ +/** + * @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 { ConstraintViolationException } from '#src/exceptions/ConstraintViolationException' + +export type ForeignKeyViolationMeta = { + table?: string + column?: string + constraint?: string + driver?: string + raw?: any +} + +export class ForeignKeyViolationException extends ConstraintViolationException { + /** + * The referencing column, when known. + */ + public column?: string + + /** + * The name of the violated foreign key constraint, when known. + */ + public constraint?: string + + public constructor(meta: ForeignKeyViolationMeta = {}) { + const { table, column, constraint, driver, raw } = meta + + super({ + status: 409, + code: 'E_FOREIGN_KEY_VIOLATION', + message: `Foreign key constraint${ + constraint ? ` "${constraint}"` : '' + } violated${table ? ` on table "${table}"` : ''}${ + column ? ` for column "${column}"` : '' + }.`, + help: `The referenced record does not exist or is still referenced by other records. Ensure the related row exists before persisting and is not deleted while referenced.` + }) + + this.table = table + this.column = column + this.constraint = constraint + this.driver = driver + this.raw = raw + } +} diff --git a/src/exceptions/NotNullViolationException.ts b/src/exceptions/NotNullViolationException.ts new file mode 100644 index 0000000..42c1536 --- /dev/null +++ b/src/exceptions/NotNullViolationException.ts @@ -0,0 +1,44 @@ +/** + * @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 { ConstraintViolationException } from '#src/exceptions/ConstraintViolationException' + +export type NotNullViolationMeta = { + table?: string + column?: string + driver?: string + raw?: any +} + +export class NotNullViolationException extends ConstraintViolationException { + /** + * The column that received a null value, when known. + */ + public column?: string + + public constructor(meta: NotNullViolationMeta = {}) { + const { table, column, driver, raw } = meta + + super({ + status: 422, + code: 'E_NOT_NULL_VIOLATION', + message: `Not-null constraint violated${ + column ? ` for column "${column}"` : '' + }${table ? ` on table "${table}"` : ''}.`, + help: `The column ${ + column ? `"${column}"` : '' + } does not accept null values. Provide a value for it before persisting.` + }) + + this.table = table + this.column = column + this.driver = driver + this.raw = raw + } +} diff --git a/src/exceptions/UniqueViolationException.ts b/src/exceptions/UniqueViolationException.ts new file mode 100644 index 0000000..cd2e5d4 --- /dev/null +++ b/src/exceptions/UniqueViolationException.ts @@ -0,0 +1,54 @@ +/** + * @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 { ConstraintViolationException } from '#src/exceptions/ConstraintViolationException' + +export type UniqueViolationMeta = { + table?: string + columns?: string[] + constraint?: string + driver?: string + raw?: any +} + +export class UniqueViolationException extends ConstraintViolationException { + /** + * The column(s) that make up the violated unique key, when known. + */ + public columns?: string[] + + /** + * The name of the violated unique constraint/index, when known. + */ + public constraint?: string + + public constructor(meta: UniqueViolationMeta = {}) { + const { table, columns, constraint, driver, raw } = meta + const cols = columns?.length ? columns.join(', ') : null + + super({ + status: 409, + code: 'E_UNIQUE_VIOLATION', + message: `Unique constraint${ + constraint ? ` "${constraint}"` : '' + } violated${table ? ` on table "${table}"` : ''}${ + cols ? ` for column(s) [${cols}]` : '' + }.`, + help: `A record with the same ${ + cols ?? 'unique value' + } already exists. Use createOrIgnore()/createOrFirst() to handle the conflict gracefully, or persist a different value.` + }) + + this.table = table + this.columns = columns + this.constraint = constraint + this.driver = driver + this.raw = raw + } +} diff --git a/src/index.ts b/src/index.ts index 70864b5..93ac2a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,3 +35,9 @@ export * from '#src/helpers/ObjectId' export * from '#src/facades/Database' export * from '#src/providers/DatabaseProvider' + +export * from '#src/exceptions/ConstraintViolationException' +export * from '#src/exceptions/UniqueViolationException' +export * from '#src/exceptions/NotNullViolationException' +export * from '#src/exceptions/ForeignKeyViolationException' +export * from '#src/exceptions/CheckViolationException' diff --git a/src/models/BaseModel.ts b/src/models/BaseModel.ts index cfeea17..d760bc1 100644 --- a/src/models/BaseModel.ts +++ b/src/models/BaseModel.ts @@ -436,6 +436,45 @@ export class BaseModel { return query.createOrUpdate(data, cleanPersist) } + /** + * Create a value, doing nothing if it would violate a unique constraint. + * The `where` is used to detect the conflict. Returns the created model, or + * `null` when a matching row already exists. + */ + public static async createOrIgnore( + this: T, + where: Partial>, + data: Partial>, + cleanPersist = true + ): Promise> { + const query = this.query() + + if (where) { + query.where(where) + } + + return query.createOrIgnore(data, cleanPersist) + } + + /** + * Find the first value matching `where` or create it, never throwing on a + * concurrent unique violation. Always returns a model. + */ + public static async createOrFirst( + this: T, + where: Partial>, + data: Partial>, + cleanPersist = true + ): Promise> { + const query = this.query() + + if (where) { + query.where(where) + } + + return query.createOrFirst(data, cleanPersist) + } + /** * Update a value in database. */ diff --git a/src/models/builders/ModelQueryBuilder.ts b/src/models/builders/ModelQueryBuilder.ts index 43229ae..a020885 100644 --- a/src/models/builders/ModelQueryBuilder.ts +++ b/src/models/builders/ModelQueryBuilder.ts @@ -375,28 +375,7 @@ export class ModelQueryBuilder< public async createMany(data: Partial[], cleanPersist = true) { data = await Promise.all( data.map(async d => { - const date = new Date() - const createdAt = this.schema.getCreatedAtColumn() - const updatedAt = this.schema.getUpdatedAtColumn() - const deletedAt = this.schema.getDeletedAtColumn() - const attributes = this.isToSetAttributes ? this.Model.attributes() : {} - - const parsed = this.schema.propertiesToColumnNames(d, { - attributes, - cleanPersist - }) - - if (createdAt && parsed[createdAt.name] === undefined) { - parsed[createdAt.name] = date - } - - if (updatedAt && parsed[updatedAt.name] === undefined) { - parsed[updatedAt.name] = date - } - - if (deletedAt && parsed[deletedAt.name] === undefined) { - parsed[deletedAt.name] = null - } + const parsed = this.toPersistColumns(d, cleanPersist) this.validateNullable(parsed) await this.validateUnique(parsed) @@ -410,6 +389,38 @@ export class ModelQueryBuilder< return this.generator.generateMany(created) } + /** + * Map model properties to columns and stamp the timestamp columns, without + * running the (race-prone) unique pre-check. Used by the conflict-aware + * persistence methods that rely on the database to enforce uniqueness. + */ + private toPersistColumns(data: Partial, cleanPersist = true) { + const date = new Date() + const createdAt = this.schema.getCreatedAtColumn() + const updatedAt = this.schema.getUpdatedAtColumn() + const deletedAt = this.schema.getDeletedAtColumn() + const attributes = this.isToSetAttributes ? this.Model.attributes() : {} + + const parsed = this.schema.propertiesToColumnNames(data, { + attributes, + cleanPersist + }) + + if (createdAt && parsed[createdAt.name] === undefined) { + parsed[createdAt.name] = date + } + + if (updatedAt && parsed[updatedAt.name] === undefined) { + parsed[updatedAt.name] = date + } + + if (deletedAt && parsed[deletedAt.name] === undefined) { + parsed[deletedAt.name] = null + } + + return parsed + } + /** * Create or update a value in database. */ @@ -428,6 +439,45 @@ export class ModelQueryBuilder< return this.create(data, cleanPersist) } + /** + * Create a value, doing nothing if it would violate a unique constraint + * (`ON CONFLICT DO NOTHING`/`INSERT IGNORE`). The current query's where + * clauses detect the conflict. Returns the created model, or `null` when a + * matching row already exists. Relies on the database constraint instead of + * the race-prone model unique pre-check. + */ + public async createOrIgnore(data: Partial = {}, cleanPersist = true) { + this.setInternalQueries() + + const parsed = this.toPersistColumns(data, cleanPersist) + + this.validateNullable(parsed) + + const created = await super.createOrIgnore(parsed) + + if (!created) { + return null + } + + return this.generator.generateOne(created) + } + + /** + * Find the first value matching the current query or create it, never + * throwing on a concurrent unique violation. Always returns a model. + */ + public async createOrFirst(data: Partial = {}, cleanPersist = true) { + this.setInternalQueries() + + const parsed = this.toPersistColumns(data, cleanPersist) + + this.validateNullable(parsed) + + const value = await super.createOrFirst(parsed) + + return this.generator.generateOne(value) + } + /** * Update a value in database. */ diff --git a/tests/unit/models/relations/HasManyThrough/HasManyThroughRelationTest.ts b/tests/unit/models/relations/HasManyThrough/HasManyThroughRelationTest.ts index 070ed4b..3fc7198 100644 --- a/tests/unit/models/relations/HasManyThrough/HasManyThroughRelationTest.ts +++ b/tests/unit/models/relations/HasManyThrough/HasManyThroughRelationTest.ts @@ -179,10 +179,7 @@ export default class HasManyThroughRelationTest { @Test() public async shouldSupportNestedWithThroughRelations({ assert }: Context) { - const appointment = await Appointment.query() - .with('saleItems') - .where('id', 1) - .find() + const appointment = await Appointment.query().with('saleItems').where('id', 1).find() assert.lengthOf(appointment.saleItems, 3) assert.instanceOf(appointment.saleItems[0], SaleItem)