346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
import { QueryEngine } from '../src/query';
|
|
import { Lossless } from '../src/views';
|
|
import { DefaultSchemaRegistry } from '../src/schema';
|
|
import { CommonSchemas, SchemaBuilder, PrimitiveSchemas } from '../src/schema';
|
|
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');
|
|
});
|
|
});
|
|
});
|