diff --git a/src/filters.ts b/src/filters.ts index 0ddc958..790d176 100644 --- a/src/filters.ts +++ b/src/filters.ts @@ -120,10 +120,18 @@ export const filter = { * Full control: receive the Lucid query builder and the raw value. * Scopes, joins and subqueries all work here. */ - custom( - handler: (query: FilterQuery, value: unknown, context: FilterContext) => void + custom( + handler: ( + query: ModelQueryBuilderContract, + value: unknown, + context: FilterContext + ) => void ): FilterHandler { - return handler + // One cast here instead of one in every handler that names a + // relation: statics cannot reference class type parameters, so the + // stored FilterHandler stays wide while the declaration site gets + // the concrete model. + return handler as FilterHandler }, } diff --git a/tests/unit/filters.spec.ts b/tests/unit/filters.spec.ts index 55122f1..a5664af 100644 --- a/tests/unit/filters.spec.ts +++ b/tests/unit/filters.spec.ts @@ -161,3 +161,23 @@ test.group('filter handlers receive the request context', () => { assert.isUndefined(captured.ctx) }) }) + +test.group('filter.custom is generic over the model', () => { + test('a typed handler uses relation names without casting', ({ assert }) => { + // The point is the typecheck: whereHas('comments') only compiles + // because custom narrows the query builder. An + // untyped handler sees ModelQueryBuilderContract, where + // relation names do not exist. + const handler = filter.custom((query) => { + // compile-time proof: the narrowed builder knows Article's + // relation names, so 'comments' is assignable where the wide + // builder would reject it as never + type RelationName = Parameters[0] + const relation: RelationName = 'comments' + void relation + }) + + // runtime: custom passes the handler through untouched + assert.isFunction(handler) + }) +})