diff --git a/package.json b/package.json index 9ae78eb..f907d8b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "scripts": { "build": "tsc", "lint": "eslint src", - "test": "vitest run", + "test": "npm run test:types && vitest run", + "test:types": "tsc --noEmit -p tsconfig.type-tests.json", "test:unit": "vitest run tests/unit", "test:e2e": "vitest run tests/e2e", "test:watch": "vitest", diff --git a/src/index.ts b/src/index.ts index 9437f26..bc531d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,9 @@ export type { DeleteManyResult, DeleteResult, EntitiesModule, + EntityFilterOperators, + EntityFilterQuery, + EntityFilterValue, EntityHandler, EntityRecord, EntityTypeRegistry, diff --git a/src/modules/entities.ts b/src/modules/entities.ts index c03be02..144c512 100644 --- a/src/modules/entities.ts +++ b/src/modules/entities.ts @@ -3,6 +3,7 @@ import { DeleteManyResult, DeleteResult, EntitiesModule, + EntityFilterQuery, EntityHandler, ImportResult, RealtimeCallback, @@ -112,7 +113,7 @@ function createEntityHandler( // Filter entities based on query async filter( - query: Partial, + query: EntityFilterQuery, sort?: SortField, limit?: number, skip?: number, diff --git a/src/modules/entities.types.ts b/src/modules/entities.types.ts index 6da0f54..c5854dd 100644 --- a/src/modules/entities.types.ts +++ b/src/modules/entities.types.ts @@ -95,6 +95,94 @@ export type SortField = | `+${keyof T & string}` | `-${keyof T & string}`; +/** + * Entity filter query type system. + * + * `EntityFilterQuery` keeps field names tied to the entity schema while + * allowing Base44's documented filtering syntax. Each field can use an exact + * value, `null`, an array shorthand for matching any listed value, or a + * field-level operator object. Root-level `$and`, `$or`, and `$nor` combine + * nested filter queries. + * + * Operator values are typed from the field they filter where possible. For + * example, numeric fields accept numeric comparison values, string fields + * accept `$regex`, and array fields accept `$all` and `$size`. + */ + +/** + * Value accepted when filtering an entity field. + * + * Supports exact matches, `null`, array shorthand for matching any of the + * provided values, and documented MongoDB-style query operators. + * + * @typeParam T - Field value type. + */ +export type EntityFilterValue = + | EntityFilterComparable + | EntityFilterComparable[] + | EntityFilterOperators; + +/** + * MongoDB-style query operators accepted for a single entity field. + * + * @typeParam T - Field value type. + */ +export type EntityFilterOperators = EntityFilterCommonOperators & { + /** Negates another field-level filter expression. */ + $not?: EntityFilterCommonOperators; +}; + +type EntityFilterComparable = Exclude | null; + +type EntityFilterCommonOperators = { + $eq?: EntityFilterComparable; + $ne?: EntityFilterComparable; + $gt?: EntityFilterComparable; + $gte?: EntityFilterComparable; + $lt?: EntityFilterComparable; + $lte?: EntityFilterComparable; + $in?: EntityFilterComparable[]; + $nin?: EntityFilterComparable[]; + $exists?: boolean; +} & EntityFilterStringOperators & + EntityFilterArrayOperators; + +type EntityFilterStringOperators = Extract< + Exclude, + string +> extends never + ? {} + : { + $regex?: string; + }; + +type EntityFilterArrayElement = T extends readonly (infer U)[] ? U : never; + +type EntityFilterArrayOperators = [ + EntityFilterArrayElement>, +] extends [never] + ? {} + : { + $all?: EntityFilterArrayElement>[]; + $size?: number; + }; + +/** + * Query object accepted by entity filtering methods. + * + * Field keys are typed from the entity schema. `$and`, `$or`, and `$nor` + * combine nested filter queries at the root level. + * + * @typeParam T - Entity record type. + */ +export type EntityFilterQuery = { + [K in keyof T]?: EntityFilterValue; +} & { + $and?: EntityFilterQuery[]; + $or?: EntityFilterQuery[]; + $nor?: EntityFilterQuery[]; +}; + /** * Fields added by the server to every entity record, such as `id`, `created_date`, `updated_date`, and `created_by`. */ @@ -208,7 +296,9 @@ export interface EntityHandler { * @typeParam K - The fields to include in the response. Defaults to all fields. * @param query - Query object with field-value pairs. Each key should be a field name * from your entity schema, and each value is the criteria to match. Records matching all - * specified criteria are returned. Field names are case-sensitive. + * specified criteria are returned. Field names are case-sensitive. Use field-value pairs + * for exact matches, `null` for null values, arrays as shorthand for matching any of the + * provided values, or documented MongoDB query operators for advanced filtering. * @param sort - Sort parameter, such as `'-created_date'` for descending. Defaults to `'-created_date'`. * @param limit - Maximum number of results to return. Defaults to `50`. * @param skip - Number of results to skip for pagination. Defaults to `0`. @@ -234,6 +324,42 @@ export interface EntityHandler { * * @example * ```typescript + * // Filter by any matching value + * const records = await base44.entities.MyEntity.filter({ + * external_id: ['item-1', 'item-2'] + * }); + * ``` + * + * @example + * ```typescript + * // Filter with query operators + * const popularRecords = await base44.entities.MyEntity.filter({ + * count: { $gte: 100 }, + * external_id: { $in: ['item-1', 'item-2'] } + * }); + * ``` + * + * @example + * ```typescript + * // Filter with logical operators + * const records = await base44.entities.MyEntity.filter({ + * $or: [ + * { name: 'Example item' }, + * { slug: 'example-item' } + * ] + * }); + * ``` + * + * @example + * ```typescript + * // Filter null values + * const recordsWithoutDescription = await base44.entities.MyEntity.filter({ + * description: null + * }); + * ``` + * + * @example + * ```typescript * // Filter with sorting and pagination * const results = await base44.entities.MyEntity.filter( * { status: 'active' }, @@ -256,7 +382,7 @@ export interface EntityHandler { * ``` */ filter( - query: Partial, + query: EntityFilterQuery, sort?: SortField, limit?: number, skip?: number, diff --git a/tests/types/entities-filter.types.ts b/tests/types/entities-filter.types.ts new file mode 100644 index 0000000..261f50d --- /dev/null +++ b/tests/types/entities-filter.types.ts @@ -0,0 +1,108 @@ +import type { EntityFilterQuery } from "../../src/index.js"; + +interface ExampleRecord { + external_id: string; + title: string; + count: number; + active: boolean; + notes?: string; + labels: string[]; + created_date: string; +} + +const exactValues = { + external_id: "item-1", + title: "Example item", + count: 37, + active: false, +} satisfies EntityFilterQuery; + +const nullValues = { + title: null, + notes: null, +} satisfies EntityFilterQuery; + +const arrayShorthand = { + external_id: ["item-1", "item-2"], +} satisfies EntityFilterQuery; + +const equalityOperators = { + external_id: { + $eq: "item-1", + $ne: "item-2", + $in: ["item-1", "item-2"], + $nin: ["item-3", null], + }, +} satisfies EntityFilterQuery; + +const comparisonOperators = { + count: { + $gt: 10, + $gte: 37, + $lt: 100, + $lte: 37, + }, + created_date: { + $gte: "2026-04-01T00:00:00.000000", + }, +} satisfies EntityFilterQuery; + +const fieldOperators = { + title: { + $exists: true, + $regex: "Example", + }, + labels: { + $all: ["featured", "demo"], + $size: 2, + }, + count: { + $not: { $lt: 10 }, + }, +} satisfies EntityFilterQuery; + +const logicalOperators = { + $and: [{ active: false }, { count: { $gt: 10 } }], + $or: [{ external_id: "item-1" }, { title: "Example item" }], + $nor: [{ active: true }, { count: { $lt: 1 } }], +} satisfies EntityFilterQuery; + +const rejectsUnknownField = { + // @ts-expect-error Unknown fields should be rejected. + missing: "value", +} satisfies EntityFilterQuery; + +const rejectsWrongScalarType = { + // @ts-expect-error Field values should match the entity field type. + count: "37", +} satisfies EntityFilterQuery; + +const rejectsWrongInType = { + // @ts-expect-error $in values should match the entity field type. + external_id: { $in: [37] }, +} satisfies EntityFilterQuery; + +const rejectsWrongExistsType = { + // @ts-expect-error $exists expects a boolean. + external_id: { $exists: "yes" }, +} satisfies EntityFilterQuery; + +const rejectsRegexOnNumber = { + // @ts-expect-error $regex is only valid for string fields. + count: { $regex: "37" }, +} satisfies EntityFilterQuery; + +const rejectsAllOnString = { + // @ts-expect-error $all is only valid for array fields. + title: { $all: ["Example"] }, +} satisfies EntityFilterQuery; + +const rejectsRootNot = { + // @ts-expect-error $not is field-level only. + $not: { count: { $lt: 10 } }, +} satisfies EntityFilterQuery; + +const rejectsFieldOr = { + // @ts-expect-error $or is root-level only. + title: { $or: [{ title: "Example item" }] }, +} satisfies EntityFilterQuery; diff --git a/tests/unit/entities.test.ts b/tests/unit/entities.test.ts index e655a05..cc5f580 100644 --- a/tests/unit/entities.test.ts +++ b/tests/unit/entities.test.ts @@ -10,6 +10,7 @@ interface Todo { id: string; title: string; completed: boolean; + description?: string; } // Module augmentation: register Todo type in EntityTypeRegistry @@ -101,6 +102,34 @@ describe("Entities Module", () => { expect(scope.isDone()).toBe(true); }); + test("filter() should support typed advanced query syntax", async () => { + const mockTodos: Todo[] = [{ id: "2", title: "Task 2", completed: true }]; + + scope + .get(`/api/apps/${appId}/entities/Todo`) + .query((query) => { + const parsedQ = JSON.parse(query.q as string); + + return ( + parsedQ.title.$in[0] === "Task 1" && + parsedQ.title.$in[1] === "Task 2" && + parsedQ.description === null && + parsedQ.$or[0].title === "Task 2" && + parsedQ.$or[1].completed === true + ); + }) + .reply(200, mockTodos); + + const result = await base44.entities.Todo.filter({ + title: { $in: ["Task 1", "Task 2"] }, + description: null, + $or: [{ title: "Task 2" }, { completed: true }], + }); + + expect(result).toHaveLength(1); + expect(scope.isDone()).toBe(true); + }); + test("get() should fetch a single entity", async () => { const todoId = "123"; const mockTodo: Todo = { diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json new file mode 100644 index 0000000..16300ac --- /dev/null +++ b/tsconfig.type-tests.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*", "tests/types/**/*.ts"], + "exclude": ["node_modules", "dist"] +}