@wearesage/schema
Version:
A flexible schema definition and validation system for TypeScript with multi-database support
724 lines (620 loc) • 24.7 kB
text/typescript
import { Type } from '../core/types';
import { DatabaseAdapter } from './interface';
import { MetadataRegistry } from '../core/MetadataRegistry';
import { SchemaReflector } from '../core/SchemaReflector';
import { RelationshipOptions } from '../core/types';
// Renamed from Index to PgIndex to avoid naming conflict with MongoDB
export function PgIndex(options: {
name?: string;
columns: string[];
unique?: boolean;
method?: 'btree' | 'hash' | 'gist' | 'gin';
} = { columns: [] }) {
return function(target: any) {
const existingIndexes = Reflect.getMetadata('postgres:indexes', target) || [];
existingIndexes.push(options);
Reflect.defineMetadata('postgres:indexes', existingIndexes, target);
};
}
/**
* PostgreSQL-specific table decorator
*/
export function Table(tableName: string) {
return function(target: any) {
Reflect.defineMetadata('postgres:table', tableName, target);
};
}
/**
* PostgreSQL-specific column decorator
*/
export function Column(options: {
name?: string;
type?: string;
nullable?: boolean;
default?: any;
primary?: boolean;
unique?: boolean;
} = {}) {
return function(target: any, propertyKey: string) {
const existingColumns = Reflect.getMetadata('postgres:columns', target.constructor) || {};
existingColumns[propertyKey] = {
...options,
fieldName: options.name || propertyKey
};
Reflect.defineMetadata('postgres:columns', existingColumns, target.constructor);
};
}
/**
* PostgreSQL-specific join table decorator for many-to-many relations
*/
export function JoinTable(options: {
name: string;
joinColumn: string;
inverseJoinColumn: string;
}) {
return function(target: any, propertyKey: string) {
Reflect.defineMetadata('postgres:joinTable', options, target, propertyKey);
};
}
/**
* Shorthand decorator for PostgreSQL entities
*/
export function PostgreSQL<T extends { new (...args: any[]): any }>(target: T): T {
return DatabaseAdapter('PostgreSQL')(target);
}
/**
* Types to simulate pg library
*/
interface Pool {
connect(): Promise<PoolClient>;
end(): Promise<void>;
}
interface PoolClient {
query(text: string, params?: any[]): Promise<QueryResult>;
release(): void;
}
interface QueryResult {
rows: any[];
rowCount: number;
}
/**
* Implementation of DatabaseAdapter for PostgreSQL
*/
export class PostgreSQLAdapter implements DatabaseAdapter {
readonly type = 'PostgreSQL';
private registry: MetadataRegistry;
private reflector: SchemaReflector;
private pool: Pool | null = null;
private initialized = false;
/**
* Constructor for PostgreSQL adapter
* @param registry The metadata registry to use for schema information
* @param connectionString The PostgreSQL connection string
*/
constructor(registry: MetadataRegistry, private connectionString: string) {
this.registry = registry;
this.reflector = new SchemaReflector(registry);
}
/**
* Initialize the PostgreSQL connection pool
*/
private async initialize(): Promise<void> {
if (this.initialized) return;
try {
// Try to use real PostgreSQL connection
let pg: any;
try {
pg = require('pg');
console.log('[PostgreSQLAdapter] Found pg library, attempting real connection...');
// Create real PostgreSQL pool
this.pool = new pg.Pool({
connectionString: this.connectionString,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Test the connection
const client = await this.pool.connect();
await client.query('SELECT 1');
client.release();
this.initialized = true;
console.log('[PostgreSQLAdapter] ✅ Successfully connected to real PostgreSQL');
return;
} catch (pgError) {
console.log('[PostgreSQLAdapter] ❌ Real PostgreSQL connection failed:', (pgError as Error).message);
if (this.pool) {
try {
await this.pool.end();
} catch (e) {
// Ignore cleanup errors
}
this.pool = null;
}
}
// Fallback to SQLite in-memory for testing
console.log('[PostgreSQLAdapter] 🔄 Falling back to SQLite in-memory...');
let sqlite3: any;
try {
sqlite3 = require('sqlite3');
const { Database } = sqlite3;
const db = new Database(':memory:');
this.pool = {
connect: async () => {
return {
query: async (text: string, params?: any[]) => {
return new Promise((resolve, reject) => {
// Convert PostgreSQL syntax to SQLite
let sqliteQuery = text
.replace(/\$(\d+)/g, '?') // $1, $2 -> ?
.replace(/VARCHAR\(\d+\)/gi, 'TEXT')
.replace(/TIMESTAMP/gi, 'TEXT')
.replace(/INTEGER/gi, 'INTEGER');
console.log('🗄️ SQLITE QUERY:', sqliteQuery);
console.log('🗄️ SQLITE PARAMS:', params);
if (sqliteQuery.toUpperCase().trim().startsWith('SELECT')) {
db.all(sqliteQuery, params || [], (err: any, rows: any[]) => {
if (err) {
console.error('SQLite SELECT error:', err);
reject(err);
} else {
console.log('🗄️ SQLITE RESULT:', rows);
resolve({ rows, rowCount: rows.length });
}
});
} else {
db.run(sqliteQuery, params || [], function(this: any, err: any) {
if (err) {
console.error('SQLite RUN error:', err);
reject(err);
} else {
console.log(`🗄️ SQLITE CHANGES: ${this.changes}, LAST ID: ${this.lastID}`);
resolve({ rows: [], rowCount: this.changes || 0 });
}
});
}
});
},
release: () => {}
};
},
end: async () => {
return new Promise<void>((resolve) => {
db.close((err) => {
if (err) console.error('Error closing SQLite:', err);
console.log('🗄️ SQLite database closed');
resolve();
});
});
}
};
this.initialized = true;
console.log('[PostgreSQLAdapter] ✅ Successfully initialized SQLite fallback');
return;
} catch (sqliteError) {
console.log('[PostgreSQLAdapter] ❌ SQLite fallback failed:', (sqliteError as Error).message);
}
// Final fallback: throw error - no mocks!
throw new Error('No PostgreSQL connection available. Please:\n' +
'1. Install PostgreSQL and start it on localhost:5432, OR\n' +
'2. Set PG_CONNECTION environment variable, OR\n' +
'3. Install sqlite3 for in-memory testing\n' +
'4. Or skip integration tests with --testPathIgnorePatterns=integration');
} catch (error) {
console.error('[PostgreSQLAdapter] Failed to initialize:', error);
throw error;
}
}
/**
* Get a PostgreSQL client from the pool
*/
private async getClient(): Promise<PoolClient> {
await this.initialize();
if (!this.pool) {
throw new Error('Pool not initialized');
}
return this.pool.connect();
}
/**
* Get the table name for an entity type
*/
private getTableName<T>(entityType: Type<T>): string {
// Safety check for null/undefined entity types
if (!entityType || !entityType.name) {
throw new Error('Entity type is null or undefined');
}
// Get custom table name if defined, otherwise use entity name in snake_case
const tableName = Reflect.getMetadata('postgres:table', entityType);
if (tableName) {
return tableName;
}
// Simple pluralization: just add 's' and convert to lowercase
// This matches our test table naming: RealConversation → realconversations
return entityType.name.toLowerCase() + 's';
}
/**
* Get column definitions for an entity type
*/
private getColumns<T>(entityType: Type<T>): Record<string, any> {
return Reflect.getMetadata('postgres:columns', entityType) || {};
}
/**
* Get join table definition for a relationship
*/
private getJoinTable<T>(entityType: Type<T>, propertyKey: string): any {
return Reflect.getMetadata('postgres:joinTable', entityType.prototype, propertyKey);
}
/**
* Map entity property names to database column names
*/
private getColumnMapping<T>(entityType: Type<T>): Record<string, string> {
const columns = this.getColumns(entityType);
const mapping: Record<string, string> = {};
// Add explicitly defined columns
for (const [prop, options] of Object.entries(columns)) {
mapping[prop] = options.fieldName || prop;
}
// Add properties from registry
const properties = this.registry.getAllProperties(entityType);
if (properties) {
for (const [prop] of properties.entries()) {
if (!mapping[prop]) {
// Simple lowercase mapping to match our test schema
// messageCount → messagecount (not message_count)
mapping[prop] = prop.toLowerCase();
}
}
}
return mapping;
}
/**
* Convert database row to entity instance
*/
private rowToEntity<T>(entityType: Type<T>, row: any): T {
const entity = new entityType();
const columnMapping = this.getColumnMapping(entityType);
// Map database columns back to entity properties
for (const [prop, col] of Object.entries(columnMapping)) {
if (row[col] !== undefined) {
(entity as any)[prop] = row[col];
}
}
return entity;
}
/**
* Convert entity to database row
*/
private entityToRow<T>(entity: T): Record<string, any> {
const entityType = (entity as any).constructor as Type<T>;
const columnMapping = this.getColumnMapping(entityType);
const row: Record<string, any> = {};
// Map entity properties to database columns
for (const [prop, col] of Object.entries(columnMapping)) {
if ((entity as any)[prop] !== undefined) {
row[col] = (entity as any)[prop];
}
}
return row;
}
/**
* Query for a single entity by criteria
*/
async query<T>(entityType: Type<T>, criteria: object): Promise<T | null> {
try {
await this.initialize();
const client = await this.getClient();
try {
const tableName = this.getTableName(entityType);
const columnMapping = this.getColumnMapping(entityType);
// Convert criteria from entity properties to database columns
const dbCriteria: Record<string, any> = {};
for (const [prop, value] of Object.entries(criteria)) {
const col = columnMapping[prop] || prop;
dbCriteria[col] = value;
}
// Build WHERE clause
const whereClause = Object.keys(dbCriteria)
.map((col, index) => `${col} = $${index + 1}`)
.join(' AND ');
const query = `
SELECT * FROM ${tableName}
${whereClause ? `WHERE ${whereClause}` : ''}
LIMIT 1
`;
const result = await client.query(query, Object.values(dbCriteria));
if (result.rows.length === 0) {
return null;
}
return this.rowToEntity(entityType, result.rows[0]);
} finally {
client.release();
}
} catch (error) {
console.error('[PostgreSQLAdapter] Error in query:', error);
throw error;
}
}
/**
* Query for multiple entities by criteria
*/
async queryMany<T>(entityType: Type<T>, criteria: object): Promise<T[]> {
try {
await this.initialize();
const client = await this.getClient();
try {
const tableName = this.getTableName(entityType);
const columnMapping = this.getColumnMapping(entityType);
// Convert criteria from entity properties to database columns
const dbCriteria: Record<string, any> = {};
for (const [prop, value] of Object.entries(criteria)) {
const col = columnMapping[prop] || prop;
dbCriteria[col] = value;
}
// Build WHERE clause
let query = `SELECT * FROM ${tableName}`;
const params: any[] = [];
if (Object.keys(dbCriteria).length > 0) {
const whereClause = Object.keys(dbCriteria)
.map((col, index) => {
params.push(dbCriteria[col]);
return `${col} = $${index + 1}`;
})
.join(' AND ');
query += ` WHERE ${whereClause}`;
}
const result = await client.query(query, params);
return result.rows.map(row => this.rowToEntity(entityType, row));
} finally {
client.release();
}
} catch (error) {
console.error('[PostgreSQLAdapter] Error in queryMany:', error);
throw error;
}
}
/**
* Save an entity to PostgreSQL
*/
async save<T>(entity: T, savingEntities?: Set<any>): Promise<void> {
// Initialize cycle detection set if not provided
if (!savingEntities) {
savingEntities = new Set();
}
// Check for circular reference - prevent infinite recursion
if (savingEntities.has(entity)) {
return; // Entity is already being saved, skip to avoid cycles
}
// Add this entity to the set of entities being saved
savingEntities.add(entity);
try {
await this.initialize();
const client = await this.getClient();
try {
const entityType = (entity as any).constructor as Type<T>;
const tableName = this.getTableName(entityType);
// Validate the entity before saving - but allow null IDs for insert operations
const validation = this.reflector.validateEntity(entity);
if (!validation.valid) {
// Filter out ID validation errors if the ID is null/undefined (insert scenario)
const idProps = this.registry.getIdProperties(entityType);
const filteredErrors = validation.errors.filter(error => {
if (idProps && idProps.size > 0) {
const idProperty = Array.from(idProps)[0] as string;
const idValue = (entity as any)[idProperty];
// Allow missing ID errors if ID is null/undefined (database will auto-generate)
if ((idValue === null || idValue === undefined) && error.includes(`Required property '${idProperty}' is missing`)) {
return false; // Filter out this error
}
}
return true; // Keep other errors
});
if (filteredErrors.length > 0) {
throw new Error(`Invalid entity: ${filteredErrors.join(', ')}`);
}
}
// Convert entity to database row
const row = this.entityToRow(entity);
// Start a transaction
await client.query('BEGIN');
try {
// Check if entity exists (has an ID and exists in the database)
const idProps = this.registry.getIdProperties(entityType);
if (!idProps || idProps.size === 0) {
throw new Error(`Entity ${entityType.name} has no ID property defined`);
}
// Get the first ID property
const idProperty = Array.from(idProps)[0] as string;
const columnMapping = this.getColumnMapping(entityType);
const idColumnName = columnMapping[idProperty] || idProperty;
const idValue = row[idColumnName];
let entityExists = false;
if (idValue) {
const checkQuery = `
SELECT 1 FROM ${tableName}
WHERE ${idColumnName} = $1
LIMIT 1
`;
const checkResult = await client.query(checkQuery, [idValue]);
entityExists = checkResult.rows.length > 0;
}
let result: QueryResult;
if (entityExists) {
// Update existing entity
const setClauses = Object.keys(row)
.filter(col => col !== idColumnName)
.map((col, index) => `${col} = $${index + 2}`)
.join(', ');
const updateQuery = `
UPDATE ${tableName}
SET ${setClauses}
WHERE ${idColumnName} = $1
RETURNING *
`;
const updateValues = [idValue, ...Object.entries(row)
.filter(([col]) => col !== idColumnName)
.map(([_, value]) => value)];
result = await client.query(updateQuery, updateValues);
} else {
// Insert new entity
const columns = Object.keys(row).join(', ');
const placeholders = Object.keys(row)
.map((_, index) => `$${index + 1}`)
.join(', ');
const insertQuery = `
INSERT INTO ${tableName} (${columns})
VALUES (${placeholders})
RETURNING *
`;
result = await client.query(insertQuery, Object.values(row));
// Update the ID if it was auto-generated
if (!idValue && result.rows.length > 0) {
(entity as any)[idProperty] = result.rows[0][idColumnName];
}
}
// Handle relationships
const entitySchema = this.reflector.getEntitySchema(entityType);
for (const [propKey, relationshipMetaUntyped] of Object.entries(entitySchema.relationships)) {
const relationshipMeta = relationshipMetaUntyped as RelationshipOptions;
const relatedEntity = (entity as any)[propKey];
if (!relatedEntity) continue;
// Get target type - it can be a class constructor directly or a function that returns one
let targetType = relationshipMeta.target;
// Check if target is a function that needs to be called
if (typeof targetType === 'function' && targetType.length === 0 && !targetType.prototype) {
// This looks like a relationship function that returns the type
targetType = targetType();
}
if (!targetType) {
// Skip relationships without valid targets (e.g., inverse relationships)
continue;
}
const targetTableName = this.getTableName(targetType);
// Handle different relationship cardinalities
if (relationshipMeta.cardinality === 'one') {
// Handle one-to-one or many-to-one relationship
// Save the related entity and set up foreign key
await this.save(relatedEntity, savingEntities);
// For one-to-one or many-to-one, typically we just saved the related entity
// And the foreign key is already in our entity
} else if (relationshipMeta.cardinality === 'many' && Array.isArray(relatedEntity)) {
// Handle one-to-many or many-to-many relationship
// For many-to-many, we need a join table
const joinTable = this.getJoinTable(entityType, propKey);
if (joinTable) {
// Many-to-many with explicit join table
const joinTableName = joinTable.name;
const sourceColumn = joinTable.joinColumn;
const targetColumn = joinTable.inverseJoinColumn;
// First, delete existing relationships
const deleteQuery = `
DELETE FROM ${joinTableName}
WHERE ${sourceColumn} = $1
`;
await client.query(deleteQuery, [idValue]);
// Then insert new relationships
for (const related of relatedEntity) {
// Save the related entity first
await this.save(related, savingEntities);
// Get the related entity's ID
const relatedIdProps = this.registry.getIdProperties(targetType);
if (!relatedIdProps || relatedIdProps.size === 0) {
throw new Error(`Entity ${targetType.name} has no ID property defined`);
}
// Get the first ID property
const relatedIdProperty = Array.from(relatedIdProps)[0] as string;
const relatedIdValue = (related as any)[relatedIdProperty];
// Insert into join table
const insertJoinQuery = `
INSERT INTO ${joinTableName} (${sourceColumn}, ${targetColumn})
VALUES ($1, $2)
`;
await client.query(insertJoinQuery, [idValue, relatedIdValue]);
}
} else {
// One-to-many (or implicit many-to-many without join table)
// Just save each related entity
for (const related of relatedEntity) {
// Handle back-reference if an inverse relationship exists
if (relationshipMeta.inverse) {
// Set the back-reference to this entity
(related as any)[relationshipMeta.inverse] = entity;
}
// Save the related entity
await this.save(related, savingEntities);
}
}
}
}
// Commit the transaction
await client.query('COMMIT');
} catch (error) {
// Rollback on error
await client.query('ROLLBACK');
throw error;
}
} finally {
client.release();
}
} catch (error) {
console.error('[PostgreSQLAdapter] Error in save:', error);
throw error;
} finally {
// Remove entity from saving set when done (whether success or failure)
savingEntities?.delete(entity);
}
}
/**
* Delete an entity from PostgreSQL
*/
async delete<T>(entityType: Type<T>, id: string | number): Promise<void> {
try {
await this.initialize();
const client = await this.getClient();
try {
const tableName = this.getTableName(entityType);
// Get the ID column name
const idProps = this.registry.getIdProperties(entityType);
if (!idProps || idProps.size === 0) {
throw new Error(`Entity ${entityType.name} has no ID property defined`);
}
const idProperty = Array.from(idProps)[0] as string;
const columnMapping = this.getColumnMapping(entityType);
const idColumnName = columnMapping[idProperty] || idProperty;
// Delete the entity
const query = `DELETE FROM ${tableName} WHERE ${idColumnName} = $1`;
await client.query(query, [id]);
} finally {
client.release();
}
} catch (error) {
console.error('[PostgreSQLAdapter] Error in delete:', error);
throw error;
}
}
/**
* Execute a raw SQL query
*/
async runNativeQuery<T>(query: string, params?: any[]): Promise<T> {
try {
await this.initialize();
const client = await this.getClient();
try {
const result = await client.query(query, params);
return { rows: result.rows, rowCount: result.rowCount } as unknown as T;
} finally {
client.release();
}
} catch (error) {
console.error('[PostgreSQLAdapter] Error in runNativeQuery:', error);
throw error;
}
}
/**
* Close the connection pool when done
*/
async close(): Promise<void> {
if (this.pool) {
await this.pool.end();
this.initialized = false;
}
}
}