diff --git a/addons/api/addon-test-support/helpers/sqlite.js b/addons/api/addon-test-support/helpers/sqlite.js new file mode 100644 index 000000000..ad6adfc79 --- /dev/null +++ b/addons/api/addon-test-support/helpers/sqlite.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { v4 as uuidv4 } from 'uuid'; + +/** + * This test helper can help set up and cleanup the database in your tests. + * + * ```js + * import { setupSqlite } from 'api/test-support/helpers/sqlite'; + * + * module('Acceptance | my test', function(hooks) { + * setupApplicationTest(hooks); + * setupSqlite(hooks); + * + * // add your actual tests here + * }); + * ``` + * + * @param hooks + */ +export function setupSqlite(hooks) { + hooks.beforeEach(async function () { + const sqliteDb = this.owner.lookup('service:sqlite'); + await sqliteDb.setup(`test-sqlite-${uuidv4()}`); + }); + + hooks.afterEach(async function () { + const sqliteDb = this.owner.lookup('service:sqlite'); + + await sqliteDb.deleteDatabase(); + }); +} + +export default setupSqlite; diff --git a/addons/api/addon/handlers/sqlite-handler.js b/addons/api/addon/handlers/sqlite-handler.js index 8583bd084..12ec7e95b 100644 --- a/addons/api/addon/handlers/sqlite-handler.js +++ b/addons/api/addon/handlers/sqlite-handler.js @@ -4,16 +4,203 @@ */ import { getOwner, setOwner } from '@ember/owner'; +import { service } from '@ember/service'; +import { modelMapping } from 'api/services/sqlite'; +import { get } from '@ember/object'; +import { typeOf } from '@ember/utils'; +import { hashCode } from '../utils/hash-code'; +import { generateSQLExpressions } from '../utils/sqlite-query'; export default class SqliteHandler { + @service sqlite; + + batchLimit = 5_000; + constructor(context) { setOwner(this, getOwner(context)); } async request(context, next) { switch (context.request.op) { + case 'query': { + const { store, data } = context.request; + const { + type, + query, + options: { + pushToStore = true, + peekDb = false, + storeToken = true, + } = {}, + } = data; + const supportedModels = Object.keys(modelMapping); + + // Go through normal flow if we don't yet support the model + // or if we don't have a sqlite db instance + if (!supportedModels.includes(type) || !this.sqlite) { + return next(context.request); + } + + const adapter = store.adapterFor(type); + const schema = store.modelFor(type); + const serializer = store.serializerFor(type); + + let { page, pageSize, query: queryObj, ...remainingQuery } = query; + let payload, + listToken, + writeToDbPromise, + totalInsert = 0; + + if (!peekDb) { + const tokenKey = `${type}-${hashCode(remainingQuery)}`; + if (storeToken) { + const [tokenObj] = await this.sqlite.fetchResource({ + ...generateSQLExpressions('token', { + filters: { id: [{ equals: tokenKey }] }, + }), + }); + listToken = tokenObj?.token; + } + + do { + try { + payload = await adapter.query(store, schema, { + ...remainingQuery, + list_token: listToken, + batchLimit: this.batchLimit, + }); + totalInsert += payload.items?.length ?? 0; + + // await the previous writeToDbPromise before writing to db again + if (writeToDbPromise) { + await writeToDbPromise; + } + } catch (err) { + console.error('Error retrieving data:', err); + throw err; + } + listToken = payload.list_token; + + writeToDbPromise = this.writeToDb( + payload, + storeToken, + tokenKey, + serializer, + store, + schema, + type, + ); + } while (payload.response_type === 'delta'); + + await writeToDbPromise; + } + + // TODO: Remove all timers once we're ready to merge back to main + console.time(`SQLite fetch ${type}`); + + if (totalInsert > 0) { + // If there were any inserts, let sqlite handle running analyze on the DB + await this.sqlite.analyzeDatabase(); + console.timeLog(`SQLite fetch ${type}`, 'analyze'); + } + + const { sql, parameters } = generateSQLExpressions(type, queryObj, { + page, + pageSize, + select: ['data'], + }); + + const rows = await this.sqlite.fetchResource({ + sql, + parameters, + }); + console.timeLog(`SQLite fetch ${type}`, 'rows'); + + const { sql: countSql, parameters: countParams } = + generateSQLExpressions(type, queryObj, { + select: ['count(*) as total'], + }); + const count = await this.sqlite.fetchResource({ + sql: countSql, + parameters: countParams, + }); + console.timeLog(`SQLite fetch ${type}`, 'count'); + + const results = rows.map((item) => JSON.parse(item.data)); + + // If we are not pushing to the store, use the raw data with id property + const records = pushToStore + ? store.push({ data: results }) + : results.map((record) => ({ + ...record.attributes, + id: record.id, + })); + + // Set a meta property on the array to store the total items of the results. + // This isn't conventional but is better than returning an ArrayProxy + // or EmberArray since the ember store query method asserts it has to be an array + // so we can't just return an object. + console.timeEnd(`SQLite fetch ${type}`); + records.meta = { totalItems: count[0].total }; + return records; + } default: return next(context.request); } } + + async writeToDb( + payload, + storeToken, + tokenKey, + serializer, + store, + schema, + type, + ) { + if (payload.list_token && storeToken) { + await this.sqlite.insertResource('token', [ + [tokenKey, payload.list_token], + ]); + } + + // Remove any records from the DB if the API indicates they've been deleted + if (payload.removed_ids?.length > 0) { + await this.sqlite.deleteResource(type, payload.removed_ids); + } + + const normalizedPayload = serializer.normalizeResponse( + store, + schema, + payload, + null, + 'query', + ); + + const { data: payloadData } = normalizedPayload; + + // Store the new data we just got back from the API refresh + const items = payloadData.map((datum) => { + const params = Object.values(modelMapping[type]).map((key) => { + let value; + + if (key === 'id') { + value = datum.id; + } else { + value = get(datum, `attributes.${key}`); + } + + if (typeOf(value) === 'date') { + return value.toISOString(); + } + return value; + }); + + return [...params, JSON.stringify(datum)]; + }); + + if (items.length > 0) console.time(`SQLite insert`); + await this.sqlite.insertResource(type, items); + if (items.length > 0) console.timeEnd(`SQLite insert`); + } } diff --git a/addons/api/addon/services/sqlite-db.js b/addons/api/addon/services/sqlite-db.js deleted file mode 100644 index 9872f19da..000000000 --- a/addons/api/addon/services/sqlite-db.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Service from '@ember/service'; -import { PWBHost } from 'promise-worker-bi'; - -export default class SqliteDbService extends Service { - // =attributes - - // We need a handle on the original web worker to properly clean it up. - webWorker; - // This will be the worker we interact with that is wrapped by PWBHost. - worker; - - // TODO: Actually use the name that gets passed into setup later - setup() { - if (this.worker) { - // If the worker already exists, we don't need to do anything. - return; - } - - // In production, our JS files will be fingerprinted with an MD5 hash - // so we need a way to map the worker file name to be able to find it. - // - // Ember is automagically replacing any URL path with the fingerprinted - // asset, and this is done by broccoli-asset-rev. - // - // The problem is it's a bit finicky as seen in this issue - // https://github.com/ember-cli/broccoli-asset-rewrite/issues/66 - // and will not work properly if it's part of a template string or interpolated. - // Therefore, we need the URL path in a string somewhere to be replaced properly. - this.webWorker = new Worker('/workers/sqlite-worker.js', { - type: 'module', - }); - - // We use PWBHost to simplify communication so we can just send and receive - // messages with promises rather than having to manually set up event listeners - this.worker = new PWBHost(this.webWorker); - } - - willDestroy() { - this.webWorker?.terminate(); - } -} diff --git a/addons/api/addon/services/sqlite.js b/addons/api/addon/services/sqlite.js new file mode 100644 index 000000000..ab1ff80a0 --- /dev/null +++ b/addons/api/addon/services/sqlite.js @@ -0,0 +1,123 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Service from '@ember/service'; +import { PWBHost } from 'promise-worker-bi'; + +// A mapping of columns to their expected model attributes for supported models +// The JSON data column is not included here but is assumed to always be last. +export const modelMapping = { + target: { + id: 'id', + type: 'type', + name: 'name', + description: 'description', + address: 'address', + scope_id: 'scope.scope_id', + created_time: 'created_time', + }, +}; + +// A list of tables that we support searching using FTS5 in SQLite. +export const searchTables = new Set(['target']); + +export default class SqliteDbService extends Service { + // =attributes + + // We need a handle on the original web worker to properly clean it up. + webWorker; + // This will be the worker we interact with that is wrapped by PWBHost. + worker; + + setup(dbName) { + if (this.worker) { + // If the worker already exists, we don't need to do anything. + return; + } + + // In production, our JS files will be fingerprinted with an MD5 hash + // so we need a way to map the worker file name to be able to find it. + // + // Ember is automagically replacing any URL path with the fingerprinted + // asset, and this is done by broccoli-asset-rev. + // + // The problem is it's a bit finicky as seen in this issue + // https://github.com/ember-cli/broccoli-asset-rewrite/issues/66 + // and will not work properly if it's part of a template string or interpolated. + // Therefore, we need the URL path in a string somewhere to be replaced properly. + this.webWorker = new Worker('/workers/sqlite-worker.js', { + type: 'module', + }); + + // We use PWBHost to simplify communication so we can just send and receive + // messages with promises rather than having to manually set up event listeners + this.worker = new PWBHost(this.webWorker); + this.worker.register(async ({ method }) => { + if (method === 'getDatabaseName') { + return dbName; + } + }); + } + + fetchResource({ sql, parameters }) { + return this.worker.postMessage({ + method: 'fetchResource', + payload: { sql, parameters }, + }); + } + + insertResource(resource, items) { + return this.worker.postMessage({ + method: 'insertResource', + payload: { resource, items, modelMapping }, + }); + } + + deleteResource(resource, ids) { + return this.worker.postMessage({ + method: 'deleteResource', + payload: { resource, ids }, + }); + } + + analyzeDatabase() { + return this.worker.postMessage({ method: 'analyzeDatabase' }); + } + + async downloadDatabase() { + const byteArray = await this.worker.postMessage({ + method: 'downloadDatabase', + }); + + const blob = new Blob([byteArray.buffer], { + type: 'application/x-sqlite3', + }); + + // Create temporary elements to initialize the download + const a = window.document.createElement('a'); + window.document.body.appendChild(a); + a.href = window.URL.createObjectURL(blob); + a.download = 'boundary.db'; + a.addEventListener('click', function () { + setTimeout(function () { + window.URL.revokeObjectURL(a.href); + a.remove(); + }, 500); + }); + a.click(); + } + + async clearDatabase() { + await this.worker.postMessage({ method: 'clearDatabase' }); + } + + async deleteDatabase() { + await this.worker.postMessage({ method: 'deleteDatabase' }); + } + + willDestroy() { + this.webWorker?.terminate(); + } +} diff --git a/addons/api/addon/utils/sqlite-query.js b/addons/api/addon/utils/sqlite-query.js new file mode 100644 index 000000000..55cf20818 --- /dev/null +++ b/addons/api/addon/utils/sqlite-query.js @@ -0,0 +1,162 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { modelMapping } from 'api/services/sqlite'; +import { searchTables } from 'api/services/sqlite'; +import { typeOf } from '@ember/utils'; + +/** + * Takes a POJO representing a filter query and builds a SQL query. + * + * @example + * const filter = generateSQLExpressions(type, { + * search: generated', + * filters: { + * scope_id: { + * logicalOperator: 'and', + * values: [{ notEquals: 'scope1' }, { notEquals: 'scope2' }], + * }, + * }, + * }); + */ +export function generateSQLExpressions( + resource, + query = {}, + { page, pageSize, select } = {}, +) { + let { search, filters, sort = {} } = query; + const conditions = []; + const parameters = []; + + addFilterConditions(filters, parameters, conditions); + addSearchConditions(search, resource, parameters, conditions); + + const { attribute, direction } = sort; + let orderByClause = ''; + if (attribute) { + // We have to check if the attribute is valid for the resource + // as we can't use parameterized queries for ORDER BY + const validAttributes = Object.keys(modelMapping[resource]); + if (validAttributes.includes(attribute)) { + orderByClause = `ORDER BY ${attribute} ${direction === 'desc' ? 'DESC' : 'ASC'}`; + } + } else if (modelMapping[resource]?.created_time) { + orderByClause = `ORDER BY created_time DESC`; + } + + const whereClause = conditions.length ? `WHERE ${and(conditions)}` : ''; + + const paginationClause = page && pageSize ? `LIMIT ? OFFSET ?` : ''; + if (paginationClause) { + parameters.push(pageSize, (page - 1) * pageSize); + } + + const selectClause = `SELECT ${select ? select.join(', ') : '*'} FROM ${resource}`; + + return { + // Replace any empty newlines or leading whitespace on each line to be consistent with formatting + // This is mainly to help us read and test the generated SQL as it has no effect on the actual SQL execution. + sql: ` + ${selectClause} + ${whereClause} + ${orderByClause} + ${paginationClause}` + .replace(/^\s+/gm, '') + .trim(), + parameters, + }; +} + +function addFilterConditions(filters, parameters, conditions) { + if (!filters) { + return; + } + + for (const [key, filterArrayOrObject] of Object.entries(filters)) { + // Filter can be an array or an object with a `values` property + const filterValueArray = Array.isArray(filterArrayOrObject) + ? filterArrayOrObject + : filterArrayOrObject.values; + + if (!filterValueArray || !filterValueArray.length) { + continue; + } + + const filterConditions = filterValueArray + .filter((f) => f) + .map((filterObjValue) => { + let [operation, value] = Object.entries(filterObjValue)[0]; + + // SQLite needs to be working with ISO strings + if (typeOf(value) === 'date') { + value = value.toISOString(); + } + + // Handle LIKE operator separately + if (operation === 'contains') { + parameters.push(`%${value}%`); + } else { + parameters.push(value); + } + + return `${key}${OPERATORS[operation]}`; + }); + + const { logicalOperator } = filterArrayOrObject; + // Default to `or` if we don't have a logical operator + conditions.push( + parenthetical( + logicalOperator === 'and' + ? and(filterConditions) + : or(filterConditions), + ), + ); + } +} + +function addSearchConditions(search, resource, parameters, conditions) { + if (!search) { + return; + } + + if (searchTables.has(resource)) { + // Use the special prefix indicator "*" for full-text search + parameters.push(`"${search}"*`); + // Use a subquery to match against the FTS table with rowids as SQLite is + // much more efficient with FTS queries when using rowids or MATCH (or both). + // We could have also used a join here but a subquery is simpler. + conditions.push( + `rowid IN (SELECT rowid FROM ${resource}_fts WHERE ${resource}_fts MATCH ?)`, + ); + return; + } + + const fields = Object.keys(modelMapping[resource]); + const searchConditions = parenthetical( + or( + fields.map((field) => { + parameters.push(`%${search}%`); + return `${field}${OPERATORS['contains']}`; + }), + ), + ); + conditions.push(searchConditions); +} + +// Comparison Operators +const OPERATORS = { + equals: ' = ?', + notEquals: ' != ?', + gt: ' > ?', + gte: ' >= ?', + lt: ' < ?', + lte: ' <= ?', + contains: ' LIKE ?', +}; + +// Logical Operators +const and = (clauses) => clauses.join(' AND '); +const or = (clauses) => clauses.join(' OR '); +const parenthetical = (expression) => (expression ? `(${expression})` : ''); diff --git a/addons/api/addon/workers/sqlite-worker.js b/addons/api/addon/workers/sqlite-worker.js index 13608c5b9..bf8b9fa93 100644 --- a/addons/api/addon/workers/sqlite-worker.js +++ b/addons/api/addon/workers/sqlite-worker.js @@ -6,21 +6,171 @@ import { PWBWorker } from 'promise-worker-bi'; import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; import sharedServiceInit from './utils/shared-service'; +import { + CLEAR_DB, + CREATE_TABLES, + DELETE_STATEMENT, + INSERT_STATEMENTS, +} from './utils/schema'; + +/** @type {import('@sqlite.org/sqlite-wasm').OpfsSAHPoolDatabase} */ +let db; +/** @type {import('@sqlite.org/sqlite-wasm').SAHPoolUtil} */ +let poolUtil; +/** @type {import('@sqlite.org/sqlite-wasm').Sqlite3Static} */ +let sqlite3; + +// Maximum number of host parameters for SQLite prepared statements. +// See "Maximum Number Of Host Parameters In A Single SQL Statement" in +// https://www.sqlite.org/limits.html +const MAX_HOST_PARAMETERS = 32766; +const SCHEMA_VERSION = 1; const methods = { initializeSQLite: async () => { - try { - const sqlite3 = await sqlite3InitModule({ - print: console.log, - printErr: console.error, - }); - console.log(`SQLite Version: ${sqlite3?.version.libVersion}`); - const poolUtil = await sqlite3.installOpfsSAHPoolVfs(); - new poolUtil.OpfsSAHPoolDb('/boundary'); - } catch (err) { - console.error('Initialization error:', err.name, err.message); + sqlite3 = await sqlite3InitModule({ + print: console.log, + printErr: console.error, + }); + console.log(`SQLite Version: ${sqlite3?.version.libVersion}`); + const dbName = await promiseWorker.postMessage({ + method: 'getDatabaseName', + }); + + let done = false; + while (!done) { + try { + poolUtil = await sqlite3.installOpfsSAHPoolVfs(); + db = new poolUtil.OpfsSAHPoolDb(dbName); + + const [row] = db.exec('PRAGMA user_version;', { rowMode: 'object' }); + // Check if we're on a newer schema version and clear the database if so + if (SCHEMA_VERSION > row.user_version) { + db.exec(CLEAR_DB); + } + + db.exec(CREATE_TABLES(SCHEMA_VERSION)); + // Force an analyze on initial connection + db.exec('PRAGMA optimize=0x10002;'); + done = true; + } catch (err) { + // If we get a SQLite3Error and the pool is at capacity, we'll assume the error is + // because we hit the cap so we add more capacity and see if that solves it. + if ( + err.name === 'SQLite3Error' && + poolUtil.getCapacity() === poolUtil.getFileCount() + ) { + await poolUtil.addCapacity(1); + + // If we have more than 20 files, that might indicate a problem so let's just + // remove the VFS and wipe all the client data across users and restart. + if (poolUtil.getCapacity() > 20) { + await poolUtil.removeVfs(); + } + } else { + console.error('SQLite initialization error:', err); + throw err; + } + } } }, + analyzeDatabase: () => { + db.exec('PRAGMA optimize'); + }, + clearDatabase: () => { + db.exec(CLEAR_DB); + db.exec(CREATE_TABLES(SCHEMA_VERSION)); + }, + deleteDatabase: () => { + const name = db.dbFilename(); + if (db) { + db.close(); + db = null; + } + + poolUtil.unlink(`/${name}`); + }, + downloadDatabase: () => { + return sqlite3.capi.sqlite3_js_db_export(db); + }, + fetchResource: ({ sql, parameters = [] }) => { + try { + return db.exec({ + sql, + bind: parameters, + rowMode: 'object', + }); + } catch (err) { + // TODO: Add error handling to rest of methods and properly return the error back to caller + console.error('Fetch resource error:', err, sql, parameters); + throw err; + } + }, + insertResource: ({ resource, items, modelMapping }) => { + if (items.length === 0) { + return []; + } + + const numberOfParameters = items.reduce((acc, item) => { + return acc + item.length; + }, 0); + + // We want to use as many VALUES as part of the insert statement as it's quicker + // than doing multiple inserts. If we have too many rows to insert, we'll manually + // chunk them up to avoid hitting the maximum number of host parameters + // and execute these separate insert statements in a transaction + if (numberOfParameters > MAX_HOST_PARAMETERS) { + const chunkSize = Math.floor(MAX_HOST_PARAMETERS / items[0].length); + + return db.transaction(() => { + const results = []; + for (let i = 0; i < items.length; i += chunkSize) { + const chunk = items.slice(i, i + chunkSize); + const result = db.exec({ + sql: INSERT_STATEMENTS(resource, chunk, modelMapping), + bind: chunk.flat(), + rowMode: 'object', + }); + results.push(result); + } + return results; + }); + } + + db.exec({ + sql: INSERT_STATEMENTS(resource, items, modelMapping), + bind: items.flat(), + rowMode: 'object', + }); + }, + deleteResource: ({ resource, ids }) => { + if (ids.length === 0) { + return []; + } + + // Check if we have too many parameters for the deletion and chunk if so + if (ids.length > MAX_HOST_PARAMETERS) { + return db.transaction(() => { + const results = []; + for (let i = 0; i < ids.length; i += MAX_HOST_PARAMETERS) { + const chunk = ids.slice(i, i + MAX_HOST_PARAMETERS); + const result = db.exec({ + sql: DELETE_STATEMENT(resource, chunk), + bind: chunk, + rowMode: 'object', + }); + results.push(result); + } + return results; + }); + } + + return db.exec({ + sql: DELETE_STATEMENT(resource, ids), + bind: ids, + rowMode: 'object', + }); + }, }; const promiseWorker = new PWBWorker(); diff --git a/addons/api/addon/workers/utils/schema.js b/addons/api/addon/workers/utils/schema.js new file mode 100644 index 000000000..7132473f5 --- /dev/null +++ b/addons/api/addon/workers/utils/schema.js @@ -0,0 +1,85 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export const CLEAR_DB = ` +PRAGMA writable_schema = 1; +DELETE FROM sqlite_master; +PRAGMA writable_schema = 0; +VACUUM; +PRAGMA integrity_check;`; + +export const CREATE_TABLES = (version) => ` +BEGIN; + +PRAGMA user_version = ${version}; + +CREATE TABLE IF NOT EXISTS token ( + id TEXT NOT NULL PRIMARY KEY, + token TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS target ( + id TEXT NOT NULL PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL UNIQUE, + description TEXT, + address TEXT, + scope_id TEXT NOT NULL, + created_time TEXT NOT NULL, + data TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_target_scope_id_created_time ON target(scope_id, created_time DESC); + +-- Create a contentless FTS table as we will only use the rowids. +-- Note that this only creates the FTS index and cannot reference the target content +CREATE VIRTUAL TABLE IF NOT EXISTS target_fts USING fts5( + id, + type, + name, + description, + address, + scope_id, + created_time, + content='', +); + +-- Create triggers to keep the FTS table in sync with the target table +-- Note: The FTS table does not have an update trigger, as it's updated only on +-- INSERT and DELETE as we do an INSERT OR REPLACE INTO on the table +-- which replaces the row and doesn't execute an UPDATE trigger. +CREATE TRIGGER IF NOT EXISTS target_ai AFTER INSERT ON target BEGIN + INSERT INTO target_fts( + id, type, name, description, address, scope_id, created_time + ) VALUES ( + new.id, new.type, new.name, new.description, new.address, new.scope_id, new.created_time + ); +END; + +CREATE TRIGGER IF NOT EXISTS target_ad AFTER DELETE ON target BEGIN + INSERT INTO target_fts(target_fts, rowid, id, type, name, description, address, scope_id, created_time) + VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.address, old.scope_id, old.created_time); +END; + +COMMIT;`; + +export const INSERT_STATEMENTS = (resource, items, modelMapping) => { + if (resource === 'token') { + return `REPLACE INTO token (id, token) VALUES ${items.map(() => '(?, ?)').join(', ')};`; + } + + if (!modelMapping?.[resource]) { + throw new Error(`modelMapping is required for ${resource} insertions.`); + } + + const columns = Object.keys(modelMapping[resource]); + // Extra column for the data column + const numColumns = Array(columns.length + 1); + const placeholders = `(${numColumns.fill('?').join(', ')})`; + + return `REPLACE INTO ${resource} (${columns.join(', ')}, data) VALUES ${items.map(() => placeholders).join(', ')};`; +}; + +export const DELETE_STATEMENT = (resource, ids) => + `DELETE FROM ${resource} WHERE id IN (${ids.map(() => '?').join(',')})`; diff --git a/addons/api/app/services/sqlite-db.js b/addons/api/app/services/sqlite.js similarity index 61% rename from addons/api/app/services/sqlite-db.js rename to addons/api/app/services/sqlite.js index ef1758e52..0404b8abf 100644 --- a/addons/api/app/services/sqlite-db.js +++ b/addons/api/app/services/sqlite.js @@ -3,4 +3,4 @@ * SPDX-License-Identifier: BUSL-1.1 */ -export { default } from 'api/services/sqlite-db'; +export { default } from 'api/services/sqlite'; diff --git a/addons/api/index.js b/addons/api/index.js index fb8929ea4..9b305a68d 100644 --- a/addons/api/index.js +++ b/addons/api/index.js @@ -37,8 +37,9 @@ module.exports = { const env = this.parent?.app?.env ?? 'production'; const { enableSqlite } = this.parent?.app?.options[this.name] ?? {}; - // If sqlite is not enabled, we don't need to build the workers - if (!enableSqlite) { + // If sqlite is not enabled and we're not running a test directly in the addon, + // we don't need to build the workers + if (!enableSqlite && this.app?.env !== 'test') { return tree; } diff --git a/addons/api/tests/unit/handlers/sqlite-handler-test.js b/addons/api/tests/unit/handlers/sqlite-handler-test.js new file mode 100644 index 000000000..0dd8273ce --- /dev/null +++ b/addons/api/tests/unit/handlers/sqlite-handler-test.js @@ -0,0 +1,438 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import SqliteHandler from 'api/handlers/sqlite-handler'; +import RequestManager from '@ember-data/request'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { faker } from '@faker-js/faker'; +import sinon from 'sinon'; +import { assert } from '@ember/debug'; +import { setupSqlite } from 'api/test-support/helpers/sqlite'; +import TargetModel from 'api/models/target'; + +function createPaginatedResponseHandler(mirageRecords, { pageSize }) { + assert('pageSize is required', pageSize); + assert('mirage server on `this` is required', this.server); + const pagesCount = mirageRecords.length / pageSize; + + const results = []; + for (let i = 0; i < pagesCount; i++) { + const items = mirageRecords.slice(i * pageSize, (i + 1) * pageSize); + const isLastPage = i === pagesCount - 1; + const list_token = `list-token-${faker.string.nanoid({ min: 5, max: 5 })}`; + + results.push({ + items: items.map((model) => + this.server.serializerOrRegistry.serialize(model), + ), + list_token, + response_type: !isLastPage ? 'delta' : 'complete', + }); + } + + function nextResult(listToken) { + const index = results.findIndex( + ({ list_token }) => list_token === listToken, + ); + const nextResult = index === -1 ? null : results[index + 1]; + return nextResult; + } + + // mirage handler compatible with paginated responses + return function (_schema, request) { + const listToken = request.queryParams.list_token; + if (!listToken) { + return results[0]; + } + + const result = nextResult(listToken); + + if (!result) { + return new Response( + 404, + {}, + `No results found for list token "${listToken}"`, + ); + } + + return result; + }; +} + +module('Unit | Handler | sqlite-handler', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + setupSqlite(hooks); + + let manager, + scope, + sqliteHandler, + store, + applicationAdapter, + sqliteHandlerSpy, + applicationAdapterSpy; + hooks.beforeEach(async function setupSqliteHandler() { + store = this.owner.lookup('service:store'); + manager = new RequestManager(); + sqliteHandler = new SqliteHandler(store); + applicationAdapter = store.adapterFor('application'); + + // spy on all handler and application adapter methods + sqliteHandlerSpy = sinon.spy(sqliteHandler); + applicationAdapterSpy = sinon.spy(applicationAdapter); + + store.requestManager = manager; + manager.use([sqliteHandler]); + + scope = this.server.create('scope', { + type: 'project', + name: 'Project Scope', + }); + }); + + module('fetching and batch loading', function (hooks) { + // `testBatchLimit` and `serverResultPageLimit` are intentionally difference + // so that multiple calls to the server are required to fetch a single batch, + // and these multiples can be asserted in the tests individually + const testBatchLimit = 10; + const serverResultPageLimit = 5; + let targets, targetsResponseHandlerSpy; + + hooks.beforeEach(function setupMirageData() { + sqliteHandler.batchLimit = testBatchLimit; + + targets = this.server.createList('target', 50, { scope }); + + // This is to just add a number to each target name to guarantee uniqueness in + // case there was a duplicate rather than increasing the number of words generated by faker. + // Because we have a UNIQUE constraint on target names and we do UPSERTS, if there's a + // duplicate name we'll end up replacing it in the DB and then have fewer targets + // than expected and flaky tests. + for (let i = 0; i < targets.length; i++) { + targets[i].name = targets[i].name + i; + } + + const targetResponseHandler = createPaginatedResponseHandler.call( + this, + this.server.schema.targets.all().models, + { pageSize: serverResultPageLimit }, + ); + + targetsResponseHandlerSpy = sinon.spy(targetResponseHandler); + this.server.get('targets', targetsResponseHandlerSpy); + }); + + test('it returns all data for a resource from the server', async function (assert) { + const results = await store.query('target', {}); + + assert.strictEqual( + targets.length, + results.length, + 'all records are returned', + ); + }); + + test('supports batching requests and writes to SQLite', async function (assert) { + await store.query('target', {}); + + assert.strictEqual( + sqliteHandlerSpy.writeToDb.callCount, + targets.length / testBatchLimit, + 'handler #writeToDb is called once for each batch', + ); + + assert.strictEqual( + applicationAdapterSpy.query.callCount, + targets.length / testBatchLimit, + 'application adapter #query is called once for each batch', + ); + + assert.strictEqual( + targetsResponseHandlerSpy.callCount, + targets.length / serverResultPageLimit, + 'server handler should be called once for each page', + ); + }); + + test('it loads from SQLite only and does not fetch from api, when peekDb option is used', async function (assert) { + const callCounts = { + get writeToDb() { + return sqliteHandlerSpy.writeToDb.callCount; + }, + get targetResponseHandler() { + return targetsResponseHandlerSpy.callCount; + }, + }; + + // before first query, no data has been fetched or written to sqlite + // the call counts should therefore be 0 + assert.strictEqual(callCounts.targetResponseHandler, 0); + assert.strictEqual(callCounts.writeToDb, 0); + + const results = await store.query('target', {}); + assert.strictEqual(results.length, 50); + + // call counts expected after first query + const expectedWriteToSqliteCallCount = 5; + const expectedResponseHandlerCallCount = 10; + + // first `store.query()` calls api and writes batches to sqlite + assert.strictEqual( + callCounts.targetResponseHandler, + expectedResponseHandlerCallCount, + ); + assert.strictEqual(callCounts.writeToDb, expectedWriteToSqliteCallCount); + + await store.query('target', {}, { peekDb: true }); + assert.strictEqual(results.length, 50); + + // second run does not increase call counts to api or writes to sqlite + assert.strictEqual( + callCounts.targetResponseHandler, + expectedResponseHandlerCallCount, + ); + assert.strictEqual(callCounts.writeToDb, expectedWriteToSqliteCallCount); + }); + }); + + module('option: pushToStore', function (hooks) { + let mirageTargets; + + hooks.beforeEach(function () { + mirageTargets = this.server.createList('target', 5, { scope }); + }); + + test('it pushes fetched results to the store by default', async function (assert) { + this.server.get( + 'targets', + createPaginatedResponseHandler.call(this, mirageTargets, { + pageSize: 1, + }), + ); + + assert.strictEqual(mirageTargets.length, 5); + assert.strictEqual(store.peekAll('target').length, 0); + const results = await store.query('target', {}); + assert.strictEqual(results.length, 5); + assert.ok( + results.every((result) => result instanceof TargetModel), + 'results are ember data models', + ); + assert.strictEqual(store.peekAll('target').length, 5); + }); + + test('it pushes fetched results to the store when pushToStore is true', async function (assert) { + this.server.get( + 'targets', + createPaginatedResponseHandler.call(this, mirageTargets, { + pageSize: 1, + }), + ); + + assert.strictEqual(mirageTargets.length, 5); + assert.strictEqual(store.peekAll('target').length, 0); + const results = await store.query('target', {}, { pushToStore: true }); + assert.strictEqual(results.length, 5); + assert.ok( + results.every((result) => result instanceof TargetModel), + 'results are ember data models', + ); + assert.strictEqual(store.peekAll('target').length, 5); + }); + + test('it does not push fetched results to the store when pushToStore is false', async function (assert) { + this.server.get( + 'targets', + createPaginatedResponseHandler.call(this, mirageTargets, { + pageSize: 1, + }), + ); + + assert.strictEqual(mirageTargets.length, 5); + assert.strictEqual(store.peekAll('target').length, 0); + const results = await store.query('target', {}, { pushToStore: false }); + assert.strictEqual(results.length, 5); + assert.ok( + results.every((result) => !(result instanceof TargetModel)), + 'results are not ember data models', + ); + assert.strictEqual(store.peekAll('target').length, 0); + }); + }); + + module('sorting', function () { + test('it sorts by ascending (default) `name` using string sorting', async function (assert) { + const target001 = this.server.create('target', { + name: 'Target 001', + scope, + }); + const target002 = this.server.create('target', { + name: 'Target 002', + scope, + }); + const target003 = this.server.create('target', { + name: 'Target 003', + scope, + }); + const target004 = this.server.create('target', { + name: 'Target 004', + scope, + }); + const target005 = this.server.create('target', { + name: 'Target 005', + scope, + }); + + const shuffledTargets = faker.helpers.shuffle([ + target001, + target002, + target003, + target004, + target005, + ]); + + this.server.get( + 'targets', + createPaginatedResponseHandler.call(this, shuffledTargets, { + pageSize: 1, + }), + ); + + const results = await store.query('target', { + query: { sort: { attribute: 'name' } }, + }); + + assert.deepEqual( + results.map(({ name }) => name), + ['Target 001', 'Target 002', 'Target 003', 'Target 004', 'Target 005'], + ); + }); + + test('it sorts by descending, then ascending `name` using string sorting', async function (assert) { + const target001 = this.server.create('target', { + name: 'Target 001', + scope, + }); + const target002 = this.server.create('target', { + name: 'Target 002', + scope, + }); + const target003 = this.server.create('target', { + name: 'Target 003', + scope, + }); + const target004 = this.server.create('target', { + name: 'Target 004', + scope, + }); + + const shuffledTargets = faker.helpers.shuffle([ + target001, + target002, + target003, + target004, + ]); + + this.server.get( + 'targets', + createPaginatedResponseHandler.call(this, shuffledTargets, { + pageSize: 1, + }), + ); + + const resultsDesc = await store.query('target', { + query: { sort: { attribute: 'name', direction: 'desc' } }, + }); + const resultsAsc = await store.query('target', { + query: { sort: { attribute: 'name', direction: 'asc' } }, + }); + assert.deepEqual( + resultsDesc.map(({ name }) => name), + ['Target 004', 'Target 003', 'Target 002', 'Target 001'], + ); + assert.deepEqual( + resultsAsc.map(({ name }) => name), + ['Target 001', 'Target 002', 'Target 003', 'Target 004'], + ); + }); + }); + + test('it supports searching', async function (assert) { + this.server.create('target', { + name: 'Magical Target', + description: 'A target', + scope, + }); + + this.server.create('target', { + name: 'Target 2', + description: 'Target with magic', + scope, + }); + + this.server.createList('target', 4, { scope }); + + this.server.get( + 'targets', + createPaginatedResponseHandler.call( + this, + this.server.schema.targets.all().models, + { + pageSize: 1, + }, + ), + ); + + const results = await store.query('target', { + query: { search: 'magic' }, + }); + + assert.deepEqual( + results + // search results aren't returning in a consistent order so + // the sorting is used to be able to assert + .sort((a, b) => (a.name > b.name ? 1 : -1)) + .map(({ name, description }) => ({ name, description })), + [ + { + description: 'A target', + name: 'Magical Target', + }, + { + description: 'Target with magic', + name: 'Target 2', + }, + ], + ); + }); + + test('it supports filtering', async function (assert) { + this.server.create('target', { + name: 'specific-target', + scope, + }); + + this.server.createList('target', 5, { scope }); + + this.server.get( + 'targets', + createPaginatedResponseHandler.call( + this, + this.server.schema.targets.all().models, + { + pageSize: 1, + }, + ), + ); + + const results = await store.query('target', { + query: { filters: { name: [{ equals: 'specific-target' }] } }, + }); + + assert.strictEqual(results[0].name, 'specific-target'); + }); +}); diff --git a/addons/api/tests/unit/services/sqlite-db-test.js b/addons/api/tests/unit/services/sqlite-db-test.js deleted file mode 100644 index df924b34e..000000000 --- a/addons/api/tests/unit/services/sqlite-db-test.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { module, test } from 'qunit'; -import { setupTest } from 'dummy/tests/helpers'; - -module('Unit | Service | sqlite-db', function (hooks) { - setupTest(hooks); - - // TODO: Replace this with your real tests. - test('it exists', function (assert) { - let service = this.owner.lookup('service:sqlite-db'); - assert.ok(service); - }); -}); diff --git a/addons/api/tests/unit/services/sqlite-test.js b/addons/api/tests/unit/services/sqlite-test.js new file mode 100644 index 000000000..facb7d6ad --- /dev/null +++ b/addons/api/tests/unit/services/sqlite-test.js @@ -0,0 +1,92 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import { setupSqlite } from 'api/test-support/helpers/sqlite'; +import { modelMapping, searchTables } from 'api/services/sqlite'; + +const supportedModels = Object.keys(modelMapping); +const supportedFtsTables = [...searchTables]; + +module('Unit | Service | sqlite', function (hooks) { + setupTest(hooks); + setupSqlite(hooks); + + test.each( + 'Mapping matches table columns', + supportedModels, + async function (assert, resource) { + const service = this.owner.lookup('service:sqlite'); + + const tableColumns = await service.fetchResource({ + sql: `SELECT * FROM pragma_table_info('${resource}')`, + }); + + const columnNames = tableColumns.map((column) => column.name); + + const modelKeys = Object.keys(modelMapping[resource]); + + // Last column should always be "data" which we don't map + assert.strictEqual(columnNames[columnNames.length - 1], 'data'); + assert.deepEqual(columnNames.slice(0, -1), modelKeys); + }, + ); + + test.each( + 'Mapping matches fts table columns', + supportedFtsTables, + async function (assert, resource) { + const service = this.owner.lookup('service:sqlite'); + + const tableColumns = await service.fetchResource({ + sql: `SELECT * FROM pragma_table_info('${resource}_fts')`, + }); + + const columnNames = tableColumns.map((column) => column.name); + const modelKeys = Object.keys(modelMapping[resource]); + + assert.deepEqual(columnNames, modelKeys); + }, + ); + + test('it inserts and deletes large amounts of rows', async function (assert) { + const service = this.owner.lookup('service:sqlite'); + + const targets = []; + const numberOfTargets = 35_000; + + for (let i = 0; i < numberOfTargets; i++) { + targets.push([ + `ttcp_${i}`, + 'tcp', + `name${i}`, + null, + '', + 'p_SWAXCPqUPr', + '2025-07-17T21:39:11.171Z', + 'test data', + ]); + } + await service.insertResource('target', targets); + + let count = await service.fetchResource({ + sql: `SELECT count(*) as count FROM target`, + }); + let total = count[0]; + + assert.strictEqual(total.count, numberOfTargets); + + const ids = targets.map((target) => target[0]); + await service.deleteResource('target', ids); + + count = await service.fetchResource({ + sql: `SELECT count(*) as count FROM target`, + }); + total = count[0]; + + assert.strictEqual(total.count, 0); + }); +}); diff --git a/addons/api/tests/unit/utils/sqlite-query-test.js b/addons/api/tests/unit/utils/sqlite-query-test.js new file mode 100644 index 000000000..86c110b9e --- /dev/null +++ b/addons/api/tests/unit/utils/sqlite-query-test.js @@ -0,0 +1,308 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { generateSQLExpressions } from 'api/utils/sqlite-query'; +import { module, test } from 'qunit'; + +module('Unit | Utility | sqlite-query', function (hooks) { + const date = new Date(2025, 1, 1); + const isoDateString = date.toISOString(); + + hooks.beforeEach(function () { + String.prototype.removeExtraWhiteSpace = function () { + return this.replace(/^\s+/gm, '').trim(); + }; + }); + + hooks.afterEach(function () { + delete String.prototype.removeExtraWhiteSpace; + }); + + // TODO: Add a normal LIKE search when we add a resource that uses it + test('it generates search correctly with FTS5', function (assert) { + const query = { search: 'favorite' }; + + const { sql, parameters } = generateSQLExpressions('target', query); + assert.strictEqual( + sql, + ` + SELECT * FROM target + WHERE rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?) + ORDER BY created_time DESC`.removeExtraWhiteSpace(), + ); + assert.deepEqual(parameters, ['"favorite"*']); + }); + + test('it grabs resources not in the model mapping', function (assert) { + const query = { + filters: { id: [{ equals: 'tokenKey' }] }, + }; + + const { sql, parameters } = generateSQLExpressions('token', query); + assert.strictEqual( + sql, + ` + SELECT * FROM token + WHERE (id = ?)`.removeExtraWhiteSpace(), + ); + assert.deepEqual(parameters, ['tokenKey']); + }); + + test('it executes count queries correctly', function (assert) { + const select = { + select: ['count(*) as total'], + }; + + const { sql, parameters } = generateSQLExpressions('target', {}, select); + assert.strictEqual( + sql, + ` + SELECT count(*) as total FROM target + ORDER BY created_time DESC`.removeExtraWhiteSpace(), + ); + assert.deepEqual(parameters, []); + }); + + test.each( + 'it generates filters correctly', + { + equals: { + query: { + filters: { + type: [{ equals: 'ssh' }], + }, + }, + expectedWhereClause: 'WHERE (type = ?)', + expectedParams: ['ssh'], + }, + notEquals: { + query: { + filters: { + type: [{ notEquals: 'ssh' }], + }, + }, + expectedWhereClause: 'WHERE (type != ?)', + expectedParams: ['ssh'], + }, + contains: { + query: { + filters: { + type: [{ contains: 'ssh' }], + }, + }, + expectedWhereClause: 'WHERE (type LIKE ?)', + expectedParams: ['%ssh%'], + }, + greaterThan: { + query: { + filters: { + created_time: [{ gt: date }], + numberField: [{ gt: 10 }], + }, + }, + expectedWhereClause: 'WHERE (created_time > ?) AND (numberField > ?)', + expectedParams: [isoDateString, 10], + }, + lessThan: { + query: { + filters: { + created_time: [{ lt: date }], + numberField: [{ lt: 10 }], + }, + }, + expectedWhereClause: 'WHERE (created_time < ?) AND (numberField < ?)', + expectedParams: [isoDateString, 10], + }, + greaterThanOrEqual: { + query: { + filters: { + created_time: [{ gte: date }], + numberField: [{ gte: 10 }], + }, + }, + expectedWhereClause: 'WHERE (created_time >= ?) AND (numberField >= ?)', + expectedParams: [isoDateString, 10], + }, + lessThanOrEqual: { + query: { + filters: { + created_time: [{ lte: date }], + numberField: [{ lte: 10 }], + }, + }, + expectedWhereClause: 'WHERE (created_time <= ?) AND (numberField <= ?)', + expectedParams: [isoDateString, 10], + }, + logicalOperators: { + query: { + filters: { + id: { + logicalOperator: 'and', + values: [{ notEquals: 'id1' }, { notEquals: 'id2' }], + }, + status: { + logicalOperator: 'or', + values: [{ equals: 'active' }, { equals: 'pending' }], + }, + type: [{ equals: 'ssh' }], + }, + }, + expectedWhereClause: + 'WHERE (id != ? AND id != ?) AND (status = ? OR status = ?) AND (type = ?)', + expectedParams: ['id1', 'id2', 'active', 'pending', 'ssh'], + }, + }, + + function (assert, { query, expectedWhereClause, expectedParams }) { + const { sql, parameters } = generateSQLExpressions('target', query); + assert.strictEqual( + sql, + ` + SELECT * FROM target + ${expectedWhereClause} + ORDER BY created_time DESC`.removeExtraWhiteSpace(), + ); + assert.deepEqual(parameters, expectedParams); + }, + ); + + test.each( + 'it generates sort direction correctly', + [ + ['asc', 'ASC'], + ['desc', 'DESC'], + ], + function (assert, [direction, expectedDirection]) { + const query = { + sort: { attribute: 'name', direction }, + }; + + const { sql, parameters } = generateSQLExpressions('target', query); + assert.strictEqual( + sql, + ` + SELECT * FROM target + ORDER BY name ${expectedDirection}`.removeExtraWhiteSpace(), + ); + assert.deepEqual(parameters, []); + }, + ); + + test.each( + 'it generates pagination clause', + [ + { page: 1, pageSize: 10, expectedParams: [10, 0] }, + { page: 3, pageSize: 10, expectedParams: [10, 20] }, + { page: 7, pageSize: 50, expectedParams: [50, 300] }, + ], + function (assert, { page, pageSize, expectedParams }) { + const { sql, parameters } = generateSQLExpressions( + 'target', + {}, + { page, pageSize }, + ); + assert.strictEqual( + sql, + ` + SELECT * FROM target + ORDER BY created_time DESC + LIMIT ? OFFSET ?`.removeExtraWhiteSpace(), + ); + assert.deepEqual(parameters, expectedParams); + }, + ); + + test('it generates FTS5 search with filters', function (assert) { + const query = { + search: 'favorite', + filters: { + id: { + logicalOperator: 'and', + values: [{ notEquals: 'id1' }, { notEquals: 'id2' }], + }, + status: [{ equals: 'active' }, { equals: 'pending' }], + }, + }; + + const { sql, parameters } = generateSQLExpressions('target', query); + assert.strictEqual( + sql, + ` + SELECT * FROM target + WHERE (id != ? AND id != ?) AND (status = ? OR status = ?) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?) + ORDER BY created_time DESC`.removeExtraWhiteSpace(), + ); + assert.deepEqual(parameters, [ + 'id1', + 'id2', + 'active', + 'pending', + '"favorite"*', + ]); + }); + + test('it generates SQL with all clauses combined', function (assert) { + const query = { + search: 'favorite', + filters: { + type: [{ equals: 'ssh' }], + status: { + logicalOperator: 'or', + values: [{ equals: 'active' }, { equals: 'pending' }], + }, + created_time: [{ gte: date }], + }, + sort: { attribute: 'name', direction: 'desc' }, + }; + + const { sql, parameters } = generateSQLExpressions('target', query, { + page: 2, + pageSize: 15, + select: ['data'], + }); + + assert.strictEqual( + sql, + ` + SELECT data FROM target + WHERE (type = ?) AND (status = ? OR status = ?) AND (created_time >= ?) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?) + ORDER BY name DESC + LIMIT ? OFFSET ?`.removeExtraWhiteSpace(), + ); + + assert.deepEqual(parameters, [ + 'ssh', + 'active', + 'pending', + isoDateString, + '"favorite"*', + 15, + 15, + ]); + }); + + test.each( + 'it handles empty and invalid fields', + { + emptyObject: {}, + emptySearch: { search: '' }, + emptyFilter: { filters: {} }, + emptyFilterAttributes: { filters: { type: [] } }, + invalidSortAttribute: { attribute: 'test', direction: 'asc' }, + onlyPage: { page: 10 }, + onlyPageSize: { pageSize: 5 }, + }, + function (assert, query) { + const { sql, parameters } = generateSQLExpressions('target', query); + assert.strictEqual( + sql, + ` + SELECT * FROM target + ORDER BY created_time DESC`.removeExtraWhiteSpace(), + ); + assert.deepEqual(parameters, []); + }, + ); +}); diff --git a/addons/core/translations/actions/en-us.yaml b/addons/core/translations/actions/en-us.yaml index 7ed7bcd09..51178af71 100644 --- a/addons/core/translations/actions/en-us.yaml +++ b/addons/core/translations/actions/en-us.yaml @@ -55,6 +55,9 @@ do-this-later: Do this later toggle-color-theme: Toggle Color Theme toggle-feature-edition: Toggle Feature Edition toggle-licensed-features: Toggle Licensed Features +manage-db: Manage SQLite +download-db: Download SQLite DB +clear-db: Clear SQLite DB more: ', +{remainingItems} more' search: Search yes: Yes diff --git a/ui/admin/app/controllers/application.js b/ui/admin/app/controllers/application.js index 6797c1202..b1bb690fe 100644 --- a/ui/admin/app/controllers/application.js +++ b/ui/admin/app/controllers/application.js @@ -7,6 +7,7 @@ import Controller from '@ember/controller'; import { service } from '@ember/service'; import { getOwner } from '@ember/application'; import { action } from '@ember/object'; +import { loading } from 'ember-loading'; import { defaultValidator } from 'ember-a11y-refocus'; import { paramValueFinder } from 'admin/utils/param-value-finder'; @@ -33,6 +34,7 @@ export default class ApplicationController extends Controller { @service featureEdition; @service flashMessages; @service router; + @service sqlite; /** * Returns available themes. @@ -113,6 +115,18 @@ export default class ApplicationController extends Controller { } } + @action + @loading + async downloadDatabase() { + await this.sqlite.downloadDatabase(); + } + + @action + async clearDatabase() { + await this.sqlite.clearDatabase(); + this.router.refresh(); + } + /** * Add custom route change validation to prevent refocus when * user is attempting to search, filter, or sort. diff --git a/ui/admin/app/routes/application.js b/ui/admin/app/routes/application.js index 5918c53c8..9a6f103a3 100644 --- a/ui/admin/app/routes/application.js +++ b/ui/admin/app/routes/application.js @@ -22,7 +22,7 @@ export default class ApplicationRoute extends Route { @service features; @service featureEdition; @service indexedDb; - @service sqliteDb; + @service sqlite; @service('browser/window') window; // =attributes @@ -74,7 +74,7 @@ export default class ApplicationRoute extends Route { const userId = this.session.data?.authenticated?.user_id; const hostUrl = this.window.location.host; if (userId && hostUrl) { - await this.sqliteDb.setup(formatDbName(userId, hostUrl)); + await this.sqlite.setup(formatDbName(userId, hostUrl)); await this.indexedDb.setup(formatDbName(userId, hostUrl)); } } diff --git a/ui/admin/app/services/session.js b/ui/admin/app/services/session.js index 5a81609f5..a10bf55d3 100644 --- a/ui/admin/app/services/session.js +++ b/ui/admin/app/services/session.js @@ -9,7 +9,7 @@ import { formatDbName } from 'api/services/indexed-db'; export default class SessionService extends BaseSessionService { @service indexedDb; - @service sqliteDb; + @service sqlite; @service('browser/window') window; /** @@ -20,7 +20,7 @@ export default class SessionService extends BaseSessionService { const userId = this.data?.authenticated?.user_id; const hostUrl = this.window.location?.host; if (userId && hostUrl) { - await this.sqliteDb.setup(formatDbName(userId, hostUrl)); + await this.sqlite.setup(formatDbName(userId, hostUrl)); await this.indexedDb.setup(formatDbName(userId, hostUrl)); } diff --git a/ui/admin/app/templates/application.hbs b/ui/admin/app/templates/application.hbs index 542a0b806..8cacfca02 100644 --- a/ui/admin/app/templates/application.hbs +++ b/ui/admin/app/templates/application.hbs @@ -54,7 +54,20 @@ > {{feature}} + {{/each}} +