diff --git a/docs/api/databases/knex.md b/docs/api/databases/knex.md index 4d3764ca55..fc57770c75 100644 --- a/docs/api/databases/knex.md +++ b/docs/api/databases/knex.md @@ -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: diff --git a/packages/knex/src/adapter.ts b/packages/knex/src/adapter.ts index 0d27a81bf8..a9751c72a5 100644 --- a/packages/knex/src/adapter.ts +++ b/packages/knex/src/adapter.ts @@ -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) diff --git a/packages/knex/src/declarations.ts b/packages/knex/src/declarations.ts index 064283c6ee..6a4429fd89 100644 --- a/packages/knex/src/declarations.ts +++ b/packages/knex/src/declarations.ts @@ -8,6 +8,9 @@ export interface KnexAdapterOptions extends AdapterServiceOptions { tableOptions?: { only?: boolean } + extendedOperators?: { + [key: string]: string + } } export interface KnexAdapterTransaction { diff --git a/packages/knex/test/index.test.ts b/packages/knex/test/index.test.ts index 7dbb736c38..475ec3ff16 100644 --- a/packages/knex/test/index.test.ts +++ b/packages/knex/test/index.test.ts @@ -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 users: KnexService todos: KnexService + 'people-extended-ops': KnexService } class TodoService extends KnexService { @@ -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() .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') })