feat: 🎸 Add ability to search other resources as well (#3021)
This commit adds the ability to search across multiple tables with joins which unlocks a better search experience in the case of tables that source their data from multiple tables.
This commit is contained in:
1 parent
d8744e81bc
commit
92f5a65c00
7 files changed
+218
-35
No files matched your search
@@ -211,26 +211,68 @@ function addSearchConditions({
|
||||
if (!search || !modelMapping[resource]) {
|
||||
return;
|
||||
}
|
||||
const searchSelect = search?.select ?? 'rowid';
|
||||
const searchSql = `SELECT ${searchSelect} FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?`;
|
||||
const getParameter = (fields, text) =>
|
||||
fields?.length > 0
|
||||
? or(fields.map((field) => `${field}:"${text}"*`))
|
||||
: `"${text}"*`;
|
||||
|
||||
// Use the special prefix indicator "*" for full-text search
|
||||
if (typeOf(search) === 'object') {
|
||||
if (!search?.text) {
|
||||
if (!search.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
parameters.push(
|
||||
or(search.fields.map((field) => `${field}:"${search.text}"*`)),
|
||||
);
|
||||
const parameter = getParameter(search.fields, search.text);
|
||||
parameters.push(parameter);
|
||||
} else {
|
||||
parameters.push(`"${search}"*`);
|
||||
}
|
||||
|
||||
// If there are extra related searches on other tables, add them too
|
||||
if (search?.relatedSearches?.length > 0) {
|
||||
const relatedSearchQueries = [];
|
||||
|
||||
search.relatedSearches.forEach((relatedSearch) => {
|
||||
const { resource: relatedResource, fields, join } = relatedSearch;
|
||||
const relatedTableName = underscore(relatedResource);
|
||||
|
||||
const parameter = getParameter(fields, search.text);
|
||||
parameters.push(parameter);
|
||||
|
||||
// Build the related FTS query with optional join
|
||||
let relatedQuery = [];
|
||||
relatedQuery.push(
|
||||
`SELECT "${tableName}".${searchSelect} FROM ${relatedTableName}_fts`,
|
||||
);
|
||||
|
||||
if (join) {
|
||||
const { joinFrom = 'id', joinOn } = join;
|
||||
relatedQuery.push(
|
||||
`JOIN "${tableName}" ON "${tableName}".${joinFrom} = ${relatedResource}_fts.${joinOn}`,
|
||||
);
|
||||
}
|
||||
relatedQuery.push(`WHERE ${relatedTableName}_fts MATCH ?`);
|
||||
|
||||
relatedSearchQueries.push(relatedQuery.join(' '));
|
||||
});
|
||||
|
||||
// Add the original search first since we've already added the original parameter first
|
||||
const conditionStatement = [searchSql, ...relatedSearchQueries].join(
|
||||
'\nUNION ',
|
||||
);
|
||||
conditions.push(
|
||||
`"${tableName}".${searchSelect} IN (${conditionStatement})`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 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(
|
||||
`"${tableName}".rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
|
||||
);
|
||||
conditions.push(`"${tableName}".${searchSelect} IN (${searchSql})`);
|
||||
}
|
||||
|
||||
function constructSelectClause(select = [{ field: '*' }], tableName) {
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
// 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 SCHEMA_VERSION = 2;
|
||||
|
||||
// Some browsers do not allow calling getDirectory in private browsing modes even
|
||||
// if we're in a secure context. This will cause the SQLite setup to fail so we should
|
||||
|
||||
@@ -25,8 +25,7 @@ const createTargetTables = `
|
||||
);
|
||||
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 a content FTS table that references the original table
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS target_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
@@ -35,7 +34,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS target_fts USING fts5(
|
||||
address,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='target',
|
||||
);
|
||||
|
||||
-- Create triggers to keep the FTS table in sync with the target table
|
||||
@@ -68,6 +67,7 @@ CREATE TABLE IF NOT EXISTS alias (
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alias_created_time ON alias(created_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_alias_destination_id ON alias(destination_id);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS alias_fts USING fts5(
|
||||
id,
|
||||
@@ -78,7 +78,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS alias_fts USING fts5(
|
||||
value,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='alias',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS alias_ai AFTER INSERT ON alias BEGIN
|
||||
@@ -111,7 +111,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS group_fts USING fts5(
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='group',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS group_ai AFTER INSERT ON "group" BEGIN
|
||||
@@ -144,7 +144,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS role_fts USING fts5(
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='role',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS role_ai AFTER INSERT ON role BEGIN
|
||||
@@ -177,7 +177,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS user_fts USING fts5(
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='user',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS user_ai AFTER INSERT ON user BEGIN
|
||||
@@ -212,7 +212,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS credential_store_fts USING fts5(
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='credential_store',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS credential_store_ai AFTER INSERT ON credential_store BEGIN
|
||||
@@ -247,7 +247,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS scope_fts USING fts5(
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='scope',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS scope_ai AFTER INSERT ON scope BEGIN
|
||||
@@ -284,7 +284,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS auth_method_fts USING fts5(
|
||||
is_primary,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='auth_method',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS auth_method_ai AFTER INSERT ON auth_method BEGIN
|
||||
@@ -321,7 +321,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS host_catalog_fts USING fts5(
|
||||
plugin_name,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='host_catalog',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS host_catalog_ai AFTER INSERT ON host_catalog BEGIN
|
||||
@@ -374,7 +374,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS session_recording_fts USING fts5(
|
||||
target_scope_name,
|
||||
target_scope_parent_scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='session_recording',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_recording_ai AFTER INSERT ON session_recording BEGIN
|
||||
@@ -413,7 +413,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS session_fts USING fts5(
|
||||
user_id,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='session',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_ai AFTER INSERT ON session BEGIN
|
||||
|
||||
@@ -419,6 +419,64 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
ORDER BY "target".created_time DESC`,
|
||||
expectedParams: ['name:"favorite"* OR description:"favorite"*'],
|
||||
},
|
||||
'search with related searches': {
|
||||
query: {
|
||||
search: {
|
||||
text: 'dev',
|
||||
select: 'id',
|
||||
relatedSearches: [
|
||||
{
|
||||
resource: 'alias',
|
||||
fields: ['name', 'description'],
|
||||
join: {
|
||||
joinOn: 'destination_id',
|
||||
joinFrom: 'id',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
expectedSql: `
|
||||
SELECT * FROM "target"
|
||||
WHERE "target".id IN (SELECT id FROM target_fts WHERE target_fts MATCH ?
|
||||
UNION SELECT "target".id FROM alias_fts JOIN "target" ON "target".id = alias_fts.destination_id WHERE alias_fts MATCH ?)
|
||||
ORDER BY "target".created_time DESC`,
|
||||
expectedParams: ['"dev"*', 'name:"dev"* OR description:"dev"*'],
|
||||
},
|
||||
'search with multiple related searches': {
|
||||
query: {
|
||||
search: {
|
||||
text: 'dev',
|
||||
relatedSearches: [
|
||||
{
|
||||
resource: 'alias',
|
||||
fields: ['name', 'description'],
|
||||
join: {
|
||||
joinOn: 'destination_id',
|
||||
},
|
||||
},
|
||||
{
|
||||
resource: 'session',
|
||||
fields: ['name'],
|
||||
join: {
|
||||
joinOn: 'target_id',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
expectedSql: `
|
||||
SELECT * FROM "target"
|
||||
WHERE "target".rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?
|
||||
UNION SELECT "target".rowid FROM alias_fts JOIN "target" ON "target".id = alias_fts.destination_id WHERE alias_fts MATCH ?
|
||||
UNION SELECT "target".rowid FROM session_fts JOIN "target" ON "target".id = session_fts.target_id WHERE session_fts MATCH ?)
|
||||
ORDER BY "target".created_time DESC`,
|
||||
expectedParams: [
|
||||
'"dev"*',
|
||||
'name:"dev"* OR description:"dev"*',
|
||||
'name:"dev"*',
|
||||
],
|
||||
},
|
||||
},
|
||||
function (assert, { query, expectedSql, expectedParams }) {
|
||||
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
import { restartableTask, timeout } from 'ember-concurrency';
|
||||
|
||||
export default class ScopesScopeAliasesIndexRoute extends Route {
|
||||
@@ -79,28 +78,68 @@ export default class ScopesScopeAliasesIndexRoute extends Route {
|
||||
direction: sortDirection,
|
||||
};
|
||||
|
||||
const searchOptions = {
|
||||
text: search,
|
||||
relatedSearches: [
|
||||
{
|
||||
resource: 'target',
|
||||
fields: ['name'],
|
||||
join: {
|
||||
joinFrom: 'destination_id',
|
||||
joinOn: 'id',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const targetPromise = this.store.query(
|
||||
'target',
|
||||
{
|
||||
scope_id,
|
||||
recursive: true,
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
},
|
||||
{ pushToStore: false },
|
||||
);
|
||||
|
||||
aliases = await this.store.query('alias', {
|
||||
scope_id,
|
||||
query: { search, sort },
|
||||
query: { search: searchOptions, sort },
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
totalItems = aliases.meta?.totalItems;
|
||||
// since we don't receive target info from aliases list API,
|
||||
// we query the store to fetch target information based on the destination id
|
||||
aliases = await Promise.all(
|
||||
aliases.map((alias) =>
|
||||
hash({
|
||||
|
||||
await targetPromise;
|
||||
// All the targets should have been retrieved just before this so we don't need to make another API request
|
||||
// Check for actual aliases with destinations as an empty array will bring back all targets which we don't want
|
||||
const associatedTargets = aliases.some((alias) => alias.destination_id)
|
||||
? await this.store.query(
|
||||
'target',
|
||||
{
|
||||
query: {
|
||||
filters: {
|
||||
id: aliases
|
||||
.filter((alias) => alias.destination_id)
|
||||
.map((alias) => ({
|
||||
equals: alias.destination_id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ pushToStore: true, peekDb: true },
|
||||
)
|
||||
: [];
|
||||
|
||||
aliases = aliases.map((alias) => ({
|
||||
alias,
|
||||
target: alias.destination_id
|
||||
? this.store.findRecord('target', alias.destination_id, {
|
||||
backgroundReload: false,
|
||||
})
|
||||
: null,
|
||||
}),
|
||||
target: associatedTargets.find(
|
||||
(target) => target.id === alias.destination_id,
|
||||
),
|
||||
);
|
||||
}));
|
||||
|
||||
doAliasesExist = await this.getDoAliasesExist(scope_id, totalItems);
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +279,46 @@ module('Acceptance | aliases | list', function (hooks) {
|
||||
.includesText(intl.t('titles.no-results-found'));
|
||||
});
|
||||
|
||||
test('user can search for aliases by associated target name', async function (assert) {
|
||||
setRunOptions({
|
||||
rules: {
|
||||
'color-contrast': {
|
||||
// [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create a target with a specific name
|
||||
const targetWithName = this.server.create('target', {
|
||||
name: 'A real production target',
|
||||
scope: instances.scopes.project,
|
||||
});
|
||||
|
||||
// Create an alias associated with this target
|
||||
const aliasWithNamedTarget = this.server.create('alias', {
|
||||
scope: instances.scopes.global,
|
||||
destination_id: targetWithName.id,
|
||||
});
|
||||
|
||||
const urlAliasWithNamedTarget = `${urls.aliases}/${aliasWithNamedTarget.id}`;
|
||||
|
||||
await visit(urls.globalScope);
|
||||
|
||||
await click(commonSelectors.HREF(urls.aliases));
|
||||
|
||||
assert.dom(commonSelectors.HREF(urls.alias)).exists();
|
||||
assert.dom(commonSelectors.HREF(urls.aliasWithTarget)).exists();
|
||||
assert.dom(commonSelectors.HREF(urlAliasWithNamedTarget)).exists();
|
||||
|
||||
await fillIn(commonSelectors.SEARCH_INPUT, 'production target');
|
||||
await waitFor(commonSelectors.HREF(urls.alias), { count: 0 });
|
||||
|
||||
assert.dom(commonSelectors.HREF(urlAliasWithNamedTarget)).exists();
|
||||
assert.dom(commonSelectors.HREF(urls.alias)).doesNotExist();
|
||||
assert.dom(commonSelectors.HREF(urls.aliasWithTarget)).doesNotExist();
|
||||
});
|
||||
|
||||
test('aliases are sorted by created_time descending by default', async function (assert) {
|
||||
setRunOptions({
|
||||
rules: {
|
||||
|
||||
@@ -39,6 +39,10 @@ module('Acceptance | aliases | update', function (hooks) {
|
||||
type: 'org',
|
||||
scope: { id: 'global', type: 'global' },
|
||||
});
|
||||
instances.scopes.project = this.server.create('scope', {
|
||||
type: 'project',
|
||||
scope: { id: instances.scopes.org.id, type: 'org' },
|
||||
});
|
||||
instances.target = this.server.create('target', {
|
||||
scope: instances.scopes.project,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user