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
38 changes: 19 additions & 19 deletions packages/esix/src/base-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends BaseModel>(
static orderBy<T extends BaseModel, K extends keyof T>(
this: ObjectType<T>,
key: string,
key: K,
order: 'asc' | 'desc' = 'asc'
): QueryBuilder<T> {
return new QueryBuilder<T>(this).orderBy(key, order)
Expand Down Expand Up @@ -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<T extends BaseModel>(
static where<T extends BaseModel, K extends keyof T>(
this: ObjectType<T>,
key: string,
key: K,
value: any
): QueryBuilder<T>
static where<T extends BaseModel>(
static where<T extends BaseModel, K extends keyof T>(
this: ObjectType<T>,
key: string,
key: K,
operator: ComparisonOperator,
value: any
): QueryBuilder<T>
static where<T extends BaseModel>(
static where<T extends BaseModel, K extends keyof T>(
this: ObjectType<T>,
key: string,
key: K,
operatorOrValue: ComparisonOperator | any,
value?: any
): QueryBuilder<T> {
Expand All @@ -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<T extends BaseModel>(
static whereIn<T extends BaseModel, K extends keyof T>(
this: ObjectType<T>,
key: string,
key: K,
values: any[]
): QueryBuilder<T> {
const queryBuilder = new QueryBuilder(this)
Expand All @@ -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<T extends BaseModel>(
static whereNotIn<T extends BaseModel, K extends keyof T>(
this: ObjectType<T>,
key: string,
key: K,
values: any[]
): QueryBuilder<T> {
const queryBuilder = new QueryBuilder(this)
Expand Down Expand Up @@ -433,10 +433,10 @@ export default class BaseModel {
): QueryBuilder<T> {
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] })
}

/**
Expand Down
31 changes: 17 additions & 14 deletions packages/esix/src/query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,10 @@ export default class QueryBuilder<T extends BaseModel> {
/**
* 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<K extends keyof T>(key: K, order?: 'asc' | 'desc'): QueryBuilder<T>
orderBy(key: string, order: 'asc' | 'desc' = 'asc'): QueryBuilder<T> {
if (!this.queryOrder) {
this.queryOrder = {}
Expand Down Expand Up @@ -415,13 +416,17 @@ export default class QueryBuilder<T extends BaseModel> {
* 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<T>
where(key: string, value: any): QueryBuilder<T>
where(key: string, operator: ComparisonOperator, value: any): QueryBuilder<T>
where<K extends keyof T>(key: K, value: any): QueryBuilder<T>
where<K extends keyof T>(
key: K,
operator: ComparisonOperator,
value: any
): QueryBuilder<T>
where(
queryOrKey: Query | string,
operatorOrValue?: ComparisonOperator | any,
Expand Down Expand Up @@ -487,16 +492,15 @@ export default class QueryBuilder<T extends BaseModel> {
/**
* Returns all the models with `key` in the array of `values`.
*
* @param key
* @param key - A property of the model
* @param values
*/
whereIn<K extends keyof T>(key: K, values: any[]): QueryBuilder<T>
whereIn(key: string, values: any[]): QueryBuilder<T> {
if (key === 'id') {
key = '_id'
}
const keyStr = key === 'id' ? '_id' : key

const query = {
[key]: {
[keyStr]: {
$in: sanitize(values)
}
}
Expand All @@ -512,16 +516,15 @@ export default class QueryBuilder<T extends BaseModel> {
/**
* Returns all the models with `key` not in the array of `values`.
*
* @param key
* @param key - A property of the model
* @param values
*/
whereNotIn<K extends keyof T>(key: K, values: any[]): QueryBuilder<T>
whereNotIn(key: string, values: any[]): QueryBuilder<T> {
if (key === 'id') {
key = '_id'
}
const keyStr = key === 'id' ? '_id' : key

const query = {
[key]: {
[keyStr]: {
$nin: sanitize(values)
}
}
Expand Down
192 changes: 192 additions & 0 deletions packages/esix/src/type-safety.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})