├── .babelrc.js ├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .yarn └── releases │ └── yarn-1.22.19.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── TODO.md ├── browser └── customYear.js ├── dist ├── Aggregation │ ├── getMeta.js │ ├── index.js │ └── parse.js ├── AnalyticsQuery │ ├── index.js │ └── parse.js ├── Filter │ ├── index.js │ └── parse.js ├── Join │ ├── index.js │ └── parse.js ├── Ordering │ ├── index.js │ └── parse.js ├── Query │ ├── index.js │ └── parse.js ├── QueryValue │ ├── index.js │ └── parse.js ├── connect.js ├── errors.js ├── index.js ├── operators.js ├── sql │ ├── custom-year.sql │ ├── geospatial.sql │ ├── index.js │ ├── json.sql │ ├── math.sql │ ├── misc.sql │ └── time.sql ├── types │ ├── functions.js │ ├── getTypes.js │ ├── index.js │ └── toSchemaType.js └── util │ ├── aggregateWithFilter.js │ ├── export.js │ ├── fixJSONFilters.js │ ├── getGeoFields.js │ ├── getJSONField.js │ ├── getJoinField.js │ ├── getModelFieldLimit.js │ ├── getScopedAttributes.js │ ├── iffy │ ├── date.js │ ├── number.js │ └── stringArray.js │ ├── intersects.js │ ├── isQueryValue.js │ ├── isValidCoordinate.js │ ├── parseTimeOptions.js │ ├── runWithTimeout.js │ ├── search.js │ ├── toString.js │ └── tz.js ├── docs ├── Getting-started.md ├── api │ ├── Aggregation.md │ ├── AnalyticsQuery.md │ ├── Filter.md │ ├── Ordering.md │ ├── Query.md │ ├── QueryValue.md │ ├── connect.md │ └── setup.md └── querying │ ├── Functions.md │ ├── Operators.md │ └── README.md ├── package.json ├── src ├── Aggregation │ ├── getMeta.js │ ├── index.js │ └── parse.js ├── AnalyticsQuery │ ├── index.js │ └── parse.js ├── Filter │ ├── index.js │ └── parse.js ├── Join │ ├── index.js │ └── parse.js ├── Ordering │ ├── index.js │ └── parse.js ├── Query │ ├── index.js │ └── parse.js ├── QueryValue │ ├── index.js │ └── parse.js ├── connect.js ├── errors.js ├── index.js ├── operators.js ├── sql │ ├── custom-year.sql │ ├── geospatial.sql │ ├── index.js │ ├── json.sql │ ├── math.sql │ ├── misc.sql │ └── time.sql ├── types │ ├── functions.js │ ├── getTypes.js │ ├── index.js │ └── toSchemaType.js └── util │ ├── aggregateWithFilter.js │ ├── export.js │ ├── fixJSONFilters.js │ ├── getGeoFields.js │ ├── getJSONField.js │ ├── getJoinField.js │ ├── getModelFieldLimit.js │ ├── getScopedAttributes.js │ ├── iffy │ ├── date.js │ ├── number.js │ └── stringArray.js │ ├── intersects.js │ ├── isQueryValue.js │ ├── isValidCoordinate.js │ ├── parseTimeOptions.js │ ├── runWithTimeout.js │ ├── search.js │ ├── toString.js │ └── tz.js ├── test ├── Aggregation │ └── index.js ├── AnalyticsQuery │ ├── constrain.js │ ├── execute.js │ ├── executeStream.js │ ├── getOutputSchema.js │ ├── index.js │ ├── options │ │ ├── aggregations.js │ │ ├── groupings.js │ │ └── joins.js │ ├── security.js │ └── update.js ├── Filter │ └── index.js ├── Ordering │ └── index.js ├── Query │ ├── constrain.js │ ├── count.js │ ├── destroy.js │ ├── execute.js │ ├── executeStream.js │ ├── getOutputSchema.js │ ├── index.js │ ├── options │ │ ├── after.js │ │ ├── before.js │ │ ├── exclusions.js │ │ ├── filters.js │ │ ├── intersects.js │ │ ├── joins.js │ │ ├── limit.js │ │ ├── offset.js │ │ ├── orderings.js │ │ ├── search.js │ │ └── within.js │ ├── security.js │ └── update.js ├── connect.js ├── errors.js ├── fixtures │ ├── 911-call.json │ ├── analytics.js │ ├── bike-trip.json │ ├── db.js │ ├── seed-data.js │ ├── seed-stores.js │ ├── seed-users.js │ ├── transit-passenger.json │ └── transit-trip.json ├── index.js ├── setup.js ├── sql │ ├── custom-year.js │ └── time.js ├── types │ ├── functions │ │ ├── add.js │ │ ├── area.js │ │ ├── average.js │ │ ├── boundingBox.js │ │ ├── bucket.js │ │ ├── count.js │ │ ├── distance.js │ │ ├── distinctCount.js │ │ ├── divide.js │ │ ├── eq.js │ │ ├── expand.js │ │ ├── extract.js │ │ ├── geojson.js │ │ ├── gt.js │ │ ├── gte.js │ │ ├── intersects.js │ │ ├── interval.js │ │ ├── last.js │ │ ├── length.js │ │ ├── lt.js │ │ ├── lte.js │ │ ├── max.js │ │ ├── median.js │ │ ├── min.js │ │ ├── multiply.js │ │ ├── now.js │ │ ├── percentage.js │ │ ├── remainder.js │ │ ├── round.js │ │ ├── subtract.js │ │ └── sum.js │ ├── getTypes.js │ ├── index.js │ └── toSchemaType.js └── util │ ├── aggregateWithFilter.js │ ├── export.js │ ├── fixJSONFilters.js │ ├── getGeoFields.js │ ├── getJSONField.js │ ├── getScopedAttributes.js │ ├── iffy │ ├── date.js │ ├── number.js │ └── stringArray.js │ ├── intersects.js │ ├── isQueryValue.js │ ├── isValidCoordinate.js │ └── toString.js └── tools └── print-functions.js /.babelrc.js: -------------------------------------------------------------------------------- 1 | const core = require('@stae/babel-node') 2 | 3 | module.exports = { 4 | ...core, 5 | env: { 6 | ...core.env, 7 | test: { 8 | plugins: [ 'istanbul' ] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | # A special property that should be specified at the top of the file outside of 4 | # any sections. Set to true to stop .editor config file search on current file 5 | root = true 6 | 7 | [*] 8 | # Indentation style 9 | # Possible values - tab, space 10 | indent_style = space 11 | 12 | # Indentation size in single-spaced characters 13 | # Possible values - an integer, tab 14 | indent_size = 2 15 | 16 | # Line ending file format 17 | # Possible values - lf, crlf, cr 18 | end_of_line = lf 19 | 20 | # File character encoding 21 | # Possible values - latin1, utf-8, utf-16be, utf-16le 22 | charset = utf-8 23 | 24 | # Denotes whether to trim whitespace at the end of lines 25 | # Possible values - true, false 26 | trim_trailing_whitespace = true 27 | 28 | # Denotes whether file should end with a newline 29 | # Possible values - true, false 30 | insert_final_newline = true 31 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { eslint } = require('@stae/linters') 2 | 3 | module.exports = { 4 | ...eslint, 5 | overrides: [ 6 | ...eslint.overrides || [], 7 | 8 | { 9 | files: [ 10 | // generic cut-outs 11 | '**/test/**' 12 | ], 13 | rules: { 14 | 'no-magic-numbers': 0 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | build 3 | lib-cov 4 | *.seed 5 | *.log 6 | *.dat 7 | *.out 8 | *.pid 9 | *.gz 10 | _book 11 | 12 | pids 13 | logs 14 | results 15 | 16 | npm-shrinkwrap.json 17 | npm-debug.log 18 | node_modules 19 | *.sublime* 20 | *.node 21 | coverage 22 | *.orig 23 | .idea 24 | sandbox 25 | .nyc_output 26 | yarn.lock 27 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-1.22.19.cjs 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Stae and Contra 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

User friendly API query language

4 |

5 | 6 | # iris-ql [![NPM version][npm-image]][npm-url] [![Downloads][downloads-image]][npm-url] [![Build Status][circle-image]][circle-url] 7 | 8 | Iris is a safe and user-friendly query system for building flexible APIs with intuitive UIs to match. Built on top of [human-schema](https://github.com/staeco/human-schema) and Sequelize. Check out the docs folder to get started! 9 | 10 | ## Install 11 | 12 | ``` 13 | npm install iris-ql --save 14 | ``` 15 | 16 | ## Basic Example 17 | 18 | ```js 19 | import { Query } from 'iris-ql' 20 | 21 | // Find all crimes by criminal 1 or 2 after 2017 22 | const query = new Query({ 23 | limit: 100, 24 | filters: { 25 | createdAt: { $gt: '2017-05-13T00:00:00.000Z' }, 26 | $or: [ 27 | { name: 'Criminal 1' }, 28 | { name: 'Criminal 2' } 29 | ] 30 | }, 31 | orderings: [ 32 | { value: { field: 'createdAt' }, direction: 'desc' } 33 | ] 34 | }, { model: crime }) 35 | 36 | const results = await query.execute() 37 | ``` 38 | 39 | ## Analytics Example 40 | 41 | ```js 42 | import { AnalyticsQuery } from 'iris-ql' 43 | 44 | // get a time series of all 911 calls 45 | const crimeTimeSeries = new AnalyticsQuery({ 46 | filters: { 47 | data: { 48 | receivedAt: { $ne: null } 49 | } 50 | }, 51 | aggregations: [ 52 | { value: { function: 'count' }, alias: 'total' }, 53 | { 54 | alias: 'day', 55 | value: { 56 | function: 'bucket', 57 | arguments: [ 58 | 'day', 59 | { field: 'data.receivedAt' } 60 | ] 61 | } 62 | } 63 | ], 64 | orderings: [ 65 | { value: { field: 'day' }, direction: 'desc' } 66 | ], 67 | groupings: [ 68 | { field: 'day' } 69 | ] 70 | }, { model: emergencyCall }) 71 | 72 | const results = await crimeTimeSeries.execute() 73 | 74 | /* 75 | [ 76 | { total: 20, day: '2017-05-13T00:00:00.000Z' }, 77 | { total: 3, day: '2017-05-14T00:00:00.000Z' }, 78 | { total: 2, day: '2017-05-15T00:00:00.000Z' } 79 | ] 80 | */ 81 | ``` 82 | 83 | 84 | ## DB Support 85 | 86 | Currently only works with Postgres 12+. Some features and specific functions may require newer versions. In the future, the database layer will be broken out into adapters and multiple stores will be supported. 87 | 88 | [downloads-image]: http://img.shields.io/npm/dm/iris-ql.svg 89 | [npm-url]: https://npmjs.org/package/iris-ql 90 | [npm-image]: http://img.shields.io/npm/v/iris-ql.svg 91 | 92 | [circle-url]: https://circleci.com/gh/staeco/iris-ql 93 | [circle-image]: https://circleci.com/gh/staeco/iris-ql.svg?style=svg 94 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## Immediate 2 | 3 | - Tests for every function variation 4 | - Test every custom SQL function 5 | - Test every code path 6 | - More SQL injection tests 7 | - Add missing math functions https://www.postgresql.org/docs/11/functions-math.html 8 | - getOptions that returns specific value suggestions for a query path 9 | - Prevent using groupings when aggregations dont support it 10 | - Reduce excess casting for numeric types 11 | - Need more variadic math function overloads! 12 | - Function name consistency (min/max but average/median/subtract?) 13 | - Query.getOutputSchema should include custom attributes added in `.update()` 14 | 15 | ## Future 16 | 17 | - Move searchable into something else 18 | - Handle includes? 19 | - Support counting in executeStream 20 | - Remove `toString` wherever possible 21 | - Validate types when used with operators 22 | -------------------------------------------------------------------------------- /browser/customYear.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment-timezone' 2 | 3 | /** 4 | * Used to generate an object containing metadata for a custom year 5 | * A current use case is generating the getRange() key for a custom temporal 6 | * range dropdown option 7 | * @param {number} customYearStart 1-based month number (i.e. 1 for January) 8 | * @param {date} [now=new Date()] optional date to use in calcs 9 | * @returns {object} returns an object containing the custom year name, start date, and end date 10 | */ 11 | export const getCustomYear = (now = new Date(), customYearStart) => { 12 | const thisYear = now.getFullYear ? now.getFullYear() : now.year() // handle moment and plain js dates 13 | const thisMonth = (now.getMonth ? now.getMonth() : now.month()) + 1 14 | 15 | const startYear = thisMonth >= customYearStart ? thisYear : thisYear - 1 16 | const startDate = moment({ days: 1, months: customYearStart - 1, years: startYear }) 17 | const endDate = moment(startDate).add(11, 'months').endOf('month') 18 | 19 | return { 20 | name: endDate.format('YYYY'), 21 | start: startDate.toISOString(), 22 | end: endDate.toISOString() 23 | } 24 | } 25 | 26 | export const rotateCustomMonth = (v, customYearStart) => { 27 | if (customYearStart == null || customYearStart === 1) return v // none set, will just default to jan in iris-ql anyways so no change needed 28 | // this is the inverse of the function get_custom_month in iris-ql 29 | // https://github.com/staeco/iris-ql/blob/master/src/sql/custom-year.sql#L18 30 | if (v + customYearStart - 1 === 12) return 12 31 | return (12 + v - 1 + customYearStart) % 12 32 | } 33 | export const getCustomQuarter = (v = new Date(), customYearStart) => { 34 | if (customYearStart == null || customYearStart === 1) return moment(v).quarter() // none set, will just default to jan in iris-ql anyways so no change needed 35 | // matches https://github.com/staeco/iris-ql/blob/master/src/sql/custom-year.sql#L11 36 | const month = moment(v).month() + 1 37 | // linter will tell you the parens arent needed, they are! 38 | // eslint-disable-next-line no-extra-parens 39 | return Math.floor(((12 + month - customYearStart) % 12) / 3) + 1 40 | } 41 | -------------------------------------------------------------------------------- /dist/Aggregation/getMeta.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _lodash = require("lodash"); 7 | 8 | var _capitalize = _interopRequireDefault(require("capitalize")); 9 | 10 | var _decamelize = _interopRequireDefault(require("decamelize")); 11 | 12 | var _getTypes = _interopRequireDefault(require("../types/getTypes")); 13 | 14 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 15 | 16 | const fmt = v => _capitalize.default.words((0, _decamelize.default)(v, { 17 | separator: ' ' 18 | })); 19 | 20 | const getFieldSchema = (field, opt) => { 21 | if (field.includes('.')) { 22 | const [head, tail] = field.split('.'); 23 | return opt.subSchemas[head][tail]; 24 | } 25 | 26 | return opt.model.rawAttributes[field]; 27 | }; 28 | 29 | const getJoinSchema = (field, opt) => { 30 | const [join, ...rest] = field.split('.'); 31 | return getFieldSchema(rest.join('.'), opt.joins?.[join.replace('~', '')]); 32 | }; 33 | 34 | var _default = (agg, opt = {}) => { 35 | const types = (0, _getTypes.default)(agg.value, opt); 36 | if (types.length === 0) return; // no types? weird 37 | 38 | const primaryType = types[0]; 39 | let fieldSchema; 40 | 41 | if (agg.value.field) { 42 | fieldSchema = agg.value.field.startsWith('~') ? getJoinSchema(agg.value.field, opt) : getFieldSchema(agg.value.field, opt); 43 | } 44 | 45 | return (0, _lodash.pickBy)({ 46 | name: agg.name || fieldSchema?.name || fmt(agg.alias), 47 | notes: agg.notes || fieldSchema?.notes, 48 | type: primaryType.type, 49 | items: primaryType.items, 50 | measurement: primaryType.measurement, 51 | validation: primaryType.validation 52 | }); 53 | }; 54 | 55 | exports.default = _default; 56 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/Aggregation/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _parse = _interopRequireDefault(require("./parse")); 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 9 | 10 | class Aggregation { 11 | constructor(obj, options = {}) { 12 | this.value = () => this._parsed; 13 | 14 | this.toJSON = () => this.input; 15 | 16 | if (!obj) throw new Error('Missing value!'); 17 | if (!options.model || !options.model.rawAttributes) throw new Error('Missing model!'); 18 | this.input = obj; 19 | this.options = options; 20 | this._parsed = (0, _parse.default)(obj, options); 21 | } 22 | 23 | } 24 | 25 | exports.default = Aggregation; 26 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/Filter/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _parse = _interopRequireDefault(require("./parse")); 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 9 | 10 | class Filter { 11 | constructor(obj, options = {}) { 12 | this.value = () => this._parsed; 13 | 14 | this.toJSON = () => this.input; 15 | 16 | if (!obj) throw new Error('Missing value!'); 17 | if (!options.model || !options.model.rawAttributes) throw new Error('Missing model!'); 18 | this.input = obj; 19 | this.options = options; 20 | this._parsed = (0, _parse.default)(obj, options); 21 | } 22 | 23 | } 24 | 25 | exports.default = Filter; 26 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/Filter/parse.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _operators = _interopRequireDefault(require("../operators")); 7 | 8 | var _errors = require("../errors"); 9 | 10 | var _isPlainObj = _interopRequireDefault(require("is-plain-obj")); 11 | 12 | var _fixJSONFilters = require("../util/fixJSONFilters"); 13 | 14 | var _isQueryValue = _interopRequireDefault(require("../util/isQueryValue")); 15 | 16 | var _QueryValue = _interopRequireDefault(require("../QueryValue")); 17 | 18 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 19 | 20 | const reserved = new Set(Object.keys(_operators.default)); 21 | 22 | function _ref3(acc, [k, v]) { 23 | return (0, _fixJSONFilters.hydrate)(acc, { ...v, 24 | from: k !== 'parent' ? k : undefined 25 | }); 26 | } 27 | 28 | var _default = (obj, opt) => { 29 | const { 30 | context = [] 31 | } = opt; 32 | const error = new _errors.ValidationError(); // recursively walk a filter object and replace query values with the real thing 33 | 34 | const transformValues = (v, parent = '', idx) => { 35 | const ctx = idx != null ? [...context, idx] : context; 36 | 37 | if ((0, _isQueryValue.default)(v)) { 38 | return new _QueryValue.default(v, { ...opt, 39 | context: ctx, 40 | hydrateJSON: false // we do this later anyways 41 | 42 | }).value(); 43 | } 44 | 45 | if (Array.isArray(v)) return v.map((i, idx) => transformValues(i, parent, idx)); 46 | 47 | function _ref2(p, k) { 48 | let fullPath; // verify 49 | 50 | function _ref(e) { 51 | return { ...e, 52 | path: [...ctx, ...fullPath.split('.')] 53 | }; 54 | } 55 | 56 | if (!reserved.has(k)) { 57 | fullPath = `${parent}${parent ? '.' : ''}${k}`; 58 | 59 | try { 60 | new _QueryValue.default({ 61 | field: fullPath 62 | }, { ...opt, 63 | context: ctx, 64 | hydrateJSON: false 65 | }); // performs the check, don't need the value 66 | } catch (err) { 67 | if (!err.fields) { 68 | error.add(err); 69 | } else { 70 | error.add(err.fields.map(_ref)); 71 | } 72 | 73 | return p; 74 | } 75 | } 76 | 77 | p[k] = transformValues(v[k], fullPath || parent, idx); 78 | return p; 79 | } 80 | 81 | if ((0, _isPlainObj.default)(v)) { 82 | return Object.keys(v).reduce(_ref2, {}); 83 | } 84 | 85 | return v; 86 | }; 87 | 88 | const transformed = transformValues(obj); // turn where object into string with fields hydrated 89 | 90 | if (!error.isEmpty()) throw error; 91 | const out = (0, _fixJSONFilters.hydrate)((0, _fixJSONFilters.unwrap)(transformed, opt), opt); 92 | if (!opt.joins) return out; // run through all of our joins and fix those up too 93 | 94 | return Object.entries(opt.joins).reduce(_ref3, out); 95 | }; 96 | 97 | exports.default = _default; 98 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/Join/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _parse = _interopRequireDefault(require("./parse")); 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 9 | 10 | class Join { 11 | constructor(obj, options = {}) { 12 | this.value = () => this._parsed; 13 | 14 | this.toJSON = () => this.input; 15 | 16 | if (!obj) throw new Error('Missing value!'); 17 | if (!options.model || !options.model.rawAttributes) throw new Error('Missing model!'); 18 | this.input = obj; 19 | this.options = options; 20 | this._parsed = (0, _parse.default)(obj, options); 21 | } 22 | 23 | } 24 | 25 | exports.default = Join; 26 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/Ordering/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _parse = _interopRequireDefault(require("./parse")); 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 9 | 10 | class Ordering { 11 | constructor(obj, options = {}) { 12 | this.value = () => this._parsed; 13 | 14 | this.toJSON = () => this.input; 15 | 16 | if (!obj) throw new Error('Missing value!'); 17 | if (!options.model || !options.model.rawAttributes) throw new Error('Missing model!'); 18 | this.input = obj; 19 | this.options = options; 20 | this._parsed = (0, _parse.default)(obj, options); 21 | } 22 | 23 | } 24 | 25 | exports.default = Ordering; 26 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/Ordering/parse.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _QueryValue = _interopRequireDefault(require("../QueryValue")); 7 | 8 | var _errors = require("../errors"); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | var _default = ({ 13 | value, 14 | direction 15 | } = {}, opt) => { 16 | const error = new _errors.ValidationError(); 17 | let out; 18 | const { 19 | context = [] 20 | } = opt; 21 | const isDirectionValid = direction === 'asc' || direction === 'desc'; 22 | 23 | if (!value) { 24 | error.add({ 25 | path: [...context, 'value'], 26 | value, 27 | message: 'Missing ordering value.' 28 | }); 29 | } 30 | 31 | if (!direction) { 32 | error.add({ 33 | path: [...context, 'direction'], 34 | value: direction, 35 | message: 'Missing ordering direction.' 36 | }); 37 | } 38 | 39 | if (direction != null && !isDirectionValid) { 40 | error.add({ 41 | path: [...context, 'direction'], 42 | value: direction, 43 | message: 'Invalid ordering direction - must be asc or desc.' 44 | }); 45 | } 46 | 47 | if (direction && value && isDirectionValid) { 48 | try { 49 | out = [new _QueryValue.default(value, { ...opt, 50 | context: [...context, 'value'] 51 | }).value(), direction]; 52 | } catch (err) { 53 | error.add(err); 54 | } 55 | } 56 | 57 | if (!error.isEmpty()) throw error; 58 | return out; 59 | }; 60 | 61 | exports.default = _default; 62 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/QueryValue/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _parse = _interopRequireDefault(require("./parse")); 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 9 | 10 | class QueryValue { 11 | constructor(obj, options = {}) { 12 | this.value = () => this._parsed; 13 | 14 | this.toJSON = () => this.input; 15 | 16 | if (!obj) throw new Error('Missing value!'); 17 | if (!options.model || !options.model.rawAttributes) throw new Error('Missing model!'); 18 | this.input = obj; 19 | this.options = options; 20 | this._parsed = (0, _parse.default)(obj, options); 21 | } 22 | 23 | } 24 | 25 | exports.default = QueryValue; 26 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/connect.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _pg = _interopRequireDefault(require("pg")); 7 | 8 | var _sequelize = _interopRequireDefault(require("sequelize")); 9 | 10 | var _pluralize = require("pluralize"); 11 | 12 | var _inflection = require("inflection"); 13 | 14 | var _operators = _interopRequireDefault(require("./operators")); 15 | 16 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 17 | 18 | /* eslint-disable no-magic-numbers */ 19 | const alignTypeParser = (conn, id) => { 20 | const parser = _pg.default.types.getTypeParser(id, 'text'); // sequelize 5+ 21 | 22 | 23 | if (conn.connectionManager.oidParserMap) { 24 | conn.connectionManager.oidParserMap.set(id, parser); 25 | return conn; 26 | } // sequelize 4 27 | 28 | 29 | conn.connectionManager.oidMap[id] = parser; 30 | return conn; 31 | }; 32 | 33 | const defaultOptions = { 34 | logging: false, 35 | native: false, 36 | operatorsAliases: _operators.default, 37 | timezone: 'UTC' 38 | }; 39 | 40 | function _ref(item) { 41 | return !this.isNull(item); 42 | } 43 | 44 | var _default = (url, opt = {}, Instance = _sequelize.default) => { 45 | // fix issues with pg types 46 | _pg.default.types.setTypeParser(20, 'text', _pg.default.types.getTypeParser(23, 'text')); // bigint = int 47 | 48 | 49 | _pg.default.types.setTypeParser(1016, 'text', _pg.default.types.getTypeParser(1007, 'text')); // bigint[] = int[] 50 | 51 | 52 | _pg.default.types.setTypeParser(1700, 'text', _pg.default.types.getTypeParser(701, 'text')); // numeric = float8 53 | 54 | 55 | _pg.default.types.setTypeParser(1231, 'text', _pg.default.types.getTypeParser(1022, 'text')); // numeric[] = float8[] 56 | // fix bugs with sequelize 57 | 58 | 59 | _sequelize.default.useInflection({ 60 | pluralize: _pluralize.plural, 61 | singularize: _pluralize.singular, 62 | underscore: _inflection.underscore 63 | }); // See https://github.com/sequelize/sequelize/issues/1500 64 | 65 | 66 | _sequelize.default.Validator.notNull = _ref; // you can override Instance if you use sequelize-typescript 67 | 68 | const conn = typeof url === 'object' ? new Instance({ ...defaultOptions, 69 | ...url 70 | }) : new Instance(url, { ...defaultOptions, 71 | ...opt 72 | }); // fix sequelize types overriding pg-types 73 | 74 | const override = () => { 75 | alignTypeParser(conn, 20); // bigint 76 | 77 | alignTypeParser(conn, 1016); // bigint[] 78 | 79 | alignTypeParser(conn, 1700); // numeric 80 | 81 | alignTypeParser(conn, 1231); // numeric[] 82 | }; 83 | 84 | const oldRefresh = conn.connectionManager.refreshTypeParser.bind(conn.connectionManager); 85 | 86 | conn.connectionManager.refreshTypeParser = (...a) => { 87 | oldRefresh(...a); 88 | override(); 89 | }; 90 | 91 | override(); 92 | return conn; 93 | }; 94 | 95 | exports.default = _default; 96 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/errors.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.ValidationError = exports.BadRequestError = void 0; 5 | 6 | var _util = require("util"); 7 | 8 | const inspectOptions = { 9 | depth: 100, 10 | breakLength: Infinity 11 | }; 12 | 13 | function _ref(f) { 14 | return `\n - ${(0, _util.inspect)(f, inspectOptions)}`; 15 | } 16 | 17 | const serializeIssues = fields => fields.map(_ref); 18 | 19 | class BadRequestError extends Error { 20 | constructor(message = 'Bad Request', status = 400) { 21 | super(message); 22 | this.name = 'BadRequestError'; 23 | 24 | this.toString = () => `${super.toString()} (HTTP ${this.status})`; 25 | 26 | this.status = status; 27 | Error.captureStackTrace(this, BadRequestError); 28 | } 29 | 30 | } 31 | 32 | exports.BadRequestError = BadRequestError; 33 | 34 | class ValidationError extends BadRequestError { 35 | constructor(fields = []) { 36 | super('Validation Error'); 37 | this.name = 'ValidationError'; 38 | 39 | this.add = err => { 40 | if (!err) return this; // nothing to do 41 | 42 | if (err.fields) { 43 | this.fields.push(...err.fields); 44 | } else if (err instanceof Error) { 45 | throw err; 46 | } else if (Array.isArray(err)) { 47 | this.fields.push(...err); 48 | } else { 49 | this.fields.push(err); 50 | } 51 | 52 | this.message = this.toString(); // update msg 53 | 54 | if (err.stack) { 55 | this.stack = err.stack; 56 | } else { 57 | Error.captureStackTrace(this, this.add); 58 | } 59 | 60 | return this; 61 | }; 62 | 63 | this.removePath = path => { 64 | this.fields = this.fields.filter(f => !f.path || !path.every((p, idx) => f.path[idx] === p)); 65 | this.message = this.toString(); // update msg 66 | }; 67 | 68 | this.isEmpty = () => this.fields.length === 0; 69 | 70 | this.toString = () => { 71 | const original = 'Error: Validation Error'; 72 | if (this.isEmpty()) return original; // no custom validation 73 | 74 | return `${original}\nIssues:${serializeIssues(this.fields)}`; 75 | }; 76 | 77 | this.fields = []; 78 | if (fields) this.add(fields); 79 | Error.captureStackTrace(this, ValidationError); 80 | } 81 | 82 | } 83 | 84 | exports.ValidationError = ValidationError; -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.types = exports.functions = void 0; 5 | 6 | var _connect = _interopRequireDefault(require("./connect")); 7 | 8 | exports.connect = _connect.default; 9 | 10 | var _sql = _interopRequireDefault(require("./sql")); 11 | 12 | exports.setup = _sql.default; 13 | 14 | var _operators = _interopRequireDefault(require("./operators")); 15 | 16 | exports.operators = _operators.default; 17 | 18 | var functions = _interopRequireWildcard(require("./types/functions")); 19 | 20 | exports.functions = functions; 21 | 22 | var types = _interopRequireWildcard(require("./types")); 23 | 24 | exports.types = types; 25 | 26 | var _getTypes = _interopRequireDefault(require("./types/getTypes")); 27 | 28 | exports.getTypes = _getTypes.default; 29 | 30 | var _AnalyticsQuery = _interopRequireDefault(require("./AnalyticsQuery")); 31 | 32 | exports.AnalyticsQuery = _AnalyticsQuery.default; 33 | 34 | var _Query = _interopRequireDefault(require("./Query")); 35 | 36 | exports.Query = _Query.default; 37 | 38 | var _QueryValue = _interopRequireDefault(require("./QueryValue")); 39 | 40 | exports.QueryValue = _QueryValue.default; 41 | 42 | var _Ordering = _interopRequireDefault(require("./Ordering")); 43 | 44 | exports.Ordering = _Ordering.default; 45 | 46 | var _Filter = _interopRequireDefault(require("./Filter")); 47 | 48 | exports.Filter = _Filter.default; 49 | 50 | var _Aggregation = _interopRequireDefault(require("./Aggregation")); 51 | 52 | exports.Aggregation = _Aggregation.default; 53 | 54 | function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } 55 | 56 | function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } 57 | 58 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } -------------------------------------------------------------------------------- /dist/operators.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _sequelize = require("sequelize"); 7 | 8 | var _default = { 9 | $eq: _sequelize.Op.eq, 10 | $ne: _sequelize.Op.ne, 11 | $gte: _sequelize.Op.gte, 12 | $gt: _sequelize.Op.gt, 13 | $lte: _sequelize.Op.lte, 14 | $lt: _sequelize.Op.lt, 15 | $not: _sequelize.Op.not, 16 | $in: _sequelize.Op.in, 17 | $notIn: _sequelize.Op.notIn, 18 | $is: _sequelize.Op.is, 19 | $like: _sequelize.Op.like, 20 | $notLike: _sequelize.Op.notLike, 21 | $iLike: _sequelize.Op.iLike, 22 | $notILike: _sequelize.Op.notILike, 23 | $regexp: _sequelize.Op.regexp, 24 | $notRegexp: _sequelize.Op.notRegexp, 25 | $iRegexp: _sequelize.Op.iRegexp, 26 | $notIRegexp: _sequelize.Op.notIRegexp, 27 | $between: _sequelize.Op.between, 28 | $notBetween: _sequelize.Op.notBetween, 29 | $overlap: _sequelize.Op.overlap, 30 | $contains: _sequelize.Op.contains, 31 | $contained: _sequelize.Op.contained, 32 | $adjacent: _sequelize.Op.adjacent, 33 | $strictLeft: _sequelize.Op.strictLeft, 34 | $strictRight: _sequelize.Op.strictRight, 35 | $noExtendRight: _sequelize.Op.noExtendRight, 36 | $noExtendLeft: _sequelize.Op.noExtendLeft, 37 | $and: _sequelize.Op.and, 38 | $or: _sequelize.Op.or, 39 | $any: _sequelize.Op.any, 40 | $all: _sequelize.Op.all, 41 | $values: _sequelize.Op.values 42 | }; 43 | exports.default = _default; 44 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/sql/custom-year.sql: -------------------------------------------------------------------------------- 1 | -- outputs the year for a date given a start month 2 | CREATE OR REPLACE FUNCTION get_custom_year(v timestamp, custom_year_start integer) RETURNS numeric AS $$ 3 | SELECT CASE 4 | WHEN date_part('month', v) >= custom_year_start THEN date_part('year', v)::numeric + 1 5 | ELSE date_part('year', v)::numeric 6 | END; 7 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 8 | RETURNS NULL ON NULL INPUT; 9 | 10 | -- outputs the quarter of a custom year for a date given a start month 11 | CREATE OR REPLACE FUNCTION get_custom_quarter(v timestamp, custom_year_start integer) RETURNS numeric AS $$ 12 | SELECT floor(((12 + date_part('month', v)::numeric - custom_year_start) % 12) / 3 ) + 1; 13 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 14 | RETURNS NULL ON NULL INPUT; 15 | 16 | -- outputs the month of a custom year for a date given a start month, rotated so 0 = the start month, and 12 = the end of the custom year 17 | -- need to invert this? see browser/customYear.js 18 | CREATE OR REPLACE FUNCTION get_custom_month(v timestamp, custom_year_start integer) RETURNS numeric AS $$ 19 | SELECT ((12 + date_part('month', v)::numeric - custom_year_start) % 12) + 1; 20 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 21 | RETURNS NULL ON NULL INPUT; 22 | 23 | -- date_part wrapper 24 | CREATE OR REPLACE FUNCTION date_part_with_custom(part text, v timestamp, custom_year_start integer) RETURNS numeric AS $$ 25 | BEGIN 26 | IF custom_year_start = 1 THEN 27 | RETURN date_part(replace(part, 'custom_', ''), v)::numeric; 28 | END IF; 29 | IF part = 'custom_year' 30 | THEN 31 | RETURN get_custom_year(v, custom_year_start); 32 | END IF; 33 | IF part = 'custom_quarter' 34 | THEN 35 | RETURN get_custom_quarter(v, custom_year_start); 36 | END IF; 37 | IF part = 'custom_month' 38 | THEN 39 | RETURN get_custom_month(v, custom_year_start); 40 | END IF; 41 | RETURN date_part(part, v); 42 | END; 43 | $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE 44 | RETURNS NULL ON NULL INPUT; 45 | 46 | -- date_trunc_with_custom utils 47 | CREATE OR REPLACE FUNCTION trunc_custom_year(v timestamp, tz text, custom_year_start integer) RETURNS timestamptz AS $$ 48 | SELECT make_timestamptz(get_custom_year(v, custom_year_start)::int - 1, custom_year_start, 1, 0, 0, 0, tz); 49 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 50 | RETURNS NULL ON NULL INPUT; 51 | 52 | CREATE OR REPLACE FUNCTION trunc_custom_quarter(v timestamp, tz text, custom_year_start integer) RETURNS timestamptz AS $$ 53 | DECLARE 54 | month_start timestamp; 55 | BEGIN 56 | month_start := trunc_custom_year(v, tz, custom_year_start) + make_interval(months => (get_custom_quarter(v, custom_year_start)::int - 1) * 3); 57 | RETURN make_timestamptz(date_part('year', month_start)::int, date_part('month', month_start)::int, 1, 0, 0, 0, tz); 58 | END; 59 | $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE 60 | RETURNS NULL ON NULL INPUT; 61 | 62 | -- date_trunc wrapper 63 | CREATE OR REPLACE FUNCTION date_trunc_with_custom(bucket text, v timestamptz, tz text, custom_year_start integer) RETURNS timestamptz AS $$ 64 | BEGIN 65 | IF custom_year_start = 1 THEN 66 | RETURN date_trunc(replace(bucket, 'custom_', ''), v, tz); 67 | END IF; 68 | 69 | IF bucket = 'custom_year' 70 | THEN 71 | RETURN trunc_custom_year(force_tz(v, tz), tz, custom_year_start); 72 | END IF; 73 | IF bucket = 'custom_quarter' 74 | THEN 75 | RETURN trunc_custom_quarter(force_tz(v, tz), tz, custom_year_start); 76 | END IF; 77 | RETURN date_trunc(bucket, v, tz); 78 | END; 79 | $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE 80 | RETURNS NULL ON NULL INPUT; 81 | -------------------------------------------------------------------------------- /dist/sql/geospatial.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION from_geojson(p_input text) RETURNS geometry AS $$ 2 | SELECT ST_MakeValid(ST_SetSRID(ST_GeomFromGeoJSON(p_input), 4326)); 3 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 4 | RETURNS NULL ON NULL INPUT; 5 | 6 | CREATE OR REPLACE FUNCTION from_geojson(p_input jsonb) RETURNS geometry AS $$ 7 | SELECT ST_MakeValid(ST_SetSRID(ST_GeomFromGeoJSON(p_input), 4326)); 8 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 9 | RETURNS NULL ON NULL INPUT; 10 | 11 | CREATE OR REPLACE FUNCTION from_geojson_collection(p_input text) RETURNS geometry AS $$ 12 | SELECT ST_SetSRID(ST_Union(from_geojson(feat->'geometry')), 4326) 13 | FROM (SELECT jsonb_array_elements(p_input::jsonb->'features') AS feat) AS f; 14 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 15 | RETURNS NULL ON NULL INPUT; 16 | 17 | CREATE OR REPLACE FUNCTION from_geojson_collection(p_input jsonb) RETURNS geometry AS $$ 18 | SELECT ST_SetSRID(ST_Union(from_geojson(feat->'geometry')), 4326) 19 | FROM (SELECT jsonb_array_elements(p_input->'features') AS feat) AS f; 20 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 21 | RETURNS NULL ON NULL INPUT; 22 | 23 | CREATE OR REPLACE FUNCTION get_features_from_feature_collection(p_input jsonb) RETURNS TABLE("geometry" geography, "properties" jsonb) AS $$ 24 | SELECT 25 | from_geojson("feature"->'geometry') AS "geometry", 26 | "feature"->'properties' as "properties" 27 | FROM jsonb_array_elements(p_input->'features') AS "feature"; 28 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 29 | RETURNS NULL ON NULL INPUT; 30 | 31 | -------------------------------------------------------------------------------- /dist/sql/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.groups = exports.default = void 0; 5 | 6 | var _gracefulFs = require("graceful-fs"); 7 | 8 | var _path = require("path"); 9 | 10 | const groups = ['misc', 'math', 'json', 'time', 'geospatial', 'custom-year'].map(name => ({ 11 | name, 12 | sql: (0, _gracefulFs.readFileSync)((0, _path.join)(__dirname, `./${name}.sql`), 'utf8') 13 | })); 14 | exports.groups = groups; 15 | 16 | function _ref(p, group) { 17 | return (//if (group.name === 'geospatial' && !hasPostGIS) return p // skip geo stuff if they dont have postgis 18 | `${p}-- ${group.name}\n${group.sql}\n` 19 | ); 20 | } 21 | 22 | var _default = async conn => { 23 | //const [ [ hasPostGIS ] ] = await conn.query(`SELECT * FROM pg_extension WHERE "extname" = 'postgis'`) 24 | const all = groups.reduce(_ref, ''); 25 | return conn.query(all, { 26 | useMaster: true 27 | }); 28 | }; 29 | 30 | exports.default = _default; -------------------------------------------------------------------------------- /dist/sql/json.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION jsonb_array_to_text_array(p_input jsonb) RETURNS text[] AS $$ 2 | SELECT array_agg(x) FROM jsonb_array_elements_text(p_input) t(x); 3 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 4 | RETURNS NULL ON NULL INPUT; 5 | 6 | CREATE OR REPLACE FUNCTION json_array_to_text_array(p_input json) RETURNS text[] AS $$ 7 | SELECT array_agg(x) FROM json_array_elements_text(p_input) t(x); 8 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 9 | RETURNS NULL ON NULL INPUT; 10 | 11 | CREATE OR REPLACE FUNCTION fix_jsonb_array(p_input text) RETURNS text[] AS $$ 12 | SELECT null_if_empty_array(jsonb_array_to_text_array(p_input::jsonb)) 13 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 14 | RETURNS NULL ON NULL INPUT; 15 | 16 | CREATE OR REPLACE FUNCTION fix_json_array(p_input text) RETURNS text[] AS $$ 17 | SELECT null_if_empty_array(json_array_to_text_array(p_input::json)) 18 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 19 | RETURNS NULL ON NULL INPUT; 20 | 21 | 22 | CREATE OR REPLACE FUNCTION jsonb_diff(val1 JSONB, val2 JSONB) RETURNS JSONB AS $$ 23 | DECLARE 24 | result JSONB; 25 | object_result JSONB; 26 | i int; 27 | v RECORD; 28 | BEGIN 29 | IF jsonb_typeof(val1) = 'null' 30 | THEN 31 | RETURN val2; 32 | END IF; 33 | 34 | result = val1; 35 | FOR v IN SELECT * FROM jsonb_each(val1) LOOP 36 | result = result || jsonb_build_object(v.key, null); 37 | END LOOP; 38 | 39 | FOR v IN SELECT * FROM jsonb_each(val2) LOOP 40 | -- if both fields are objects, recurse to get deep diff 41 | IF jsonb_typeof(val1->v.key) = 'object' AND jsonb_typeof(val2->v.key) = 'object' 42 | THEN 43 | object_result = jsonb_diff_val(val1->v.key, val2->v.key); 44 | -- check if result is not empty 45 | IF object_result = '{}'::jsonb THEN 46 | result = result - v.key; --if empty remove 47 | ELSE 48 | result = result || jsonb_build_object(v.key, object_result); 49 | END IF; 50 | -- if they are equal, remove the key 51 | ELSIF val1->v.key = val2->v.key THEN 52 | result = result - v.key; 53 | -- if they are different, add to the diff 54 | ELSE 55 | result = result || jsonb_build_object(v.key, v.value); 56 | END IF; 57 | END LOOP; 58 | RETURN result; 59 | END; 60 | $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE; 61 | 62 | CREATE OR REPLACE FUNCTION jsonb_merge(a jsonb, b jsonb) RETURNS JSONB AS $$ 63 | select 64 | jsonb_object_agg( 65 | coalesce(ka, kb), 66 | case 67 | when va isnull then vb 68 | when vb isnull then va 69 | when jsonb_typeof(va) <> 'object' or jsonb_typeof(vb) <> 'object' then vb 70 | else jsonb_merge(va, vb) end 71 | ) 72 | from jsonb_each(a) e1(ka, va) 73 | full join jsonb_each(b) e2(kb, vb) on ka = kb 74 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE; 75 | 76 | 77 | CREATE OR REPLACE FUNCTION get_label_from_json(p_input jsonb) RETURNS text AS $$ 78 | SELECT coalesce(p_input->>'name', p_input->>'type', p_input->>'id', NULL); 79 | $$ 80 | LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT; 81 | -------------------------------------------------------------------------------- /dist/sql/math.sql: -------------------------------------------------------------------------------- 1 | -- From: https://wiki.postgresql.org/wiki/Aggregate_Median 2 | CREATE OR REPLACE FUNCTION _final_median(numeric[]) 3 | RETURNS numeric AS 4 | $$ 5 | SELECT AVG(val) 6 | FROM ( 7 | SELECT val 8 | FROM unnest($1) val 9 | ORDER BY 1 10 | LIMIT 2 - MOD(array_upper($1, 1), 2) 11 | OFFSET CEIL(array_upper($1, 1) / 2.0) - 1 12 | ) sub; 13 | $$ 14 | LANGUAGE 'sql' IMMUTABLE; 15 | 16 | DROP AGGREGATE IF EXISTS median(anyelement); 17 | DROP AGGREGATE IF EXISTS median(numeric); 18 | CREATE AGGREGATE median(numeric) ( 19 | SFUNC=array_append, 20 | STYPE=numeric[], 21 | FINALFUNC=_final_median, 22 | INITCOND='{}' 23 | ); 24 | 25 | CREATE OR REPLACE FUNCTION add(a numeric, b numeric) RETURNS numeric AS $$ 26 | SELECT a + b; 27 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 28 | RETURNS NULL ON NULL INPUT; 29 | 30 | CREATE OR REPLACE FUNCTION subtract(a numeric, b numeric) RETURNS numeric AS $$ 31 | SELECT a - b; 32 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 33 | RETURNS NULL ON NULL INPUT; 34 | 35 | CREATE OR REPLACE FUNCTION multiply(a numeric, b numeric) RETURNS numeric AS $$ 36 | SELECT a * b; 37 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 38 | RETURNS NULL ON NULL INPUT; 39 | 40 | CREATE OR REPLACE FUNCTION divide(a numeric, b numeric) RETURNS numeric AS $$ 41 | SELECT a / NULLIF(b, 0); 42 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 43 | RETURNS NULL ON NULL INPUT; 44 | 45 | CREATE OR REPLACE FUNCTION modulus(a numeric, b numeric) RETURNS numeric AS $$ 46 | SELECT a % NULLIF(b, 0); 47 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 48 | RETURNS NULL ON NULL INPUT; 49 | 50 | CREATE OR REPLACE FUNCTION round(a numeric) RETURNS numeric AS $$ 51 | SELECT round(a); 52 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 53 | RETURNS NULL ON NULL INPUT; 54 | 55 | CREATE OR REPLACE FUNCTION gt(a numeric, b numeric) RETURNS boolean AS $$ 56 | SELECT a > b; 57 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 58 | RETURNS NULL ON NULL INPUT; 59 | 60 | CREATE OR REPLACE FUNCTION gte(a numeric, b numeric) RETURNS boolean AS $$ 61 | SELECT a >= b; 62 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 63 | RETURNS NULL ON NULL INPUT; 64 | 65 | CREATE OR REPLACE FUNCTION lt(a numeric, b numeric) RETURNS boolean AS $$ 66 | SELECT a < b; 67 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 68 | RETURNS NULL ON NULL INPUT; 69 | 70 | CREATE OR REPLACE FUNCTION lte(a numeric, b numeric) RETURNS boolean AS $$ 71 | SELECT a <= b; 72 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 73 | RETURNS NULL ON NULL INPUT; 74 | 75 | CREATE OR REPLACE FUNCTION eq(a numeric, b numeric) RETURNS boolean AS $$ 76 | SELECT a = b; 77 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 78 | RETURNS NULL ON NULL INPUT; 79 | 80 | CREATE OR REPLACE FUNCTION ne(a numeric, b numeric) RETURNS boolean AS $$ 81 | SELECT a != b; 82 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 83 | RETURNS NULL ON NULL INPUT; 84 | -------------------------------------------------------------------------------- /dist/sql/misc.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION null_if_empty_array(a text[]) RETURNS text[] AS $$ 2 | SELECT NULLIF(a, ARRAY[]::text[]); 3 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 4 | RETURNS NULL ON NULL INPUT; 5 | 6 | CREATE OR REPLACE FUNCTION dist_sum_func(numeric, anyelement, numeric) RETURNS numeric AS $$ 7 | SELECT CASE WHEN $3 IS NOT NULL THEN COALESCE($1, 0) + $3 ELSE $1 END; 8 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE; 9 | 10 | CREATE OR REPLACE AGGREGATE dist_sum(anyelement, numeric) ( 11 | SFUNC = dist_sum_func, 12 | STYPE = numeric 13 | ); 14 | -------------------------------------------------------------------------------- /dist/sql/time.sql: -------------------------------------------------------------------------------- 1 | -- date_part has a different type sig on 12 and 13 so just make it numeric everywhere 2 | -- on 13 its integer, 12 its double precision 3 | CREATE OR REPLACE FUNCTION force_tz(base_date timestamptz, tz text) RETURNS timestamp AS $$ 4 | SELECT base_date AT TIME ZONE tz; 5 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 6 | RETURNS NULL ON NULL INPUT; 7 | 8 | CREATE OR REPLACE FUNCTION time_to_ms(a timestamptz) RETURNS numeric AS $$ 9 | SELECT date_part('epoch', a)::numeric * 1000; 10 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 11 | RETURNS NULL ON NULL INPUT; 12 | 13 | CREATE OR REPLACE FUNCTION time_to_ms(a timestamp) RETURNS numeric AS $$ 14 | SELECT date_part('epoch', a)::numeric * 1000; 15 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 16 | RETURNS NULL ON NULL INPUT; 17 | 18 | CREATE OR REPLACE FUNCTION parse_iso(a text) RETURNS timestamptz AS $$ 19 | SELECT to_timestamp(a, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'); 20 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 21 | RETURNS NULL ON NULL INPUT; 22 | -------------------------------------------------------------------------------- /dist/types/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.text = exports.polygon = exports.point = exports.object = exports.number = exports.multipolygon = exports.multiline = exports.line = exports.date = exports.boolean = exports.array = void 0; 5 | 6 | var _sequelize = _interopRequireDefault(require("sequelize")); 7 | 8 | var _isNumber = _interopRequireDefault(require("is-number")); 9 | 10 | var _humanSchema = require("human-schema"); 11 | 12 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 13 | 14 | const wgs84 = 4326; 15 | 16 | const geoCast = txt => _sequelize.default.fn('ST_SetSRID', _sequelize.default.fn('ST_GeomFromGeoJSON', txt), wgs84); // Extend human-schema types and: 17 | // - add a hydrate function to go from db text values -> properly typed values 18 | // - make some types more permissive, since queries are often passed in via querystring 19 | 20 | 21 | const array = { ..._humanSchema.types.array, 22 | // TODO: recursively map the array against the right types 23 | // this treats everything as a text array 24 | // probably need to pass in type and let the db figure out hydrating 25 | hydrate: txt => _sequelize.default.fn('fix_jsonb_array', txt) 26 | }; 27 | exports.array = array; 28 | const object = { ..._humanSchema.types.object, 29 | hydrate: txt => _sequelize.default.cast(txt, 'jsonb') 30 | }; 31 | exports.object = object; 32 | const text = { ..._humanSchema.types.text, 33 | hydrate: txt => txt 34 | }; 35 | exports.text = text; 36 | const number = { ..._humanSchema.types.number, 37 | test: _isNumber.default, 38 | hydrate: txt => _sequelize.default.cast(txt, 'numeric') 39 | }; 40 | exports.number = number; 41 | const boolean = { ..._humanSchema.types.boolean, 42 | hydrate: txt => _sequelize.default.cast(txt, 'boolean') 43 | }; 44 | exports.boolean = boolean; 45 | const date = { ..._humanSchema.types.date, 46 | hydrate: txt => _sequelize.default.fn('parse_iso', txt) 47 | }; 48 | exports.date = date; 49 | const point = { ..._humanSchema.types.point, 50 | hydrate: geoCast 51 | }; 52 | exports.point = point; 53 | const line = { ..._humanSchema.types.line, 54 | hydrate: geoCast 55 | }; 56 | exports.line = line; 57 | const multiline = { ..._humanSchema.types.multiline, 58 | hydrate: geoCast 59 | }; 60 | exports.multiline = multiline; 61 | const polygon = { ..._humanSchema.types.polygon, 62 | hydrate: geoCast 63 | }; 64 | exports.polygon = polygon; 65 | const multipolygon = { ..._humanSchema.types.multipolygon, 66 | hydrate: geoCast 67 | }; 68 | exports.multipolygon = multipolygon; -------------------------------------------------------------------------------- /dist/types/toSchemaType.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | // converts sequelize types to subSchema types 6 | const geomTypes = { 7 | point: 'point', 8 | linestring: 'line', 9 | multilinestring: 'multiline', 10 | polygon: 'polygon', 11 | multipolygon: 'multipolygon' 12 | }; 13 | 14 | const toSchemaType = (type, subSchema) => { 15 | const key = type.key || type.constructor.key; 16 | if (key === 'STRING') return { 17 | type: 'text' 18 | }; 19 | if (key === 'TEXT') return { 20 | type: 'text' 21 | }; 22 | if (key === 'UUID') return { 23 | type: 'text' 24 | }; 25 | if (key === 'CITEXT') return { 26 | type: 'text' 27 | }; 28 | if (key === 'CHAR') return { 29 | type: 'text' 30 | }; 31 | if (key === 'DATE') return { 32 | type: 'date' 33 | }; 34 | if (key === 'DATEONLY') return { 35 | type: 'date' 36 | }; 37 | if (key === 'BOOLEAN') return { 38 | type: 'boolean' 39 | }; 40 | if (key === 'INTEGER') return { 41 | type: 'number' 42 | }; 43 | if (key === 'TINYINT') return { 44 | type: 'number' 45 | }; 46 | if (key === 'SMALLINT') return { 47 | type: 'number' 48 | }; 49 | if (key === 'BIGINT') return { 50 | type: 'number' 51 | }; 52 | if (key === 'FLOAT') return { 53 | type: 'number' 54 | }; 55 | if (key === 'REAL') return { 56 | type: 'number' 57 | }; 58 | if (key === 'DOUBLE PRECISION') return { 59 | type: 'number' 60 | }; 61 | if (key === 'DECIMAL') return { 62 | type: 'number' 63 | }; 64 | if (key === 'JSON') return { 65 | type: 'object', 66 | schema: subSchema 67 | }; 68 | if (key === 'JSONB') return { 69 | type: 'object', 70 | schema: subSchema 71 | }; 72 | if (key === 'ARRAY') return { 73 | type: 'array', 74 | items: toSchemaType(type.type) 75 | }; 76 | 77 | if (key === 'GEOMETRY' || key === 'GEOGRAPHY') { 78 | const subtype = type.type?.toLowerCase(); 79 | if (geomTypes[subtype]) return { 80 | type: geomTypes[subtype] 81 | }; 82 | return { 83 | type: 'geometry' 84 | }; 85 | } // Unsupported types: ENUM, BLOB, CIDR, INET, MACADDR, RANGE, HSTORE 86 | 87 | 88 | return null; 89 | }; 90 | 91 | var _default = toSchemaType; 92 | exports.default = _default; 93 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/aggregateWithFilter.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _sequelize = _interopRequireDefault(require("sequelize")); 7 | 8 | var _errors = require("../errors"); 9 | 10 | var _toString = require("./toString"); 11 | 12 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 13 | 14 | var _default = ({ 15 | aggregation, 16 | filters, 17 | model, 18 | instanceQuery 19 | }) => { 20 | if (!filters) throw new _errors.BadRequestError('Missing filters'); 21 | if (!aggregation) throw new _errors.BadRequestError('Missing aggregation'); 22 | const query = (0, _toString.where)({ 23 | value: filters, 24 | model, 25 | instanceQuery 26 | }); 27 | const agg = (0, _toString.value)({ 28 | value: aggregation, 29 | model, 30 | instanceQuery 31 | }); 32 | return _sequelize.default.literal(`${agg} FILTER (WHERE ${query})`); 33 | }; 34 | 35 | exports.default = _default; 36 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/export.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _pgQueryStream = _interopRequireDefault(require("pg-query-stream")); 7 | 8 | var _stream = require("stream"); 9 | 10 | var _toString = require("./toString"); 11 | 12 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 13 | 14 | // this wraps a sql query in a stream via a cursor so as each row is found 15 | // it gets transformed and emitted from the stream 16 | // this is how you want to return millions of rows with low memory overhead 17 | const batchSize = 16; 18 | 19 | const streamable = async ({ 20 | useMaster, 21 | model, 22 | sql, 23 | transform, 24 | timeout, 25 | finishTimeout, 26 | debug, 27 | tupleFraction, 28 | onError 29 | }) => { 30 | const conn = await model.sequelize.connectionManager.getConnection({ 31 | useMaster, 32 | type: 'SELECT' 33 | }); 34 | const warm = []; 35 | if (timeout) warm.push(`SET idle_in_transaction_session_timeout = ${parseInt(timeout)};`); 36 | if (finishTimeout) warm.push(`SET statement_timeout = ${parseInt(finishTimeout)};`); 37 | if (typeof tupleFraction === 'number') warm.push(`SET cursor_tuple_fraction=${tupleFraction};`); 38 | 39 | if (warm.length > 0) { 40 | await conn.query(warm.join('\n')); 41 | } // a not so fun hack to tie our sequelize types into this raw cursor 42 | 43 | 44 | let out; 45 | if (debug) debug(sql); 46 | const query = conn.query(new _pgQueryStream.default(sql, undefined, { 47 | batchSize, 48 | types: { 49 | getTypeParser: conn.getTypeParser.bind(conn) 50 | } 51 | })); 52 | 53 | function _ref(err) { 54 | if (err && onError) onError(err); 55 | model.sequelize.connectionManager.releaseConnection(conn); 56 | } 57 | 58 | const end = err => { 59 | if (err && onError) onError(err); 60 | if (err) out.emit('error', err); // clean up the connection 61 | 62 | query.destroy(null, _ref); 63 | }; 64 | 65 | if (transform) { 66 | out = (0, _stream.pipeline)(query, new _stream.Transform({ 67 | objectMode: true, 68 | 69 | transform(obj, _, cb) { 70 | cb(null, transform(obj)); 71 | } 72 | 73 | }), end); 74 | } else { 75 | out = query; 76 | (0, _stream.finished)(query, end); 77 | } 78 | 79 | return out; 80 | }; 81 | 82 | var _default = async ({ 83 | useMaster, 84 | model, 85 | value, 86 | format, 87 | transform, 88 | tupleFraction, 89 | debug, 90 | timeout, 91 | finishTimeout, 92 | onError, 93 | analytics = false 94 | }) => { 95 | const nv = { ...value 96 | }; 97 | const sql = (0, _toString.select)({ 98 | value: nv, 99 | model, 100 | analytics 101 | }); 102 | const src = await streamable({ 103 | useMaster, 104 | model, 105 | tupleFraction, 106 | timeout, 107 | finishTimeout, 108 | debug, 109 | sql, 110 | transform, 111 | onError 112 | }); 113 | if (!format) return src; 114 | const out = (0, _stream.pipeline)(src, format(), err => { 115 | if (err) out.emit('error', err); 116 | }); 117 | out.contentType = format.contentType; 118 | return out; 119 | }; 120 | 121 | exports.default = _default; 122 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/fixJSONFilters.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.unwrap = exports.hydrate = void 0; 5 | 6 | var _sequelize = _interopRequireDefault(require("sequelize")); 7 | 8 | var _toString = require("./toString"); 9 | 10 | var _getJSONField = _interopRequireDefault(require("./getJSONField")); 11 | 12 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 13 | 14 | const jsonField = /"(\w*)"\."(\w*)"#>>'{(\w*)}'/; // sometimes sequelize randomly wraps json access in useless parens, so unwrap everything 15 | 16 | const wrapped = /\("(\w*)"\."(\w*)"#>>'{(\w*)}'\)/g; 17 | 18 | function _ref(match, table, col, field) { 19 | return `"${table}"."${col}"#>>'{${field}}'`; 20 | } 21 | 22 | const unwrap = (v, opt) => { 23 | if (Array.isArray(v)) v = { 24 | $and: v 25 | }; // convert it 26 | 27 | const str = (0, _toString.where)({ ...opt, 28 | value: v 29 | }); 30 | if (!jsonField.test(str)) return v; // nothing to do! no fields to hydrate 31 | 32 | const redone = str.replace(wrapped, _ref); 33 | return _sequelize.default.literal(redone); 34 | }; 35 | 36 | exports.unwrap = unwrap; 37 | 38 | const hydrate = (v, opt) => { 39 | if (Array.isArray(v)) v = { 40 | $and: v 41 | }; // convert it 42 | 43 | const str = (0, _toString.where)({ ...opt, 44 | value: v 45 | }); 46 | if (!jsonField.test(str)) return v; // nothing to do! no fields to hydrate 47 | 48 | const fixing = (0, _toString.identifier)({ ...opt, 49 | value: opt.from || opt.model.name 50 | }); // if the field is followed by " IS" then skip, because we dont need to hydrate that 51 | // since its either IS NULL or IS NOT NULL 52 | 53 | const needsCasting = new RegExp(`${fixing}\\."(\\w*)"#>>'{(\\w*)}'(?! (IS NULL|IS NOT NULL))`, 'g'); 54 | const redone = str.replace(needsCasting, (match, col, field) => { 55 | const lit = (0, _getJSONField.default)(`${col}.${field}`, opt); 56 | return (0, _toString.value)({ ...opt, 57 | value: lit 58 | }); 59 | }); 60 | return _sequelize.default.literal(redone); 61 | }; 62 | 63 | exports.hydrate = hydrate; -------------------------------------------------------------------------------- /dist/util/getGeoFields.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _sequelize = _interopRequireDefault(require("sequelize")); 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 9 | 10 | var _default = model => { 11 | const attrs = model.rawAttributes; 12 | const ret = Object.keys(attrs).filter(k => { 13 | const { 14 | type 15 | } = attrs[k]; 16 | return type instanceof _sequelize.default.GEOGRAPHY || type instanceof _sequelize.default.GEOMETRY; 17 | }); 18 | return ret.length > 0 ? ret : null; 19 | }; 20 | 21 | exports.default = _default; 22 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/getJoinField.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.parse = exports.default = void 0; 5 | 6 | var _errors = require("../errors"); 7 | 8 | var _QueryValue = _interopRequireDefault(require("../QueryValue")); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | const parse = v => { 13 | const [alias, ...rest] = v.split('.'); 14 | const joinKey = alias.replace('~', ''); 15 | return { 16 | alias: joinKey, 17 | field: rest.join('.') 18 | }; 19 | }; 20 | 21 | exports.parse = parse; 22 | 23 | var _default = (v, opt) => { 24 | const { 25 | joins, 26 | hydrateJSON, 27 | context = [] 28 | } = opt; 29 | const { 30 | alias, 31 | field 32 | } = parse(v); 33 | const joinKey = alias.replace('~', ''); 34 | const joinConfig = joins?.[joinKey]; 35 | 36 | if (!joinConfig) { 37 | throw new _errors.ValidationError({ 38 | path: context, 39 | value: v, 40 | message: 'Must be a defined join!' 41 | }); 42 | } 43 | 44 | return new _QueryValue.default({ 45 | field 46 | }, { ...joinConfig, 47 | hydrateJSON, 48 | context, 49 | instanceQuery: true, 50 | from: joinKey !== 'parent' ? joinKey : undefined 51 | }).value(); 52 | }; 53 | 54 | exports.default = _default; -------------------------------------------------------------------------------- /dist/util/getModelFieldLimit.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _getScopedAttributes = _interopRequireDefault(require("../util/getScopedAttributes")); 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 9 | 10 | function _ref(f) { 11 | return { 12 | type: 'column', 13 | field: f 14 | }; 15 | } 16 | 17 | var _default = model => Object.keys((0, _getScopedAttributes.default)(model)).map(_ref); 18 | 19 | exports.default = _default; 20 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/getScopedAttributes.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _default = ({ 7 | rawAttributes, 8 | _scope 9 | }) => { 10 | if (!_scope) return rawAttributes; // no scope 11 | 12 | const { 13 | attributes 14 | } = _scope; 15 | if (!attributes) return rawAttributes; // scope does not apply to attrs 16 | 17 | function _ref(prev, [k, v]) { 18 | if (!attributes.includes(k)) return prev; 19 | prev[k] = v; 20 | return prev; 21 | } 22 | 23 | if (Array.isArray(attributes)) { 24 | return Object.entries(rawAttributes).reduce(_ref, {}); 25 | } 26 | 27 | function _ref2(prev, [k, v]) { 28 | if (attributes.exclude && attributes.exclude.includes(k)) return prev; 29 | if (attributes.include && !attributes.include.includes(k)) return prev; 30 | prev[k] = v; 31 | return prev; 32 | } 33 | 34 | if (Array.isArray(attributes.exclude) || Array.isArray(attributes.include)) { 35 | return Object.entries(rawAttributes).reduce(_ref2, {}); 36 | } 37 | 38 | throw new Error('Scope too complex - could not determine safe values!'); 39 | }; 40 | 41 | exports.default = _default; 42 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/iffy/date.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _default = v => { 7 | if (v == null || !v) return; 8 | const d = new Date(v); 9 | if (isNaN(d)) throw new Error('Bad date value'); 10 | return d; 11 | }; 12 | 13 | exports.default = _default; 14 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/iffy/number.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _default = v => { 7 | if (typeof v === 'number') return v; 8 | if (v == null || !v) return; 9 | 10 | if (typeof v === 'string') { 11 | const n = parseFloat(v); 12 | if (isNaN(n)) throw new Error('Bad number value'); 13 | return n; 14 | } 15 | 16 | throw new Error('Bad number value'); 17 | }; 18 | 19 | exports.default = _default; 20 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/iffy/stringArray.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | function _ref(s) { 7 | return String(s); 8 | } 9 | 10 | var _default = v => { 11 | if (v == null) return []; // nada 12 | 13 | if (Array.isArray(v)) return v.map(_ref); 14 | if (typeof v === 'string') return v.split(','); 15 | return [String(v)]; 16 | }; 17 | 18 | exports.default = _default; 19 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/intersects.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _getGeoFields = _interopRequireDefault(require("./getGeoFields")); 7 | 8 | var _sequelize = require("sequelize"); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | var _default = (geo, { 13 | model, 14 | column = model.name 15 | }) => { 16 | const geoFields = (0, _getGeoFields.default)(model); 17 | if (!geo || !geoFields) return (0, _sequelize.literal)(false); 18 | const wheres = geoFields.map(f => (0, _sequelize.fn)('ST_Intersects', (0, _sequelize.cast)((0, _sequelize.col)(`${column}.${f}`), 'geometry'), (0, _sequelize.cast)(geo, 'geometry'))); 19 | if (wheres.length === 1) return wheres[0]; 20 | return (0, _sequelize.or)(...wheres); 21 | }; 22 | 23 | exports.default = _default; 24 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/isQueryValue.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _isPlainObj = _interopRequireDefault(require("is-plain-obj")); 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 9 | 10 | var _default = v => (0, _isPlainObj.default)(v) && (v.function || v.field); 11 | 12 | exports.default = _default; 13 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/isValidCoordinate.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.lon = exports.lat = void 0; 5 | 6 | /* eslint-disable no-magic-numbers */ 7 | const lat = lat => { 8 | if (typeof lat !== 'number') return `Latitude not a number, got ${typeof lat}`; 9 | if (lat > 90) return 'Latitude greater than 90'; 10 | if (lat < -90) return 'Latitude less than -90'; 11 | return true; 12 | }; 13 | 14 | exports.lat = lat; 15 | 16 | const lon = lon => { 17 | if (typeof lon !== 'number') return `Longitude not a number, got ${typeof lon}`; 18 | if (lon < -180) return 'Longitude less than -180'; 19 | if (lon > 180) return 'Longitude greater than 180'; 20 | return true; 21 | }; 22 | 23 | exports.lon = lon; -------------------------------------------------------------------------------- /dist/util/parseTimeOptions.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _momentTimezone = _interopRequireDefault(require("moment-timezone")); 7 | 8 | var _errors = require("../errors"); 9 | 10 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 11 | 12 | /* eslint-disable no-magic-numbers */ 13 | const zones = new Set(_momentTimezone.default.tz.names()); 14 | 15 | var _default = (query, { 16 | context = [] 17 | }) => { 18 | const error = new _errors.ValidationError(); 19 | const out = {}; // if user specified a timezone, tack it on so downstream stuff in types/query knows about it 20 | 21 | if (query.timezone) { 22 | if (typeof query.timezone !== 'string') { 23 | error.add({ 24 | path: [...context, 'timezone'], 25 | value: query.timezone, 26 | message: 'Must be a string.' 27 | }); 28 | } else { 29 | if (!zones.has(query.timezone)) { 30 | error.add({ 31 | path: [...context, 'timezone'], 32 | value: query.timezone, 33 | message: 'Not a valid timezone.' 34 | }); 35 | } else { 36 | out.timezone = query.timezone; 37 | } 38 | } 39 | 40 | delete query.timezone; 41 | } // if user specified a customYearStart, tack it on so downstream stuff in types/query knows about it 42 | 43 | 44 | if (query.customYearStart) { 45 | if (typeof query.customYearStart !== 'number') { 46 | error.add({ 47 | path: [...context, 'customYearStart'], 48 | value: query.customYearStart, 49 | message: 'Must be a number.' 50 | }); 51 | } else { 52 | if (query.customYearStart < 1 || query.customYearStart > 12) { 53 | error.add({ 54 | path: [...context, 'customYearStart'], 55 | value: query.customYearStart, 56 | message: 'Not a valid month.' 57 | }); 58 | } else { 59 | out.customYearStart = query.customYearStart; 60 | } 61 | } 62 | 63 | delete query.customYearStart; 64 | } 65 | 66 | if (!error.isEmpty()) throw error; 67 | return out; 68 | }; 69 | 70 | exports.default = _default; 71 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/runWithTimeout.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _default = async (fn, { 7 | timeout, 8 | sequelize, 9 | debug 10 | }) => sequelize.transaction(async transaction => { 11 | const qopt = { 12 | transaction, 13 | logging: debug 14 | }; 15 | await sequelize.query(` 16 | SET LOCAL statement_timeout = ${parseInt(timeout)}; 17 | SET LOCAL idle_in_transaction_session_timeout = ${parseInt(timeout)}; 18 | `.trim(), qopt); 19 | return fn(transaction, sequelize); 20 | }); 21 | 22 | exports.default = _default; 23 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/search.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.default = void 0; 5 | 6 | var _eachDeep = _interopRequireDefault(require("deepdash/eachDeep")); 7 | 8 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 9 | 10 | var _default = (v, fn) => { 11 | const res = []; 12 | (0, _eachDeep.default)(v, (value, key, path) => { 13 | if (fn(key, value)) res.push({ 14 | path, 15 | value 16 | }); 17 | }, { 18 | pathFormat: 'array' 19 | }); 20 | return res.length === 0 ? undefined : res; 21 | }; 22 | 23 | exports.default = _default; 24 | module.exports = exports.default; -------------------------------------------------------------------------------- /dist/util/tz.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.__esModule = true; 4 | exports.force = void 0; 5 | 6 | var _momentTimezone = _interopRequireDefault(require("moment-timezone")); 7 | 8 | var _sequelize = _interopRequireDefault(require("sequelize")); 9 | 10 | var _errors = require("../errors"); 11 | 12 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 13 | 14 | const zones = new Set(_momentTimezone.default.tz.names()); 15 | 16 | const force = (v, timezone = 'Etc/UTC') => { 17 | if (!zones.has(timezone)) throw new _errors.BadRequestError('Not a valid timezone'); 18 | return _sequelize.default.fn('force_tz', v, timezone); 19 | }; 20 | 21 | exports.force = force; -------------------------------------------------------------------------------- /docs/Getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ### Connecting 4 | 5 | ### Seeding 6 | 7 | ### Querying 8 | -------------------------------------------------------------------------------- /docs/api/Aggregation.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staeco/iris-ql/f5deb2a894e10d7a4d2f13a7a6171dc2ecb8de5b/docs/api/Aggregation.md -------------------------------------------------------------------------------- /docs/api/AnalyticsQuery.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staeco/iris-ql/f5deb2a894e10d7a4d2f13a7a6171dc2ecb8de5b/docs/api/AnalyticsQuery.md -------------------------------------------------------------------------------- /docs/api/Filter.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staeco/iris-ql/f5deb2a894e10d7a4d2f13a7a6171dc2ecb8de5b/docs/api/Filter.md -------------------------------------------------------------------------------- /docs/api/Ordering.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staeco/iris-ql/f5deb2a894e10d7a4d2f13a7a6171dc2ecb8de5b/docs/api/Ordering.md -------------------------------------------------------------------------------- /docs/api/Query.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staeco/iris-ql/f5deb2a894e10d7a4d2f13a7a6171dc2ecb8de5b/docs/api/Query.md -------------------------------------------------------------------------------- /docs/api/QueryValue.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staeco/iris-ql/f5deb2a894e10d7a4d2f13a7a6171dc2ecb8de5b/docs/api/QueryValue.md -------------------------------------------------------------------------------- /docs/api/connect.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staeco/iris-ql/f5deb2a894e10d7a4d2f13a7a6171dc2ecb8de5b/docs/api/connect.md -------------------------------------------------------------------------------- /docs/api/setup.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/staeco/iris-ql/f5deb2a894e10d7a4d2f13a7a6171dc2ecb8de5b/docs/api/setup.md -------------------------------------------------------------------------------- /docs/querying/Operators.md: -------------------------------------------------------------------------------- 1 | # Operators 2 | 3 | ### $eq 4 | ### $ne 5 | ### $gte 6 | ### $gt 7 | ### $lte 8 | ### $lt 9 | ### $not 10 | ### $in 11 | ### $notIn 12 | ### $is 13 | ### $like 14 | ### $notLike 15 | ### $iLike 16 | ### $notILike 17 | ### $regexp 18 | ### $notRegexp 19 | ### $iRegexp 20 | ### $notIRegexp 21 | ### $between 22 | ### $notBetween 23 | ### $overlap 24 | ### $contains 25 | ### $contained 26 | ### $adjacent 27 | ### $strictLeft 28 | ### $strictRight 29 | ### $noExtendRight 30 | ### $noExtendLeft 31 | ### $and 32 | ### $or 33 | ### $any 34 | ### $all 35 | ### $values 36 | -------------------------------------------------------------------------------- /docs/querying/README.md: -------------------------------------------------------------------------------- 1 | # Basic 2 | 3 | ### limit 4 | ### offset 5 | ### search 6 | ### before 7 | ### after 8 | ### within 9 | ### intersects 10 | ### exclusions 11 | ### filters 12 | ### orderings 13 | 14 | # Analytics 15 | 16 | ### aggregations 17 | ### groupings 18 | ### timezone 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iris-ql", 3 | "version": "5.0.0", 4 | "description": "User friendly API query language", 5 | "main": "dist/index.js", 6 | "keywords": [], 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/staeco/iris-ql.git" 10 | }, 11 | "contributors": [ 12 | "Contra (http://contra.io)" 13 | ], 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/staeco/iris-ql/issues" 17 | }, 18 | "homepage": "https://github.com/staeco/iris-ql#readme", 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "preversion": "npm run clean && npm run build", 24 | "build": "npm run-script clean && NODE_ENV=production babel src --copy-files --out-dir dist", 25 | "clean": "rimraf dist", 26 | "lint": "eslint src test --fix", 27 | "test-make-user": "createuser postgres -lsd", 28 | "test-db": "dropdb iris-ql -f -U postgres || true && createdb iris-ql -U postgres && psql iris-ql -c 'CREATE EXTENSION IF NOT EXISTS postgis;' -U postgres", 29 | "test": "npm run-script test-db && NODE_ENV=test nyc mocha --require @babel/register --recursive --reporter spec --bail --exit --timeout 36000 && npm run-script lint", 30 | "test:ci": "NODE_ENV=test nyc mocha --require @babel/register --recursive --reporter spec --bail --exit --timeout 36000 && npm run-script lint" 31 | }, 32 | "nyc": { 33 | "extends": "@istanbuljs/nyc-config-babel", 34 | "include": [ 35 | "src/**/*.js" 36 | ], 37 | "all": true, 38 | "skip-full": true, 39 | "check-coverage": true 40 | }, 41 | "peerDependencies": { 42 | "pg": ">=7", 43 | "sequelize": "*" 44 | }, 45 | "devDependencies": { 46 | "@babel/cli": "^7.4.4", 47 | "@babel/core": "^7.4.5", 48 | "@babel/register": "^7.4.4", 49 | "@istanbuljs/nyc-config-babel": "^3.0.0", 50 | "@stae/babel-node": "^1.0.0", 51 | "@stae/linters": "^1.0.0", 52 | "babel-plugin-istanbul": "^6.0.0", 53 | "eslint": "^7.0.0", 54 | "get-stream": "^6.0.0", 55 | "jsonstream-next": "^3.0.0", 56 | "mocha": "^10.0.0", 57 | "nyc": "^15.0.0", 58 | "pg": "^8.0.0", 59 | "pumpify": "^2.0.1", 60 | "rimraf": "^3.0.0", 61 | "sequelize": "^6.0.0", 62 | "should": "^13.0.0" 63 | }, 64 | "dependencies": { 65 | "capitalize": "^2.0.0", 66 | "decamelize": "^5.0.0", 67 | "deepdash": "^5.3.0", 68 | "fast-deep-equal": "github:yocontra/fast-deep-equal", 69 | "graceful-fs": "^4.2.0", 70 | "human-schema": "^1.1.4", 71 | "inflection": "^1.12.0", 72 | "is-number": "^7.0.0", 73 | "is-plain-obj": "^3.0.0", 74 | "moment-timezone": "^0.5.27", 75 | "pg-query-stream": "^4.0.0", 76 | "pluralize": "^8.0.0", 77 | "pretty-ms": "^7.0.0" 78 | }, 79 | "packageManager": "yarn@1.22.19" 80 | } 81 | -------------------------------------------------------------------------------- /src/Aggregation/getMeta.js: -------------------------------------------------------------------------------- 1 | import { pickBy } from 'lodash' 2 | import capitalize from 'capitalize' 3 | import decamelize from 'decamelize' 4 | import getTypes from '../types/getTypes' 5 | 6 | const fmt = (v) => capitalize.words(decamelize(v, { separator: ' ' })) 7 | 8 | const getFieldSchema = (field, opt) => { 9 | if (field.includes('.')) { 10 | const [ head, tail ] = field.split('.') 11 | return opt.subSchemas[head][tail] 12 | } 13 | return opt.model.rawAttributes[field] 14 | } 15 | 16 | const getJoinSchema = (field, opt) => { 17 | const [ join, ...rest ] = field.split('.') 18 | return getFieldSchema(rest.join('.'), opt.joins?.[join.replace('~', '')]) 19 | } 20 | 21 | export default (agg, opt = {}) => { 22 | const types = getTypes(agg.value, opt) 23 | if (types.length === 0) return // no types? weird 24 | const primaryType = types[0] 25 | let fieldSchema 26 | if (agg.value.field) { 27 | fieldSchema = agg.value.field.startsWith('~') 28 | ? getJoinSchema(agg.value.field, opt) 29 | : getFieldSchema(agg.value.field, opt) 30 | } 31 | return pickBy({ 32 | name: agg.name || fieldSchema?.name || fmt(agg.alias), 33 | notes: agg.notes || fieldSchema?.notes, 34 | type: primaryType.type, 35 | items: primaryType.items, 36 | measurement: primaryType.measurement, 37 | validation: primaryType.validation 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /src/Aggregation/index.js: -------------------------------------------------------------------------------- 1 | import parse from './parse' 2 | 3 | export default class Aggregation { 4 | constructor(obj, options = {}) { 5 | if (!obj) throw new Error('Missing value!') 6 | if (!options.model || !options.model.rawAttributes) throw new Error('Missing model!') 7 | this.input = obj 8 | this.options = options 9 | this._parsed = parse(obj, options) 10 | } 11 | value = () => this._parsed 12 | toJSON = () => this.input 13 | } 14 | -------------------------------------------------------------------------------- /src/Aggregation/parse.js: -------------------------------------------------------------------------------- 1 | import isObject from 'is-plain-obj' 2 | import QueryValue from '../QueryValue' 3 | import Filter from '../Filter' 4 | import { ValidationError } from '../errors' 5 | import aggregateWithFilter from '../util/aggregateWithFilter' 6 | 7 | const MAX_LENGTH = 64 8 | const MAX_NOTES_LENGTH = 1024 9 | const alphanumPlus = /[^\w-]/i 10 | 11 | export default (a, opt) => { 12 | const { model, context = [], instanceQuery } = opt 13 | let agg, parsedFilters 14 | const error = new ValidationError() 15 | 16 | if (!isObject(a)) { 17 | error.add({ 18 | path: context, 19 | value: a, 20 | message: 'Must be an object.' 21 | }) 22 | throw error // dont even bother continuing 23 | } 24 | if (!a.alias) { 25 | error.add({ 26 | path: [ ...context, 'alias' ], 27 | value: a.alias, 28 | message: 'Missing alias!' 29 | }) 30 | } else if (typeof a.alias !== 'string') { 31 | error.add({ 32 | path: [ ...context, 'alias' ], 33 | value: a.alias, 34 | message: 'Must be a string.' 35 | }) 36 | } 37 | 38 | if (a.name && typeof a.name !== 'string') { 39 | error.add({ 40 | path: [ ...context, 'name' ], 41 | value: a.name, 42 | message: 'Must be a string.' 43 | }) 44 | } 45 | 46 | if (a.notes && typeof a.notes !== 'string') { 47 | error.add({ 48 | path: [ ...context, 'notes' ], 49 | value: a.notes, 50 | message: 'Must be a string.' 51 | }) 52 | } 53 | 54 | if (typeof a.alias === 'string') { 55 | if (a.alias.length > MAX_LENGTH) error.add({ value: a.alias, path: [ ...context, 'alias' ], message: `Must be less than ${MAX_LENGTH} characters` }) 56 | if (alphanumPlus.test(a.alias)) error.add({ value: a.alias, path: [ ...context, 'alias' ], message: 'Must be alphanumeric, _, or -' }) 57 | } 58 | if (typeof a.name === 'string' && a.name.length > MAX_LENGTH) error.add({ value: a.name, path: [ ...context, 'name' ], message: `Must be less than ${MAX_LENGTH} characters` }) 59 | if (typeof a.notes === 'string' && a.notes.length > MAX_NOTES_LENGTH) error.add({ value: a.notes, path: [ ...context, 'notes' ], message: `Must be less than ${MAX_LENGTH} characters` }) 60 | 61 | if (!a.value) { 62 | error.add({ 63 | path: [ ...context, 'value' ], 64 | value: a.value, 65 | message: 'Missing value!' 66 | }) 67 | throw error // dont even bother continuing 68 | } 69 | 70 | try { 71 | agg = new QueryValue(a.value, { 72 | ...opt, 73 | context: [ ...context, 'value' ] 74 | }).value() 75 | } catch (err) { 76 | error.add(err) 77 | } 78 | 79 | if (a.filters && !isObject(a.filters) && !Array.isArray(a.filters)) { 80 | error.add({ 81 | path: [ ...context, 'filters' ], 82 | value: a.filters, 83 | message: 'Must be an object or array.' 84 | }) 85 | } 86 | try { 87 | parsedFilters = a.filters && new Filter(a.filters, { 88 | ...opt, 89 | context: [ ...context, 'filters' ] 90 | }).value() 91 | } catch (err) { 92 | error.add(err) 93 | } 94 | if (!error.isEmpty()) throw error 95 | return [ 96 | parsedFilters 97 | ? aggregateWithFilter({ 98 | aggregation: agg, 99 | filters: parsedFilters, 100 | model, 101 | instanceQuery 102 | }) 103 | : agg, 104 | a.alias 105 | ] 106 | } 107 | -------------------------------------------------------------------------------- /src/Filter/index.js: -------------------------------------------------------------------------------- 1 | import parse from './parse' 2 | 3 | export default class Filter { 4 | constructor(obj, options = {}) { 5 | if (!obj) throw new Error('Missing value!') 6 | if (!options.model || !options.model.rawAttributes) throw new Error('Missing model!') 7 | this.input = obj 8 | this.options = options 9 | this._parsed = parse(obj, options) 10 | } 11 | value = () => this._parsed 12 | toJSON = () => this.input 13 | } 14 | -------------------------------------------------------------------------------- /src/Filter/parse.js: -------------------------------------------------------------------------------- 1 | import operators from '../operators' 2 | import { ValidationError } from '../errors' 3 | import isObject from 'is-plain-obj' 4 | import { unwrap, hydrate } from '../util/fixJSONFilters' 5 | import isQueryValue from '../util/isQueryValue' 6 | import QueryValue from '../QueryValue' 7 | 8 | const reserved = new Set(Object.keys(operators)) 9 | 10 | export default (obj, opt) => { 11 | const { context = [] } = opt 12 | const error = new ValidationError() 13 | // recursively walk a filter object and replace query values with the real thing 14 | const transformValues = (v, parent = '', idx) => { 15 | const ctx = idx != null ? [ ...context, idx ] : context 16 | if (isQueryValue(v)) { 17 | return new QueryValue(v, { 18 | ...opt, 19 | context: ctx, 20 | hydrateJSON: false // we do this later anyways 21 | }).value() 22 | } 23 | if (Array.isArray(v)) return v.map((i, idx) => transformValues(i, parent, idx)) 24 | if (isObject(v)) { 25 | return Object.keys(v).reduce((p, k) => { 26 | let fullPath 27 | // verify 28 | if (!reserved.has(k)) { 29 | fullPath = `${parent}${parent ? '.' : ''}${k}` 30 | try { 31 | new QueryValue({ field: fullPath }, { 32 | ...opt, 33 | context: ctx, 34 | hydrateJSON: false 35 | }) // performs the check, don't need the value 36 | } catch (err) { 37 | if (!err.fields) { 38 | error.add(err) 39 | } else { 40 | error.add(err.fields.map((e) => ({ 41 | ...e, 42 | path: [ ...ctx, ...fullPath.split('.') ] 43 | }))) 44 | } 45 | return p 46 | } 47 | } 48 | p[k] = transformValues(v[k], fullPath || parent, idx) 49 | return p 50 | }, {}) 51 | } 52 | return v 53 | } 54 | 55 | const transformed = transformValues(obj) 56 | // turn where object into string with fields hydrated 57 | if (!error.isEmpty()) throw error 58 | 59 | const out = hydrate(unwrap(transformed, opt), opt) 60 | if (!opt.joins) return out 61 | 62 | // run through all of our joins and fix those up too 63 | return Object.entries(opt.joins).reduce((acc, [ k, v ]) => 64 | hydrate(acc, { ...v, from: k !== 'parent' ? k : undefined }) 65 | , out) 66 | } 67 | -------------------------------------------------------------------------------- /src/Join/index.js: -------------------------------------------------------------------------------- 1 | import parse from './parse' 2 | 3 | export default class Join { 4 | constructor(obj, options = {}) { 5 | if (!obj) throw new Error('Missing value!') 6 | if (!options.model || !options.model.rawAttributes) throw new Error('Missing model!') 7 | this.input = obj 8 | this.options = options 9 | this._parsed = parse(obj, options) 10 | } 11 | value = () => this._parsed 12 | toJSON = () => this.input 13 | } 14 | -------------------------------------------------------------------------------- /src/Join/parse.js: -------------------------------------------------------------------------------- 1 | import isObject from 'is-plain-obj' 2 | import Query from '../Query' 3 | import { ValidationError } from '../errors' 4 | 5 | const MAX_LENGTH = 64 6 | const MAX_NOTES_LENGTH = 1024 7 | const alphanumPlus = /[^\w-]/i 8 | 9 | export default (a, opt) => { 10 | const { joins, context = [] } = opt 11 | const error = new ValidationError() 12 | 13 | if (!isObject(a)) { 14 | error.add({ 15 | path: context, 16 | value: a, 17 | message: 'Must be an object.' 18 | }) 19 | throw error // dont even bother continuing 20 | } 21 | if (!a.alias) { 22 | error.add({ 23 | path: [ ...context, 'alias' ], 24 | value: a.alias, 25 | message: 'Missing alias!' 26 | }) 27 | } else if (typeof a.alias !== 'string') { 28 | error.add({ 29 | path: [ ...context, 'alias' ], 30 | value: a.alias, 31 | message: 'Must be a string.' 32 | }) 33 | } else if (!joins[a.alias]) { 34 | error.add({ 35 | path: [ ...context, 'alias' ], 36 | value: a.alias, 37 | message: 'Must be a defined join!' 38 | }) 39 | } 40 | 41 | if (a.name && typeof a.name !== 'string') { 42 | error.add({ 43 | path: [ ...context, 'name' ], 44 | value: a.name, 45 | message: 'Must be a string.' 46 | }) 47 | } 48 | 49 | if (a.notes && typeof a.notes !== 'string') { 50 | error.add({ 51 | path: [ ...context, 'notes' ], 52 | value: a.notes, 53 | message: 'Must be a string.' 54 | }) 55 | } 56 | 57 | if (typeof a.alias === 'string') { 58 | if (a.alias.length > MAX_LENGTH) error.add({ value: a.alias, path: [ ...context, 'alias' ], message: `Must be less than ${MAX_LENGTH} characters` }) 59 | if (alphanumPlus.test(a.alias)) error.add({ value: a.alias, path: [ ...context, 'alias' ], message: 'Must be alphanumeric, _, or -' }) 60 | } 61 | if (typeof a.name === 'string' && a.name.length > MAX_LENGTH) error.add({ value: a.name, path: [ ...context, 'name' ], message: `Must be less than ${MAX_LENGTH} characters` }) 62 | if (typeof a.notes === 'string' && a.notes.length > MAX_NOTES_LENGTH) error.add({ value: a.notes, path: [ ...context, 'notes' ], message: `Must be less than ${MAX_LENGTH} characters` }) 63 | 64 | if (!error.isEmpty()) throw error 65 | 66 | const joinConfig = joins[a.alias] 67 | if (!joinConfig.model || !joinConfig.model.rawAttributes) throw new Error(`Missing model for join ${a.alias}!`) 68 | 69 | let query 70 | try { 71 | query = new Query(a, { 72 | context, 73 | ...joinConfig, 74 | from: a.alias, 75 | joins: { 76 | parent: { 77 | fieldLimit: opt.fieldLimit, 78 | model: opt.model, 79 | subSchemas: opt.subSchemas 80 | } 81 | } 82 | }) 83 | } catch (err) { 84 | error.add(err) 85 | } 86 | 87 | if (!error.isEmpty()) throw error 88 | 89 | return { 90 | ...joinConfig, 91 | required: a.required, 92 | alias: a.alias, 93 | where: query.value().where 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Ordering/index.js: -------------------------------------------------------------------------------- 1 | import parse from './parse' 2 | 3 | export default class Ordering { 4 | constructor(obj, options = {}) { 5 | if (!obj) throw new Error('Missing value!') 6 | if (!options.model || !options.model.rawAttributes) throw new Error('Missing model!') 7 | this.input = obj 8 | this.options = options 9 | this._parsed = parse(obj, options) 10 | } 11 | value = () => this._parsed 12 | toJSON = () => this.input 13 | } 14 | -------------------------------------------------------------------------------- /src/Ordering/parse.js: -------------------------------------------------------------------------------- 1 | import QueryValue from '../QueryValue' 2 | import { ValidationError } from '../errors' 3 | 4 | export default ({ value, direction } = {}, opt) => { 5 | const error = new ValidationError() 6 | let out 7 | const { context = [] } = opt 8 | const isDirectionValid = direction === 'asc' || direction === 'desc' 9 | if (!value) { 10 | error.add({ 11 | path: [ ...context, 'value' ], 12 | value, 13 | message: 'Missing ordering value.' 14 | }) 15 | } 16 | if (!direction) { 17 | error.add({ 18 | path: [ ...context, 'direction' ], 19 | value: direction, 20 | message: 'Missing ordering direction.' 21 | }) 22 | } 23 | if (direction != null && !isDirectionValid) { 24 | error.add({ 25 | path: [ ...context, 'direction' ], 26 | value: direction, 27 | message: 'Invalid ordering direction - must be asc or desc.' 28 | }) 29 | } 30 | 31 | if (direction && value && isDirectionValid) { 32 | try { 33 | out = [ 34 | new QueryValue(value, { 35 | ...opt, 36 | context: [ ...context, 'value' ] 37 | }).value(), 38 | direction 39 | ] 40 | } catch (err) { 41 | error.add(err) 42 | } 43 | } 44 | 45 | if (!error.isEmpty()) throw error 46 | return out 47 | } 48 | -------------------------------------------------------------------------------- /src/QueryValue/index.js: -------------------------------------------------------------------------------- 1 | import parse from './parse' 2 | 3 | export default class QueryValue { 4 | constructor(obj, options = {}) { 5 | if (!obj) throw new Error('Missing value!') 6 | if (!options.model || !options.model.rawAttributes) throw new Error('Missing model!') 7 | this.input = obj 8 | this.options = options 9 | this._parsed = parse(obj, options) 10 | } 11 | value = () => this._parsed 12 | toJSON = () => this.input 13 | } 14 | -------------------------------------------------------------------------------- /src/connect.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-magic-numbers */ 2 | import pg from 'pg' 3 | import Sequelize from 'sequelize' 4 | import { plural, singular } from 'pluralize' 5 | import { underscore } from 'inflection' 6 | import operators from './operators' 7 | 8 | const alignTypeParser = (conn, id) => { 9 | const parser = pg.types.getTypeParser(id, 'text') 10 | // sequelize 5+ 11 | if (conn.connectionManager.oidParserMap) { 12 | conn.connectionManager.oidParserMap.set(id, parser) 13 | return conn 14 | } 15 | // sequelize 4 16 | conn.connectionManager.oidMap[id] = parser 17 | return conn 18 | } 19 | 20 | const defaultOptions = { 21 | logging: false, 22 | native: false, 23 | operatorsAliases: operators, 24 | timezone: 'UTC' 25 | } 26 | export default (url, opt = {}, Instance = Sequelize) => { 27 | // fix issues with pg types 28 | pg.types.setTypeParser(20, 'text', pg.types.getTypeParser(23, 'text')) // bigint = int 29 | pg.types.setTypeParser(1016, 'text', pg.types.getTypeParser(1007, 'text')) // bigint[] = int[] 30 | pg.types.setTypeParser(1700, 'text', pg.types.getTypeParser(701, 'text')) // numeric = float8 31 | pg.types.setTypeParser(1231, 'text', pg.types.getTypeParser(1022, 'text')) // numeric[] = float8[] 32 | 33 | // fix bugs with sequelize 34 | Sequelize.useInflection({ 35 | pluralize: plural, 36 | singularize: singular, 37 | underscore 38 | }) 39 | // See https://github.com/sequelize/sequelize/issues/1500 40 | Sequelize.Validator.notNull = function (item) { 41 | return !this.isNull(item) 42 | } 43 | // you can override Instance if you use sequelize-typescript 44 | const conn = typeof url === 'object' 45 | ? new Instance({ 46 | ...defaultOptions, 47 | ...url 48 | }) 49 | : new Instance(url, { 50 | ...defaultOptions, 51 | ...opt 52 | }) 53 | 54 | // fix sequelize types overriding pg-types 55 | const override = () => { 56 | alignTypeParser(conn, 20) // bigint 57 | alignTypeParser(conn, 1016) // bigint[] 58 | alignTypeParser(conn, 1700) // numeric 59 | alignTypeParser(conn, 1231) // numeric[] 60 | } 61 | const oldRefresh = conn.connectionManager.refreshTypeParser.bind(conn.connectionManager) 62 | conn.connectionManager.refreshTypeParser = (...a) => { 63 | oldRefresh(...a) 64 | override() 65 | } 66 | override() 67 | return conn 68 | } 69 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util' 2 | 3 | const inspectOptions = { 4 | depth: 100, 5 | breakLength: Infinity 6 | } 7 | 8 | const serializeIssues = (fields) => 9 | fields.map((f) => `\n - ${inspect(f, inspectOptions)}`) 10 | 11 | export class BadRequestError extends Error { 12 | name = 'BadRequestError' 13 | constructor(message = 'Bad Request', status = 400) { 14 | super(message) 15 | this.status = status 16 | Error.captureStackTrace(this, BadRequestError) 17 | } 18 | toString = () => 19 | `${super.toString()} (HTTP ${this.status})` 20 | } 21 | 22 | export class ValidationError extends BadRequestError { 23 | name = 'ValidationError' 24 | constructor(fields = []) { 25 | super('Validation Error') 26 | this.fields = [] 27 | if (fields) this.add(fields) 28 | Error.captureStackTrace(this, ValidationError) 29 | } 30 | add = (err) => { 31 | if (!err) return this // nothing to do 32 | if (err.fields) { 33 | this.fields.push(...err.fields) 34 | } else if (err instanceof Error) { 35 | throw err 36 | } else if (Array.isArray(err)) { 37 | this.fields.push(...err) 38 | } else { 39 | this.fields.push(err) 40 | } 41 | this.message = this.toString() // update msg 42 | 43 | if (err.stack) { 44 | this.stack = err.stack 45 | } else { 46 | Error.captureStackTrace(this, this.add) 47 | } 48 | return this 49 | } 50 | removePath = (path) => { 51 | this.fields = this.fields.filter((f) => 52 | !f.path || !path.every((p, idx) => f.path[idx] === p) 53 | ) 54 | this.message = this.toString() // update msg 55 | } 56 | isEmpty = () => 57 | this.fields.length === 0 58 | 59 | toString = () => { 60 | const original = 'Error: Validation Error' 61 | if (this.isEmpty()) return original // no custom validation 62 | return `${original}\nIssues:${serializeIssues(this.fields)}` 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import connect from './connect' 2 | import setup from './sql' 3 | import operators from './operators' 4 | import * as functions from './types/functions' 5 | import * as types from './types' 6 | import getTypes from './types/getTypes' 7 | import AnalyticsQuery from './AnalyticsQuery' 8 | import Query from './Query' 9 | import QueryValue from './QueryValue' 10 | import Ordering from './Ordering' 11 | import Filter from './Filter' 12 | import Aggregation from './Aggregation' 13 | 14 | export { 15 | connect, setup, 16 | operators, functions, types, getTypes, 17 | Query, AnalyticsQuery, 18 | Filter, Ordering, 19 | Aggregation, QueryValue 20 | } 21 | -------------------------------------------------------------------------------- /src/operators.js: -------------------------------------------------------------------------------- 1 | import { Op } from 'sequelize' 2 | 3 | export default { 4 | $eq: Op.eq, 5 | $ne: Op.ne, 6 | $gte: Op.gte, 7 | $gt: Op.gt, 8 | $lte: Op.lte, 9 | $lt: Op.lt, 10 | $not: Op.not, 11 | $in: Op.in, 12 | $notIn: Op.notIn, 13 | $is: Op.is, 14 | $like: Op.like, 15 | $notLike: Op.notLike, 16 | $iLike: Op.iLike, 17 | $notILike: Op.notILike, 18 | $regexp: Op.regexp, 19 | $notRegexp: Op.notRegexp, 20 | $iRegexp: Op.iRegexp, 21 | $notIRegexp: Op.notIRegexp, 22 | $between: Op.between, 23 | $notBetween: Op.notBetween, 24 | $overlap: Op.overlap, 25 | $contains: Op.contains, 26 | $contained: Op.contained, 27 | $adjacent: Op.adjacent, 28 | $strictLeft: Op.strictLeft, 29 | $strictRight: Op.strictRight, 30 | $noExtendRight: Op.noExtendRight, 31 | $noExtendLeft: Op.noExtendLeft, 32 | $and: Op.and, 33 | $or: Op.or, 34 | $any: Op.any, 35 | $all: Op.all, 36 | $values: Op.values 37 | } 38 | -------------------------------------------------------------------------------- /src/sql/geospatial.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION from_geojson(p_input text) RETURNS geometry AS $$ 2 | SELECT ST_MakeValid(ST_SetSRID(ST_GeomFromGeoJSON(p_input), 4326)); 3 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 4 | RETURNS NULL ON NULL INPUT; 5 | 6 | CREATE OR REPLACE FUNCTION from_geojson(p_input jsonb) RETURNS geometry AS $$ 7 | SELECT ST_MakeValid(ST_SetSRID(ST_GeomFromGeoJSON(p_input), 4326)); 8 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 9 | RETURNS NULL ON NULL INPUT; 10 | 11 | CREATE OR REPLACE FUNCTION from_geojson_collection(p_input text) RETURNS geometry AS $$ 12 | SELECT ST_SetSRID(ST_Union(from_geojson(feat->'geometry')), 4326) 13 | FROM (SELECT jsonb_array_elements(p_input::jsonb->'features') AS feat) AS f; 14 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 15 | RETURNS NULL ON NULL INPUT; 16 | 17 | CREATE OR REPLACE FUNCTION from_geojson_collection(p_input jsonb) RETURNS geometry AS $$ 18 | SELECT ST_SetSRID(ST_Union(from_geojson(feat->'geometry')), 4326) 19 | FROM (SELECT jsonb_array_elements(p_input->'features') AS feat) AS f; 20 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 21 | RETURNS NULL ON NULL INPUT; 22 | 23 | CREATE OR REPLACE FUNCTION get_features_from_feature_collection(p_input jsonb) RETURNS TABLE("geometry" geography, "properties" jsonb) AS $$ 24 | SELECT 25 | from_geojson("feature"->'geometry') AS "geometry", 26 | "feature"->'properties' as "properties" 27 | FROM jsonb_array_elements(p_input->'features') AS "feature"; 28 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 29 | RETURNS NULL ON NULL INPUT; 30 | 31 | -------------------------------------------------------------------------------- /src/sql/index.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'graceful-fs' 2 | import { join } from 'path' 3 | 4 | export const groups = [ 5 | 'misc', 6 | 'math', 7 | 'json', 8 | 'time', 9 | 'geospatial', 10 | 'custom-year' 11 | ].map((name) => ({ 12 | name, 13 | sql: readFileSync(join(__dirname, `./${name}.sql`), 'utf8') 14 | })) 15 | 16 | export default async (conn) => { 17 | //const [ [ hasPostGIS ] ] = await conn.query(`SELECT * FROM pg_extension WHERE "extname" = 'postgis'`) 18 | const all = groups.reduce((p, group) => 19 | //if (group.name === 'geospatial' && !hasPostGIS) return p // skip geo stuff if they dont have postgis 20 | `${p}-- ${group.name}\n${group.sql}\n` 21 | , '') 22 | 23 | return conn.query(all, { useMaster: true }) 24 | } 25 | -------------------------------------------------------------------------------- /src/sql/json.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION jsonb_array_to_text_array(p_input jsonb) RETURNS text[] AS $$ 2 | SELECT array_agg(x) FROM jsonb_array_elements_text(p_input) t(x); 3 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 4 | RETURNS NULL ON NULL INPUT; 5 | 6 | CREATE OR REPLACE FUNCTION json_array_to_text_array(p_input json) RETURNS text[] AS $$ 7 | SELECT array_agg(x) FROM json_array_elements_text(p_input) t(x); 8 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 9 | RETURNS NULL ON NULL INPUT; 10 | 11 | CREATE OR REPLACE FUNCTION fix_jsonb_array(p_input text) RETURNS text[] AS $$ 12 | SELECT null_if_empty_array(jsonb_array_to_text_array(p_input::jsonb)) 13 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 14 | RETURNS NULL ON NULL INPUT; 15 | 16 | CREATE OR REPLACE FUNCTION fix_json_array(p_input text) RETURNS text[] AS $$ 17 | SELECT null_if_empty_array(json_array_to_text_array(p_input::json)) 18 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 19 | RETURNS NULL ON NULL INPUT; 20 | 21 | 22 | CREATE OR REPLACE FUNCTION jsonb_diff(val1 JSONB, val2 JSONB) RETURNS JSONB AS $$ 23 | DECLARE 24 | result JSONB; 25 | object_result JSONB; 26 | i int; 27 | v RECORD; 28 | BEGIN 29 | IF jsonb_typeof(val1) = 'null' 30 | THEN 31 | RETURN val2; 32 | END IF; 33 | 34 | result = val1; 35 | FOR v IN SELECT * FROM jsonb_each(val1) LOOP 36 | result = result || jsonb_build_object(v.key, null); 37 | END LOOP; 38 | 39 | FOR v IN SELECT * FROM jsonb_each(val2) LOOP 40 | -- if both fields are objects, recurse to get deep diff 41 | IF jsonb_typeof(val1->v.key) = 'object' AND jsonb_typeof(val2->v.key) = 'object' 42 | THEN 43 | object_result = jsonb_diff_val(val1->v.key, val2->v.key); 44 | -- check if result is not empty 45 | IF object_result = '{}'::jsonb THEN 46 | result = result - v.key; --if empty remove 47 | ELSE 48 | result = result || jsonb_build_object(v.key, object_result); 49 | END IF; 50 | -- if they are equal, remove the key 51 | ELSIF val1->v.key = val2->v.key THEN 52 | result = result - v.key; 53 | -- if they are different, add to the diff 54 | ELSE 55 | result = result || jsonb_build_object(v.key, v.value); 56 | END IF; 57 | END LOOP; 58 | RETURN result; 59 | END; 60 | $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE; 61 | 62 | CREATE OR REPLACE FUNCTION jsonb_merge(a jsonb, b jsonb) RETURNS JSONB AS $$ 63 | select 64 | jsonb_object_agg( 65 | coalesce(ka, kb), 66 | case 67 | when va isnull then vb 68 | when vb isnull then va 69 | when jsonb_typeof(va) <> 'object' or jsonb_typeof(vb) <> 'object' then vb 70 | else jsonb_merge(va, vb) end 71 | ) 72 | from jsonb_each(a) e1(ka, va) 73 | full join jsonb_each(b) e2(kb, vb) on ka = kb 74 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE; 75 | 76 | 77 | CREATE OR REPLACE FUNCTION get_label_from_json(p_input jsonb) RETURNS text AS $$ 78 | SELECT coalesce(p_input->>'name', p_input->>'type', p_input->>'id', NULL); 79 | $$ 80 | LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT; 81 | -------------------------------------------------------------------------------- /src/sql/math.sql: -------------------------------------------------------------------------------- 1 | -- From: https://wiki.postgresql.org/wiki/Aggregate_Median 2 | CREATE OR REPLACE FUNCTION _final_median(numeric[]) 3 | RETURNS numeric AS 4 | $$ 5 | SELECT AVG(val) 6 | FROM ( 7 | SELECT val 8 | FROM unnest($1) val 9 | ORDER BY 1 10 | LIMIT 2 - MOD(array_upper($1, 1), 2) 11 | OFFSET CEIL(array_upper($1, 1) / 2.0) - 1 12 | ) sub; 13 | $$ 14 | LANGUAGE 'sql' IMMUTABLE; 15 | 16 | DROP AGGREGATE IF EXISTS median(anyelement); 17 | DROP AGGREGATE IF EXISTS median(numeric); 18 | CREATE AGGREGATE median(numeric) ( 19 | SFUNC=array_append, 20 | STYPE=numeric[], 21 | FINALFUNC=_final_median, 22 | INITCOND='{}' 23 | ); 24 | 25 | CREATE OR REPLACE FUNCTION add(a numeric, b numeric) RETURNS numeric AS $$ 26 | SELECT a + b; 27 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 28 | RETURNS NULL ON NULL INPUT; 29 | 30 | CREATE OR REPLACE FUNCTION subtract(a numeric, b numeric) RETURNS numeric AS $$ 31 | SELECT a - b; 32 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 33 | RETURNS NULL ON NULL INPUT; 34 | 35 | CREATE OR REPLACE FUNCTION multiply(a numeric, b numeric) RETURNS numeric AS $$ 36 | SELECT a * b; 37 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 38 | RETURNS NULL ON NULL INPUT; 39 | 40 | CREATE OR REPLACE FUNCTION divide(a numeric, b numeric) RETURNS numeric AS $$ 41 | SELECT a / NULLIF(b, 0); 42 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 43 | RETURNS NULL ON NULL INPUT; 44 | 45 | CREATE OR REPLACE FUNCTION modulus(a numeric, b numeric) RETURNS numeric AS $$ 46 | SELECT a % NULLIF(b, 0); 47 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 48 | RETURNS NULL ON NULL INPUT; 49 | 50 | CREATE OR REPLACE FUNCTION round(a numeric) RETURNS numeric AS $$ 51 | SELECT round(a); 52 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 53 | RETURNS NULL ON NULL INPUT; 54 | 55 | CREATE OR REPLACE FUNCTION gt(a numeric, b numeric) RETURNS boolean AS $$ 56 | SELECT a > b; 57 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 58 | RETURNS NULL ON NULL INPUT; 59 | 60 | CREATE OR REPLACE FUNCTION gte(a numeric, b numeric) RETURNS boolean AS $$ 61 | SELECT a >= b; 62 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 63 | RETURNS NULL ON NULL INPUT; 64 | 65 | CREATE OR REPLACE FUNCTION lt(a numeric, b numeric) RETURNS boolean AS $$ 66 | SELECT a < b; 67 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 68 | RETURNS NULL ON NULL INPUT; 69 | 70 | CREATE OR REPLACE FUNCTION lte(a numeric, b numeric) RETURNS boolean AS $$ 71 | SELECT a <= b; 72 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 73 | RETURNS NULL ON NULL INPUT; 74 | 75 | CREATE OR REPLACE FUNCTION eq(a numeric, b numeric) RETURNS boolean AS $$ 76 | SELECT a = b; 77 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 78 | RETURNS NULL ON NULL INPUT; 79 | 80 | CREATE OR REPLACE FUNCTION ne(a numeric, b numeric) RETURNS boolean AS $$ 81 | SELECT a != b; 82 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 83 | RETURNS NULL ON NULL INPUT; 84 | -------------------------------------------------------------------------------- /src/sql/misc.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION null_if_empty_array(a text[]) RETURNS text[] AS $$ 2 | SELECT NULLIF(a, ARRAY[]::text[]); 3 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 4 | RETURNS NULL ON NULL INPUT; 5 | 6 | CREATE OR REPLACE FUNCTION dist_sum_func(numeric, anyelement, numeric) RETURNS numeric AS $$ 7 | SELECT CASE WHEN $3 IS NOT NULL THEN COALESCE($1, 0) + $3 ELSE $1 END; 8 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE; 9 | 10 | CREATE OR REPLACE AGGREGATE dist_sum(anyelement, numeric) ( 11 | SFUNC = dist_sum_func, 12 | STYPE = numeric 13 | ); 14 | -------------------------------------------------------------------------------- /src/sql/time.sql: -------------------------------------------------------------------------------- 1 | -- date_part has a different type sig on 12 and 13 so just make it numeric everywhere 2 | -- on 13 its integer, 12 its double precision 3 | CREATE OR REPLACE FUNCTION force_tz(base_date timestamptz, tz text) RETURNS timestamp AS $$ 4 | SELECT base_date AT TIME ZONE tz; 5 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 6 | RETURNS NULL ON NULL INPUT; 7 | 8 | CREATE OR REPLACE FUNCTION time_to_ms(a timestamptz) RETURNS numeric AS $$ 9 | SELECT date_part('epoch', a)::numeric * 1000; 10 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 11 | RETURNS NULL ON NULL INPUT; 12 | 13 | CREATE OR REPLACE FUNCTION time_to_ms(a timestamp) RETURNS numeric AS $$ 14 | SELECT date_part('epoch', a)::numeric * 1000; 15 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 16 | RETURNS NULL ON NULL INPUT; 17 | 18 | CREATE OR REPLACE FUNCTION parse_iso(a text) RETURNS timestamptz AS $$ 19 | SELECT to_timestamp(a, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'); 20 | $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE 21 | RETURNS NULL ON NULL INPUT; 22 | -------------------------------------------------------------------------------- /src/types/getTypes.js: -------------------------------------------------------------------------------- 1 | import { sortBy, pickBy } from 'lodash' 2 | import isQueryValue from '../util/isQueryValue' 3 | import * as schemaTypes from './' 4 | import * as functions from './functions' 5 | import toSchemaType from './toSchemaType' 6 | 7 | const getValueTypes = (v) => 8 | sortBy(Object.entries(schemaTypes).reduce((prev, [ type, desc ]) => { 9 | if (!desc || typeof desc.test !== 'function') return prev 10 | if (desc.test(v) === true) prev.push({ type }) 11 | return prev 12 | }, [])) 13 | 14 | const getJSONTypes = (fieldPath, { model, subSchemas }) => { 15 | const path = fieldPath.split('.') 16 | const col = path.shift() 17 | const colInfo = model.rawAttributes[col] 18 | if (!colInfo) return [] 19 | const schema = subSchemas?.[col] || colInfo.subSchema 20 | if (!schema) return [] 21 | const field = path[0] 22 | const attrDef = schema[field] 23 | if (!attrDef) return [] 24 | const desc = schemaTypes[attrDef.type] 25 | if (!desc) return [] 26 | return [ pickBy({ 27 | type: attrDef.type, 28 | measurement: attrDef.measurement, 29 | items: attrDef.items, 30 | validation: attrDef.validation 31 | }) ] 32 | } 33 | 34 | const getJoinTypes = (fieldPath, { joins }) => { 35 | const [ join, ...rest ] = fieldPath.split('.') 36 | return getPlainFieldTypes(rest.join('.'), joins?.[join.replace('~', '')]) 37 | } 38 | 39 | const getFieldTypes = (fieldPath, { model, subSchemas }) => { 40 | const desc = model.rawAttributes[fieldPath] 41 | if (!desc) return [] 42 | const schemaType = pickBy({ 43 | ...toSchemaType(desc.type, subSchemas?.[fieldPath]), 44 | name: desc.name, 45 | notes: desc.notes 46 | }) 47 | return schemaType ? [ schemaType ] : [] 48 | } 49 | 50 | const getPlainFieldTypes = (fieldPath, opt) => 51 | fieldPath.includes('.') 52 | ? getJSONTypes(fieldPath, opt) 53 | : getFieldTypes(fieldPath, opt) 54 | 55 | // return empty on any invalid condition, `parse` will handle main validation before this function is called 56 | const getTypes = (v, opt = {}) => { 57 | if (!isQueryValue(v)) return getValueTypes(v) 58 | if (v.function) { 59 | const fn = functions[v.function] 60 | if (!fn) return [] 61 | // dynamic return type based on inputs 62 | if (typeof fn.returns.dynamic === 'function') { 63 | const sigArgs = fn.signature || [] 64 | const args = v.arguments || [] 65 | const resolvedArgs = sigArgs.map((sig, idx) => { 66 | const nopt = { 67 | ...opt, 68 | context: [ 69 | ...opt.context || [], 70 | 'arguments', idx 71 | ] 72 | } 73 | const argValue = args[idx] 74 | return { 75 | types: getTypes(argValue, nopt), 76 | raw: argValue 77 | } 78 | }) 79 | const nv = pickBy(fn.returns.dynamic(resolvedArgs, opt)) 80 | return Array.isArray(nv) ? pickBy(nv) : [ nv ] 81 | } 82 | return Array.isArray(fn.returns.static) 83 | ? fn.returns.static 84 | : [ fn.returns.static ] 85 | } 86 | if (v.field) { 87 | if (typeof v.field !== 'string') return [] 88 | return v.field.startsWith('~') 89 | ? getJoinTypes(v.field, opt) 90 | : getPlainFieldTypes(v.field, opt) 91 | } 92 | return [] 93 | } 94 | 95 | export default getTypes 96 | -------------------------------------------------------------------------------- /src/types/index.js: -------------------------------------------------------------------------------- 1 | import sql from 'sequelize' 2 | import isNumber from 'is-number' 3 | import { types } from 'human-schema' 4 | 5 | const wgs84 = 4326 6 | const geoCast = (txt) => 7 | sql.fn('ST_SetSRID', sql.fn('ST_GeomFromGeoJSON', txt), wgs84) 8 | 9 | // Extend human-schema types and: 10 | // - add a hydrate function to go from db text values -> properly typed values 11 | // - make some types more permissive, since queries are often passed in via querystring 12 | 13 | export const array = { 14 | ...types.array, 15 | // TODO: recursively map the array against the right types 16 | // this treats everything as a text array 17 | // probably need to pass in type and let the db figure out hydrating 18 | hydrate: (txt) => sql.fn('fix_jsonb_array', txt) 19 | } 20 | export const object = { 21 | ...types.object, 22 | hydrate: (txt) => sql.cast(txt, 'jsonb') 23 | } 24 | export const text = { 25 | ...types.text, 26 | hydrate: (txt) => txt 27 | } 28 | export const number = { 29 | ...types.number, 30 | test: isNumber, 31 | hydrate: (txt) => sql.cast(txt, 'numeric') 32 | } 33 | export const boolean = { 34 | ...types.boolean, 35 | hydrate: (txt) => sql.cast(txt, 'boolean') 36 | } 37 | export const date = { 38 | ...types.date, 39 | hydrate: (txt) => sql.fn('parse_iso', txt) 40 | } 41 | export const point = { 42 | ...types.point, 43 | hydrate: geoCast 44 | } 45 | export const line = { 46 | ...types.line, 47 | hydrate: geoCast 48 | } 49 | export const multiline = { 50 | ...types.multiline, 51 | hydrate: geoCast 52 | } 53 | export const polygon = { 54 | ...types.polygon, 55 | hydrate: geoCast 56 | } 57 | export const multipolygon = { 58 | ...types.multipolygon, 59 | hydrate: geoCast 60 | } 61 | -------------------------------------------------------------------------------- /src/types/toSchemaType.js: -------------------------------------------------------------------------------- 1 | // converts sequelize types to subSchema types 2 | const geomTypes = { 3 | point: 'point', 4 | linestring: 'line', 5 | multilinestring: 'multiline', 6 | polygon: 'polygon', 7 | multipolygon: 'multipolygon' 8 | } 9 | const toSchemaType = (type, subSchema) => { 10 | const key = type.key || type.constructor.key 11 | if (key === 'STRING') return { type: 'text' } 12 | if (key === 'TEXT') return { type: 'text' } 13 | if (key === 'UUID') return { type: 'text' } 14 | if (key === 'CITEXT') return { type: 'text' } 15 | if (key === 'CHAR') return { type: 'text' } 16 | if (key === 'DATE') return { type: 'date' } 17 | if (key === 'DATEONLY') return { type: 'date' } 18 | if (key === 'BOOLEAN') return { type: 'boolean' } 19 | if (key === 'INTEGER') return { type: 'number' } 20 | if (key === 'TINYINT') return { type: 'number' } 21 | if (key === 'SMALLINT') return { type: 'number' } 22 | if (key === 'BIGINT') return { type: 'number' } 23 | if (key === 'FLOAT') return { type: 'number' } 24 | if (key === 'REAL') return { type: 'number' } 25 | if (key === 'DOUBLE PRECISION') return { type: 'number' } 26 | if (key === 'DECIMAL') return { type: 'number' } 27 | if (key === 'JSON') return { type: 'object', schema: subSchema } 28 | if (key === 'JSONB') return { type: 'object', schema: subSchema } 29 | if (key === 'ARRAY') return { type: 'array', items: toSchemaType(type.type) } 30 | if (key === 'GEOMETRY' || key === 'GEOGRAPHY') { 31 | const subtype = type.type?.toLowerCase() 32 | if (geomTypes[subtype]) return { type: geomTypes[subtype] } 33 | return { type: 'geometry' } 34 | } 35 | 36 | // Unsupported types: ENUM, BLOB, CIDR, INET, MACADDR, RANGE, HSTORE 37 | return null 38 | } 39 | 40 | export default toSchemaType 41 | -------------------------------------------------------------------------------- /src/util/aggregateWithFilter.js: -------------------------------------------------------------------------------- 1 | import sql from 'sequelize' 2 | import { BadRequestError } from '../errors' 3 | import { where, value } from './toString' 4 | 5 | export default ({ aggregation, filters, model, instanceQuery }) => { 6 | if (!filters) throw new BadRequestError('Missing filters') 7 | if (!aggregation) throw new BadRequestError('Missing aggregation') 8 | 9 | const query = where({ value: filters, model, instanceQuery }) 10 | const agg = value({ value: aggregation, model, instanceQuery }) 11 | return sql.literal(`${agg} FILTER (WHERE ${query})`) 12 | } 13 | -------------------------------------------------------------------------------- /src/util/export.js: -------------------------------------------------------------------------------- 1 | import QueryStream from 'pg-query-stream' 2 | import { pipeline, Transform, finished } from 'stream' 3 | import { select } from './toString' 4 | 5 | // this wraps a sql query in a stream via a cursor so as each row is found 6 | // it gets transformed and emitted from the stream 7 | // this is how you want to return millions of rows with low memory overhead 8 | const batchSize = 16 9 | const streamable = async ({ useMaster, model, sql, transform, timeout, finishTimeout, debug, tupleFraction, onError }) => { 10 | const conn = await model.sequelize.connectionManager.getConnection({ 11 | useMaster, 12 | type: 'SELECT' 13 | }) 14 | const warm = [] 15 | if (timeout) warm.push(`SET idle_in_transaction_session_timeout = ${parseInt(timeout)};`) 16 | if (finishTimeout) warm.push(`SET statement_timeout = ${parseInt(finishTimeout)};`) 17 | if (typeof tupleFraction === 'number') warm.push(`SET cursor_tuple_fraction=${tupleFraction};`) 18 | 19 | if (warm.length > 0) { 20 | await conn.query(warm.join('\n')) 21 | } 22 | // a not so fun hack to tie our sequelize types into this raw cursor 23 | let out 24 | if (debug) debug(sql) 25 | const query = conn.query(new QueryStream(sql, undefined, { 26 | batchSize, 27 | types: { 28 | getTypeParser: conn.getTypeParser.bind(conn) 29 | } 30 | })) 31 | 32 | const end = (err) => { 33 | if (err && onError) onError(err) 34 | if (err) out.emit('error', err) 35 | 36 | // clean up the connection 37 | query.destroy(null, (err) => { 38 | if (err && onError) onError(err) 39 | model.sequelize.connectionManager.releaseConnection(conn) 40 | }) 41 | } 42 | if (transform) { 43 | out = pipeline( 44 | query, 45 | new Transform({ 46 | objectMode: true, 47 | transform(obj, _, cb) { 48 | cb(null, transform(obj)) 49 | } 50 | }), 51 | end 52 | ) 53 | } else { 54 | out = query 55 | finished(query, end) 56 | } 57 | return out 58 | } 59 | 60 | 61 | export default async ({ useMaster, model, value, format, transform, tupleFraction, debug, timeout, finishTimeout, onError, analytics = false }) => { 62 | const nv = { ...value } 63 | const sql = select({ value: nv, model, analytics }) 64 | const src = await streamable({ 65 | useMaster, 66 | model, 67 | tupleFraction, 68 | timeout, 69 | finishTimeout, 70 | debug, 71 | sql, 72 | transform, 73 | onError 74 | }) 75 | if (!format) return src 76 | const out = pipeline(src, format(), (err) => { 77 | if (err) out.emit('error', err) 78 | }) 79 | out.contentType = format.contentType 80 | return out 81 | } 82 | -------------------------------------------------------------------------------- /src/util/fixJSONFilters.js: -------------------------------------------------------------------------------- 1 | import sql from 'sequelize' 2 | import { where, value, identifier } from './toString' 3 | import getJSONField from './getJSONField' 4 | 5 | const jsonField = /"(\w*)"\."(\w*)"#>>'{(\w*)}'/ 6 | 7 | // sometimes sequelize randomly wraps json access in useless parens, so unwrap everything 8 | const wrapped = /\("(\w*)"\."(\w*)"#>>'{(\w*)}'\)/g 9 | export const unwrap = (v, opt) => { 10 | if (Array.isArray(v)) v = { $and: v } // convert it 11 | const str = where({ ...opt, value: v }) 12 | if (!jsonField.test(str)) return v // nothing to do! no fields to hydrate 13 | const redone = str.replace(wrapped, (match, table, col, field) => `"${table}"."${col}"#>>'{${field}}'`) 14 | return sql.literal(redone) 15 | } 16 | 17 | export const hydrate = (v, opt) => { 18 | if (Array.isArray(v)) v = { $and: v } // convert it 19 | const str = where({ ...opt, value: v }) 20 | if (!jsonField.test(str)) return v // nothing to do! no fields to hydrate 21 | 22 | const fixing = identifier({ ...opt, value: opt.from || opt.model.name }) 23 | 24 | // if the field is followed by " IS" then skip, because we dont need to hydrate that 25 | // since its either IS NULL or IS NOT NULL 26 | const needsCasting = new RegExp(`${fixing}\\."(\\w*)"#>>'{(\\w*)}'(?! (IS NULL|IS NOT NULL))`, 'g') 27 | const redone = str.replace(needsCasting, (match, col, field) => { 28 | const lit = getJSONField(`${col}.${field}`, opt) 29 | return value({ ...opt, value: lit }) 30 | }) 31 | return sql.literal(redone) 32 | } 33 | -------------------------------------------------------------------------------- /src/util/getGeoFields.js: -------------------------------------------------------------------------------- 1 | import sql from 'sequelize' 2 | 3 | export default (model) => { 4 | const attrs = model.rawAttributes 5 | const ret = Object.keys(attrs).filter((k) => { 6 | const { type } = attrs[k] 7 | return type instanceof sql.GEOGRAPHY || type instanceof sql.GEOMETRY 8 | }) 9 | 10 | return ret.length > 0 ? ret : null 11 | } 12 | -------------------------------------------------------------------------------- /src/util/getJSONField.js: -------------------------------------------------------------------------------- 1 | import sql from 'sequelize' 2 | import { jsonPath } from './toString' 3 | import * as schemaTypes from '../types' 4 | import { ValidationError } from '../errors' 5 | import getModelFieldLimit from './getModelFieldLimit' 6 | 7 | export default (v, opt) => { 8 | const { 9 | context = [], 10 | subSchemas = {}, 11 | model, 12 | fieldLimit = getModelFieldLimit(model), 13 | instanceQuery, 14 | from, 15 | hydrateJSON = true 16 | } = opt 17 | const path = v.split('.') 18 | const col = path.shift() 19 | const colInfo = model.rawAttributes[col] 20 | if (!colInfo || !fieldLimit.some((i) => i.field === col)) { 21 | throw new ValidationError({ 22 | path: context, 23 | value: v, 24 | message: `Field does not exist: ${col}` 25 | }) 26 | } 27 | if (!(colInfo.type instanceof sql.JSONB || colInfo.type instanceof sql.JSON)) { 28 | throw new ValidationError({ 29 | path: context, 30 | value: v, 31 | message: `Field is not JSON: ${col}` 32 | }) 33 | } 34 | const lit = sql.literal(jsonPath({ column: col, model, path, from, instanceQuery })) 35 | const schema = subSchemas[col] || colInfo.subSchema 36 | if (!schema) { 37 | // did not give sufficient info to query json objects safely! 38 | throw new ValidationError({ 39 | path: context, 40 | value: v, 41 | message: `Field is not queryable: ${col}` 42 | }) 43 | } 44 | if (!hydrateJSON) return lit // asked to keep it raw 45 | 46 | // if a schema is specified, check the type of the field to see if it needs hydrating 47 | // this is because pg treats all json values as text, so we need to explicitly hydrate types for things 48 | // to work the way we expect 49 | const field = path[0] 50 | const attrDef = schema[field] 51 | if (!attrDef) { 52 | throw new ValidationError({ 53 | path: context, 54 | value: v, 55 | message: `Field does not exist: ${col}.${field}` 56 | }) 57 | } 58 | return schemaTypes[attrDef.type].hydrate(lit) 59 | } 60 | -------------------------------------------------------------------------------- /src/util/getJoinField.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../errors' 2 | import QueryValue from '../QueryValue' 3 | 4 | export const parse = (v) => { 5 | const [ alias, ...rest ] = v.split('.') 6 | const joinKey = alias.replace('~', '') 7 | return { alias: joinKey, field: rest.join('.') } 8 | } 9 | 10 | export default (v, opt) => { 11 | const { joins, hydrateJSON, context = [] } = opt 12 | const { alias, field } = parse(v) 13 | const joinKey = alias.replace('~', '') 14 | const joinConfig = joins?.[joinKey] 15 | if (!joinConfig) { 16 | throw new ValidationError({ 17 | path: context, 18 | value: v, 19 | message: 'Must be a defined join!' 20 | }) 21 | } 22 | 23 | return new QueryValue({ field }, { 24 | ...joinConfig, 25 | hydrateJSON, 26 | context, 27 | instanceQuery: true, 28 | from: joinKey !== 'parent' ? joinKey : undefined 29 | }).value() 30 | } 31 | -------------------------------------------------------------------------------- /src/util/getModelFieldLimit.js: -------------------------------------------------------------------------------- 1 | import getScopedAttributes from '../util/getScopedAttributes' 2 | 3 | export default (model) => 4 | Object.keys(getScopedAttributes(model)).map((f) => ({ type: 'column', field: f })) 5 | -------------------------------------------------------------------------------- /src/util/getScopedAttributes.js: -------------------------------------------------------------------------------- 1 | export default ({ rawAttributes, _scope }) => { 2 | if (!_scope) return rawAttributes // no scope 3 | const { attributes } = _scope 4 | if (!attributes) return rawAttributes // scope does not apply to attrs 5 | if (Array.isArray(attributes)) { 6 | return Object.entries(rawAttributes).reduce((prev, [ k, v ]) => { 7 | if (!attributes.includes(k)) return prev 8 | prev[k] = v 9 | return prev 10 | }, {}) 11 | } 12 | if (Array.isArray(attributes.exclude) || Array.isArray(attributes.include)) { 13 | return Object.entries(rawAttributes).reduce((prev, [ k, v ]) => { 14 | if (attributes.exclude && attributes.exclude.includes(k)) return prev 15 | if (attributes.include && !attributes.include.includes(k)) return prev 16 | prev[k] = v 17 | return prev 18 | }, {}) 19 | } 20 | throw new Error('Scope too complex - could not determine safe values!') 21 | } 22 | -------------------------------------------------------------------------------- /src/util/iffy/date.js: -------------------------------------------------------------------------------- 1 | export default (v) => { 2 | if (v == null || !v) return 3 | const d = new Date(v) 4 | if (isNaN(d)) throw new Error('Bad date value') 5 | return d 6 | } 7 | -------------------------------------------------------------------------------- /src/util/iffy/number.js: -------------------------------------------------------------------------------- 1 | export default (v) => { 2 | if (typeof v === 'number') return v 3 | if (v == null || !v) return 4 | if (typeof v === 'string') { 5 | const n = parseFloat(v) 6 | if (isNaN(n)) throw new Error('Bad number value') 7 | return n 8 | } 9 | throw new Error('Bad number value') 10 | } 11 | -------------------------------------------------------------------------------- /src/util/iffy/stringArray.js: -------------------------------------------------------------------------------- 1 | export default (v) => { 2 | if (v == null) return [] // nada 3 | if (Array.isArray(v)) return v.map((s) => String(s)) 4 | if (typeof v === 'string') return v.split(',') 5 | return [ String(v) ] 6 | } 7 | -------------------------------------------------------------------------------- /src/util/intersects.js: -------------------------------------------------------------------------------- 1 | import getGeoFields from './getGeoFields' 2 | import { fn, or, cast, col, literal } from 'sequelize' 3 | 4 | export default (geo, { model, column = model.name }) => { 5 | const geoFields = getGeoFields(model) 6 | if (!geo || !geoFields) return literal(false) 7 | const wheres = geoFields.map((f) => 8 | fn('ST_Intersects', cast(col(`${column}.${f}`), 'geometry'), cast(geo, 'geometry')) 9 | ) 10 | if (wheres.length === 1) return wheres[0] 11 | return or(...wheres) 12 | } 13 | -------------------------------------------------------------------------------- /src/util/isQueryValue.js: -------------------------------------------------------------------------------- 1 | import isObject from 'is-plain-obj' 2 | 3 | export default (v) => isObject(v) && (v.function || v.field) 4 | -------------------------------------------------------------------------------- /src/util/isValidCoordinate.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-magic-numbers */ 2 | 3 | export const lat = (lat) => { 4 | if (typeof lat !== 'number') return `Latitude not a number, got ${typeof lat}` 5 | if (lat > 90) return 'Latitude greater than 90' 6 | if (lat < -90) return 'Latitude less than -90' 7 | return true 8 | } 9 | 10 | export const lon = (lon) => { 11 | if (typeof lon !== 'number') return `Longitude not a number, got ${typeof lon}` 12 | if (lon < -180) return 'Longitude less than -180' 13 | if (lon > 180) return 'Longitude greater than 180' 14 | return true 15 | } 16 | -------------------------------------------------------------------------------- /src/util/parseTimeOptions.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-magic-numbers */ 2 | import moment from 'moment-timezone' 3 | import { ValidationError } from '../errors' 4 | 5 | const zones = new Set(moment.tz.names()) 6 | 7 | export default (query, { context = [] }) => { 8 | const error = new ValidationError() 9 | const out = {} 10 | 11 | // if user specified a timezone, tack it on so downstream stuff in types/query knows about it 12 | if (query.timezone) { 13 | if (typeof query.timezone !== 'string') { 14 | error.add({ 15 | path: [ ...context, 'timezone' ], 16 | value: query.timezone, 17 | message: 'Must be a string.' 18 | }) 19 | } else { 20 | if (!zones.has(query.timezone)) { 21 | error.add({ 22 | path: [ ...context, 'timezone' ], 23 | value: query.timezone, 24 | message: 'Not a valid timezone.' 25 | }) 26 | } else { 27 | out.timezone = query.timezone 28 | } 29 | } 30 | delete query.timezone 31 | } 32 | // if user specified a customYearStart, tack it on so downstream stuff in types/query knows about it 33 | if (query.customYearStart) { 34 | if (typeof query.customYearStart !== 'number') { 35 | error.add({ 36 | path: [ ...context, 'customYearStart' ], 37 | value: query.customYearStart, 38 | message: 'Must be a number.' 39 | }) 40 | } else { 41 | if (query.customYearStart < 1 || query.customYearStart > 12) { 42 | error.add({ 43 | path: [ ...context, 'customYearStart' ], 44 | value: query.customYearStart, 45 | message: 'Not a valid month.' 46 | }) 47 | } else { 48 | out.customYearStart = query.customYearStart 49 | } 50 | } 51 | delete query.customYearStart 52 | } 53 | 54 | if (!error.isEmpty()) throw error 55 | return out 56 | } 57 | -------------------------------------------------------------------------------- /src/util/runWithTimeout.js: -------------------------------------------------------------------------------- 1 | export default async (fn, { timeout, sequelize, debug }) => 2 | sequelize.transaction(async (transaction) => { 3 | const qopt = { transaction, logging: debug } 4 | await sequelize.query(` 5 | SET LOCAL statement_timeout = ${parseInt(timeout)}; 6 | SET LOCAL idle_in_transaction_session_timeout = ${parseInt(timeout)}; 7 | `.trim(), qopt) 8 | return fn(transaction, sequelize) 9 | }) 10 | -------------------------------------------------------------------------------- /src/util/search.js: -------------------------------------------------------------------------------- 1 | import eachDeep from 'deepdash/eachDeep' 2 | 3 | export default (v, fn) => { 4 | const res = [] 5 | eachDeep(v, (value, key, path) => { 6 | if (fn(key, value)) res.push({ path, value }) 7 | }, { pathFormat: 'array' }) 8 | return res.length === 0 ? undefined : res 9 | } 10 | -------------------------------------------------------------------------------- /src/util/tz.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment-timezone' 2 | import sql from 'sequelize' 3 | import { BadRequestError } from '../errors' 4 | 5 | const zones = new Set(moment.tz.names()) 6 | 7 | export const force = (v, timezone = 'Etc/UTC') => { 8 | if (!zones.has(timezone)) throw new BadRequestError('Not a valid timezone') 9 | return sql.fn('force_tz', v, timezone) 10 | } 11 | -------------------------------------------------------------------------------- /test/Aggregation/index.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Aggregation } from '../../src' 3 | import db from '../fixtures/db' 4 | 5 | describe('Aggregation', () => { 6 | const { user } = db.models 7 | it('should blow up on invalid options', async () => { 8 | should.throws(() => new Aggregation({ value: { field: 'name' }, alias: 'name' }, { model: null })) 9 | should.throws(() => new Aggregation({ value: { field: 'name' }, alias: 'name' })) 10 | should.throws(() => new Aggregation(null, { model: user })) 11 | should.throws(() => new Aggregation(true, { model: user })) 12 | }) 13 | it('should work with basic value', async () => { 14 | const query = new Aggregation({ value: { field: 'name' }, alias: 'name' }, { model: user }) 15 | should.exist(query.value()) 16 | should.exist(query.toJSON()) 17 | should.exist(query.input) 18 | }) 19 | it('should work with functions', async () => { 20 | const query = new Aggregation({ 21 | value: { function: 'now' }, 22 | alias: 'now' 23 | }, { model: user }) 24 | should.exist(query.value()) 25 | should.exist(query.toJSON()) 26 | should.exist(query.input) 27 | }) 28 | it('should work with basic value and filters', async () => { 29 | const query = new Aggregation({ 30 | value: { field: 'name' }, 31 | alias: 'name', 32 | filters: { 33 | name: { $eq: 'Yo' } 34 | } 35 | }, { model: user }) 36 | should.exist(query.value()) 37 | should.exist(query.toJSON()) 38 | should.exist(query.input) 39 | }) 40 | it('should blow up when missing alias', async () => { 41 | try { 42 | new Aggregation({ value: { field: 'name' }, alias: null }, { model: user }) 43 | } catch (err) { 44 | err.fields.should.eql([ { path: [ 'alias' ], value: null, message: 'Missing alias!' } ]) 45 | return 46 | } 47 | throw new Error('Did not throw!') 48 | }) 49 | it('should blow up when alias value invalid', async () => { 50 | try { 51 | new Aggregation({ value: { field: 'name' }, alias: true }, { model: user }) 52 | } catch (err) { 53 | err.fields.should.eql([ { path: [ 'alias' ], value: true, message: 'Must be a string.' } ]) 54 | return 55 | } 56 | throw new Error('Did not throw!') 57 | }) 58 | it('should blow up when missing value', async () => { 59 | try { 60 | new Aggregation({ value: null, alias: 'name' }, { model: user }) 61 | } catch (err) { 62 | err.fields.should.eql([ { path: [ 'value' ], value: null, message: 'Missing value!' } ]) 63 | return 64 | } 65 | throw new Error('Did not throw!') 66 | }) 67 | it('should blow up when filters value invalid', async () => { 68 | try { 69 | new Aggregation({ value: { field: 'name' }, filters: true, alias: 'name' }, { model: user }) 70 | } catch (err) { 71 | err.fields.should.eql([ { path: [ 'filters' ], value: true, message: 'Must be an object or array.' } ]) 72 | return 73 | } 74 | throw new Error('Did not throw!') 75 | }) 76 | it('should blow up when filters has a bad value', async () => { 77 | try { 78 | new Aggregation({ value: { field: 'name' }, filters: { doesNotExist: { $eq: true } }, alias: 'name' }, { model: user }) 79 | } catch (err) { 80 | err.fields.should.eql([ { path: [ 'filters', 'doesNotExist' ], value: 'doesNotExist', message: 'Field does not exist.' } ]) 81 | return 82 | } 83 | throw new Error('Did not throw!') 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /test/AnalyticsQuery/constrain.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../src' 3 | import db from '../fixtures/db' 4 | 5 | describe('AnalyticsQuery#constrain', () => { 6 | const { user } = db.models 7 | it('should throw on bad where', async () => { 8 | const query = new AnalyticsQuery({ 9 | aggregations: [ 10 | { 11 | value: { function: 'count' }, 12 | alias: 'count' 13 | }, 14 | { 15 | value: { field: 'name' }, 16 | alias: 'name' 17 | } 18 | ], 19 | groupings: [ 20 | { field: 'name' } 21 | ] 22 | }, { model: user }) 23 | 24 | should.throws(() => query.constrain({ where: 1 })) 25 | }) 26 | it('should constrain with new where', async () => { 27 | const query = new AnalyticsQuery({ 28 | aggregations: [ 29 | { 30 | value: { function: 'count' }, 31 | alias: 'count' 32 | }, 33 | { 34 | value: { field: 'name' }, 35 | alias: 'name' 36 | } 37 | ], 38 | groupings: [ 39 | { field: 'name' } 40 | ] 41 | }, { model: user }) 42 | 43 | query.constrain({ 44 | where: [ { createdAt: { $eq: null } } ] 45 | }) 46 | 47 | const res = await query.execute() 48 | should.exist(res) 49 | res.length.should.eql(0) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /test/AnalyticsQuery/executeStream.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../src' 3 | import db from '../fixtures/db' 4 | import collect from 'get-stream' 5 | import JSONStream from 'jsonstream-next' 6 | 7 | const json = () => JSONStream.stringify('[', ',', ']') 8 | json.contentType = 'application/json' 9 | json.extension = 'json' 10 | 11 | describe('AnalyticsQuery#executeStream', () => { 12 | const { user } = db.models 13 | it('should execute with scope', async () => { 14 | const query = new AnalyticsQuery({ 15 | aggregations: [ 16 | { 17 | value: { function: 'count' }, 18 | alias: 'count' 19 | }, 20 | { 21 | value: { field: 'name' }, 22 | alias: 'name' 23 | } 24 | ], 25 | groupings: [ 26 | { field: 'name' } 27 | ] 28 | }, { model: user.scope('public') }) 29 | const stream = await query.executeStream() 30 | const res = await collect.array(stream) 31 | res.length.should.equal(3) 32 | }) 33 | it('should execute with transform', async () => { 34 | const query = new AnalyticsQuery({ 35 | aggregations: [ 36 | { 37 | value: { function: 'count' }, 38 | alias: 'count' 39 | }, 40 | { 41 | value: { field: 'name' }, 42 | alias: 'name' 43 | } 44 | ], 45 | groupings: [ 46 | { field: 'name' } 47 | ] 48 | }, { model: user.scope('public') }) 49 | const stream = await query.executeStream({ 50 | transform: (v) => ({ 51 | ...v, 52 | newName: v.name, 53 | name: undefined 54 | }) 55 | }) 56 | const res = await collect.array(stream) 57 | res.length.should.equal(3) 58 | should.exist(res[0].newName) 59 | should.not.exist(res[0].name) 60 | }) 61 | it('should execute with format', async () => { 62 | const query = new AnalyticsQuery({ 63 | aggregations: [ 64 | { 65 | value: { function: 'count' }, 66 | alias: 'count' 67 | }, 68 | { 69 | value: { field: 'name' }, 70 | alias: 'name' 71 | } 72 | ], 73 | groupings: [ 74 | { field: 'name' } 75 | ] 76 | }, { model: user.scope('public') }) 77 | const stream = await query.executeStream({ 78 | format: json 79 | }) 80 | should(stream.contentType).eql(json.contentType) 81 | const res = await collect(stream) 82 | should(typeof res).eql('string') 83 | const parsed = JSON.parse(res) 84 | parsed.length.should.equal(3) 85 | should.exist(parsed[0].name) 86 | }) 87 | it('should work with timeout', async () => { 88 | const query = new AnalyticsQuery({ 89 | aggregations: [ 90 | { 91 | value: { function: 'count' }, 92 | alias: 'count' 93 | }, 94 | { 95 | value: { field: 'name' }, 96 | alias: 'name' 97 | } 98 | ], 99 | groupings: [ 100 | { field: 'name' } 101 | ] 102 | }, { model: user.scope('public') }) 103 | const stream = await query.executeStream({ 104 | timeout: 1000, 105 | format: json 106 | }) 107 | should(stream.contentType).eql(json.contentType) 108 | const res = await collect(stream) 109 | should(typeof res).eql('string') 110 | const parsed = JSON.parse(res) 111 | parsed.length.should.equal(3) 112 | should.exist(parsed[0].name) 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /test/AnalyticsQuery/getOutputSchema.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../src' 3 | import db from '../fixtures/db' 4 | import { crimeTimeSeries, crimePerOfficer } from '../fixtures/analytics' 5 | import dataType from '../fixtures/911-call' 6 | 7 | describe('AnalyticsQuery#getOutputSchema', () => { 8 | const { user, datum } = db.models 9 | it('should get a basic schema', async () => { 10 | const query = new AnalyticsQuery({ 11 | aggregations: [ 12 | { 13 | name: 'Total #', 14 | notes: 'Total number of users', 15 | value: { function: 'count' }, 16 | alias: 'count' 17 | }, 18 | { 19 | name: 'Username', 20 | notes: 'Their name', 21 | value: { field: 'name' }, 22 | alias: 'name' 23 | } 24 | ], 25 | groupings: [ 26 | { field: 'name' } 27 | ] 28 | }, { model: user }) 29 | const res = query.getOutputSchema() 30 | should.exist(res) 31 | should(res).eql({ 32 | count: { 33 | name: 'Total #', 34 | notes: 'Total number of users', 35 | type: 'number' 36 | }, 37 | name: { 38 | name: 'Username', 39 | notes: 'Their name', 40 | type: 'text' 41 | } 42 | }) 43 | }) 44 | it('should get crime time series', async () => { 45 | const query = new AnalyticsQuery(crimeTimeSeries, { model: datum, subSchemas: { data: dataType.schema } }) 46 | const res = query.getOutputSchema() 47 | should.exist(res) 48 | should(res).eql({ 49 | day: { 50 | name: 'Day', 51 | type: 'date', 52 | measurement: { 53 | type: 'bucket', 54 | value: 'day' 55 | } 56 | }, 57 | total: { name: 'Total', type: 'number' } 58 | }) 59 | }) 60 | it('should get crime per officer', async () => { 61 | const query = new AnalyticsQuery(crimePerOfficer, { model: datum, subSchemas: { data: dataType.schema } }) 62 | const res = query.getOutputSchema() 63 | should.exist(res) 64 | should(res).eql({ 65 | pre70s: { name: 'Pre70s', type: 'number' }, 66 | weekly: { name: 'Weekly', type: 'number' }, 67 | total: { name: 'Total', type: 'number' }, 68 | officer: { 69 | name: 'Officer', 70 | type: 'text', 71 | validation: { 72 | required: true, 73 | notEmpty: true, 74 | maxLength: 2048 75 | } 76 | } 77 | }) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /test/AnalyticsQuery/index.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../src' 3 | import db from '../fixtures/db' 4 | 5 | describe('AnalyticsQuery', () => { 6 | const { user } = db.models 7 | it('should blow up on invalid options', async () => { 8 | should.throws(() => new AnalyticsQuery({ limit: 1, groupings: [ { field: 'name' } ] }, { model: null })) 9 | should.throws(() => new AnalyticsQuery({ limit: 1, groupings: [ { field: 'name' } ] })) 10 | should.throws(() => new AnalyticsQuery(null, { model: user })) 11 | }) 12 | it('should allow basic queries and shift back to the Query constructor', async () => { 13 | new AnalyticsQuery({ limit: 1 }, { model: user }) 14 | }) 15 | it('should not be able to access out of scope variables', async () => { 16 | try { 17 | new AnalyticsQuery({ 18 | aggregations: [ 19 | { 20 | value: { function: 'count' }, 21 | alias: 'count' 22 | }, 23 | { 24 | value: { field: 'authToken' }, 25 | alias: 'authToken2' 26 | } 27 | ], 28 | groupings: [ 29 | { field: 'authToken' } 30 | ] 31 | }, { model: user.scope('public') }) 32 | } catch (err) { 33 | err.fields.should.eql([ 34 | { 35 | path: [ 'aggregations', 1, 'value', 'field' ], 36 | value: 'authToken', 37 | message: 'Field does not exist.' 38 | } 39 | ]) 40 | return 41 | } 42 | throw new Error('Did not throw!') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/AnalyticsQuery/options/groupings.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../../src' 3 | import db from '../../fixtures/db' 4 | 5 | describe('AnalyticsQuery#options#groupings', () => { 6 | const { user } = db.models 7 | it('should execute with a field', async () => { 8 | const query = new AnalyticsQuery({ 9 | aggregations: [ 10 | { 11 | value: { function: 'count' }, 12 | alias: 'count' 13 | }, 14 | { 15 | value: { field: 'name' }, 16 | alias: 'name' 17 | } 18 | ], 19 | groupings: [ 20 | { field: 'name' } 21 | ] 22 | }, { model: user }) 23 | const res = await query.execute() 24 | should.exist(res) 25 | res.length.should.eql(3) 26 | res[0].count.should.eql(1) 27 | }) 28 | it('should return grouping invalid field errors correctly', async () => { 29 | try { 30 | new AnalyticsQuery({ 31 | aggregations: [ 32 | { 33 | value: { function: 'count' }, 34 | alias: 'count' 35 | }, 36 | { 37 | value: { field: 'name' }, 38 | alias: 'name' 39 | } 40 | ], 41 | groupings: [ 42 | { field: 'does-not-exist' } 43 | ] 44 | }, { model: user }) 45 | } catch (err) { 46 | should.exist(err) 47 | should.exist(err.fields) 48 | err.fields.should.eql([ { 49 | path: [ 'groupings', 0, 'field' ], 50 | value: 'does-not-exist', 51 | message: 'Field does not exist.' 52 | } ]) 53 | return 54 | } 55 | throw new Error('Did not throw!') 56 | }) 57 | it('should return deep grouping value errors correctly', async () => { 58 | try { 59 | new AnalyticsQuery({ 60 | aggregations: [ 61 | { 62 | value: { function: 'count' }, 63 | alias: 'count' 64 | }, 65 | { 66 | value: { field: 'name' }, 67 | alias: 'name' 68 | } 69 | ], 70 | groupings: [ 71 | { 72 | function: 'area', 73 | arguments: [ { field: 'yo' } ] 74 | } 75 | ] 76 | }, { model: user }) 77 | } catch (err) { 78 | should.exist(err) 79 | should.exist(err.fields) 80 | err.fields.should.eql([ { 81 | path: [ 'groupings', 0, 'arguments', 0, 'field' ], 82 | value: 'yo', 83 | message: 'Field does not exist.' 84 | } ]) 85 | return 86 | } 87 | throw new Error('Did not throw!') 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /test/AnalyticsQuery/security.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/AnalyticsQuery/update.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../src' 3 | import db from '../fixtures/db' 4 | 5 | describe('AnalyticsQuery#update', () => { 6 | const { user } = db.models 7 | it('should throw on bad function', async () => { 8 | const query = new AnalyticsQuery({ 9 | aggregations: [ 10 | { 11 | value: { function: 'count' }, 12 | alias: 'count' 13 | }, 14 | { 15 | value: { field: 'name' }, 16 | alias: 'name' 17 | } 18 | ], 19 | groupings: [ 20 | { field: 'name' } 21 | ] 22 | }, { model: user }) 23 | 24 | should.throws(() => query.update(null)) 25 | should.throws(() => query.update(() => null)) 26 | }) 27 | it('should update with new where clauses', async () => { 28 | const query = new AnalyticsQuery({ 29 | aggregations: [ 30 | { 31 | value: { function: 'count' }, 32 | alias: 'count' 33 | }, 34 | { 35 | value: { field: 'name' }, 36 | alias: 'name' 37 | } 38 | ], 39 | groupings: [ 40 | { field: 'name' } 41 | ] 42 | }, { model: user }) 43 | 44 | query.update((v) => ({ 45 | ...v, 46 | where: [ 47 | ...v.where, 48 | { createdAt: { $eq: null } } 49 | ] 50 | })) 51 | const res = await query.execute() 52 | should.exist(res) 53 | res.length.should.eql(0) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /test/Ordering/index.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Ordering } from '../../src' 3 | import parse from '../../src/Ordering/parse' 4 | import db from '../fixtures/db' 5 | 6 | describe('Ordering', () => { 7 | const { user } = db.models 8 | it('should blow up on invalid options', async () => { 9 | should.throws(() => new Ordering()) 10 | should.throws(() => new Ordering({})) 11 | should.throws(() => new Ordering({}, {})) 12 | }) 13 | it('should blow up on invalid parsing options', async () => { 14 | should.throws(() => new Ordering({}, { model: true })) 15 | should.throws(() => new Ordering({}, { model: user })) 16 | should.throws(() => new Ordering({}, { model: user }).value()) 17 | should.throws(() => new Ordering({ value: 'id', direction: NaN }, { model: user }).value()) 18 | should.throws(() => new Ordering({ value: 'id' }, { model: user }).value()) 19 | should.throws(() => new Ordering({ direction: 'asc' }, { model: user }).value()) 20 | should.throws(() => new Ordering({ value: { function: 'wat' }, direction: 'asc' }, { model: user }).value()) 21 | }) 22 | it('should return an ordering query when valid', async () => { 23 | const t = new Ordering({ value: 'id', direction: 'asc' }, { model: user }).value() 24 | should(t[0].val).equal('\'id\'') 25 | }) 26 | }) 27 | 28 | describe('Ordering#parse', () => { 29 | 30 | const { user } = db.models 31 | it('should blow up on invalid options', async () => { 32 | should.throws(() => parse()) 33 | should.throws(() => parse({})) 34 | should.throws(() => parse({}, {})) 35 | }) 36 | it('should blow up on invalid parsing options', async () => { 37 | should.throws(() => parse({}, { model: true })) 38 | should.throws(() => parse({}, { model: user })) 39 | should.throws(() => parse({}, { model: user }).value()) 40 | should.throws(() => parse({ value: 'id', direction: NaN }, { model: user }).value()) 41 | should.throws(() => parse({ value: 'id' }, { model: user }).value()) 42 | should.throws(() => parse({ direction: 'asc' }, { model: user }).value()) 43 | should.throws(() => parse({ value: { function: 'wat' }, direction: 'asc' }, { model: user }).value()) 44 | }) 45 | it('should return an ordering query when valid', async () => { 46 | const t = parse({ value: 'id', direction: 'asc' }, { model: user }) 47 | should(t[0].val).equal('\'id\'') 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /test/Query/constrain.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../src' 3 | import db from '../fixtures/db' 4 | 5 | describe('Query#constrain', () => { 6 | const { user } = db.models 7 | it('should throw on bad where', async () => { 8 | const query = new Query({ limit: 1 }, { model: user.scope('public') }) 9 | should.throws(() => query.constrain({ where: 1 })) 10 | }) 11 | it('should constrain with new where clauses', async () => { 12 | const query = new Query({ limit: 1 }, { model: user.scope('public') }) 13 | query.constrain({ 14 | where: [ { createdAt: { $eq: null } } ] 15 | }) 16 | const res = await query.execute() 17 | should.exist(res.count) 18 | should.exist(res.rows) 19 | res.count.should.equal(0) 20 | res.rows.length.should.equal(0) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/Query/destroy.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../src' 3 | import db from '../fixtures/db' 4 | 5 | const dataType = { 6 | schema: { 7 | id: { 8 | type: 'text' 9 | } 10 | } 11 | } 12 | 13 | describe('Query#destroy', () => { 14 | const { user } = db.models 15 | it('should fail trying to destroy by invalid scope', async () => { 16 | const email = 'yo@yo.com' 17 | try { 18 | new Query({ 19 | filters: [ 20 | { email } 21 | ] 22 | }, { model: user.scope('public') }) 23 | } catch (err) { 24 | return 25 | } 26 | throw new Error('Did not throw!') 27 | }) 28 | it('should work with filters and update', async () => { 29 | const email = 'yo@yo.com' 30 | const query = new Query({ 31 | filters: [ 32 | { email } 33 | ] 34 | }, { model: user }) 35 | query.update((v) => ({ 36 | ...v, 37 | where: [ 38 | ...v.where, 39 | { email: 'noexist@noexist.com' } 40 | ] 41 | })) 42 | const count = await query.destroy() 43 | should.exist(count) 44 | should(count).equal(0) 45 | }) 46 | it('should destroy with filters', async () => { 47 | const email = 'yo@yo.com' 48 | const total = await user.count() 49 | const query = new Query({ 50 | filters: [ 51 | { email } 52 | ] 53 | }, { model: user }) 54 | const count = await query.destroy() 55 | should.exist(count) 56 | should(count).equal(1) 57 | const whatsLeft = await user.findAll() 58 | should(total - whatsLeft.length).equal(1) 59 | should.not.exist(whatsLeft.find((i) => i.email === email)) 60 | }) 61 | it('should destroy with json filters', async () => { 62 | const query = new Query({ 63 | filters: [ 64 | { 'settings.id': 'abc' } 65 | ] 66 | }, { model: user, subSchemas: { settings: dataType.schema } }) 67 | await query.destroy() 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /test/Query/index.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../src' 3 | import db from '../fixtures/db' 4 | 5 | describe('Query', () => { 6 | const { user } = db.models 7 | it('should blow up on invalid options', async () => { 8 | should.throws(() => new Query({ limit: 1 }, { model: null })) 9 | should.throws(() => new Query({ limit: 1 })) 10 | should.throws(() => new Query(null, { model: user })) 11 | }) 12 | it('should not be able to access out of scope variables', async () => { 13 | try { 14 | new Query({ 15 | filters: { 16 | authToken: '123' 17 | } 18 | }, { model: user.scope('public') }) 19 | } catch (err) { 20 | err.fields.should.eql([ { 21 | path: [ 'filters', 'authToken' ], 22 | value: 'authToken', 23 | message: 'Field does not exist.' 24 | } ]) 25 | return 26 | } 27 | throw new Error('Did not throw!') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /test/Query/options/after.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../../src' 3 | import db from '../../fixtures/db' 4 | 5 | describe('Query#options#after', () => { 6 | const { user } = db.models 7 | 8 | it('should work for valid after values', async () => { 9 | should.exist(new Query({ after: '' }, { model: user })) 10 | should.exist(new Query({ after: null }, { model: user })) 11 | should.exist(new Query({ after: new Date().toISOString() }, { model: user })) 12 | }) 13 | it('should return 400 on bad after', async () => { 14 | should.throws(() => new Query({ after: {} }, { model: user })) 15 | should.throws(() => new Query({ after: [ 'blah' ] }, { model: user })) 16 | should.throws(() => new Query({ after: 'blah' }, { model: user })) 17 | }) 18 | it('should execute with after', async () => { 19 | const query = new Query({ after: new Date('1/1/1975').toISOString(), limit: 1 }, { model: user }) 20 | const res = await query.execute() 21 | should.exist(res.count) 22 | should.exist(res.rows) 23 | res.count.should.equal(3) 24 | res.rows.length.should.equal(1) 25 | }) 26 | it('should execute with after and no results', async () => { 27 | const query = new Query({ after: new Date(Date.now() + Date.now()).toISOString() }, { model: user }) 28 | const res = await query.execute() 29 | should.exist(res.count) 30 | should.exist(res.rows) 31 | res.count.should.equal(0) 32 | res.rows.length.should.equal(0) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/Query/options/before.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../../src' 3 | import db from '../../fixtures/db' 4 | 5 | describe('Query#options#before', () => { 6 | const { user } = db.models 7 | 8 | it('should work for valid before values', async () => { 9 | should.exist(new Query({ before: '' }, { model: user })) 10 | should.exist(new Query({ before: null }, { model: user })) 11 | should.exist(new Query({ before: new Date().toISOString() }, { model: user })) 12 | }) 13 | it('should return 400 on bad before', async () => { 14 | should.throws(() => new Query({ before: {} }, { model: user })) 15 | should.throws(() => new Query({ before: [ 'blah' ] }, { model: user })) 16 | should.throws(() => new Query({ before: 'blah' }, { model: user })) 17 | }) 18 | it('should execute with before', async () => { 19 | const query = new Query({ before: new Date().toISOString(), limit: 1 }, { model: user }) 20 | const res = await query.execute() 21 | should.exist(res.count) 22 | should.exist(res.rows) 23 | res.count.should.equal(3) 24 | res.rows.length.should.equal(1) 25 | }) 26 | it('should execute with before and no results', async () => { 27 | const query = new Query({ before: new Date('1/1/1975').toISOString() }, { model: user }) 28 | const res = await query.execute() 29 | should.exist(res.count) 30 | should.exist(res.rows) 31 | res.count.should.equal(0) 32 | res.rows.length.should.equal(0) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/Query/options/exclusions.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../../src' 3 | import db from '../../fixtures/db' 4 | 5 | describe('Query#options#exclusions', () => { 6 | const { user } = db.models 7 | 8 | it('should work for valid exclusions values', async () => { 9 | should.exist(new Query({ exclusions: [] }, { model: user })) 10 | should.exist(new Query({ exclusions: [ 'id' ] }, { model: user })) 11 | should.exist(new Query({ exclusions: '' }, { model: user })) 12 | should.exist(new Query({ exclusions: null }, { model: user })) 13 | should.exist(new Query({ exclusions: 'id,name' }, { model: user })) 14 | }) 15 | it('should return 400 on bad exclusions', async () => { 16 | should.throws(() => new Query({ exclusions: {} }, { model: user })) 17 | should.throws(() => new Query({ exclusions: 'blahblah' }, { model: user })) 18 | should.throws(() => new Query({ exclusions: [ 'field-does-not-exist' ] }, { model: user })) 19 | }) 20 | it('should execute with exclusions', async () => { 21 | const query = new Query({ exclusions: [ 'id' ], limit: 1 }, { model: user }) 22 | const res = await query.execute() 23 | should.exist(res.count) 24 | should.exist(res.rows) 25 | res.count.should.equal(3) 26 | res.rows.length.should.equal(1) 27 | should.not.exist(res.rows[0].id) 28 | }) 29 | it.skip('should execute with nested exclusions', async () => { 30 | const query = new Query({ exclusions: [ 'settings.vegan' ], limit: 1 }, { model: user }) 31 | const res = await query.execute() 32 | should.exist(res.count) 33 | should.exist(res.rows) 34 | res.count.should.equal(3) 35 | res.rows.length.should.equal(1) 36 | should.not.exist(res.rows[0].settings.vegan) 37 | should.exist(res.rows[0].settings.glutenAllergy) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /test/Query/options/filters.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../../src' 3 | import db from '../../fixtures/db' 4 | 5 | describe('Query#options#filters', () => { 6 | const { user } = db.models 7 | 8 | it('should work for valid filters values', async () => { 9 | should.exist(new Query({ filters: {} }, { model: user })) 10 | should.exist(new Query({ filters: { name: { $ne: null } } }, { model: user })) 11 | }) 12 | it('should return 400 on bad filters', async () => { 13 | should.throws(() => new Query({ filters: 'blahblah' }, { model: user })) 14 | should.throws(() => new Query({ filters: { missing: true } }, { model: user })) 15 | }) 16 | it('should execute with filters', async () => { 17 | const query = new Query({ filters: { name: { $eq: 'Yo Yo 1' } } }, { model: user }) 18 | const res = await query.execute() 19 | should.exist(res.count) 20 | should.exist(res.rows) 21 | res.count.should.equal(1) 22 | res.rows.length.should.equal(1) 23 | should(res.rows[0].name === 'Yo Yo 1') 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/Query/options/joins.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../../src' 3 | import call from '../../fixtures/911-call' 4 | import bikeTrip from '../../fixtures/bike-trip' 5 | import transitPassenger from '../../fixtures/transit-passenger' 6 | import db from '../../fixtures/db' 7 | import { groupBy } from 'lodash' 8 | 9 | describe('Query#joins', () => { 10 | const { datum } = db.models 11 | 12 | it('should handle a join with no groupings', async () => { 13 | const query = new Query({ 14 | filters: [ 15 | { 16 | sourceId: 'bike-trips' 17 | } 18 | ], 19 | joins: [ { 20 | name: '911 Calls', 21 | alias: 'calls', 22 | where: [ 23 | { sourceId: '911-calls' } 24 | ] 25 | }, 26 | { 27 | name: 'Transit Passengers', 28 | alias: 'transitPassengers', 29 | where: [ 30 | { sourceId: 'transit-passengers' } 31 | ] 32 | } ] 33 | }, { 34 | model: datum, 35 | subSchemas: { data: bikeTrip.schema }, 36 | joins: { 37 | calls: { 38 | model: datum, 39 | subSchemas: { data: call.schema } 40 | }, 41 | transitPassengers: { 42 | model: datum, 43 | subSchemas: { data: transitPassenger.schema } 44 | } 45 | } 46 | }) 47 | 48 | const res = await query.execute() 49 | should.exist(res) 50 | should(res.length).eql(10) 51 | 52 | // assert on number of results in join and verify _alias result column 53 | const aliasGroups = groupBy(res, '_alias') 54 | should(Object.keys(aliasGroups) == [ 'null', 'calls', 'transitPassengers' ]) 55 | should(aliasGroups.null.length == 2) 56 | should(aliasGroups.calls.length == 2) 57 | should(aliasGroups.transitPassengers.length == 6) 58 | }) 59 | 60 | it('should handle union all with limit', async () => { 61 | const query = new Query({ 62 | filters: [ 63 | { 64 | sourceId: 'bike-trips' 65 | } 66 | ], 67 | joins: [ { 68 | name: '911 Calls', 69 | alias: 'calls', 70 | where: [ 71 | { sourceId: '911-calls' } 72 | ] 73 | }, 74 | { 75 | name: 'Transit Passengers', 76 | alias: 'transitPassengers', 77 | where: [ 78 | { sourceId: 'transit-passengers' } 79 | ] 80 | } ], 81 | limit: 8 82 | }, { 83 | model: datum, 84 | subSchemas: { data: bikeTrip.schema }, 85 | joins: { 86 | calls: { 87 | model: datum, 88 | subSchemas: { data: call.schema } 89 | }, 90 | transitPassengers: { 91 | model: datum, 92 | subSchemas: { data: transitPassenger.schema } 93 | } 94 | } 95 | }) 96 | 97 | const res = await query.execute() 98 | should.exist(res) 99 | should(res.length).eql(8) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/Query/options/limit.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../../src' 3 | import db from '../../fixtures/db' 4 | 5 | describe('Query#options#limit', () => { 6 | const { user } = db.models 7 | 8 | it('should work for valid limit values', async () => { 9 | should.exist(new Query({ limit: 1 }, { model: user })) 10 | should.exist(new Query({ limit: '1' }, { model: user })) 11 | should.exist(new Query({ limit: '' }, { model: user })) 12 | should.exist(new Query({ limit: null }, { model: user })) 13 | }) 14 | it('should return 400 on bad limits', async () => { 15 | should.throws(() => new Query({ limit: {} }, { model: user })) 16 | should.throws(() => new Query({ limit: 'blahblah' }, { model: user })) 17 | }) 18 | it('should execute with limit', async () => { 19 | const query = new Query({ limit: 1 }, { model: user }) 20 | const res = await query.execute() 21 | should.exist(res.count) 22 | should.exist(res.rows) 23 | res.count.should.equal(3) 24 | res.rows.length.should.equal(1) 25 | should.exist(res.rows[0].authToken) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/Query/options/offset.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../../src' 3 | import db from '../../fixtures/db' 4 | 5 | describe('Query#options#offset', () => { 6 | const { user } = db.models 7 | 8 | it('should work for valid offset values', async () => { 9 | should.exist(new Query({ offset: 1 }, { model: user })) 10 | should.exist(new Query({ offset: '1' }, { model: user })) 11 | should.exist(new Query({ offset: '' }, { model: user })) 12 | }) 13 | it('should return 400 on bad offsets', async () => { 14 | should.throws(() => new Query({ offset: {} }, { model: user })) 15 | should.throws(() => new Query({ offset: 'blahblah' }, { model: user })) 16 | }) 17 | it('should execute with offset', async () => { 18 | const query = new Query({ offset: 1000 }, { model: user }) 19 | const res = await query.execute() 20 | should.exist(res.count) 21 | should.exist(res.rows) 22 | res.count.should.equal(3) 23 | res.rows.length.should.equal(0) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/Query/options/orderings.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../../src' 3 | import db from '../../fixtures/db' 4 | 5 | describe('Query#options#orderings', () => { 6 | const { user } = db.models 7 | 8 | it('should work for valid ordering values', async () => { 9 | should.exist(new Query({ orderings: [] }, { model: user })) 10 | should.exist(new Query({ 11 | orderings: [ 12 | { value: { field: 'name' }, direction: 'asc' } 13 | ] 14 | }, { model: user })) 15 | }) 16 | it('should return 400 on bad orderings', async () => { 17 | should.throws(() => new Query({ orderings: {} }, { model: user })) 18 | should.throws(() => new Query({ orderings: 'blahblah' }, { model: user })) 19 | }) 20 | it('should execute with orderings', async () => { 21 | const query = new Query({ 22 | orderings: [ 23 | { value: { field: 'name' }, direction: 'desc' } 24 | ] 25 | }, { model: user }) 26 | const res = await query.execute() 27 | should(res.rows[0].name === 'Yo Yo 3') 28 | 29 | const query2 = new Query({ 30 | orderings: [ 31 | { value: { field: 'name' }, direction: 'asc' } 32 | ] 33 | }, { model: user }) 34 | const res2 = await query2.execute() 35 | should(res2.rows[0].name === 'Yo Yo 1') 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /test/Query/options/search.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../../src' 3 | import db from '../../fixtures/db' 4 | 5 | describe('Query#options#search', () => { 6 | const { user } = db.models 7 | 8 | it('should work for valid search values', async () => { 9 | should.exist(new Query({ search: '' }, { model: user })) 10 | should.exist(new Query({ search: 'test' }, { model: user })) 11 | }) 12 | it('should return 400 on bad search', async () => { 13 | should.throws(() => new Query({ search: {} }, { model: user })) 14 | should.throws(() => new Query({ search: [ 'blah' ] }, { model: user })) 15 | }) 16 | it('should execute with search', async () => { 17 | const query = new Query({ search: 'yo', limit: 1 }, { model: user }) 18 | const res = await query.execute() 19 | should.exist(res.count) 20 | should.exist(res.rows) 21 | res.count.should.equal(3) 22 | res.rows.length.should.equal(1) 23 | }) 24 | it('should execute with search and no results', async () => { 25 | const query = new Query({ search: 'sdfsdfsdf' }, { model: user }) 26 | const res = await query.execute() 27 | should.exist(res.count) 28 | should.exist(res.rows) 29 | res.count.should.equal(0) 30 | res.rows.length.should.equal(0) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /test/Query/options/within.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../../src' 3 | import db from '../../fixtures/db' 4 | 5 | describe('Query#options#within', () => { 6 | const { store } = db.models 7 | 8 | it('should work for valid within values', async () => { 9 | should.exist(new Query({ within: { xmax: 0, ymax: 0, xmin: 0, ymin: 0 } }, { model: store })) 10 | should.exist(new Query({ within: '' }, { model: store })) 11 | }) 12 | it('should return 400 on bad within', async () => { 13 | should.throws(() => new Query({ within: {} }, { model: store })) 14 | should.throws(() => new Query({ within: 'blahblah' }, { model: store })) 15 | should.throws(() => new Query({ within: [ 'eee' ] }, { model: store })) 16 | should.throws(() => new Query({ within: [] }, { model: store })) 17 | should.throws(() => new Query({ within: [ '1', '2' ] }, { model: store })) 18 | }) 19 | it('should execute with within', async () => { 20 | const bbox = { 21 | xmax: 180, 22 | ymax: 90, 23 | xmin: -179.999, 24 | ymin: -89.999 25 | } 26 | const query = new Query({ within: bbox, limit: 1 }, { model: store }) 27 | const res = await query.execute() 28 | should.exist(res.count) 29 | should.exist(res.rows) 30 | res.count.should.equal(3) 31 | res.rows.length.should.equal(1) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /test/Query/security.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../src' 3 | import db from '../fixtures/db' 4 | 5 | const filterRes = [ 6 | { settings: { '%; union select authToken': 1 } }, 7 | { authToken: { $eq: 'test' } }, 8 | { settingsb: { "a')) AS DECIMAL0 = UNION SELECT VERSION(); --": 1 } } 9 | ] 10 | const searchRes = [ 11 | "') union select authToken from users ->>'id'::json %", 12 | "') union select authToken from users -- %", 13 | '%; union select authToken', 14 | 'test%; UNION SELECT * FROM "users" --', 15 | '/*%; drop model users', 16 | '/*%; drop model users', 17 | `[]/*%; drop model users""` 18 | ] 19 | const searchError = [ 20 | '\'' 21 | // { authToken: { $eq: 'test' } } 22 | ] 23 | 24 | describe('Query#security', () => { 25 | const { user } = db.models 26 | 27 | filterRes.forEach((param, k) => { 28 | it(`should not return results for filter injections ${k}`, async () => { 29 | try { 30 | const query = new Query({ filters: param }, { model: user.scope('public') }) 31 | const res = await query.execute() 32 | should(res.rows.length).equal(0) 33 | } catch (err) { 34 | // just as good! did not even validate 35 | } 36 | }) 37 | }) 38 | 39 | searchRes.forEach((param, k) => { 40 | it(`should not return results for search injections ${k}`, async () => { 41 | try { 42 | const query = new Query({ search: param }, { model: user }) 43 | const res = await query.execute() 44 | should(res.rows.length).equal(0) 45 | } catch (err) { 46 | // just as good! did not even validate 47 | } 48 | }) 49 | }) 50 | 51 | searchError.forEach((param, k) => { 52 | it(`should return error for search injections ${k}`, async () => { 53 | should.throws(() => new Query({ filters: param }, { model: user.scope('public') })) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /test/Query/update.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { Query } from '../../src' 3 | import db from '../fixtures/db' 4 | 5 | describe('Query#update', () => { 6 | const { user } = db.models 7 | it('should throw on bad function', async () => { 8 | const query = new Query({ limit: 1 }, { model: user.scope('public') }) 9 | should.throws(() => query.update(null)) 10 | should.throws(() => query.update(() => null)) 11 | }) 12 | it('should update with new where clauses', async () => { 13 | const query = new Query({ limit: 1 }, { model: user.scope('public') }) 14 | query.update((v) => ({ 15 | ...v, 16 | where: [ 17 | ...v.where, 18 | { createdAt: { $eq: null } } 19 | ] 20 | })) 21 | const res = await query.execute() 22 | should.exist(res.count) 23 | should.exist(res.rows) 24 | res.count.should.equal(0) 25 | res.rows.length.should.equal(0) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/connect.js: -------------------------------------------------------------------------------- 1 | import { connect } from '../src' 2 | import should from 'should' 3 | 4 | describe('connect', () => { 5 | it('should work with string', () => { 6 | should.exist(connect(`postgres://postgres@localhost:5432/dummy`)) 7 | }) 8 | it('should work with object', () => { 9 | should.exist(connect({ dialect: 'postgres' })) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /test/errors.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import getJSONField from '../src/util/getJSONField' 3 | import db from './fixtures/db' 4 | 5 | const dataType = { 6 | schema: { 7 | id: { 8 | type: 'text' 9 | } 10 | } 11 | } 12 | 13 | describe('errors', () => { 14 | const { user } = db.models 15 | 16 | it('should return errors with status', () => { 17 | try { 18 | getJSONField('noExist.id', { context: [ 'path' ], model: user, subSchemas: { data: dataType.schema } }) 19 | } catch (err) { 20 | should(err.status).eql(400) 21 | } 22 | }) 23 | 24 | it('should return errors with fields', () => { 25 | try { 26 | getJSONField('noExist.id', { context: [ 'path' ], model: user, subSchemas: { data: dataType.schema } }) 27 | } catch (err) { 28 | should(err.fields).eql([ 29 | { 30 | path: [ 'path' ], 31 | value: 'noExist.id', 32 | message: 'Field does not exist: noExist' 33 | } 34 | ]) 35 | } 36 | }) 37 | 38 | it('should return serializable errors', () => { 39 | try { 40 | getJSONField('noExist.id', { context: [ 'path' ], model: user, subSchemas: { data: dataType.schema } }) 41 | } catch (err) { 42 | should(err.toString()).eql(` 43 | Error: Validation Error 44 | Issues: 45 | - { path: [ 'path' ], value: 'noExist.id', message: 'Field does not exist: noExist' } 46 | `.trim()) 47 | } 48 | }) 49 | 50 | it('should allow removing specific field paths', () => { 51 | try { 52 | getJSONField('noExist.id', { context: [ 'test', 'path' ], model: user, subSchemas: { data: dataType.schema } }) 53 | } catch (err) { 54 | should(err.fields).eql([ 55 | { 56 | path: [ 'test', 'path' ], 57 | value: 'noExist.id', 58 | message: 'Field does not exist: noExist' 59 | } 60 | ]) 61 | 62 | err.removePath([ 'test' ]) 63 | 64 | should(err.isEmpty()).equal(true) 65 | should(err.fields).eql([]) 66 | } 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /test/fixtures/911-call.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": { 3 | "id": { 4 | "name": "ID", 5 | "type": "text", 6 | "validation": { 7 | "required": true, 8 | "notEmpty": true 9 | } 10 | }, 11 | "receivedAt": { 12 | "name": "Received", 13 | "type": "date", 14 | "validation": { 15 | "required": true 16 | } 17 | }, 18 | "dispatchedAt": { 19 | "name": "Dispatched", 20 | "type": "date" 21 | }, 22 | "arrivedAt": { 23 | "name": "Arrived", 24 | "type": "date" 25 | }, 26 | "units": { 27 | "name": "Units", 28 | "type": "array", 29 | "validation": { 30 | "notEmpty": true, 31 | "maxItems": 2048 32 | }, 33 | "items": { 34 | "name": "Unit ID", 35 | "type": "text", 36 | "validation": { 37 | "required": true, 38 | "notEmpty": true, 39 | "maxLength": 2048 40 | } 41 | } 42 | }, 43 | "officers": { 44 | "name": "Officers", 45 | "type": "array", 46 | "validation": { 47 | "notEmpty": true, 48 | "maxItems": 2048 49 | }, 50 | "items": { 51 | "name": "Officer ID", 52 | "type": "text", 53 | "validation": { 54 | "required": true, 55 | "notEmpty": true, 56 | "maxLength": 2048 57 | } 58 | } 59 | }, 60 | "code": { 61 | "name": "Code", 62 | "type": "text", 63 | "validation": { 64 | "notEmpty": true, 65 | "maxLength": 2048 66 | } 67 | }, 68 | "type": { 69 | "name": "Type", 70 | "type": "text", 71 | "validation": { 72 | "notEmpty": true, 73 | "maxLength": 2048 74 | } 75 | }, 76 | "notes": { 77 | "name": "Notes", 78 | "type": "text", 79 | "validation": { 80 | "notEmpty": true 81 | } 82 | }, 83 | "images": { 84 | "name": "Images", 85 | "type": "array", 86 | "validation": { 87 | "notEmpty": true, 88 | "maxItems": 2048 89 | }, 90 | "items": { 91 | "name": "Image", 92 | "type": "text", 93 | "validation": { 94 | "required": true, 95 | "image": true 96 | } 97 | } 98 | }, 99 | "address": { 100 | "name": "Address", 101 | "type": "text", 102 | "validation": { 103 | "notEmpty": true, 104 | "maxLength": 2048 105 | } 106 | }, 107 | "location": { 108 | "name": "Location", 109 | "type": "point", 110 | "validation": { 111 | "required": true 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /test/fixtures/analytics.js: -------------------------------------------------------------------------------- 1 | export const crimeTimeSeries = { 2 | filters: { 3 | sourceId: '911-calls', 4 | data: { 5 | receivedAt: { $ne: null } 6 | } 7 | }, 8 | aggregations: [ 9 | { value: { function: 'count' }, alias: 'total' }, 10 | { 11 | alias: 'day', 12 | value: { 13 | function: 'bucket', 14 | arguments: [ 15 | 'day', 16 | { field: 'data.receivedAt' } 17 | ] 18 | } 19 | } 20 | ], 21 | orderings: [ 22 | { value: { field: 'day' }, direction: 'desc' } 23 | ], 24 | groupings: [ 25 | { field: 'day' } 26 | ] 27 | } 28 | 29 | export const crimeBetweenQuarters = { 30 | filters: [ { 31 | sourceId: '911-calls', 32 | data: { 33 | receivedAt: { $ne: null } 34 | } 35 | }, 36 | { 37 | function: 'gte', 38 | arguments: [ 39 | { 40 | function: 'bucket', 41 | arguments: [ 'day', { field: 'data.receivedAt' } ] 42 | }, 43 | { 44 | function: 'bucket', 45 | arguments: [ 46 | 'day', 47 | new Date('1-1-2010').toISOString() 48 | ] 49 | } 50 | ] 51 | }, 52 | { 53 | function: 'lte', 54 | arguments: [ 55 | { 56 | function: 'bucket', 57 | arguments: [ 'day', { field: 'data.receivedAt' } ] 58 | }, 59 | { 60 | function: 'bucket', 61 | arguments: [ 62 | 'day', 63 | new Date(Date.now()).toISOString() 64 | ] 65 | } 66 | ] 67 | } 68 | ], 69 | aggregations: [ 70 | { value: { function: 'count' }, alias: 'total' }, 71 | { 72 | alias: 'day', 73 | value: { 74 | function: 'bucket', 75 | arguments: [ 76 | 'day', 77 | { field: 'data.receivedAt' } 78 | ] 79 | } 80 | } 81 | ], 82 | orderings: [ 83 | { value: { field: 'day' }, direction: 'desc' } 84 | ], 85 | groupings: [ 86 | { field: 'day' } 87 | ] 88 | } 89 | 90 | const seventies = new Date(0).toISOString() 91 | export const crimePerOfficer = { 92 | sourceId: '911-calls', 93 | visibility: 'public', 94 | filters: { 95 | data: { 96 | officers: { $ne: null } 97 | } 98 | }, 99 | aggregations: [ 100 | { 101 | value: { function: 'count' }, 102 | filters: { 103 | data: { 104 | receivedAt: { 105 | $lt: seventies 106 | } 107 | } 108 | }, 109 | alias: 'pre70s' 110 | }, 111 | { 112 | value: { function: 'count' }, 113 | filters: { 114 | data: { 115 | receivedAt: { 116 | $gte: seventies 117 | } 118 | } 119 | }, 120 | alias: 'weekly' 121 | }, 122 | { 123 | value: { function: 'count' }, 124 | alias: 'total' 125 | }, 126 | { 127 | alias: 'officer', 128 | value: { 129 | function: 'expand', 130 | arguments: [ 131 | { field: 'data.officers' } 132 | ] 133 | } 134 | } 135 | ], 136 | orderings: [ 137 | { value: { field: 'total' }, direction: 'desc' }, 138 | { value: { field: 'officer' }, direction: 'asc' } 139 | ], 140 | groupings: [ 141 | { field: 'officer' } 142 | ] 143 | } 144 | -------------------------------------------------------------------------------- /test/fixtures/bike-trip.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": { 3 | "id": { 4 | "name": "ID", 5 | "type": "text", 6 | "validation": { 7 | "required": true, 8 | "notEmpty": true 9 | } 10 | }, 11 | "startedAt": { 12 | "name": "Received", 13 | "type": "date", 14 | "validation": { 15 | "required": true 16 | } 17 | }, 18 | "endedAt": { 19 | "name": "Dispatched", 20 | "type": "date" 21 | }, 22 | "issues": { 23 | "name": "Issues", 24 | "type": "array", 25 | "validation": { 26 | "notEmpty": true, 27 | "maxItems": 2048 28 | }, 29 | "items": { 30 | "name": "Officer ID", 31 | "type": "text", 32 | "validation": { 33 | "required": true, 34 | "notEmpty": true, 35 | "maxLength": 2048 36 | } 37 | } 38 | }, 39 | "cost": { 40 | "name": "Cost", 41 | "type": "number", 42 | "measurement": { 43 | "type": "currency", 44 | "value": "usd" 45 | }, 46 | "validation": { 47 | "min": 0, 48 | "max": 10000 49 | } 50 | }, 51 | "tax": { 52 | "name": "Tax", 53 | "type": "number", 54 | "measurement": { 55 | "type": "currency", 56 | "value": "usd" 57 | }, 58 | "validation": { 59 | "min": 0, 60 | "max": 10000 61 | } 62 | }, 63 | "type": { 64 | "name": "Type", 65 | "type": "text", 66 | "validation": { 67 | "notEmpty": true, 68 | "maxLength": 2048 69 | } 70 | }, 71 | "notes": { 72 | "name": "Notes", 73 | "type": "text", 74 | "validation": { 75 | "notEmpty": true 76 | } 77 | }, 78 | "images": { 79 | "name": "Images", 80 | "type": "array", 81 | "validation": { 82 | "notEmpty": true, 83 | "maxItems": 2048 84 | }, 85 | "items": { 86 | "name": "Image", 87 | "type": "text", 88 | "validation": { 89 | "required": true, 90 | "image": true 91 | } 92 | } 93 | }, 94 | "path": { 95 | "name": "Path", 96 | "type": "line", 97 | "validation": { 98 | "required": true 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/fixtures/seed-stores.js: -------------------------------------------------------------------------------- 1 | import db from './db' 2 | 3 | const data = [ 4 | { 5 | name: 'Nike', 6 | type: 'shoes', 7 | location: { 8 | type: 'Point', 9 | coordinates: [ 10 | 5, 5 11 | ] 12 | } 13 | }, 14 | { 15 | name: 'Petco', 16 | type: 'animals', 17 | location: { 18 | type: 'Point', 19 | coordinates: [ 20 | 10, 10 21 | ] 22 | } 23 | }, 24 | { 25 | name: 'Goodwill', 26 | type: 'thrift', 27 | location: { 28 | type: 'Point', 29 | coordinates: [ 30 | 20, 20 31 | ] 32 | } 33 | } 34 | ].map((v) => db.models.store.build(v).toJSON()) // generate the IDs 35 | 36 | export default async () => 37 | Promise.all(data.map(async (i) => 38 | db.models.store.upsert(i) 39 | )) 40 | -------------------------------------------------------------------------------- /test/fixtures/seed-users.js: -------------------------------------------------------------------------------- 1 | import db from './db' 2 | 3 | const data = [ 4 | { 5 | email: 'yo@yo.com', 6 | name: 'Yo Yo 1', 7 | authId: 'yaba', 8 | authToken: 'yoyo', 9 | settings: { 10 | vegan: true, 11 | glutenAllergy: false 12 | } 13 | }, 14 | { 15 | email: 'yo2@yo.com', 16 | name: 'Yo Yo 2', 17 | authId: 'yaba2', 18 | authToken: 'yoyo', 19 | settings: { 20 | vegan: true, 21 | glutenAllergy: false 22 | } 23 | }, 24 | { 25 | email: 'yo3@yo.com', 26 | name: 'Yo Yo 3', 27 | authId: 'yaba3', 28 | authToken: 'yoyo', 29 | settings: { 30 | vegan: true, 31 | glutenAllergy: false 32 | } 33 | } 34 | ].map((v) => db.models.user.build(v).toJSON()) // generate the IDs 35 | 36 | export default async () => 37 | Promise.all(data.map(async (i) => 38 | db.models.user.upsert(i) 39 | )) 40 | -------------------------------------------------------------------------------- /test/fixtures/transit-passenger.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": { 3 | "id": { 4 | "name": "ID", 5 | "type": "text", 6 | "validation": { 7 | "required": true, 8 | "notEmpty": true 9 | } 10 | }, 11 | "route": { 12 | "name": "Route", 13 | "type": "text", 14 | "validation": { 15 | "required": true 16 | } 17 | }, 18 | "year": { 19 | "name": "Received", 20 | "type": "number", 21 | "measurement": { 22 | "type": "datePart", 23 | "value": "year" 24 | }, 25 | "validation": { 26 | "required": true 27 | } 28 | }, 29 | "passengers": { 30 | "name": "Passengers", 31 | "type": "number", 32 | "validation": { 33 | "min": 0 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/fixtures/transit-trip.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": { 3 | "id": { 4 | "name": "ID", 5 | "type": "text", 6 | "validation": { 7 | "required": true, 8 | "notEmpty": true 9 | } 10 | }, 11 | "route": { 12 | "name": "Route", 13 | "type": "text", 14 | "validation": { 15 | "required": true 16 | } 17 | }, 18 | "year": { 19 | "name": "Received", 20 | "type": "number", 21 | "measurement": { 22 | "type": "datePart", 23 | "value": "year" 24 | }, 25 | "validation": { 26 | "required": true 27 | } 28 | }, 29 | "miles": { 30 | "name": "Miles", 31 | "type": "number", 32 | "validation": { 33 | "min": 0 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import { setup as setupTests } from '../src' 2 | import db from './fixtures/db' 3 | import seedUsers from './fixtures/seed-users' 4 | import seedStores from './fixtures/seed-stores' 5 | import seedData from './fixtures/seed-data' 6 | 7 | before(async () => { 8 | await db.sync() 9 | await setupTests(db) 10 | }) 11 | 12 | beforeEach(async () => { 13 | await seedUsers() 14 | await seedStores() 15 | await seedData() 16 | }) 17 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import { setup } from '../src' 2 | import db from './fixtures/db' 3 | 4 | describe('setup', () => { 5 | it('should setup', async () => { 6 | await setup(db) 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /test/sql/time.js: -------------------------------------------------------------------------------- 1 | import { QueryTypes } from 'sequelize' 2 | import should from 'should' 3 | import db from '../fixtures/db' 4 | 5 | const select = async (q, returnSet = false, replacements) => { 6 | const res = await db.query(`select ${q}`, { 7 | type: QueryTypes.SELECT, 8 | plain: !returnSet, 9 | replacements 10 | }) 11 | if (Array.isArray(res)) return res 12 | const keys = Object.keys(res) 13 | if (keys.length === 1) return res[keys[0]] 14 | return res 15 | } 16 | 17 | describe('sql#time#time_to_ms', () => { 18 | it('should handle a basic date', async () => { 19 | const now = new Date() 20 | should(await select('time_to_ms(:time)', false, { 21 | time: now 22 | })).eql(now.getTime()) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/types/functions/area.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/average.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../../src' 3 | import db from '../../fixtures/db' 4 | import dataType from '../../fixtures/bike-trip' 5 | 6 | describe('types#functions#average', () => { 7 | const { datum } = db.models 8 | 9 | it('should work', async () => { 10 | const funcVal = { 11 | function: 'average', 12 | arguments: [ 13 | { field: 'data.cost' } 14 | ] 15 | } 16 | const fullQuery = { 17 | filters: { sourceId: 'bike-trips' }, 18 | aggregations: [ 19 | { value: funcVal, alias: 'cost' }, 20 | { value: { field: 'data.type' }, alias: 'type' } 21 | ], 22 | groupings: [ 23 | { field: 'type' } 24 | ] 25 | } 26 | const expectedResponse = [ 27 | { cost: 5.14, type: 'electric' }, 28 | { cost: 50.14, type: 'regular' } 29 | ] 30 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 31 | const res = await query.execute() 32 | should(res).eql(expectedResponse) 33 | }) 34 | it('should fail when given invalid arguments', async () => { 35 | const funcVal = { 36 | function: 'average', 37 | arguments: [ 38 | 'abc' 39 | ] 40 | } 41 | const fullQuery = { 42 | filters: { sourceId: 'bike-trips' }, 43 | aggregations: [ 44 | { value: { function: 'count' }, alias: 'total' }, 45 | { value: { field: 'data.type' }, alias: 'type' }, 46 | { value: { function: 'sum', arguments: [ funcVal ] }, alias: 'cost' } 47 | ], 48 | groupings: [ 49 | { field: 'type' } 50 | ] 51 | } 52 | try { 53 | new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 54 | } catch (err) { 55 | should.exist(err) 56 | should(err.fields).eql([ { 57 | path: [ 'aggregations', 2, 'value', 'arguments', 0, 'arguments', 0 ], 58 | value: 'abc', 59 | message: 'Argument "Value" for "Average" must be of type: number - instead got text' 60 | } ]) 61 | return 62 | } 63 | throw new Error('Did not throw!') 64 | }) 65 | it('should bubble up schema correctly', async () => { 66 | const funcVal = { 67 | function: 'average', 68 | arguments: [ 69 | { field: 'data.cost' } 70 | ] 71 | } 72 | const fullQuery = { 73 | filters: { sourceId: 'bike-trips' }, 74 | aggregations: [ 75 | { value: funcVal, alias: 'cost' }, 76 | { value: { field: 'data.type' }, alias: 'type' } 77 | ], 78 | groupings: [ 79 | { field: 'type' } 80 | ] 81 | } 82 | const expectedResponse = { 83 | cost: { 84 | name: 'Cost', 85 | type: 'number', 86 | measurement: { 87 | type: 'currency', 88 | value: 'usd' 89 | } 90 | }, 91 | type: { 92 | name: 'Type', 93 | type: 'text', 94 | validation: { notEmpty: true, maxLength: 2048 } 95 | } 96 | } 97 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 98 | const res = query.getOutputSchema() 99 | should(res).eql(expectedResponse) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/types/functions/boundingBox.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/count.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../../src' 3 | import db from '../../fixtures/db' 4 | import dataType from '../../fixtures/bike-trip' 5 | 6 | describe('types#functions#count', () => { 7 | const { datum } = db.models 8 | 9 | it('should work', async () => { 10 | const funcVal = { function: 'count' } 11 | const fullQuery = { 12 | filters: { sourceId: 'bike-trips' }, 13 | aggregations: [ 14 | { value: funcVal, alias: 'total' }, 15 | { value: { field: 'data.type' }, alias: 'type' } 16 | ], 17 | groupings: [ 18 | { field: 'type' } 19 | ] 20 | } 21 | const expectedResponse = [ 22 | { total: 1, type: 'electric' }, 23 | { total: 1, type: 'regular' } 24 | ] 25 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 26 | const res = await query.execute() 27 | should(res).eql(expectedResponse) 28 | }) 29 | it('should bubble up schema correctly', async () => { 30 | const funcVal = { function: 'count' } 31 | const fullQuery = { 32 | filters: { sourceId: 'bike-trips' }, 33 | aggregations: [ 34 | { value: funcVal, alias: 'total' }, 35 | { value: { field: 'data.type' }, alias: 'type' } 36 | ], 37 | groupings: [ 38 | { field: 'type' } 39 | ] 40 | } 41 | const expectedResponse = { 42 | total: { 43 | name: 'Total', 44 | type: 'number' 45 | }, 46 | type: { 47 | name: 'Type', 48 | type: 'text', 49 | validation: { notEmpty: true, maxLength: 2048 } 50 | } 51 | } 52 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 53 | const res = query.getOutputSchema() 54 | should(res).eql(expectedResponse) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /test/types/functions/eq.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/expand.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/geojson.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/gt.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/gte.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/intersects.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/interval.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/last.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/length.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/lt.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/lte.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/median.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../../src' 3 | import db from '../../fixtures/db' 4 | import dataType from '../../fixtures/bike-trip' 5 | 6 | describe('types#functions#median', () => { 7 | const { datum } = db.models 8 | 9 | it('should work', async () => { 10 | const funcVal = { 11 | function: 'median', 12 | arguments: [ 13 | { field: 'data.cost' } 14 | ] 15 | } 16 | const fullQuery = { 17 | filters: { sourceId: 'bike-trips' }, 18 | aggregations: [ 19 | { value: funcVal, alias: 'cost' }, 20 | { value: { field: 'data.type' }, alias: 'type' } 21 | ], 22 | groupings: [ 23 | { field: 'type' } 24 | ] 25 | } 26 | const expectedResponse = [ 27 | { cost: 5.14, type: 'electric' }, 28 | { cost: 50.14, type: 'regular' } 29 | ] 30 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 31 | const res = await query.execute() 32 | should(res).eql(expectedResponse) 33 | }) 34 | it('should fail when given invalid arguments', async () => { 35 | const funcVal = { 36 | function: 'median', 37 | arguments: [ 38 | 'abc' 39 | ] 40 | } 41 | const fullQuery = { 42 | filters: { sourceId: 'bike-trips' }, 43 | aggregations: [ 44 | { value: { function: 'count' }, alias: 'total' }, 45 | { value: { field: 'data.type' }, alias: 'type' }, 46 | { value: { function: 'sum', arguments: [ funcVal ] }, alias: 'cost' } 47 | ], 48 | groupings: [ 49 | { field: 'type' } 50 | ] 51 | } 52 | try { 53 | new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 54 | } catch (err) { 55 | should.exist(err) 56 | should(err.fields).eql([ { 57 | path: [ 'aggregations', 2, 'value', 'arguments', 0, 'arguments', 0 ], 58 | value: 'abc', 59 | message: 'Argument "Value" for "Median" must be of type: number - instead got text' 60 | } ]) 61 | return 62 | } 63 | throw new Error('Did not throw!') 64 | }) 65 | it('should bubble up schema correctly', async () => { 66 | const funcVal = { 67 | function: 'median', 68 | arguments: [ 69 | { field: 'data.cost' } 70 | ] 71 | } 72 | const fullQuery = { 73 | filters: { sourceId: 'bike-trips' }, 74 | aggregations: [ 75 | { value: funcVal, alias: 'cost' }, 76 | { value: { field: 'data.type' }, alias: 'type' } 77 | ], 78 | groupings: [ 79 | { field: 'type' } 80 | ] 81 | } 82 | const expectedResponse = { 83 | cost: { 84 | name: 'Cost', 85 | type: 'number', 86 | measurement: { 87 | type: 'currency', 88 | value: 'usd' 89 | } 90 | }, 91 | type: { 92 | name: 'Type', 93 | type: 'text', 94 | validation: { notEmpty: true, maxLength: 2048 } 95 | } 96 | } 97 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 98 | const res = query.getOutputSchema() 99 | should(res).eql(expectedResponse) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/types/functions/min.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../../src' 3 | import db from '../../fixtures/db' 4 | import dataType from '../../fixtures/bike-trip' 5 | 6 | describe('types#functions#min', () => { 7 | const { datum } = db.models 8 | 9 | it('should work', async () => { 10 | const funcVal = { 11 | function: 'min', 12 | arguments: [ 13 | { field: 'data.cost' } 14 | ] 15 | } 16 | const fullQuery = { 17 | filters: { sourceId: 'bike-trips' }, 18 | aggregations: [ 19 | { value: funcVal, alias: 'cost' }, 20 | { value: { field: 'data.type' }, alias: 'type' } 21 | ], 22 | groupings: [ 23 | { field: 'type' } 24 | ] 25 | } 26 | const expectedResponse = [ 27 | { cost: 5.14, type: 'electric' }, 28 | { cost: 50.14, type: 'regular' } 29 | ] 30 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 31 | const res = await query.execute() 32 | should(res).eql(expectedResponse) 33 | }) 34 | it('should fail when given invalid arguments', async () => { 35 | const funcVal = { 36 | function: 'min', 37 | arguments: [ 38 | 'abc' 39 | ] 40 | } 41 | const fullQuery = { 42 | filters: { sourceId: 'bike-trips' }, 43 | aggregations: [ 44 | { value: { function: 'count' }, alias: 'total' }, 45 | { value: { field: 'data.type' }, alias: 'type' }, 46 | { value: { function: 'sum', arguments: [ funcVal ] }, alias: 'cost' } 47 | ], 48 | groupings: [ 49 | { field: 'type' } 50 | ] 51 | } 52 | try { 53 | new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 54 | } catch (err) { 55 | should.exist(err) 56 | should(err.fields).eql([ { 57 | path: [ 'aggregations', 2, 'value', 'arguments', 0, 'arguments', 0 ], 58 | value: 'abc', 59 | message: 'Argument "Value" for "Minimum" must be of type: number, date - instead got text' 60 | } ]) 61 | return 62 | } 63 | throw new Error('Did not throw!') 64 | }) 65 | it('should bubble up schema correctly', async () => { 66 | const funcVal = { 67 | function: 'min', 68 | arguments: [ 69 | { field: 'data.cost' } 70 | ] 71 | } 72 | const fullQuery = { 73 | filters: { sourceId: 'bike-trips' }, 74 | aggregations: [ 75 | { value: funcVal, alias: 'cost' }, 76 | { value: { field: 'data.type' }, alias: 'type' } 77 | ], 78 | groupings: [ 79 | { field: 'type' } 80 | ] 81 | } 82 | const expectedResponse = { 83 | cost: { 84 | name: 'Cost', 85 | type: 'number', 86 | measurement: { 87 | type: 'currency', 88 | value: 'usd' 89 | } 90 | }, 91 | type: { 92 | name: 'Type', 93 | type: 'text', 94 | validation: { notEmpty: true, maxLength: 2048 } 95 | } 96 | } 97 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 98 | const res = query.getOutputSchema() 99 | should(res).eql(expectedResponse) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/types/functions/now.js: -------------------------------------------------------------------------------- 1 | // TODO-TEST 2 | -------------------------------------------------------------------------------- /test/types/functions/percentage.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../../src' 3 | import db from '../../fixtures/db' 4 | import dataType from '../../fixtures/bike-trip' 5 | 6 | describe('types#functions#percentage', () => { 7 | const { datum } = db.models 8 | 9 | it('should work with subfield / subfield', async () => { 10 | const funcVal = { 11 | function: 'percentage', 12 | arguments: [ 13 | { field: 'data.tax' }, 14 | { field: 'data.cost' } 15 | ] 16 | } 17 | const fullQuery = { 18 | filters: { sourceId: 'bike-trips' }, 19 | aggregations: [ 20 | { value: { function: 'count' }, alias: 'total' }, 21 | { value: { field: 'data.type' }, alias: 'type' }, 22 | { value: { function: 'sum', arguments: [ funcVal ] }, alias: 'avgTaxRatio' } 23 | ], 24 | groupings: [ 25 | { field: 'type' } 26 | ] 27 | } 28 | const expectedResponse = [ 29 | { total: 1, type: 'electric', avgTaxRatio: 0.3093385214007782 }, 30 | { total: 1, type: 'regular', avgTaxRatio: 0.2052253689668927 } 31 | ] 32 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 33 | const res = await query.execute() 34 | should(res).eql(expectedResponse) 35 | }) 36 | it('should work with value / subfield', async () => { 37 | const funcVal = { 38 | function: 'percentage', 39 | arguments: [ 40 | 1, 41 | { field: 'data.cost' } 42 | ] 43 | } 44 | const fullQuery = { 45 | filters: { sourceId: 'bike-trips' }, 46 | aggregations: [ 47 | { value: { function: 'count' }, alias: 'total' }, 48 | { value: { field: 'data.type' }, alias: 'type' }, 49 | { value: { function: 'sum', arguments: [ funcVal ] }, alias: 'costRatio' } 50 | ], 51 | groupings: [ 52 | { field: 'type' } 53 | ] 54 | } 55 | const expectedResponse = [ 56 | { total: 1, type: 'electric', costRatio: 0.19455252918287938 }, 57 | { total: 1, type: 'regular', costRatio: 0.01994415636218588 } 58 | ] 59 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 60 | const res = await query.execute() 61 | should(res).eql(expectedResponse) 62 | }) 63 | it('should fail when given invalid arguments', async () => { 64 | const funcVal = { 65 | function: 'percentage', 66 | arguments: [ 67 | { field: 'data.cost' }, 68 | 'abc' 69 | ] 70 | } 71 | const fullQuery = { 72 | filters: { sourceId: 'bike-trips' }, 73 | aggregations: [ 74 | { value: { function: 'count' }, alias: 'total' }, 75 | { value: { field: 'data.type' }, alias: 'type' }, 76 | { value: { function: 'sum', arguments: [ funcVal ] }, alias: 'cost' } 77 | ], 78 | groupings: [ 79 | { field: 'type' } 80 | ] 81 | } 82 | try { 83 | new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 84 | } catch (err) { 85 | should.exist(err) 86 | should(err.fields).eql([ { 87 | path: [ 'aggregations', 2, 'value', 'arguments', 0, 'arguments', 1 ], 88 | value: 'abc', 89 | message: 'Argument "Value B" for "Percentage" must be of type: number - instead got text' 90 | } ]) 91 | return 92 | } 93 | throw new Error('Did not throw!') 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /test/types/functions/sum.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { AnalyticsQuery } from '../../../src' 3 | import db from '../../fixtures/db' 4 | import dataType from '../../fixtures/bike-trip' 5 | 6 | describe('types#functions#sum', () => { 7 | const { datum } = db.models 8 | 9 | it('should work', async () => { 10 | const funcVal = { 11 | function: 'sum', 12 | arguments: [ 13 | { field: 'data.cost' } 14 | ] 15 | } 16 | const fullQuery = { 17 | filters: { sourceId: 'bike-trips' }, 18 | aggregations: [ 19 | { value: funcVal, alias: 'cost' }, 20 | { value: { field: 'data.type' }, alias: 'type' } 21 | ], 22 | groupings: [ 23 | { field: 'type' } 24 | ] 25 | } 26 | const expectedResponse = [ 27 | { cost: 5.14, type: 'electric' }, 28 | { cost: 50.14, type: 'regular' } 29 | ] 30 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 31 | const res = await query.execute() 32 | should(res).eql(expectedResponse) 33 | }) 34 | it('should fail when given invalid arguments', async () => { 35 | const funcVal = { 36 | function: 'sum', 37 | arguments: [ 38 | 'abc' 39 | ] 40 | } 41 | const fullQuery = { 42 | filters: { sourceId: 'bike-trips' }, 43 | aggregations: [ 44 | { value: { function: 'count' }, alias: 'total' }, 45 | { value: { field: 'data.type' }, alias: 'type' }, 46 | { value: { function: 'sum', arguments: [ funcVal ] }, alias: 'cost' } 47 | ], 48 | groupings: [ 49 | { field: 'type' } 50 | ] 51 | } 52 | try { 53 | new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 54 | } catch (err) { 55 | should.exist(err) 56 | should(err.fields).eql([ { 57 | path: [ 'aggregations', 2, 'value', 'arguments', 0, 'arguments', 0 ], 58 | value: 'abc', 59 | message: 'Argument "Value" for "Sum" must be of type: number - instead got text' 60 | } ]) 61 | return 62 | } 63 | throw new Error('Did not throw!') 64 | }) 65 | it('should bubble up schema correctly', async () => { 66 | const funcVal = { 67 | function: 'sum', 68 | arguments: [ 69 | { field: 'data.cost' } 70 | ] 71 | } 72 | const fullQuery = { 73 | filters: { sourceId: 'bike-trips' }, 74 | aggregations: [ 75 | { value: funcVal, alias: 'cost' }, 76 | { value: { field: 'data.type' }, alias: 'type' } 77 | ], 78 | groupings: [ 79 | { field: 'type' } 80 | ] 81 | } 82 | const expectedResponse = { 83 | cost: { 84 | name: 'Cost', 85 | type: 'number', 86 | measurement: { 87 | type: 'currency', 88 | value: 'usd' 89 | } 90 | }, 91 | type: { 92 | name: 'Type', 93 | type: 'text', 94 | validation: { notEmpty: true, maxLength: 2048 } 95 | } 96 | } 97 | const query = new AnalyticsQuery(fullQuery, { model: datum, subSchemas: { data: dataType.schema } }) 98 | const res = query.getOutputSchema() 99 | should(res).eql(expectedResponse) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/types/getTypes.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import fn from '../../src/types/getTypes' 3 | import db from '../fixtures/db' 4 | import dataType from '../fixtures/bike-trip' 5 | 6 | describe('types#getTypes', () => { 7 | const opt = { model: db.models.datum, subSchemas: { data: dataType.schema } } 8 | it('should work on plain values', () => { 9 | should(fn(1)).eql([ { type: 'number' } ]) 10 | should(fn(1000)).eql([ { type: 'number' } ]) 11 | should(fn('1000')).eql([ { type: 'number' }, { type: 'text' } ]) 12 | should(fn('test')).eql([ { type: 'text' } ]) 13 | should(fn(new Date().toISOString())).eql([ { type: 'date' }, { type: 'text' } ]) 14 | should(fn({ type: 'Point', coordinates: [ 1, 1 ] })).eql([ { type: 'object' }, { type: 'point' } ]) 15 | }) 16 | it('should work on functions', () => { 17 | should(fn({ function: 'now' })).eql([ { type: 'date' } ]) 18 | should(fn({ function: 'add', arguments: [ 1, 1 ] })).eql([ { type: 'number' } ]) 19 | }) 20 | it('should work on functions that bubble up types', () => { 21 | should(fn({ function: 'max', arguments: [ { field: 'data.startedAt' } ] }, opt)).eql([ { type: 'date' } ]) 22 | should(fn({ function: 'max', arguments: [ { field: 'data.cost' } ] }, opt)).eql([ { 23 | type: 'number', 24 | measurement: { 25 | type: 'currency', 26 | value: 'usd' 27 | } 28 | } ]) 29 | }) 30 | it('should work on functions that bubble up measurements', () => { 31 | should(fn({ function: 'add', arguments: [ { field: 'data.cost' }, 1 ] }, opt)).eql([ { 32 | type: 'number', 33 | measurement: { 34 | type: 'currency', 35 | value: 'usd' 36 | } 37 | } ]) 38 | }) 39 | it('should work on plain fields', () => { 40 | should(fn({ field: 'data.startedAt' }, opt)).eql([ { type: 'date', validation: { required: true } } ]) 41 | should(fn({ field: 'data.cost' }, opt)).eql([ { 42 | type: 'number', 43 | measurement: { 44 | type: 'currency', 45 | value: 'usd' 46 | }, 47 | validation: { 48 | max: 10000, 49 | min: 0 50 | } 51 | } ]) 52 | }) 53 | it('should work on top-level', () => { 54 | should(fn({ field: 'createdAt' }, opt)).eql([ { 55 | name: 'Created', 56 | notes: 'Date and time this data was created', 57 | type: 'date' 58 | } ]) 59 | should(fn({ field: 'sourceId' }, opt)).eql([ { 60 | name: 'Source ID', 61 | type: 'text' 62 | } ]) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /test/types/toSchemaType.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import sql from 'sequelize' 3 | import fn from '../../src/types/toSchemaType' 4 | 5 | describe('types#toSchemaType', () => { 6 | it('should work on text types', () => { 7 | const expected = { type: 'text' } 8 | should(fn(sql.STRING)).eql(expected) 9 | should(fn(sql.STRING(100))).eql(expected) 10 | should(fn(sql.STRING.BINARY)).eql(expected) 11 | should(fn(sql.TEXT)).eql(expected) 12 | should(fn(sql.TEXT('tiny'))).eql(expected) 13 | should(fn(sql.UUID)).eql(expected) 14 | should(fn(sql.CITEXT)).eql(expected) 15 | should(fn(sql.CHAR)).eql(expected) 16 | }) 17 | it('should work on date types', () => { 18 | const expected = { type: 'date' } 19 | should(fn(sql.DATE)).eql(expected) 20 | should(fn(sql.DATE(6))).eql(expected) 21 | should(fn(sql.DATEONLY)).eql(expected) 22 | }) 23 | it('should work on boolean types', () => { 24 | const expected = { type: 'boolean' } 25 | should(fn(sql.BOOLEAN)).eql(expected) 26 | }) 27 | it('should work on number types', () => { 28 | const expected = { type: 'number' } 29 | should(fn(sql.INTEGER)).eql(expected) 30 | should(fn(sql.TINYINT)).eql(expected) 31 | should(fn(sql.SMALLINT)).eql(expected) 32 | should(fn(sql.BIGINT)).eql(expected) 33 | should(fn(sql.BIGINT(11))).eql(expected) 34 | should(fn(sql.FLOAT)).eql(expected) 35 | should(fn(sql.FLOAT(11))).eql(expected) 36 | should(fn(sql.FLOAT(11, 10))).eql(expected) 37 | should(fn(sql.REAL)).eql(expected) 38 | should(fn(sql.REAL(11))).eql(expected) 39 | should(fn(sql.REAL(11, 10))).eql(expected) 40 | should(fn(sql.DOUBLE)).eql(expected) 41 | should(fn(sql.DOUBLE(11))).eql(expected) 42 | should(fn(sql.DOUBLE(11, 10))).eql(expected) 43 | should(fn(sql.DECIMAL)).eql(expected) 44 | should(fn(sql.DECIMAL(11, 10))).eql(expected) 45 | }) 46 | it('should work on json types', () => { 47 | const expected = { type: 'object', schema: undefined } 48 | should(fn(sql.JSON)).eql(expected) 49 | should(fn(sql.JSONB)).eql(expected) 50 | }) 51 | it('should work on array types', () => { 52 | should(fn(sql.ARRAY(sql.STRING))).eql({ 53 | type: 'array', 54 | items: { 55 | type: 'text' 56 | } 57 | }) 58 | should(fn(sql.ARRAY(sql.FLOAT))).eql({ 59 | type: 'array', 60 | items: { 61 | type: 'number' 62 | } 63 | }) 64 | }) 65 | it('should work on geometry types', () => { 66 | should(fn(sql.GEOMETRY)).eql({ type: 'geometry' }) 67 | should(fn(sql.GEOMETRY('POINT'))).eql({ type: 'point' }) 68 | should(fn(sql.GEOMETRY('LINESTRING'))).eql({ type: 'line' }) 69 | should(fn(sql.GEOMETRY('MULTILINESTRING'))).eql({ type: 'multiline' }) 70 | should(fn(sql.GEOMETRY('POLYGON'))).eql({ type: 'polygon' }) 71 | should(fn(sql.GEOMETRY('MULTIPOLYGON'))).eql({ type: 'multipolygon' }) 72 | should(fn(sql.GEOGRAPHY)).eql({ type: 'geometry' }) 73 | should(fn(sql.GEOGRAPHY('POINT'))).eql({ type: 'point' }) 74 | should(fn(sql.GEOGRAPHY('LINESTRING'))).eql({ type: 'line' }) 75 | should(fn(sql.GEOGRAPHY('MULTILINESTRING'))).eql({ type: 'multiline' }) 76 | should(fn(sql.GEOGRAPHY('POLYGON'))).eql({ type: 'polygon' }) 77 | should(fn(sql.GEOGRAPHY('MULTIPOLYGON'))).eql({ type: 'multipolygon' }) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /test/util/aggregateWithFilter.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import aggregateWithFilter from '../../src/util/aggregateWithFilter' 3 | import { Aggregation, Filter } from '../../src' 4 | import db from '../fixtures/db' 5 | 6 | describe('util#aggregateWithFilter', () => { 7 | const { user } = db.models 8 | 9 | const agg = new Aggregation({ 10 | value: { function: 'now' }, 11 | alias: 'now' 12 | }, { model: user }) 13 | 14 | it('should throw without opts', () => { 15 | should.throws(() => aggregateWithFilter({})) 16 | should.throws(() => aggregateWithFilter({ filters: undefined, model: user })) 17 | should.throws(() => aggregateWithFilter({ aggregation: undefined, filters: [], model: user })) 18 | should.throws(() => aggregateWithFilter({ aggregation: agg, model: user })) 19 | }) 20 | 21 | it('should return aggregation', () => { 22 | const filters = new Filter({ name: { $ne: null } }, { model: user }) 23 | 24 | const t = aggregateWithFilter({ aggregation: agg, filters, model: user }) 25 | should(t.val).equal('[object Object] FILTER (WHERE 1=1)') 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/util/export.js: -------------------------------------------------------------------------------- 1 | // TODO: more tests 2 | -------------------------------------------------------------------------------- /test/util/fixJSONFilters.js: -------------------------------------------------------------------------------- 1 | // TODO: more tests 2 | 3 | import should from 'should' 4 | import { hydrate } from '../../src/util/fixJSONFilters' 5 | import db from '../fixtures/db' 6 | 7 | describe('util#fixJSONFilters#hydrate', () => { 8 | const { user } = db.models 9 | 10 | it('should skip hydrating when no data type specified', () => { 11 | const t = hydrate({ id: '' }, { model: user }) 12 | should(t).eql({ id: '' }) 13 | }) 14 | 15 | it('should accept array of fields as input', () => { 16 | const t = hydrate([ { id: '' } ], { model: user }) 17 | should(t).eql({ $and: [ { id: '' } ] }) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test/util/getGeoFields.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import getGeoFields from '../../src/util/getGeoFields' 3 | import db from '../fixtures/db' 4 | 5 | describe('util#getGeoFields', () => { 6 | const { user, store } = db.models 7 | 8 | it('should return null for a non geo model ', () => { 9 | const t = getGeoFields(user) 10 | should(t).equal(null) 11 | }) 12 | 13 | it('should return location for a geo model ', () => { 14 | const t = getGeoFields(store) 15 | should(t).deepEqual([ 'location' ]) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/util/getJSONField.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import getJSONField from '../../src/util/getJSONField' 3 | import db from '../fixtures/db' 4 | 5 | const dataType = { 6 | schema: { 7 | id: { 8 | type: 'text' 9 | } 10 | } 11 | } 12 | 13 | describe('util#getJSONField', () => { 14 | const { user } = db.models 15 | 16 | it('should return json fields', () => { 17 | const t = getJSONField('settings.id', { model: user, subSchemas: { settings: dataType.schema } }) 18 | should(t.val).equal('"user"."settings"#>>\'{id}\'') 19 | }) 20 | 21 | it('should return json fields with subSchema', () => { 22 | const t = getJSONField('settings.id', { model: user, subSchemas: { settings: dataType.schema } }) 23 | should(t.val).equal('"user"."settings"#>>\'{id}\'') 24 | }) 25 | 26 | it('should error if root field does not exist', () => { 27 | try { 28 | getJSONField('noExist.id', { context: [ 'path' ], model: user, subSchemas: { settings: dataType.schema } }) 29 | } catch (err) { 30 | err.fields.should.eql([ 31 | { 32 | path: [ 'path' ], 33 | value: 'noExist.id', 34 | message: 'Field does not exist: noExist' 35 | } 36 | ]) 37 | } 38 | }) 39 | 40 | it('should error if primary field subschema does not exist', () => { 41 | try { 42 | getJSONField('settings.noExist', { context: [ 'path' ], model: user }) 43 | } catch (err) { 44 | err.fields.should.eql([ 45 | { 46 | path: [ 'path' ], 47 | value: 'settings.noExist', 48 | message: 'Field is not queryable: settings' 49 | } 50 | ]) 51 | } 52 | }) 53 | 54 | it('should error if sub field does not exist', () => { 55 | try { 56 | getJSONField('settings.noExist', { context: [ 'path' ], model: user, subSchemas: { settings: dataType.schema } }) 57 | } catch (err) { 58 | err.fields.should.eql([ 59 | { 60 | path: [ 'path' ], 61 | value: 'settings.noExist', 62 | message: 'Field does not exist: settings.noExist' 63 | } 64 | ]) 65 | } 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /test/util/getScopedAttributes.js: -------------------------------------------------------------------------------- 1 | // TODO: more tests 2 | -------------------------------------------------------------------------------- /test/util/iffy/date.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import date from '../../../src/util/iffy/date' 3 | 4 | describe('util#iffy#date', () => { 5 | it('should exist', () => { 6 | date.should.not.be.null() 7 | }) 8 | it('should parse date', () => { 9 | should(date(95376520918).getTime()).eql(95376520918) 10 | }) 11 | it('should throw on invalid date', () => { 12 | should.throws(() => date('bla bla bla')) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /test/util/iffy/number.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import number from '../../../src/util/iffy/number' 3 | 4 | describe('util#iffy#number', () => { 5 | it('should exist', () => { 6 | should(number).not.be.null() 7 | }) 8 | it('should process number', () => { 9 | number(5).should.eql(5) 10 | }) 11 | it('should process number string', () => { 12 | number('256').should.eql(256) 13 | }) 14 | it('should throw error on invalid number', () => { 15 | should.throws(() => number('fity')) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/util/iffy/stringArray.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import stringArray from '../../../src/util/iffy/stringArray' 3 | 4 | describe('util#iffy#stringArray', () => { 5 | it('should exist', () => { 6 | should(stringArray).not.be.null() 7 | }) 8 | it('should create string array', () => { 9 | const sample = [ 'a', 'b', 'c', 'd', 'e', 'f' ] 10 | stringArray(sample).should.eql(sample) 11 | stringArray(sample.join(',')).should.eql(sample) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /test/util/intersects.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import intersects from '../../src/util/intersects' 3 | import db from '../fixtures/db' 4 | 5 | describe('util#intersects', () => { 6 | const { user, store } = db.models 7 | 8 | it('should return error', () => { 9 | should.throws(() => intersects({})) 10 | }) 11 | 12 | it('should return false when no geo fields', () => { 13 | const t = intersects({ type: 'Polygon', coordinates: [ 1, 2 ] }, { model: user }) 14 | should(t.val).equal(false) 15 | }) 16 | 17 | it('should return intersects', () => { 18 | const t = intersects({ type: 'Point', coordinates: [ 1, 2 ] }, { model: store }) 19 | should(t.fn).equal('ST_Intersects') 20 | should.exist(t.args[0]) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/util/isQueryValue.js: -------------------------------------------------------------------------------- 1 | // TODO: more tests 2 | -------------------------------------------------------------------------------- /test/util/isValidCoordinate.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { lat, lon } from '../../src/util/isValidCoordinate' 3 | 4 | describe('util#isValidCoordinate', () => { 5 | 6 | it('should return type error', () => { 7 | should(lat('nope')).equal('Latitude not a number, got string') 8 | should(lon('nope')).equal('Longitude not a number, got string') 9 | }) 10 | 11 | it('should return size error', () => { 12 | should(lat(91)).equal('Latitude greater than 90') 13 | should(lat(-91)).equal('Latitude less than -90') 14 | should(lon(181)).equal('Longitude greater than 180') 15 | should(lon(-181)).equal('Longitude less than -180') 16 | }) 17 | 18 | it('should return true', () => { 19 | should(lat(20)).equal(true) 20 | should(lat(-20)).equal(true) 21 | should(lon(20)).equal(true) 22 | should(lon(-20)).equal(true) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/util/toString.js: -------------------------------------------------------------------------------- 1 | import should from 'should' 2 | import { where, value, jsonPath } from '../../src/util/toString' 3 | import getJSONField from '../../src/util/getJSONField' 4 | import db from '../fixtures/db' 5 | 6 | const dataType = { 7 | schema: { 8 | id: { 9 | type: 'text' 10 | } 11 | } 12 | } 13 | 14 | describe('util#toString', () => { 15 | const { user } = db.models 16 | 17 | it('should return a where string', () => { 18 | const t = where({ value: { id: '' }, model: user }) 19 | should(t).equal(`"user"."id" = ''`) 20 | }) 21 | 22 | it('should return a jsonPath string', () => { 23 | const t = jsonPath({ column: 'settings', model: user, path: 'settings.id' }) 24 | should(t).equal(`"user"."settings"#>>'{settings,id}'`) 25 | }) 26 | 27 | it('should return a value string', () => { 28 | const val = getJSONField('settings.id', { model: user, subSchemas: { settings: dataType.schema } }) 29 | const t = value({ value: val, model: user }) 30 | should(t).equal(`"user"."settings"#>>'{id}'`) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /tools/print-functions.js: -------------------------------------------------------------------------------- 1 | const functions = require('../dist/types/functions') 2 | 3 | Object.values(functions).forEach((v) => { 4 | if (!v.name) return 5 | console.log(`${v.name} - ${v.notes}`) 6 | }) 7 | --------------------------------------------------------------------------------