From 4b750c593d42120855a0163ab088b73d3476b3d6 Mon Sep 17 00:00:00 2001 From: Lentil Hoffman Date: Sun, 15 Jun 2025 13:17:15 -0500 Subject: [PATCH] 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 --- __tests__/query.ts | 20 ++++++++-- src/query/query-engine.ts | 78 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/__tests__/query.ts b/__tests__/query.ts index 80d1c58..f89928b 100644 --- a/__tests__/query.ts +++ b/__tests__/query.ts @@ -331,16 +331,28 @@ describe('Query Engine', () => { 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); + // Should throw an error for invalid operator + await expect( + queryEngine.query('user', { + 'invalid-operator': [{ 'var': 'age' }, 25] + }) + ).rejects.toThrow('Invalid query operator: invalid-operator'); + }); + + 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', { - 'invalid-operator': [{ 'var': 'age' }, 25] + '>': [{ 'var': 'name' }, 25] // Can't compare string and number with > }); - // Should not crash, may return empty results or skip problematic entities + // Should still return a result but log the error expect(result).toBeDefined(); - expect(typeof result.totalFound).toBe('number'); + expect(result.totalFound).toBe(0); // No matches due to the error }); }); }); diff --git a/src/query/query-engine.ts b/src/query/query-engine.ts index 7c8bb72..fe8cb23 100644 --- a/src/query/query-engine.ts +++ b/src/query/query-engine.ts @@ -1,4 +1,4 @@ -import { apply } from 'json-logic-js'; +import { apply, is_logic } from 'json-logic-js'; import Debug from 'debug'; import { SchemaRegistry, SchemaID, ObjectSchema } from '../schema/schema'; import { Lossless, LosslessViewOne, LosslessViewMany, CollapsedDelta } from '../views/lossless'; @@ -7,6 +7,21 @@ 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; export interface QueryOptions { @@ -29,12 +44,52 @@ export class QueryEngine { /** * 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; + 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 { 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); @@ -154,12 +209,14 @@ export class QueryEngine { } const filteredViews: LosslessViewMany = {}; + let hasFilterErrors = false; + const filterErrors: string[] = []; for (const [entityId, view] of Object.entries(views)) { - // Convert lossless view to queryable object using schema - const queryableObject = this.losslessViewToQueryableObject(view, schema); - try { + // Convert lossless view to queryable object using schema + const queryableObject = this.losslessViewToQueryableObject(view, schema); + // Apply JSON Logic filter const matches = apply(filter, queryableObject); @@ -170,11 +227,20 @@ export class QueryEngine { debug(`Entity ${entityId} does not match filter`); } } catch (error) { - debug(`Error applying filter to entity ${entityId}:`, error); - // Skip entities that cause filter errors + 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; }