From 08806e1357d0349b7a0e8811db32af48f7199a16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Dec 2025 00:02:22 +0000 Subject: [PATCH] feat: Add type safety for model field filters Add compile-time type checking for `where`, `whereIn`, `whereNotIn`, and `orderBy` methods to ensure only valid model field names are accepted. This prevents runtime errors from typos in field names by catching them at compile time. For example, `MenuItem.where('active', true)` will now produce a TypeScript error if the field is actually named 'available'. Closes #60 --- packages/esix/src/base-model.ts | 38 ++--- packages/esix/src/query-builder.ts | 31 +++-- packages/esix/src/type-safety.spec.ts | 192 ++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 33 deletions(-) create mode 100644 packages/esix/src/type-safety.spec.ts diff --git a/packages/esix/src/base-model.ts b/packages/esix/src/base-model.ts index de051fb..d9c5498 100644 --- a/packages/esix/src/base-model.ts +++ b/packages/esix/src/base-model.ts @@ -245,12 +245,12 @@ export default class BaseModel { * const posts = await BlogPost.orderBy('publishedAt', 'desc').get(); * ``` * - * @param key + * @param key - A property of the model * @param order */ - static orderBy( + static orderBy( this: ObjectType, - key: string, + key: K, order: 'asc' | 'desc' = 'asc' ): QueryBuilder { return new QueryBuilder(this).orderBy(key, order) @@ -332,24 +332,24 @@ export default class BaseModel { * const youngUsers = await User.where('age', '<', 30).get(); * ``` * - * @param key + * @param key - A property of the model * @param operatorOrValue - Comparison operator or value when using 2-param syntax * @param value - The value when using 3-param syntax with operator */ - static where( + static where( this: ObjectType, - key: string, + key: K, value: any ): QueryBuilder - static where( + static where( this: ObjectType, - key: string, + key: K, operator: ComparisonOperator, value: any ): QueryBuilder - static where( + static where( this: ObjectType, - key: string, + key: K, operatorOrValue: ComparisonOperator | any, value?: any ): QueryBuilder { @@ -371,12 +371,12 @@ export default class BaseModel { * const comments = await Comment.whereIn('postId', [1, 2, 3]).get(); * ``` * - * @param key + * @param key - A property of the model * @param values */ - static whereIn( + static whereIn( this: ObjectType, - key: string, + key: K, values: any[] ): QueryBuilder { const queryBuilder = new QueryBuilder(this) @@ -392,12 +392,12 @@ export default class BaseModel { * const users = await User.whereNotIn('id', [1, 2, 3]).get(); * ``` * - * @param key + * @param key - A property of the model * @param values */ - static whereNotIn( + static whereNotIn( this: ObjectType, - key: string, + key: K, values: any[] ): QueryBuilder { const queryBuilder = new QueryBuilder(this) @@ -433,10 +433,10 @@ export default class BaseModel { ): QueryBuilder { const queryBuilder = new QueryBuilder(ctor) - foreignKey = foreignKey || camelCase(`${this.constructor.name}Id`) - localKey = localKey || 'id' + const fk = foreignKey || camelCase(`${this.constructor.name}Id`) + const lk = localKey || 'id' - return queryBuilder.where(foreignKey, (this as any)[localKey]) + return queryBuilder.where({ [fk]: (this as any)[lk] }) } /** diff --git a/packages/esix/src/query-builder.ts b/packages/esix/src/query-builder.ts index 37f59eb..8cb730e 100644 --- a/packages/esix/src/query-builder.ts +++ b/packages/esix/src/query-builder.ts @@ -283,9 +283,10 @@ export default class QueryBuilder { /** * Sorts the models by the given key. * - * @param key The key you want to sort by. + * @param key - A property of the model to sort by. * @param order Defaults to ascending order. */ + orderBy(key: K, order?: 'asc' | 'desc'): QueryBuilder orderBy(key: string, order: 'asc' | 'desc' = 'asc'): QueryBuilder { if (!this.queryOrder) { this.queryOrder = {} @@ -415,13 +416,17 @@ export default class QueryBuilder { * Adds a constraint to the current query. * * @param query - A query object to filter by - * @param key - Property name to filter by + * @param key - Property name to filter by (must be a valid model field) * @param operatorOrValue - Comparison operator or value when using 2-param syntax * @param value - The value to filter by when using 3-param syntax with operator */ where(query: Query): QueryBuilder - where(key: string, value: any): QueryBuilder - where(key: string, operator: ComparisonOperator, value: any): QueryBuilder + where(key: K, value: any): QueryBuilder + where( + key: K, + operator: ComparisonOperator, + value: any + ): QueryBuilder where( queryOrKey: Query | string, operatorOrValue?: ComparisonOperator | any, @@ -487,16 +492,15 @@ export default class QueryBuilder { /** * Returns all the models with `key` in the array of `values`. * - * @param key + * @param key - A property of the model * @param values */ + whereIn(key: K, values: any[]): QueryBuilder whereIn(key: string, values: any[]): QueryBuilder { - if (key === 'id') { - key = '_id' - } + const keyStr = key === 'id' ? '_id' : key const query = { - [key]: { + [keyStr]: { $in: sanitize(values) } } @@ -512,16 +516,15 @@ export default class QueryBuilder { /** * Returns all the models with `key` not in the array of `values`. * - * @param key + * @param key - A property of the model * @param values */ + whereNotIn(key: K, values: any[]): QueryBuilder whereNotIn(key: string, values: any[]): QueryBuilder { - if (key === 'id') { - key = '_id' - } + const keyStr = key === 'id' ? '_id' : key const query = { - [key]: { + [keyStr]: { $nin: sanitize(values) } } diff --git a/packages/esix/src/type-safety.spec.ts b/packages/esix/src/type-safety.spec.ts new file mode 100644 index 0000000..3fd7cbc --- /dev/null +++ b/packages/esix/src/type-safety.spec.ts @@ -0,0 +1,192 @@ +/** + * Type-level tests for model field type safety. + * + * These tests verify that TypeScript correctly rejects invalid field names + * at compile time. The @ts-expect-error comments indicate lines that SHOULD + * produce type errors - if they don't, the test file won't compile. + * + * Related to: https://github.com/Artmann/esix/issues/60 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import BaseModel from './base-model' + +vi.mock('mongodb') + +// Mock the MongoDB connection to avoid actual database calls +vi.mock('./connection-handler', () => ({ + connectionHandler: { + getConnection: vi.fn().mockResolvedValue({ + collection: vi.fn().mockResolvedValue({ + find: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnThis(), + skip: vi.fn().mockReturnThis(), + sort: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue([]) + }), + count: vi.fn().mockResolvedValue(0) + }) + }) + } +})) + +/** + * Example model for testing - matches the issue example + */ +class MenuItem extends BaseModel { + public available = false + public name = '' + public order = 0 + public price = 0 +} + +/** + * Another test model with different fields + */ +class User extends BaseModel { + public email = '' + public age = 0 + public isActive = true +} + +describe('Type Safety for Model Fields', () => { + beforeEach(() => { + Object.assign(process.env, { + DB_ADAPTER: 'mock', + DB_DATABASE: 'test' + }) + }) + + describe('where method', () => { + it('accepts valid model fields with two-parameter syntax', () => { + // These should all compile without errors + MenuItem.where('available', true) + MenuItem.where('name', 'Pizza') + MenuItem.where('order', 1) + MenuItem.where('price', 9.99) + + // BaseModel fields should also work + MenuItem.where('id', 'some-id') + MenuItem.where('createdAt', 123456789) + MenuItem.where('updatedAt', 123456789) + + expect(true).toBe(true) + }) + + it('accepts valid model fields with three-parameter syntax (comparison operators)', () => { + // These should all compile without errors + MenuItem.where('price', '>', 10) + MenuItem.where('price', '>=', 10) + MenuItem.where('price', '<', 100) + MenuItem.where('price', '<=', 100) + MenuItem.where('price', '=', 50) + MenuItem.where('price', '!=', 0) + MenuItem.where('order', '<>', 5) + + expect(true).toBe(true) + }) + + it('rejects invalid field names with two-parameter syntax', () => { + // @ts-expect-error - 'active' is not a valid field on MenuItem (should be 'available') + MenuItem.where('active', true) + + // @ts-expect-error - 'status' is not a valid field on MenuItem + MenuItem.where('status', 'published') + + // @ts-expect-error - 'nonExistentField' is not a valid field + MenuItem.where('nonExistentField', 123) + + expect(true).toBe(true) + }) + + it('rejects invalid field names with three-parameter syntax', () => { + // @ts-expect-error - 'rating' is not a valid field on MenuItem + MenuItem.where('rating', '>', 4) + + // @ts-expect-error - 'quantity' is not a valid field + MenuItem.where('quantity', '>=', 10) + + expect(true).toBe(true) + }) + + it('type-checks fields per model', () => { + // User model has different fields than MenuItem + User.where('email', 'test@example.com') + User.where('age', 25) + User.where('isActive', true) + + // @ts-expect-error - 'price' is a MenuItem field, not a User field + User.where('price', 10) + + // @ts-expect-error - 'available' is a MenuItem field, not a User field + User.where('available', true) + + expect(true).toBe(true) + }) + + it('works with chained where calls', () => { + MenuItem.where('available', true).where('price', '>', 5) + + // @ts-expect-error - invalid field in chained call + MenuItem.where('available', true).where('invalid', 'value') + + expect(true).toBe(true) + }) + }) + + describe('whereIn method', () => { + it('accepts valid model fields', () => { + MenuItem.whereIn('name', ['Pizza', 'Burger', 'Salad']) + MenuItem.whereIn('price', [5, 10, 15]) + MenuItem.whereIn('id', ['id1', 'id2', 'id3']) + + expect(true).toBe(true) + }) + + it('rejects invalid field names', () => { + // @ts-expect-error - 'category' is not a valid field on MenuItem + MenuItem.whereIn('category', ['food', 'drink']) + + // @ts-expect-error - 'invalidField' is not a valid field + MenuItem.whereIn('invalidField', [1, 2, 3]) + + expect(true).toBe(true) + }) + }) + + describe('whereNotIn method', () => { + it('accepts valid model fields', () => { + MenuItem.whereNotIn('name', ['Expired Item']) + MenuItem.whereNotIn('id', ['deleted-id']) + + expect(true).toBe(true) + }) + + it('rejects invalid field names', () => { + // @ts-expect-error - 'type' is not a valid field on MenuItem + MenuItem.whereNotIn('type', ['archived']) + + expect(true).toBe(true) + }) + }) + + describe('orderBy method', () => { + it('accepts valid model fields', () => { + MenuItem.orderBy('name') + MenuItem.orderBy('price', 'desc') + MenuItem.orderBy('order', 'asc') + MenuItem.orderBy('createdAt', 'desc') + + expect(true).toBe(true) + }) + + it('rejects invalid field names', () => { + // @ts-expect-error - 'ranking' is not a valid field on MenuItem + MenuItem.orderBy('ranking') + + // @ts-expect-error - 'sortOrder' is not a valid field + MenuItem.orderBy('sortOrder', 'desc') + + expect(true).toBe(true) + }) + }) +})