'use strict';
// functions related to spatial querying, attribute querying
const sqlParser = require('js-sql-parser');
/**
* Helper function to modify input geometries for queries. Will attempt to avoid various pitfalls,
* usually around projections
*
* @private
* @param {Object} esriBundle internal system collection of esri api classes
* @param {Object} geometry the geometry we want to query against
* @param {Boolean} isFileLayer true if layer is not tied to an arcgis server
* @param {Integer} [mapScale] optional scale value of the map to help detect problem situations
* @param {Integer} [sourceWkid] optional WKID of the layer being queried to help detect problem situations
* @return {Object} resolves with a feature set of features that satisfy the query
*/
function queryGeometryHelper(esriBundle, geometry, isFileLayer, mapScale, sourceWkid) {
let finalGeom;
if (isFileLayer && geometry.type !== 'extent') {
throw new Error('Cannot use geometries other than Extents in queries against non-ArcGIS Server based layers');
}
if (!isFileLayer && geometry.type === 'extent') {
// first check for case of very large extent in Lambert against a LatLong layer.
// in this case, we tend to get better results keeping things in an Extent form
// as it handles the north pole/180meridan crossage better.
if (mapScale && sourceWkid && mapScale > 20000000 && geometry.spatialReference &&
geometry.spatialReference.wkid === 3978 && sourceWkid === 4326) {
finalGeom = geometry;
} else {
// convert extent to polygon to avoid issues when a service in a different projection
// attempts to warp the extent
finalGeom = esriBundle.Polygon.fromExtent(geometry);
}
} else {
// take as is
finalGeom = geometry;
}
return finalGeom;
}
function queryGeometryBuilder(esriBundle) {
/**
* Fetch attributes from a layer that intersects with the given geometry
* Accepts the following options:
* - geometry: Required. geometry to intersect with the layer.
* Layers that are not hosted on an ArcGIS Server (e.g. file layers, WFS) can only use Extent geometries
* - url: Required if server based layer. Url to the map service layer to query against. Endpoint must support
* ESRI REST query interface. E.g. A feature layer endpoint.
* - featureLayer: Required if file based layer. Feature layer to query against
* - outFields: Optional. Array of strings containing field names to include in the results. Defaults to all fields.
* - where: Optional. A SQL where clause to filter results further. Useful when dealing with more results than the server can return.
* Cannot be used with layers that are not hosted on an ArcGIS Server (e.g. file layers, WFS)
* - returnGeometry: Optional. A boolean indicating if result geometery should be returned with results. Defaults to false
* - sourceWkid: Optional. An integer indicating the WKID that the queried layer is natively stored in on the server.
* If provided, allows query to attempt to mitigate any extent projection issues. Irrelevant for file based layers.
* - mapScale: Optional. An integer indicating the current map scale. If provided, allows query to attempt to mitigate any
* extent projection issues. Irrelevant for file based layers.
* - outSpatialReference: Required if returnGeometry is true. The spatial reference the return geometry should be in.
* @param {Object} options settings to determine the details and behaviors of the query.
* @return {Promise} resolves with a feature set of features that satisfy the query
*/
return options => {
// create and set the esri query parameters
const isFile = !!options.featureLayer;
const query = new esriBundle.Query();
query.returnGeometry = options.returnGeometry || false;
if (options.returnGeometry) {
query.outSpatialReference = options.outSpatialReference;
}
if (options.outFields) {
query.outFields = options.outFields;
} else {
query.outFields = ['*'];
}
if (options.where) {
if (isFile) {
throw new Error('Cannot use WHERE clauses in queries against non-ArcGIS Server based layers');
}
query.where = options.where;
}
query.geometry = queryGeometryHelper(esriBundle, options.geometry, isFile, options.mapScale, options.sourceWkid);
query.spatialRelationship = esriBundle.Query.SPATIAL_REL_INTERSECTS; // esriSpatialRelIntersects
return new Promise((resolve, reject) => {
// run the query. server based layers use a query task. file based layers use the layer's query function.
if (options.url) {
const queryTask = new esriBundle.QueryTask(options.url);
// issue the map server query request
queryTask.execute(query,
featureSet => {
resolve(featureSet);
},
error => {
reject(error);
}
);
} else if (isFile) {
// run the query on the layers internal data
options.featureLayer.queryFeatures(query,
featureSet => {
resolve(featureSet);
},
error => {
reject(error);
}
);
}
});
};
}
// similar to queryGeometry, but only returns OIDs, allowing us to run more efficient web requests.
// specifically, we can ignore the result limit on the server. Also doesn't require a geomtery, can just be
// where clause
function queryIdsBuilder(esriBundle) {
/**
* Fetch the Object IDs of features from a layer that satisfies the options
* Accepts the following options:
* - geometry: Optional. geometry to intersect with the layer.
* Layers that are not hosted on an ArcGIS Server (e.g. file layers, WFS) can only use Extent geometries
* - url: Required if server based layer. Url to the map service layer to query against. Endpoint must support
* ESRI REST query interface. E.g. A feature layer endpoint.
* - featureLayer: Required if file based layer. Feature layer to query against
* - where: Optional. A SQL where clause to filter results further. Useful when dealing with more results than the server can return,
* or if additional filters are active.
* Cannot be used with layers that are not hosted on an ArcGIS Server (e.g. file layers, WFS)
* - sourceWkid: Optional. An integer indicating the WKID that the queried layer is natively stored in on the server.
* If provided, allows query to attempt to mitigate any extent projection issues. Irrelevant for file based layers.
* - mapScale: Optional. An integer indicating the current map scale. If provided, allows query to attempt to mitigate any
* extent projection issues. Irrelevant for file based layers.
* @param {Object} options settings to determine the details of the query
* @return {Promise} resolves with an array of Object Ids that satisfy the query
*/
return options => {
// create and set the esri query parameters
const isFile = !!options.featureLayer;
const query = new esriBundle.Query();
query.returnGeometry = false;
if (options.where) {
if (isFile) {
throw new Error('Cannot use WHERE clauses in queries against non-ArcGIS Server based layers');
}
query.where = options.where;
}
if (options.geometry) {
query.geometry = queryGeometryHelper(esriBundle, options.geometry, isFile, options.mapScale, options.sourceWkid);
query.spatialRelationship = esriBundle.Query.SPATIAL_REL_INTERSECTS; // esriSpatialRelIntersects
}
return new Promise((resolve, reject) => {
// run the query. server based layers use a query task. file based layers use the layer's query function.
if (options.url) {
const queryTask = new esriBundle.QueryTask(options.url);
// issue the map server query request
queryTask.executeForIds(query,
oidArray => {
resolve(oidArray);
},
error => {
reject(error);
}
);
} else if (isFile) {
// run the query on the layers internal data
options.featureLayer.queryIds(query,
oidArray => {
resolve(oidArray);
},
error => {
reject(error);
}
);
}
});
};
}
// AQL classes. Attribute Query Language is a cheap thing that allows you to evaluate a SQL where style clause
// against an attribute object (i.e. a key-value dictionary). Has limited support for basic query logic
// (can be expanded)
// for science. baseclass of all Aql classes. if we have a common property we can add here
class AqlRoot {
}
// baseclass for Aql classes that deal with one value
class AqlAtomic extends AqlRoot {
constructor (val) {
super();
this.value = val;
}
}
// baseclass for Aql classes that deal with two values
class AqlDiatomic extends AqlRoot {
constructor (left, right) {
super();
this.left = left;
this.right = right;
}
}
// handles a literal (a constant). a string, a number, a boolean
class AqlLiteral extends AqlAtomic {
// constructor param is the literal value
evaluate () {
return this.value;
}
}
// handles an identifier. the name of an attribute property
class AqlIdentifier extends AqlAtomic {
// constructor param is the property name of the attribute
evaluate (attribute) {
return attribute[this.value];
}
}
// handles an array of values
class AqlArray extends AqlAtomic {
// constructor param is the array
evaluate () {
return this.value;
}
}
// handles an equals operator
class AqlEquals extends AqlDiatomic {
evaluate (attribute) {
return this.left.evaluate(attribute) === this.right.evaluate(attribute);
}
}
// handles a not-equals operator
class AqlNotEquals extends AqlDiatomic {
evaluate (attribute) {
return this.left.evaluate(attribute) !== this.right.evaluate(attribute);
}
}
// handles a greater-than-equals operator
class AqlGreaterEquals extends AqlDiatomic {
evaluate (attribute) {
return this.left.evaluate(attribute) >= this.right.evaluate(attribute);
}
}
// handles a less-than-equals operator
class AqlLessEquals extends AqlDiatomic {
evaluate (attribute) {
return this.left.evaluate(attribute) <= this.right.evaluate(attribute);
}
}
// handles a greater-than operator
class AqlGreater extends AqlDiatomic {
evaluate (attribute) {
return this.left.evaluate(attribute) > this.right.evaluate(attribute);
}
}
// handles a less-than operator
class AqlLess extends AqlDiatomic {
evaluate (attribute) {
return this.left.evaluate(attribute) < this.right.evaluate(attribute);
}
}
// handles an and operator
class AqlAnd extends AqlDiatomic {
evaluate (attribute) {
return this.left.evaluate(attribute) && this.right.evaluate(attribute);
}
}
// handles an or operator
class AqlOr extends AqlDiatomic {
evaluate (attribute) {
return this.left.evaluate(attribute) || this.right.evaluate(attribute);
}
}
// handles an in / not in operator
class AqlIn extends AqlDiatomic {
constructor (left, right, hasNot) {
super(left, right);
this.hasNot = hasNot;
}
evaluate (attribute) {
// we assume .right is an array (AqlArray)
const result = this.right.evaluate(attribute).includes(this.left.evaluate(attribute));
return this.hasNot ? !result : result;
}
}
// handles a like operator
class AqlLike extends AqlDiatomic {
constructor (left, right, hasNot) {
super(left, right);
this.hasNot = hasNot;
}
evaluate (attribute) {
// we assume .right evaulates to a string
const attVal = this.left.evaluate(attribute);
let pattern = this.right.evaluate(attribute);
// TODO basic wildcard search for now. may need to handle escaping special characters
// can steal codes from https://stackoverflow.com/questions/1314045/emulating-sql-like-in-javascript
// convert % to *
pattern = pattern.replace(/%/g, '.*');
const result = RegExp(pattern).test(attVal);
return this.hasNot ? !result : result;
}
}
// handles a not operator
class AqlNot extends AqlAtomic {
evaluate (attribute) {
return !this.value.evaluate(attribute);
}
}
// handles a set of parenthesis
class AqlParentheses extends AqlAtomic {
evaluate (attribute) {
// INTENSE
return this.value.evaluate(attribute);
}
}
// handles an inline function.
// can add functions as we find need to support them
class AqlFunctionCall extends AqlRoot {
constructor (functionName, params) {
super();
this.params = params; // array of parameters for the function. can be none or many
// get what function we are doing, check for support
this.fName = functionName.toLowerCase();
if (['upper', 'lower', 'date'].indexOf(this.fName) === -1) {
throw new Error('Encountered unsupported sql function in filter. Unhandled function: ' + functionName);
}
}
evaluate (attribute) {
// call our named function. evaluate the parameters array then pass them as actual params to the function
return this[this.fName](...this.params.map(p => p.evaluate(attribute)));
}
// assumes param will be a string
upper (value) {
return value.toUpperCase();
}
// assumes param will be a string
lower (value) {
return value.toLowerCase();
}
// assumes param will be something that can be cast to a date
date (value) {
// ESRI date fields are epoch dates (integers).
const d = new Date(value);
return d.getTime();
}
}
// converts a tree node from our SQL parse output to the appropriate Aql object
function sqlNodeToAqlNode (node) {
// TODO add support for datatype casting?
const typeReactor = {
AndExpression: n => {
return new AqlAnd(sqlNodeToAqlNode(n.left), sqlNodeToAqlNode(n.right));
},
OrExpression: n => {
return new AqlOr(sqlNodeToAqlNode(n.left), sqlNodeToAqlNode(n.right));
},
NotExpression: n => {
return new AqlNot(sqlNodeToAqlNode(n.value));
},
InExpressionListPredicate: n => {
return new AqlIn(sqlNodeToAqlNode(n.left), sqlNodeToAqlNode(n.right), !!n.hasNot);
},
LikePredicate: n => {
return new AqlLike(sqlNodeToAqlNode(n.left), sqlNodeToAqlNode(n.right), !!n.hasNot);
},
ComparisonBooleanPrimary: n => {
const operatorMap = {
'=': AqlEquals,
'==': AqlEquals,
'===': AqlEquals,
'>': AqlGreater,
'>=': AqlGreaterEquals,
'<': AqlLess,
'<=': AqlLessEquals,
'!=': AqlNotEquals,
'!==': AqlNotEquals
};
const aqlClass = operatorMap[n.operator];
if (!aqlClass) {
throw new Error('Encountered unsupported operator in filter. Unhandled operator: ' + n.operator);
}
return new aqlClass(sqlNodeToAqlNode(n.left), sqlNodeToAqlNode(n.right));
},
FunctionCall: n => {
return new AqlFunctionCall(n.name, n.params.map(p => sqlNodeToAqlNode(p)));
},
Identifier: n => {
return new AqlIdentifier(n.value);
},
Number: n => {
// number in string form
return new AqlLiteral(Number(n.value));
},
String: n => {
// remove embedded quotes from string
// TODO check if escaped double quote actually exists or was just part of the npm test page output display
let s = n.value;
if (s.startsWith('"') || s.startsWith(`'`)) {
s = s.substring(1, s.length - 1);
} else if (s.startsWith('\"')) {
s = s.substring(2, s.length - 2);
}
return new AqlLiteral(s);
},
Boolean: n => {
// node values are in all caps
return new AqlLiteral(n.value.toLowerCase() === 'true');
},
ExpressionList: n => {
// this code currently assumes that items in the expression list are literals.
// if we need any dynamically generated stuff (i.e. checking against other attribute properties)
// then this needs to change and the guts of AqlArray.evaluate needs to generate the array at
// every call (way less efficient)
return new AqlArray(n.value.map(nn => sqlNodeToAqlNode(nn).evaluate()));
},
SimpleExprParentheses: n => {
// n.value here is an ExpressionList, but i've yet to see an instance where it has more than one element in the array.
// for now we do a hack and hoist up the first element. This hack lets us pre-evaluate other expression lists
// (i.e. ones used in IN commands) that are filled with constants.
// TODO invest some time to try to find a case where there is > 1 element, and ensure the ExpressionList result
// formatting doesn't break the equation.
// TODO there could be some minor optimization in removing the brackets from the expression list
// or conversly not adding brackets here. Would want to be confident we know n.value is
// always an expression list. Not urgent, no harm in redundant brackets.
if (n.value.type === 'ExpressionList') {
if (n.value.value.length > 1) {
console.warn(`While converting SQL to AQL, encountered a parsed bracket containing an ExpressionList with more than one element`, n.value);
}
return new AqlParentheses(sqlNodeToAqlNode(n.value.value[0]));
} else {
// warn, and hail mary that we can just parse it
console.warn(`While converting SQL to AQL, encountered a parsed bracket containing ${n.value.type} instead of ExpressionList`);
return new AqlParentheses(sqlNodeToAqlNode(n.value));
}
}
}
if (!typeReactor[node.type]) {
throw new Error('Encountered unsupported query in filter. Unhandled type: ' + node.type);
} else {
return typeReactor[node.type](node);
}
}
// scans a where clause for any funny notation and attempts to convert to standard sql
// returns cleaned version.
function standardizeSql (sqlWhere) {
// ESRI supports a non-standard approach to casting dates.
// e.g. you can say `start_date < DATE '1-1-2001'`
// our sql parser will die on this.
// so we attempt to convert it to a function.
// TODO this logic needs flushing out.
// can there be other delimiters? can they have escape characters?
// do we need to handle a date cast on an attribute name instead of a constant?
// can we rely on a space before/after DATE function?
const findDate = sql => {
return sql.toUpperCase().indexOf(' DATE ');
};
let nextDate = findDate(sqlWhere);
while (nextDate > -1) {
const firstDelim = sqlWhere.indexOf(`'`, nextDate);
const lastDelim = sqlWhere.indexOf(`'`, firstDelim + 1);
// put bracket before and after delimiters
sqlWhere = sqlWhere.substring(0, nextDate) + ' DATE(' + sqlWhere.substring(firstDelim, lastDelim + 1) +
')' + sqlWhere.substring(lastDelim + 1);
// see if we still have more DATE functions
nextDate = findDate(sqlWhere);
}
return sqlWhere;
}
// converts a SQL where clause to an Aql object
function sqlToAql (sqlWhere) {
const fakeSQL = 'SELECT muffins FROM pod WHERE ' + standardizeSql(sqlWhere);
// the sqlParser will construct an object tree of the sql statement. we then crawl through the where clause tree
// and convert each node to the equivalent aql object
const queryTree = sqlParser.parse(fakeSQL);
return sqlNodeToAqlNode(queryTree.value.where);
}
/**
* Given an SQL WHERE condition, will search an array of attribute objects and return a filtered
* array containing attributes that satisfy the WHERE condition.
* Array can contain raw attribute objects, or objects with a propery `attributes` that contain
* an attribute object.
*
* @function sqlAttributeFilter
* @param {Array} attributeArray array of attribute objects or objects with `attributes` property.
* @param {String} sqlWhere a SQL WHERE clause (without the word `WHERE`) that has field names matching the attribute property names.
* @param {Boolean} [attribAsProperty=false] indicates if the attribute object resides in a propery called `attributes`. Set to false if array contains raw attribute objects.
* @returns {Array} array of attribute objects that meet the conditions of the filter. the result objects will be in the same form as they were passed in
*/
function sqlAttributeFilter (attributeArray, sqlWhere, attribAsProperty = false) {
// attribAsProperty means where the attribute lives in relation to the array
// {att} is a standard key-value object of attributes
// [ {att} , {att} ] would be the false case. this is the format of attributes from the geoApi attribute loader
// [ {attributes:{att}}, {attributes:{att}} ] would be the true case. this is the format of attributes sitting in the graphics array of a filebased layer
// convert the sql where clause to an attribute query language tree, then
// use that to evaluate against each attribute.
const aql = sqlToAql(sqlWhere);
// split in two to avoid doing boolean check at every iteration
if (attribAsProperty) {
return attributeArray.filter(a => {
return aql.evaluate(a.attributes);
});
} else {
return attributeArray.filter(a => {
return aql.evaluate(a);
});
}
}
/**
* Given an SQL WHERE condition, will search an array of Graphics adjust their visibility
* based on if they satisfy the WHERE condition.
*
* @function sqlAttributeFilter
* @param {Array} graphics array of Graphics.
* @param {String} sqlWhere a SQL WHERE clause (without the word `WHERE`) that has field names matching the attribute property names.
* @returns {Array} array of attributes of visible features.
*/
function sqlGraphicsVisibility (graphics, sqlWhere) {
// variant of sqlAttributeFilter. customized for turning graphics visibility on and off.
// since we need to turn off the items "not in the query", this saves us doing multiple iterations.
// however it becomes limited in that it really needs arrays of Graphic objects.
// convert the sql where clause to an attribute query language tree, then
// use that to evaluate against each attribute.
if (sqlWhere === '') {
// no restrictions. show everything
graphics.forEach(g => g.show());
return graphics;
} else if (sqlWhere === '1=2') {
// everything off. hide everything
// TODO layer should be invisible, so maybe this is irrelevant? or is it better to be safe, as something else could use this function.
graphics.forEach(g => g.hide());
return [];
}
// otherwise we have a sql query to evaluate
const aql = sqlToAql(sqlWhere);
const visibleAttributes = [];
graphics.forEach(g => {
if (aql.evaluate(g.attributes)) {
g.show();
visibleAttributes.push(g.attributes);
} else {
g.hide();
}
});
return visibleAttributes;
}
module.exports = esriBundle => {
return {
queryGeometry: queryGeometryBuilder(esriBundle),
queryIds: queryIdsBuilder(esriBundle),
sqlAttributeFilter,
sqlGraphicsVisibility
};
};