diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dc83430..d929d35 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,6 +50,7 @@ jobs: 'mysql:8.0.23', 'mariadb:11.4', 'postgres:16.3', + 'sqlite:3', ] steps: - uses: actions/checkout@master diff --git a/README.md b/README.md index b7fe4fd..6365942 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,14 @@ npm i dare --save Import the appropriate entry point for your database engine. The versions listed are minimum supported versions Dare has been tested against - more recent versions are expected to be backwards compatible. -| Import Path | Database Engine | -| ----------------- | ----------------------------- | -| `dare` | MySQL 8.0+, MariaDB | -| `dare/mariadb11` | MariaDB 11+ (alias of `dare`) | -| `dare/mysql80` | MySQL 8.0+ (alias of `dare`) | -| `dare/mysql57` | MySQL 5.7+, MySQL 5.6+ | -| `dare/postgres16` | PostgreSQL 16+ | +| Database Engine | Import Path | +| ----------------------------- | ----------------- | +| MySQL 8.0+, MariaDB | `dare` | +| MariaDB 11+ (alias of `dare`) | `dare/mariadb11` | +| MySQL 8.0+ (alias of `dare`) | `dare/mysql80` | +| MySQL 5.7+, MySQL 5.6+ | `dare/mysql57` | +| PostgreSQL 16+ | `dare/postgres16` | +| SQLite 3+ | `dare/sqlite3` | ## Example diff --git a/package.json b/package.json index 3bc6ed2..d1fba45 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,10 @@ "import": "./src/postgres16.js", "types": "./types/src/index.d.ts" }, + "./sqlite3": { + "import": "./src/sqlite.js", + "types": "./types/src/index.d.ts" + }, "./utils/*": { "import": "./src/utils/*.js", "types": "./types/src/utils/*.d.ts" @@ -40,6 +44,7 @@ "test": "npm run lint && npm run spec && ((c8 report --reporter=text-lcov | coveralls) || exit 0)", "test:ci": "npm run lint && c8 node --test test/specs/**/*.spec.js && (c8 report --reporter=text-lcov | coveralls)", "test:integration": "bash ./test/integration/run.sh", + "test:integration:sqlite": "DB_ENGINE=sqlite:3 bash ./test/integration/run.sh", "spec": "c8 node --test 'test/specs/**/*.spec.js'", "lint": "eslint ./ && npx prettier --check . && npm run check-types", "prettier": "prettier --write --ignore-unknown .", diff --git a/src/format/reducer_conditions.js b/src/format/reducer_conditions.js index 0e75c13..f4572e8 100644 --- a/src/format/reducer_conditions.js +++ b/src/format/reducer_conditions.js @@ -14,6 +14,7 @@ import unwrap_field from '../utils/unwrap_field.js'; * @param {object} options - Options object * @param {Function} options.extract - Extract (key, value) related to nested model * @param {string} options.sql_alias - Table SQL Alias, e.g. 'a', 'b', etc.. + * @param {string} [options.sql_table] - Table name, e.g. 'users' * @param {object} options.table_schema - Table schema * @param {string|null} options.conditional_operators_in_value - Allowable conditional operators in value * @param {Dare} options.dareInstance - Dare Instance @@ -24,6 +25,7 @@ export default function reduceConditions( { extract, sql_alias, + sql_table, table_schema, conditional_operators_in_value, dareInstance, @@ -67,6 +69,7 @@ export default function reduceConditions( field: key, value, sql_alias, + sql_table, table_schema, operators, conditional_operators_in_value, @@ -107,6 +110,7 @@ function stripKey(key) { * @param {string} params.field - Field name * @param {string} params.value - Field value * @param {string} params.sql_alias - SQL Alias + * @param {string} params.sql_table - Table name * @param {object} params.table_schema - Table schema * @param {string|null} params.operators - Allowable operators * @param {string|null} params.conditional_operators_in_value - Allowable conditional operators in value @@ -117,6 +121,7 @@ function prepCondition({ field, value, sql_alias, + sql_table, table_schema, operators = '', conditional_operators_in_value, @@ -142,7 +147,10 @@ function prepCondition({ // Join the fields const sql_field_array = sql_fields.map(({sql}) => sql); - return dareInstance.fulltextSearch(sql_field_array, value, NOT); + return dareInstance.fulltextSearch(sql_field_array, value, NOT, { + sql_alias, + sql_table, + }); } else if (sql_fields.length > 1) { /* * Is the field an array of field names? @@ -157,6 +165,7 @@ function prepCondition({ field, value, sql_alias, + sql_table, table_schema, operators: operators.replace('-', ''), conditional_operators_in_value, diff --git a/src/format_request.js b/src/format_request.js index 0fe681c..93d58bf 100644 --- a/src/format_request.js +++ b/src/format_request.js @@ -99,6 +99,12 @@ async function format_request(options, dareInstance) { */ if (options.method === 'del' && !options.parent) { options.sql_alias = options.sql_table; + } else if ( + options.method === 'patch' && + !options.parent && + !dareInstance.applyTableAliasOnUpdate + ) { + options.sql_alias = options.sql_table; } else { /** EOF Hack */ options.sql_alias = dareInstance.get_unique_alias(); @@ -226,6 +232,7 @@ async function format_request(options, dareInstance) { const arr = reduceConditions(options.filter, { extract, sql_alias, + sql_table: options.sql_table, table_schema, conditional_operators_in_value, dareInstance, @@ -289,6 +296,7 @@ async function format_request(options, dareInstance) { const arrJoins = reduceConditions(options.join, { extract, sql_alias, + sql_table: options.sql_table, table_schema, conditional_operators_in_value, dareInstance, diff --git a/src/index.js b/src/index.js index c202d8d..ad040b3 100644 --- a/src/index.js +++ b/src/index.js @@ -22,7 +22,7 @@ import response_handler, {responseRowHandler} from './response_handler.js'; /** * @import {Sql} from 'sql-template-tag' * - * @typedef {`${'mysql' | 'postgres' | 'mariadb'}:${number}.${number}${string?}`} Engine + * @typedef {`${'mysql' | 'postgres' | 'mariadb' | 'sqlite'}:${number}.${number}${string?}` | `sqlite:${number}`} Engine * * @typedef {Pick} ModalHandlerExtraProps * @@ -253,6 +253,12 @@ Dare.prototype.jsonFormatValue = function jsonFormatValue( /** @type {string} */ Dare.prototype.rowid = '_rowid'; +/** + * Default value to use in INSERT statements when a column value is missing + * MySQL/MariaDB support the DEFAULT keyword, SQLite does not + */ +Dare.prototype.sql_default_value = raw('DEFAULT'); + // Set the Max Limit for SELECT statements /** @type {number} */ Dare.prototype.MAX_LIMIT = null; @@ -289,6 +295,12 @@ Dare.prototype.applySubqueryOnDML = false; */ Dare.prototype.applyAliasesOnUpdate = true; +/** + * Apply table alias on UPDATE statement - SQLite doesn't support UPDATE tbl alias SET ... + * @type {boolean} + */ +Dare.prototype.applyTableAliasOnUpdate = true; + /** * SQL insert suffix - Additional SQL to append to insert statements, e.g., RETURNING clause for Postgres * @type {string | undefined} @@ -351,12 +363,15 @@ Dare.prototype.getFieldKey = function getFieldKey(field, schema) { * @param {Sql[]} sql_field_array - Array of SQL fields to apply the fulltext search to * @param {string} value - Fulltext search string * @param {Sql} [NOT] - Whether to negate the fulltext search + * @param {object} [context] - Additional context (sql_alias, sql_table) * @returns {Sql} SQL condition for the fulltext search */ Dare.prototype.fulltextSearch = function fulltextSearch( sql_field_array, value, - NOT + NOT, + // eslint-disable-next-line no-unused-vars + context ) { return SQL`${NOT}MATCH(${join(sql_field_array, ', ')}) AGAINST(${this.fulltextParser(value)} IN BOOLEAN MODE)`; }; @@ -775,7 +790,7 @@ Dare.prototype.patch = async function patch(table, filter, body, options = {}) { // Construct a db update const sql = SQL` - UPDATE ${raw(exec)}${raw(req.sql_table)} ${raw(req.sql_alias)} + UPDATE ${raw(exec)}${raw(req.sql_table)} ${dareInstance.applyTableAliasOnUpdate ? raw(req.sql_alias) : empty} ${req.sql_joins.length ? join(req.sql_joins, '\n') : empty} SET ${sql_set} WHERE @@ -980,7 +995,7 @@ Dare.prototype.post = async function post(table, body, options = {}) { const a = fields.map((_, index) => { // If any of the values are missing, set them as DEFAULT if (_data[index] === undefined) { - return raw('DEFAULT'); + return dareInstance.sql_default_value; } // Return the prepared statement placeholder diff --git a/src/sqlite.js b/src/sqlite.js new file mode 100644 index 0000000..84fca72 --- /dev/null +++ b/src/sqlite.js @@ -0,0 +1,198 @@ +import SQL, {raw} from 'sql-template-tag'; +import Dare from './index.js'; + +/** + * SQLiteDare + * Extends Dare with SQLite-specific overrides + * + * @param {object} options - Initial options defining the instance + * @returns {import('./index.js').default} instance of SQLiteDare + */ +function SQLiteDare(options = {}) { + const instance = new Dare({ + ...options, + engine: options.engine || 'sqlite:3', + }); + Object.setPrototypeOf(instance, SQLiteDare.prototype); + return instance; +} + +// Inherit from Dare +SQLiteDare.prototype = Object.create(Dare.prototype); +SQLiteDare.prototype.constructor = SQLiteDare; + +/** + * Default engine for SQLite + * @type {string} + */ +SQLiteDare.prototype.engine = 'sqlite:3'; + +/** + * SQLite uses `id` as the rowid (alias for rowid) + * @type {string} + */ +SQLiteDare.prototype.rowid = 'id'; + +/** + * SQLite uses LIKE (case-insensitive for ASCII by default) + * @type {string} + */ +SQLiteDare.prototype.sql_keyword_like = 'LIKE'; + +/** + * SQLite JSON EXTRACT prefix + * @type {string} + */ +SQLiteDare.prototype.sql_json_extract_prefix = '$'; + +/** + * SQLite JSON EXTRACT operator + * @type {string} + */ +SQLiteDare.prototype.sql_json_extract_operator = '->>'; + +/** + * Sql_json_array - SQLite JSON_ARRAY + * @type {Dare['sql_json_array']} + */ +SQLiteDare.prototype.sql_json_array = function sql_json_array(expressions) { + return `JSON_ARRAY(${expressions.join(',')})`; +}; + +/** + * SQL Array Agg - SQLite uses JSON_GROUP_ARRAY + * @type {Dare['sql_json_arrayagg']} + */ +SQLiteDare.prototype.sql_json_arrayagg = function sql_json_arrayagg({ + sql_alias, + expression, +}) { + const condition = `CASE WHEN (${sql_alias}.${this.rowid} IS NOT NULL) THEN (${expression}) ELSE NULL END`; + return `JSON_GROUP_ARRAY(${condition})`; +}; + +/** + * SQLite does not support CTE LIMIT filtering + * @type {Dare['applyCTELimitFiltering']} + */ +SQLiteDare.prototype.applyCTELimitFiltering = function () { + return false; +}; + +/** + * Apply limit on DML - SQLite supports LIMIT on DELETE but not in all contexts + * @type {boolean} + */ +SQLiteDare.prototype.applyLimitOnDML = false; + +/** + * SQLite does not allow joining onto the table being modified in patch / delete requests + * To work around this, we need to use subquery joins + * @type {boolean} + */ +SQLiteDare.prototype.applySubqueryOnDML = true; + +/** + * Apply aliases to UPDATE statements - SQLite doesn't support this + * @type {boolean} + */ +SQLiteDare.prototype.applyAliasesOnUpdate = false; + +/** + * SQLite does not support UPDATE tbl alias SET ... + * @type {boolean} + */ +SQLiteDare.prototype.applyTableAliasOnUpdate = false; + +/** + * SQL insert suffix - SQLite uses RETURNING clause + * @type {string} + */ +SQLiteDare.prototype.sql_insert_suffix = ` RETURNING id`; + +/** + * IdentifierWrapper - SQLite uses double quotes for identifiers + * @type {Dare['identifierWrapper']} + */ +SQLiteDare.prototype.identifierWrapper = function identifierWrapper(field) { + return ['"', field, '"'].join(''); +}; + +/** + * SQLite does not support DEFAULT keyword in VALUES, use NULL instead + */ +SQLiteDare.prototype.sql_default_value = null; + +/** + * On Duplicate Keys Update - SQLite uses ON CONFLICT with DO UPDATE/DO NOTHING + * @type {Dare['onDuplicateKeysUpdate']} + */ +SQLiteDare.prototype.onDuplicateKeysUpdate = function onDuplicateKeysUpdate({ + keys = [], + existing = [], + duplicate_keys, +}) { + if (!keys.length) { + return `ON CONFLICT DO NOTHING`; + } + + let conflictKeys; + + if (Array.isArray(duplicate_keys) && duplicate_keys.length) { + conflictKeys = duplicate_keys; + } else { + conflictKeys = existing.filter(item => !keys.includes(item)); + + if (!conflictKeys.length) { + conflictKeys.push(this.rowid); + } + } + + return ` + ON CONFLICT (${conflictKeys.map(key => this.identifierWrapper(key)).join(',')}) + DO UPDATE + SET ${keys.map(name => `${this.identifierWrapper(name)}=EXCLUDED.${this.identifierWrapper(name)}`).join(',')} + `; +}; + +/** + * FulltextSearch - SQLite implementation using FTS5 + * Requires a virtual FTS5 table named {table}_fts with matching columns + * @type {Dare['fulltextSearch']} + */ +SQLiteDare.prototype.fulltextSearch = function fulltextSearch( + sql_field_array, + value, + NOT, + {sql_alias, sql_table} = {} +) { + const fts_table = `${sql_table}_fts`; + const parsed = this.fulltextParser(value); + return SQL`${NOT}${raw(sql_alias)}.id IN (SELECT rowid FROM ${raw(fts_table)} WHERE ${raw(fts_table)} MATCH ${parsed})`; +}; + +/** + * FulltextSignParser - SQLite FTS5 does not use +/< />/~ prefixes + * @type {Dare['fulltextSignParser']} + */ +SQLiteDare.prototype.fulltextSignParser = function fulltextSignParser( + sign, + // eslint-disable-next-line no-unused-vars + index +) { + // Strip MySQL boolean mode operators; FTS5 uses implicit AND + return sign.replace(/[+<>~]/g, ''); +}; + +/** + * Pass through value verbatim for JSON formatting + * @type {Dare['jsonFormatValue']} + */ +SQLiteDare.prototype.jsonFormatValue = function jsonFormatValue(value) { + if (Array.isArray(value)) { + return value.map(item => this.jsonFormatValue(item)); + } + return value; +}; + +export default SQLiteDare; diff --git a/test/integration/data/schema.sqlite.sql b/test/integration/data/schema.sqlite.sql new file mode 100644 index 0000000..2ab78ea --- /dev/null +++ b/test/integration/data/schema.sqlite.sql @@ -0,0 +1,57 @@ +CREATE TABLE country ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code CHAR(2) NOT NULL, + created_time INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(255) NOT NULL, + first_name VARCHAR(255) DEFAULT NULL, + last_name VARCHAR(255) DEFAULT NULL, + uuid BLOB DEFAULT NULL, + secret VARCHAR(2048) DEFAULT NULL, + country_id INTEGER DEFAULT NULL, + settings TEXT DEFAULT NULL, + CONSTRAINT unique_username UNIQUE (username), + FOREIGN KEY (country_id) REFERENCES country (id) +); + +CREATE TABLE users_email ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + email VARCHAR(255) NOT NULL, + CONSTRAINT unique_email UNIQUE (email), + FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT NULL, + updated_time TIMESTAMP, + CONSTRAINT unique_name UNIQUE (name) +); + +CREATE TABLE userTeams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + team_id INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (team_id) REFERENCES teams (id) +); + +CREATE VIRTUAL TABLE users_fts USING fts5(username, first_name, last_name, content='users', content_rowid='id'); + +CREATE TRIGGER users_ai AFTER INSERT ON users BEGIN + INSERT INTO users_fts(rowid, username, first_name, last_name) VALUES (new.id, new.username, new.first_name, new.last_name); +END; + +CREATE TRIGGER users_ad AFTER DELETE ON users BEGIN + INSERT INTO users_fts(users_fts, rowid, username, first_name, last_name) VALUES('delete', old.id, old.username, old.first_name, old.last_name); +END; + +CREATE TRIGGER users_au AFTER UPDATE ON users BEGIN + INSERT INTO users_fts(users_fts, rowid, username, first_name, last_name) VALUES('delete', old.id, old.username, old.first_name, old.last_name); + INSERT INTO users_fts(rowid, username, first_name, last_name) VALUES (new.id, new.username, new.first_name, new.last_name); +END; diff --git a/test/integration/helpers/SQLite.js b/test/integration/helpers/SQLite.js new file mode 100644 index 0000000..d93db81 --- /dev/null +++ b/test/integration/helpers/SQLite.js @@ -0,0 +1,166 @@ +import {DatabaseSync} from 'node:sqlite'; +import {Readable} from 'node:stream'; +import fs from 'node:fs'; + +const {TEST_DB_SCHEMA_PATH, TEST_DB_DATA_PATH} = process.env; + +const schemaSql = TEST_DB_SCHEMA_PATH + ? fs.readFileSync(TEST_DB_SCHEMA_PATH, 'utf8') + : ''; +const insertDataSql = TEST_DB_DATA_PATH + ? fs.readFileSync(TEST_DB_DATA_PATH, 'utf8') + : ''; + +/* + * SQLite returns Object.create(null) rows; convert to plain objects + * Also convert Uint8Array to Buffer for compatibility + */ +function toPlainObject(row) { + const obj = {}; + for (const [key, value] of Object.entries(row)) { + obj[key] = value instanceof Uint8Array ? Buffer.from(value) : value; + } + return obj; +} + +export default class SQLite { + constructor(credentials) { + this.credentials = credentials; + } + + async init() { + // Open an in-memory SQLite database + this.db = new DatabaseSync(':memory:'); + + // Enable WAL mode for performance + this.db.exec('PRAGMA journal_mode=WAL'); + this.db.exec('PRAGMA foreign_keys=ON'); + + // Create schema + if (schemaSql) { + this.db.exec(schemaSql); + } + + // Extract the table names (exclude virtual/shadow tables like FTS5 which are managed by triggers) + const tables = this.db + .prepare( + "SELECT name FROM pragma_table_list WHERE schema='main' AND type='table' AND name NOT LIKE 'sqlite_%'" + ) + .all(); + this.tables = tables.map(({name}) => name); + + // Insert data + if (insertDataSql) { + this.db.exec(insertDataSql); + } + + return this.db; + } + + async query(request) { + const sql = request.sql || request.text || request; + const values = (request.values || []).map(v => { + // SQLite cannot bind undefined; convert to null + if (v === undefined) return null; + // SQLite cannot bind booleans; convert to 0/1 + if (typeof v === 'boolean') return v ? 1 : 0; + // SQLite needs Uint8Array for binary data, not Buffer + if (Buffer.isBuffer(v)) return new Uint8Array(v); + // SQLite cannot bind objects/arrays; serialize as JSON string + if (v !== null && typeof v === 'object') return JSON.stringify(v); + return v; + }); + + // Determine statement type + const trimmed = sql.trim().toUpperCase(); + + if (trimmed.startsWith('INSERT')) { + const stmt = this.db.prepare(sql); + if (trimmed.includes('RETURNING')) { + const rows = stmt.all(...values).map(toPlainObject); + return { + insertId: rows?.[0]?.id, + affectedRows: rows.length, + }; + } + const result = stmt.run(...values); + return { + insertId: Number(result.lastInsertRowid), + affectedRows: result.changes, + }; + } else if ( + trimmed.startsWith('UPDATE') || + trimmed.startsWith('DELETE') + ) { + const stmt = this.db.prepare(sql); + const result = stmt.run(...values); + return { + affectedRows: result.changes, + }; + } else if (trimmed.startsWith('SELECT')) { + const stmt = this.db.prepare(sql); + return stmt.all(...values).map(toPlainObject); + } else { + // For other statements (CREATE, etc.) + this.db.exec(sql); + return {affectedRows: 0}; + } + } + + async resetDbState() { + // Disable foreign keys during reset + this.db.exec('PRAGMA foreign_keys=OFF'); + + // Truncate all tables + for (const table of this.tables) { + this.db.exec(`DELETE FROM "${table}"`); + } + + // Reset auto-increment sequences + this.db.exec(`DELETE FROM sqlite_sequence`); + + // Re-insert base data + if (insertDataSql) { + this.db.exec(insertDataSql); + } + + // Re-enable foreign keys + this.db.exec('PRAGMA foreign_keys=ON'); + } + + stream(request, streamOptions = {objectMode: true, highWaterMark: 5}) { + const sql = request.sql || request.text || request; + const values = (request.values || []).map(v => { + if (v === undefined) return null; + if (typeof v === 'boolean') return v ? 1 : 0; + if (Buffer.isBuffer(v)) return new Uint8Array(v); + if (v !== null && typeof v === 'object') return JSON.stringify(v); + return v; + }); + + const stmt = this.db.prepare(sql); + const iterator = stmt.iterate(...values); + + return new Readable({ + ...streamOptions, + read() { + try { + const {value, done} = iterator.next(); + if (done) { + this.push(null); + } else { + this.push(toPlainObject(value)); + } + } catch (err) { + this.destroy(err); + } + }, + }); + } + + end() { + if (this.db) { + this.db.close(); + } + } +} diff --git a/test/integration/helpers/api.js b/test/integration/helpers/api.js index 9ca9c3b..055a102 100644 --- a/test/integration/helpers/api.js +++ b/test/integration/helpers/api.js @@ -1,6 +1,7 @@ import Dare from '../../../src/index.js'; import PostgresDare from '../../../src/postgres16.js'; import MySQL57Dare from '../../../src/mysql57.js'; +import SQLiteDare from '../../../src/sqlite.js'; import Debug from 'debug'; import mysql from 'mysql2/promise'; import db from './db.js'; @@ -15,9 +16,12 @@ const {DB_ENGINE} = process.env; /** @type {any} */ const DareConstructor = DB_ENGINE?.startsWith('postgres') ? PostgresDare - : DB_ENGINE?.startsWith('mysql:5.7') || DB_ENGINE?.startsWith('mysql:5.6') - ? MySQL57Dare - : Dare; + : DB_ENGINE?.startsWith('sqlite') + ? SQLiteDare + : DB_ENGINE?.startsWith('mysql:5.7') || + DB_ENGINE?.startsWith('mysql:5.6') + ? MySQL57Dare + : Dare; export default function dareInstance() { // Initiate diff --git a/test/integration/helpers/db.js b/test/integration/helpers/db.js index d4aaed1..53ca5ca 100644 --- a/test/integration/helpers/db.js +++ b/test/integration/helpers/db.js @@ -1,5 +1,6 @@ import MySQL from './MySQL.js'; import Postgres from './Postgres.js'; +import SQLite from './SQLite.js'; const { DB_ENGINE = 'mysql:5.6', @@ -22,7 +23,9 @@ const dbSettings = { }; let dbInstance; -if (DB_ENGINE.startsWith('mysql') || DB_ENGINE.startsWith('mariadb')) { +if (DB_ENGINE.startsWith('sqlite')) { + dbInstance = new SQLite(dbSettings); +} else if (DB_ENGINE.startsWith('mysql') || DB_ENGINE.startsWith('mariadb')) { dbInstance = new MySQL(dbSettings); } else if (DB_ENGINE.startsWith('postgres')) { dbInstance = new Postgres(dbSettings); diff --git a/test/integration/json.spec.js b/test/integration/json.spec.js index db124a0..93958ba 100644 --- a/test/integration/json.spec.js +++ b/test/integration/json.spec.js @@ -114,9 +114,13 @@ describe('Working with JSON DataType', () => { '-bool': false, '-notnull': null, - // Range - '~digit': '0..2', - '-~digit': '10..100', + /** + * Range + * SQLite does not cast string values to numbers + */ + ...(!DB_ENGINE?.startsWith('sqlite') + ? {'~digit': '1..2', '-~digit': '10..100'} + : null), // Like operator '%stringy': 'chee%', // In MySQL the LIKE operator looks at the "quoted" string, so need to add a quote if comparing against the start or end of a value respectively @@ -182,6 +186,11 @@ describe('Working with JSON DataType', () => { // Update the user settings with a setFunction dare.options.models.users.schema.settings.patch = { setFunction({sql_field, value}) { + if (DB_ENGINE?.startsWith('sqlite')) { + return SQL`JSON_PATCH(${raw(sql_field)}, ${value})`; + } + + // MySQL/MariaDB/Postgres... possibly others, but not SQLite return SQL`JSON_MERGE_PATCH(${raw(sql_field)}, ${value})`; }, }; diff --git a/test/integration/run.sh b/test/integration/run.sh index 19574f3..fd5904a 100644 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -40,6 +40,27 @@ export DB_PORT=3308 export TZ="UTC" +if [ "$DB_ENGINE_NAME" = "sqlite" ] +then + echo "Starting SQLite (in-process, no Docker needed)" + + echo 'building template db...' + export TEST_DB_PREFIX="test_" + echo 'template db built' + + echo 'running tests...' + set +e + ( + set -x + mocha './**/*.spec.js' "$@" + ) + EXIT_CODE=$? + set -e + echo 'tests complete' + echo "finished (tests exit code: $EXIT_CODE)" + exit $EXIT_CODE +fi + docker rm -vf "dare_db" echo "Starting $DB_ENGINE" diff --git a/test/integration/stream.spec.js b/test/integration/stream.spec.js index 82bcd4d..0d6ad70 100644 --- a/test/integration/stream.spec.js +++ b/test/integration/stream.spec.js @@ -1,17 +1,15 @@ import assert from 'node:assert/strict'; -import Dare from '../../src/index.js'; import Debug from 'debug'; import mysql from 'mysql2/promise'; import db from './helpers/db.js'; -import options from '../data/options.js'; import defaultAPI from './helpers/api.js'; const debug = Debug('sql'); function DareStream() { // Initiate - const dare = new Dare(options); + const dare = defaultAPI(); // Set a test instance dare.execute = async function (query) { diff --git a/test/specs/sqlite/delete.spec.js b/test/specs/sqlite/delete.spec.js new file mode 100644 index 0000000..a7106ab --- /dev/null +++ b/test/specs/sqlite/delete.spec.js @@ -0,0 +1,52 @@ +import assert from 'node:assert'; +import Dare from '../../../src/sqlite.js'; +import {describe, it, beforeEach} from 'node:test'; +import sqlEqual from '../../lib/sql-equal.js'; + +describe('SQLite - Delete', () => { + let dare; + + // Mock instance of Dare + beforeEach(() => { + dare = new Dare(); + }); + + it(`should delete with subquery conditions rather than JOINs`, async () => { + dare.options.models = { + tbl: { + schema: { + // Create a reference to tblB + ref_id: ['tblB.id'], + }, + }, + }; + + dare.execute = async ({sql, values}) => { + sqlEqual( + sql, + `DELETE FROM tbl + WHERE tbl.id = ? + AND tbl.ref_id IN ( + SELECT id FROM ( + SELECT a.id FROM tblB a WHERE a.id= ? + ) AS a_tmp + ) + ` + ); + assert.deepStrictEqual(values, [1, 1]); + return {success: true}; + }; + + const test = await dare.del({ + table: 'tbl', + filter: { + id: 1, + tblB: { + id: 1, + }, + }, + }); + + assert.deepStrictEqual(test, {success: true}); + }); +}); diff --git a/test/specs/sqlite/filter_reducer.spec.js b/test/specs/sqlite/filter_reducer.spec.js new file mode 100644 index 0000000..856c3fc --- /dev/null +++ b/test/specs/sqlite/filter_reducer.spec.js @@ -0,0 +1,90 @@ +import assert from 'node:assert'; +import Dare from '../../../src/sqlite.js'; + +import reduceConditions from '../../../src/format/reducer_conditions.js'; +import {describe, it, beforeEach} from 'node:test'; + +const engine = 'sqlite:3'; + +describe('SQLite - Filter Reducer', () => { + let dareInstance; + const conditional_operators_in_value = null; + let table_schema = null; + // eslint-disable-next-line func-style + const extract = () => { + // Do nothing + }; + + // Mock instance of Dare + beforeEach(() => { + dareInstance = new Dare(); + table_schema = { + jsonSettings: { + type: 'json', + }, + // Join with an arbitrary table + a_id: 'a.id', + }; + }); + + it('should use FTS5 MATCH for fulltext search', async () => { + const dareInst = dareInstance.use({engine}); + + table_schema = { + textsearch: 'first_name,last_name', + }; + + const filter = { + '*textsearch': 'hello', + }; + + const [query] = reduceConditions(filter, { + extract, + sql_alias: 'a', + sql_table: 'users', + table_schema, + conditional_operators_in_value, + dareInstance: dareInst, + }); + + // SQLite fulltext uses FTS5 MATCH + assert.ok(query.sql.includes('MATCH')); + assert.ok(query.sql.includes('users_fts')); + }); + + it('should handle JSON fields with ->> operator', async () => { + const dareInst = dareInstance.use({engine}); + + const filter = { + 'jsonSettings.key': 'value', + }; + + const [query] = reduceConditions(filter, { + extract, + sql_alias: 'a', + sql_table: 'users', + table_schema, + conditional_operators_in_value, + dareInstance: dareInst, + }); + + assert.ok(query.sql.includes('->>')); + }); + it('should format array values of a JSON field correctly', async () => { + const dareInst = dareInstance.use({engine}); + + const filter = { + 'jsonSettings.key': ['value1', 'value2'], + }; + + const [query] = reduceConditions(filter, { + extract, + sql_alias: 'a', + sql_table: 'users', + table_schema, + conditional_operators_in_value, + dareInstance: dareInst, + }); + assert.ok(query.sql.includes(' IN (')); + }); +}); diff --git a/test/specs/sqlite/get-subquery.spec.js b/test/specs/sqlite/get-subquery.spec.js new file mode 100644 index 0000000..76ae565 --- /dev/null +++ b/test/specs/sqlite/get-subquery.spec.js @@ -0,0 +1,79 @@ +import SQLiteDare from '../../../src/sqlite.js'; + +// Test Generic DB functions +import expectSQLEqual from '../../lib/sql-equal.js'; +import {describe, it, beforeEach} from 'node:test'; + +// Dare instance +let dare; + +// Create a schema +const options = { + models: { + // Define Datasets + assets: {}, + collections: {}, + + // Define a table to associate datasets + assetCollections: { + schema: { + asset_id: ['assets.id'], + collection_id: ['collections.id'], + }, + }, + + // Collection children + collectionChildren: { + schema: { + collection_id: ['collections.id'], + }, + }, + }, +}; + +describe('get - subquery', () => { + beforeEach(() => { + dare = new SQLiteDare({...options}); + }); + + it('MySQL 5.* does not support CTE', async () => { + const dareInst = dare.use(); + + dareInst.sql = ({sql}) => { + const expected = ` + SELECT a.id, + ( + SELECT b.email + FROM userEmails b + WHERE + b.user_id = a.id + LIMIT 1 + ) AS "email" + FROM users a + GROUP BY a.id + LIMIT 1`; + + expectSQLEqual(sql, expected); + + return Promise.resolve([{}]); + }; + + dareInst.options = { + models: { + userEmails: { + schema: {user_id: ['users.id']}, + }, + }, + }; + + return dareInst.get({ + table: 'users', + fields: [ + 'id', + { + email: 'userEmails.email', + }, + ], + }); + }); +}); diff --git a/test/specs/sqlite/patch.spec.js b/test/specs/sqlite/patch.spec.js new file mode 100644 index 0000000..07da3ea --- /dev/null +++ b/test/specs/sqlite/patch.spec.js @@ -0,0 +1,40 @@ +import assert from 'node:assert'; +import Dare from '../../../src/sqlite.js'; + +// Test Generic DB functions +import sqlEqual from '../../lib/sql-equal.js'; + +import {describe, it, beforeEach} from 'node:test'; + +const id = 1; +const name = 'name'; + +describe('SQLite - patch', () => { + /** @type {any} */ + let dare; + + beforeEach(() => { + dare = new Dare(); + + // Should not be called... + dare.execute = () => { + throw new Error('execute called'); + }; + }); + + it(`should use the correct syntax for sqlite`, async () => { + const dareInst = dare.use({engine: 'sqlite:3'}); + + dareInst.execute = async ({sql, values}) => { + sqlEqual(sql, 'UPDATE tbl SET "name" = ? WHERE tbl.id = ?'); + assert.deepStrictEqual(values, [name, id]); + return {success: true}; + }; + + return dareInst.patch({ + table: 'tbl', + filter: {id}, + body: {name}, + }); + }); +}); diff --git a/test/specs/sqlite/post.spec.js b/test/specs/sqlite/post.spec.js new file mode 100644 index 0000000..a2417ea --- /dev/null +++ b/test/specs/sqlite/post.spec.js @@ -0,0 +1,75 @@ +import assert from 'node:assert'; +import Dare from '../../../src/sqlite.js'; + +// Test Generic DB functions +import sqlEqual from '../../lib/sql-equal.js'; + +import {describe, it, beforeEach} from 'node:test'; + +const DB_ENGINE = 'sqlite:3'; + +describe('sqlite - post', () => { + /** @type {any} */ + let dare; + + beforeEach(() => { + dare = new Dare({engine: DB_ENGINE}); + + // Should not be called... + dare.execute = () => { + throw new Error('execute called'); + }; + }); + + it(`${DB_ENGINE} should use ON CONFLICT ... UPDATE ...`, async () => { + dare.execute = async ({sql, values}) => { + sqlEqual( + sql, + 'INSERT INTO test ("id", "name") VALUES (?, ?) ON CONFLICT ("uni_colmn") DO UPDATE SET "name"=EXCLUDED."name" RETURNING id' + ); + assert.deepStrictEqual(values, [1, 'name']); + return {success: true}; + }; + + return dare.post({ + table: 'test', + body: {id: 1, name: 'name'}, + duplicate_keys_update: ['name'], + duplicate_keys: ['uni_colmn'], + }); + }); + + it(`${DB_ENGINE} should use ON CONFLICT ("id") UPDATE ...`, async () => { + dare.execute = async ({sql, values}) => { + sqlEqual( + sql, + 'INSERT INTO test ("id", "name") VALUES (?, ?) ON CONFLICT ("id") DO UPDATE SET "id"=EXCLUDED."id", "name"=EXCLUDED."name" RETURNING id' + ); + assert.deepStrictEqual(values, [1, 'name']); + return {success: true}; + }; + + return dare.post({ + table: 'test', + body: {id: 1, name: 'name'}, + duplicate_keys_update: ['id', 'name'], + }); + }); + + it(`${DB_ENGINE} should use ON CONFLICT DO NOTHING for ignore`, async () => { + dare.execute = async ({sql, values}) => { + sqlEqual( + sql, + 'INSERT INTO test ("id", "name") VALUES (?, ?) ON CONFLICT DO NOTHING RETURNING id' + ); + assert.deepStrictEqual(values, [1, 'name']); + return {success: true}; + }; + + return dare.post({ + table: 'test', + body: {id: 1, name: 'name'}, + duplicate_keys: 'ignore', + }); + }); +}); diff --git a/test/specs/sqlite/utils_group_concat.spec.js b/test/specs/sqlite/utils_group_concat.spec.js new file mode 100644 index 0000000..3fdd873 --- /dev/null +++ b/test/specs/sqlite/utils_group_concat.spec.js @@ -0,0 +1,59 @@ +/* eslint quotes: ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": true }]*/ +import assert from 'node:assert'; + +// Test Generic DB functions +import group_concat from '../../../src/utils/group_concat.js'; +import SQLiteDare from '../../../src/sqlite.js'; +import {describe, it, beforeEach} from 'node:test'; + +describe(`utils/group_concat: (sqlite)`, () => { + let dareInstance; + + beforeEach(() => { + dareInstance = new SQLiteDare({engine: 'sqlite:3'}); + }); + + it('should reduce an array of fields to a JSON_GROUP_ARRAY statement', async () => { + const gc = group_concat({ + fields: [ + { + expression: 'table.a', + label: 'collection.a', + }, + { + expression: 'table.b', + label: 'collection.b', + }, + ], + address: 'collection.', + sql_alias: 'a', + dareInstance, + }); + + assert.strictEqual( + gc.expression, + `JSON_GROUP_ARRAY(CASE WHEN (a.id IS NOT NULL) THEN (JSON_ARRAY(table.a,table.b)) ELSE NULL END)` + ); + assert.deepStrictEqual(gc.label, 'collection[a,b]'); + }); + + it('should not wrap fields which are marked as aggregating the row', async () => { + const gc = group_concat({ + fields: [ + { + expression: 'table.a', + label: 'a', + agg: true, + }, + { + expression: 'table.b', + label: 'b', + }, + ], + dareInstance, + }); + + assert.strictEqual(gc.expression, `JSON_ARRAY(table.a,table.b)`); + assert.strictEqual(gc.label, 'a,b'); + }); +});