Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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(() => {
// 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 });
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 (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.");
});
});

Expand Down
Loading