forge-sql-orm
Version:
Drizzle ORM integration for Forge-SQL in Atlassian Forge applications.
441 lines (385 loc) • 15.8 kB
text/typescript
import { ForgeSqlOrmOptions } from "..";
import { CRUDForgeSQL, ForgeSqlOperation } from "./ForgeSQLQueryBuilder";
import { AnyMySqlTable } from "drizzle-orm/mysql-core/index";
import { AnyColumn, InferInsertModel } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { SQL } from "drizzle-orm";
import { getPrimaryKeys, getTableMetadata } from "../utils/sqlUtils";
/**
* Class implementing CRUD operations for ForgeSQL ORM.
* Provides methods for inserting, updating, and deleting records with support for optimistic locking.
*/
export class ForgeSQLCrudOperations implements CRUDForgeSQL {
private readonly forgeOperations: ForgeSqlOperation;
private readonly options: ForgeSqlOrmOptions;
/**
* Creates a new instance of ForgeSQLCrudOperations.
* @param forgeSqlOperations - The ForgeSQL operations instance
* @param options - Configuration options for the ORM
*/
constructor(forgeSqlOperations: ForgeSqlOperation, options: ForgeSqlOrmOptions) {
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.
*
* @template T - The type of the table schema
* @param {T} schema - The entity schema
* @param {Partial<InferInsertModel<T>>[]} models - Array of entities to insert
* @param {boolean} [updateIfExists=false] - Whether to update existing records
* @returns {Promise<number>} The number of inserted rows
* @throws {Error} If the insert operation fails
*/
async insert<T extends AnyMySqlTable>(
schema: T,
models: InferInsertModel<T>[],
updateIfExists: boolean = false,
): Promise<number> {
if (!models?.length) return 0;
const { tableName, columns } = 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
.getDrizzleQueryBuilder()
.insert(schema)
.values(preparedModels);
// Add onDuplicateKeyUpdate if needed
const finalQuery = updateIfExists
? queryBuilder.onDuplicateKeyUpdate({
set: Object.fromEntries(
Object.keys(preparedModels[0]).map((key) => [key, (schema as any)[key]]),
) as any,
})
: queryBuilder;
// Execute query
const result = await finalQuery;
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.
*
* @template T - The type of the table schema
* @param {unknown} id - The ID of the record to delete
* @param {T} schema - The entity schema
* @returns {Promise<number>} Number of affected rows
* @throws {Error} If the delete operation fails
* @throws {Error} If multiple primary keys are found
*/
async deleteById<T extends AnyMySqlTable>(id: unknown, schema: T): Promise<number> {
const { tableName, columns } = 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: SQL<unknown>[] = [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(eq(versionField, (oldModel as any)[versionMetadata.fieldName]));
}
}
// Execute delete query
const queryBuilder = this.forgeOperations
.getDrizzleQueryBuilder()
.delete(schema)
.where(and(...conditions));
const result = await queryBuilder;
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
*
* @template T - The type of the table schema
* @param {Partial<InferInsertModel<T>>} entity - The entity with updated values
* @param {T} schema - The entity schema
* @returns {Promise<number>} 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<T extends AnyMySqlTable>(
entity: Partial<InferInsertModel<T>>,
schema: T,
): Promise<number> {
const { tableName, columns } = 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: SQL<unknown>[] = [
eq(primaryKeyColumn, entity[primaryKeyName as keyof typeof entity]),
];
if (versionMetadata && columns) {
const versionField = columns[versionMetadata.fieldName];
if (versionField) {
conditions.push(eq(versionField, currentVersion));
}
}
// Execute update query
const queryBuilder = this.forgeOperations
.getDrizzleQueryBuilder()
.update(schema)
.set(updateData)
.where(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 as keyof typeof entity]} has been modified`,
);
}
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<T extends AnyMySqlTable>(
updateData: Partial<InferInsertModel<T>>,
schema: T,
where?: SQL<unknown>,
): Promise<number> {
if (!where) {
throw new Error("WHERE conditions must be provided");
}
const queryBuilder = this.forgeOperations
.getDrizzleQueryBuilder()
.update(schema)
.set(updateData)
.where(where);
const result = await queryBuilder;
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
*/
private getPrimaryKeys<T extends AnyMySqlTable>(schema: T): [string, AnyColumn][] {
const primaryKeys = 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
*/
private validateVersionField(
tableName: string,
columns: Record<string, AnyColumn>,
): { fieldName: string; type: string } | undefined {
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) {
console.warn(
`Version field "${versionMetadata.fieldName}" not found in table ${tableName}. Versioning will be skipped.`,
);
return undefined;
}
if (!versionField.notNull) {
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) {
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
*/
private async getCurrentVersion<T extends AnyMySqlTable>(
entity: Partial<InferInsertModel<T>>,
primaryKeyName: string,
versionMetadata: { fieldName: string; type: string } | undefined,
columns: Record<string, AnyColumn>,
schema: T,
): Promise<unknown> {
if (!versionMetadata || !columns) return undefined;
const versionField = columns[versionMetadata.fieldName];
if (!versionField) return undefined;
if (versionMetadata.fieldName in entity) {
return entity[versionMetadata.fieldName as keyof typeof entity];
}
const oldModel = await this.getOldModel(
{ [primaryKeyName]: entity[primaryKeyName as keyof typeof entity] },
schema,
[versionMetadata.fieldName, versionField],
);
return (oldModel as any)[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
*/
private prepareModelWithVersion<T extends AnyMySqlTable>(
model: Partial<InferInsertModel<T>>,
versionMetadata: { fieldName: string; type: string } | undefined,
columns: Record<string, AnyColumn>,
): InferInsertModel<T> {
if (!versionMetadata || !columns) return model as InferInsertModel<T>;
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 as InferInsertModel<T>;
const modelWithVersion = { ...model };
const fieldType = versionField.getSQLType();
const versionValue = fieldType === "datetime" || fieldType === "timestamp" ? new Date() : 1;
modelWithVersion[fieldName as keyof typeof modelWithVersion] = versionValue as any;
return modelWithVersion as InferInsertModel<T>;
}
/**
* 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
*/
private prepareUpdateData<T extends AnyMySqlTable>(
entity: Partial<InferInsertModel<T>>,
versionMetadata: { fieldName: string; type: string } | undefined,
columns: Record<string, AnyColumn>,
currentVersion: unknown,
): Partial<InferInsertModel<T>> {
const updateData = { ...entity };
if (versionMetadata && columns) {
const versionField = columns[versionMetadata.fieldName];
if (versionField) {
const fieldType = versionField.getSQLType();
updateData[versionMetadata.fieldName as keyof typeof updateData] =
fieldType === "datetime" || fieldType === "timestamp"
? new Date()
: (((currentVersion as number) + 1) as any);
}
}
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
*/
private async getOldModel<T extends AnyMySqlTable>(
primaryKeyValues: Record<string, unknown>,
schema: T,
versionField: [string, AnyColumn],
): Promise<
Awaited<T> extends Array<any> ? Awaited<T>[number] | undefined : Awaited<T> | undefined
> {
const [versionFieldName, versionFieldColumn] = versionField;
const primaryKeys = this.getPrimaryKeys(schema);
const [primaryKeyName, primaryKeyColumn] = primaryKeys[0];
const resultQuery = this.forgeOperations
.getDrizzleQueryBuilder()
.select({
[primaryKeyName]: primaryKeyColumn as any,
[versionFieldName]: versionFieldColumn as any,
})
.from(schema)
.where(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 as Awaited<T> extends Array<any> ? Awaited<T>[number] : Awaited<T>;
}
}