Skip to content

Commit 9d22312

Browse files
nikgrafCopilot
andauthored
improve filters (#454)
Co-authored-by: Copilot <[email protected]>
1 parent 9cc82be commit 9d22312

File tree

3 files changed

+80
-34
lines changed

3 files changed

+80
-34
lines changed

.changeset/mighty-numbers-run.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@graphprotocol/hypergraph": minor
3+
---
4+
5+
rework filter logic to match public filter logic - logic operators are only allowed at the cross-field level
6+

packages/hypergraph/src/entity/findMany.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -255,12 +255,11 @@ export function findMany<const S extends AnyNoContext>(
255255
const filtered: Array<Entity<S>> = [];
256256

257257
const evaluateFilter = <T>(fieldFilter: EntityFieldFilter<T>, fieldValue: T): boolean => {
258-
const ff = fieldFilter as unknown as Record<string, unknown>;
259-
if ('not' in ff || 'or' in ff || 'and' in ff) {
260-
throw new Error("Logical operators 'not', 'or', 'and' are only allowed at the root (cross-field) level.");
258+
if ('not' in fieldFilter || 'or' in fieldFilter) {
259+
throw new Error("Logical operators 'not', 'or' are only allowed at the root (cross-field) level.");
261260
}
262261

263-
// Handle basic filters
262+
// handle basic filters
264263
if ('is' in fieldFilter) {
265264
if (typeof fieldValue === 'boolean') {
266265
return fieldValue === fieldFilter.is;
@@ -316,33 +315,26 @@ export function findMany<const S extends AnyNoContext>(
316315
crossFieldFilter: CrossFieldFilter<Schema.Schema.Type<S>>,
317316
entity: Entity<S>,
318317
): boolean => {
319-
// Evaluate regular field filters with AND semantics
318+
// evaluate regular field filters with AND semantics
320319
for (const fieldName in crossFieldFilter) {
321320
if (fieldName === 'or' || fieldName === 'not') continue;
322-
const fieldFilter = crossFieldFilter[fieldName] as unknown as EntityFieldFilter<unknown> | undefined;
321+
const fieldFilter = crossFieldFilter[fieldName];
323322
if (!fieldFilter) continue;
324-
const fieldValue = (entity as unknown as Record<string, unknown>)[fieldName] as unknown;
323+
const fieldValue = entity[fieldName];
325324
if (!evaluateFilter(fieldFilter, fieldValue)) {
326325
return false;
327326
}
328327
}
329328

330-
// Evaluate nested OR at cross-field level (if present)
331-
const cf = crossFieldFilter as unknown as Record<string, unknown>;
332-
const maybeOr = cf.or;
333-
if (Array.isArray(maybeOr)) {
334-
const orFilters = maybeOr as Array<CrossFieldFilter<Schema.Schema.Type<S>>>;
335-
const orSatisfied = orFilters.some((orFilter) => evaluateCrossFieldFilter(orFilter, entity));
329+
// evaluate nested OR at cross-field level (if present)
330+
if (Array.isArray(crossFieldFilter.or)) {
331+
const orSatisfied = crossFieldFilter.or.some((orFilter) => evaluateCrossFieldFilter(orFilter, entity));
336332
if (!orSatisfied) return false;
337333
}
338334

339-
// Evaluate nested NOT at cross-field level (if present)
340-
const maybeNot = cf.not;
341-
if (maybeNot) {
342-
const notFilter = maybeNot as CrossFieldFilter<Schema.Schema.Type<S>>;
343-
if (evaluateCrossFieldFilter(notFilter, entity)) {
344-
return false;
345-
}
335+
// evaluate nested NOT at cross-field level (if present)
336+
if (crossFieldFilter.not && evaluateCrossFieldFilter(crossFieldFilter.not, entity)) {
337+
return false;
346338
}
347339

348340
return true;
@@ -380,7 +372,14 @@ export function findMany<const S extends AnyNoContext>(
380372
decoded.__schema = type;
381373
filtered.push(decoded);
382374
}
383-
} catch (_error) {
375+
} catch (error) {
376+
// rethrow in case it's filter error
377+
if (
378+
error instanceof Error &&
379+
error.message.includes("Logical operators 'not', 'or' are only allowed at the root (cross-field) level")
380+
) {
381+
throw error;
382+
}
384383
corruptEntityIds.push(id);
385384
}
386385
}

packages/hypergraph/test/entity/findMany.test.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,27 @@ describe('findMany with filters', () => {
201201
expect(result.entities.map((e) => e.name).sort()).toEqual(['Bob', 'Jane']);
202202
});
203203

204-
it('should filter entities using NOT operator with number fields', () => {
204+
it('should throw an error if NOT operator is used at the field level', () => {
205+
// Create test entities
206+
Entity.create(handle, Person)({ name: 'John', age: 30, isActive: true });
207+
Entity.create(handle, Person)({ name: 'Jane', age: 25, isActive: true });
208+
Entity.create(handle, Person)({ name: 'Bob', age: 40, isActive: false });
209+
210+
expect(() => {
211+
// Filter by name NOT equal to 'John'
212+
Entity.findMany(
213+
handle,
214+
Person,
215+
{
216+
// @ts-expect-error
217+
name: { not: { is: 'John' } },
218+
},
219+
undefined,
220+
);
221+
}).toThrowError("Logical operators 'not', 'or' are only allowed at the root (cross-field) level.");
222+
});
223+
224+
it.skip('should filter entities using NOT operator with number fields', () => {
205225
// Create test entities
206226
Entity.create(handle, Person)({ name: 'John', age: 30, isActive: true });
207227
Entity.create(handle, Person)({ name: 'Jane', age: 25, isActive: true });
@@ -241,6 +261,26 @@ describe('findMany with filters', () => {
241261
expect(result.entities.map((e) => e.name).sort()).toEqual(['Jane', 'John']);
242262
});
243263

264+
it('should throw an error if OR operator is used at the field level', () => {
265+
// Create test entities
266+
Entity.create(handle, Person)({ name: 'John', age: 30, isActive: true });
267+
Entity.create(handle, Person)({ name: 'Jane', age: 25, isActive: true });
268+
Entity.create(handle, Person)({ name: 'Bob', age: 40, isActive: false });
269+
270+
expect(() => {
271+
// Attempt to use OR operator at the field level (should throw)
272+
Entity.findMany(
273+
handle,
274+
Person,
275+
{
276+
// @ts-expect-error
277+
name: { or: [{ is: 'John' }, { is: 'Jane' }] },
278+
},
279+
undefined,
280+
);
281+
}).toThrowError("Logical operators 'not', 'or' are only allowed at the root (cross-field) level.");
282+
});
283+
244284
it('should filter entities using OR operator with number fields', () => {
245285
// Create test entities
246286
Entity.create(handle, Person)({ name: 'John', age: 30, isActive: true });
@@ -281,23 +321,24 @@ describe('findMany with filters', () => {
281321
expect(result.entities[0].name).toBe('Bob');
282322
});
283323

284-
it('should filter entities using NOT with OR operator', () => {
324+
it('should throw an error if NOT operator is used at the field level', () => {
285325
// Create test entities
286326
Entity.create(handle, Person)({ name: 'John', age: 30, isActive: true });
287327
Entity.create(handle, Person)({ name: 'Jane', age: 25, isActive: true });
288328
Entity.create(handle, Person)({ name: 'Bob', age: 40, isActive: false });
289329

290-
// Filter by NOT (name equal to 'John' OR 'Jane')
291-
const result = Entity.findMany(
292-
handle,
293-
Person,
294-
{
295-
not: { or: [{ name: { is: 'John' } }, { name: { is: 'Jane' } }] },
296-
},
297-
undefined,
298-
);
299-
expect(result.entities).toHaveLength(1);
300-
expect(result.entities[0].name).toBe('Bob');
330+
expect(() => {
331+
// Filter by name NOT (name is 'John' OR name is 'Jane')
332+
Entity.findMany(
333+
handle,
334+
Person,
335+
{
336+
// @ts-expect-error
337+
name: { not: { or: [{ is: 'John' }, { is: 'Jane' }] } },
338+
},
339+
undefined,
340+
);
341+
}).toThrowError("Logical operators 'not', 'or' are only allowed at the root (cross-field) level.");
301342
});
302343
});
303344

0 commit comments

Comments
 (0)