Skip to content

Commit

Permalink
feat: new operators
Browse files Browse the repository at this point in the history
  • Loading branch information
smiley-uriux committed Dec 11, 2024
1 parent 356eeaa commit df309ee
Show file tree
Hide file tree
Showing 6 changed files with 128 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/gold-pens-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@jayalfredprufrock/mongoes': minor
---

feat: new $unlike and $nempty custom operators
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ See [MongoDb docs](https://www.mongodb.com/docs/manual/reference/operator/query/

OOTB, mongoes includes a few operators that aren't a part of the MongoDB query specification:

- `$like` - Maps to [ES Wildcard](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-wildcard-query.html)
- `$like` (`$unlike`) - Maps to [ES Wildcard](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-wildcard-query.html)
queries. Both `*` and `%` can be used to match zero or more characters, while `?` can be used to match exactly one character.
Like the `$regex` operator, set `$options` to "i" to set the ES option `case_insensitive` to true. Note that exactly how
ElasticSearch treats case sensitivity is also dependent on the underlying field mapping.
- `$prefix` - Maps to [ES Prefix](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-prefix-query.html)
queries. Similar to `$like`, supports passing "i" to `$options` for case insensitivity.
- `$ids` - Maps to ES "ids" query. The operand is an array of document \_ids. The field name is not used when constructing the ES
query, however it is used to specify a document-level id field for supporting Sift queries.
- `$empty` - Works just like `$exists`, but does not consider empty strings (after trimming) to exist.
- `$empty` (`$nempty`) - Works just like `$exists`, but does not consider empty strings (after trimming) to exist.

Additionally, users can create their own custom operations by including an object of operator functions:

Expand Down
51 changes: 51 additions & 0 deletions src/convert-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ describe('convertQuery()', () => {
bool: { must: [{ exists: { field: 'name' } }, { bool: { must_not: { term: { name: '' } } } }] },
});
});

test('$nempty: true', () => {
expect(convertQuery({ name: { $nempty: true } })).toEqual({
bool: { must_not: { bool: { should: [{ bool: { must_not: { exists: { field: 'name' } } } }, { term: { name: '' } }] } } },
});
});

test('$nempty: false', () => {
expect(convertQuery({ name: { $nempty: false } })).toEqual({
bool: { must_not: { bool: { must: [{ exists: { field: 'name' } }, { bool: { must_not: { term: { name: '' } } } }] } } },
});
});
});

describe('supports range comparison operators', () => {
Expand Down Expand Up @@ -304,6 +316,45 @@ describe('convertQuery()', () => {
});
});

describe('supports $unlike operator', () => {
test('with no flags', () => {
expect(
convertQuery({
name: { $unlike: '*b%ss?' },
})
).toEqual({
bool: {
must_not: {
wildcard: {
name: {
value: '*b*ss?',
},
},
},
},
});
});

test('with "i" flag', () => {
expect(
convertQuery({
name: { $unlike: '*b%ss?', $options: 'i' },
})
).toEqual({
bool: {
must_not: {
wildcard: {
name: {
value: '*b*ss?',
case_insensitive: true,
},
},
},
},
});
});
});

