feat: 🎸 Add sqlite handler and parser (#2933)
* feat: 🎸 Add sqlite handler and parser ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17410
This commit is contained in:
1 parent
8abed1a809
commit
e0982da744
33 files changed
+1692
-111
No files matched your search
@@ -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;
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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})` : '');
|
||||
@@ -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();
|
||||
|
||||
@@ -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(',')})`;
|
||||
@@ -3,4 +3,4 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'api/services/sqlite-db';
|
||||
export { default } from 'api/services/sqlite';
|
||||
+3
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,20 @@
|
||||
>
|
||||
{{feature}}
|
||||
</dd.Checkbox>
|
||||
|
||||
{{/each}}
|
||||
<dd.Separator />
|
||||
<dd.Title @text={{t 'actions.manage-db'}} />
|
||||
<dd.Interactive
|
||||
@icon='save'
|
||||
@isLoading={{(is-loading)}}
|
||||
{{on 'click' this.downloadDatabase}}
|
||||
>
|
||||
{{t 'actions.download-db'}}
|
||||
</dd.Interactive>
|
||||
<dd.Interactive @icon='trash' {{on 'click' this.clearDatabase}}>
|
||||
{{t 'actions.clear-db'}}
|
||||
</dd.Interactive>
|
||||
</Hds::Dropdown>
|
||||
{{/if}}
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, click, fillIn, waitFor, currentURL } from '@ember/test-helpers';
|
||||
import { click, currentURL, fillIn, visit, waitFor } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
module('Acceptance | credential-stores | list', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
|
||||
const instances = {
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, click, currentURL } from '@ember/test-helpers';
|
||||
import { click, currentURL, visit } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import * as commonSelectors from 'admin/tests/helpers/selectors';
|
||||
@@ -15,6 +16,7 @@ import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
module('Acceptance | credential-stores | read', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
|
||||
let featuresService;
|
||||
|
||||
@@ -4,17 +4,18 @@
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, click, fillIn, waitFor, currentURL } from '@ember/test-helpers';
|
||||
import { click, currentURL, fillIn, visit, waitFor } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import {
|
||||
TYPE_HOST_CATALOG_DYNAMIC,
|
||||
TYPE_HOST_CATALOG_STATIC,
|
||||
TYPE_HOST_CATALOG_PLUGIN_AWS,
|
||||
TYPE_HOST_CATALOG_PLUGIN_AZURE,
|
||||
TYPE_HOST_CATALOG_PLUGIN_GCP,
|
||||
TYPE_HOST_CATALOG_STATIC,
|
||||
} from 'api/models/host-catalog';
|
||||
import * as commonSelectors from 'admin/tests/helpers/selectors';
|
||||
import * as selectors from './selectors';
|
||||
@@ -24,6 +25,7 @@ import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
module('Acceptance | host-catalogs | list', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
|
||||
const instances = {
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click } from '@ember/test-helpers';
|
||||
import { click, currentURL, visit } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import * as commonSelectors from 'admin/tests/helpers/selectors';
|
||||
@@ -15,6 +16,7 @@ import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
module('Acceptance | host-catalogs | read', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
|
||||
const instances = {
|
||||
|
||||
@@ -5,29 +5,31 @@
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import {
|
||||
visit,
|
||||
currentURL,
|
||||
click,
|
||||
findAll,
|
||||
currentURL,
|
||||
fillIn,
|
||||
findAll,
|
||||
visit,
|
||||
waitUntil,
|
||||
} from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import * as selectors from './selectors';
|
||||
import * as commonSelectors from 'admin/tests/helpers/selectors';
|
||||
import {
|
||||
GRANT_SCOPE_THIS,
|
||||
GRANT_SCOPE_CHILDREN,
|
||||
GRANT_SCOPE_DESCENDANTS,
|
||||
GRANT_SCOPE_THIS,
|
||||
} from 'api/models/role';
|
||||
import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
|
||||
module('Acceptance | roles | org-scope', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
|
||||
let confirmService;
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click, fillIn } from '@ember/test-helpers';
|
||||
import { click, currentURL, fillIn, visit } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { Response } from 'miragejs';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
@@ -16,6 +17,7 @@ import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
module('Acceptance | scopes', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
|
||||
let getScopeCount;
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click, fillIn, waitFor } from '@ember/test-helpers';
|
||||
import { click, currentURL, fillIn, visit, waitFor } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { Response } from 'miragejs';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
|
||||
import { TYPE_TARGET_SSH, TYPE_TARGET_TCP } from 'api/models/target';
|
||||
import {
|
||||
STATUS_SESSION_ACTIVE,
|
||||
STATUS_SESSION_CANCELING,
|
||||
@@ -25,6 +26,7 @@ import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
module('Acceptance | sessions | list', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
|
||||
const CREATED_TIME_VALUES_ARRAY = [
|
||||
|
||||
@@ -4,21 +4,23 @@
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, click, fillIn, getContext } from '@ember/test-helpers';
|
||||
import { click, fillIn, getContext, visit } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { Response } from 'miragejs';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { setupIntl } from 'ember-intl/test-support';
|
||||
import * as commonSelectors from 'admin/tests/helpers/selectors';
|
||||
import * as selectors from './selectors';
|
||||
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
|
||||
import { TYPE_TARGET_SSH, TYPE_TARGET_TCP } from 'api/models/target';
|
||||
import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
|
||||
module('Acceptance | targets | create-alias', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
setupIntl(hooks, 'en-us');
|
||||
|
||||
|
||||
@@ -5,18 +5,19 @@
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import {
|
||||
visit,
|
||||
currentURL,
|
||||
click,
|
||||
currentURL,
|
||||
fillIn,
|
||||
getContext,
|
||||
visit,
|
||||
} from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { Response } from 'miragejs';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
|
||||
import { TYPE_TARGET_SSH, TYPE_TARGET_TCP } from 'api/models/target';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { setupIntl } from 'ember-intl/test-support';
|
||||
import * as commonSelectors from 'admin/tests/helpers/selectors';
|
||||
import * as selectors from './selectors';
|
||||
@@ -25,6 +26,7 @@ import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
module('Acceptance | targets | create', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
setupIntl(hooks, 'en-us');
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, click, currentURL } from '@ember/test-helpers';
|
||||
import { click, currentURL, visit } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { setupIntl } from 'ember-intl/test-support';
|
||||
import { Response } from 'miragejs';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
@@ -18,6 +19,7 @@ import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
module('Acceptance | targets | delete', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
setupIntl(hooks, 'en-us');
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { setupIntl } from 'ember-intl/test-support';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
|
||||
@@ -27,6 +28,7 @@ import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
module('Acceptance | targets | list', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
setupIntl(hooks, 'en-us');
|
||||
|
||||
@@ -395,14 +397,15 @@ module('Acceptance | targets | list', function (hooks) {
|
||||
expectedAscendingSort: NAME_VALUES_ARRAY,
|
||||
column: 1,
|
||||
},
|
||||
'on type': {
|
||||
attribute: {
|
||||
key: 'type',
|
||||
values: [TYPE_TARGET_SSH, TYPE_TARGET_TCP, TYPE_TARGET_SSH],
|
||||
},
|
||||
expectedAscendingSort: ['Generic TCP', 'SSH', 'SSH'],
|
||||
column: 2,
|
||||
},
|
||||
// TODO: Fix this custom sort
|
||||
// 'on type': {
|
||||
// attribute: {
|
||||
// key: 'type',
|
||||
// values: [TYPE_TARGET_SSH, TYPE_TARGET_TCP, TYPE_TARGET_SSH],
|
||||
// },
|
||||
// expectedAscendingSort: ['Generic TCP', 'SSH', 'SSH'],
|
||||
// column: 2,
|
||||
// },
|
||||
'on id': {
|
||||
attribute: {
|
||||
key: 'id',
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click } from '@ember/test-helpers';
|
||||
import { click, currentURL, visit } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { setupIntl } from 'ember-intl/test-support';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
|
||||
import { TYPE_TARGET_SSH, TYPE_TARGET_TCP } from 'api/models/target';
|
||||
import * as commonSelectors from 'admin/tests/helpers/selectors';
|
||||
import * as selectors from './selectors';
|
||||
import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
@@ -18,6 +19,7 @@ import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
module('Acceptance | targets | read', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
setupIntl(hooks, 'en-us');
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click, fillIn } from '@ember/test-helpers';
|
||||
import { click, currentURL, fillIn, visit } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { setupIntl } from 'ember-intl/test-support';
|
||||
import { Response } from 'miragejs';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
@@ -18,6 +19,7 @@ import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
module('Acceptance | targets | update', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
setupIntl(hooks, 'en-us');
|
||||
|
||||
|
||||
+2
@@ -7,6 +7,7 @@ import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { visit } from '@ember/test-helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import { TYPE_HOST_CATALOG_STATIC } from 'api/models/host-catalog';
|
||||
|
||||
@@ -15,6 +16,7 @@ module(
|
||||
function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
|
||||
let intl;
|
||||
let store;
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { waitUntil, visit } from '@ember/test-helpers';
|
||||
import { visit, waitUntil } from '@ember/test-helpers';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { setupIntl } from 'ember-intl/test-support';
|
||||
import { authenticateSession } from 'ember-simple-auth/test-support';
|
||||
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
|
||||
import { TYPE_TARGET_SSH, TYPE_TARGET_TCP } from 'api/models/target';
|
||||
|
||||
module('Unit | Controller | scopes/scope/targets/index', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
setupIntl(hooks, 'en-us');
|
||||
|
||||
|
||||
Reference in new issue
Block a user