diff --git a/spec/MongoTransform.spec.js b/spec/MongoTransform.spec.js index c432e7960e..4c99f658f2 100644 --- a/spec/MongoTransform.spec.js +++ b/spec/MongoTransform.spec.js @@ -145,13 +145,25 @@ describe('parseObjectToMongoObjectForCreate', () => { }); it('geopoint', (done) => { - var input = {location: [180, -180]}; + var input = {location: [45, -45]}; var output = transform.mongoObjectToParseObject(null, input, { fields: { location: { type: 'GeoPoint' }}, }); expect(typeof output.location).toEqual('object'); expect(output.location).toEqual( - {__type: 'GeoPoint', longitude: 180, latitude: -180} + {__type: 'GeoPoint', longitude: 45, latitude: -45} + ); + done(); + }); + + it('polygon', (done) => { + var input = {location: { type: 'Polygon', coordinates: [[[45, -45],[45, -45]]]}}; + var output = transform.mongoObjectToParseObject(null, input, { + fields: { location: { type: 'Polygon' }}, + }); + expect(typeof output.location).toEqual('object'); + expect(output.location).toEqual( + {__type: 'Polygon', coordinates: [[45, -45],[45, -45]]} ); done(); }); diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js index a5d64eebad..cc449458ce 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -344,7 +344,7 @@ describe('Parse.Object testing', () => { it("invalid __type", function(done) { var item = new Parse.Object("Item"); - var types = ['Pointer', 'File', 'Date', 'GeoPoint', 'Bytes']; + var types = ['Pointer', 'File', 'Date', 'GeoPoint', 'Bytes', 'Polygon']; var tests = types.map(type => { var test = new Parse.Object("Item"); test.set('foo', { diff --git a/spec/ParsePolygon.spec.js b/spec/ParsePolygon.spec.js new file mode 100644 index 0000000000..44dd66fd90 --- /dev/null +++ b/spec/ParsePolygon.spec.js @@ -0,0 +1,262 @@ +const TestObject = Parse.Object.extend('TestObject'); +const MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); +const mongoURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; +const rp = require('request-promise'); +const defaultHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest' +} + +describe('Parse.Polygon testing', () => { + it('polygon save open path', (done) => { + const coords = [[0,0],[0,1],[1,1],[1,0]]; + const closed = [[0,0],[0,1],[1,1],[1,0],[0,0]]; + const obj = new TestObject(); + obj.set('polygon', {__type: 'Polygon', coordinates: coords}); + return obj.save().then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }).then((result) => { + const polygon = result.get('polygon'); + equal(polygon.__type, 'Polygon'); + equal(polygon.coordinates, closed); + done(); + }, done.fail); + }); + + it('polygon save closed path', (done) => { + const coords = [[0,0],[0,1],[1,1],[1,0],[0,0]]; + const obj = new TestObject(); + obj.set('polygon', {__type: 'Polygon', coordinates: coords}); + return obj.save().then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }).then((result) => { + const polygon = result.get('polygon'); + equal(polygon.__type, 'Polygon'); + equal(polygon.coordinates, coords); + done(); + }, done.fail); + }); + + it('polygon equalTo (open/closed) path', (done) => { + const openPoints = [[0,0],[0,1],[1,1],[1,0]]; + const closedPoints = [[0,0],[0,1],[1,1],[1,0],[0,0]]; + const openPolygon = {__type: 'Polygon', coordinates: openPoints}; + const closedPolygon = {__type: 'Polygon', coordinates: closedPoints}; + const obj = new TestObject(); + obj.set('polygon', openPolygon); + return obj.save().then(() => { + const query = new Parse.Query(TestObject); + query.equalTo('polygon', openPolygon); + return query.find(); + }).then((results) => { + const polygon = results[0].get('polygon'); + equal(polygon.__type, 'Polygon'); + equal(polygon.coordinates, closedPoints); + const query = new Parse.Query(TestObject); + query.equalTo('polygon', closedPolygon); + return query.find(); + }).then((results) => { + const polygon = results[0].get('polygon'); + equal(polygon.__type, 'Polygon'); + equal(polygon.coordinates, closedPoints); + done(); + }, done.fail); + }); + + it('polygon update', (done) => { + const oldCoords = [[0,0],[0,1],[1,1],[1,0]]; + const oldPolygon = {__type: 'Polygon', coordinates: oldCoords}; + const newCoords = [[2,2],[2,3],[3,3],[3,2]]; + const newPolygon = {__type: 'Polygon', coordinates: newCoords}; + const obj = new TestObject(); + obj.set('polygon', oldPolygon); + return obj.save().then(() => { + obj.set('polygon', newPolygon); + return obj.save(); + }).then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }).then((result) => { + const polygon = result.get('polygon'); + newCoords.push(newCoords[0]); + equal(polygon.__type, 'Polygon'); + equal(polygon.coordinates, newCoords); + done(); + }, done.fail); + }); + + it('polygon invalid value', (done) => { + const coords = [['foo','bar'],[0,1],[1,0],[1,1],[0,0]]; + const obj = new TestObject(); + obj.set('polygon', {__type: 'Polygon', coordinates: coords}); + return obj.save().then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }).then(done.fail, done); + }); + + it('polygon three points minimum', (done) => { + const coords = [[0,0]]; + const obj = new TestObject(); + obj.set('polygon', {__type: 'Polygon', coordinates: coords}); + obj.save().then(done.fail, done); + }); + + it('polygon three different points minimum', (done) => { + const coords = [[0,0],[0,1],[0,0]]; + const obj = new TestObject(); + obj.set('polygon', {__type: 'Polygon', coordinates: coords}); + obj.save().then(done.fail, done); + }); + + it('polygon counterclockwise', (done) => { + const coords = [[1,1],[0,1],[0,0],[1,0]]; + const closed = [[1,1],[0,1],[0,0],[1,0],[1,1]]; + const obj = new TestObject(); + obj.set('polygon', {__type: 'Polygon', coordinates: coords}); + obj.save().then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }).then((result) => { + const polygon = result.get('polygon'); + equal(polygon.__type, 'Polygon'); + equal(polygon.coordinates, closed); + done(); + }, done.fail); + }); + + it('polygonContain query', (done) => { + const points1 = [[0,0],[0,1],[1,1],[1,0]]; + const points2 = [[0,0],[0,2],[2,2],[2,0]]; + const points3 = [[10,10],[10,15],[15,15],[15,10],[10,10]]; + const polygon1 = {__type: 'Polygon', coordinates: points1}; + const polygon2 = {__type: 'Polygon', coordinates: points2}; + const polygon3 = {__type: 'Polygon', coordinates: points3}; + const obj1 = new TestObject({location: polygon1}); + const obj2 = new TestObject({location: polygon2}); + const obj3 = new TestObject({location: polygon3}); + Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { + const where = { + location: { + $geoIntersects: { + $point: { __type: 'GeoPoint', latitude: 0.5, longitude: 0.5 } + } + } + }; + return rp.post({ + url: Parse.serverURL + '/classes/TestObject', + json: { where, '_method': 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey + } + }); + }).then((resp) => { + expect(resp.results.length).toBe(2); + done(); + }, done.fail); + }); + + it('polygonContain invalid input', (done) => { + const points = [[0,0],[0,1],[1,1],[1,0]]; + const polygon = {__type: 'Polygon', coordinates: points}; + const obj = new TestObject({location: polygon}); + obj.save().then(() => { + const where = { + location: { + $geoIntersects: { + $point: { __type: 'GeoPoint', latitude: 181, longitude: 181 } + } + } + }; + return rp.post({ + url: Parse.serverURL + '/classes/TestObject', + json: { where, '_method': 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey + } + }); + }).then(done.fail, done); + }); + + it('polygonContain invalid geoPoint', (done) => { + const points = [[0,0],[0,1],[1,1],[1,0]]; + const polygon = {__type: 'Polygon', coordinates: points}; + const obj = new TestObject({location: polygon}); + obj.save().then(() => { + const where = { + location: { + $geoIntersects: { + $point: [] + } + } + }; + return rp.post({ + url: Parse.serverURL + '/classes/TestObject', + json: { where, '_method': 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey + } + }); + }).then(done.fail, done); + }); +}); + +describe_only_db('mongo')('Parse.Polygon testing', () => { + it('support 2d and 2dsphere', (done) => { + const coords = [[0,0],[0,1],[1,1],[1,0],[0,0]]; + const polygon = {__type: 'Polygon', coordinates: coords}; + const location = {__type: 'GeoPoint', latitude:10, longitude:10}; + const databaseAdapter = new MongoStorageAdapter({ uri: mongoURI }); + return reconfigureServer({ + appId: 'test', + restAPIKey: 'rest', + publicServerURL: 'http://localhost:8378/1', + databaseAdapter + }).then(() => { + return databaseAdapter.createIndex('TestObject', {location: '2d'}); + }).then(() => { + return databaseAdapter.createIndex('TestObject', {polygon: '2dsphere'}); + }).then(() => { + return rp.post({ + url: 'http://localhost:8378/1/classes/TestObject', + json: { + '_method': 'POST', + location, + polygon, + polygon2: polygon + }, + headers: defaultHeaders + }); + }).then((resp) => { + return rp.post({ + url: `http://localhost:8378/1/classes/TestObject/${resp.objectId}`, + json: {'_method': 'GET'}, + headers: defaultHeaders + }); + }).then((resp) => { + equal(resp.location, location); + equal(resp.polygon, polygon); + equal(resp.polygon2, polygon); + return databaseAdapter.getIndexes('TestObject'); + }).then((indexes) => { + equal(indexes.length, 4); + equal(indexes[0].key, {_id: 1}); + equal(indexes[1].key, {location: '2d'}); + equal(indexes[2].key, {polygon: '2dsphere'}); + equal(indexes[3].key, {polygon2: '2dsphere'}); + done(); + }, done.fail); + }); + + it('polygon loop is not valid', (done) => { + const coords = [[0,0],[0,1],[1,0],[1,1]]; + const obj = new TestObject(); + obj.set('polygon', {__type: 'Polygon', coordinates: coords}); + obj.save().then(done.fail, done); + }); +}); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 74d2212a7d..dcccb970f3 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -524,6 +524,7 @@ describe('SchemaController', () => { aPointer: {type: 'Pointer', targetClass: 'ThisClassDoesNotExistYet'}, aRelation: {type: 'Relation', targetClass: 'NewClass'}, aBytes: {type: 'Bytes'}, + aPolygon: {type: 'Polygon'}, })) .then(actualSchema => { const expectedSchema = { @@ -544,6 +545,7 @@ describe('SchemaController', () => { aPointer: { type: 'Pointer', targetClass: 'ThisClassDoesNotExistYet' }, aRelation: { type: 'Relation', targetClass: 'NewClass' }, aBytes: {type: 'Bytes'}, + aPolygon: {type: 'Polygon'}, }, classLevelPermissions: { find: { '*': true }, diff --git a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js index 74303eae36..5b7d9b9857 100644 --- a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js +++ b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js @@ -25,6 +25,7 @@ function mongoFieldToParseSchemaField(type) { case 'geopoint': return {type: 'GeoPoint'}; case 'file': return {type: 'File'}; case 'bytes': return {type: 'Bytes'}; + case 'polygon': return {type: 'Polygon'}; } } @@ -98,6 +99,7 @@ function parseFieldTypeToMongoFieldType({ type, targetClass }) { case 'GeoPoint': return 'geopoint'; case 'File': return 'file'; case 'Bytes': return 'bytes'; + case 'Polygon': return 'polygon'; } } diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index c126ee8aed..4bac1d24dd 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -182,7 +182,8 @@ export class MongoStorageAdapter { addFieldIfNotExists(className, fieldName, type) { return this._schemaCollection() - .then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type)); + .then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type)) + .then(() => this.createIndexesIfNeeded(className, fieldName, type)); } // Drops a collection. Resolves with true if it was a Parse Schema (eg. _User, Custom, etc.) @@ -429,6 +430,21 @@ export class MongoStorageAdapter { return this._adaptiveCollection(className) .then(collection => collection._mongoCollection.createIndex(index)); } + + createIndexesIfNeeded(className, fieldName, type) { + if (type && type.type === 'Polygon') { + const index = { + [fieldName]: '2dsphere' + }; + return this.createIndex(className, index); + } + return Promise.resolve(); + } + + getIndexes(className) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.indexes()); + } } export default MongoStorageAdapter; diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index 80c7be9430..f46c310e23 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -495,6 +495,9 @@ function transformTopLevelAtom(atom) { if (GeoPointCoder.isValidJSON(atom)) { return GeoPointCoder.JSONToDatabase(atom); } + if (PolygonCoder.isValidJSON(atom)) { + return PolygonCoder.JSONToDatabase(atom); + } if (FileCoder.isValidJSON(atom)) { return FileCoder.JSONToDatabase(atom); } @@ -692,6 +695,24 @@ function transformConstraint(constraint, inArray) { }; break; } + case '$geoIntersects': { + const point = constraint[key]['$point']; + if (!GeoPointCoder.isValidJSON(point)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoIntersect value; $point should be GeoPoint' + ); + } else { + Parse.GeoPoint._validate(point.latitude, point.longitude); + } + answer[key] = { + $geometry: { + type: 'Point', + coordinates: [point.longitude, point.latitude] + } + }; + break; + } default: if (key.match(/^\$+/)) { throw new Parse.Error( @@ -940,6 +961,10 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { restObject[key] = GeoPointCoder.databaseToJSON(value); break; } + if (schema.fields[key] && schema.fields[key].type === 'Polygon' && PolygonCoder.isValidDatabaseObject(value)) { + restObject[key] = PolygonCoder.databaseToJSON(value); + break; + } if (schema.fields[key] && schema.fields[key].type === 'Bytes' && BytesCoder.isValidDatabaseObject(value)) { restObject[key] = BytesCoder.databaseToJSON(value); break; @@ -1043,6 +1068,64 @@ var GeoPointCoder = { } }; +var PolygonCoder = { + databaseToJSON(object) { + return { + __type: 'Polygon', + coordinates: object['coordinates'][0] + } + }, + + isValidDatabaseObject(object) { + const coords = object.coordinates[0]; + if (object.type !== 'Polygon' || !(coords instanceof Array)) { + return false; + } + for (let i = 0; i < coords.length; i++) { + const point = coords[i]; + if (!GeoPointCoder.isValidDatabaseObject(point)) { + return false; + } + Parse.GeoPoint._validate(parseFloat(point[1]), parseFloat(point[0])); + } + return true; + }, + + JSONToDatabase(json) { + const coords = json.coordinates; + if (coords[0][0] !== coords[coords.length - 1][0] || + coords[0][1] !== coords[coords.length - 1][1]) { + coords.push(coords[0]); + } + const unique = coords.filter((item, index, ar) => { + let foundIndex = -1; + for (let i = 0; i < ar.length; i += 1) { + const pt = ar[i]; + if (pt[0] === item[0] && + pt[1] === item[1]) { + foundIndex = i; + break; + } + } + return foundIndex === index; + }); + if (unique.length < 3) { + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'GeoJSON: Loop must have at least 3 different vertices' + ); + } + return { type: 'Polygon', coordinates: [coords] }; + }, + + isValidJSON(value) { + return (typeof value === 'object' && + value !== null && + value.__type === 'Polygon' + ); + } +}; + var FileCoder = { databaseToJSON(object) { return { diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 6233df84fb..07801bd661 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -29,6 +29,7 @@ const parseTypeToPostgresType = type => { case 'Number': return 'double precision'; case 'GeoPoint': return 'point'; case 'Bytes': return 'jsonb'; + case 'Polygon': return 'polygon'; case 'Array': if (type.contents && type.contents.type === 'String') { return 'text[]'; @@ -435,6 +436,20 @@ const buildWhereClause = ({ schema, query, index }) => { values.push(fieldName, `(${points})`); index += 2; } + if (fieldValue.$geoIntersects && fieldValue.$geoIntersects.$point) { + const point = fieldValue.$geoIntersects.$point; + if (typeof point !== 'object' || point.__type !== 'GeoPoint') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoIntersect value; $point should be GeoPoint' + ); + } else { + Parse.GeoPoint._validate(point.latitude, point.longitude); + } + patterns.push(`$${index}:name::polygon @> $${index + 1}::point`); + values.push(fieldName, `(${point.longitude}, ${point.latitude})`); + index += 2; + } if (fieldValue.$regex) { let regex = fieldValue.$regex; @@ -480,6 +495,13 @@ const buildWhereClause = ({ schema, query, index }) => { index += 3; } + if (fieldValue.__type === 'Polygon') { + const value = convertPolygonToSQL(fieldValue.coordinates); + patterns.push(`$${index}:name ~= $${index + 1}::polygon`); + values.push(fieldName, value); + index += 2; + } + Object.keys(ParseToPosgresComparator).forEach(cmp => { if (fieldValue[cmp]) { const pgComparator = ParseToPosgresComparator[cmp]; @@ -844,6 +866,11 @@ export class PostgresStorageAdapter { case 'File': valuesArray.push(object[fieldName].name); break; + case 'Polygon': { + const value = convertPolygonToSQL(object[fieldName].coordinates); + valuesArray.push(value); + break; + } case 'GeoPoint': // pop the point and process later geoPoints[fieldName] = object[fieldName]; @@ -1024,6 +1051,11 @@ export class PostgresStorageAdapter { updatePatterns.push(`$${index}:name = POINT($${index + 1}, $${index + 2})`); values.push(fieldName, fieldValue.longitude, fieldValue.latitude); index += 3; + } else if (fieldValue.__type === 'Polygon') { + const value = convertPolygonToSQL(fieldValue.coordinates); + updatePatterns.push(`$${index}:name = $${index + 1}::polygon`); + values.push(fieldName, value); + index += 2; } else if (fieldValue.__type === 'Relation') { // noop } else if (typeof fieldValue === 'number') { @@ -1186,6 +1218,20 @@ export class PostgresStorageAdapter { longitude: object[fieldName].x } } + if (object[fieldName] && schema.fields[fieldName].type === 'Polygon') { + let coords = object[fieldName]; + coords = coords.substr(2, coords.length - 4).split('),('); + coords = coords.map((point) => { + return [ + parseFloat(point.split(',')[1]), + parseFloat(point.split(',')[0]) + ]; + }); + object[fieldName] = { + __type: "Polygon", + coordinates: coords + } + } if (object[fieldName] && schema.fields[fieldName].type === 'File') { object[fieldName] = { __type: 'File', @@ -1303,6 +1349,42 @@ export class PostgresStorageAdapter { } } +function convertPolygonToSQL(polygon) { + if (polygon.length < 3) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `Polygon must have at least 3 values` + ); + } + if (polygon[0][0] !== polygon[polygon.length - 1][0] || + polygon[0][1] !== polygon[polygon.length - 1][1]) { + polygon.push(polygon[0]); + } + const unique = polygon.filter((item, index, ar) => { + let foundIndex = -1; + for (let i = 0; i < ar.length; i += 1) { + const pt = ar[i]; + if (pt[0] === item[0] && + pt[1] === item[1]) { + foundIndex = i; + break; + } + } + return foundIndex === index; + }); + if (unique.length < 3) { + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'GeoJSON: Loop must have at least 3 different vertices' + ); + } + const points = polygon.map((point) => { + Parse.GeoPoint._validate(parseFloat(point[1]), parseFloat(point[0])); + return `(${point[1]}, ${point[0]})`; + }).join(', '); + return `(${points})`; +} + function removeWhiteSpace(regex) { if (!regex.endsWith('\n')){ regex += '\n'; diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 643febce0d..86ae1d556a 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -231,7 +231,8 @@ const validNonRelationOrPointerTypes = [ 'Array', 'GeoPoint', 'File', - 'Bytes' + 'Bytes', + 'Polygon' ]; // Returns an error suitable for throwing if the type is invalid const fieldTypeIsInvalid = ({ type, targetClass }) => { @@ -995,6 +996,11 @@ function getObjectType(obj) { return 'Bytes'; } break; + case 'Polygon' : + if(obj.coordinates) { + return 'Polygon'; + } + break; } throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid " + obj.__type); }