From 30f17509ea9927d923ffd42c703adefc902b66ea Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 24 Aug 2021 17:28:08 -0400 Subject: [PATCH] feat: point field localization and graphql --- demo/collections/Geolocation.ts | 3 + .../elements/WhereBuilder/field-types.tsx | 13 ++ src/collections/bindCollection.ts | 11 +- src/collections/buildSchema.ts | 7 +- src/collections/init.ts | 3 +- src/collections/operations/find.ts | 1 + src/collections/tests/pointField.spec.js | 135 +++++++++++++++--- src/express/types.ts | 2 +- src/graphql/schema/buildWhereInputType.ts | 3 +- src/mongoose/buildQuery.ts | 13 +- src/mongoose/buildSchema.ts | 15 +- src/types/index.ts | 1 + 12 files changed, 174 insertions(+), 33 deletions(-) diff --git a/demo/collections/Geolocation.ts b/demo/collections/Geolocation.ts index 7773e4c542..1a4205ab5a 100644 --- a/demo/collections/Geolocation.ts +++ b/demo/collections/Geolocation.ts @@ -7,6 +7,9 @@ const Geolocation: CollectionConfig = { singular: 'Geolocation', plural: 'Geolocations', }, + access: { + read: () => true, + }, hooks: { beforeRead: [ (operation) => operation.doc, diff --git a/src/admin/components/elements/WhereBuilder/field-types.tsx b/src/admin/components/elements/WhereBuilder/field-types.tsx index 1cdf649d8e..870f981ce1 100644 --- a/src/admin/components/elements/WhereBuilder/field-types.tsx +++ b/src/admin/components/elements/WhereBuilder/field-types.tsx @@ -45,6 +45,15 @@ const numeric = [ }, ]; +const geo = [ + ...base, + ...boolean, + { + label: 'near', + value: 'near', + }, +]; + const like = { label: 'is like', value: 'like', @@ -79,6 +88,10 @@ const fieldTypeConditions = { component: 'Date', operators: [...base, ...numeric], }, + point: { + component: 'Point', + operators: [...geo], + }, upload: { component: 'Text', operators: [...base], diff --git a/src/collections/bindCollection.ts b/src/collections/bindCollection.ts index 5499a95c72..6a50e04982 100644 --- a/src/collections/bindCollection.ts +++ b/src/collections/bindCollection.ts @@ -1,8 +1,9 @@ -const bindCollectionMiddleware = (collection) => { - return (req, res, next) => { - req.collection = collection; - next(); - }; +import { NextFunction, Response } from 'express'; +import { PayloadRequest } from '../express/types'; + +const bindCollectionMiddleware = (collection: string) => (req: PayloadRequest, res: Response, next: NextFunction) => { + req.collection = collection; + next(); }; export default bindCollectionMiddleware; diff --git a/src/collections/buildSchema.ts b/src/collections/buildSchema.ts index 0453693782..1cd583a285 100644 --- a/src/collections/buildSchema.ts +++ b/src/collections/buildSchema.ts @@ -1,15 +1,18 @@ import paginate from 'mongoose-paginate-v2'; +import { Schema } from 'mongoose'; +import { SanitizedConfig } from '../../config'; import buildQueryPlugin from '../mongoose/buildQuery'; import buildSchema from '../mongoose/buildSchema'; +import { SanitizedCollectionConfig } from './config/types'; -const buildCollectionSchema = (collection, config, schemaOptions = {}) => { +const buildCollectionSchema = (collection: SanitizedCollectionConfig, config: SanitizedConfig, schemaOptions = {}): Schema => { const schema = buildSchema( config, collection.fields, { timestamps: collection.timestamps !== false, ...schemaOptions }, ); - schema.plugin(paginate) + schema.plugin(paginate, { useEstimatedCount: true }) .plugin(buildQueryPlugin); return schema; diff --git a/src/collections/init.ts b/src/collections/init.ts index 82df0ee87e..1159c8da27 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -8,6 +8,7 @@ import apiKeyStrategy from '../auth/strategies/apiKey'; import buildSchema from './buildSchema'; import bindCollectionMiddleware from './bindCollection'; import { SanitizedCollectionConfig } from './config/types'; +import { SanitizedConfig } from '../../config'; import { Payload } from '../index'; const LocalStrategy = Passport.Strategy; @@ -16,7 +17,7 @@ export default function registerCollections(ctx: Payload): void { ctx.config.collections = ctx.config.collections.map((collection: SanitizedCollectionConfig) => { const formattedCollection = collection; - const schema = buildSchema(formattedCollection, ctx.config); + const schema = buildSchema(formattedCollection, ctx.config as SanitizedConfig); if (collection.auth) { schema.plugin(passportLocalMongoose, { diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts index 53242c4f6e..8eeab70501 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -108,6 +108,7 @@ async function find(incomingArgs: Arguments): Promise { sort, lean: true, leanWithId: true, + useEstimatedCount: true, }; const paginatedDocs = await Model.paginate(query, optionsToExecute); diff --git a/src/collections/tests/pointField.spec.js b/src/collections/tests/pointField.spec.js index 981b552e03..057c591ad1 100644 --- a/src/collections/tests/pointField.spec.js +++ b/src/collections/tests/pointField.spec.js @@ -1,16 +1,17 @@ +import { GraphQLClient } from 'graphql-request'; import getConfig from '../../config/load'; import { email, password } from '../../mongoose/testCredentials'; require('isomorphic-fetch'); -const { serverURL: url } = getConfig(); +const { serverURL, routes } = getConfig(); let token = null; let headers = null; describe('GeoJSON', () => { beforeAll(async (done) => { - const response = await fetch(`${url}/api/admins/login`, { + const response = await fetch(`${serverURL}/api/admins/login`, { body: JSON.stringify({ email, password, @@ -32,16 +33,15 @@ describe('GeoJSON', () => { done(); }); - describe('Point Field', () => { - const location = { - coordinates: [100, 200], - }; + describe('Point Field - REST', () => { + const location = [10, 20]; + const localizedPoint = [30, 40]; let doc; beforeAll(async (done) => { // create document a - const create = await fetch(`${url}/api/geolocation`, { - body: JSON.stringify({ location }), + const create = await fetch(`${serverURL}/api/geolocation`, { + body: JSON.stringify({ location, localizedPoint }), headers, method: 'post', }); @@ -51,21 +51,122 @@ describe('GeoJSON', () => { }); it('should create and read collections with points', async () => { - expect(doc).not.toBeNull(); - console.log(doc); + expect(doc.id).not.toBeNull(); + expect(doc.location).toStrictEqual(location); + expect(doc.localizedPoint).toStrictEqual(localizedPoint); }); - it('should query for nearest point', async () => { - const q = `${url}/api/geolocation?location[near]=${location.coordinates}`; - console.log(q); - const response = await fetch(`${url}/api/geolocation?where[location][near]=${location.coordinates}`, { + it('should query where near point', async () => { + const [x, y] = location; + const hitResponse = await fetch(`${serverURL}/api/geolocation?where[location][near]=${x + 0.01},${y + 0.01},10000`, { headers, method: 'get', }); - const data = await response.json(); - [doc] = data.docs; + const hitData = await hitResponse.json(); + const hitDocs = hitData.docs; - expect(data.docs).toHaveLength(1); + const missResponse = await fetch(`${serverURL}/api/geolocation?where[location][near]=-${x},-${y},5000`, { + headers, + method: 'get', + }); + const missData = await missResponse.json(); + const missDocs = missData.docs; + + expect(hitDocs).toHaveLength(1); + expect(missDocs).toHaveLength(0); + }); + + it('should query where near localized point', async () => { + const [x, y] = localizedPoint; + const hitResponse = await fetch(`${serverURL}/api/geolocation?where[localizedPoint][near]=${x + 0.01},${y + 0.01},10000`, { + headers, + method: 'get', + }); + const hitData = await hitResponse.json(); + const hitDocs = hitData.docs; + + const missResponse = await fetch(`${serverURL}/api/geolocation?where[localizedPoint][near]=-${x},-${y},5000`, { + headers, + method: 'get', + }); + const missData = await missResponse.json(); + const missDocs = missData.docs; + + expect(hitDocs).toHaveLength(1); + expect(missDocs).toHaveLength(0); + }); + }); + + describe('Point Field - GraphQL', () => { + const url = `${serverURL}${routes.api}${routes.graphQL}`; + let client = null; + const location = [50, 60]; + const localizedPoint = [70, 80]; + let doc; + + beforeAll(async (done) => { + client = new GraphQLClient(url, { headers: { Authorization: `JWT ${token}` } }); + + // language=graphQL + const query = `mutation { + createGeolocation (data: {location: [${location[0]}, ${location[1]}], localizedPoint: [${localizedPoint[0]}, ${localizedPoint[1]}]}) { + id + location + localizedPoint + } + }`; + + const response = await client.request(query); + + const { id } = response.createGeolocation; + // language=graphQL + const readQuery = `query { + Geolocation(id: "${id}") { + id + location + localizedPoint + } + }`; + const readResponse = await client.request(readQuery); + doc = readResponse.Geolocation; + done(); + }); + + it('should create and read collections with points', async () => { + expect(doc.id).not.toBeNull(); + expect(doc.location).toStrictEqual(location); + expect(doc.localizedPoint).toStrictEqual(localizedPoint); + }); + + it('should query where near point', async () => { + const [x, y] = location; + // language=graphQL + const hitQuery = `query getGeos { + Geolocations(where: { location: { near: [${x + 0.01},${y + 0.01},10000]}}) { + docs { + id + location + localizedPoint + } + } + }`; + const hitResponse = await client.request(hitQuery); + const hitDocs = hitResponse.Geolocations.docs; + + const missQuery = `query getGeos { + Geolocations(where: { location: { near: [${-x},${-y},10000]}}) { + docs { + id + location + localizedPoint + } + } + }`; + const missResponse = await client.request(missQuery); + const missDocs = missResponse.Geolocations.docs; + + expect(hitDocs).toHaveLength(1); + expect(missDocs).toHaveLength(0); }); }); }); diff --git a/src/express/types.ts b/src/express/types.ts index e8b6644519..24d7763d8d 100644 --- a/src/express/types.ts +++ b/src/express/types.ts @@ -9,7 +9,7 @@ export type PayloadRequest = Request & { payload: Payload; locale?: string; fallbackLocale?: string; - collection?: Collection; + collection?: Collection | string; payloadAPI: 'REST' | 'local' | 'graphQL' file?: UploadedFile user: User | null diff --git a/src/graphql/schema/buildWhereInputType.ts b/src/graphql/schema/buildWhereInputType.ts index e84a155fd7..7863bedcaf 100644 --- a/src/graphql/schema/buildWhereInputType.ts +++ b/src/graphql/schema/buildWhereInputType.ts @@ -84,6 +84,7 @@ const buildWhereInputType = (name: string, fields: Field[], parentName: string): equality: ['equals', 'not_equals'], contains: ['in', 'not_in', 'all'], comparison: ['greater_than_equal', 'greater_than', 'less_than_equal', 'less_than'], + geo: ['near'], }; const fieldToSchemaMap = { @@ -198,7 +199,7 @@ const buildWhereInputType = (name: string, fields: Field[], parentName: string): field, type, parentName, - [...operators.equality, ...operators.comparison, 'near'], + [...operators.equality, ...operators.comparison, ...operators.geo], ), }; }, diff --git a/src/mongoose/buildQuery.ts b/src/mongoose/buildQuery.ts index 1effc6fe58..e485f45a12 100644 --- a/src/mongoose/buildQuery.ts +++ b/src/mongoose/buildQuery.ts @@ -2,7 +2,7 @@ /* eslint-disable no-restricted-syntax */ import mongoose, { FilterQuery } from 'mongoose'; -const validOperators = ['like', 'in', 'all', 'not_in', 'greater_than_equal', 'greater_than', 'less_than_equal', 'less_than', 'not_equals', 'equals', 'exists']; +const validOperators = ['like', 'in', 'all', 'not_in', 'greater_than_equal', 'greater_than', 'less_than_equal', 'less_than', 'not_equals', 'equals', 'exists', 'near']; function addSearchParam(key, value, searchParams) { return { ...searchParams, @@ -216,6 +216,17 @@ class ParamParser { case 'exists': formattedValue = { $exists: (formattedValue === 'true' || formattedValue === true) }; break; + case 'near': + // eslint-disable-next-line no-case-declarations + const [longitude, latitude, maxDistance, minDistance] = convertArrayFromCommaDelineated(formattedValue); + formattedValue = { + $near: { + $geometry: { type: 'Point', coordinates: [parseFloat(longitude), parseFloat(latitude)] }, + }, + }; + if (maxDistance) formattedValue.$near.$maxDistance = parseFloat(maxDistance); + if (minDistance) formattedValue.$near.$maxDistance = parseFloat(maxDistance); + break; default: break; } diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index 20e12969f5..9de2cde969 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -56,10 +56,10 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], options = { if (fieldSchema) { fields = fieldSchema(field, fields, config); - // geospatial field index must be created after the schema is created - if (fieldIndexMap[field.type]) { - indexFields.push(field); - } + } + // geospatial field index must be created after the schema is created + if (fieldIndexMap[field.type]) { + indexFields.push(...fieldIndexMap[field.type](field, config)); } }); @@ -74,7 +74,12 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], options = { }; const fieldIndexMap = { - point: (field: Field) => ({ [field.name]: field.index === false ? undefined : field.index || '2dsphere' }), + point: (field: Field, config: SanitizedConfig) => { + if (field.localized) { + return config.localization.locales.map((locale) => ({ [`${field.name}.${locale}`]: field.index === false ? undefined : field.index || '2dsphere' })); + } + return [{ [field.name]: field.index === false ? undefined : field.index || '2dsphere' }]; + }, }; const fieldToSchemaMap = { diff --git a/src/types/index.ts b/src/types/index.ts index 2b037d196e..ee0db05368 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,6 +11,7 @@ export type Operator = 'equals' | 'less_than' | 'less_than_equals' | 'like' + | 'near' export type WhereField = { [key in Operator]?: unknown