diff --git a/addons/api/addon/utils/sqlite-query.js b/addons/api/addon/utils/sqlite-query.js index 4b46a6a281..80d9ed90be 100644 --- a/addons/api/addon/utils/sqlite-query.js +++ b/addons/api/addon/utils/sqlite-query.js @@ -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) { diff --git a/addons/api/addon/workers/sqlite-worker.js b/addons/api/addon/workers/sqlite-worker.js index 9c9ac0580d..5110cb8ca4 100644 --- a/addons/api/addon/workers/sqlite-worker.js +++ b/addons/api/addon/workers/sqlite-worker.js @@ -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 diff --git a/addons/api/addon/workers/utils/schema.js b/addons/api/addon/workers/utils/schema.js index fd762cc934..5080cd37d9 100644 --- a/addons/api/addon/workers/utils/schema.js +++ b/addons/api/addon/workers/utils/schema.js @@ -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 diff --git a/addons/api/tests/unit/utils/sqlite-query-test.js b/addons/api/tests/unit/utils/sqlite-query-test.js index 27d2685d18..d1f2a26e76 100644 --- a/addons/api/tests/unit/utils/sqlite-query-test.js +++ b/addons/api/tests/unit/utils/sqlite-query-test.js @@ -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); diff --git a/ui/admin/app/routes/scopes/scope/aliases/index.js b/ui/admin/app/routes/scopes/scope/aliases/index.js index e6845eba88..d4231eaa65 100644 --- a/ui/admin/app/routes/scopes/scope/aliases/index.js +++ b/ui/admin/app/routes/scopes/scope/aliases/index.js @@ -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({ - alias, - target: alias.destination_id - ? this.store.findRecord('target', alias.destination_id, { - backgroundReload: false, - }) - : null, - }), + + 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: associatedTargets.find( + (target) => target.id === alias.destination_id, ), - ); + })); + doAliasesExist = await this.getDoAliasesExist(scope_id, totalItems); } diff --git a/ui/admin/tests/acceptance/aliases/list-test.js b/ui/admin/tests/acceptance/aliases/list-test.js index 9481db7a89..ab1659c362 100644 --- a/ui/admin/tests/acceptance/aliases/list-test.js +++ b/ui/admin/tests/acceptance/aliases/list-test.js @@ -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: { diff --git a/ui/admin/tests/acceptance/aliases/update-test.js b/ui/admin/tests/acceptance/aliases/update-test.js index 3fc6bafbb7..c258edfde1 100644 --- a/ui/admin/tests/acceptance/aliases/update-test.js +++ b/ui/admin/tests/acceptance/aliases/update-test.js @@ -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, });