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:
Zhihe Li committed 2025-09-03 11:41:41 -04:00
1 parent 8abed1a809
commit e0982da744
33 files changed
+1692 -111

No files matched your search

@@ -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, []);
},
);
});