From 0155eb707143b841e5d7140d863636dd4f96a7a7 Mon Sep 17 00:00:00 2001 From: Ryan Marganti Date: Sat, 7 Feb 2026 22:34:58 -0500 Subject: [PATCH 1/3] feat: nested fields --- packages/bigquery/src/bigquery-filter.spec.ts | 63 +++++++++++++++++++ packages/bigquery/src/bigquery-filter.ts | 8 ++- packages/core/src/index.ts | 8 ++- packages/core/src/types.spec.ts | 58 ++++++++++++++++- packages/core/src/types.ts | 58 +++++++++++++++-- packages/drizzle/src/drizzle-filter.spec.ts | 10 +++ packages/drizzle/src/drizzle-filter.ts | 9 +++ .../in-memory/src/in-memory-filter.spec.ts | 57 +++++++++++++++++ packages/in-memory/src/in-memory-filter.ts | 48 ++++++++++---- 9 files changed, 297 insertions(+), 22 deletions(-) diff --git a/packages/bigquery/src/bigquery-filter.spec.ts b/packages/bigquery/src/bigquery-filter.spec.ts index 7c9a6b0..0ccea0f 100644 --- a/packages/bigquery/src/bigquery-filter.spec.ts +++ b/packages/bigquery/src/bigquery-filter.spec.ts @@ -878,3 +878,66 @@ describe("BigQuery Integration Usage Pattern", () => { // }); }); }); + +describe("Nested field filtering", () => { + interface UserWithAddress { + name: { first: string; last: string }; + address: { city: string; geo: { lat: number; lng: number } }; + } + + it("eq on nested field produces dot-path SQL with sanitized param", () => { + const filter = bigqueryFilter().def({ + firstName: { kind: "eq", field: "name.first" }, + }); + const result = filter({ firstName: "Alice" }); + expect(result).toEqual({ + sql: "name.first = @firstName", + params: { firstName: "Alice" }, + }); + }); + + it("eq using key-as-path sanitizes param key", () => { + const filter = bigqueryFilter().def({ + "name.first": { kind: "eq" }, + }); + const result = filter({ "name.first": "Bob" }); + expect(result).toEqual({ + sql: "name.first = @name_first", + params: { name_first: "Bob" }, + }); + }); + + it("contains on nested field", () => { + const filter = bigqueryFilter().def({ + cityContains: { + kind: "contains", + field: "address.city", + caseInsensitive: true, + }, + }); + const result = filter({ cityContains: "port" }); + expect(result).toEqual({ + sql: "LOWER(address.city) LIKE LOWER(@cityContains)", + params: { cityContains: "%port%" }, + }); + }); + + it("gt on deeply nested field", () => { + const filter = bigqueryFilter().def({ + latAbove: { kind: "gt", field: "address.geo.lat" }, + }); + const result = filter({ latAbove: 46 }); + expect(result).toEqual({ + sql: "address.geo.lat > @latAbove", + params: { latAbove: 46 }, + }); + }); + + it("type-checks: nested field input has correct type", () => { + const filter = bigqueryFilter().def({ + firstName: { kind: "eq", field: "name.first" }, + }); + type Input = BigQueryFilterInput; + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/bigquery/src/bigquery-filter.ts b/packages/bigquery/src/bigquery-filter.ts index 1631b44..646ba57 100644 --- a/packages/bigquery/src/bigquery-filter.ts +++ b/packages/bigquery/src/bigquery-filter.ts @@ -9,6 +9,8 @@ import type { ValidateFilterDef, } from "@filter-def/core"; +const sanitizeParamKey = (key: string): string => key.replace(/\./g, "_"); + // ---------------------------------------------------------------- // Entry Point // ---------------------------------------------------------------- @@ -195,7 +197,7 @@ const compileFilterDef = >( continue; } - const result = compiler(filterValue, key); + const result = compiler(filterValue, sanitizeParamKey(key)); sqlFragments.push(result.sql); Object.assign(allParams, result.params); } @@ -265,7 +267,7 @@ const compileBooleanFilter = ( for (let i = 0; i < compiledConditions.length; i++) { const result = compiledConditions[i]( filterValue, - `${key}_${i}`, + `${sanitizeParamKey(key)}_${i}`, ); fragments.push(result.sql); Object.assign(params, result.params); @@ -285,7 +287,7 @@ const compileBooleanFilter = ( for (let i = 0; i < compiledConditions.length; i++) { const result = compiledConditions[i]( filterValue, - `${key}_${i}`, + `${sanitizeParamKey(key)}_${i}`, ); fragments.push(result.sql); Object.assign(params, result.params); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7f8ee08..8a6fa86 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,4 +36,10 @@ export type { AndFilter, BooleanFilter, OrFilter } from "./types.ts"; export type { ValidateFilterDef } from "./types.ts"; // Utilities -export type { GetFieldForFilter, Simplify, TypeError } from "./types.ts"; +export type { + FieldPath, + GetFieldForFilter, + PathValue, + Simplify, + TypeError, +} from "./types.ts"; diff --git a/packages/core/src/types.spec.ts b/packages/core/src/types.spec.ts index bbcf3c8..00fce48 100644 --- a/packages/core/src/types.spec.ts +++ b/packages/core/src/types.spec.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, it } from "vitest"; -import type { CoreFilterInput } from "./types.ts"; +import type { CoreFilterInput, FieldPath, PathValue } from "./types.ts"; interface User { id: string | null; @@ -13,3 +13,59 @@ describe("CoreFilterInput", () => { expectTypeOf().toEqualTypeOf(); }); }); + +describe("FieldPath", () => { + interface Nested { + name: { first: string; last: string }; + address: { city: string; geo: { lat: number; lng: number } }; + age: number; + } + + it("should produce top-level keys", () => { + type Paths = FieldPath; + expectTypeOf<"age">().toMatchTypeOf(); + expectTypeOf<"name">().toMatchTypeOf(); + }); + + it("should produce dot-separated nested paths", () => { + type Paths = FieldPath; + expectTypeOf<"name.first">().toMatchTypeOf(); + expectTypeOf<"address.geo.lat">().toMatchTypeOf(); + }); + + it("should not produce invalid paths", () => { + type Paths = FieldPath; + expectTypeOf<"invalid">().not.toMatchTypeOf(); + expectTypeOf<"name.middle">().not.toMatchTypeOf(); + }); +}); + +describe("PathValue", () => { + interface Nested { + name: { first: string; last: string }; + age: number; + } + + it("should resolve top-level field types", () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it("should resolve nested field types", () => { + expectTypeOf>().toEqualTypeOf(); + }); +}); + +describe("CoreFilterInput with nested fields", () => { + interface Nested { + name: { first: string; last: string }; + } + + it("should resolve input type for nested field path", () => { + type EqInput = CoreFilterInput< + "firstName", + Nested, + { kind: "eq"; field: "name.first" } + >; + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8fc10ec..7d5aa71 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -37,8 +37,53 @@ export type PrimitiveFilter = | LTFilter | LTEFilter; +type DepthLimit = [never, 0, 1, 2, 3]; + +type IsPlainObject = T extends object + ? T extends Function | readonly any[] | Date + ? false + : true + : false; + +/** + * Produces a union of all dot-separated paths into T. + * + * FieldPath<{ name: { first: string } }> + * // => "name" | "name.first" + * + * Depth is capped to avoid TS instantiation-depth errors on large types. + */ +export type FieldPath = [Depth] extends [0] + ? never + : IsPlainObject extends true + ? { + [K in Extract]: + | K + | (IsPlainObject> extends true + ? `${K}.${FieldPath, DepthLimit[Depth]>}` + : never); + }[Extract] + : never; + +/** + * Resolves the value type at a dot-separated path. + * + * PathValue<{ name: { first: string } }, "name.first"> + * // => string + */ +export type PathValue< + T, + P extends string, +> = P extends `${infer K}.${infer Rest}` + ? K extends keyof T + ? PathValue, Rest> + : never + : P extends keyof T + ? T[P] + : never; + export interface CommonFilterOptions { - field?: keyof Entity; + field?: FieldPath; } /** @@ -172,7 +217,10 @@ type FieldTypeForFilter< TFilterField extends CoreFilter, TFilter extends CoreFilter, > = Exclude< - Entity[GetFieldForFilter>], + PathValue< + Entity, + GetFieldForFilter> & string + >, null | undefined >; @@ -304,7 +352,7 @@ export type ValidateFilterDef = { : TFilterDef[K] extends (...args: any[]) => any ? // Custom filters do not rely on fields, so they are always valid. TFilterDef[K] - : K extends keyof Entity + : K extends FieldPath ? // We otherwise require the filter key to be a valid field. TFilterDef[K] : // Everything else is invalid @@ -353,8 +401,8 @@ export type GetFieldForFilter< K extends PropertyKey, Entity, TFilterField, -> = TFilterField extends { field: infer F extends keyof Entity } +> = TFilterField extends { field: infer F extends FieldPath } ? F - : K extends keyof Entity + : K extends FieldPath ? K : never; diff --git a/packages/drizzle/src/drizzle-filter.spec.ts b/packages/drizzle/src/drizzle-filter.spec.ts index 8ba425d..a289e8b 100644 --- a/packages/drizzle/src/drizzle-filter.spec.ts +++ b/packages/drizzle/src/drizzle-filter.spec.ts @@ -1228,3 +1228,13 @@ describe("SQL Output Verification", () => { expect(where).toBeDefined(); }); }); + +describe("Nested field paths", () => { + it("should throw for nested field paths", () => { + expect(() => + drizzleFilter(usersTable).def({ + firstName: { kind: "eq", field: "name.first" as any }, + }), + ).toThrow(/Nested field path.*not supported by drizzleFilter/); + }); +}); diff --git a/packages/drizzle/src/drizzle-filter.ts b/packages/drizzle/src/drizzle-filter.ts index bdf994b..e8b01f3 100644 --- a/packages/drizzle/src/drizzle-filter.ts +++ b/packages/drizzle/src/drizzle-filter.ts @@ -285,6 +285,15 @@ const compilePrimitiveFilter = ( filterField: PrimitiveFilter, ): CompiledFilterField => { const fieldName = (filterField.field ?? key) as string; + + if (fieldName.includes(".")) { + throw new Error( + `Nested field path "${fieldName}" is not supported by drizzleFilter. ` + + `Drizzle operates on flat table columns. Use a custom filter with ` + + `JSON operators or joins for nested data.`, + ); + } + const column = columns[fieldName]; if (!column) { diff --git a/packages/in-memory/src/in-memory-filter.spec.ts b/packages/in-memory/src/in-memory-filter.spec.ts index 4594786..ca9872c 100644 --- a/packages/in-memory/src/in-memory-filter.spec.ts +++ b/packages/in-memory/src/in-memory-filter.spec.ts @@ -1214,3 +1214,60 @@ describe("Boolean Filter Field Requirement Validation", () => { expect(true).toBe(true); }); }); + +describe("Nested field filtering", () => { + interface UserWithAddress { + name: { first: string; last: string }; + address: { city: string; geo: { lat: number; lng: number } }; + } + + const testUsers: UserWithAddress[] = [ + { + name: { first: "Alice", last: "Smith" }, + address: { city: "Portland", geo: { lat: 45.5, lng: -122.7 } }, + }, + { + name: { first: "Bob", last: "Jones" }, + address: { city: "Seattle", geo: { lat: 47.6, lng: -122.3 } }, + }, + ]; + + it("eq on nested field", () => { + const filter = inMemoryFilter().def({ + firstName: { kind: "eq", field: "name.first" }, + }); + const results = testUsers.filter(filter({ firstName: "Bob" })); + expect(results).toHaveLength(1); + expect(results[0].name.first).toBe("Bob"); + }); + + it("contains on nested field", () => { + const filter = inMemoryFilter().def({ + cityContains: { + kind: "contains", + field: "address.city", + caseInsensitive: true, + }, + }); + const results = testUsers.filter(filter({ cityContains: "port" })); + expect(results).toHaveLength(1); + expect(results[0].address.city).toBe("Portland"); + }); + + it("gt on deeply nested field", () => { + const filter = inMemoryFilter().def({ + latGreaterThan: { kind: "gt", field: "address.geo.lat" }, + }); + const results = testUsers.filter(filter({ latGreaterThan: 46 })); + expect(results).toHaveLength(1); + expect(results[0].name.first).toBe("Bob"); + }); + + it("type-checks: nested field input has correct type", () => { + const filter = inMemoryFilter().def({ + firstName: { kind: "eq", field: "name.first" }, + }); + type Input = InMemoryFilterInput; + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/in-memory/src/in-memory-filter.ts b/packages/in-memory/src/in-memory-filter.ts index 0943317..e6a81c7 100644 --- a/packages/in-memory/src/in-memory-filter.ts +++ b/packages/in-memory/src/in-memory-filter.ts @@ -287,6 +287,20 @@ const compileBooleanFilter = ( } }; +const getByPath = (obj: unknown, path: string): unknown => { + if (!path.includes(".")) return (obj as Record)?.[path]; + + const segments = path.split("."); + let current: unknown = obj; + + for (const segment of segments) { + if (current == null) return undefined; + current = (current as Record)[segment]; + } + + return current; +}; + /** * Pre-compiles a primitive filter definition into an optimized checker function. */ @@ -294,24 +308,28 @@ const compilePrimitiveFilter = ( key: string, filterField: PrimitiveFilter, ): CompiledFilterField => { - const field = (filterField.field ?? key) as keyof Entity; + const fieldPath = (filterField.field ?? key) as string; switch (filterField.kind) { case "eq": - return (entity, filterValue) => entity[field] === filterValue; + return (entity, filterValue) => + getByPath(entity, fieldPath) === filterValue; case "neq": - return (entity, filterValue) => entity[field] !== filterValue; + return (entity, filterValue) => + getByPath(entity, fieldPath) !== filterValue; case "contains": { return (entity, filterValue) => { const { entityVal, filterVal } = filterField.caseInsensitive ? { - entityVal: String(entity[field]).toLocaleLowerCase(), + entityVal: String( + getByPath(entity, fieldPath), + ).toLocaleLowerCase(), filterVal: String(filterValue).toLocaleLowerCase(), } : { - entityVal: String(entity[field]), + entityVal: String(getByPath(entity, fieldPath)), filterVal: String(filterValue), }; @@ -321,31 +339,37 @@ const compilePrimitiveFilter = ( case "inArray": return (entity, filterValue) => - (filterValue as unknown[]).includes(entity[field]); + (filterValue as unknown[]).includes( + getByPath(entity, fieldPath), + ); case "isNull": return (entity, filterValue) => - filterValue ? entity[field] == null : entity[field] != null; + filterValue + ? getByPath(entity, fieldPath) == null + : getByPath(entity, fieldPath) != null; case "isNotNull": return (entity, filterValue) => - filterValue ? entity[field] != null : entity[field] == null; + filterValue + ? getByPath(entity, fieldPath) != null + : getByPath(entity, fieldPath) == null; case "gt": return (entity, filterValue) => - Number(entity[field]) > (filterValue as number); + Number(getByPath(entity, fieldPath)) > (filterValue as number); case "gte": return (entity, filterValue) => - Number(entity[field]) >= (filterValue as number); + Number(getByPath(entity, fieldPath)) >= (filterValue as number); case "lt": return (entity, filterValue) => - Number(entity[field]) < (filterValue as number); + Number(getByPath(entity, fieldPath)) < (filterValue as number); case "lte": return (entity, filterValue) => - Number(entity[field]) <= (filterValue as number); + Number(getByPath(entity, fieldPath)) <= (filterValue as number); default: filterField satisfies never; From a54a99d1699d03c1e89b1e3d55e821df4f9135da Mon Sep 17 00:00:00 2001 From: Ryan Marganti Date: Sat, 7 Feb 2026 22:51:59 -0500 Subject: [PATCH 2/3] chore: changeset --- .changeset/twenty-chefs-grab.md | 36 ++++++++++ packages/bigquery/src/bigquery-filter.spec.ts | 14 ++++ packages/in-memory/examples/nested-fields.ts | 71 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 .changeset/twenty-chefs-grab.md create mode 100644 packages/in-memory/examples/nested-fields.ts diff --git a/.changeset/twenty-chefs-grab.md b/.changeset/twenty-chefs-grab.md new file mode 100644 index 0000000..7f2adc8 --- /dev/null +++ b/.changeset/twenty-chefs-grab.md @@ -0,0 +1,36 @@ +--- +"@filter-def/in-memory": minor +"@filter-def/bigquery": minor +"@filter-def/drizzle": minor +"@filter-def/core": minor +--- + +## Changes + +- Adds support for filtering on nested fields in `@filter-def/in-memory` and `@filter-def/bigquery` packages +- errors for `@filter-def/drizzle`, due to driver-specific implementations. For now, we are driver agnostic. + +## Example (BigQuery): + +```typescript +interface UserWithAddress { + name: { first: string; last: string }; + address: { city: string; geo: { lat: number; lng: number } }; +} + +interface UserWithAddress { + name: { first: string; last: string }; + address: { city: string; geo: { lat: number; lng: number } }; +} + +const userFilter = bigqueryFilter().def({ + firstName: { kind: "eq", field: "name.first" }, + lat: { kind: "eq", field: "address.geo.lat" }, +}); + +const where = userFilter({ firstName: 'Bob', lat: 25 }); +// { +// sql: 'name.first = @firstName AND address.geo.lat = @lat', +// params: { firstName: 'Bob', lat: 21 }, +// } +``` diff --git a/packages/bigquery/src/bigquery-filter.spec.ts b/packages/bigquery/src/bigquery-filter.spec.ts index 0ccea0f..8a1a4f2 100644 --- a/packages/bigquery/src/bigquery-filter.spec.ts +++ b/packages/bigquery/src/bigquery-filter.spec.ts @@ -896,6 +896,20 @@ describe("Nested field filtering", () => { }); }); + it("handles booleans with nested fields", () => { + const filter = bigqueryFilter().def({ + firstName: { kind: "eq", field: "name.first" }, + lat: { kind: "eq", field: "address.geo.lat" }, + }); + + const result = filter({ firstName: "Bob", lat: 21 }); + + expect(result).toEqual({ + sql: "name.first = @firstName AND address.geo.lat = @lat", + params: { firstName: "Bob", lat: 21 }, + }); + }); + it("eq using key-as-path sanitizes param key", () => { const filter = bigqueryFilter().def({ "name.first": { kind: "eq" }, diff --git a/packages/in-memory/examples/nested-fields.ts b/packages/in-memory/examples/nested-fields.ts new file mode 100644 index 0000000..5289508 --- /dev/null +++ b/packages/in-memory/examples/nested-fields.ts @@ -0,0 +1,71 @@ +import { inMemoryFilter } from "@filter-def/in-memory"; + +// ---------------------------------------------------------------- +// Model +// ---------------------------------------------------------------- + +interface Employee { + name: { first: string; last: string }; + department: string; + address: { city: string; geo: { lat: number; lng: number } }; +} + +// ---------------------------------------------------------------- +// Sample data +// ---------------------------------------------------------------- + +const employees: Employee[] = [ + { + name: { first: "Alice", last: "Chen" }, + department: "engineering", + address: { city: "Portland", geo: { lat: 45.5, lng: -122.7 } }, + }, + { + name: { first: "Bob", last: "Smith" }, + department: "design", + address: { city: "Seattle", geo: { lat: 47.6, lng: -122.3 } }, + }, + { + name: { first: "Carol", last: "Chen" }, + department: "engineering", + address: { city: "San Francisco", geo: { lat: 37.8, lng: -122.4 } }, + }, +]; + +// ---------------------------------------------------------------- +// Filter definition +// ---------------------------------------------------------------- + +const employeeFilter = inMemoryFilter().def({ + firstName: { kind: "eq", field: "name.first" }, + lastName: { kind: "eq", field: "name.last" }, + department: { kind: "eq" }, + cityContains: { + kind: "contains", + field: "address.city", + caseInsensitive: true, + }, + minLatitude: { kind: "gte", field: "address.geo.lat" }, +}); + +// ---------------------------------------------------------------- +// Examples +// ---------------------------------------------------------------- + +// Filter by nested name field +const chenEmployees = employees.filter(employeeFilter({ lastName: "Chen" })); +console.log("Employees with last name Chen:", chenEmployees.length); + +// Combine nested and flat fields +const chenEngineers = employees.filter( + employeeFilter({ lastName: "Chen", department: "engineering" }), +); +console.log("Chen engineers:", chenEngineers.length); + +// Search nested string field +const portlandArea = employees.filter(employeeFilter({ cityContains: "port" })); +console.log("Employees in cities containing 'port':", portlandArea.length); + +// Filter on deeply nested numeric field +const northernEmployees = employees.filter(employeeFilter({ minLatitude: 46 })); +console.log("Employees above 46° latitude:", northernEmployees.length); From bed5727d414399fd71f867a6a6d435c60ab85468 Mon Sep 17 00:00:00 2001 From: Ryan Marganti Date: Sun, 8 Feb 2026 09:18:00 -0500 Subject: [PATCH 3/3] chore: small cleanup --- packages/bigquery/src/bigquery-filter.ts | 8 +++++-- packages/core/src/types.ts | 28 +++++++++++++++--------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/bigquery/src/bigquery-filter.ts b/packages/bigquery/src/bigquery-filter.ts index 646ba57..c5f4410 100644 --- a/packages/bigquery/src/bigquery-filter.ts +++ b/packages/bigquery/src/bigquery-filter.ts @@ -9,8 +9,6 @@ import type { ValidateFilterDef, } from "@filter-def/core"; -const sanitizeParamKey = (key: string): string => key.replace(/\./g, "_"); - // ---------------------------------------------------------------- // Entry Point // ---------------------------------------------------------------- @@ -385,3 +383,9 @@ const compilePrimitiveFilter = ( return () => EMPTY_FILTER_RESULT; } }; + +/** + * Sanitizes a filter key to be used as a BigQuery parameter name + * by replacing dots with underscores. + */ +const sanitizeParamKey = (key: string): string => key.replace(/\./g, "_"); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7d5aa71..18d2bff 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -37,14 +37,6 @@ export type PrimitiveFilter = | LTFilter | LTEFilter; -type DepthLimit = [never, 0, 1, 2, 3]; - -type IsPlainObject = T extends object - ? T extends Function | readonly any[] | Date - ? false - : true - : false; - /** * Produces a union of all dot-separated paths into T. * @@ -65,11 +57,27 @@ export type FieldPath = [Depth] extends [0] }[Extract] : never; +/** + * Helper type to limit recursion depth in FieldPath to prevent TS errors. + */ +type DepthLimit = [never, 0, 1, 2, 3]; + +/** + * Helper type to check if a type is a plain object (not a function, array, or date). + */ +type IsPlainObject = T extends object + ? T extends Function | readonly any[] | Date + ? false + : true + : false; + /** * Resolves the value type at a dot-separated path. * - * PathValue<{ name: { first: string } }, "name.first"> - * // => string + * ```typescript + * PathValue<{ name: { first: string } }, "name.first"> + * // => string + * ``` */ export type PathValue< T,