Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions addons/api/addon/handlers/sqlite-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default class SqliteHandler {
pushToStore = true,
peekDb = false,
storeToken = true,
returnRawData = false,
} = {},
} = data;
const supportedModels = Object.keys(modelMapping);
Expand All @@ -49,7 +50,13 @@ export default class SqliteHandler {
const schema = store.modelFor(type);
const serializer = store.serializerFor(type);

let { page, pageSize, query: queryObj, ...remainingQuery } = query;
let {
page,
pageSize,
select,
query: queryObj,
...remainingQuery
} = query;
let payload,
listToken,
writeToDbPromise,
Expand Down Expand Up @@ -119,17 +126,21 @@ export default class SqliteHandler {
const { sql, parameters } = generateSQLExpressions(type, queryObj, {
page,
pageSize,
select: ['data'],
select: select ?? [{ field: 'data' }],
});

const rows = await this.sqlite.fetchResource({
sql,
parameters,
});

if (returnRawData) {
return rows;
}

const { sql: countSql, parameters: countParams } =
generateSQLExpressions(type, queryObj, {
select: ['count(*) as total'],
select: [{ field: '*', isCount: true, alias: 'total' }],
});
const count = await this.sqlite.fetchResource({
sql: countSql,
Expand Down
17 changes: 2 additions & 15 deletions addons/api/addon/services/sqlite.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export const modelMapping = {
target_name: 'create_time_values.target.name',
target_scope_id: 'create_time_values.target.scope.id',
target_scope_name: 'create_time_values.target.scope.name',
target_scope_parent_scope_id:
'create_time_values.target.scope.parent_scope_id',
created_time: 'created_time',
},
session: {
Expand All @@ -114,21 +116,6 @@ export const modelMapping = {
},
};

// A list of tables that we support searching using FTS5 in SQLite.
export const searchTables = new Set([
'target',
'alias',
'group',
'role',
'user',
'credential-store',
'scope',
'auth-method',
'host-catalog',
'session-recording',
'session',
]);

export default class SqliteDbService extends Service {
// =attributes

Expand Down
68 changes: 45 additions & 23 deletions addons/api/addon/utils/sqlite-query.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
*/

import { modelMapping } from 'api/services/sqlite';
import { searchTables } from 'api/services/sqlite';
import { typeOf } from '@ember/utils';
import { underscore } from '@ember/string';

Expand Down Expand Up @@ -35,17 +34,15 @@ export function generateSQLExpressions(
addFilterConditions({ filters, parameters, conditions });
addSearchConditions({ search, resource, tableName, parameters, conditions });

const selectClause = constructSelectClause(select, tableName);
const orderByClause = constructOrderByClause(resource, sort);

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 "${tableName}"`;

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.
Expand Down Expand Up @@ -142,32 +139,57 @@ function addSearchConditions({
parameters,
conditions,
}) {
if (!search) {
if (!search || !modelMapping[resource]) {
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 ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
// Use the special prefix indicator "*" for full-text search
if (typeOf(search) === 'object') {
if (!search?.text) {
return;
}

parameters.push(
or(search.fields.map((field) => `${field}:"${search.text}"*`)),
);
return;
} else {
parameters.push(`"${search}"*`);
}

const fields = Object.keys(modelMapping[resource]);
const searchConditions = parenthetical(
or(
fields.map((field) => {
parameters.push(`%${search}%`);
return `${field}${OPERATORS['contains']}`;
}),
),
// 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 ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
);
conditions.push(searchConditions);
}
function constructSelectClause(select = [{ field: '*' }], tableName) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it easier to use the model mapping and have selects expressed in terms of the model attributes and mapped to the underlying column? It feels like the outer api is starting to blend between the model/attributes and row/columns.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yeah you're right, this is a little leaky. I could make a reverse mapping of modelMapping (since it currently goes from column to JSON path) and use that so we don't have to be aware of the columns

const distinctColumns = select.filter(({ isDistinct }) => isDistinct);
let selectColumns;

// Special case for distinct columns as they must be grouped together.
// We're only handling simple use cases as anything more complicated
// like windows/CTEs can be custom SQL.
if (distinctColumns.length > 0) {
selectColumns = `DISTINCT ${distinctColumns.map(({ field }) => field).join(', ')}`;
} else {
Comment on lines +173 to +175
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it a valid case to have a mix of distinct and regular columns selected? If it isn't, maybe an error could be thrown?

selectColumns = select
.map(({ field, isCount, alias }) => {
let column = field;

if (isCount) {
column = `count(${column})`;
}
if (alias) {
column = `${column} as ${alias}`;
}

return column;
})
.join(', ');
}

return `SELECT ${selectColumns} FROM "${tableName}"`;
}

function constructOrderByClause(resource, sort) {
Expand Down
10 changes: 6 additions & 4 deletions addons/api/addon/workers/utils/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ CREATE TABLE IF NOT EXISTS session_recording (
target_name TEXT,
target_scope_id TEXT,
target_scope_name TEXT,
target_scope_parent_scope_id TEXT,
created_time TEXT NOT NULL,
data TEXT NOT NULL
);
Expand All @@ -371,21 +372,22 @@ CREATE VIRTUAL TABLE IF NOT EXISTS session_recording_fts USING fts5(
target_name,
target_scope_id,
target_scope_name,
target_scope_parent_scope_id,
created_time,
content='',
);

CREATE TRIGGER IF NOT EXISTS session_recording_ai AFTER INSERT ON session_recording BEGIN
INSERT INTO session_recording_fts(
rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, created_time
rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, target_scope_parent_scope_id, created_time
) VALUES (
new.rowid, new.id, new.type, new.state, new.start_time, new.end_time, new.duration, new.scope_id, new.user_id, new.user_name, new.target_id, new.target_name, new.target_scope_id, new.target_scope_name, new.created_time
new.rowid, new.id, new.type, new.state, new.start_time, new.end_time, new.duration, new.scope_id, new.user_id, new.user_name, new.target_id, new.target_name, new.target_scope_id, new.target_scope_name, new.target_scope_parent_scope_id, new.created_time
);
END;

CREATE TRIGGER IF NOT EXISTS session_recording_ad AFTER DELETE ON session_recording BEGIN
INSERT INTO session_recording_fts(session_recording_fts, rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, created_time)
VALUES('delete', old.rowid, old.id, old.type, old.state, old.start_time, old.end_time, old.duration, old.scope_id, old.user_id, old.user_name, old.target_id, old.target_name, old.target_scope_id, old.target_scope_name, old.created_time);
INSERT INTO session_recording_fts(session_recording_fts, rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, target_scope_parent_scope_id, created_time)
VALUES('delete', old.rowid, old.id, old.type, old.state, old.start_time, old.end_time, old.duration, old.scope_id, old.user_id, old.user_name, old.target_id, old.target_name, old.target_scope_id, old.target_scope_name, old.target_scope_parent_scope_id, old.created_time);
END;`;

const createSessionTables = `
Expand Down
5 changes: 2 additions & 3 deletions addons/api/tests/unit/services/sqlite-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@
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';
import { modelMapping } from 'api/services/sqlite';
import { underscore } from '@ember/string';

const supportedModels = Object.keys(modelMapping);
const supportedFtsTables = [...searchTables];

module('Unit | Service | sqlite', function (hooks) {
setupTest(hooks);
Expand Down Expand Up @@ -38,7 +37,7 @@ module('Unit | Service | sqlite', function (hooks) {

test.each(
'Mapping matches fts table columns',
supportedFtsTables,
supportedModels,
async function (assert, resource) {
const service = this.owner.lookup('service:sqlite');

Expand Down
76 changes: 59 additions & 17 deletions addons/api/tests/unit/utils/sqlite-query-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,6 @@ module('Unit | Utility | sqlite-query', function (hooks) {
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' }] },
Expand All @@ -52,7 +37,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {

test('it executes count queries correctly', function (assert) {
const select = {
select: ['count(*) as total'],
select: [{ field: '*', isCount: true, alias: 'total' }],
};

const { sql, parameters } = generateSQLExpressions('target', {}, select);
Expand All @@ -65,6 +50,38 @@ module('Unit | Utility | sqlite-query', function (hooks) {
assert.deepEqual(parameters, []);
});

test.each(
'it generates DISTINCT queries correctly',
[
{
select: [{ field: 'type', isDistinct: true }],
expectedSelect: 'type',
},
{
select: [
{ field: 'type', isDistinct: true },
{ field: 'status', isDistinct: true },
],
expectedSelect: 'type, status',
},
],
function (assert, { select, expectedSelect }) {
const { sql, parameters } = generateSQLExpressions(
'target',
{},
{ select },
);

assert.strictEqual(
sql,
`
SELECT DISTINCT ${expectedSelect} FROM "target"
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, []);
},
);

test.each(
'it generates filters correctly',
{
Expand Down Expand Up @@ -283,6 +300,31 @@ module('Unit | Utility | sqlite-query', function (hooks) {
]);
});

test('it generates FTS5 search with object parameter', function (assert) {
const query = {
search: {
text: 'favorite',
fields: ['name', 'description'],
},
filters: {
type: [{ equals: 'ssh' }],
},
};

const { sql, parameters } = generateSQLExpressions('target', query);
assert.strictEqual(
sql,
`
SELECT * FROM "target"
WHERE (type = ?) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
);
assert.deepEqual(parameters, [
'ssh',
'name:"favorite"* OR description:"favorite"*',
]);
});

test('it generates SQL with all clauses combined', function (assert) {
const query = {
search: 'favorite',
Expand All @@ -300,7 +342,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
const { sql, parameters } = generateSQLExpressions('target', query, {
page: 2,
pageSize: 15,
select: ['data'],
select: [{ field: 'data' }],
});

assert.strictEqual(
Expand Down
Loading