├── .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 |
--------------------------------------------------------------------------------