UNPKG

@wearesage/schema

Version:

A flexible schema definition and validation system for TypeScript with multi-database support

311 lines (257 loc) 9.71 kB
import "reflect-metadata"; import { MetadataLayerAdapter, PostgresMetadata } from "../../adapters/metadata-layer"; import { DatabaseAdapter } from "../../adapters/interface"; import { MetadataRegistry } from "../../core/MetadataRegistry"; import { Entity, Property, Id } from "../../core/decorators"; // Mock primary adapter (simulating Neo4j) class MockPrimaryAdapter implements DatabaseAdapter { readonly type = 'mock-primary'; private entities = new Map<string, any>(); async save<T extends object>(entity: T): Promise<void> { const id = (entity as any).id; this.entities.set(id, { ...entity, _source: 'primary' }); } async query<T>(entityType: any, criteria: object): Promise<T | null> { // Handle ID-based queries if ('id' in criteria) { const id = (criteria as any).id; const entity = this.entities.get(id); return entity ? { ...entity, _queriedFrom: 'primary' } : null; } // Handle other field queries const entity = Array.from(this.entities.values()).find(entity => { return Object.entries(criteria).every(([key, value]) => { // Skip special query options like 'include' if (['include', 'relations', 'with'].includes(key)) return true; return entity[key] === value; }); }); return entity ? { ...entity, _queriedFrom: 'primary' } : null; } async queryMany<T>(entityType: any, criteria: object): Promise<T[]> { const results = Array.from(this.entities.values()) .filter(entity => { return Object.entries(criteria).every(([key, value]) => { // Skip special query options like 'include' if (['include', 'relations', 'with'].includes(key)) return true; return entity[key] === value; }); }) .map(entity => ({ ...entity, _queriedFrom: 'primary' })); return results; } async delete<T>(entityType: any, id: string | number): Promise<void> { this.entities.delete(id.toString()); } async runNativeQuery<T>(query: string, params?: any): Promise<T> { return { query, params, _source: 'primary' } as T; } } // Mock metadata adapter (simulating Postgres) class MockMetadataAdapter implements DatabaseAdapter { readonly type = 'mock-metadata'; private entities = new Map<string, any>(); async save<T extends object>(entity: T): Promise<void> { const id = (entity as any).id; this.entities.set(id, { ...entity, _source: 'metadata' }); } async query<T>(entityType: any, criteria: object): Promise<T | null> { const id = (criteria as any).id; const entity = this.entities.get(id); return entity ? { ...entity, _queriedFrom: 'metadata' } : null; } async queryMany<T>(entityType: any, criteria: object): Promise<T[]> { const results = Array.from(this.entities.values()) .filter(entity => { return Object.entries(criteria).every(([key, value]) => entity[key] === value); }) .map(entity => ({ ...entity, _queriedFrom: 'metadata' })); return results; } async delete<T>(entityType: any, id: string | number): Promise<void> { this.entities.delete(id.toString()); } async runNativeQuery<T>(query: string, params?: any): Promise<T> { return { query, params, _source: 'metadata' } as T; } } // Test entities @Entity() @PostgresMetadata({ fields: ['id', 'name', 'messageCount', 'createdAt'] }) class TestConversation { @Id() id!: string; @Property() name!: string; @Property() messageCount!: number; @Property() content?: string; // Not in metadata fields @Property() createdAt!: Date; } @Entity() class TestUser { @Id() id!: string; @Property() name!: string; @Property() email!: string; } describe('MetadataLayerAdapter', () => { let primaryAdapter: MockPrimaryAdapter; let metadataAdapter: MockMetadataAdapter; let registry: MetadataRegistry; let adapter: MetadataLayerAdapter; beforeEach(() => { primaryAdapter = new MockPrimaryAdapter(); metadataAdapter = new MockMetadataAdapter(); registry = new MetadataRegistry(); adapter = new MetadataLayerAdapter(primaryAdapter, metadataAdapter, registry); }); describe('save operations', () => { it('should save to primary adapter and sync metadata', async () => { const conversation = new TestConversation(); conversation.id = 'conv1'; conversation.name = 'Test Chat'; conversation.messageCount = 5; conversation.content = 'Full conversation content'; conversation.createdAt = new Date(); await adapter.save(conversation); // Check primary adapter has full entity const primaryResult = await primaryAdapter.query(TestConversation, { id: 'conv1' }); expect(primaryResult).toMatchObject({ id: 'conv1', name: 'Test Chat', messageCount: 5, content: 'Full conversation content', _source: 'primary' }); // Check metadata adapter has only configured fields const metadataResult = await metadataAdapter.query(TestConversation, { id: 'conv1' }); expect(metadataResult).toMatchObject({ id: 'conv1', name: 'Test Chat', messageCount: 5, _source: 'metadata' }); expect(metadataResult).not.toHaveProperty('content'); // Not in metadata fields }); }); describe('query routing', () => { beforeEach(async () => { // Set up test data const conversation = new TestConversation(); conversation.id = 'conv1'; conversation.name = 'Test Chat'; conversation.messageCount = 5; conversation.content = 'Full conversation content'; conversation.createdAt = new Date(); await adapter.save(conversation); }); it('should route simple queries to metadata adapter', async () => { // Query using only metadata fields const result = await adapter.query(TestConversation, { id: 'conv1' }); expect(result).toMatchObject({ id: 'conv1', name: 'Test Chat', messageCount: 5, _queriedFrom: 'metadata' }); }); it('should route queryMany with metadata fields to metadata adapter', async () => { const results = await adapter.queryMany(TestConversation, { messageCount: 5 }); expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ id: 'conv1', messageCount: 5, _queriedFrom: 'metadata' }); }); it('should route complex queries to primary adapter', async () => { // Query with non-metadata field const result = await adapter.query(TestConversation, { content: 'Full conversation content' }); expect(result).toMatchObject({ id: 'conv1', content: 'Full conversation content', _queriedFrom: 'primary' }); }); it('should route queries with include to primary adapter', async () => { // Query with relationship inclusion const result = await adapter.query(TestConversation, { id: 'conv1', include: ['messages'] }); expect(result).toMatchObject({ id: 'conv1', _queriedFrom: 'primary' }); }); it('should fallback to primary adapter for entities without metadata config', async () => { const user = new TestUser(); user.id = 'user1'; user.name = 'John Doe'; user.email = 'john@example.com'; await adapter.save(user); const result = await adapter.query(TestUser, { id: 'user1' }); expect(result).toMatchObject({ id: 'user1', _queriedFrom: 'primary' }); }); }); describe('delete operations', () => { it('should delete from both adapters', async () => { const conversation = new TestConversation(); conversation.id = 'conv1'; conversation.name = 'Test Chat'; conversation.messageCount = 5; conversation.content = 'Full conversation content'; conversation.createdAt = new Date(); await adapter.save(conversation); // Verify both adapters have the entity expect(await primaryAdapter.query(TestConversation, { id: 'conv1' })).toBeTruthy(); expect(await metadataAdapter.query(TestConversation, { id: 'conv1' })).toBeTruthy(); // Delete await adapter.delete(TestConversation, 'conv1'); // Verify both adapters no longer have the entity expect(await primaryAdapter.query(TestConversation, { id: 'conv1' })).toBeNull(); expect(await metadataAdapter.query(TestConversation, { id: 'conv1' })).toBeNull(); }); }); describe('native queries', () => { it('should route native queries to primary adapter', async () => { const result = await adapter.runNativeQuery('MATCH (n) RETURN n', { param: 'value' }); expect(result).toMatchObject({ query: 'MATCH (n) RETURN n', params: { param: 'value' }, _source: 'primary' }); }); }); describe('PostgresMetadata decorator', () => { it('should store metadata configuration on entity', () => { const config = Reflect.getMetadata('postgres:metadata', TestConversation); expect(config).toMatchObject({ fields: ['id', 'name', 'messageCount', 'createdAt'], tableName: 'testconversations', autoSync: true }); }); it('should use default configuration when no config provided', () => { @Entity() @PostgresMetadata() class DefaultEntity { @Id() id!: string; } const config = Reflect.getMetadata('postgres:metadata', DefaultEntity); expect(config).toMatchObject({ fields: ['id', 'name', 'createdAt', 'updatedAt'], tableName: 'defaultentitys', autoSync: true }); }); }); });