@wearesage/schema
Version:
A flexible schema definition and validation system for TypeScript with multi-database support
563 lines (480 loc) • 16.6 kB
text/typescript
import { Type } from "../core/types";
import { DatabaseAdapter } from "./interface";
import { MetadataRegistry } from "../core/MetadataRegistry";
import { SchemaReflector } from "../core/SchemaReflector";
/**
* SQLite-specific table decorator
*/
export function Table(name: string) {
return function (target: any) {
Reflect.defineMetadata("sqlite:table", name, target);
};
}
/**
* SQLite-specific primary key decorator
*/
export function PrimaryKey() {
return function (target: any, propertyKey: string) {
Reflect.defineMetadata(
"sqlite:primaryKey",
propertyKey,
target.constructor
);
};
}
/**
* SQLite-specific index decorator
*/
export function Index(name?: string, unique = false) {
return function (target: any, propertyKey: string) {
const indexes =
Reflect.getMetadata("sqlite:indexes", target.constructor) || [];
indexes.push({
name: name || `idx_${propertyKey}`,
column: propertyKey,
unique,
});
Reflect.defineMetadata("sqlite:indexes", indexes, target.constructor);
};
}
/**
* SQLite-specific column decorator
*/
export function Column(options: {
name?: string;
type?: "TEXT" | "INTEGER" | "REAL" | "BLOB" | "NULL";
nullable?: boolean;
defaultValue?: any;
}) {
return function (target: any, propertyKey: string) {
const columns =
Reflect.getMetadata("sqlite:columns", target.constructor) || {};
columns[propertyKey] = options;
Reflect.defineMetadata("sqlite:columns", columns, target.constructor);
};
}
/**
* SQLite-specific foreign key decorator
*/
export function ForeignKey(options: {
references: string;
column: string;
onDelete?: "CASCADE" | "RESTRICT" | "SET NULL" | "NO ACTION";
onUpdate?: "CASCADE" | "RESTRICT" | "SET NULL" | "NO ACTION";
}) {
return function (target: any, propertyKey: string) {
const foreignKeys =
Reflect.getMetadata("sqlite:foreignKeys", target.constructor) || [];
foreignKeys.push({
column: propertyKey,
...options,
});
Reflect.defineMetadata(
"sqlite:foreignKeys",
foreignKeys,
target.constructor
);
};
}
/**
* Shorthand decorator for SQLite entities
*/
export function SQLite<T extends { new (...args: any[]): any }>(target: T): T {
return DatabaseAdapter("SQLite")(target);
}
/**
* Implementation of DatabaseAdapter for SQLite
*/
export class SQLiteAdapter implements DatabaseAdapter {
readonly type = "SQLite";
private registry: MetadataRegistry;
private reflector = new SchemaReflector();
/**
* Constructor for SQLite adapter
* @param registry The metadata registry to use for schema information
* @param databasePath Path to the SQLite database file
*/
constructor(registry: MetadataRegistry, private databasePath: string) {
this.registry = registry;
}
/**
* Get the table name for an entity type
*/
private getTableName<T>(entityType: Type<T>): string {
// Get custom table name if defined, otherwise use entity name
const tableName = Reflect.getMetadata("sqlite:table", entityType);
if (tableName) {
return tableName;
}
// Use entity name in snake_case
return this.toSnakeCase(entityType.name);
}
/**
* Get primary key column for an entity type
*/
private getPrimaryKey<T>(entityType: Type<T>): string {
const primaryKey = Reflect.getMetadata("sqlite:primaryKey", entityType);
if (primaryKey) {
return primaryKey;
}
// Default to 'id' if not specified
return "id";
}
/**
* Get column configurations for an entity type
*/
private getColumns<T>(entityType: Type<T>): Record<string, any> {
return Reflect.getMetadata("sqlite:columns", entityType) || {};
}
/**
* Get index configurations for an entity type
*/
private getIndexes<T>(entityType: Type<T>): any[] {
return Reflect.getMetadata("sqlite:indexes", entityType) || [];
}
/**
* Get foreign key configurations for an entity type
*/
private getForeignKeys<T>(entityType: Type<T>): any[] {
return Reflect.getMetadata("sqlite:foreignKeys", entityType) || [];
}
/**
* Convert entity to SQLite row
*/
private entityToRow<T>(entity: T): Record<string, any> {
const entityType = entity.constructor as Type<T>;
const entitySchema = this.reflector.getEntitySchema(entityType);
const columns = this.getColumns(entityType);
const row: Record<string, any> = {};
// Convert each property to column value
for (const [key, meta] of Object.entries(entitySchema.properties)) {
if ((entity as any)[key] !== undefined) {
const columnConfig = columns[key];
const columnName = columnConfig?.name || this.toSnakeCase(key);
let value = (entity as any)[key];
// Handle date objects
if (value instanceof Date) {
value = value.toISOString();
}
// Handle objects (convert to JSON string)
if (typeof value === "object" && value !== null) {
value = JSON.stringify(value);
}
row[columnName] = value;
}
}
return row;
}
/**
* Convert SQLite row to entity instance
*/
private rowToEntity<T>(entityType: Type<T>, row: Record<string, any>): T {
const entity = new entityType();
const entitySchema = this.reflector.getEntitySchema(entityType);
const columns = this.getColumns(entityType);
// Create a map of column names to property names
const columnNameMap: Record<string, string> = {};
for (const [key, config] of Object.entries(columns)) {
if (config.name) {
columnNameMap[config.name] = key;
} else {
columnNameMap[this.toSnakeCase(key)] = key;
}
}
// Convert each column to entity property
for (const [colName, value] of Object.entries(row)) {
// Find the property name for this column
const propName = columnNameMap[colName] || this.toCamelCase(colName);
// Only set if the property exists in the schema
if (entitySchema.properties[propName]) {
let propValue = value;
// Handle string to object conversion
const propType = entitySchema.properties[propName].type;
if (propType === "object" && typeof value === "string") {
try {
propValue = JSON.parse(value);
} catch (e) {
// If parsing fails, use the original value
}
}
// Handle string to date conversion
if (propType === "date" && typeof value === "string") {
propValue = new Date(value);
}
(entity as any)[propName] = propValue;
}
}
return entity;
}
/**
* Convert string to snake_case
*/
private toSnakeCase(str: string): string {
return str
.replace(/([A-Z])/g, "_$1")
.toLowerCase()
.replace(/^_/, "");
}
/**
* Convert string to camelCase
*/
private toCamelCase(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
/**
* Build a SQL WHERE clause from criteria
*/
private buildWhereClause(criteria: Record<string, any>): {
clause: string;
params: any[];
} {
const conditions: string[] = [];
const params: any[] = [];
for (const [key, value] of Object.entries(criteria)) {
if (value === null) {
conditions.push(`${this.toSnakeCase(key)} IS NULL`);
} else {
conditions.push(`${this.toSnakeCase(key)} = ?`);
params.push(value);
}
}
return {
clause: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "",
params,
};
}
/**
* Query for a single entity by criteria
*/
async query<T>(entityType: Type<T>, criteria: object): Promise<T | null> {
const tableName = this.getTableName(entityType);
console.log(
`[SQLiteAdapter] Querying ${tableName} with criteria:`,
criteria
);
const { clause, params } = this.buildWhereClause(
criteria as Record<string, any>
);
const sql = `SELECT * FROM ${tableName} ${clause} LIMIT 1`;
console.log(`[SQLiteAdapter] SQL query: ${sql}`, params);
// In a real implementation, this would execute a SQL query
// const db = new SQLite3(this.databasePath);
// const row = await db.get(sql, ...params);
// return row ? this.rowToEntity(entityType, row) : null;
// Mock implementation
const result = await this.mockQueryExecution<T>(entityType, criteria);
return result;
}
/**
* Query for multiple entities by criteria
*/
async queryMany<T>(entityType: Type<T>, criteria: object): Promise<T[]> {
const tableName = this.getTableName(entityType);
console.log(
`[SQLiteAdapter] Querying for multiple rows in ${tableName} with criteria:`,
criteria
);
const { clause, params } = this.buildWhereClause(
criteria as Record<string, any>
);
const sql = `SELECT * FROM ${tableName} ${clause}`;
console.log(`[SQLiteAdapter] SQL query: ${sql}`, params);
// In a real implementation, this would execute a SQL query
// const db = new SQLite3(this.databasePath);
// const rows = await db.all(sql, ...params);
// return rows.map(row => this.rowToEntity(entityType, row));
// Mock implementation
return [];
}
/**
* Save an entity to SQLite
*/
async save<T extends object>(entity: T): Promise<void> {
const entityType = entity.constructor as Type<T>;
const tableName = this.getTableName(entityType);
// Validate the entity before saving
const validation = this.reflector.validateEntity(entity);
if (!validation.valid) {
throw new Error(`Invalid entity: ${validation.errors.join(", ")}`);
}
// Convert entity to row
const row = this.entityToRow(entity);
console.log(`[SQLiteAdapter] Saving row to table ${tableName}:`, row);
// Get primary key
const primaryKey = this.getPrimaryKey(entityType);
const primaryKeyCol = this.toSnakeCase(primaryKey);
// Determine if this is an insert or update
let sql: string;
let params: any[];
if (row[primaryKeyCol] === undefined) {
// Insert - exclude primary key if it's undefined (autoincrement)
const cols = Object.keys(row).filter(
(col) => col !== primaryKeyCol || row[primaryKeyCol] !== undefined
);
const placeholders = cols.map(() => "?").join(", ");
sql = `INSERT INTO ${tableName} (${cols.join(
", "
)}) VALUES (${placeholders})`;
params = cols.map((col) => row[col]);
} else {
// Update
const setClauses = Object.keys(row)
.filter((col) => col !== primaryKeyCol)
.map((col) => `${col} = ?`)
.join(", ");
sql = `UPDATE ${tableName} SET ${setClauses} WHERE ${primaryKeyCol} = ?`;
params = [
...Object.keys(row)
.filter((col) => col !== primaryKeyCol)
.map((col) => row[col]),
row[primaryKeyCol],
];
}
console.log(`[SQLiteAdapter] SQL: ${sql}`, params);
// In a real implementation, this would execute the SQL
// const db = new SQLite3(this.databasePath);
// await db.run(sql, ...params);
}
/**
* Delete an entity from SQLite
*/
async delete<T>(entityType: Type<T>, id: string | number): Promise<void> {
const tableName = this.getTableName(entityType);
const primaryKey = this.getPrimaryKey(entityType);
const primaryKeyCol = this.toSnakeCase(primaryKey);
console.log(
`[SQLiteAdapter] Deleting from ${tableName} where ${primaryKeyCol} = ${id}`
);
const sql = `DELETE FROM ${tableName} WHERE ${primaryKeyCol} = ?`;
// In a real implementation, this would execute the SQL
// const db = new SQLite3(this.databasePath);
// await db.run(sql, id);
}
/**
* Execute a raw SQL query
*/
async runNativeQuery<T>(query: string, params?: any[]): Promise<T> {
console.log(`[SQLiteAdapter] Running SQL query: ${query}`);
console.log(`[SQLiteAdapter] Params:`, params);
// In a real implementation, this would execute the SQL
// const db = new SQLite3(this.databasePath);
//
// if (query.trim().toUpperCase().startsWith('SELECT')) {
// return db.all(query, ...(params || [])) as T;
// } else {
// return db.run(query, ...(params || [])) as T;
// }
return {} as T; // Mock result
}
/**
* Create schema for an entity type
* This would typically be called during setup to create tables
*/
async createSchema<T>(entityType: Type<T>): Promise<void> {
const tableName = this.getTableName(entityType);
const entitySchema = this.reflector.getEntitySchema(entityType);
const columns = this.getColumns(entityType);
const primaryKey = this.getPrimaryKey(entityType);
const foreignKeys = this.getForeignKeys(entityType);
// Build column definitions
const columnDefs: string[] = [];
// Add column definitions for each property
for (const [propName, propMeta] of Object.entries(
entitySchema.properties
)) {
const columnConfig = columns[propName] || {};
const columnName = columnConfig.name || this.toSnakeCase(propName);
let sqlType = columnConfig.type;
// Derive SQL type from property type if not specified
if (!sqlType) {
if ((propMeta as any).type === "string") {
sqlType = "TEXT";
} else if ((propMeta as any).type === "number") {
sqlType = "REAL";
} else if ((propMeta as any).type === "boolean") {
sqlType = "INTEGER"; // SQLite doesn't have a boolean type
} else if ((propMeta as any).type === "date") {
sqlType = "TEXT"; // Store dates as ISO strings
} else {
sqlType = "TEXT"; // Default to TEXT for objects (stored as JSON)
}
}
// Build column definition
let columnDef = `${columnName} ${sqlType}`;
// Add primary key constraint
if (propName === primaryKey) {
columnDef += " PRIMARY KEY";
if (sqlType === "INTEGER") {
columnDef += " AUTOINCREMENT";
}
}
// Add nullable constraint
if (columnConfig.nullable === false) {
columnDef += " NOT NULL";
}
// Add default value
if (columnConfig.defaultValue !== undefined) {
let defaultValue = columnConfig.defaultValue;
if (typeof defaultValue === "string") {
defaultValue = `'${defaultValue}'`;
}
columnDef += ` DEFAULT ${defaultValue}`;
}
columnDefs.push(columnDef);
}
// Add foreign key constraints
for (const fk of foreignKeys) {
const columnName = this.toSnakeCase(fk.column);
let constraint = `FOREIGN KEY (${columnName}) REFERENCES ${fk.references}(${fk.column})`;
if (fk.onDelete) {
constraint += ` ON DELETE ${fk.onDelete}`;
}
if (fk.onUpdate) {
constraint += ` ON UPDATE ${fk.onUpdate}`;
}
columnDefs.push(constraint);
}
const createTableSQL = `
CREATE TABLE IF NOT EXISTS ${tableName} (
${columnDefs.join(",\n ")}
)
`;
console.log(`[SQLiteAdapter] Creating table with SQL:`, createTableSQL);
// In a real implementation, this would execute the SQL
// const db = new SQLite3(this.databasePath);
// await db.run(createTableSQL);
// Create indexes
const indexes = this.getIndexes(entityType);
for (const index of indexes) {
const createIndexSQL = `
CREATE ${index.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${index.name}
ON ${tableName} (${this.toSnakeCase(index.column)})
`;
console.log(`[SQLiteAdapter] Creating index with SQL:`, createIndexSQL);
// In a real implementation, this would execute the SQL
// await db.run(createIndexSQL);
}
}
/**
* Mock method to simulate database query execution
* In a real implementation, this would use the SQLite library
*/
private async mockQueryExecution<T>(
entityType: Type<T>,
criteria: object
): Promise<T | null> {
// Just a mock implementation for demonstration
const primaryKey = this.getPrimaryKey(entityType);
const criteriaObj = criteria as Record<string, any>;
if (criteriaObj.hasOwnProperty(primaryKey) && criteriaObj[primaryKey] === "123") {
const entity = new entityType();
Object.assign(entity, {
id: "123",
name: "Mock SQLite Entity",
createdAt: new Date().toISOString(),
});
return entity;
}
return null;
}
}