Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mighty-numbers-run.md
Original file line number Diff line number Diff line change
@@ -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

41 changes: 20 additions & 21 deletions packages/hypergraph/src/entity/findMany.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,11 @@ export function findMany<const S extends AnyNoContext>(
const filtered: Array<Entity<S>> = [];

const evaluateFilter = <T>(fieldFilter: EntityFieldFilter<T>, fieldValue: T): boolean => {
const ff = fieldFilter as unknown as Record<string, unknown>;
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;
Expand Down Expand Up @@ -316,33 +315,26 @@ export function findMany<const S extends AnyNoContext>(
crossFieldFilter: CrossFieldFilter<Schema.Schema.Type<S>>,
entity: Entity<S>,
): 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<unknown> | undefined;
const fieldFilter = crossFieldFilter[fieldName];
if (!fieldFilter) continue;
const fieldValue = (entity as unknown as Record<string, unknown>)[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<string, unknown>;
const maybeOr = cf.or;
if (Array.isArray(maybeOr)) {
const orFilters = maybeOr as Array<CrossFieldFilter<Schema.Schema.Type<S>>>;
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<Schema.Schema.Type<S>>;
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;
Expand Down Expand Up @@ -380,7 +372,14 @@ export function findMany<const S extends AnyNoContext>(
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);
}
}
Expand Down
67 changes: 54 additions & 13 deletions packages/hypergraph/test/entity/findMany.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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(() => {
// Filter by name NOT equal to 'John'
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 });
Expand Down Expand Up @@ -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 equal to 'John'
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.");
});
});

Expand Down
Loading