Skip to content

feat(knex): Add support for extended operators in query builder (fixes #3377) #3578

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 3, 2025
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
2 changes: 1 addition & 1 deletion docs/api/databases/knex.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
15 changes: 13 additions & 2 deletions packages/knex/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
]
})
}

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/knex/src/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export interface KnexAdapterOptions extends AdapterServiceOptions {
tableOptions?: {
only?: boolean
}
extendedOperators?: {
[key: string]: string
}
}

export interface KnexAdapterTransaction {
Expand Down
79 changes: 78 additions & 1 deletion packages/knex/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -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')
})