feat(query): validate JSON Logic operators and improve error handling

- Add validation for JSON Logic operators in query filters
- Throw InvalidQueryOperatorError for invalid operators
- Improve error handling and logging in applyJsonLogicFilter
- Update tests to verify new error handling behavior
- Fix TypeScript linting issues and improve code style
This commit is contained in:
Lentil Hoffman 2025-06-15 13:17:15 -05:00
parent 51d336b88b
commit 4b750c593d
Signed by: lentil
GPG Key ID: 0F5B99F3F4D0C087
2 changed files with 88 additions and 10 deletions

View File

@ -331,16 +331,28 @@ describe('Query Engine', () => {
expect(Object.keys(result.entities)).toHaveLength(0); expect(Object.keys(result.entities)).toHaveLength(0);
}); });
it('handles malformed JSON Logic expressions', async () => { it('rejects invalid JSON Logic operators', async () => {
await createUser('user1', 'Alice', 25); await createUser('user1', 'Alice', 25);
const result = await queryEngine.query('user', { // Should throw an error for invalid operator
await expect(
queryEngine.query('user', {
'invalid-operator': [{ 'var': 'age' }, 25] 'invalid-operator': [{ 'var': 'age' }, 25]
})
).rejects.toThrow('Invalid query operator: invalid-operator');
}); });
// Should not crash, may return empty results or skip problematic entities it('handles valid JSON Logic expressions with runtime errors', async () => {
await createUser('user1', 'Alice', 25);
// This is a valid operator but will cause a runtime error due to type mismatch
const result = await queryEngine.query('user', {
'>': [{ 'var': 'name' }, 25] // Can't compare string and number with >
});
// Should still return a result but log the error
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(typeof result.totalFound).toBe('number'); expect(result.totalFound).toBe(0); // No matches due to the error
}); });
}); });
}); });

View File

@ -1,4 +1,4 @@
import { apply } from 'json-logic-js'; import { apply, is_logic } from 'json-logic-js';
import Debug from 'debug'; import Debug from 'debug';
import { SchemaRegistry, SchemaID, ObjectSchema } from '../schema/schema'; import { SchemaRegistry, SchemaID, ObjectSchema } from '../schema/schema';
import { Lossless, LosslessViewOne, LosslessViewMany, CollapsedDelta } from '../views/lossless'; import { Lossless, LosslessViewOne, LosslessViewMany, CollapsedDelta } from '../views/lossless';
@ -7,6 +7,21 @@ import { DeltaFilter } from '../core/delta';
const debug = Debug('rz:query'); 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 type JsonLogic = Record<string, unknown>;
export interface QueryOptions { export interface QueryOptions {
@ -29,6 +44,33 @@ export class QueryEngine {
/** /**
* Query entities by schema type with optional JSON Logic filter * 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( async query(
schemaId: SchemaID, schemaId: SchemaID,
filter?: JsonLogic, filter?: JsonLogic,
@ -36,6 +78,19 @@ export class QueryEngine {
): Promise<QueryResult> { ): Promise<QueryResult> {
debug(`Querying schema ${schemaId} with filter:`, filter); 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 // 1. Find all entities that could match this schema
const candidateEntityIds = this.discoverEntitiesBySchema(schemaId); const candidateEntityIds = this.discoverEntitiesBySchema(schemaId);
debug(`Found ${candidateEntityIds.length} candidate entities for schema ${schemaId}`); debug(`Found ${candidateEntityIds.length} candidate entities for schema ${schemaId}`);
@ -154,12 +209,14 @@ export class QueryEngine {
} }
const filteredViews: LosslessViewMany = {}; const filteredViews: LosslessViewMany = {};
let hasFilterErrors = false;
const filterErrors: string[] = [];
for (const [entityId, view] of Object.entries(views)) { for (const [entityId, view] of Object.entries(views)) {
try {
// Convert lossless view to queryable object using schema // Convert lossless view to queryable object using schema
const queryableObject = this.losslessViewToQueryableObject(view, schema); const queryableObject = this.losslessViewToQueryableObject(view, schema);
try {
// Apply JSON Logic filter // Apply JSON Logic filter
const matches = apply(filter, queryableObject); const matches = apply(filter, queryableObject);
@ -170,11 +227,20 @@ export class QueryEngine {
debug(`Entity ${entityId} does not match filter`); debug(`Entity ${entityId} does not match filter`);
} }
} catch (error) { } catch (error) {
debug(`Error applying filter to entity ${entityId}:`, error); hasFilterErrors = true;
// Skip entities that cause filter errors 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; return filteredViews;
} }