rhizome-node/src/query/query-engine.ts
Lentil Hoffman d0941f417e
refactor: optimize lossless view and improve code quality
- Optimize pointer handling and entity reference tracking in Lossless view
- Improve type safety with proper TypeScript types
- Add debug logging for better troubleshooting
- Clean up imports and unused variables
- Update test cases for DeltaBuilder
2025-06-25 11:18:52 -05:00

372 lines
12 KiB
TypeScript

import jsonLogic from 'json-logic-js';
const { apply, is_logic } = jsonLogic;
import Debug from 'debug';
import { SchemaRegistry, SchemaID, ObjectSchema } from '../schema/schema';
import { Lossless, LosslessViewOne, LosslessViewMany, CollapsedDelta } from '../views/lossless';
import { DomainEntityID } from '../core/types';
import { DeltaFilter } from '../core/delta';
const debug = Debug('rz:query');
// List of valid JSON Logic operators
const VALID_OPERATORS = new Set([
'==', '===', '!=', '!==', '>', '>=', '<', '<=', '!', '!!',
'and', 'or', 'if', '?:', '??', '!!', '!', '!!', '!!', '!',
'var', 'missing', 'missing_some', 'in', 'cat', 'log', 'method', 'merge',
'+', '-', '*', '/', '%', 'min', 'max', 'map', 'reduce', 'filter', 'all', 'some', 'none'
]);
class InvalidQueryOperatorError extends Error {
constructor(operator: string) {
super(`Invalid query operator: ${operator}`);
this.name = 'InvalidQueryOperatorError';
}
}
export type JsonLogic = Record<string, unknown>;
export interface QueryOptions {
maxResults?: number;
deltaFilter?: DeltaFilter;
}
export interface QueryResult {
entities: LosslessViewMany;
totalFound: number;
limited: boolean;
}
export class QueryEngine {
constructor(
private lossless: Lossless,
private schemaRegistry: SchemaRegistry
) {}
/**
* Query entities by schema type with optional JSON Logic filter
*/
/**
* Validate JSON Logic operators in a filter
* @throws {InvalidQueryOperatorError} If an invalid operator is found
*/
private validateJsonLogicOperators(logic: unknown): void {
if (!logic || typeof logic !== 'object') {
return;
}
const logicObj = logic as Record<string, unknown>;
const operator = Object.keys(logicObj)[0];
// Check if this is an operator
if (is_logic(logic) && operator && !VALID_OPERATORS.has(operator)) {
throw new InvalidQueryOperatorError(operator);
}
// Recursively check nested logic
for (const value of Object.values(logicObj)) {
if (Array.isArray(value)) {
value.forEach(item => this.validateJsonLogicOperators(item));
} else if (value && typeof value === 'object') {
this.validateJsonLogicOperators(value);
}
}
}
async query(
schemaId: SchemaID,
filter?: JsonLogic,
options: QueryOptions = {}
): Promise<QueryResult> {
debug(`Querying schema ${schemaId} with filter:`, filter);
// Validate filter operators if provided
if (filter) {
try {
this.validateJsonLogicOperators(filter);
} catch (error) {
if (error instanceof InvalidQueryOperatorError) {
debug(`Invalid query operator: ${error.message}`);
throw error; // Re-throw to let the caller handle it
}
throw error;
}
}
// 1. Find all entities that could match this schema
const candidateEntityIds = this.discoverEntitiesBySchema(schemaId);
debug(`Found ${candidateEntityIds.length} candidate entities for schema ${schemaId}`);
// 2. Compose lossless views for all candidates
const allViews = this.lossless.compose(candidateEntityIds, options.deltaFilter);
debug(`Composed ${Object.keys(allViews).length} lossless views`);
// 3. Apply JSON Logic filter if provided
let filteredViews: LosslessViewMany = allViews;
if (filter) {
filteredViews = this.applyJsonLogicFilter(allViews, filter, schemaId);
debug(`After filtering: ${Object.keys(filteredViews).length} entities match`);
}
// 4. Apply result limits if specified
const totalFound = Object.keys(filteredViews).length;
let limited = false;
if (options.maxResults && totalFound > options.maxResults) {
const entityIds = Object.keys(filteredViews).slice(0, options.maxResults);
filteredViews = {};
for (const entityId of entityIds) {
filteredViews[entityId] = allViews[entityId];
}
limited = true;
debug(`Limited results to ${options.maxResults} entities`);
}
return {
entities: filteredViews,
totalFound,
limited
};
}
/**
* Query for a single entity by ID with schema validation
*/
async queryOne(schemaId: SchemaID, entityId: DomainEntityID): Promise<LosslessViewOne | null> {
debug(`Querying single entity ${entityId} with schema ${schemaId}`);
const views = this.lossless.compose([entityId]);
const view = views[entityId];
if (!view) {
debug(`Entity ${entityId} not found`);
return null;
}
// Validate that the entity matches the schema
if (!this.entityMatchesSchema(view, schemaId)) {
debug(`Entity ${entityId} does not match schema ${schemaId}`);
return null;
}
return view;
}
/**
* Discover all entities that could potentially match a given schema
* This is a heuristic based on the schema's required properties
*/
private discoverEntitiesBySchema(schemaId: SchemaID): DomainEntityID[] {
const schema = this.schemaRegistry.get(schemaId);
if (!schema) {
debug(`Schema ${schemaId} not found in registry`);
return [];
}
// Strategy: Find entities that have deltas for the schema's required properties
const requiredProperties = schema.requiredProperties || [];
const allEntityIds = Array.from(this.lossless.domainEntities.keys());
if (requiredProperties.length === 0) {
// No required properties - return all entities
debug(`Schema ${schemaId} has no required properties, returning all entities`);
return allEntityIds;
}
// Find entities that have at least one required property
const candidateEntities: DomainEntityID[] = [];
for (const entityId of allEntityIds) {
const entity = this.lossless.domainEntities.get(entityId);
if (!entity) continue;
// Check if entity has deltas for all required property
const hasRequiredProperties = requiredProperties.every(propertyId =>
entity.properties.has(propertyId)
);
if (hasRequiredProperties) {
candidateEntities.push(entityId);
}
}
debug(`Found ${candidateEntities.length} entities with required properties for schema ${schemaId}`);
return candidateEntities;
}
/**
* Apply JSON Logic filter to lossless views
* This requires converting each lossless view to a queryable object
*/
private applyJsonLogicFilter(
views: LosslessViewMany,
filter: JsonLogic,
schemaId: SchemaID
): LosslessViewMany {
const schema = this.schemaRegistry.get(schemaId);
if (!schema) {
debug(`Cannot filter without schema ${schemaId}`);
return views;
}
const filteredViews: LosslessViewMany = {};
let hasFilterErrors = false;
const filterErrors: string[] = [];
for (const [entityId, view] of Object.entries(views)) {
try {
// Convert lossless view to queryable object using schema
const queryableObject = this.losslessViewToQueryableObject(view, schema);
// Apply JSON Logic filter
const matches = apply(filter, queryableObject);
if (matches) {
filteredViews[entityId] = view;
debug(`Entity ${entityId} matches filter`);
} else {
debug(`Entity ${entityId} does not match filter`);
}
} catch (error) {
hasFilterErrors = true;
const errorMsg = `Error applying filter to entity ${entityId}: ${error instanceof Error ? error.message : String(error)}`;
filterErrors.push(errorMsg);
debug(errorMsg, error);
// Continue processing other entities
}
}
// If we had any filter errors, log them as a warning
if (hasFilterErrors) {
console.warn(`Encountered ${filterErrors.length} filter errors. First error: ${filterErrors[0]}`);
debug('All filter errors:', filterErrors);
}
return filteredViews;
}
/**
* Convert a lossless view to a queryable object based on schema
* Uses simple resolution strategies for now
*/
private losslessViewToQueryableObject(view: LosslessViewOne, schema: ObjectSchema): Record<string, unknown> {
const obj: Record<string, unknown> = {
id: view.id,
_referencedAs: view.referencedAs
};
// Convert each schema property from lossless view deltas
for (const [propertyId, propertySchema] of Object.entries(schema.properties)) {
const deltas = view.propertyDeltas[propertyId] || [];
if (deltas.length === 0) {
obj[propertyId] = null;
continue;
}
// Apply simple resolution strategy based on property schema type
switch (propertySchema.type) {
case 'primitive': {
// Use last-write-wins for primitives
const deltasSorted = deltas.sort((a, b) => b.timeCreated - a.timeCreated);
for (const delta of deltasSorted) {
const primitiveValue = this.extractPrimitiveValue(delta, propertyId);
if (primitiveValue !== null) {
obj[propertyId] = primitiveValue;
}
}
break;
}
case 'array': {
// Collect all values as array
const arrayValues = deltas
.map(delta => this.extractPrimitiveValue(delta, propertyId))
.filter(value => value !== null);
obj[propertyId] = arrayValues;
break;
}
case 'reference': {
// For references, include the target IDs
const refValues = deltas
.map(delta => this.extractReferenceValue(delta, propertyId))
.filter(value => value !== null);
obj[propertyId] = refValues;
break;
}
default:
obj[propertyId] = deltas.length;
}
}
debug(`Converted entity ${view.id} to queryable object:`, obj);
return obj;
}
/**
* Extract primitive value from a delta for a given property
*/
private extractPrimitiveValue(delta: CollapsedDelta, propertyId: string): unknown {
// Look for the value in collapsed pointers
// CollapsedPointer is {[key: PropertyID]: PropertyTypes}
for (const pointer of delta.pointers) {
if (pointer[propertyId] !== undefined) {
return pointer[propertyId];
}
}
return null;
}
/**
* Extract reference value (target ID) from a delta for a given property
*/
private extractReferenceValue(delta: CollapsedDelta, _propertyId: string): string | null {
// For references, we want the value pointer that contains the reference ID
for (const pointer of delta.pointers) {
if (pointer.value !== undefined && typeof pointer.value === 'string') {
return pointer.value;
}
}
return null;
}
/**
* Check if an entity matches a schema (basic validation)
*/
private entityMatchesSchema(view: LosslessViewOne, schemaId: SchemaID): boolean {
const schema = this.schemaRegistry.get(schemaId);
if (!schema) return false;
// Check that all required properties have at least one delta
const requiredProperties = schema.requiredProperties || [];
for (const propertyId of requiredProperties) {
const deltas = view.propertyDeltas[propertyId];
if (!deltas || deltas.length === 0) {
debug(`Entity ${view.id} missing required property ${propertyId} for schema ${schemaId}`);
return false;
}
}
return true;
}
/**
* Get statistics about queryable entities
*/
getStats() {
const totalEntities = this.lossless.domainEntities.size;
const registeredSchemas = this.schemaRegistry.list().length;
return {
totalEntities,
registeredSchemas,
schemasById: this.schemaRegistry.list().reduce((acc, schema) => {
acc[schema.id] = this.discoverEntitiesBySchema(schema.id).length;
return acc;
}, {} as Record<string, number>)
};
}
}