bun-sqlite-orm
Version:
A lightweight TypeScript ORM for Bun runtime with Bun SQLite, featuring Active Record pattern and decorator-based entities
665 lines (565 loc) • 29.8 kB
text/typescript
import type { Database, Statement } from 'bun:sqlite';
import { validate } from 'class-validator';
import { typeBunContainer } from '../container';
import { DatabaseError, EntityNotFoundError, ValidationError } from '../errors';
import type { ValidationErrorDetail } from '../errors';
import type { MetadataContainer } from '../metadata';
import type { QueryBuilder } from '../sql';
import { StatementCache } from '../statement-cache';
import type { CompositeKeyValue, DbLogger, EntityConstructor, PrimaryKeyValue, SQLQueryBindings } from '../types';
import { storageToDate } from '../utils/date-utils';
import {
buildDataObject,
buildPrimaryKeyConditions,
executeWithErrorHandling,
getEntityMetadata,
resolveDependencies,
toSQLQueryBinding,
transformValueFromStorage,
validateDataSourceInitialization,
} from './entity-utils';
export abstract class BaseEntity {
private _isNew = true;
private _originalValues: Record<string, unknown> = {};
// Private helper for executing queries with cached prepared statements
private static _executeQuery<T>(sql: string, params: SQLQueryBindings[], method: 'get' | 'all' | 'run'): T {
const db = typeBunContainer.resolve<Database>('DatabaseConnection');
// Use StatementCache for optimized prepared statement reuse
// This provides 30-50% performance improvement for repeated queries
return StatementCache.executeQuery<T>(db, sql, params, method);
}
// Static methods
static async create<T extends BaseEntity>(this: new () => T, data: Partial<T>): Promise<T> {
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
const instance = new this();
Object.assign(instance, data);
await instance.save();
return instance;
}
static build<T extends BaseEntity>(this: new () => T, data: Partial<T>): T {
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
const instance = new this();
Object.assign(instance, data);
(instance as unknown as { _captureOriginalValues(): void })._captureOriginalValues();
return instance;
}
static async get<T extends BaseEntity>(this: new () => T, id: PrimaryKeyValue): Promise<T> {
const { metadataContainer, logger } = resolveDependencies();
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
const { tableName, primaryColumns } = getEntityMetadata(this, metadataContainer, true);
const queryBuilder = typeBunContainer.resolve<QueryBuilder>('QueryBuilder');
let conditions: Record<string, SQLQueryBindings>;
// Handle single primary key
if (primaryColumns.length === 1) {
const primaryColumn = primaryColumns[0];
// Support both single value and object notation for single keys
if (typeof id === 'object' && id !== null && !Buffer.isBuffer(id) && !(id instanceof Uint8Array)) {
const compositeId = id as CompositeKeyValue;
// Validate that the object has the expected primary key
if (!(primaryColumn.propertyName in compositeId)) {
throw new Error(
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
`Invalid composite key object for entity ${this.name}. Expected property: ${primaryColumn.propertyName}`
);
}
conditions = { [primaryColumn.propertyName]: compositeId[primaryColumn.propertyName] };
} else {
// Traditional single value
conditions = { [primaryColumn.propertyName]: id as SQLQueryBindings };
}
}
// Handle composite primary keys
else if (primaryColumns.length > 1) {
if (typeof id !== 'object' || id === null || Buffer.isBuffer(id) || id instanceof Uint8Array) {
throw new Error(
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
`Entity ${this.name} has ${primaryColumns.length} primary keys. Expected object with keys: ${primaryColumns.map((col) => col.propertyName).join(', ')}`
);
}
const compositeId = id as CompositeKeyValue;
conditions = {};
// Validate all primary key properties are provided
for (const primaryColumn of primaryColumns) {
if (!(primaryColumn.propertyName in compositeId)) {
throw new Error(
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
`Missing primary key property '${primaryColumn.propertyName}' for entity ${this.name}`
);
}
conditions[primaryColumn.propertyName] = compositeId[primaryColumn.propertyName];
}
} else {
throw new Error(
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
`Entity ${this.name} has no primary keys defined`
);
}
const { sql, params } = queryBuilder.select(tableName, conditions, 1);
logger.debug(`Executing query: ${sql}`, { params });
return executeWithErrorHandling(
() => {
const row = BaseEntity._executeQuery<Record<string, unknown> | undefined>(sql, params, 'get');
if (!row) {
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
throw new EntityNotFoundError(this.name, conditions);
}
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
const instance = new this();
instance._loadFromRow(row);
return instance;
},
'get',
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
this.name,
logger
);
}
static async find<T extends BaseEntity>(
this: new () => T,
conditions: Record<string, SQLQueryBindings>
): Promise<T[]> {
const { metadataContainer, logger } = resolveDependencies();
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
const { tableName } = getEntityMetadata(this, metadataContainer);
const queryBuilder = typeBunContainer.resolve<QueryBuilder>('QueryBuilder');
const { sql, params } = queryBuilder.select(tableName, conditions);
logger.debug(`Executing query: ${sql}`, { params });
return executeWithErrorHandling(
() => {
const rows = BaseEntity._executeQuery<Record<string, unknown>[]>(sql, params, 'all');
return rows.map((row) => {
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
const instance = new this();
instance._loadFromRow(row);
return instance;
});
},
'find',
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
this.name,
logger
);
}
static async findFirst<T extends BaseEntity>(
this: new () => T,
conditions: Record<string, SQLQueryBindings>
): Promise<T | null> {
const { metadataContainer, logger } = resolveDependencies();
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
const { tableName } = getEntityMetadata(this, metadataContainer);
const queryBuilder = typeBunContainer.resolve<QueryBuilder>('QueryBuilder');
const { sql, params } = queryBuilder.select(tableName, conditions, 1);
logger.debug(`Executing query: ${sql}`, { params });
return executeWithErrorHandling(
() => {
const row = BaseEntity._executeQuery<Record<string, unknown> | undefined>(sql, params, 'get');
if (!row) {
return null;
}
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
const instance = new this();
instance._loadFromRow(row);
return instance;
},
'findFirst',
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
this.name,
logger
);
}
static async count(conditions?: Record<string, SQLQueryBindings>): Promise<number> {
const { metadataContainer, logger } = resolveDependencies();
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
const { tableName } = getEntityMetadata(this as unknown as EntityConstructor, metadataContainer);
const queryBuilder = typeBunContainer.resolve<QueryBuilder>('QueryBuilder');
const { sql, params } = queryBuilder.count(tableName, conditions);
logger.debug(`Executing query: ${sql}`, { params });
return executeWithErrorHandling(
() => {
const result = BaseEntity._executeQuery<{ count: number }>(sql, params, 'get');
return result.count;
},
'count',
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
this.name,
logger
);
}
static async exists(conditions: Record<string, SQLQueryBindings>): Promise<boolean> {
const count = await // biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
(this as unknown as { count: (conditions?: Record<string, SQLQueryBindings>) => Promise<number> }).count(
conditions
);
return count > 0;
}
static async deleteAll(conditions: Record<string, SQLQueryBindings>): Promise<number> {
validateDataSourceInitialization();
const metadataContainer = typeBunContainer.resolve<MetadataContainer>('MetadataContainer');
const logger = typeBunContainer.resolve<DbLogger>('DbLogger');
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
const tableName = metadataContainer.getTableName(this as unknown as EntityConstructor);
const queryBuilder = typeBunContainer.resolve<QueryBuilder>('QueryBuilder');
const { sql, params } = queryBuilder.delete(tableName, conditions, true);
logger.debug(`Executing query: ${sql}`, { params });
try {
const result = BaseEntity._executeQuery<{ changes: number }>(sql, params, 'run');
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
logger.info(`Deleted ${result.changes} ${this.name} records`);
return result.changes;
} catch (error) {
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
logger.error(`Database error in ${this.name}.deleteAll()`, error);
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
throw new DatabaseError(`Failed to delete ${this.name} records`, error as Error, this.name, 'delete');
}
}
static async updateAll(
data: Record<string, SQLQueryBindings>,
conditions: Record<string, SQLQueryBindings>
): Promise<number> {
validateDataSourceInitialization();
const metadataContainer = typeBunContainer.resolve<MetadataContainer>('MetadataContainer');
const logger = typeBunContainer.resolve<DbLogger>('DbLogger');
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
const tableName = metadataContainer.getTableName(this as unknown as EntityConstructor);
const queryBuilder = typeBunContainer.resolve<QueryBuilder>('QueryBuilder');
const { sql, params } = queryBuilder.update(tableName, data, conditions, true);
logger.debug(`Executing query: ${sql}`, { params });
try {
const result = BaseEntity._executeQuery<{ changes: number }>(sql, params, 'run');
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
logger.info(`Updated ${result.changes} ${this.name} records`);
return result.changes;
} catch (error) {
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
logger.error(`Database error in ${this.name}.updateAll()`, error);
// biome-ignore lint/complexity/noThisInStatic: Required for Active Record polymorphism
throw new DatabaseError(`Failed to update ${this.name} records`, error as Error, this.name, 'update');
}
}
// Instance methods
async save(): Promise<void> {
await this._validate();
if (this._isNew) {
await this._insert();
} else {
await this._update();
}
}
async update(data: Partial<this>): Promise<void> {
Object.assign(this, data);
await this.save();
}
async remove(): Promise<void> {
if (this._isNew) {
throw new Error('Cannot remove unsaved entity');
}
const { metadataContainer, logger } = resolveDependencies();
const { tableName, primaryColumns } = getEntityMetadata(
this.constructor as unknown as EntityConstructor,
metadataContainer
);
const conditions = buildPrimaryKeyConditions(this, primaryColumns);
const queryBuilder = typeBunContainer.resolve<QueryBuilder>('QueryBuilder');
const { sql, params } = queryBuilder.delete(tableName, conditions);
logger.debug(`Executing query: ${sql}`, { params });
try {
BaseEntity._executeQuery<{ changes: number }>(sql, params, 'run');
logger.info(`Removed ${this.constructor.name} entity`);
this._isNew = true;
} catch (error) {
logger.error(`Database error in ${this.constructor.name}.remove()`, error);
throw new DatabaseError(
`Failed to remove ${this.constructor.name}`,
error as Error,
this.constructor.name,
'remove'
);
}
}
async reload(): Promise<void> {
if (this._isNew) {
throw new Error('Cannot reload unsaved entity');
}
const metadataContainer = typeBunContainer.resolve<MetadataContainer>('MetadataContainer');
const primaryColumns = metadataContainer.getPrimaryColumns(this.constructor as unknown as EntityConstructor);
if (primaryColumns.length === 0) {
throw new Error(`No primary key defined for entity ${this.constructor.name}`);
}
// Build primary key conditions from current entity values
const primaryKeyConditions = buildPrimaryKeyConditions(this, primaryColumns);
// Validate that all primary key values are present
if (Object.keys(primaryKeyConditions).length !== primaryColumns.length) {
throw new Error(`Cannot reload entity ${this.constructor.name}: missing primary key values`);
}
// Use appropriate format for get() method
let keyValue: PrimaryKeyValue;
if (primaryColumns.length === 1) {
// Single primary key - use the value directly
const primaryColumn = primaryColumns[0];
keyValue = primaryKeyConditions[primaryColumn.propertyName];
} else {
// Composite primary key - use object notation
keyValue = primaryKeyConditions as CompositeKeyValue;
}
const fresh = await (this.constructor as unknown as { get: (id: PrimaryKeyValue) => Promise<BaseEntity> }).get(
keyValue
);
Object.assign(this, fresh);
}
isNew(): boolean {
return this._isNew;
}
isChanged(): boolean {
validateDataSourceInitialization();
const metadataContainer = typeBunContainer.resolve<MetadataContainer>('MetadataContainer');
const columns = metadataContainer.getColumns(this.constructor as unknown as EntityConstructor);
for (const [propertyName] of columns) {
if (this._originalValues[propertyName] !== (this as Record<string, unknown>)[propertyName]) {
return true;
}
}
return false;
}
getChanges(): Record<string, { from: unknown; to: unknown }> {
const changes: Record<string, { from: unknown; to: unknown }> = {};
validateDataSourceInitialization();
const metadataContainer = typeBunContainer.resolve<MetadataContainer>('MetadataContainer');
const columns = metadataContainer.getColumns(this.constructor as unknown as EntityConstructor);
for (const [propertyName] of columns) {
const originalValue = this._originalValues[propertyName];
const currentValue = (this as Record<string, unknown>)[propertyName];
if (originalValue !== currentValue) {
changes[propertyName] = { from: originalValue, to: currentValue };
}
}
return changes;
}
toJSON(): Record<string, unknown> {
try {
// Try to use entity metadata if DataSource is initialized
const metadataContainer = typeBunContainer.resolve<MetadataContainer>('MetadataContainer');
const columns = metadataContainer.getColumns(this.constructor as unknown as EntityConstructor);
const result: Record<string, unknown> = {};
for (const [propertyName] of columns) {
const value = (this as Record<string, unknown>)[propertyName];
if (value !== undefined) {
result[propertyName] = value;
}
}
return result;
} catch (error) {
// Only use fallback for specific initialization errors
if (error instanceof Error && error.message.includes('not initialized')) {
// Fallback: if DataSource is not initialized, return all non-internal properties
// This ensures toJSON() works even before database initialization
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(this as Record<string, unknown>)) {
// Exclude internal ORM properties
if (!key.startsWith('_') && value !== undefined) {
result[key] = value;
}
}
return result;
}
// Re-throw unexpected errors
throw error;
}
}
// Private methods
private async _validate(): Promise<void> {
validateDataSourceInitialization();
const logger = typeBunContainer.resolve<DbLogger>('DbLogger');
const errors = await validate(this, {
skipMissingProperties: true,
forbidNonWhitelisted: false,
});
// Filter out the "unknown value" error which occurs for entities without validation decorators
const realErrors = errors.filter((error) => {
const constraints = error.constraints || {};
return (
!constraints.unknownValue ||
constraints.unknownValue !== 'an unknown value was passed to the validate function'
);
});
if (realErrors.length > 0) {
const validationErrors: ValidationErrorDetail[] = realErrors.flatMap((error) =>
Object.values(error.constraints || {}).map((message) => ({
property: error.property,
message,
value: error.value,
}))
);
logger.warn('Validation failed', { errors: validationErrors });
throw new ValidationError(this.constructor.name, validationErrors);
}
}
private async _insert(): Promise<void> {
validateDataSourceInitialization();
const metadataContainer = typeBunContainer.resolve<MetadataContainer>('MetadataContainer');
const logger = typeBunContainer.resolve<DbLogger>('DbLogger');
const tableName = metadataContainer.getTableName(this.constructor as unknown as EntityConstructor);
const columns = metadataContainer.getColumns(this.constructor as unknown as EntityConstructor);
// Apply application defaults and generate values
for (const [propertyName, metadata] of columns) {
if (metadata.isGenerated && metadata.generationStrategy === 'uuid') {
if (!(this as Record<string, unknown>)[propertyName]) {
(this as Record<string, unknown>)[propertyName] = crypto.randomUUID();
}
} else if (metadata.default !== undefined && typeof metadata.default === 'function') {
// Only apply JS function defaults if no SQL default is defined
// SQL defaults are handled by the database automatically
if (
metadata.sqlDefault === undefined &&
(this as Record<string, unknown>)[propertyName] === undefined
) {
(this as Record<string, unknown>)[propertyName] = metadata.default();
}
}
}
const data = buildDataObject(this, columns);
// Remove auto-increment columns
for (const [propertyName, metadata] of columns) {
if (metadata.isGenerated && metadata.generationStrategy === 'increment') {
delete data[propertyName];
}
}
const queryBuilder = typeBunContainer.resolve<QueryBuilder>('QueryBuilder');
const { sql, params } = queryBuilder.insert(tableName, data);
logger.debug(`Executing query: ${sql}`, { params });
try {
const result = BaseEntity._executeQuery<{ lastInsertRowid: number | bigint; changes: number }>(
sql,
params,
'run'
);
// Set auto-generated ID if applicable
const primaryColumns = metadataContainer.getPrimaryColumns(
this.constructor as unknown as EntityConstructor
);
const autoIncrementColumn = primaryColumns.find((col) => col.generationStrategy === 'increment');
if (autoIncrementColumn) {
(this as Record<string, unknown>)[autoIncrementColumn.propertyName] = result.lastInsertRowid;
}
// Reload entity to get SQL defaults that were applied by the database
const columnsWithSqlDefaults = Array.from(columns.values()).filter((col) => col.sqlDefault !== undefined);
if (columnsWithSqlDefaults.length > 0) {
// Build conditions for the reload using primary key(s)
const conditions = buildPrimaryKeyConditions(this, primaryColumns);
if (Object.keys(conditions).length > 0) {
const queryBuilder = typeBunContainer.resolve<QueryBuilder>('QueryBuilder');
const { sql, params } = queryBuilder.select(tableName, conditions, 1);
logger.debug(`Reloading entity to get SQL defaults: ${sql}`, { params });
const row = BaseEntity._executeQuery<Record<string, unknown> | undefined>(sql, params, 'get');
if (row) {
this._loadFromRow(row);
}
}
}
this._isNew = false;
this._captureOriginalValues();
logger.info(`Created new ${this.constructor.name} entity`);
} catch (error) {
logger.error(`Database error in ${this.constructor.name}._insert()`, error);
throw new DatabaseError(
`Failed to create ${this.constructor.name}`,
error as Error,
this.constructor.name,
'create'
);
}
}
private async _update(): Promise<void> {
validateDataSourceInitialization();
const metadataContainer = typeBunContainer.resolve<MetadataContainer>('MetadataContainer');
const logger = typeBunContainer.resolve<DbLogger>('DbLogger');
const tableName = metadataContainer.getTableName(this.constructor as unknown as EntityConstructor);
const columns = metadataContainer.getColumns(this.constructor as unknown as EntityConstructor);
const primaryColumns = metadataContainer.getPrimaryColumns(this.constructor as unknown as EntityConstructor);
// Build data object excluding primary keys
const data = buildDataObject(this, columns, true);
// If no data to update, skip the database call
if (Object.keys(data).length === 0) {
logger.debug(`No data to update for ${this.constructor.name} entity`);
return;
}
// Build conditions from primary keys
const conditions = buildPrimaryKeyConditions(this, primaryColumns);
const queryBuilder = typeBunContainer.resolve<QueryBuilder>('QueryBuilder');
const { sql, params } = queryBuilder.update(tableName, data, conditions);
logger.debug(`Executing query: ${sql}`, { params });
try {
BaseEntity._executeQuery<{ changes: number }>(sql, params, 'run');
this._captureOriginalValues();
logger.info(`Updated ${this.constructor.name} entity`);
} catch (error) {
logger.error(`Database error in ${this.constructor.name}._update()`, error);
throw new DatabaseError(
`Failed to update ${this.constructor.name}`,
error as Error,
this.constructor.name,
'update'
);
}
}
private _loadFromRow(row: Record<string, unknown>): void {
const metadataContainer = typeBunContainer.resolve<MetadataContainer>('MetadataContainer');
const columns = metadataContainer.getColumns(this.constructor as unknown as EntityConstructor);
for (const [propertyName, metadata] of columns) {
const value = row[propertyName];
if (value !== undefined) {
if (value === null) {
// Only explicitly set null values for fields that have sqlDefault: null
// This preserves the distinction between explicit null and missing optional fields
if (metadata.sqlDefault === null) {
(this as Record<string, unknown>)[propertyName] = null;
}
// For other nullable fields, leave them as undefined if they weren't set
} else {
const tsType = Reflect.getMetadata('design:type', this, propertyName);
let transformedValue: unknown;
// Check if transformer or JSON type should be applied
if (metadata.transformer || metadata.type === 'json') {
transformedValue = transformValueFromStorage(value, metadata.type, metadata.transformer);
}
// Convert INTEGER (1/0) back to boolean for boolean properties
else if (metadata.type === 'integer' && tsType === Boolean) {
transformedValue = value === 1;
}
// Convert stored date values back to Date objects using DateUtils
else if (tsType === Date && (typeof value === 'string' || typeof value === 'number')) {
transformedValue = storageToDate(value);
}
// Default: use value as-is
else {
transformedValue = value;
}
(this as Record<string, unknown>)[propertyName] = transformedValue;
}
}
}
this._isNew = false;
this._captureOriginalValues();
}
private _captureOriginalValues(): void {
try {
// Try to capture original values using metadata if available
const metadataContainer = typeBunContainer.resolve<MetadataContainer>('MetadataContainer');
const columns = metadataContainer.getColumns(this.constructor as unknown as EntityConstructor);
this._originalValues = {};
for (const [propertyName] of columns) {
this._originalValues[propertyName] = (this as Record<string, unknown>)[propertyName];
}
} catch (error) {
// If DataSource is not initialized, fall back to capturing all enumerable properties
// This allows build() to work without database initialization
this._originalValues = {};
for (const propertyName of Object.keys(this as Record<string, unknown>)) {
// Skip internal properties
if (!propertyName.startsWith('_')) {
this._originalValues[propertyName] = (this as Record<string, unknown>)[propertyName];
}
}
}
}
}