diff --git a/.circleci/config.yml b/.circleci/config.yml index c14f8c5..a3ea91e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,7 +25,7 @@ executors: # Orbs orbs: - node: circleci/node@7.0.0 + node: circleci/node@7.1.0 ################################ # Jobs diff --git a/.eslintrc.json b/.eslintrc.json index 45cf7c9..e26f748 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,6 +21,12 @@ "max-params": [2, {"max": 4}], "multiline-comment-style": [2, "starred-block"], "linebreak-style": 0, + "jsdoc/check-tag-names": [ + 2, + { + "definedTags": ["import"] + } + ], "n/no-unsupported-features/es-syntax": [2, {"ignores": ["modules"]}], "n/no-missing-import": [ "error", diff --git a/CHANGELOG.md b/CHANGELOG.md index ff5a887..efb986f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,59 @@ +## [0.97.1](https://github.com/5app/dare/compare/v0.97.0...v0.97.1) (2025-07-03) + + +### Bug Fixes + +* add type RequestOptions ignore boolean ([#410](https://github.com/5app/dare/issues/410)) ([c3ff946](https://github.com/5app/dare/commit/c3ff946ef27c9143acf28f923559e8ee11aed837)) + +# [0.97.0](https://github.com/5app/dare/compare/v0.96.0...v0.97.0) (2025-05-21) + + +### Features + +* **type:** format date filters, noissue ([#407](https://github.com/5app/dare/issues/407)) ([6a12832](https://github.com/5app/dare/commit/6a12832e5e0fb75493e45c99e743dd5bba5b5e18)) + +# [0.96.0](https://github.com/5app/dare/compare/v0.95.0...v0.96.0) (2025-05-21) + + +### Features + +* **type:** format date types ([#406](https://github.com/5app/dare/issues/406)) ([4256631](https://github.com/5app/dare/commit/4256631f0234d4c9ed2098382ae5b6169bf3862f)) + +# [0.95.0](https://github.com/5app/dare/compare/v0.94.1...v0.95.0) (2025-05-15) + + +### Features + +* **jsdoc:** use [@import](https://github.com/import) ([#405](https://github.com/5app/dare/issues/405)) ([64b0fd9](https://github.com/5app/dare/commit/64b0fd93f600fdebf700f0bd0dc052e1e799a217)) + +## [0.94.1](https://github.com/5app/dare/compare/v0.94.0...v0.94.1) (2025-04-29) + + +### Bug Fixes + +* **null-safe:** add null-safe negate equality conditions ([#404](https://github.com/5app/dare/issues/404)) ([3eb962f](https://github.com/5app/dare/commit/3eb962f7bcb93a7f0099bfa5053c3aca41969a59)) + +# [0.94.0](https://github.com/5app/dare/compare/v0.93.2...v0.94.0) (2025-04-25) + + +### Features + +* **type:** field attributes ([#403](https://github.com/5app/dare/issues/403)) ([dc92f17](https://github.com/5app/dare/commit/dc92f17cef0ab697b41c86a6dd3c9cc0aaa9b37e)) + +## [0.93.2](https://github.com/5app/dare/compare/v0.93.1...v0.93.2) (2025-02-05) + + +### Bug Fixes + +* **ts:** type model handler parameters ([#398](https://github.com/5app/dare/issues/398)) ([877c3e7](https://github.com/5app/dare/commit/877c3e7a7e14fa4f245ce28c54222e1ab50b966c)) + +## [0.93.1](https://github.com/5app/dare/compare/v0.93.0...v0.93.1) (2025-02-04) + + +### Bug Fixes + +* **ts:** type model handler parameters ([#397](https://github.com/5app/dare/issues/397)) ([d434f67](https://github.com/5app/dare/commit/d434f67fcfa6de3cfa2bd1e5490fd6b9c7def4dc)) + # [0.93.0](https://github.com/5app/dare/compare/v0.92.1...v0.93.0) (2025-01-23) diff --git a/README.md b/README.md index 6170b93..e4faedc 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ The setup needs to define a execution handler `dare.execute(SqlRequest) : Promis The integration tests illustrates how a [setup of a dare instance](`./test/integration/helpers/api.js`) connects to different clients... -- **MySQL** (5.6, 5.7, 8.0,...) and **MariaDB** (11) See [connection with `mysql2`](./test/integration/helpers/MySQL.js) -- **Postgres** (16+) See [connection with `pg`](./test/integration/helpers/Postgres.js) +- **MySQL** (5.6, 5.7, 8.0,...) and **MariaDB** (11) See [connection with `mysql2`](./test/integration/helpers/MySQL.js) +- **Postgres** (16+) See [connection with `pg`](./test/integration/helpers/Postgres.js) # Methods @@ -146,11 +146,11 @@ _note_: It is currently limited to defining just one table field, we hope this w `FUNCTION_NAME([FIELD_PREFIX]? field_name [MATH_OPERATOR MATH_VALUE]?[, ADDITIONAL_PARAMETERS]*)` -- _FUNCTION_NAME_: uppercase, no spaces -- _FIELD_PREFIX_: optional, uppercase -- _field_name_: db field reference -- _MATH_OPERATOR_ _MATH_VALUE_: optional -- _ADDITIONAL_PARAMETERS_: optional, prefixed with `,`, (uppercase, digit or quoted string) +- _FUNCTION_NAME_: uppercase, no spaces +- _FIELD_PREFIX_: optional, uppercase +- _field_name_: db field reference +- _MATH_OPERATOR_ _MATH_VALUE_: optional +- _ADDITIONAL_PARAMETERS_: optional, prefixed with `,`, (uppercase, digit or quoted string) _e.g._ @@ -193,8 +193,8 @@ The SQL this creates renames the fields and then recreates the structured format } ``` -- At the moment this only supports _n:1_ mapping. -- The relationship between the tables must be defined in a model field reference. +- At the moment this only supports _n:1_ mapping. +- The relationship between the tables must be defined in a model field reference. ### Filter `filter` @@ -245,9 +245,9 @@ The type of value affects the choice of SQL Condition syntax to use. For example Prefixing the prop with: -- `%`: creates a `LIKE` comparison (or `ILIKE` in _postgres_) -- `-`: hyhen negates the value -- `~`: creates a range +- `%`: creates a `LIKE` comparison (or `ILIKE` in _postgres_) +- `-`: hyhen negates the value +- `~`: creates a range | Key | Value | Type | = SQL Condition | | ---------- | ------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | @@ -589,7 +589,7 @@ await dare.get({ | `models` | `Object` | An object where the keys are the model names to which models can be referred. | | `validateInput` | `Function(fieldAttributes, field, value)` | Validate input on _patch_ and _post_ operations | | `getFieldKey` | `Function(field, schema)` | Override the default function for retrieving schema fields, this is useful if you want to support altenative case (camelCase and/or snakeCase) | -| `state` | `any` | Arbitary data which can be used within the Method handlers to set additional filters or formatting | +| `state` | `any` | Arbitary data which can be used within the Method handlers to set additional filters or formatting | # Model @@ -708,6 +708,27 @@ const dare = new Dare({ }); ``` +**`date`** + +With `type=date`; Instance of the `Date` objects are converted to `YYYY-MM-DD` format for insertion + +```js +const dare = new Dare({ + models: { + members: { + schema: { + start_date: { + type: 'date', + }, + }, + }, + }, +}); + +// Example POSt => SQL +dare.post('members', {start_date: new Date()}); // 'INSERT INTO members (`start_date`) VALUES ('2025-01-02');` +``` + **`json`** Serializes Objects and Deserializes JSON strings in `get`, `post` and `patch` operations. Setting this value also enables the ability to filter results by querying within the JSON values @@ -1027,7 +1048,6 @@ Here's an example of setting a model to be invoked whenever we access `users` mo ```js function get(options) { - // In this example we're filtering access to the `users` model by the properties of the `state` data. options.filter.id = options.state.userId; } @@ -1048,7 +1068,7 @@ await dare.get({ limit: 100, state: { userId: 123, - } + }, }); // SELECT name FROM users WHERE id = 123 LIMIT 100; @@ -1248,8 +1268,8 @@ Typically databases will buffer the resultset into memory and send over one larg Dare, has some functions to take advantage of Streaming -- `this.addRow(record)`: process an individual record -- `options.rowHandler`: See above +- `this.addRow(record)`: process an individual record +- `options.rowHandler`: See above When combined we can efficiently redirect the results immediatly without building up an internal memory. @@ -1474,9 +1494,9 @@ await dare.get({ By default `conditional_operators_in_value = '!%'`. Which is a selection of special characters within the value to be compared. -- `%`: A string containing `%` within the value to be compared will indicate a wild character and the SQL `LIKE` conditional operator will be used. -- `!`: A string starting with `!` will negate the value using a SQL `LIKE` comparison operator. -- `..`: A string containing `..` will use a range `BETWEEN`, `<` or `>` comparison operator where a string value contains `..` or the value is an array with two values (dependending if the first or second value is empty it will use `<` or `>` respecfively). This denotes a range and is enabled using the `~` operator (because `.` within prop name has another meaning) +- `%`: A string containing `%` within the value to be compared will indicate a wild character and the SQL `LIKE` conditional operator will be used. +- `!`: A string starting with `!` will negate the value using a SQL `LIKE` comparison operator. +- `..`: A string containing `..` will use a range `BETWEEN`, `<` or `>` comparison operator where a string value contains `..` or the value is an array with two values (dependending if the first or second value is empty it will use `<` or `>` respecfively). This denotes a range and is enabled using the `~` operator (because `.` within prop name has another meaning) ```js // Enabling support for one or more of the above special characters... @@ -1553,9 +1573,9 @@ await dare.patch({ This version of Dare is designed to work with: -- MySQL (5.6, 5.7 and 8) -- Postgres (16.3) -- MariaDB (11) +- MySQL (5.6, 5.7 and 8) +- Postgres (16.3) +- MariaDB (11) Set the property `engine` on the Dare instance e.g. diff --git a/package-lock.json b/package-lock.json index 600f491..10af5ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dare", - "version": "0.93.0", + "version": "0.97.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dare", - "version": "0.93.0", + "version": "0.97.1", "license": "MIT", "dependencies": { "semver-compare": "^1.0.0", @@ -34,7 +34,7 @@ "is-ci": "^4.1.0", "mocha": "^11.1.0", "mocha-circleci-reporter": "0.0.3", - "mysql2": "3.12", + "mysql2": "3.14", "pg": "^8.13.1", "pg-query-stream": "^4.7.1", "prettier": "^3.4.2", @@ -6431,9 +6431,9 @@ "license": "MIT" }, "node_modules/mysql2": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.12.0.tgz", - "integrity": "sha512-C8fWhVysZoH63tJbX8d10IAoYCyXy4fdRFz2Ihrt9jtPILYynFEKUUzpp1U7qxzDc3tMbotvaBH+sl6bFnGZiw==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.0.tgz", + "integrity": "sha512-8eMhmG6gt/hRkU1G+8KlGOdQi2w+CgtNoD1ksXZq9gQfkfDsX4LHaBwTe1SY0Imx//t2iZA03DFnyYKPinxSRw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 21e9957..aaf8506 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dare", - "version": "0.93.0", + "version": "0.97.1", "description": "Database to REST, REST to Database", "type": "module", "main": "./src/index.js", @@ -21,13 +21,13 @@ "scripts": { "prepare": "is-ci || husky install", "pretest": "npm run lint", - "types": "tsc -p ./tsconfig.json", - "prepublish": "npm run types", + "check-types": "tsc -p ./tsconfig.json", + "prepublish": "npm run check-types", "test": "npm run spec && ((c8 report --reporter=text-lcov | coveralls) || exit 0)", "test:ci": "eslint ./ && c8 mocha test/specs/**/*.spec.js --reporter mocha-circleci-reporter && (c8 report --reporter=text-lcov | coveralls)", "test:integration": "bash ./test/integration/run.sh", "spec": "c8 mocha test/specs/**/*.spec.js", - "lint": "eslint ./ && npx prettier --check . && npx tsc -p ./tsconfig.json", + "lint": "eslint ./ && npx prettier --check . && npm run check-types", "prettier": "prettier --write --ignore-unknown .", "lint-fix": "eslint --fix ./", "lint-diff": "LIST=`git diff-index --cached --name-only --diff-filter=d HEAD | grep '.*\\.js$';`; if [ \"$LIST\" ]; then eslint $LIST; fi", @@ -73,7 +73,7 @@ "is-ci": "^4.1.0", "mocha": "^11.1.0", "mocha-circleci-reporter": "0.0.3", - "mysql2": "3.12", + "mysql2": "3.14", "pg": "^8.13.1", "pg-query-stream": "^4.7.1", "prettier": "^3.4.2", diff --git a/src/format/limit_clause.js b/src/format/limit_clause.js index cbf43bf..f949c6d 100644 --- a/src/format/limit_clause.js +++ b/src/format/limit_clause.js @@ -11,8 +11,8 @@ import DareError from '../utils/error.js'; * Limit Clause * Set/Check limit and start positions * @param {object} opts - Options object - * @param {number} opts.limit - Limit defintion - * @param {number} opts.start - Start defintion + * @param {number} [opts.limit] - Limit defintion + * @param {number} [opts.start] - Start defintion * @param {number} MAX_LIMIT - Max limit on instance * @returns {LimitClause} - Limit Clause */ diff --git a/src/format/reducer_conditions.js b/src/format/reducer_conditions.js index a1bf944..2f93c7e 100644 --- a/src/format/reducer_conditions.js +++ b/src/format/reducer_conditions.js @@ -5,17 +5,11 @@ import formatDateTime from '../utils/format_datetime.js'; import getFieldAttributes from '../utils/field_attributes.js'; import unwrap_field from '../utils/unwrap_field.js'; -/* eslint-disable jsdoc/valid-types */ /** - * @typedef {import('sql-template-tag').Sql} Sql - * @typedef {import('../index.js').default} Dare - * @typedef {import('../index.js').Engine} Engine - */ -/* eslint-enable jsdoc/valid-types */ - -/** - * Reduce conditions, call extract + * @import {Sql} from 'sql-template-tag' + * @import Dare, {Engine} from '../index.js' * + * Reduce conditions, call extract * @param {object} filter - Filter conditions * @param {object} options - Options object * @param {Function} options.extract - Extract (key, value) related to nested model @@ -57,6 +51,7 @@ export default function reduceConditions( if ( value && typeof value === 'object' && + !(value instanceof Date) && !Array.isArray(value) && key_definition?.type !== 'json' && !Buffer.isBuffer(value) @@ -196,6 +191,10 @@ function prepCondition({ if (type === 'datetime') { value = formatDateTime(value); } + // @ts-ignore + else if (type === 'date' && value instanceof Date) { + value = value.toISOString().split('T').at(0); + } // JSON if ( @@ -383,7 +382,15 @@ function sqlCondition({ const items = engine.startsWith('mysql:5.7') ? filteredValue.map(quote) : filteredValue; - conds.push(SQL`${sql_field} ${NOT}IN (${join(items)})`); + + let condition = SQL`${sql_field} ${NOT}IN (${join(items)})`; + + if (negate && !value.includes(null)) { + // If negated, and the value is not null, then add the null check + condition = SQL`(${condition} OR ${sql_field} IS NULL)`; + } + + conds.push(condition); } // Other Values which can't be grouped ... @@ -413,7 +420,17 @@ function sqlCondition({ value = String(value); } - return SQL`${sql_field} ${raw(negate ? '!' : '')}= ${value}`; + let condition = SQL`${sql_field} ${raw(negate ? '!' : '')}= ${value}`; + + if (negate) { + /* + * NULL-safe equality operator + * @see {@link https://vettabase.com/null-comparisons-in-mariadb-postgresql-and-sqlite/} + * If negated, then add the null check + */ + condition = SQL`(${condition} OR ${sql_field} IS NULL)`; + } + return condition; } } diff --git a/src/format_request.js b/src/format_request.js index 1ea6258..3f83b8b 100644 --- a/src/format_request.js +++ b/src/format_request.js @@ -10,11 +10,10 @@ import getFieldAttributes from './utils/field_attributes.js'; import extend from './utils/extend.js'; import buildQuery from './get.js'; -/* eslint-disable jsdoc/valid-types */ /** - * @typedef {import('./index.js').default} Dare + * @import {Sql} from 'sql-template-tag' + * @import Dare, {QueryOptions} from './index.js' */ -/* eslint-enable jsdoc/valid-types */ /** * Format Request initiation @@ -26,12 +25,19 @@ export default function (options) { return format_request(options, this); } +/** + * @typedef {object} SimpleNode + * @property {string} alias - Alias + * @property {string} field_alias_path - Field alias path + * @property {string} table - Table name + */ + /** * Format Request * - * @param {object} options - Current iteration + * @param {QueryOptions} options - Current iteration * @param {Dare} dareInstance - Instance of Dare - * @returns {Promise} formatted object with all the joins + * @returns {Promise} formatted object with all the joins */ async function format_request(options, dareInstance) { if (!options) { @@ -170,7 +176,7 @@ async function format_request(options, dareInstance) { const {field_alias_path} = options; // Current Path - const current_path = options.field_alias_path || `${options.alias}.`; + const current_path = field_alias_path || `${options.alias}.`; // Create a shared object to provide nested objects const joined = {}; @@ -199,6 +205,9 @@ async function format_request(options, dareInstance) { } } + /** @type {Array} */ + const sql_filters = []; + // Format filters if (options.filter) { // Filter must be an object with key=>values @@ -221,7 +230,8 @@ async function format_request(options, dareInstance) { dareInstance, }); - options._filter = arr.length ? arr : null; + // Add to filters + sql_filters.push(...arr); } // Format fields @@ -249,6 +259,9 @@ async function format_request(options, dareInstance) { options.fields = toArray(options.fields).reduce(reducer, []); } + /** @type {Array} */ + const sql_join_condition = []; + // Format conditional joins if (options.join) { // Filter must be an object with key=>values @@ -272,7 +285,7 @@ async function format_request(options, dareInstance) { const extract = extractJoined.bind(null, 'join', false); // Return array of immediate props - options._join = reduceConditions(options.join, { + const arrJoins = reduceConditions(options.join, { extract, sql_alias, table_schema, @@ -283,9 +296,10 @@ async function format_request(options, dareInstance) { /* * Convert root joins to filters... */ - if (options._join.length && !options.parent) { - options._filter ??= []; - options._filter.push(...options._join); + if (arrJoins.length && !options.parent) { + sql_filters.push(...arrJoins); + } else { + sql_join_condition.push(...arrJoins); } } @@ -345,6 +359,7 @@ async function format_request(options, dareInstance) { // Joins { + /** @type {Array} */ const joins = options.joins || []; // Add additional joins which have been derived from nested fields and filters... @@ -402,16 +417,20 @@ async function format_request(options, dareInstance) { join_object.parent = options; // Format join... - const formatedObject = await format_request(join_object, dareInstance); + const formatedObject = await format_request( + join_object, + dareInstance + ); // If this is present if (formatedObject) { // The handler may have assigned filters when their previously wasn't any - formatedObject.has_filter ||= Boolean(formatedObject.filter); + formatedObject.has_filter ||= Boolean( + formatedObject.filter + ); } return formatedObject; - }); // Add Joins @@ -427,16 +446,10 @@ async function format_request(options, dareInstance) { { // Place holder - const sql_where_conditions = []; - - if (options._filter) { - // Get current filters - sql_where_conditions.push(...options._filter); - } // Get nested filters if (options._joins) { - sql_where_conditions.push( + sql_filters.push( ...options._joins.flatMap( ({sql_where_conditions}) => sql_where_conditions ) @@ -444,7 +457,8 @@ async function format_request(options, dareInstance) { } // Assign - options.sql_where_conditions = sql_where_conditions.filter(Boolean); + /** @type {Array} */ + options.sql_where_conditions = sql_filters.filter(Boolean); } // Initial SQL JOINS reference @@ -455,17 +469,7 @@ async function format_request(options, dareInstance) { * If this item has a parent, it'll require a join statement with conditions */ if (options.parent) { - // Update the values with the alias of the parent - const sql_join_condition = []; - - if (options._join) { - sql_join_condition.push(...options._join); - - // Prevent join condifions from being applied twice in buildQuery - options._join.length = 0; - } - - // Always going to be defined + // Join_conditions, defines how a node is linked to its parent for (const x in options.join_conditions) { const val = options.join_conditions[x]; sql_join_condition.push( @@ -535,7 +539,7 @@ async function format_request(options, dareInstance) { sql_where_conditions = [ SQL`${raw(parentReferences[0])} ${sql_negate} IN ( - SELECT ${raw(options.fields)} FROM ( + SELECT ${join(options.fields.map(field => raw(String(field))))} FROM ( ${sub_query} ) AS ${raw(options.sql_alias)}_tmp ) diff --git a/src/get.js b/src/get.js index 93538b1..5dc805d 100644 --- a/src/get.js +++ b/src/get.js @@ -150,7 +150,7 @@ export default function buildQuery(opts, dareInstance) { if (dareInstance.engine?.startsWith('mysql:8') && alias) { if (fields.every(item => item.agg)) { opts.limit = null; - }; + } } // Put it all together diff --git a/src/index.js b/src/index.js index 7f2945a..331d837 100644 --- a/src/index.js +++ b/src/index.js @@ -18,25 +18,76 @@ import response_handler, {responseRowHandler} from './response_handler.js'; /* eslint-disable jsdoc/valid-types */ /** - * @typedef {import('sql-template-tag').Sql} Sql + * @import {Sql} from 'sql-template-tag' * * @typedef {`${'mysql' | 'postgres' | 'mariadb'}:${number}.${number}${string?}`} Engine * + * @typedef {Pick} ModalHandlerExtraProps + * + * @callback GetModelHandler + * @param {GetRequestOptions & ModalHandlerExtraProps} [options] - Request Options + * @param {Dare} [dareInstance] - Dare Instance + * @returns {void} + * + * @callback PostModelHandler + * @param {PostRequestOptions & ModalHandlerExtraProps} [options] - Request Options + * @param {Dare} [dareInstance] - Dare Instance + * @returns {void} + * + * @callback PatchModelHandler + * @param {PatchRequestOptions & ModalHandlerExtraProps} [options] - Request Options + * @param {Dare} [dareInstance] - Dare Instance + * @returns {void} + * + * @callback DeleteModelHandler + * @param {DeleteRequestOptions & ModalHandlerExtraProps} [options] - Request Options + * @param {Dare} [dareInstance] - Dare Instance + * @returns {void} + * + * @typedef {string} Alias + * @typedef {`${string}.${string}`} Reference + * @typedef {string | number | boolean | null} DefaultValue + * @typedef {Function} Handler + * @typedef {boolean} Authorised + * + * @typedef {object} FieldAttributeProps + * @property {'json' | 'number' | 'boolean' | 'string' | 'datetime' | 'date'} [type] - The type of the field + * @property {Alias} [alias] - Alias for the field + * @property {Reference[]} [references] - References to other models fields + * @property {string} [type] - Type of field + * @property {DefaultValue | DefaultValue[]} [defaultValue] - Default value for the field + * @property {boolean} [readable=true] - Whether this field is readable + * @property {boolean} [writeable=true] - Whether this field is writeable + * @property {boolean} [required=false] - Whether this field is required + * @property {Handler} [handler] - Handler to generate the field value + * @property {FieldAttributes} [get] - The get definition of this field + * @property {FieldAttributes} [post] - The post definition of this field + * @property {FieldAttributes} [patch] - The patch definition of this field + * @property {FieldAttributes} [del] - The del definition of this field + * + * @typedef {Record & FieldAttributeProps} FieldAttributes + * @typedef {FieldAttributes | Handler | Reference[] | Alias | (Authorised & false) | null} FieldAttributesWithShorthand + * @typedef {Record} Schema + * * @typedef {object} Model - * @property {Object | string | null | boolean>} [schema] - Model Schema + * @property {Schema} [schema] - Model Schema * @property {string} [table] - Alias for the table * @property {Object} [shortcut_map] - Shortcut map - * @property {Function} [get] - Get handler - * @property {Function} [post] - Post handler - * @property {Function} [patch] - Patch handler - * @property {Function} [del] - Delete handler + * @property {GetModelHandler} [get] - Get handler + * @property {PostModelHandler} [post] - Post handler + * @property {PatchModelHandler} [patch] - Patch handler + * @property {DeleteModelHandler} [del] - Delete handler + * + * @typedef {Array>} RequestFields + * + * @typedef {function(Record, string, any):void} ValidateInputFunction * * @typedef {object} RequestOptions * @property {string} [table] - Name of the table to query - * @property {Array} [fields] - Fields array to return - * @property {object} [filter] - Filter Object to query - * @property {object} [join] - Place filters on the joining tables - * @property {object} [body] - Body containing new data + * @property {RequestFields} [fields] - Fields array to return + * @property {Record} [filter] - Filter Object to query + * @property {Record} [join] - Place filters on the joining tables + * @property {Record} [body] - Body containing new data * @property {RequestOptions} [query] - Query attached to a post request to create INSERT...SELECT operations * @property {number} [limit] - Number of items to return * @property {number} [start] - Number of items to skip @@ -45,14 +96,15 @@ import response_handler, {responseRowHandler} from './response_handler.js'; * @property {string} [duplicate_keys] - 'ignore' to prevent throwing Duplicate key errors * @property {string[]} [duplicate_keys_update] - An array of fields to update on presence of duplicate key constraints * @property {*} [notfound] - If not undefined will be returned in case of a single entry not found - * @property {Object} [models] - Models with schema defintitions - * @property {Function} [validateInput] - Validate input + * @property {Record} [models] - Models with schema defintitions + * @property {ValidateInputFunction} [validateInput] - Validate input * @property {boolean} [infer_intermediate_models] - Infer intermediate models - * @property {Function} [rowHandler] - Override default Function to handle each row + * @property {function(Record):any} [rowHandler] - Override default Function to handle each row * @property {Function} [getFieldKey] - Override default Function to interpret the field key * @property {string} [conditional_operators_in_value] - Allowable conditional operators in value * @property {any} [state] - Arbitary data to carry through to the model/response handlers * @property {Engine} [engine] - DB Engine to use + * @property {boolean} [ignore] - Aka INSERT IGNORE INTO... * * @typedef {object} InternalProps * @property {'post' | 'get' | 'patch' | 'del'} [method] - Method to use @@ -64,9 +116,21 @@ import response_handler, {responseRowHandler} from './response_handler.js'; * @property {string} [sql_table] - SQL Table * @property {string} [sql_alias] - SQL Alias * @property {Array} [sql_joins] - SQL Join - * @property {string} [ignore] - SQL Fields + * @property {QueryOptions} [parent] - Defines the parent request * @property {boolean} [forceSubquery] - Force the table joins to use a subquery. * @property {Array} [sql_where_conditions] - SQL Where conditions + * -- properties of the format_request function + * @property {boolean} [has_filter] - Has filter, used to determine whether a node and it's descendents can be defined as a subquery + * @property {boolean} [has_fields] - Has fields, used to determine whether the subqueries are required + * @property {string} [field_alias_path] - An alternative path to these fields when intermediate models are used + * @property {string} [required_join] - Join is required + * @property {boolean} [negate] - Whether this node is connected via a negation operator + * @property {boolean} [is_subquery] - Identify a node as a subquery, used to place fields into a subquery, or a filter into a subquery (i.e. when negated) + * @property {boolean} [many] - 1:n join + * @property {Record} [join_conditions] - Join conditions between nodes + * @property {Sql} [sql_join_condition] - Table to join + * @property {Array} [joins] - Usually this is derived when processing the node, however when there is an intermediate node, we can pre-define the joins + * @property {Array} [_joins] - Descendent nodes, post-processed * * @typedef {RequestOptions & InternalProps} QueryOptions */ @@ -186,7 +250,7 @@ Dare.prototype.response_handler = response_handler; /** * GetFieldKey * @param {string} field - Field - * @param {object} schema - Model Schema + * @param {Schema} schema - Model Schema * @returns {string | void} Field Key */ // eslint-disable-next-line no-unused-vars @@ -276,9 +340,8 @@ Dare.prototype.fulltextParser = function fulltextParser(input) { /** * Dare.after * Defines where the instance goes looking to apply post execution handlers and potentially mutate the response - * @template {object|Array} T - * @param {T} resp - Response object - * @returns {T} response data formatted or not + * @param {any} resp - Response object + * @returns {any} response data formatted or not */ /* eslint-enable jsdoc/valid-types */ /* eslint-enable jsdoc/check-tag-names */ @@ -979,7 +1042,7 @@ Dare.prototype.onDuplicateKeysUpdate = function onDuplicateKeysUpdate( * Format Input Value * For a given field definition, return the db key (alias) and format the input it required * @param {object} obj - Object - * @param {object} [obj.tableSchema={}] - An object containing the table schema + * @param {Schema} [obj.tableSchema={}] - An object containing the table schema * @param {string} obj.field - field identifier * @param {*} obj.value - Given value * @param {Function} [obj.validateInput] - Custom validation function @@ -1041,6 +1104,16 @@ function formatInputValue({ } } + /** + * Type=date + * Sadly, Date objects via prepared statements are converted to full length datetimeoffset i.e. `YYYY-MM-DDT23:59:59.000Z` + * - MySQL (that we know of) throws errors when expecting just `YYYY-MM-DD` + */ + if (type === 'date' && value instanceof Date) { + // ISO date, extract the date part + value = value.toISOString().split('T').at(0); + } + // Check this is not an object if (value && typeof value === 'object' && !Buffer.isBuffer(value)) { throw new DareError( @@ -1068,7 +1141,7 @@ function formatInputValue({ /** * Return un-aliased field names * - * @param {object} tableSchema - An object containing the table schema + * @param {Schema} tableSchema - An object containing the table schema * @param {string} field - field identifier * @param {Dare} dareInstance - Dare Instance * @returns {string} Unaliased field name diff --git a/src/utils/field_attributes.js b/src/utils/field_attributes.js index a67cc2f..3e4edb4 100644 --- a/src/utils/field_attributes.js +++ b/src/utils/field_attributes.js @@ -1,27 +1,13 @@ /** - * @typedef {object} FieldDefinition - * @property {'json' | 'number' | 'boolean' | 'string' | 'datetime'} [type] - The type of the field - * @property {string} [alias] - The alias of the field - * @property {Array} [references] - A reference to another table - * @property {boolean} [readable=true] - Whether this field is readable - * @property {boolean} [writeable=true] - Whether this field is writeable - * @property {boolean} [required=false] - Whether this field is required - * @property {Function} [handler] - Handler to generate the field value - * @property {string | number | boolean | object | Array} [defaultValue=null] - The default value of this field - * @property {FieldDefinition} [get] - The get definition of this field - * @property {FieldDefinition} [post] - The post definition of this field - * @property {FieldDefinition} [patch] - The patch definition of this field - * @property {FieldDefinition} [del] - The del definition of this field - */ - -/** - * Given a field definition defined in the schema, extract it's attributes + * @import {FieldAttributesWithShorthand} from '../index.js' + * @import {FieldAttributes} from '../index.js' * + * Given a field definition defined in the schema, extract it's attributes * @param {string} field - A field reference - * @param {Object} schema - A model schema definition + * @param {Object} schema - A model schema definition * @param {object} dareInstance - A dare instance * @param {boolean} [useDefault=false] - Fallback to `default` schema field definition - * @returns {FieldDefinition} An object containing the attributes of the field + * @returns {FieldAttributes} An object containing the attributes of the field */ export default function getFieldAttributes( field, @@ -32,7 +18,7 @@ export default function getFieldAttributes( const fieldKey = dareInstance?.getFieldKey?.(field, schema) || field; /** - * @type {FieldDefinition} respDefinition + * @type {FieldAttributes} respDefinition */ const respDefinition = { ...(fieldKey !== field && {alias: fieldKey}), @@ -60,18 +46,6 @@ export default function getFieldAttributes( Object.assign(respDefinition, fieldDefinition[method]); } - /* - * @legacy support `defaultValue{get, post, patch, del}` definitions. - * If 'defaultValue' is an object - * Expand default value - */ - if ( - fieldDefinition.defaultValue !== null && - typeof fieldDefinition.defaultValue === 'object' - ) { - respDefinition.defaultValue = fieldDefinition.defaultValue[method]; - } - // This is already a definition object return { ...fieldDefinition, diff --git a/src/utils/unwrap_field.js b/src/utils/unwrap_field.js index 6c27323..b562d21 100644 --- a/src/utils/unwrap_field.js +++ b/src/utils/unwrap_field.js @@ -133,8 +133,6 @@ export default function unwrap_field(expression, allowValue = true) { }; } - - // Is this a valid field throw new DareError( DareError.INVALID_REFERENCE, diff --git a/test/README.md b/test/README.md index d2a125f..3d813eb 100644 --- a/test/README.md +++ b/test/README.md @@ -15,6 +15,6 @@ DEBUG=sql npm run test:integration -- \ _Options_ -- `DEBUG=sql` - print out SQL requests -- `KEEP_DOCKER=1` - dont remove docker containers and volumes after tests complete -- `TEST_STATE_CLEANUP_MODE=none` - leave the db engine running after tests complete +- `DEBUG=sql` - print out SQL requests +- `KEEP_DOCKER=1` - dont remove docker containers and volumes after tests complete +- `TEST_STATE_CLEANUP_MODE=none` - leave the db engine running after tests complete diff --git a/test/data/options.js b/test/data/options.js index 9ccf555..1b19bf1 100644 --- a/test/data/options.js +++ b/test/data/options.js @@ -1,18 +1,23 @@ +/* eslint-disable jsdoc/valid-types */ +/** + * @import {RequestOptions, FieldAttributes, Engine} from '../../src/index.js' + */ +/* eslint-enable jsdoc/valid-types */ + +/** + * @type {FieldAttributes} + */ const created_time = { type: 'datetime', }; +/** + * @type {FieldAttributes} + */ const string = { type: 'string', }; -/* eslint-disable jsdoc/valid-types */ -/** - * @typedef {import('../../src/index.js').RequestOptions} RequestOptions - * @typedef {import('../../src/index.js').Engine} Engine - */ -/* eslint-enable jsdoc/valid-types */ - /** * @type {Engine} */ @@ -124,7 +129,7 @@ export default { comments: { schema: { author_id: { - references: 'users.id', + references: ['users.id'], }, /* * Date Type @@ -136,7 +141,7 @@ export default { activityEvents: { schema: { session_id: { - references: 'activitySession.id', + references: ['activitySession.id'], }, ref_id: ['asset.id'], diff --git a/test/integration/get.spec.js b/test/integration/get.spec.js index 78cd3e0..45c52f1 100644 --- a/test/integration/get.spec.js +++ b/test/integration/get.spec.js @@ -269,6 +269,51 @@ describe(`Dare init tests: options ${Object.keys(options)}`, () => { } }); + it('NULL-safe negate equality operator', async () => { + const username = 'name@example.com'; + await dare.post('users', [ + { + username, + first_name: "First Old'n'Name", + last_name: null, + }, + ]); + + // Should be able to return null values in a negated search + { + const resp = await dare.get('users', ['username'], { + '-last_name': 'Last-Name', + }); + + assert.deepStrictEqual(resp, {username}); + } + + // Should be able to return null values in a negated search + { + const resp = await dare.get('users', ['username'], { + '-last_name': ['Last-Name'], + }); + + assert.deepStrictEqual(resp, {username}); + } + + // Should be able to exclude null values in a negated search + { + const resp = await dare.get( + 'users', + ['username'], + { + '-last_name': ['Last-Name', null], + }, + { + notfound: {}, + } + ); + + assert.deepStrictEqual(resp, {}); + } + }); + it('Return a truthy value for existance if no fields are provided', async () => { const username = 'A Name'; await dare.post('users', {username}); diff --git a/test/integration/helpers/Postgres.js b/test/integration/helpers/Postgres.js index c64e5c1..2a20b18 100644 --- a/test/integration/helpers/Postgres.js +++ b/test/integration/helpers/Postgres.js @@ -7,7 +7,7 @@ import QueryStream from 'pg-query-stream'; * Aggregate functions are returned as BigInts which by default are converted to strings * This changes floats and ints to their respective types */ -pg.types.setTypeParser(1700, parseFloat) +pg.types.setTypeParser(1700, parseFloat); pg.types.setTypeParser(20, parseInt); const {TEST_DB_DATA_PATH, TEST_DB_SCHEMA_PATH} = process.env; diff --git a/test/specs/filter_reducer.spec.js b/test/specs/filter_reducer.spec.js index 1b2f484..67bf8e9 100644 --- a/test/specs/filter_reducer.spec.js +++ b/test/specs/filter_reducer.spec.js @@ -115,8 +115,8 @@ describe('Filter Reducer', () => { '-key': testStr, }, }, - `(a.jsonSettings->? != ?)`, - ['$.key', testStr], + `((a.jsonSettings->? != ? OR a.jsonSettings->? IS NULL))`, + ['$.key', testStr, '$.key'], ], [ { diff --git a/test/specs/format_request.spec.js b/test/specs/format_request.spec.js index 6231360..705c30a 100644 --- a/test/specs/format_request.spec.js +++ b/test/specs/format_request.spec.js @@ -61,14 +61,9 @@ describe('format_request', () => { sql_table: actualtable, field_alias_path: '', filter, - _filter: [ - SQL`a.id = ${1}`, - ], sql_alias: 'a', sql_joins: [], - sql_where_conditions: [ - SQL`a.id = ${1}`, - ], + sql_where_conditions: [SQL`a.id = ${1}`], limit: 1, single: true, }); @@ -355,9 +350,12 @@ describe('format_request', () => { models: { [table]: { schema: { - date: { + datetime: { type: 'datetime', }, + date: { + type: 'date', + }, }, }, }, @@ -367,7 +365,11 @@ describe('format_request', () => { describe('should prep conditions', () => { const a = [ [{prop: 'string'}, 'a.prop = ?', ['string']], - [{'-prop': 'string'}, 'a.prop != ?', ['string']], + [ + {'-prop': 'string'}, + '(a.prop != ? OR a.prop IS NULL)', + ['string'], + ], [{prop: '%string'}, 'a.prop LIKE ?', ['%string']], [ {prop: '%string'}, @@ -387,14 +389,22 @@ describe('format_request', () => { [{'-prop': 'patt%rn'}, 'a.prop NOT LIKE ?', ['patt%rn']], [ {'-prop': 'patt%rn'}, - 'a.prop != ?', + '(a.prop != ? OR a.prop IS NULL)', ['patt%rn'], noCondOperators, ], [{prop: [1, 2, 3]}, 'a.prop IN (?,?,?)', [1, 2, 3]], - [{'-prop': [1, 2, 3]}, 'a.prop NOT IN (?,?,?)', [1, 2, 3]], + [ + {'-prop': [1, 2, 3]}, + '(a.prop NOT IN (?,?,?) OR a.prop IS NULL)', + [1, 2, 3], + ], [{prop: [1]}, 'a.prop IN (?)', [1]], - [{'-prop': [1]}, 'a.prop NOT IN (?)', [1]], + [ + {'-prop': [1]}, + '(a.prop NOT IN (?) OR a.prop IS NULL)', + [1], + ], [ {prop: [1, null, 2]}, '(a.prop IN (?,?) OR a.prop IS NULL)', @@ -435,8 +445,8 @@ describe('format_request', () => { [{prop: null}, 'a.prop IS NULL', []], [{'-prop': null}, 'a.prop IS NOT NULL', []], [ - {'-date': '1981-12-05..'}, - '(NOT a.date > ? OR a.date IS NULL)', + {'-datetime': '1981-12-05..'}, + '(NOT a.datetime > ? OR a.datetime IS NULL)', ['1981-12-05T00:00:00'], ], [{prop: '1981-12-05..'}, 'a.prop > ?', ['1981-12-05']], @@ -484,7 +494,7 @@ describe('format_request', () => { [condition_type]: filter, }); - const query = resp[`_${condition_type}`][0]; + const query = resp.sql_where_conditions[0]; expect(query.sql).to.equal(sql); expect(query.values).to.deep.equal(values); @@ -532,36 +542,59 @@ describe('format_request', () => { describe('field type=datetime', () => { const o = { '1981-12-05': [ - 'a.date BETWEEN ? AND ?', + 'a.datetime BETWEEN ? AND ?', ['1981-12-05T00:00:00', '1981-12-05T23:59:59'], ], '1981-1-5': [ - 'a.date BETWEEN ? AND ?', + 'a.datetime BETWEEN ? AND ?', ['1981-01-05T00:00:00', '1981-01-05T23:59:59'], ], '1981-12-05..1981-12-06': [ - 'a.date BETWEEN ? AND ?', + 'a.datetime BETWEEN ? AND ?', ['1981-12-05T00:00:00', '1981-12-06T23:59:59'], ], - '1981-12-05..': ['a.date > ?', ['1981-12-05T00:00:00']], - '..1981-12-05': ['a.date < ?', ['1981-12-05T00:00:00']], + '1981-12-05..': ['a.datetime > ?', ['1981-12-05T00:00:00']], + '..1981-12-05': ['a.datetime < ?', ['1981-12-05T00:00:00']], '1981-12': [ - 'a.date BETWEEN ? AND ?', + 'a.datetime BETWEEN ? AND ?', ['1981-12-01T00:00:00', '1981-12-31T23:59:59'], ], '1981-1': [ - 'a.date BETWEEN ? AND ?', + 'a.datetime BETWEEN ? AND ?', ['1981-01-01T00:00:00', '1981-01-31T23:59:59'], ], 2016: [ - 'a.date BETWEEN ? AND ?', + 'a.datetime BETWEEN ? AND ?', ['2016-01-01T00:00:00', '2016-12-31T23:59:59'], ], }; - for (const date in o) { - const [sql, values] = o[date]; + for (const datetime in o) { + const [sql, values] = o[datetime]; + it(`should augment filter values ${datetime}`, async () => { + const resp = await dare.format_request({ + table, + fields: ['id'], + [condition_type]: { + datetime, + }, + }); + + const query = resp.sql_where_conditions[0]; + + expect(query.sql).to.equal(sql); + expect(query.values).to.deep.equal(values); + }); + } + }); + + describe('field type=date', () => { + const o = [ + [new Date('1981-12-05'), 'a.date = ?', ['1981-12-05']], + ]; + + o.forEach(([date, sql, values]) => { it(`should augment filter values ${date}`, async () => { const resp = await dare.format_request({ table, @@ -571,12 +604,12 @@ describe('format_request', () => { }, }); - const query = resp[`_${condition_type}`][0]; + const query = resp.sql_where_conditions[0]; expect(query.sql).to.equal(sql); expect(query.values).to.deep.equal(values); }); - } + }); }); }); }); diff --git a/test/specs/fulltext_parser.spec.js b/test/specs/fulltext_parser.spec.js index 52cdd1a..2caaf2a 100644 --- a/test/specs/fulltext_parser.spec.js +++ b/test/specs/fulltext_parser.spec.js @@ -1,13 +1,9 @@ import assert from 'node:assert/strict'; import Dare from '../../src/index.js'; -/* eslint-disable jsdoc/valid-types */ -/** - * @typedef {import('../../src/index.js').Engine} Engine - */ -/* eslint-enable jsdoc/valid-types */ - /** + * @import {Engine} from '../../src/index.js' + * * @type {Engine} */ const ENGINE_POSTGRES = 'postgres:16.3'; diff --git a/test/specs/get-subquery.spec.js b/test/specs/get-subquery.spec.js index 40398fe..c5becfb 100644 --- a/test/specs/get-subquery.spec.js +++ b/test/specs/get-subquery.spec.js @@ -377,16 +377,13 @@ describe('get - subquery', () => { }); }); - - describe(`Disparities`, () => { - it('MySQL 8 fails to correctly count the items in this scenario', async () => { /* * See Bug report: https://bugs.mysql.com/bug.php?id=109585 */ const dareInst = dare.use({engine: 'mysql:8.0.36'}); - + dareInst.options.models.userContent = { schema: { content_id: ['content.id'], @@ -396,15 +393,13 @@ describe('get - subquery', () => { dareInst.sql = async ({sql}) => { expect(sql).to.not.contain('LIMIT 1'); }; - + // Construct a query which counts these await dareInst.get({ table: 'content', fields: ['id', {count: 'COUNT(DISTINCT userContent.user_id)'}], limit: 3, }); - }); }); - }); diff --git a/test/specs/init.spec.js b/test/specs/init.spec.js index 8581249..56d6ced 100644 --- a/test/specs/init.spec.js +++ b/test/specs/init.spec.js @@ -1,3 +1,7 @@ +/** + * @import {QueryOptions} from '../../src/index.js' + */ + import {expect} from 'chai'; import Dare, {DareError} from '../../src/index.js'; import clone from 'tricks/object/clone.js'; @@ -50,6 +54,10 @@ describe('Dare', () => { describe('dare.use to extend the instance', () => { let dare; + + /** + * @type {QueryOptions} + */ let options; beforeEach(() => { diff --git a/test/specs/post.spec.js b/test/specs/post.spec.js index f889593..76cf98b 100644 --- a/test/specs/post.spec.js +++ b/test/specs/post.spec.js @@ -282,6 +282,34 @@ describe('post', () => { .and.have.property('code', DareError.INVALID_VALUE); }); }); + + [new Date('2023-01-01')].forEach(testValue => { + it(`type=date: should accept date, given ${testValue}`, async () => { + dare.options = { + models: { + test: { + schema: { + startDate: { + type: 'date', + }, + }, + }, + }, + }; + + dare.execute = async ({sql, values}) => { + // Limit: 1 + sqlEqual(sql, 'INSERT INTO test (`startDate`) VALUES (?)'); + expect(values).to.deep.equal(['2023-01-01']); + return {success: true}; + }; + + return dare.post({ + table: 'test', + body: {startDate: testValue}, + }); + }); + }); }); describe('DB Engine specific tests', () => { diff --git a/test/specs/schema.defaultValue.spec.js b/test/specs/schema.defaultValue.spec.js index 0063b0f..1b688e5 100644 --- a/test/specs/schema.defaultValue.spec.js +++ b/test/specs/schema.defaultValue.spec.js @@ -46,24 +46,6 @@ describe('schema.defaultValue', () => { expect(attr).to.not.have.property('defaultValue'); }); - // @deprecated defaultValue[method] - it('@legacy: defaultValue object should return as a single property', () => { - const defaultValue = { - post: 'postValue', - get: 123, - patch: null, - }; - - const value = defaultValue[dareInstance.options.method]; - - const attr = getFieldAttributes( - field, - {[field]: {defaultValue}}, - dareInstance - ); - expect(attr).to.have.property('defaultValue', value); - }); - [undefined, 1, null, 'string'].forEach(defaultValue => { it(`should expand defaultValue, ${defaultValue}`, () => { const attr = getFieldAttributes( @@ -85,25 +67,6 @@ describe('schema.defaultValue', () => { describe('formatRequest', () => { ['get', 'post', 'patch', 'del'].forEach(method => { - // @deprecated defaultValue[method] - it(`@legacy: should add as a join filter in formatRequest for ${method}`, async () => { - const value = method; - - // Update dare instance - dare.options.method = method; - - // Set the default value for the method - dare.options.models.mytable.schema.status = { - defaultValue: {[value]: value}, - }; - - const resp = await dare.format_request({ - table: 'mytable', - fields: ['id', 'name'], - }); - - expect(resp.join).to.have.property('status', value); - }); it(`should add as a join filter in formatRequest for ${method}`, async () => { const defaultValue = method; @@ -214,30 +177,6 @@ describe('schema.defaultValue', () => { describe('DEL/GET/PATCH', () => { ['get', 'patch', 'del'].forEach(method => { - // @deprecated defaultValue[method] - it(`@legacy: should add WHERE condition for the dare.${method}() call`, async () => { - const value = method; - - // Set the default value for the method - dare.options.models.mytable.schema.status = { - defaultValue: {[method]: value}, - }; - - const history = spy(dare, 'execute', () => []); - - await dare[method]({ - table: 'mytable', - fields: ['id', 'name'], - body: {name: 'newvalue'}, - notfound: null, - }); - - const [{sql, values}] = history.at(0); - - expect(sql).to.include('status = '); - expect(values).to.include(method); - }); - it(`should add WHERE condition for the dare.${method}() call`, async () => { const defaultValue = method; diff --git a/test/specs/validateInput.spec.js b/test/specs/validateInput.spec.js index 7e5f92a..11213ce 100644 --- a/test/specs/validateInput.spec.js +++ b/test/specs/validateInput.spec.js @@ -1,8 +1,16 @@ +/** + * @import {Schema} from '../../src/index.js' + */ + import {expect} from 'chai'; import Dare from '../../src/index.js'; describe('validateInput', () => { let dare; + + /** + * @type {Schema} + */ let memberSchema; beforeEach(() => {