Skip to content

Commit 92f5a65

Browse files
authored
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.
1 parent d8744e8 commit 92f5a65

File tree

7 files changed

+219
-36
lines changed

7 files changed

+219
-36
lines changed

‎addons/api/addon/utils/sqlite-query.js‎

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -211,26 +211,68 @@ function addSearchConditions({
211211
if (!search || !modelMapping[resource]) {
212212
return;
213213
}
214+
const searchSelect = search?.select ?? 'rowid';
215+
const searchSql = `SELECT ${searchSelect} FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?`;
216+
const getParameter = (fields, text) =>
217+
fields?.length > 0
218+
? or(fields.map((field) => `${field}:"${text}"*`))
219+
: `"${text}"*`;
214220

215221
// Use the special prefix indicator "*" for full-text search
216222
if (typeOf(search) === 'object') {
217-
if (!search?.text) {
223+
if (!search.text) {
218224
return;
219225
}
220226

221-
parameters.push(
222-
or(search.fields.map((field) => `${field}:"${search.text}"*`)),
223-
);
227+
const parameter = getParameter(search.fields, search.text);
228+
parameters.push(parameter);
224229
} else {
225230
parameters.push(`"${search}"*`);
226231
}
227232

233+
// If there are extra related searches on other tables, add them too
234+
if (search?.relatedSearches?.length > 0) {
235+
const relatedSearchQueries = [];
236+
237+
search.relatedSearches.forEach((relatedSearch) => {
238+
const { resource: relatedResource, fields, join } = relatedSearch;
239+
const relatedTableName = underscore(relatedResource);
240+
241+
const parameter = getParameter(fields, search.text);
242+
parameters.push(parameter);
243+
244+
// Build the related FTS query with optional join
245+
let relatedQuery = [];
246+
relatedQuery.push(
247+
`SELECT "${tableName}".${searchSelect} FROM ${relatedTableName}_fts`,
248+
);
249+
250+
if (join) {
251+
const { joinFrom = 'id', joinOn } = join;
252+
relatedQuery.push(
253+
`JOIN "${tableName}" ON "${tableName}".${joinFrom} = ${relatedResource}_fts.${joinOn}`,
254+
);
255+
}
256+
relatedQuery.push(`WHERE ${relatedTableName}_fts MATCH ?`);
257+
258+
relatedSearchQueries.push(relatedQuery.join(' '));
259+
});
260+
261+
// Add the original search first since we've already added the original parameter first
262+
const conditionStatement = [searchSql, ...relatedSearchQueries].join(
263+
'\nUNION ',
264+
);
265+
conditions.push(
266+
`"${tableName}".${searchSelect} IN (${conditionStatement})`,
267+
);
268+
269+
return;
270+
}
271+
228272
// Use a subquery to match against the FTS table with rowids as SQLite is
229273
// much more efficient with FTS queries when using rowids or MATCH (or both).
230274
// We could have also used a join here but a subquery is simpler.
231-
conditions.push(
232-
`"${tableName}".rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
233-
);
275+
conditions.push(`"${tableName}".${searchSelect} IN (${searchSql})`);
234276
}
235277

236278
function constructSelectClause(select = [{ field: '*' }], tableName) {

‎addons/api/addon/workers/sqlite-worker.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
// See "Maximum Number Of Host Parameters In A Single SQL Statement" in
2929
// https://www.sqlite.org/limits.html
3030
const MAX_HOST_PARAMETERS = 32766;
31-
const SCHEMA_VERSION = 1;
31+
const SCHEMA_VERSION = 2;
3232

3333
// Some browsers do not allow calling getDirectory in private browsing modes even
3434
// if we're in a secure context. This will cause the SQLite setup to fail so we should

‎addons/api/addon/workers/utils/schema.js‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ const createTargetTables = `
2525
);
2626
CREATE INDEX IF NOT EXISTS idx_target_scope_id_created_time ON target(scope_id, created_time DESC);
2727
28-
-- Create a contentless FTS table as we will only use the rowids.
29-
-- Note that this only creates the FTS index and cannot reference the target content
28+
-- Create a content FTS table that references the original table
3029
CREATE VIRTUAL TABLE IF NOT EXISTS target_fts USING fts5(
3130
id,
3231
type,
@@ -35,7 +34,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS target_fts USING fts5(
3534
address,
3635
scope_id,
3736
created_time,
38-
content='',
37+
content='target',
3938
);
4039
4140
-- Create triggers to keep the FTS table in sync with the target table
@@ -68,6 +67,7 @@ CREATE TABLE IF NOT EXISTS alias (
6867
data TEXT NOT NULL
6968
);
7069
CREATE INDEX IF NOT EXISTS idx_alias_created_time ON alias(created_time DESC);
70+
CREATE INDEX IF NOT EXISTS idx_alias_destination_id ON alias(destination_id);
7171
7272
CREATE VIRTUAL TABLE IF NOT EXISTS alias_fts USING fts5(
7373
id,
@@ -78,7 +78,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS alias_fts USING fts5(
7878
value,
7979
scope_id,
8080
created_time,
81-
content='',
81+
content='alias',
8282
);
8383
8484
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(
111111
description,
112112
scope_id,
113113
created_time,
114-
content='',
114+
content='group',
115115
);
116116
117117
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(
144144
description,
145145
scope_id,
146146
created_time,
147-
content='',
147+
content='role',
148148
);
149149
150150
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(
177177
description,
178178
scope_id,
179179
created_time,
180-
content='',
180+
content='user',
181181
);
182182
183183
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(
212212
description,
213213
scope_id,
214214
created_time,
215-
content='',
215+
content='credential_store',
216216
);
217217
218218
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(
247247
description,
248248
scope_id,
249249
created_time,
250-
content='',
250+
content='scope',
251251
);
252252
253253
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(
284284
is_primary,
285285
scope_id,
286286
created_time,
287-
content='',
287+
content='auth_method',
288288
);
289289
290290
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(
321321
plugin_name,
322322
scope_id,
323323
created_time,
324-
content='',
324+
content='host_catalog',
325325
);
326326
327327
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(
374374
target_scope_name,
375375
target_scope_parent_scope_id,
376376
created_time,
377-
content='',
377+
content='session_recording',
378378
);
379379
380380
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(
413413
user_id,
414414
scope_id,
415415
created_time,
416-
content='',
416+
content='session',
417417
);
418418
419419
CREATE TRIGGER IF NOT EXISTS session_ai AFTER INSERT ON session BEGIN

‎addons/api/tests/unit/utils/sqlite-query-test.js‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,64 @@ module('Unit | Utility | sqlite-query', function (hooks) {
419419
ORDER BY "target".created_time DESC`,
420420
expectedParams: ['name:"favorite"* OR description:"favorite"*'],
421421
},
422+
'search with related searches': {
423+
query: {
424+
search: {
425+
text: 'dev',
426+
select: 'id',
427+
relatedSearches: [
428+
{
429+
resource: 'alias',
430+
fields: ['name', 'description'],
431+
join: {
432+
joinOn: 'destination_id',
433+
joinFrom: 'id',
434+
},
435+
},
436+
],
437+
},
438+
},
439+
expectedSql: `
440+
SELECT * FROM "target"
441+
WHERE "target".id IN (SELECT id FROM target_fts WHERE target_fts MATCH ?
442+
UNION SELECT "target".id FROM alias_fts JOIN "target" ON "target".id = alias_fts.destination_id WHERE alias_fts MATCH ?)
443+
ORDER BY "target".created_time DESC`,
444+
expectedParams: ['"dev"*', 'name:"dev"* OR description:"dev"*'],
445+
},
446+
'search with multiple related searches': {
447+
query: {
448+
search: {
449+
text: 'dev',
450+
relatedSearches: [
451+
{
452+
resource: 'alias',
453+
fields: ['name', 'description'],
454+
join: {
455+
joinOn: 'destination_id',
456+
},
457+
},
458+
{
459+
resource: 'session',
460+
fields: ['name'],
461+
join: {
462+
joinOn: 'target_id',
463+
},
464+
},
465+
],
466+
},
467+
},
468+
expectedSql: `
469+
SELECT * FROM "target"
470+
WHERE "target".rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?
471+
UNION SELECT "target".rowid FROM alias_fts JOIN "target" ON "target".id = alias_fts.destination_id WHERE alias_fts MATCH ?
472+
UNION SELECT "target".rowid FROM session_fts JOIN "target" ON "target".id = session_fts.target_id WHERE session_fts MATCH ?)
473+
ORDER BY "target".created_time DESC`,
474+
expectedParams: [
475+
'"dev"*',
476+
'name:"dev"* OR description:"dev"*',
477+
'name:"dev"*',
478+
],
479+
},
422480
},
423481
function (assert, { query, expectedSql, expectedParams }) {
424482
const { sql, parameters } = generateSQLExpressions('target', query);

‎ui/admin/app/routes/scopes/scope/aliases/index.js‎

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import Route from '@ember/routing/route';
77
import { service } from '@ember/service';
8-
import { hash } from 'rsvp';
98
import { restartableTask, timeout } from 'ember-concurrency';
109

1110
export default class ScopesScopeAliasesIndexRoute extends Route {
@@ -79,28 +78,68 @@ export default class ScopesScopeAliasesIndexRoute extends Route {
7978
direction: sortDirection,
8079
};
8180

81+
const searchOptions = {
82+
text: search,
83+
relatedSearches: [
84+
{
85+
resource: 'target',
86+
fields: ['name'],
87+
join: {
88+
joinFrom: 'destination_id',
89+
joinOn: 'id',
90+
},
91+
},
92+
],
93+
};
94+
95+
const targetPromise = this.store.query(
96+
'target',
97+
{
98+
scope_id,
99+
recursive: true,
100+
page: 1,
101+
pageSize: 1,
102+
},
103+
{ pushToStore: false },
104+
);
105+
82106
aliases = await this.store.query('alias', {
83107
scope_id,
84-
query: { search, sort },
108+
query: { search: searchOptions, sort },
85109
page,
86110
pageSize,
87111
});
88112

89113
totalItems = aliases.meta?.totalItems;
90-
// since we don't receive target info from aliases list API,
91-
// we query the store to fetch target information based on the destination id
92-
aliases = await Promise.all(
93-
aliases.map((alias) =>
94-
hash({
95-
alias,
96-
target: alias.destination_id
97-
? this.store.findRecord('target', alias.destination_id, {
98-
backgroundReload: false,
99-
})
100-
: null,
101-
}),
114+
115+
await targetPromise;
116+
// All the targets should have been retrieved just before this so we don't need to make another API request
117+
// Check for actual aliases with destinations as an empty array will bring back all targets which we don't want
118+
const associatedTargets = aliases.some((alias) => alias.destination_id)
119+
? await this.store.query(
120+
'target',
121+
{
122+
query: {
123+
filters: {
124+
id: aliases
125+
.filter((alias) => alias.destination_id)
126+
.map((alias) => ({
127+
equals: alias.destination_id,
128+
})),
129+
},
130+
},
131+
},
132+
{ pushToStore: true, peekDb: true },
133+
)
134+
: [];
135+
136+
aliases = aliases.map((alias) => ({
137+
alias,
138+
target: associatedTargets.find(
139+
(target) => target.id === alias.destination_id,
102140
),
103-
);
141+
}));
142+
104143
doAliasesExist = await this.getDoAliasesExist(scope_id, totalItems);
105144
}
106145

0 commit comments

Comments
 (0)