diff --git a/examples/examples.json b/examples/examples.json index ae9c06d4f2..2230ef1537 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -126,6 +126,31 @@ } ] }, + { + "title": "Filters", + "samples": [ + { + "title": "Category Filter", + "desc": "Create a Category filter with checkbox selectors", + "file": "public/filters/category-filter.html" + }, + { + "title": "Range Filter", + "desc": "Create a Range filter with a range slider", + "file": "public/filters/range-filter.html" + }, + { + "title": "AND Filter", + "desc": "Apply an AND filter to combine multiple filters", + "file": "public/filters/and-filter.html" + }, + { + "title": "Complex Filter", + "desc": "Apply several combined filters to a dataset", + "file": "public/filters/complex-filter.html" + } + ] + }, { "title": "Misc", "samples": [ diff --git a/examples/public/filters/and-filter.html b/examples/public/filters/and-filter.html new file mode 100644 index 0000000000..5922e6f00b --- /dev/null +++ b/examples/public/filters/and-filter.html @@ -0,0 +1,153 @@ + + + + AND Filter | CARTO + + + + + + + + + + + +
+ + + + + diff --git a/examples/public/filters/category-filter.html b/examples/public/filters/category-filter.html new file mode 100644 index 0000000000..d4d1e538f9 --- /dev/null +++ b/examples/public/filters/category-filter.html @@ -0,0 +1,113 @@ + + + + Category Filter | CARTO + + + + + + + + + + + +
+ + + + + diff --git a/examples/public/filters/complex-filter.html b/examples/public/filters/complex-filter.html new file mode 100644 index 0000000000..e620a08bdb --- /dev/null +++ b/examples/public/filters/complex-filter.html @@ -0,0 +1,90 @@ + + + + Complex Filter Example | CARTO + + + + + + + + + + + +
+ + + + + diff --git a/examples/public/filters/range-filter.html b/examples/public/filters/range-filter.html new file mode 100644 index 0000000000..7462cd16be --- /dev/null +++ b/examples/public/filters/range-filter.html @@ -0,0 +1,105 @@ + + + + Range Filter | CARTO + + + + + + + + + + + +
+ + + + + diff --git a/examples/public/style.css b/examples/public/style.css index 098519a9cf..28b653f86b 100644 --- a/examples/public/style.css +++ b/examples/public/style.css @@ -167,6 +167,9 @@ aside.toolbox { #controls li input { margin: 0 8px 0 0; } +#controls li input[type=checkbox] { + margin: 2px 8px 0 0; +} #controls li label { font: 12px/16px 'Open Sans'; cursor: pointer; @@ -389,3 +392,96 @@ aside.toolbox { border-radius: 0 4px 4px 0; border-right: 1px solid rgba(22, 39, 69, 0.2); } + +/* RANGE SLIDER */ + +input[type="range"] { + position: relative; + display: inline-block; + width: 100%; + height: 10px; + background-color: rgb(23, 133, 251); + border-radius: 10px; + outline: none; + cursor: pointer; + margin: 0 0 22px; + -webkit-appearance: none; +} + +input[type=range]::before, +input[type=range]::after { + color: #747D82; + position: absolute; + top: 20px; +} + +input[type=range]::before { + content: attr(min-with-suffix); + left: 0; +} + +input[type=range]::after { + content: attr(max-with-suffix); + right: 0; +} + +input[type="range"]::-webkit-slider-runnable-track, +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; +} + +input[type="range"]::-webkit-slider-thumb { + width: 15px; + height: 15px; + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 50%; + box-shadow: 0 5px 15px 0 rgba(20, 75, 102, 0.15); + margin-top: -2px; +} + +input[type="range"]::-moz-range-thumb { + width: 15px; + height: 15px; + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 50%; + box-shadow: 0 5px 15px 0 rgba(20, 75, 102, 0.15); + margin-top: -2px; +} + +input[type="range"]::-ms-thumb { + width: 15px; + height: 15px; + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 50%; + box-shadow: 0 5px 15px 0 rgba(20, 75, 102, 0.15); + margin-top: -2px; +} + +input[type="range"]::-webkit-slider-runnable-track { + height: 10px; + width: 100%; + background: linear-gradient(to right, transparent calc(var(--value) * 1%), rgb(226, 230, 227) 0); + border-radius: 10px; +} + +input[type="range"]::-moz-range-track { + height: 10px; + background: linear-gradient(to right, transparent calc(var(--value) * 1%), rgb(226, 230, 227) 0); + border-radius: 10px; +} + +input[type="range"]::-ms-track { + background-color: rgb(23, 133, 251); + height: 10px; +} + +input[type="range"]::-ms-fill-lower { + background-color: rgb(226, 230, 227); +} + +input[type="range"]::-ms-fill-upper { + background-color: rgb(23, 133, 251);; +} diff --git a/src/api/v4/dataview/base.js b/src/api/v4/dataview/base.js index a5abc1adb9..577420ecb3 100644 --- a/src/api/v4/dataview/base.js +++ b/src/api/v4/dataview/base.js @@ -3,6 +3,7 @@ var Backbone = require('backbone'); var status = require('../constants').status; var SourceBase = require('../source/base'); var FilterBase = require('../filter/base'); +var SQLFilterBase = require('../filter/base-sql'); var CartoError = require('../error-handling/carto-error'); var CartoValidationError = require('../error-handling/carto-validation-error'); @@ -11,14 +12,14 @@ var CartoValidationError = require('../error-handling/carto-validation-error'); * * Dataviews are a way to extract data from a CARTO account in predefined ways * (eg: a list of categories, the result of a formula operation, etc.). - * + * * **This object should not be used directly** * * The data used in a dataviews cames from a {@link carto.source.Base|source} that might change * due to different reasons (eg: SQL query changed). - * + * * When dataview data changes the dataview will trigger events to notify subscribers when new data is available. - * + * * @example * // Keep your widget data sync. Remember each dataview has his own data format. * dataview.on('dataChanged', newData => { @@ -246,7 +247,7 @@ Base.prototype._checkOptions = function (options) { }; Base.prototype._checkFilter = function (filter) { - if (!(filter instanceof FilterBase)) { + if (!(filter instanceof FilterBase) || filter instanceof SQLFilterBase) { throw this._getValidationError('filterRequired'); } }; diff --git a/src/api/v4/error-handling/error-list/validation-errors.js b/src/api/v4/error-handling/error-list/validation-errors.js index b32d5fde2c..dcf423d057 100644 --- a/src/api/v4/error-handling/error-list/validation-errors.js +++ b/src/api/v4/error-handling/error-list/validation-errors.js @@ -189,6 +189,34 @@ module.exports = { 'invalid-bounds-object': { messageRegex: /invalidBoundsObject/, friendlyMessage: 'Bounds object is not valid. Use a carto.filter.Bounds object' + }, + 'column-required': { + messageRegex: /columnRequired/, + friendlyMessage: 'Column property is required.' + }, + 'column-string': { + messageRegex: /columnString/, + friendlyMessage: 'Column property must be a string.' + }, + 'empty-column': { + messageRegex: /emptyColumn/, + friendlyMessage: 'Column property must be not empty.' + }, + 'invalid-filter': { + messageRegex: /invalidFilter(.+)/, + friendlyMessage: "'$0' is not a valid filter. Please check documentation." + }, + 'invalid-option': { + messageRegex: /invalidOption(.+)/, + friendlyMessage: "'$0' is not a valid option for this filter." + }, + 'wrong-filter-type': { + messageRegex: /wrongFilterType/, + friendlyMessage: 'Filters need to extend from carto.filter.SQLBase. Please use carto.filter.Category or carto.filter.Range.' + }, + 'invalid-parameter-type': { + messageRegex: /invalidParameterType(.+)/, + friendlyMessage: "Invalid parameter type for '$0'. Please check filters documentation." } }, aggregation: { diff --git a/src/api/v4/filter/and.js b/src/api/v4/filter/and.js new file mode 100644 index 0000000000..a995030998 --- /dev/null +++ b/src/api/v4/filter/and.js @@ -0,0 +1,34 @@ +const FiltersCollection = require('./filters-collection'); + +/** + * When including this filter into a {@link carto.source.SQL} or a {@link carto.source.Dataset}, the rows will be filtered by the conditions included within filters. + * + * This filter will group as many filters as you want and it will add them to the query returning the rows that match ALL the filters to render the visualization. + * + * You can add or remove filters by invoking `.addFilter()` and `.removeFilter()`. + * + * @example + * // Create a filter by room type, showing only private rooms + * const roomTypeFilter = new carto.filter.Category('room_type', { eq: 'Private room' }); + * // Create a filter by price, showing only listings lower than or equal to 50€ + * const priceFilter = new carto.filter.Range('price', { lte: 50 }); + * + * // Combine the filters with an AND condition, returning rows that match both filters + * const filterByRoomTypeAndPrice = new carto.filter.AND([ roomTypeFilter, priceFilter ]); + * + * // Add filters to the existing source + * source.addFilter(filterByRoomTypeAndPrice); + * + * @class AND + * @extends carto.filter.FiltersCollection + * @memberof carto.filter + * @api + */ +class AND extends FiltersCollection { + constructor (filters) { + super(filters); + this.JOIN_OPERATOR = 'AND'; + } +} + +module.exports = AND; diff --git a/src/api/v4/filter/base-sql.js b/src/api/v4/filter/base-sql.js new file mode 100644 index 0000000000..e716687bb3 --- /dev/null +++ b/src/api/v4/filter/base-sql.js @@ -0,0 +1,172 @@ +const _ = require('underscore'); +const Base = require('./base'); +const getObjectValue = require('../../../../src/util/get-object-value'); + +const ALLOWED_OPTIONS = ['includeNull']; +const DEFAULT_JOIN_OPERATOR = 'AND'; + +/** + * SQL Filter + * + * A SQL filter is the base for all the SQL filters such as the Category Filter or the Range filter + * + * @param {string} column - The filtering will be performed against this column + * @param {object} [options={}] + * @param {boolean} [options.includeNull] - Include null rows when returning data + * + * @class SQLBase + * @extends carto.filter.Base + * @memberof carto.filter + */ +class SQLBase extends Base { + constructor (column, options = {}) { + super(); + + this._checkColumn(column); + this._checkOptions(options); + + this._column = column; + this._filters = {}; + this._options = options; + } + + /** + * Set any of the filter conditions, overwriting the previous one. + * @param {string} filterType - The filter type that you want to set + * @param {string} filterValue - The value of the filter + */ + set (filterType, filterValue) { + if (!filterType || !filterValue || !_.isString(filterType)) { + return; + } + + const newFilter = { [filterType]: filterValue }; + + this._checkFilters(newFilter); + this._filters[filterType] = filterValue; + + this.trigger('change:filters', newFilter); + } + + /** + * Set the filter conditions, overriding all the previous ones. + * @param {object} filters - The object containing all the new filters to apply. + */ + setFilters (filters) { + if (!filters || !_.isObject(filters) || _.isEmpty(filters)) { + return; + } + + this._checkFilters(filters); + this._filters = filters; + + this.trigger('change:filters', filters); + } + + $getSQL () { + const filters = Object.keys(this._filters); + let sql = filters + .map(filterType => this._interpolateFilter(filterType, this._filters[filterType])) + .filter(filter => Boolean(filter)) + .join(` ${DEFAULT_JOIN_OPERATOR} `); + + if (this._options.includeNull) { + this._includeNullInQuery(sql); + } + + if (filters.length > 1) { + return `(${sql})`; + } + + return sql; + } + + _checkColumn (column) { + if (_.isUndefined(column)) { + throw this._getValidationError('columnRequired'); + } + + if (!_.isString(column)) { + throw this._getValidationError('columnString'); + } + + if (_.isEmpty(column)) { + throw this._getValidationError('emptyColumn'); + } + } + + _checkFilters (filters) { + Object.keys(filters).forEach(filter => { + const isFilterValid = _.contains(this.ALLOWED_FILTERS, filter); + + if (!isFilterValid) { + throw this._getValidationError(`invalidFilter${filter}`); + } + + const parameters = this.PARAMETER_SPECIFICATION[filter].parameters; + const haveCorrectType = parameters.every( + parameter => { + const parameterValue = getObjectValue(filters, parameter.name); + return parameter.allowedTypes.some(type => parameterIsOfType(type, parameterValue)); + } + ); + + if (!haveCorrectType) { + throw this._getValidationError(`invalidParameterType${filter}`); + } + }); + } + + _checkOptions (options) { + Object.keys(options).forEach(option => { + const isOptionValid = _.contains(ALLOWED_OPTIONS, option); + + if (!isOptionValid) { + throw this._getValidationError(`invalidOption${option}`); + } + }); + } + + _convertValueToSQLString (filterValue) { + if (_.isDate(filterValue)) { + return `'${filterValue.toISOString()}'`; + } + + if (_.isArray(filterValue)) { + return filterValue + .map(value => `'${normalizeString(value.toString())}'`) + .join(','); + } + + if (_.isObject(filterValue) || _.isNumber(filterValue)) { + return filterValue; + } + + return `'${normalizeString(filterValue.toString())}'`; + } + + _interpolateFilter (filterType, filterValue) { + const sqlString = _.template(this.SQL_TEMPLATES[filterType]); + return sqlString({ column: this._column, value: this._convertValueToSQLString(filterValue) }); + } + + _includeNullInQuery (sql) { + const filters = Object.keys(this._filters); + + if (filters.length > 1) { + sql = `(${sql})`; + } + + return `(${sql} OR ${this._column} IS NULL)`; + } +} + +const parameterIsOfType = function (parameterType, parameterValue) { + return _[`is${parameterType}`](parameterValue); +}; + +const normalizeString = function (value) { + return value.replace(/\n/g, '\\n').replace(/\"/g, '\\"').replace(/'/g, "''"); +}; + +module.exports = SQLBase; diff --git a/src/api/v4/filter/base.js b/src/api/v4/filter/base.js index d807d8679a..3b3ab8d5ed 100644 --- a/src/api/v4/filter/base.js +++ b/src/api/v4/filter/base.js @@ -1,5 +1,6 @@ var _ = require('underscore'); var Backbone = require('backbone'); +var CartoValidationError = require('../error-handling/carto-validation-error'); /** * Base filter object @@ -13,6 +14,10 @@ function Base () {} _.extend(Base.prototype, Backbone.Events); +Base.prototype._getValidationError = function (code) { + return new CartoValidationError('filter', code); +}; + module.exports = Base; /** diff --git a/src/api/v4/filter/category.js b/src/api/v4/filter/category.js new file mode 100644 index 0000000000..84fa3a9581 --- /dev/null +++ b/src/api/v4/filter/category.js @@ -0,0 +1,84 @@ +const SQLBase = require('./base-sql'); + +const CATEGORY_COMPARISON_OPERATORS = { + in: { parameters: [{ name: 'in', allowedTypes: ['Array', 'String'] }] }, + notIn: { parameters: [{ name: 'notIn', allowedTypes: ['Array', 'String'] }] }, + eq: { parameters: [{ name: 'eq', allowedTypes: ['String', 'Number', 'Date'] }] }, + notEq: { parameters: [{ name: 'notEq', allowedTypes: ['String', 'Number', 'Date'] }] }, + like: { parameters: [{ name: 'like', allowedTypes: ['String'] }] }, + similarTo: { parameters: [{ name: 'similarTo', allowedTypes: ['String'] }] } +}; + +const ALLOWED_FILTERS = Object.freeze(Object.keys(CATEGORY_COMPARISON_OPERATORS)); + +/** + * When including this filter into a {@link carto.source.SQL} or a {@link carto.source.Dataset}, the rows will be filtered by the conditions included within the filter. + * + * You can filter columns with `in`, `notIn`, `eq`, `notEq`, `like`, `similarTo` filters, and update the conditions with `.set()` or `.setFilters()` method. It will refresh the visualization automatically when any filter is added or modified. + * + * This filter won't include null values within returned rows by default but you can include them by setting `includeNull` option. + * + * @param {string} column - The column which the filter will be performed against + * @param {object} filters - The filters you want to apply to the table rows + * @param {string[]} filters.in - Return rows whose column value is included within the provided values + * @param {string[]} filters.notIn - Return rows whose column value is included within the provided values + * @param {(string|number|Date)} filters.eq - Return rows whose column value is equal to the provided value + * @param {(string|number|Date)} filters.notEq - Return rows whose column value is not equal to the provided value + * @param {string} filters.like - Return rows whose column value is like the provided value + * @param {string} filters.similarTo - Return rows whose column value is similar to the provided values + * @param {object} [options] + * @param {boolean} [options.includeNull] - Include null rows when returning data + * + * @example + * // Create a filter by room type, showing only private rooms + * const roomTypeFilter = new carto.filter.Category('room_type', { eq: 'Entire home/apt' }); + * airbnbDataset.addFilter(roomTypeFilter); + * + * @class Category + * @memberof carto.filter + * @api + */ +class Category extends SQLBase { + constructor (column, filters = {}, options) { + super(column, options); + + this.SQL_TEMPLATES = this._getSQLTemplates(); + this.ALLOWED_FILTERS = ALLOWED_FILTERS; + this.PARAMETER_SPECIFICATION = CATEGORY_COMPARISON_OPERATORS; + + this._checkFilters(filters); + this._filters = filters; + } + + _getSQLTemplates () { + return { + in: '<% if (value) { %><%= column %> IN (<%= value %>)<% } else { %>true = false<% } %>', + notIn: '<% if (value) { %><%= column %> NOT IN (<%= value %>)<% } %>', + eq: '<%= column %> = <%= value %>', + notEq: '<%= column %> != <%= value %>', + like: '<%= column %> LIKE <%= value %>', + similarTo: '<%= column %> SIMILAR TO <%= value %>' + }; + } + + /** + * Set any of the filter conditions, overwriting the previous one. + * @param {string} filterType - The filter type that you want to set. `in`, `notIn`, `eq`, `notEq`, `like`, `similarTo`. + * @param {string} filterValue - The value of the filter. Check types in {@link carto.filter.Category} + * + * @memberof Category + * @method set + * @api + */ + + /** + * Set filter conditions, overriding all the previous ones. + * @param {object} filters - Object containing all the new filters to apply. Check filter options in {@link carto.filter.Category}. + * + * @memberof Category + * @method setFilters + * @api + */ +} + +module.exports = Category; diff --git a/src/api/v4/filter/filters-collection.js b/src/api/v4/filter/filters-collection.js new file mode 100644 index 0000000000..ff82484f21 --- /dev/null +++ b/src/api/v4/filter/filters-collection.js @@ -0,0 +1,108 @@ +const _ = require('underscore'); +const Base = require('./base'); +const SQLBase = require('./base-sql'); + +const DEFAULT_JOIN_OPERATOR = 'AND'; + +/** + * Base class for AND and OR filters. + * + * Filters Collection is a way to group a set of filters in order to create composed filters, allowing the user to change the operator that joins the filters. + * + * **This object should not be used directly.** + * + * @class FiltersCollection + * @memberof carto.filter + * @api + */ +class FiltersCollection extends Base { + constructor (filters) { + super(); + this._initialize(filters); + } + + _initialize (filters) { + this._filters = []; + + if (filters && filters.length) { + filters.map(filter => this.addFilter(filter)); + } + } + + /** + * Add a new filter to collection + * + * @param {(carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR)} filter + * @memberof FiltersCollection + * @api + */ + addFilter (filter) { + if (!(filter instanceof SQLBase) && !(filter instanceof FiltersCollection)) { + throw this._getValidationError('wrongFilterType'); + } + + if (_.contains(this._filters, filter)) return; + + this.listenTo(filter, 'change:filters', filters => this._triggerFilterChange(filters)); + this._filters.push(filter); + this._triggerFilterChange(); + } + + /** + * Remove an existing filter from collection + * + * @param {(carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR)} filter + * @returns {(carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR)} The removed element + * @memberof FiltersCollection + * @api + */ + removeFilter (filter) { + const filterIndex = _.indexOf(this._filters, filter); + if (filterIndex === -1) return; + + const removedElement = this._filters.splice(filterIndex, 1)[0]; + removedElement.off('change:filters', null, this); + + this._triggerFilterChange(); + return removedElement; + } + + /** + * Get the number of added filters + * + * @returns {number} Number of contained filters + * @memberof FiltersCollection + * @api + */ + count () { + return this._filters.length; + } + + /** + * Get added filters + * + * @returns {Array} Added filters + * @memberof FiltersCollection + * @api + */ + getFilters () { + return this._filters; + } + + $getSQL () { + const sql = this._filters.map(filter => filter.$getSQL()) + .join(` ${this.JOIN_OPERATOR || DEFAULT_JOIN_OPERATOR} `); + + if (this.count() > 1) { + return `(${sql})`; + } + + return sql; + } + + _triggerFilterChange (filters) { + this.trigger('change:filters', filters); + } +} + +module.exports = FiltersCollection; diff --git a/src/api/v4/filter/index.js b/src/api/v4/filter/index.js index f80f99fb22..5715150588 100644 --- a/src/api/v4/filter/index.js +++ b/src/api/v4/filter/index.js @@ -1,13 +1,21 @@ -var BoundingBox = require('./bounding-box'); -var BoundingBoxLeaflet = require('./bounding-box-leaflet'); -var BoundingBoxGoogleMaps = require('./bounding-box-gmaps'); +const BoundingBox = require('./bounding-box'); +const BoundingBoxLeaflet = require('./bounding-box-leaflet'); +const BoundingBoxGoogleMaps = require('./bounding-box-gmaps'); +const Category = require('./category'); +const Range = require('./range'); +const AND = require('./and'); +const OR = require('./or'); /** * @namespace carto.filter * @api */ module.exports = { - BoundingBox: BoundingBox, - BoundingBoxLeaflet: BoundingBoxLeaflet, - BoundingBoxGoogleMaps: BoundingBoxGoogleMaps + BoundingBox, + BoundingBoxLeaflet, + BoundingBoxGoogleMaps, + Category, + Range, + AND, + OR }; diff --git a/src/api/v4/filter/or.js b/src/api/v4/filter/or.js new file mode 100644 index 0000000000..b7119890fc --- /dev/null +++ b/src/api/v4/filter/or.js @@ -0,0 +1,38 @@ +const FiltersCollection = require('./filters-collection'); + +/** + * When including this filter into a {@link carto.source.SQL} or a {@link carto.source.Dataset}, the rows will be filtered by the conditions included within filters. + * + * This filter will group as many filters as you want and it will add them to the query returning the rows that match ANY of the filters to render the visualization. + * + * You can add or remove filters by invoking `.addFilter()` and `.removeFilter()`. + * + * @example + * // Create a filter by room type, showing only private rooms + * const roomTypeFilter = new carto.filter.Category('room_type', { eq: 'Private room' }); + * // Create a filter by price, showing only listings lower than or equal to 50€ + * const priceFilter = new carto.filter.Range('price', { lte: 50 }); + * + * // Combine the filters with an OR operator, returning rows that match one or the other filter + * const filterByRoomTypeOrPrice = new carto.filter.OR([ roomTypeFilter, priceFilter ]); + * + * // Add filters to the existing source + * source.addFilter(filterByRoomTypeOrPrice); + * + * @class OR + * @extends carto.filter.FiltersCollection + * @memberof carto.filter + * @api + */ +class OR extends FiltersCollection { + /** + * Create a OR group filter + * @param {Array} filters - The filters to apply in the query + */ + constructor (filters) { + super(filters); + this.JOIN_OPERATOR = 'OR'; + } +} + +module.exports = OR; diff --git a/src/api/v4/filter/range.js b/src/api/v4/filter/range.js new file mode 100644 index 0000000000..386aa0096f --- /dev/null +++ b/src/api/v4/filter/range.js @@ -0,0 +1,120 @@ +const SQLBase = require('./base-sql'); + +const RANGE_COMPARISON_OPERATORS = { + lt: { parameters: [{ name: 'lt', allowedTypes: ['Number', 'Date'] }] }, + lte: { parameters: [{ name: 'lte', allowedTypes: ['Number', 'Date'] }] }, + gt: { parameters: [{ name: 'gt', allowedTypes: ['Number', 'Date'] }] }, + gte: { parameters: [{ name: 'gte', allowedTypes: ['Number', 'Date'] }] }, + between: { + parameters: [ + { name: 'between.min', allowedTypes: ['Number', 'Date'] }, + { name: 'between.max', allowedTypes: ['Number', 'Date'] } + ] + }, + notBetween: { + parameters: [ + { name: 'notBetween.min', allowedTypes: ['Number', 'Date'] }, + { name: 'notBetween.max', allowedTypes: ['Number', 'Date'] } + ] + }, + betweenSymmetric: { + parameters: [ + { name: 'betweenSymmetric.min', allowedTypes: ['Number', 'Date'] }, + { name: 'betweenSymmetric.max', allowedTypes: ['Number', 'Date'] } + ] + }, + notBetweenSymmetric: { + parameters: [ + { name: 'notBetweenSymmetric.min', allowedTypes: ['Number', 'Date'] }, + { name: 'notBetweenSymmetric.max', allowedTypes: ['Number', 'Date'] } + ] + } +}; + +const ALLOWED_FILTERS = Object.freeze(Object.keys(RANGE_COMPARISON_OPERATORS)); + +/** + * When including this filter into a {@link carto.source.SQL} or a {@link carto.source.Dataset}, the rows will be filtered by the conditions included within the filter. + * + * You can filter columns with `in`, `notIn`, `eq`, `notEq`, `like`, `similarTo` filters, and update the conditions with `.set()` or `.setFilters()` method. It will refresh the visualization automatically when any filter is added or modified. + * + * This filter won't include null values within returned rows by default but you can include them by setting `includeNull` option. + * + * @param {string} column - The column to filter rows + * @param {object} filters - The filters you want to apply to the column + * @param {(number|Date)} filters.lt - Return rows whose column value is less than the provided value + * @param {(number|Date)} filters.lte - Return rows whose column value is less than or equal to the provided value + * @param {(number|Date)} filters.gt - Return rows whose column value is greater than to the provided value + * @param {(number|Date)} filters.gte - Return rows whose column value is greater than or equal to the provided value + * @param {(number|Date)} filters.between - Return rows whose column value is between the provided values + * @param {(number|Date)} filters.between.min - Lower value of the comparison range + * @param {(number|Date)} filters.between.max - Upper value of the comparison range + * @param {(number|Date)} filters.notBetween - Return rows whose column value is not between the provided values + * @param {(number|Date)} filters.notBetween.min - Lower value of the comparison range + * @param {(number|Date)} filters.notBetween.max - Upper value of the comparison range + * @param {(number|Date)} filters.betweenSymmetric - Return rows whose column value is between the provided values after sorting them + * @param {(number|Date)} filters.betweenSymmetric.min - Lower value of the comparison range + * @param {(number|Date)} filters.betweenSymmetric.max - Upper value of the comparison range + * @param {(number|Date)} filters.notBetweenSymmetric - Return rows whose column value is not between the provided values after sorting them + * @param {(number|Date)} filters.notBetweenSymmetric.min - Lower value of the comparison range + * @param {(number|Date)} filters.notBetweenSymmetric.max - Upper value of the comparison range + * @param {object} [options] + * @param {boolean} [options.includeNull] - Include null rows when returning data + * + * @example + * // Create a filter by price, showing only listings lower than or equal to 50€, and higher than 100€ + * const priceFilter = new carto.filter.Range('price', { lte: 50, gt: 100 }); + * + * // Add filter to the existing source + * airbnbDataset.addFilter(priceFilter); + * + * @class Range + * @memberof carto.filter + * @api + */ +class Range extends SQLBase { + constructor (column, filters = {}, options) { + super(column, options); + + this.SQL_TEMPLATES = this._getSQLTemplates(); + this.ALLOWED_FILTERS = ALLOWED_FILTERS; + this.PARAMETER_SPECIFICATION = RANGE_COMPARISON_OPERATORS; + + this._checkFilters(filters); + this._filters = filters; + } + + _getSQLTemplates () { + return { + lt: '<%= column %> < <%= value %>', + lte: '<%= column %> <= <%= value %>', + gt: '<%= column %> > <%= value %>', + gte: '<%= column %> >= <%= value %>', + between: '<%= column %> BETWEEN <%= value.min %> AND <%= value.max %>', + notBetween: '<%= column %> NOT BETWEEN <%= value.min %> AND <%= value.max %>', + betweenSymmetric: '<%= column %> BETWEEN SYMMETRIC <%= value.min %> AND <%= value.max %>', + notBetweenSymmetric: '<%= column %> NOT BETWEEN SYMMETRIC <%= value.min %> AND <%= value.max %>' + }; + } + + /** + * Set any of the filter conditions, overwriting the previous one. + * @param {string} filterType - The filter type that you want to set. `lt`, `lte`, `gt`, `gte`, `between`, `notBetween`, `betweenSymmetric`, `notBetweenSymmetric`. + * @param {string} filterValue - The value of the filter. Check types in {@link carto.filter.Range} + * + * @memberof Range + * @method set + * @api + */ + + /** + * Set filter conditions, overriding all the previous ones. + * @param {object} filters - Object containing all the new filters to apply. Check filter options in {@link carto.filter.Range}. + * + * @memberof Range + * @method setFilters + * @api + */ +} + +module.exports = Range; diff --git a/src/api/v4/source/base.js b/src/api/v4/source/base.js index ca61c51337..ab58e04d3b 100644 --- a/src/api/v4/source/base.js +++ b/src/api/v4/source/base.js @@ -1,15 +1,16 @@ -var _ = require('underscore'); -var Backbone = require('backbone'); -var CartoError = require('../error-handling/carto-error'); -var EVENTS = require('../events'); +const _ = require('underscore'); +const Backbone = require('backbone'); +const CartoError = require('../error-handling/carto-error'); +const FiltersCollection = require('../filter/filters-collection'); +const EVENTS = require('../events'); /** * Base data source object. - * + * * The methods listed in the {@link carto.source.Base|source.Base} object are available in all source objects. - * + * * Use a source to reference the data used in a {@link carto.dataview.Base|dataview} or a {@link carto.layer.Base|layer}. - * + * * {@link carto.source.Base} should not be used directly use {@link carto.source.Dataset} or {@link carto.source.SQL} instead. * * @constructor @@ -20,6 +21,8 @@ var EVENTS = require('../events'); */ function Base () { this._id = Base.$generateId(); + this._hasFiltersApplied = false; + this._appliedFilters = new FiltersCollection(); } _.extend(Base.prototype, Backbone.Events); @@ -38,7 +41,7 @@ Base.$generateId = function () { /** * Return a unique autogenerated id. - * + * * @return {string} Unique autogenerated id */ Base.prototype.getId = function () { @@ -79,4 +82,56 @@ Base.prototype.$getInternalModel = function () { return this._internalModel; }; +/** + * Get added filters + * + * @returns {Array} Added filters + * @api + */ +Base.prototype.getFilters = function () { + return this._appliedFilters.getFilters(); +}; + +/** + * Add new filter to the source + * + * @param {(carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR)} filter + * @api + */ +Base.prototype.addFilter = function (filter) { + this._hasFiltersApplied = true; + this._appliedFilters.addFilter(filter); +}; + +/** + * Add new filters to the source + * + * @param {Array} filters + * @api + */ +Base.prototype.addFilters = function (filters) { + filters.forEach(filter => this.addFilter(filter)); +}; + +/** + * Remove an existing filter from source + * + * @param {(carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR)} filter + * @api + */ +Base.prototype.removeFilter = function (filter) { + this._appliedFilters.removeFilter(filter); + this._hasFiltersApplied = Boolean(this._appliedFilters.count()); +}; + +/** + * Remove existing filters from source + * + * @param {Array} filters + * @api + */ +Base.prototype.removeFilters = function (filters) { + filters.forEach(filter => this.removeFilter(filter)); +}; + module.exports = Base; diff --git a/src/api/v4/source/dataset.js b/src/api/v4/source/dataset.js index ff91367f79..934a286c87 100644 --- a/src/api/v4/source/dataset.js +++ b/src/api/v4/source/dataset.js @@ -3,6 +3,7 @@ var Base = require('./base'); var AnalysisModel = require('../../../analysis/analysis-model'); var CamshaftReference = require('../../../analysis/camshaft-reference'); var CartoValidationError = require('../error-handling/carto-validation-error'); +var CartoError = require('../error-handling/carto-error'); /** * A Dataset that can be used as the data source for layers and dataviews. @@ -19,7 +20,10 @@ var CartoValidationError = require('../error-handling/carto-validation-error'); function Dataset (tableName) { _checkTableName(tableName); this._tableName = tableName; + Base.apply(this, arguments); + + this._appliedFilters.on('change:filters', () => this._updateInternalModelQuery(this._getQueryToApply())); } Dataset.prototype = Object.create(Base.prototype); @@ -43,7 +47,7 @@ Dataset.prototype._createInternalModel = function (engine) { var internalModel = new AnalysisModel({ id: this.getId(), type: 'source', - query: 'SELECT * from ' + this._tableName + query: this._getQueryToApply() }, { camshaftReference: CamshaftReference, engine: engine @@ -52,6 +56,36 @@ Dataset.prototype._createInternalModel = function (engine) { return internalModel; }; +Dataset.prototype._updateInternalModelQuery = function (query) { + if (!this._internalModel) return; + + this._internalModel.set('query', query, { silent: true }); + + return this._internalModel._engine.reload() + .catch(windshaftError => Promise.reject(new CartoError(windshaftError))); +}; + +Dataset.prototype._getQueryToApply = function () { + const whereClause = this._appliedFilters.$getSQL(); + const datasetQuery = `SELECT * from ${this._tableName}`; + + if (_.isEmpty(whereClause)) { + return datasetQuery; + } + + return `SELECT * FROM (${datasetQuery}) as datasetQuery WHERE ${whereClause}`; +}; + +Dataset.prototype.addFilter = function (filter) { + Base.prototype.addFilter.apply(this, arguments); + this._updateInternalModelQuery(this._getQueryToApply()); +}; + +Dataset.prototype.removeFilter = function (filters) { + Base.prototype.removeFilter.apply(this, arguments); + this._updateInternalModelQuery(this._getQueryToApply()); +}; + function _checkTableName (tableName) { if (_.isUndefined(tableName)) { throw new CartoValidationError('source', 'noDatasetName'); diff --git a/src/api/v4/source/sql.js b/src/api/v4/source/sql.js index 1a8b4c018d..4a76941771 100644 --- a/src/api/v4/source/sql.js +++ b/src/api/v4/source/sql.js @@ -21,7 +21,10 @@ var CartoError = require('../error-handling/carto-error'); function SQL (query) { _checkQuery(query); this._query = query; + Base.apply(this, arguments); + + this._appliedFilters.on('change:filters', () => this._updateInternalModelQuery(this._getQueryToApply())); } SQL.prototype = Object.create(Base.prototype); @@ -29,7 +32,7 @@ SQL.prototype = Object.create(Base.prototype); /** * Update the query. This method is asyncronous and returns a promise which is resolved when the style * is changed succesfully. It also fires a 'queryChanged' event. - * + * * @param {string} query - The sql query that will be the source of the data * @fires queryChanged * @returns {Promise} - A promise that will be fulfilled when the reload cycle is completed @@ -38,19 +41,15 @@ SQL.prototype = Object.create(Base.prototype); SQL.prototype.setQuery = function (query) { _checkQuery(query); this._query = query; + + const sqlString = this._getQueryToApply(); + if (!this._internalModel) { - this._triggerQueryChanged(this, query); + this._triggerQueryChanged(this, sqlString); return Promise.resolve(); } - this._internalModel.set('query', query, { silent: true }); - return this._internalModel._engine.reload() - .then(function () { - this._triggerQueryChanged(this, query); - }.bind(this)) - .catch(function (windshaftError) { - return Promise.reject(new CartoError(windshaftError)); - }); + return this._updateInternalModelQuery(sqlString); }; /** @@ -72,7 +71,7 @@ SQL.prototype._createInternalModel = function (engine) { var internalModel = new AnalysisModel({ id: this.getId(), type: 'source', - query: this._query + query: this._getQueryToApply() }, { camshaftReference: CamshaftReference, engine: engine @@ -83,6 +82,36 @@ SQL.prototype._createInternalModel = function (engine) { return internalModel; }; +SQL.prototype._updateInternalModelQuery = function (query) { + if (!this._internalModel) return; + + this._internalModel.set('query', query, { silent: true }); + + return this._internalModel._engine.reload() + .then(() => this._triggerQueryChanged(this, query)) + .catch(windshaftError => Promise.reject(new CartoError(windshaftError))); +}; + +SQL.prototype._getQueryToApply = function () { + const whereClause = this._appliedFilters.$getSQL(); + + if (!this._hasFiltersApplied || _.isEmpty(whereClause)) { + return this._query; + } + + return `SELECT * FROM (${this._query}) as originalQuery WHERE ${whereClause}`; +}; + +SQL.prototype.addFilter = function (filter) { + Base.prototype.addFilter.apply(this, arguments); + this._updateInternalModelQuery(this._getQueryToApply()); +}; + +SQL.prototype.removeFilter = function (filters) { + Base.prototype.removeFilter.apply(this, arguments); + this._updateInternalModelQuery(this._getQueryToApply()); +}; + SQL.prototype._triggerQueryChanged = function (model, value) { this.trigger('queryChanged', value); }; diff --git a/test/spec/api/v4/dataview/base.spec.js b/test/spec/api/v4/dataview/base.spec.js index 17429099cf..1531244af3 100644 --- a/test/spec/api/v4/dataview/base.spec.js +++ b/test/spec/api/v4/dataview/base.spec.js @@ -230,5 +230,14 @@ describe('api/v4/dataview/base', function () { expect(test).toThrowError('Filter property is required.'); }); + + it('should throw an error if an SQL filter is passed', function () { + function test () { + var categoryFilter = new carto.filter.Category('fake_column', { in: ['category_value'] }); + base.addFilter(categoryFilter); + } + + expect(test).toThrowError('Filter property is required.'); + }); }); }); diff --git a/test/spec/api/v4/filter/base-sql.spec.js b/test/spec/api/v4/filter/base-sql.spec.js new file mode 100644 index 0000000000..1c7f6baf6f --- /dev/null +++ b/test/spec/api/v4/filter/base-sql.spec.js @@ -0,0 +1,229 @@ +const SQLBase = require('../../../../../src/api/v4/filter/base-sql'); + +const PARAMETER_SPECIFICATION = { + in: { parameters: [{ name: 'in', allowedTypes: ['Array', 'String'] }] }, + notIn: { parameters: [{ name: 'notIn', allowedTypes: ['Array', 'String'] }] }, + like: { parameters: [{ name: 'like', allowedTypes: ['Array', 'String'] }] } +}; + +const SQL_TEMPLATES = { + 'in': '<%= column %> IN (<%= value %>)', + 'like': '<%= column %> LIKE <%= value %>' +}; + +const column = 'fake_column'; + +describe('api/v4/filter/base-sql', function () { + describe('constructor', function () { + it('should throw a descriptive error when column is undefined, not a string, or empty', function () { + expect(function () { + new SQLBase(undefined); // eslint-disable-line + }).toThrowError('Column property is required.'); + + expect(function () { + new SQLBase(1); // eslint-disable-line + }).toThrowError('Column property must be a string.'); + + expect(function () { + new SQLBase(''); // eslint-disable-line + }).toThrowError('Column property must be not empty.'); + }); + + it('should throw a descriptive error when there is an invalid option', function () { + expect(function () { + new SQLBase('fake_column', { unknown_option: false }); // eslint-disable-line + }).toThrowError("'unknown_option' is not a valid option for this filter."); + }); + + it('should set column and options as class properties', function () { + const options = { includeNull: true }; + + const sqlFilter = new SQLBase(column, options); + + expect(sqlFilter._column).toBe(column); + expect(sqlFilter._options).toBe(options); + }); + }); + + describe('.set', function () { + it('should throw a descriptive error when an unknown filter has been passed', function () { + const sqlFilter = new SQLBase('fake_column'); + + expect(function () { + sqlFilter.set('unknown_filter', 'test_filter'); + }).toThrowError("'unknown_filter' is not a valid filter. Please check documentation."); + }); + + it('should set the new filter to the filters object', function () { + const sqlFilter = new SQLBase(column); + sqlFilter.ALLOWED_FILTERS = ['in']; + sqlFilter.PARAMETER_SPECIFICATION = { in: PARAMETER_SPECIFICATION.in }; + + sqlFilter.set('in', ['test_filter']); + + expect(sqlFilter._filters).toEqual({ in: ['test_filter'] }); + }); + + it("should trigger a 'change:filters' event", function () { + const spy = jasmine.createSpy(); + + const sqlFilter = new SQLBase('fake_column'); + sqlFilter.ALLOWED_FILTERS = ['in']; + sqlFilter.PARAMETER_SPECIFICATION = { in: PARAMETER_SPECIFICATION.in }; + sqlFilter.on('change:filters', spy); + + sqlFilter.set('in', ['test_filter']); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('.setFilters', function () { + it('should throw a descriptive error when an unknown filter has been passed', function () { + const sqlFilter = new SQLBase('fake_column'); + + expect(function () { + sqlFilter.setFilters({ unknown_filter: 'test_filter' }); + }).toThrowError("'unknown_filter' is not a valid filter. Please check documentation."); + }); + + it('should set the new filters and override previous ones', function () { + const newFilters = { notIn: 'test_filter2' }; + + const sqlFilter = new SQLBase(column); + sqlFilter.ALLOWED_FILTERS = ['in', 'notIn']; + sqlFilter.PARAMETER_SPECIFICATION = { + in: PARAMETER_SPECIFICATION.in, + notIn: PARAMETER_SPECIFICATION.notIn + }; + sqlFilter.set('in', ['test_filter']); + + sqlFilter.setFilters(newFilters); + + expect(sqlFilter._filters).toEqual(newFilters); + }); + + it("should trigger a 'change:filters' event", function () { + const newFilters = { notIn: 'test_filter2' }; + const spy = jasmine.createSpy(); + + const sqlFilter = new SQLBase(column); + sqlFilter.ALLOWED_FILTERS = ['in', 'notIn']; + sqlFilter.PARAMETER_SPECIFICATION = { + in: PARAMETER_SPECIFICATION.in, + notIn: PARAMETER_SPECIFICATION.notIn + }; + sqlFilter.set('in', ['test_filter']); + sqlFilter.on('change:filters', spy); + + sqlFilter.setFilters(newFilters); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('.$getSQL', function () { + it('should return SQL string containing all the filters joined by AND clause', function () { + const sqlFilter = new SQLBase(column); + sqlFilter.ALLOWED_FILTERS = ['in', 'like']; + sqlFilter.PARAMETER_SPECIFICATION = { + in: PARAMETER_SPECIFICATION.in, + like: PARAMETER_SPECIFICATION.like + }; + sqlFilter.SQL_TEMPLATES = { + in: SQL_TEMPLATES.in, + like: SQL_TEMPLATES.like + }; + sqlFilter.setFilters({ in: ['category 1', 'category 2'], like: '%category%' }); + + expect(sqlFilter.$getSQL()).toBe("(fake_column IN ('category 1','category 2') AND fake_column LIKE '%category%')"); + }); + + it('should call _includeNullInQuery if includeNull option is set', function () { + const sqlFilter = new SQLBase(column, { includeNull: true }); + sqlFilter.ALLOWED_FILTERS = ['in']; + sqlFilter.PARAMETER_SPECIFICATION = { in: PARAMETER_SPECIFICATION.in }; + sqlFilter.SQL_TEMPLATES = { in: SQL_TEMPLATES.in }; + sqlFilter.setFilters({ in: ['category 1', 'category 2'] }); + + spyOn(sqlFilter, '_includeNullInQuery'); + + sqlFilter.$getSQL(); + + expect(sqlFilter._includeNullInQuery).toHaveBeenCalled(); + }); + }); + + describe('.checkFilters', function () { + let sqlFilter; + + beforeEach(function () { + sqlFilter = new SQLBase(column); + }); + + it('should throw an error when an invalid filter is passed', function () { + expect(function () { + sqlFilter._checkFilters({ unknown_filter: 'filter' }); + }).toThrowError("'unknown_filter' is not a valid filter. Please check documentation."); + }); + + it("should throw an error when there's a type mismatching in filter parameters", function () { + expect(function () { + sqlFilter.ALLOWED_FILTERS = ['in']; + sqlFilter.PARAMETER_SPECIFICATION = { in: PARAMETER_SPECIFICATION.in }; + + sqlFilter._checkFilters({ in: 1 }); + }).toThrowError("Invalid parameter type for 'in'. Please check filters documentation."); + }); + }); + + describe('._convertValueToSQLString', function () { + it('should format date to ISO8601 string', function () { + const sqlFilter = new SQLBase(column); + + const fakeDate = new Date('Thu Jun 28 2018 15:04:31 GMT+0200 (Central European Summer Time)'); + expect(sqlFilter._convertValueToSQLString(fakeDate)).toBe("'2018-06-28T13:04:31.000Z'"); + }); + + it('should convert array to a comma-separated string wrapped by single comma', function () { + const sqlFilter = new SQLBase(column); + + const fakeArray = ['Element 1', 'Element 2']; + expect(sqlFilter._convertValueToSQLString(fakeArray)).toBe("'Element 1','Element 2'"); + }); + + it('should return number without modifying', function () { + const sqlFilter = new SQLBase(column); + + expect(sqlFilter._convertValueToSQLString(1)).toBe(1); + }); + + it('should return object without modifying', function () { + const sqlFilter = new SQLBase(column); + + const fakeObject = { fakeProperty: 'fakeValue' }; + + expect(sqlFilter._convertValueToSQLString(fakeObject)).toBe(fakeObject); + }); + + it('should wrap strings in single-quotes', function () { + const sqlFilter = new SQLBase(column); + + const fakeString = 'fake_string'; + + expect(sqlFilter._convertValueToSQLString(fakeString)).toBe(`'${fakeString}'`); + }); + }); + + describe('._interpolateFilterIntoTemplate', function () { + it('should inject filter values into SQL template', function () { + const sqlFilter = new SQLBase(column); + + sqlFilter.SQL_TEMPLATES = { + gte: '<%= column %> > <%= value %>' + }; + + expect(sqlFilter._interpolateFilter('gte', 10)).toBe('fake_column > 10'); + }); + }); +}); diff --git a/test/spec/api/v4/filter/category.spec.js b/test/spec/api/v4/filter/category.spec.js new file mode 100644 index 0000000000..d789acfeca --- /dev/null +++ b/test/spec/api/v4/filter/category.spec.js @@ -0,0 +1,43 @@ +const carto = require('../../../../../src/api/v4/index'); + +describe('api/v4/filter/category', function () { + describe('constructor', function () { + it('should throw a descriptive error when an unknown filter has been passed', function () { + expect(function () { + new carto.filter.Category('fake_column', { unknown_filter: '' }); // eslint-disable-line + }).toThrowError("'unknown_filter' is not a valid filter. Please check documentation."); + }); + }); + + describe('SQL Templates', function () { + it('IN', function () { + const categoryFilter = new carto.filter.Category('fake_column', { in: ['Category 1'] }); + expect(categoryFilter.$getSQL()).toBe("fake_column IN ('Category 1')"); + }); + + it('NOT IN', function () { + const categoryFilter = new carto.filter.Category('fake_column', { notIn: ['Category 1'] }); + expect(categoryFilter.$getSQL()).toBe("fake_column NOT IN ('Category 1')"); + }); + + it('EQ', function () { + const categoryFilter = new carto.filter.Category('fake_column', { eq: 'Category 1' }); + expect(categoryFilter.$getSQL()).toBe("fake_column = 'Category 1'"); + }); + + it('NOT EQ', function () { + const categoryFilter = new carto.filter.Category('fake_column', { notEq: 'Category 1' }); + expect(categoryFilter.$getSQL()).toBe("fake_column != 'Category 1'"); + }); + + it('LIKE', function () { + const categoryFilter = new carto.filter.Category('fake_column', { like: '%Category%' }); + expect(categoryFilter.$getSQL()).toBe("fake_column LIKE '%Category%'"); + }); + + it('SIMILAR TO', function () { + const categoryFilter = new carto.filter.Category('fake_column', { similarTo: '%Category%' }); + expect(categoryFilter.$getSQL()).toBe("fake_column SIMILAR TO '%Category%'"); + }); + }); +}); diff --git a/test/spec/api/v4/filter/filters-collection.spec.js b/test/spec/api/v4/filter/filters-collection.spec.js new file mode 100644 index 0000000000..3a580b5cfd --- /dev/null +++ b/test/spec/api/v4/filter/filters-collection.spec.js @@ -0,0 +1,163 @@ +const FiltersCollection = require('../../../../../src/api/v4/filter/filters-collection'); +const carto = require('../../../../../src/api/v4/index'); + +const column = 'fake_column'; + +describe('api/v4/filter/filters-collection', function () { + describe('constructor', function () { + it('should call _initialize', function () { + spyOn(FiltersCollection.prototype, '_initialize'); + new FiltersCollection(); // eslint-disable-line + + expect(FiltersCollection.prototype._initialize).toHaveBeenCalled(); + }); + }); + + describe('._initialize', function () { + it('should set an empty array to filters', function () { + const filtersCollection = new FiltersCollection(); + expect(filtersCollection._filters).toEqual([]); + }); + + it('should set provided filters and call add()', function () { + spyOn(FiltersCollection.prototype, 'addFilter').and.callThrough(); + + const filters = [ + new carto.filter.Range(column, { lt: 1 }), + new carto.filter.Category(column, { in: ['category'] }) + ]; + const filtersCollection = new FiltersCollection(filters); + + expect(filtersCollection._filters).toEqual(filters); + expect(FiltersCollection.prototype.addFilter).toHaveBeenCalledTimes(2); + }); + }); + + describe('.addFilter', function () { + let filtersCollection, rangeFilter, triggerFilterChangeSpy, listenToChangeSpy; + + beforeEach(function () { + triggerFilterChangeSpy = spyOn(FiltersCollection.prototype, '_triggerFilterChange'); + listenToChangeSpy = spyOn(FiltersCollection.prototype, 'listenTo'); + filtersCollection = new FiltersCollection(); + rangeFilter = new carto.filter.Range(column, { lt: 1 }); + }); + + it('should throw an error if filter is not an instance of SQLBase or FiltersCollection', function () { + expect(function () { + filtersCollection.addFilter({}); + }).toThrowError('Filters need to extend from carto.filter.SQLBase. Please use carto.filter.Category or carto.filter.Range.'); + }); + + it('should not readd a filter if it is already added', function () { + filtersCollection.addFilter(rangeFilter); + filtersCollection.addFilter(rangeFilter); + + expect(filtersCollection._filters.length).toBe(1); + expect(triggerFilterChangeSpy).toHaveBeenCalledTimes(1); + }); + + it('should add new filter and trigger change:filters event', function () { + filtersCollection.addFilter(rangeFilter); + + expect(filtersCollection._filters.length).toBe(1); + expect(triggerFilterChangeSpy).toHaveBeenCalledTimes(1); + }); + + it('should register listener to change:filters event in the added filter', function () { + filtersCollection.addFilter(rangeFilter); + + expect(listenToChangeSpy).toHaveBeenCalled(); + expect(listenToChangeSpy.calls.mostRecent().args[0]).toBe(rangeFilter); + expect(listenToChangeSpy.calls.mostRecent().args[1]).toEqual('change:filters'); + }); + }); + + describe('.removeFilter', function () { + let filtersCollection, rangeFilter, triggerFilterChangeSpy; + + beforeEach(function () { + triggerFilterChangeSpy = spyOn(FiltersCollection.prototype, '_triggerFilterChange'); + filtersCollection = new FiltersCollection(); + rangeFilter = new carto.filter.Range(column, { lt: 1 }); + }); + + it('should not remove the filter if it was not already added', function () { + const removedElement = filtersCollection.removeFilter(rangeFilter); + + expect(removedElement).toBeUndefined(); + expect(triggerFilterChangeSpy).not.toHaveBeenCalled(); + }); + + it('should remove the filter if it was already added', function () { + filtersCollection.addFilter(rangeFilter); + + const removedElement = filtersCollection.removeFilter(rangeFilter); + + expect(removedElement).toBe(rangeFilter); + expect(triggerFilterChangeSpy).toHaveBeenCalled(); + }); + }); + + describe('.count', function () { + let filtersCollection, rangeFilter; + + beforeEach(function () { + filtersCollection = new FiltersCollection(); + rangeFilter = new carto.filter.Range(column, { lt: 1 }); + filtersCollection.addFilter(rangeFilter); + }); + + it('should return filters length', function () { + expect(filtersCollection.count()).toBe(1); + }); + }); + + describe('.getFilters', function () { + let filtersCollection, rangeFilter; + + beforeEach(function () { + filtersCollection = new FiltersCollection(); + rangeFilter = new carto.filter.Range(column, { lt: 1 }); + filtersCollection.addFilter(rangeFilter); + }); + + it('should return added filters', function () { + expect(filtersCollection.getFilters()).toEqual([rangeFilter]); + }); + }); + + describe('.$getSQL', function () { + let filtersCollection; + + beforeEach(function () { + let rangeFilter = new carto.filter.Range(column, { lt: 1 }); + let categoryFilter = new carto.filter.Category(column, { in: ['category'] }); + + filtersCollection = new FiltersCollection(); + filtersCollection.addFilter(rangeFilter); + filtersCollection.addFilter(categoryFilter); + }); + + it('should build the SQL string and join filters', function () { + expect(filtersCollection.$getSQL()).toEqual("(fake_column < 1 AND fake_column IN ('category'))"); + }); + }); + + describe('._triggerFilterChange', function () { + let filtersCollection; + + beforeEach(function () { + filtersCollection = new FiltersCollection(); + }); + + it('should trigger change:filters', function () { + spyOn(filtersCollection, 'trigger'); + + const filters = []; + filtersCollection._triggerFilterChange(filters); + + expect(filtersCollection.trigger).toHaveBeenCalledWith('change:filters', filters); + }); + }); +}); diff --git a/test/spec/api/v4/filter/range.spec.js b/test/spec/api/v4/filter/range.spec.js new file mode 100644 index 0000000000..ed365c7479 --- /dev/null +++ b/test/spec/api/v4/filter/range.spec.js @@ -0,0 +1,53 @@ +const carto = require('../../../../../src/api/v4/index'); + +describe('api/v4/filter/range', function () { + describe('constructor', function () { + it('should throw a descriptive error when an unknown filter has been passed', function () { + expect(function () { + new carto.filter.Range('fake_column', { unknown_filter: '' }); // eslint-disable-line + }).toThrowError("'unknown_filter' is not a valid filter. Please check documentation."); + }); + }); + + describe('SQL Templates', function () { + it('LT', function () { + const categoryFilter = new carto.filter.Range('fake_column', { lt: 10 }); + expect(categoryFilter.$getSQL()).toBe('fake_column < 10'); + }); + + it('LTE', function () { + const categoryFilter = new carto.filter.Range('fake_column', { lte: 10 }); + expect(categoryFilter.$getSQL()).toBe('fake_column <= 10'); + }); + + it('GT', function () { + const categoryFilter = new carto.filter.Range('fake_column', { gt: 10 }); + expect(categoryFilter.$getSQL()).toBe('fake_column > 10'); + }); + + it('GTE', function () { + const categoryFilter = new carto.filter.Range('fake_column', { gte: 10 }); + expect(categoryFilter.$getSQL()).toBe('fake_column >= 10'); + }); + + it('BETWEEN', function () { + const categoryFilter = new carto.filter.Range('fake_column', { between: { min: 1, max: 10 } }); + expect(categoryFilter.$getSQL()).toBe('fake_column BETWEEN 1 AND 10'); + }); + + it('NOT BETWEEN', function () { + const categoryFilter = new carto.filter.Range('fake_column', { notBetween: { min: 1, max: 10 } }); + expect(categoryFilter.$getSQL()).toBe('fake_column NOT BETWEEN 1 AND 10'); + }); + + it('BETWEEN SYMMETRIC', function () { + const categoryFilter = new carto.filter.Range('fake_column', { betweenSymmetric: { min: 1, max: 10 } }); + expect(categoryFilter.$getSQL()).toBe('fake_column BETWEEN SYMMETRIC 1 AND 10'); + }); + + it('NOT BETWEEN SYMMETRIC', function () { + const categoryFilter = new carto.filter.Range('fake_column', { notBetweenSymmetric: { min: 1, max: 10 } }); + expect(categoryFilter.$getSQL()).toBe('fake_column NOT BETWEEN SYMMETRIC 1 AND 10'); + }); + }); +}); diff --git a/test/spec/api/v4/source/dataset.spec.js b/test/spec/api/v4/source/dataset.spec.js index 59b429e88a..853d9ac2e9 100644 --- a/test/spec/api/v4/source/dataset.spec.js +++ b/test/spec/api/v4/source/dataset.spec.js @@ -1,4 +1,5 @@ -var carto = require('../../../../../src/api/v4'); +const Base = require('../../../../../src/api/v4/source/base'); +const carto = require('../../../../../src/api/v4'); describe('api/v4/source/dataset', function () { describe('constructor', function () { @@ -51,6 +52,79 @@ describe('api/v4/source/dataset', function () { }); }); + describe('.getQueryToApply', function () { + let populatedPlacesDataset; + + beforeEach(function () { + populatedPlacesDataset = new carto.source.Dataset('ne_10m_populated_places_simple'); + spyOn(populatedPlacesDataset, '_updateInternalModelQuery'); + }); + + it('should return original query if applied filters returns no SQL', function () { + expect(populatedPlacesDataset._getQueryToApply()).toBe('SELECT * from ne_10m_populated_places_simple'); + }); + + it('should return wrapped query if filters are applied', function () { + populatedPlacesDataset.addFilter(new carto.filter.Category('fake_column', { in: ['category'] })); + + expect(populatedPlacesDataset._getQueryToApply()).toBe("SELECT * FROM (SELECT * from ne_10m_populated_places_simple) as datasetQuery WHERE fake_column IN ('category')"); + }); + }); + + describe('.addFilter', function () { + let populatedPlacesDataset; + + beforeEach(function () { + spyOn(Base.prototype, 'addFilter'); + + populatedPlacesDataset = new carto.source.Dataset('ne_10m_populated_places_simple'); + spyOn(populatedPlacesDataset, '_updateInternalModelQuery'); + }); + + it('should call original addFilter and _updateInternalModelQuery', function () { + populatedPlacesDataset.addFilter(new carto.filter.Category('fake_column', { in: ['category'] })); + + expect(Base.prototype.addFilter).toHaveBeenCalled(); + expect(populatedPlacesDataset._updateInternalModelQuery).toHaveBeenCalledWith(populatedPlacesDataset._getQueryToApply()); + }); + }); + + describe('.removeFilter', function () { + let populatedPlacesDataset, filter; + + beforeEach(function () { + spyOn(Base.prototype, 'addFilter'); + + populatedPlacesDataset = new carto.source.Dataset('ne_10m_populated_places_simple'); + spyOn(populatedPlacesDataset, '_updateInternalModelQuery'); + + filter = populatedPlacesDataset.addFilter(new carto.filter.Category('fake_column', { in: ['category'] })); + populatedPlacesDataset.addFilter(filter); + }); + + it('should call original removeFilter and _updateInternalModelQuery', function () { + populatedPlacesDataset.removeFilter(filter); + + expect(Base.prototype.addFilter).toHaveBeenCalled(); + expect(populatedPlacesDataset._updateInternalModelQuery).toHaveBeenCalledWith(populatedPlacesDataset._getQueryToApply()); + }); + }); + + describe('.getFilters', function () { + let populatedPlacesDataset, filter; + + beforeEach(function () { + filter = new carto.filter.Category('fake_column', { in: ['category'] }); + + populatedPlacesDataset = new carto.source.Dataset('ne_10m_populated_places_simple'); + populatedPlacesDataset.addFilter(filter); + }); + + it('should return added filters', function () { + expect(populatedPlacesDataset.getFilters()).toEqual([filter]); + }); + }); + describe('errors', function () { it('should trigger an error when invalid', function (done) { var client = new carto.Client({ diff --git a/test/spec/api/v4/source/sql.spec.js b/test/spec/api/v4/source/sql.spec.js index 78408482d7..92af2a03ac 100644 --- a/test/spec/api/v4/source/sql.spec.js +++ b/test/spec/api/v4/source/sql.spec.js @@ -1,4 +1,5 @@ -var carto = require('../../../../../src/api/v4'); +const Base = require('../../../../../src/api/v4/source/base'); +const carto = require('../../../../../src/api/v4'); describe('api/v4/source/sql', function () { var sqlQuery; @@ -141,6 +142,79 @@ describe('api/v4/source/sql', function () { }); }); + describe('.getQueryToApply', function () { + let populatedPlacesSQL; + + beforeEach(function () { + populatedPlacesSQL = new carto.source.SQL('SELECT * FROM ne_10m_populated_places_simple'); + spyOn(populatedPlacesSQL, '_updateInternalModelQuery'); + }); + + it('should return original query if applied filters returns no SQL', function () { + expect(populatedPlacesSQL._getQueryToApply()).toBe('SELECT * FROM ne_10m_populated_places_simple'); + }); + + it('should return wrapped query if filters are applied', function () { + populatedPlacesSQL.addFilter(new carto.filter.Category('fake_column', { in: ['category'] })); + + expect(populatedPlacesSQL._getQueryToApply()).toBe("SELECT * FROM (SELECT * FROM ne_10m_populated_places_simple) as originalQuery WHERE fake_column IN ('category')"); + }); + }); + + describe('.addFilter', function () { + let populatedPlacesSQL; + + beforeEach(function () { + spyOn(Base.prototype, 'addFilter'); + + populatedPlacesSQL = new carto.source.SQL('SELECT * FROM ne_10m_populated_places_simple'); + spyOn(populatedPlacesSQL, '_updateInternalModelQuery'); + }); + + it('should call original addFilter and _updateInternalModelQuery', function () { + populatedPlacesSQL.addFilter(new carto.filter.Category('fake_column', { in: ['category'] })); + + expect(Base.prototype.addFilter).toHaveBeenCalled(); + expect(populatedPlacesSQL._updateInternalModelQuery).toHaveBeenCalledWith(populatedPlacesSQL._getQueryToApply()); + }); + }); + + describe('.removeFilter', function () { + let populatedPlacesDataset, filter; + + beforeEach(function () { + spyOn(Base.prototype, 'addFilter'); + + populatedPlacesDataset = new carto.source.Dataset('ne_10m_populated_places_simple'); + spyOn(populatedPlacesDataset, '_updateInternalModelQuery'); + + filter = populatedPlacesDataset.addFilter(new carto.filter.Category('fake_column', { in: ['category'] })); + populatedPlacesDataset.addFilter(filter); + }); + + it('should call original removeFilter and _updateInternalModelQuery', function () { + populatedPlacesDataset.removeFilter(filter); + + expect(Base.prototype.addFilter).toHaveBeenCalled(); + expect(populatedPlacesDataset._updateInternalModelQuery).toHaveBeenCalledWith(populatedPlacesDataset._getQueryToApply()); + }); + }); + + describe('.getFilters', function () { + let populatedPlacesDataset, filter; + + beforeEach(function () { + filter = new carto.filter.Category('fake_column', { in: ['category'] }); + + populatedPlacesDataset = new carto.source.Dataset('ne_10m_populated_places_simple'); + populatedPlacesDataset.addFilter(filter); + }); + + it('should return added filters', function () { + expect(populatedPlacesDataset.getFilters()).toEqual([filter]); + }); + }); + describe('errors', function () { it('should trigger an error when invalid', function (done) { var client = new carto.Client({