forge-sql-orm
Version:
Drizzle ORM integration for Atlassian @forge/sql. Provides a custom driver, schema migration, two levels of caching (local and global via @forge/kvs), optimistic locking, and query analysis.
349 lines • 16.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ForgeSQLCrudOperations = void 0;
const drizzle_orm_1 = require("drizzle-orm");
const sqlUtils_1 = require("../utils/sqlUtils");
const cacheContextUtils_1 = require("../utils/cacheContextUtils");
/**
* Class implementing Modification operations for ForgeSQL ORM.
* Provides methods for inserting, updating, and deleting records with support for optimistic locking.
*/
class ForgeSQLCrudOperations {
forgeOperations;
options;
/**
* Creates a new instance of ForgeSQLCrudOperations.
* @param forgeSqlOperations - The ForgeSQL operations instance
* @param options - Configuration options for the ORM
*/
constructor(forgeSqlOperations, options) {
this.forgeOperations = forgeSqlOperations;
this.options = options;
}
/**
* Inserts records into the database with optional versioning support.
* If a version field exists in the schema, versioning is applied.
*
* This method automatically handles:
* - Version field initialization for optimistic locking
* - Batch insertion for multiple records
* - Duplicate key handling with optional updates
*
* @template T - The type of the table schema
* @param schema - The entity schema
* @param models - Array of entities to insert
* @param updateIfExists - Whether to update existing records (default: false)
* @returns Promise that resolves to the number of inserted rows
* @throws Error if the insert operation fails
*/
async insert(schema, models, updateIfExists = false) {
if (!models?.length)
return 0;
const { tableName, columns } = (0, sqlUtils_1.getTableMetadata)(schema);
const versionMetadata = this.validateVersionField(tableName, columns);
// Prepare models with version field if needed
const preparedModels = models.map((model) => this.prepareModelWithVersion(model, versionMetadata, columns));
// Build insert query
const queryBuilder = this.forgeOperations.insert(schema).values(preparedModels);
// Add onDuplicateKeyUpdate if needed
const finalQuery = updateIfExists
? queryBuilder.onDuplicateKeyUpdate({
set: Object.fromEntries(Object.keys(preparedModels[0]).map((key) => [key, schema[key]])),
})
: queryBuilder;
// Execute query
const result = await finalQuery;
await (0, cacheContextUtils_1.saveTableIfInsideCacheContext)(schema);
return result[0].insertId;
}
/**
* Deletes a record by its primary key with optional version check.
* If versioning is enabled, ensures the record hasn't been modified since last read.
*
* This method automatically handles:
* - Single primary key validation
* - Optimistic locking checks if versioning is enabled
* - Version field validation before deletion
*
* @template T - The type of the table schema
* @param id - The ID of the record to delete
* @param schema - The entity schema
* @returns Promise that resolves to the number of affected rows
* @throws Error if the delete operation fails
* @throws Error if multiple primary keys are found
* @throws Error if optimistic locking check fails
*/
async deleteById(id, schema) {
const { tableName, columns } = (0, sqlUtils_1.getTableMetadata)(schema);
const primaryKeys = this.getPrimaryKeys(schema);
if (primaryKeys.length !== 1) {
throw new Error("Only single primary key is supported");
}
const [primaryKeyName, primaryKeyColumn] = primaryKeys[0];
const versionMetadata = this.validateVersionField(tableName, columns);
// Build delete conditions
const conditions = [(0, drizzle_orm_1.eq)(primaryKeyColumn, id)];
// Add version check if needed
if (versionMetadata && columns) {
const versionField = columns[versionMetadata.fieldName];
if (versionField) {
const oldModel = await this.getOldModel({ [primaryKeyName]: id }, schema, [
versionMetadata.fieldName,
versionField,
]);
conditions.push((0, drizzle_orm_1.eq)(versionField, oldModel[versionMetadata.fieldName]));
}
}
// Execute delete query
const queryBuilder = this.forgeOperations.delete(schema).where((0, drizzle_orm_1.and)(...conditions));
const result = await queryBuilder;
if (versionMetadata && result[0].affectedRows === 0) {
throw new Error(`Optimistic locking failed: record with primary key ${id} has been modified`);
}
await (0, cacheContextUtils_1.saveTableIfInsideCacheContext)(schema);
return result[0].affectedRows;
}
/**
* Updates a record by its primary key with optimistic locking support.
* If versioning is enabled:
* - Retrieves the current version
* - Checks for concurrent modifications
* - Increments the version on successful update
*
* This method automatically handles:
* - Primary key validation
* - Version field retrieval and validation
* - Optimistic locking conflict detection
* - Version field incrementation
*
* @template T - The type of the table schema
* @param entity - The entity with updated values (must include primary key)
* @param schema - The entity schema
* @returns Promise that resolves to the number of affected rows
* @throws Error if the primary key is not provided
* @throws Error if optimistic locking check fails
* @throws Error if multiple primary keys are found
*/
async updateById(entity, schema) {
const { tableName, columns } = (0, sqlUtils_1.getTableMetadata)(schema);
const primaryKeys = this.getPrimaryKeys(schema);
if (primaryKeys.length !== 1) {
throw new Error("Only single primary key is supported");
}
const [primaryKeyName, primaryKeyColumn] = primaryKeys[0];
const versionMetadata = this.validateVersionField(tableName, columns);
// Validate primary key
if (!(primaryKeyName in entity)) {
throw new Error(`Primary key ${primaryKeyName} must be provided in the entity`);
}
// Get current version if needed
const currentVersion = await this.getCurrentVersion(entity, primaryKeyName, versionMetadata, columns, schema);
// Prepare update data with version
const updateData = this.prepareUpdateData(entity, versionMetadata, columns, currentVersion);
// Build update conditions
const conditions = [
(0, drizzle_orm_1.eq)(primaryKeyColumn, entity[primaryKeyName]),
];
if (versionMetadata && columns) {
const versionField = columns[versionMetadata.fieldName];
if (versionField) {
conditions.push((0, drizzle_orm_1.eq)(versionField, currentVersion));
}
}
// Execute update query
const queryBuilder = this.forgeOperations
.update(schema)
.set(updateData)
.where((0, drizzle_orm_1.and)(...conditions));
const result = await queryBuilder;
// Check optimistic locking
if (versionMetadata && result[0].affectedRows === 0) {
throw new Error(`Optimistic locking failed: record with primary key ${entity[primaryKeyName]} has been modified`);
}
await (0, cacheContextUtils_1.saveTableIfInsideCacheContext)(schema);
return result[0].affectedRows;
}
/**
* Updates specified fields of records based on provided conditions.
* This method does not support versioning and should be used with caution.
*
* @template T - The type of the table schema
* @param {Partial<InferInsertModel<T>>} updateData - The data to update
* @param {T} schema - The entity schema
* @param {SQL<unknown>} where - The WHERE conditions
* @returns {Promise<number>} Number of affected rows
* @throws {Error} If WHERE conditions are not provided
* @throws {Error} If the update operation fails
*/
async updateFields(updateData, schema, where) {
if (!where) {
throw new Error("WHERE conditions must be provided");
}
const queryBuilder = this.forgeOperations.update(schema).set(updateData).where(where);
const result = await queryBuilder;
await (0, cacheContextUtils_1.saveTableIfInsideCacheContext)(schema);
return result[0].affectedRows;
}
// Helper methods
/**
* Gets primary keys from the schema.
* @template T - The type of the table schema
* @param {T} schema - The table schema
* @returns {[string, AnyColumn][]} Array of primary key name and column pairs
* @throws {Error} If no primary keys are found
*/
getPrimaryKeys(schema) {
const primaryKeys = (0, sqlUtils_1.getPrimaryKeys)(schema);
if (!primaryKeys) {
throw new Error(`No primary keys found for schema: ${schema}`);
}
return primaryKeys;
}
/**
* Validates and retrieves version field metadata.
* @param {string} tableName - The name of the table
* @param {Record<string, AnyColumn>} columns - The table columns
* @returns {Object | undefined} Version field metadata if valid, undefined otherwise
*/
validateVersionField(tableName, columns) {
if (this.options.disableOptimisticLocking) {
return undefined;
}
const versionMetadata = this.options.additionalMetadata?.[tableName]?.versionField;
if (!versionMetadata)
return undefined;
let fieldName = versionMetadata.fieldName;
let versionField = columns[versionMetadata.fieldName];
if (!versionField) {
const find = Object.entries(columns).find(([, c]) => c.name === versionMetadata.fieldName);
if (find) {
fieldName = find[0];
versionField = find[1];
}
}
if (!versionField) {
// eslint-disable-next-line no-console
console.warn(`Version field "${versionMetadata.fieldName}" not found in table ${tableName}. Versioning will be skipped.`);
return undefined;
}
if (!versionField.notNull) {
// eslint-disable-next-line no-console
console.warn(`Version field "${versionMetadata.fieldName}" in table ${tableName} is nullable. Versioning may not work correctly.`);
return undefined;
}
const fieldType = versionField.getSQLType();
const isSupportedType = fieldType === "datetime" ||
fieldType === "timestamp" ||
fieldType === "int" ||
fieldType === "number" ||
fieldType === "decimal";
if (!isSupportedType) {
// eslint-disable-next-line no-console
console.warn(`Version field "${versionMetadata.fieldName}" in table ${tableName} has unsupported type "${fieldType}". ` +
`Only datetime, timestamp, int, and decimal types are supported for versioning. Versioning will be skipped.`);
return undefined;
}
return { fieldName, type: fieldType };
}
/**
* Gets the current version of an entity.
* @template T - The type of the table schema
* @param {Partial<InferInsertModel<T>>} entity - The entity
* @param {string} primaryKeyName - The name of the primary key
* @param {Object | undefined} versionMetadata - Version field metadata
* @param {Record<string, AnyColumn>} columns - The table columns
* @param {T} schema - The table schema
* @returns {Promise<unknown>} The current version value
*/
async getCurrentVersion(entity, primaryKeyName, versionMetadata, columns, schema) {
if (!versionMetadata || !columns)
return undefined;
const versionField = columns[versionMetadata.fieldName];
if (!versionField)
return undefined;
if (versionMetadata.fieldName in entity) {
return entity[versionMetadata.fieldName];
}
const oldModel = await this.getOldModel({ [primaryKeyName]: entity[primaryKeyName] }, schema, [versionMetadata.fieldName, versionField]);
return oldModel[versionMetadata.fieldName];
}
/**
* Prepares a model for insertion with version field.
* @template T - The type of the table schema
* @param {Partial<InferInsertModel<T>>} model - The model to prepare
* @param {Object | undefined} versionMetadata - Version field metadata
* @param {Record<string, AnyColumn>} columns - The table columns
* @returns {InferInsertModel<T>} The prepared model
*/
prepareModelWithVersion(model, versionMetadata, columns) {
if (!versionMetadata || !columns)
return model;
let fieldName = versionMetadata.fieldName;
let versionField = columns[versionMetadata.fieldName];
if (!versionField) {
const find = Object.entries(columns).find(([, c]) => c.name === versionMetadata.fieldName);
if (find) {
fieldName = find[0];
versionField = find[1];
}
}
if (!versionField)
return model;
const modelWithVersion = { ...model };
const fieldType = versionField.getSQLType();
const versionValue = fieldType === "datetime" || fieldType === "timestamp" ? new Date() : 1;
modelWithVersion[fieldName] = versionValue;
return modelWithVersion;
}
/**
* Prepares update data with version field.
* @template T - The type of the table schema
* @param {Partial<InferInsertModel<T>>} entity - The entity to update
* @param {Object | undefined} versionMetadata - Version field metadata
* @param {Record<string, AnyColumn>} columns - The table columns
* @param {unknown} currentVersion - The current version value
* @returns {Partial<InferInsertModel<T>>} The prepared update data
*/
prepareUpdateData(entity, versionMetadata, columns, currentVersion) {
const updateData = { ...entity };
if (versionMetadata && columns) {
const versionField = columns[versionMetadata.fieldName];
if (versionField) {
const fieldType = versionField.getSQLType();
updateData[versionMetadata.fieldName] =
fieldType === "datetime" || fieldType === "timestamp"
? new Date()
: (currentVersion + 1);
}
}
return updateData;
}
/**
* Retrieves an existing model by primary key.
* @template T - The type of the table schema
* @param {Record<string, unknown>} primaryKeyValues - The primary key values
* @param {T} schema - The table schema
* @param {[string, AnyColumn]} versionField - The version field name and column
* @returns {Promise<Awaited<T> extends Array<any> ? Awaited<T>[number] | undefined : Awaited<T> | undefined>} The existing model
* @throws {Error} If the record is not found
*/
async getOldModel(primaryKeyValues, schema, versionField) {
const [versionFieldName, versionFieldColumn] = versionField;
const primaryKeys = this.getPrimaryKeys(schema);
const [primaryKeyName, primaryKeyColumn] = primaryKeys[0];
const resultQuery = this.forgeOperations
.select({
[primaryKeyName]: primaryKeyColumn,
[versionFieldName]: versionFieldColumn,
})
.from(schema)
.where((0, drizzle_orm_1.eq)(primaryKeyColumn, primaryKeyValues[primaryKeyName]));
const model = await this.forgeOperations.fetch().executeQueryOnlyOne(resultQuery);
if (!model) {
throw new Error(`Record not found in table ${schema}`);
}
return model;
}
}
exports.ForgeSQLCrudOperations = ForgeSQLCrudOperations;
//# sourceMappingURL=ForgeSQLCrudOperations.js.map