Skip to content

Commit

Permalink
feat: add support for geo
Browse files Browse the repository at this point in the history
  • Loading branch information
CodyTseng committed May 25, 2024
1 parent 8e7e415 commit ccf6d0e
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 16 deletions.
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,17 @@ type FilterableAttributes = 'name' | 'age' | 'isStudent';

buildMeiliSearchFilter<FilterableAttributes>({
$or: [
{ name: 'John', age: { $gt: 18 } },
{ name: 'Doe', age: { $gt: 20 } },
{ $or: [{ name: 'John' }, { name: 'Doe' }] },
{ $and: [{ age: { $gt: 18 } }, { isStudent: true }] },
],
isStudent: true,
age: { $lt: 30 },
$geoRadius: {
lat: 45.472735,
lng: 9.184019,
distanceInMeters: 2000,
},
});
// => (name = "John" AND age > 18 OR name = "Doe" AND age > 20) AND isStudent = true
// => ((name = "John" OR name = "Doe") OR age > 18 AND isStudent = true) AND age < 30 AND _geoRadius(45.472735, 9.184019, 2000)
```

More examples can be found in the [tests](./__test__/filter.test.js).
Expand All @@ -47,6 +52,8 @@ Supported operators:
- `$empty`: Empty - `EMPTY`
- `$or`: Or - `OR`
- `$and`: And - `AND`
- `$geoRadius`: Geo radius - `_geoRadius`
- `$geoBoundingBox`: Geo bounding box - `_geoBoundingBox`

### `buildMeiliSearchSort`

Expand All @@ -60,8 +67,9 @@ type SortableAttributes = 'name' | 'age';
buildMeiliSearchSort<SortableAttributes>({
name: 1,
age: -1,
_geoPoint: { lat: 48.8561446, lng: 2.2978204, direction: 1 },
});
// => [ "name:asc", "age:desc" ]
// => [ "name:asc", "age:desc", "_geoPoint(48.8561446, 2.2978204):asc" ]
```

More examples can be found in the [tests](./__test__/sort.test.js).
Expand Down
51 changes: 45 additions & 6 deletions __test__/filter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,31 @@ describe('filter', () => {
buildMeiliSearchFilter({ $or: [{ name: 'John' }, { age: 18 }] }),
).toBe('(name = "John" OR age = 18)');

// geo
expect(
buildMeiliSearchFilter({
$geoRadius: {
lat: 45.472735,
lng: 9.184019,
distanceInMeters: 2000,
},
}),
).toBe('_geoRadius(45.472735, 9.184019, 2000)');
expect(
buildMeiliSearchFilter({
$geoRoundingBox: {
topRight: {
lat: 45.494181,
lng: 9.214024,
},
bottomLeft: {
lat: 45.449484,
lng: 9.179175,
},
},
}),
).toBe('_geoBoundingBox([45.494181, 9.214024], [45.449484, 9.179175])');

// complex
expect(
buildMeiliSearchFilter({
Expand All @@ -115,9 +140,20 @@ describe('filter', () => {
{ $and: [{ age: { $gt: 18 } }, { isStudent: true }] },
],
age: { $lt: 30 },
$geoRadius: {
lat: 45.472735,
lng: 9.184019,
distanceInMeters: 2000,
},
}),
).toBe(
'((name = "John" OR name = "Doe") OR age > 18 AND isStudent = true) AND age < 30',
'((name = "John" OR name = "Doe") OR age > 18 AND isStudent = true) AND age < 30 AND _geoRadius(45.472735, 9.184019, 2000)',
);
});

it('some special cases', () => {
expect(buildMeiliSearchFilter({ name: undefined, age: { $gt: 18 } })).toBe(
'age > 18',
);
});

Expand All @@ -143,11 +179,14 @@ describe('filter', () => {
expect(() => buildMeiliSearchFilter({ $and: 'a' })).toThrow(
'$and must be an array',
);
});

it('some special cases', () => {
expect(buildMeiliSearchFilter({ name: undefined, age: { $gt: 18 } })).toBe(
'age > 18',
expect(() => buildMeiliSearchFilter({ $geoRadius: 'a' })).toThrow(
'$geoRadius must be an object',
);
expect(() => buildMeiliSearchFilter({ $geoRoundingBox: 'a' })).toThrow(
'$geoBoundingBox must be an object',
);
expect(() => buildMeiliSearchFilter({ $geoRadius: { lat: 'a' } })).toThrow(
'$geoRadius.lat must be a number',
);
});
});
12 changes: 12 additions & 0 deletions __test__/sort.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ describe('sort', () => {
'name:desc',
]);

// geoPoint
expect(
buildMeiliSearchSort({
_geoPoint: { lat: 48.8561446, lng: 2.2978204, direction: 1 },
}),
).toStrictEqual(['_geoPoint(48.8561446, 2.2978204):asc']);
expect(
buildMeiliSearchSort({
_geoPoint: { lat: 48.8561446, lng: 2.2978204 },
}),
).toStrictEqual(['_geoPoint(48.8561446, 2.2978204):asc']);

