@wearesage/schema
Version:
A flexible schema definition and validation system for TypeScript with multi-database support
311 lines (257 loc) • 9.71 kB
text/typescript
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
()
({
fields: ['id', 'name', 'messageCount', 'createdAt']
})
class TestConversation {
()
id!: string;
()
name!: string;
()
messageCount!: number;
()
content?: string; // Not in metadata fields
()
createdAt!: Date;
}
()
class TestUser {
()
id!: string;
()
name!: string;
()
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', () => {
()
()
class DefaultEntity {
()
id!: string;
}
const config = Reflect.getMetadata('postgres:metadata', DefaultEntity);
expect(config).toMatchObject({
fields: ['id', 'name', 'createdAt', 'updatedAt'],
tableName: 'defaultentitys',
autoSync: true
});
});
});
});