- Moved CommonSchemas from src/schema/schema.ts to src/test-utils/schemas.ts - Updated all test files to import CommonSchemas from the new location - Fixed the Document schema to match test expectations by making the author field required - Added additional fields to the Document schema to match the original implementation - Ensured all tests pass with the new implementation Addresses PR feedback that CommonSchemas is only used in tests and should be moved to test files.
347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
import { QueryEngine } from '../src/query';
|
|
import { Lossless } from '../src/views';
|
|
import { DefaultSchemaRegistry } from '../src/schema';
|
|
import { SchemaBuilder, PrimitiveSchemas } from '../src/schema';
|
|
import { CommonSchemas } from '../src/test-utils/schemas';
|
|
import { Delta } from '../src/core';
|
|
import { RhizomeNode } from '../src/node';
|
|
|
|
describe('Query Engine', () => {
|
|
let queryEngine: QueryEngine;
|
|
let lossless: Lossless;
|
|
let schemaRegistry: DefaultSchemaRegistry;
|
|
let rhizomeNode: RhizomeNode;
|
|
|
|
beforeEach(async () => {
|
|
rhizomeNode = new RhizomeNode({
|
|
peerId: 'test-query-node',
|
|
publishBindPort: 4002,
|
|
requestBindPort: 4003
|
|
});
|
|
|
|
lossless = rhizomeNode.lossless;
|
|
schemaRegistry = new DefaultSchemaRegistry();
|
|
queryEngine = new QueryEngine(lossless, schemaRegistry);
|
|
|
|
// Register test schemas
|
|
schemaRegistry.register(CommonSchemas.User());
|
|
schemaRegistry.register(CommonSchemas.UserSummary());
|
|
|
|
// Create a custom test schema
|
|
const blogPostSchema = SchemaBuilder
|
|
.create('blog-post')
|
|
.name('Blog Post')
|
|
.property('title', PrimitiveSchemas.requiredString())
|
|
.property('content', PrimitiveSchemas.string())
|
|
.property('author', PrimitiveSchemas.requiredString())
|
|
.property('published', PrimitiveSchemas.boolean())
|
|
.property('views', PrimitiveSchemas.number())
|
|
.required('title', 'author')
|
|
.build();
|
|
|
|
schemaRegistry.register(blogPostSchema);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// No cleanup needed for now
|
|
});
|
|
|
|
async function createUser(id: string, name: string, age?: number, email?: string) {
|
|
// Create user entity with name
|
|
const nameDelta = new Delta({
|
|
id: `delta-${id}-name-${Date.now()}`,
|
|
creator: 'test',
|
|
host: 'test-host',
|
|
timeCreated: Date.now(),
|
|
pointers: [
|
|
{ localContext: 'user', target: id, targetContext: 'name' },
|
|
{ localContext: 'value', target: name }
|
|
]
|
|
});
|
|
lossless.ingestDelta(nameDelta);
|
|
|
|
// Add age if provided
|
|
if (age !== undefined) {
|
|
const ageDelta = new Delta({
|
|
id: `delta-${id}-age-${Date.now()}`,
|
|
creator: 'test',
|
|
host: 'test-host',
|
|
timeCreated: Date.now(),
|
|
pointers: [
|
|
{ localContext: 'user', target: id, targetContext: 'age' },
|
|
{ localContext: 'value', target: age }
|
|
]
|
|
});
|
|
lossless.ingestDelta(ageDelta);
|
|
}
|
|
|
|
// Add email if provided
|
|
if (email) {
|
|
const emailDelta = new Delta({
|
|
id: `delta-${id}-email-${Date.now()}`,
|
|
creator: 'test',
|
|
host: 'test-host',
|
|
timeCreated: Date.now(),
|
|
pointers: [
|
|
{ localContext: 'user', target: id, targetContext: 'email' },
|
|
{ localContext: 'value', target: email }
|
|
]
|
|
});
|
|
lossless.ingestDelta(emailDelta);
|
|
}
|
|
}
|
|
|
|
async function createBlogPost(id: string, title: string, author: string, published = false, views = 0) {
|
|
// Title delta
|
|
const titleDelta = new Delta({
|
|
id: `delta-${id}-title-${Date.now()}`,
|
|
creator: 'test',
|
|
host: 'test-host',
|
|
timeCreated: Date.now(),
|
|
pointers: [
|
|
{ localContext: 'post', target: id, targetContext: 'title' },
|
|
{ localContext: 'value', target: title }
|
|
]
|
|
});
|
|
lossless.ingestDelta(titleDelta);
|
|
|
|
// Author delta
|
|
const authorDelta = new Delta({
|
|
id: `delta-${id}-author-${Date.now()}`,
|
|
creator: 'test',
|
|
host: 'test-host',
|
|
timeCreated: Date.now(),
|
|
pointers: [
|
|
{ localContext: 'post', target: id, targetContext: 'author' },
|
|
{ localContext: 'value', target: author }
|
|
]
|
|
});
|
|
lossless.ingestDelta(authorDelta);
|
|
|
|
// Published delta
|
|
const publishedDelta = new Delta({
|
|
id: `delta-${id}-published-${Date.now()}`,
|
|
creator: 'test',
|
|
host: 'test-host',
|
|
timeCreated: Date.now(),
|
|
pointers: [
|
|
{ localContext: 'post', target: id, targetContext: 'published' },
|
|
{ localContext: 'value', target: published }
|
|
]
|
|
});
|
|
lossless.ingestDelta(publishedDelta);
|
|
|
|
// Views delta
|
|
const viewsDelta = new Delta({
|
|
id: `delta-${id}-views-${Date.now()}`,
|
|
creator: 'test',
|
|
host: 'test-host',
|
|
timeCreated: Date.now(),
|
|
pointers: [
|
|
{ localContext: 'post', target: id, targetContext: 'views' },
|
|
{ localContext: 'value', target: views }
|
|
]
|
|
});
|
|
lossless.ingestDelta(viewsDelta);
|
|
}
|
|
|
|
describe('Basic Query Operations', () => {
|
|
it('can query all entities of a schema type', async () => {
|
|
// Create test users
|
|
await createUser('user1', 'Alice', 25, 'alice@example.com');
|
|
await createUser('user2', 'Bob', 30);
|
|
await createUser('user3', 'Charlie', 35, 'charlie@example.com');
|
|
|
|
const result = await queryEngine.query('user');
|
|
|
|
expect(result.totalFound).toBe(3);
|
|
expect(result.limited).toBe(false);
|
|
expect(Object.keys(result.entities)).toHaveLength(3);
|
|
expect(result.entities['user1']).toBeDefined();
|
|
expect(result.entities['user2']).toBeDefined();
|
|
expect(result.entities['user3']).toBeDefined();
|
|
});
|
|
|
|
it('can query a single entity by ID', async () => {
|
|
await createUser('user1', 'Alice', 25, 'alice@example.com');
|
|
|
|
const result = await queryEngine.queryOne('user', 'user1');
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result?.id).toBe('user1');
|
|
expect(result?.propertyDeltas.name).toBeDefined();
|
|
expect(result?.propertyDeltas.age).toBeDefined();
|
|
expect(result?.propertyDeltas.email).toBeDefined();
|
|
});
|
|
|
|
it('returns null for non-existent entity', async () => {
|
|
const result = await queryEngine.queryOne('user', 'nonexistent');
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('JSON Logic Filtering', () => {
|
|
beforeEach(async () => {
|
|
// Create test data
|
|
await createUser('user1', 'Alice', 25, 'alice@example.com');
|
|
await createUser('user2', 'Bob', 30, 'bob@example.com');
|
|
await createUser('user3', 'Charlie', 35, 'charlie@example.com');
|
|
await createUser('user4', 'Diana', 20);
|
|
});
|
|
|
|
it('can filter by primitive property values', async () => {
|
|
// Find users older than 28
|
|
const result = await queryEngine.query('user', {
|
|
'>': [{ 'var': 'age' }, 28]
|
|
});
|
|
|
|
expect(result.totalFound).toBe(2);
|
|
expect(result.entities['user2']).toBeDefined(); // Bob, 30
|
|
expect(result.entities['user3']).toBeDefined(); // Charlie, 35
|
|
expect(result.entities['user1']).toBeUndefined(); // Alice, 25
|
|
expect(result.entities['user4']).toBeUndefined(); // Diana, 20
|
|
});
|
|
|
|
it('can filter by string properties', async () => {
|
|
// Find users with name starting with 'A' - using substring check instead of startsWith
|
|
const result = await queryEngine.query('user', {
|
|
'in': ['A', { 'var': 'name' }]
|
|
});
|
|
|
|
expect(result.totalFound).toBe(1);
|
|
expect(result.entities['user1']).toBeDefined(); // Alice
|
|
});
|
|
|
|
it('can filter by null/missing properties', async () => {
|
|
// Find users without email
|
|
const result = await queryEngine.query('user', {
|
|
'==': [{ 'var': 'email' }, null]
|
|
});
|
|
|
|
expect(result.totalFound).toBe(1);
|
|
expect(result.entities['user4']).toBeDefined(); // Diana has no email
|
|
});
|
|
|
|
it('can use complex logic expressions', async () => {
|
|
// Find users who are (older than 30) OR (younger than 25 AND have email)
|
|
const result = await queryEngine.query('user', {
|
|
'or': [
|
|
{ '>': [{ 'var': 'age' }, 30] },
|
|
{
|
|
'and': [
|
|
{ '<': [{ 'var': 'age' }, 25] },
|
|
{ '!=': [{ 'var': 'email' }, null] }
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
expect(result.totalFound).toBe(1);
|
|
expect(result.entities['user3']).toBeDefined(); // Charlie, 35 (older than 30)
|
|
// Diana is younger than 25 but has no email
|
|
// Alice is 25, not younger than 25
|
|
});
|
|
});
|
|
|
|
describe('Blog Post Queries', () => {
|
|
beforeEach(async () => {
|
|
await createBlogPost('post1', 'Introduction to Rhizome', 'alice', true, 150);
|
|
await createBlogPost('post2', 'Advanced Queries', 'bob', true, 75);
|
|
await createBlogPost('post3', 'Draft Post', 'alice', false, 0);
|
|
await createBlogPost('post4', 'Popular Post', 'charlie', true, 1000);
|
|
});
|
|
|
|
it('can filter published posts', async () => {
|
|
const result = await queryEngine.query('blog-post', {
|
|
'==': [{ 'var': 'published' }, true]
|
|
});
|
|
|
|
expect(result.totalFound).toBe(3);
|
|
expect(result.entities['post1']).toBeDefined();
|
|
expect(result.entities['post2']).toBeDefined();
|
|
expect(result.entities['post4']).toBeDefined();
|
|
expect(result.entities['post3']).toBeUndefined(); // Draft
|
|
});
|
|
|
|
it('can filter by author', async () => {
|
|
const result = await queryEngine.query('blog-post', {
|
|
'==': [{ 'var': 'author' }, 'alice']
|
|
});
|
|
|
|
expect(result.totalFound).toBe(2);
|
|
expect(result.entities['post1']).toBeDefined();
|
|
expect(result.entities['post3']).toBeDefined();
|
|
});
|
|
|
|
it('can filter by view count ranges', async () => {
|
|
// Posts with more than 100 views
|
|
const result = await queryEngine.query('blog-post', {
|
|
'>': [{ 'var': 'views' }, 100]
|
|
});
|
|
|
|
expect(result.totalFound).toBe(2);
|
|
expect(result.entities['post1']).toBeDefined(); // 150 views
|
|
expect(result.entities['post4']).toBeDefined(); // 1000 views
|
|
});
|
|
});
|
|
|
|
describe('Query Options', () => {
|
|
beforeEach(async () => {
|
|
for (let i = 1; i <= 10; i++) {
|
|
await createUser(`user${i}`, `User${i}`, 20 + i);
|
|
}
|
|
});
|
|
|
|
it('can limit query results', async () => {
|
|
const result = await queryEngine.query('user', undefined, { maxResults: 5 });
|
|
|
|
expect(result.totalFound).toBe(10);
|
|
expect(result.limited).toBe(true);
|
|
expect(Object.keys(result.entities)).toHaveLength(5);
|
|
});
|
|
|
|
it('respects delta filters', async () => {
|
|
const result = await queryEngine.query('user', undefined, {
|
|
deltaFilter: (delta) => delta.creator === 'test'
|
|
});
|
|
|
|
expect(result.totalFound).toBe(10);
|
|
expect(result.limited).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Statistics', () => {
|
|
it('provides query engine statistics', async () => {
|
|
await createUser('user1', 'Alice', 25);
|
|
await createBlogPost('post1', 'Test Post', 'alice', true, 50);
|
|
|
|
const stats = queryEngine.getStats();
|
|
|
|
expect(stats.totalEntities).toBe(2);
|
|
expect(stats.registeredSchemas).toBeGreaterThan(0);
|
|
expect(stats.schemasById['user']).toBe(1);
|
|
expect(stats.schemasById['blog-post']).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('handles invalid schema IDs gracefully', async () => {
|
|
const result = await queryEngine.query('nonexistent-schema');
|
|
expect(result.totalFound).toBe(0);
|
|
expect(Object.keys(result.entities)).toHaveLength(0);
|
|
});
|
|
|
|
it('handles malformed JSON Logic expressions', async () => {
|
|
await createUser('user1', 'Alice', 25);
|
|
|
|
const result = await queryEngine.query('user', {
|
|
'invalid-operator': [{ 'var': 'age' }, 25]
|
|
});
|
|
|
|
// Should not crash, may return empty results or skip problematic entities
|
|
expect(result).toBeDefined();
|
|
expect(typeof result.totalFound).toBe('number');
|
|
});
|
|
});
|
|
});
|