fix(knex): Add support for extended operators in query builder (#3578)

This commit is contained in:
Greg Michalec 2025-05-03 15:14:22 -07:00 committed by daffl
parent 6d3acbaa26
commit c355ae3184
4 changed files with 95 additions and 4 deletions

View File

@ -63,7 +63,7 @@ The Knex specific adapter options are:
- `name {string}` (**required**) - The name of the table
- `schema {string}` (_optional_) - The name of the schema table prefix (example: `schema.table`)
- `tableOptions {only: boolean` (_optional_) - For PostgreSQL only. Argument for passing options to knex db builder. ONLY keyword is used before the tableName to discard inheriting tables' data. (https://knexjs.org/guide/query-builder.html#common)
- `extendedOperators {[string]: string}` (_optional_) - A map defining additional operators for the query builder. Example: `{ $fulltext: '@@' }` for PostgreSQL full text search. See [Knex source](https://github.com/knex/knex/blob/master/lib/formatter/wrappingFormatter.js#L10) for operators supported by Knex.
The [common API options](./common.md#options) are:

View File

@ -50,7 +50,13 @@ export class KnexAdapter<
...options.filters,
$and: (value: any) => value
},
operators: [...(options.operators || []), '$like', '$notlike', '$ilike']
operators: [
...(options.operators || []),
...Object.keys(options.extendedOperators || {}),
'$like',
'$notlike',
'$ilike'
]
})
}
@ -82,6 +88,11 @@ export class KnexAdapter<
knexify(knexQuery: Knex.QueryBuilder, query: Query = {}, parentKey?: string): Knex.QueryBuilder {
const knexify = this.knexify.bind(this)
const { extendedOperators = {} } = this.getOptions({} as ServiceParams)
const operatorsMap = {
...OPERATORS,
...extendedOperators
}
return Object.keys(query || {}).reduce((currentQuery, key) => {
const value = query[key]
@ -110,7 +121,7 @@ export class KnexAdapter<
return (currentQuery as any)[method](column, value)
}
const operator = OPERATORS[key as keyof typeof OPERATORS] || '='
const operator = operatorsMap[key as keyof typeof operatorsMap] || '='
return operator === '='
? currentQuery.where(column, value)

View File

@ -8,6 +8,9 @@ export interface KnexAdapterOptions extends AdapterServiceOptions {
tableOptions?: {
only?: boolean
}
extendedOperators?: {
[key: string]: string
}
}
export interface KnexAdapterTransaction {

View File

@ -117,7 +117,15 @@ const clean = async () => {
table.boolean('created')
return table
})
await db.schema.dropTableIfExists(peopleExtendedOps.fullName)
await db.schema.createTable(peopleExtendedOps.fullName, (table) => {
table.increments('id')
table.string('name')
table.integer('age')
table.integer('time')
table.boolean('created')
return table
})
await db.schema.dropTableIfExists(users.fullName)
await db.schema.createTable(users.fullName, (table) => {
table.increments('id')
@ -181,6 +189,7 @@ type ServiceTypes = {
'people-customid': KnexService<Person>
users: KnexService<Person>
todos: KnexService<Todo>
'people-extended-ops': KnexService<Person>
}
class TodoService extends KnexService<Todo> {
@ -217,6 +226,16 @@ const todos = new TodoService({
name: 'todos'
})
const peopleExtendedOps = new KnexService({
Model: db,
name: 'people-extended-ops',
events: ['testing'],
extendedOperators: {
$neq: '<>', // Not equal (alternative syntax)
$startsWith: 'like' // Same as $like but with a different name
}
})
describe('Feathers Knex Service', () => {
const app = feathers<ServiceTypes>()
.hooks({
@ -228,6 +247,7 @@ describe('Feathers Knex Service', () => {
.use('people-customid', peopleId)
.use('users', users)
.use('todos', todos)
.use('people-extended-ops', peopleExtendedOps)
const peopleService = app.service('people')
peopleService.hooks({
@ -722,7 +742,64 @@ describe('Feathers Knex Service', () => {
})
})
describe('extendedOperators', () => {
const extendedService = app.service('people-extended-ops')
let testData: Person[]
beforeEach(async () => {
testData = await Promise.all([
extendedService.create({
name: 'StartWithA',
age: 25
}),
extendedService.create({
name: 'MiddleAMiddle',
age: 30
}),
extendedService.create({
name: 'EndWithA',
age: 35
})
])
})
afterEach(async () => {
try {
for (const item of testData) {
await extendedService.remove(item.id)
}
} catch (error: unknown) {}
})
it('supports custom operators through extendedOperators option', async () => {
// Test the $startsWith custom operator
const startsWithResults = await extendedService.find({
paginate: false,
query: {
name: {
$startsWith: 'Start%' // LIKE operator with % wildcard
}
}
})
assert.strictEqual(startsWithResults.length, 1)
assert.strictEqual(startsWithResults[0].name, 'StartWithA')
// Test that regular operators still work alongside extended ones
const combinedResults = await extendedService.find({
paginate: false,
query: {
$and: [{ name: { $neq: 'EndWithA' } }, { age: { $gt: 26 } }]
}
})
assert.strictEqual(combinedResults.length, 1)
assert.strictEqual(combinedResults[0].name, 'MiddleAMiddle')
})
})
testSuite(app, errors, 'users')
testSuite(app, errors, 'people')
testSuite(app, errors, 'people-customid', 'customid')
testSuite(app, errors, 'people-extended-ops')
})