describe('supports $prefix operator', () => {
test('with no flags', () => {
expect(
Expand Down
2 changes: 1 addition & 1 deletion src/convert-query.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

const negatedOps: Record<string, string> = { $ne: '$eq', $nin: '$in' };
const negatedOps: Record<string, string> = { $ne: '$eq', $nin: '$in', $unlike: '$like', $nempty: '$empty' };
const boolOps: Record<string, string> = { $and: 'must', $or: 'should', $nor: 'must_not' };

type CustomOperator = (field: string, operand: any, options?: any) => any;
Expand Down
50 changes: 50 additions & 0 deletions src/sift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,39 @@ describe('sift()', () => {
});
});

describe('$unlike custom operation', () => {
test('Matches correctly without wildcard tokens, trimming whitespace.', () => {
expect(sift({ name: { $unlike: ' Ravel' } })({ name: 'Ravel ' })).toBe(false);
expect(sift({ name: { $unlike: 'Maurice' } })({ name: 'Ravel' })).toBe(true);
});

test('Matches "?" as a single character', () => {
expect(sift({ name: { $unlike: 'Ra?el' } })({ name: 'Ravel' })).toBe(false);
expect(sift({ name: { $unlike: 'Ra?el' } })({ name: 'Ravvel' })).toBe(true);
});

test('Matches "*" as multiple characters', () => {
expect(sift({ name: { $unlike: 'R*l' } })({ name: 'Ravel' })).toBe(false);
expect(sift({ name: { $unlike: 'M* R*' } })({ name: 'Maurice Ravel' })).toBe(false);
expect(sift({ name: { $unlike: 'M* R*' } })({ name: 'Claude Debussy' })).toBe(true);
});

test('Matches "%" as multiple characters', () => {
expect(sift({ name: { $unlike: 'R%l' } })({ name: 'Ravel' })).toBe(false);
expect(sift({ name: { $unlike: 'M% R%' } })({ name: 'Maurice Ravel' })).toBe(false);
expect(sift({ name: { $unlike: 'M% R%' } })({ name: 'Claude Debussy' })).toBe(true);
});

test('Case sensitive by default', () => {
expect(sift({ name: { $unlike: 'Ravel' } })({ name: 'ravel' })).toBe(true);
});

test('Supports case-insensitive option flag', () => {
expect(sift({ name: { $unlike: 'Ravel', $options: '' } })({ name: 'ravel' })).toBe(true);
expect(sift({ name: { $unlike: 'Ravel', $options: 'i' } })({ name: 'ravel' })).toBe(false);
});
});

describe('$prefix custom operation', () => {
test('Matches correctly without, trimming whitespace.', () => {
expect(sift({ name: { $prefix: 'Maurice' } })({ name: ' Maurice Ravel ' })).toBe(true);
Expand Down Expand Up @@ -73,4 +106,21 @@ describe('sift()', () => {
expect(sift({ name: { $empty: false } })({ name: ' ' })).toBe(false);
});
});

describe('$nempty custom operation', () => {
test('Matches correctly when operands have a value', () => {
expect(sift({ name: { $nempty: true } })({ name: 'Maurice Ravel ' })).toBe(true);
expect(sift({ name: { $nempty: false } })({ name: 'Maurice Ravel ' })).toBe(false);
});

test('Matches correctly when values are missing', () => {
expect(sift({ id: { $nempty: true } })({ name: 'Maurice Ravel ' })).toBe(false);
expect(sift({ id: { $nempty: false } })({ name: 'Maurice Ravel ' })).toBe(true);
});

test('Matches correctly when values are empty strings (after trimming)', () => {
expect(sift({ name: { $nempty: true } })({ name: ' ' })).toBe(false);
expect(sift({ name: { $nempty: false } })({ name: ' ' })).toBe(true);
});
});
});
19 changes: 19 additions & 0 deletions src/sift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ export const siftCustomOperations: Record<string, OperationCreator<any>> = {

return createEqualsOperation((value: unknown) => regex.test(String(value).trim()), ownerQuery, options);
},
$unlike(params, ownerQuery, options) {
const caseInsensitive = ownerQuery.$options?.toString().includes('i');

// convert "*" and "%" to ".*" while escaping any other regex tokens
const exp = escapeRegex(String(params).trim().replaceAll('%', '*'), ['*', '?']).replaceAll('*', '.*').replaceAll('?', '.');
const regex = new RegExp(exp, caseInsensitive ? 'si' : 's');

return createEqualsOperation((value: unknown) => !regex.test(String(value).trim()), ownerQuery, options);
},
$prefix(params, ownerQuery, options) {
const caseInsensitive = ownerQuery.$options?.toString().includes('i');
let exp = String(params).trim();
Expand Down Expand Up @@ -61,6 +70,16 @@ export const siftCustomOperations: Record<string, OperationCreator<any>> = {
options
);
},
$nempty(params, ownerQuery, options) {
return createEqualsOperation(
(value: unknown) => {
const isEmpty = value === undefined || (typeof value === 'string' && !value.trim());
return params !== isEmpty;
},
ownerQuery,
options
);
},
};

export const sift: typeof originalSift = (query, options) => originalSift(query, { ...options, operations: siftCustomOperations });

0 comments on commit df309ee

Please sign in to comment.