diff --git a/.changeset/mighty-numbers-run.md b/.changeset/mighty-numbers-run.md new file mode 100644 index 00000000..c612d810 --- /dev/null +++ b/.changeset/mighty-numbers-run.md @@ -0,0 +1,6 @@ +--- +"@graphprotocol/hypergraph": minor +--- + +rework filter logic to match public filter logic - logic operators are only allowed at the cross-field level + \ No newline at end of file diff --git a/packages/hypergraph/src/entity/findMany.ts b/packages/hypergraph/src/entity/findMany.ts index afca4b8b..297d0190 100644 --- a/packages/hypergraph/src/entity/findMany.ts +++ b/packages/hypergraph/src/entity/findMany.ts @@ -255,12 +255,11 @@ export function findMany( const filtered: Array> = []; const evaluateFilter = (fieldFilter: EntityFieldFilter, fieldValue: T): boolean => { - const ff = fieldFilter as unknown as Record; - if ('not' in ff || 'or' in ff || 'and' in ff) { - throw new Error("Logical operators 'not', 'or', 'and' are only allowed at the root (cross-field) level."); + if ('not' in fieldFilter || 'or' in fieldFilter) { + throw new Error("Logical operators 'not', 'or' are only allowed at the root (cross-field) level."); } - // Handle basic filters + // handle basic filters if ('is' in fieldFilter) { if (typeof fieldValue === 'boolean') { return fieldValue === fieldFilter.is; @@ -316,33 +315,26 @@ export function findMany( crossFieldFilter: CrossFieldFilter>, entity: Entity, ): boolean => { - // Evaluate regular field filters with AND semantics + // evaluate regular field filters with AND semantics for (const fieldName in crossFieldFilter) { if (fieldName === 'or' || fieldName === 'not') continue; - const fieldFilter = crossFieldFilter[fieldName] as unknown as EntityFieldFilter | undefined; + const fieldFilter = crossFieldFilter[fieldName]; if (!fieldFilter) continue; - const fieldValue = (entity as unknown as Record)[fieldName] as unknown; + const fieldValue = entity[fieldName]; if (!evaluateFilter(fieldFilter, fieldValue)) { return false; } } - // Evaluate nested OR at cross-field level (if present) - const cf = crossFieldFilter as unknown as Record; - const maybeOr = cf.or; - if (Array.isArray(maybeOr)) { - const orFilters = maybeOr as Array>>; - const orSatisfied = orFilters.some((orFilter) => evaluateCrossFieldFilter(orFilter, entity)); + // evaluate nested OR at cross-field level (if present) + if (Array.isArray(crossFieldFilter.or)) { + const orSatisfied = crossFieldFilter.or.some((orFilter) => evaluateCrossFieldFilter(orFilter, entity)); if (!orSatisfied) return false; } - // Evaluate nested NOT at cross-field level (if present) - const maybeNot = cf.not; - if (maybeNot) { - const notFilter = maybeNot as CrossFieldFilter>; - if (evaluateCrossFieldFilter(notFilter, entity)) { - return false; - } + // evaluate nested NOT at cross-field level (if present) + if (crossFieldFilter.not && evaluateCrossFieldFilter(crossFieldFilter.not, entity)) { + return false; } return true; @@ -380,7 +372,14 @@ export function findMany( decoded.__schema = type; filtered.push(decoded); } - } catch (_error) { + } catch (error) { + // rethrow in case it's filter error + if ( + error instanceof Error && + error.message.includes("Logical operators 'not', 'or' are only allowed at the root (cross-field) level") + ) { + throw error; + } corruptEntityIds.push(id); } } diff --git a/packages/hypergraph/test/entity/findMany.test.ts b/packages/hypergraph/test/entity/findMany.test.ts index 811426e2..23c639a3 100644 --- a/packages/hypergraph/test/entity/findMany.test.ts +++ b/packages/hypergraph/test/entity/findMany.test.ts @@ -201,7 +201,27 @@ describe('findMany with filters', () => { expect(result.entities.map((e) => e.name).sort()).toEqual(['Bob', 'Jane']); }); - it('should filter entities using NOT operator with number fields', () => { + it('should throw an error if NOT operator is used at the field level', () => { + // Create test entities + Entity.create(handle, Person)({ name: 'John', age: 30, isActive: true }); + Entity.create(handle, Person)({ name: 'Jane', age: 25, isActive: true }); + Entity.create(handle, Person)({ name: 'Bob', age: 40, isActive: false }); + + expect(() => { + // Filter by name NOT equal to 'John' + Entity.findMany( + handle, + Person, + { + // @ts-expect-error + name: { not: { is: 'John' } }, + }, + undefined, + ); + }).toThrowError("Logical operators 'not', 'or' are only allowed at the root (cross-field) level."); + }); + + it.skip('should filter entities using NOT operator with number fields', () => { // Create test entities Entity.create(handle, Person)({ name: 'John', age: 30, isActive: true }); Entity.create(handle, Person)({ name: 'Jane', age: 25, isActive: true }); @@ -241,6 +261,26 @@ describe('findMany with filters', () => { expect(result.entities.map((e) => e.name).sort()).toEqual(['Jane', 'John']); }); + it('should throw an error if OR operator is used at the field level', () => { + // Create test entities + Entity.create(handle, Person)({ name: 'John', age: 30, isActive: true }); + Entity.create(handle, Person)({ name: 'Jane', age: 25, isActive: true }); + Entity.create(handle, Person)({ name: 'Bob', age: 40, isActive: false }); + + expect(() => { + // Attempt to use OR operator at the field level (should throw) + Entity.findMany( + handle, + Person, + { + // @ts-expect-error + name: { or: [{ is: 'John' }, { is: 'Jane' }] }, + }, + undefined, + ); + }).toThrowError("Logical operators 'not', 'or' are only allowed at the root (cross-field) level."); + }); + it('should filter entities using OR operator with number fields', () => { // Create test entities Entity.create(handle, Person)({ name: 'John', age: 30, isActive: true }); @@ -281,23 +321,24 @@ describe('findMany with filters', () => { expect(result.entities[0].name).toBe('Bob'); }); - it('should filter entities using NOT with OR operator', () => { + it('should throw an error if NOT operator is used at the field level', () => { // Create test entities Entity.create(handle, Person)({ name: 'John', age: 30, isActive: true }); Entity.create(handle, Person)({ name: 'Jane', age: 25, isActive: true }); Entity.create(handle, Person)({ name: 'Bob', age: 40, isActive: false }); - // Filter by NOT (name equal to 'John' OR 'Jane') - const result = Entity.findMany( - handle, - Person, - { - not: { or: [{ name: { is: 'John' } }, { name: { is: 'Jane' } }] }, - }, - undefined, - ); - expect(result.entities).toHaveLength(1); - expect(result.entities[0].name).toBe('Bob'); + expect(() => { + // Filter by name NOT (name is 'John' OR name is 'Jane') + Entity.findMany( + handle, + Person, + { + // @ts-expect-error + name: { not: { or: [{ is: 'John' }, { is: 'Jane' }] } }, + }, + undefined, + ); + }).toThrowError("Logical operators 'not', 'or' are only allowed at the root (cross-field) level."); }); });