// multiple fields
expect(buildMeiliSearchSort({ name: 1, age: -1 })).toStrictEqual([
'name:asc',
Expand Down
38 changes: 38 additions & 0 deletions src/filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
isArray,
isDate,
checkIsNumberOrDate,
checkIsNumber,
} = require('./utils');

function buildMeiliSearchFilter(filterQuery) {
Expand All @@ -27,6 +28,12 @@ function buildMeiliSearchFilter(filterQuery) {
case '$and':
filters.push(parseAndSelector(condition));
break;
case '$geoRadius':
filters.push(parseGeoRadiusSelector(condition));
break;
case '$geoRoundingBox':
filters.push(parseGeoBoundingBoxSelector(condition));
break;
default:
filters.push(parseQuerySelector(field, condition));
}
Expand All @@ -46,6 +53,37 @@ function parseAndSelector(filterQueries) {
return filterQueries.map((f) => buildMeiliSearchFilter(f)).join(' AND ');
}

function parseGeoRadiusSelector(condition) {
checkIsObject(condition, '$geoRadius must be an object');

const { lat, lng, distanceInMeters } = condition;
checkIsNumber(lat, '$geoRadius.lat must be a number');
checkIsNumber(lng, '$geoRadius.lng must be a number');
checkIsNumber(
distanceInMeters,
'$geoRadius.distanceInMeters must be a number',
);

return `_geoRadius(${lat}, ${lng}, ${distanceInMeters})`;
}

function parseGeoBoundingBoxSelector(condition) {
checkIsObject(condition, '$geoBoundingBox must be an object');

const { topRight, bottomLeft } = condition;
checkIsObject(topRight, '$geoBoundingBox.topRight must be an object');
checkIsObject(bottomLeft, '$geoBoundingBox.bottomLeft must be an object');

const { lat: lat1, lng: lng1 } = topRight;
const { lat: lat2, lng: lng2 } = bottomLeft;
checkIsNumber(lat1, '$geoBoundingBox.topRight.lat must be a number');
checkIsNumber(lng1, '$geoBoundingBox.topRight.lng must be a number');
checkIsNumber(lat2, '$geoBoundingBox.bottomLeft.lat must be a number');
checkIsNumber(lng2, '$geoBoundingBox.bottomLeft.lng must be a number');

return `_geoBoundingBox([${lat1}, ${lng1}], [${lat2}, ${lng2}])`;
}

function parseQuerySelector(field, condition) {
if (!isObject(condition) || isDate(condition)) {
return translateOperator(field, '$eq', condition);
Expand Down
26 changes: 25 additions & 1 deletion src/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type FilterQuery<FilterableAttributes extends string = string> = {
[P in FilterableAttributes]?: Condition;
} & LogicalSelector<FilterableAttributes>;
} & LogicalSelector<FilterableAttributes> &
GeoSelector;

export function buildMeiliSearchFilter<
FilterableAttributes extends string = string,
Expand All @@ -12,6 +13,11 @@ export function buildMeiliSearchFilter<

export type Sort<SortableAttributes extends string = string> = {
[K in SortableAttributes]?: SortDirection;
_geoPoint?: {
lat: number;
lng: number;
direction?: SortDirection;
}
};

export function buildMeiliSearchSort<
Expand All @@ -31,6 +37,24 @@ type ComparisonSelector = {
$empty?: boolean;
};

type GeoSelector = {
$geoRadius?: {
lat: number;
lng: number;
distanceInMeters: number;
};
$geoRoundingBox?: {
topRight: {
lat: number;
lng: number;
};
bottomLeft: {
lat: number;
lng: number;
};
};
};

type LogicalSelector<K extends string> = {
$or?: FilterQuery<K>[];
$and?: FilterQuery<K>[];
Expand Down
20 changes: 16 additions & 4 deletions src/sort.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { checkIsObject, isArray } = require('./utils');
const { checkIsObject, isArray, checkIsNumber } = require('./utils');

function buildMeiliSearchSort(sort) {
if (sort == null) {
Expand All @@ -11,12 +11,24 @@ function buildMeiliSearchSort(sort) {
throw new Error('Expected an object');
}

return Object.entries(sort).map(
([field, direction]) => `${field}:${prepareDirection(direction)}`,
return Object.entries(sort).map(([field, direction]) =>
field === '_geoPoint'
? parseGeoPointSort(direction)
: `${field}:${prepareDirection(direction)}`,
);
}

function prepareDirection(direction) {
function parseGeoPointSort(obj) {
checkIsObject(obj, '_geoPoint must be an object');

const { lat, lng, direction } = obj;
checkIsNumber(lat, 'lat must be a number');
checkIsNumber(lng, 'lng must be a number');

return `_geoPoint(${lat}, ${lng}):${prepareDirection(direction)}`;
}

function prepareDirection(direction = 1) {
const value = `${direction}`.toLowerCase();
switch (value) {
case 'ascending':
Expand Down
12 changes: 12 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ function isDate(obj) {
return obj instanceof Date;
}

function isNumber(n) {
return typeof n === 'number';
}

function isNumberOrDate(obj) {
return typeof obj === 'number' || isDate(obj);
}
Expand All @@ -28,6 +32,13 @@ function checkIsArray(obj, errMsg) {
throw new Error(errMsg);
}

function checkIsNumber(obj, errMsg) {
if (isNumber(obj)) {
return;
}
throw new Error(errMsg);
}

function checkIsNumberOrDate(obj, errMsg) {
if (isNumberOrDate(obj)) {
return;
Expand All @@ -41,5 +52,6 @@ module.exports = {
isDate,
checkIsObject,
checkIsArray,
checkIsNumber,
checkIsNumberOrDate,
};

0 comments on commit ccf6d0e

Please sign in to comment.