diff --git a/packages/pds/src/api/com/atproto/admin/searchRepos.ts b/packages/pds/src/api/com/atproto/admin/searchRepos.ts index 3ee72dec892..da2d7fa3788 100644 --- a/packages/pds/src/api/com/atproto/admin/searchRepos.ts +++ b/packages/pds/src/api/com/atproto/admin/searchRepos.ts @@ -26,11 +26,11 @@ export default function (server: Server, ctx: AppContext) { const { db, services } = ctx const moderationService = services.moderation(db) const { limit, cursor, invitedBy } = params - const term = params.term?.trim() ?? '' + const query = params.q?.trim() ?? params.term?.trim() ?? '' const keyset = new ListKeyset(sql``, sql``) - if (!term) { + if (!query) { const results = await services .account(db) .list({ limit, cursor, includeSoftDeleted: true, invitedBy }) @@ -47,13 +47,13 @@ export default function (server: Server, ctx: AppContext) { const results = await services .account(db) - .search({ term, limit, cursor, includeSoftDeleted: true }) + .search({ query, limit, cursor, includeSoftDeleted: true }) return { encoding: 'application/json', body: { // For did search, we can only find 1 or no match, cursors can be ignored entirely - cursor: term.startsWith('did:') + cursor: query.startsWith('did:') ? undefined : keyset.packFromResult(results), repos: await moderationService.views.repo(results, { diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 06c0fd4bf9e..3987d022a23 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -11,7 +11,6 @@ import { countAll, notSoftDeletedClause } from '../../db/util' import { paginate, TimeCidKeyset } from '../../db/pagination' import * as sequencer from '../../sequencer' import { AppPassword } from '../../lexicon/types/com/atproto/server/createAppPassword' -import { getUserSearchQueryPg, getUserSearchQuerySqlite } from '../util/search' export class AccountService { constructor(public db: Database) {} @@ -275,22 +274,44 @@ export class AccountService { } async search(opts: { - term: string + query: string limit: number cursor?: string includeSoftDeleted?: boolean }): Promise<(RepoRoot & DidHandle)[]> { - const builder = - this.db.dialect === 'pg' - ? getUserSearchQueryPg(this.db, opts) - .selectAll('did_handle') - .selectAll('repo_root') - : getUserSearchQuerySqlite(this.db, opts) - .selectAll('did_handle') - .selectAll('repo_root') - .select(sql`0`.as('distance')) - - return await builder.execute() + const { query, limit, cursor, includeSoftDeleted } = opts + const { ref } = this.db.db.dynamic + + const builder = this.db.db + .selectFrom('did_handle') + .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') + .innerJoin('user_account', 'user_account.did', 'did_handle.did') + .if(!includeSoftDeleted, (qb) => + qb.where(notSoftDeletedClause(ref('repo_root'))), + ) + .where((qb) => { + // sqlite doesn't support "ilike", but performs "like" case-insensitively + const likeOp = this.db.dialect === 'pg' ? 'ilike' : 'like' + if (query.includes('@')) { + return qb.where('user_account.email', likeOp, `%${query}%`) + } + if (query.startsWith('did:')) { + return qb.where('did_handle.did', '=', query) + } + return qb.where('did_handle.handle', likeOp, `${query}%`) + }) + .selectAll(['did_handle', 'repo_root']) + + const keyset = new ListKeyset( + ref('repo_root.indexedAt'), + ref('did_handle.handle'), + ) + + return await paginate(builder, { + limit, + cursor, + keyset, + }).execute() } async list(opts: { diff --git a/packages/pds/src/services/util/search.ts b/packages/pds/src/services/util/search.ts deleted file mode 100644 index 26b3e81bf06..00000000000 --- a/packages/pds/src/services/util/search.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { sql } from 'kysely' -import { InvalidRequestError } from '@atproto/xrpc-server' -import Database from '../../db' -import { notSoftDeletedClause, DbRef } from '../../db/util' -import { GenericKeyset, paginate } from '../../db/pagination' - -// @TODO utilized in both pds and app-view -export const getUserSearchQueryPg = ( - db: Database, - opts: { - term: string - limit: number - cursor?: string - includeSoftDeleted?: boolean - invitedBy?: string - }, -) => { - const { ref } = db.db.dynamic - const { term, limit, cursor, includeSoftDeleted } = opts - // Matching user accounts based on handle - const distanceAccount = distance(term, ref('handle')) - const accountsQb = getMatchingAccountsQb(db, { term, includeSoftDeleted }) - return paginate(accountsQb, { - limit, - cursor, - direction: 'asc', - keyset: new SearchKeyset(distanceAccount, ref('handle')), - }) -} - -// Matching user accounts based on handle -const getMatchingAccountsQb = ( - db: Database, - opts: { term: string; includeSoftDeleted?: boolean }, -) => { - const { ref } = db.db.dynamic - const { term, includeSoftDeleted } = opts - const distanceAccount = distance(term, ref('handle')) - return db.db - .selectFrom('did_handle') - .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') - .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('repo_root'))), - ) - .where(similar(term, ref('handle'))) // Coarse filter engaging trigram index - .select(['did_handle.did as did', distanceAccount.as('distance')]) -} - -export const getUserSearchQuerySqlite = ( - db: Database, - opts: { - term: string - limit: number - cursor?: string - includeSoftDeleted?: boolean - }, -) => { - const { ref } = db.db.dynamic - const { term, limit, cursor, includeSoftDeleted } = opts - - // Take the first three words in the search term. We're going to build a dynamic query - // based on the number of words, so to keep things predictable just ignore words 4 and - // beyond. We also remove the special wildcard characters supported by the LIKE operator, - // since that's where these values are heading. - const safeWords = term - .replace(/[%_]/g, '') - .split(/\s+/) - .filter(Boolean) - .slice(0, 3) - - if (!safeWords.length) { - // Return no results. This could happen with weird input like ' % _ '. - return db.db - .selectFrom('did_handle') - .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') - .where(sql`1 = 0`) - } - - // We'll ensure there's a space before each word in both textForMatch and in safeWords, - // so that we can reliably match word prefixes using LIKE operator. - const textForMatch = sql`lower(' ' || ${ref( - 'did_handle.handle', - )} || ' ' || coalesce(${ref('profile.displayName')}, ''))` - - const keyset = new SearchKeyset(sql``, sql``) - const unpackedCursor = keyset.unpackCursor(cursor) - - return db.db - .selectFrom('did_handle') - .innerJoin('repo_root', 'repo_root.did', 'did_handle.did') - .if(!includeSoftDeleted, (qb) => - qb.where(notSoftDeletedClause(ref('repo_root'))), - ) - .where((q) => { - safeWords.forEach((word) => { - // Match word prefixes against contents of handle and displayName - q = q.where(textForMatch, 'like', `% ${word.toLowerCase()}%`) - }) - return q - }) - .if(!!unpackedCursor, (qb) => - unpackedCursor ? qb.where('handle', '>', unpackedCursor.secondary) : qb, - ) - .orderBy('handle') - .limit(limit) -} - -// Remove leading @ in case a handle is input that way -export const cleanTerm = (term: string) => term.trim().replace(/^@/g, '') - -// Uses pg_trgm strict word similarity to check similarity between a search term and a stored value -const distance = (term: string, ref: DbRef) => - sql`(${term} <<-> ${ref})` - -// Can utilize trigram index to match on strict word similarity. -// The word_similarity_threshold is set to .4 (i.e. distance < .6) in db/index.ts. -const similar = (term: string, ref: DbRef) => sql`(${term} <% ${ref})` - -type Result = { distance: number; handle: string } -type LabeledResult = { primary: number; secondary: string } -export class SearchKeyset extends GenericKeyset { - labelResult(result: Result) { - return { - primary: result.distance, - secondary: result.handle, - } - } - labeledResultToCursor(labeled: LabeledResult) { - return { - primary: labeled.primary.toString().replace('0.', '.'), - secondary: labeled.secondary, - } - } - cursorToLabeledResult(cursor: { primary: string; secondary: string }) { - const distance = parseFloat(cursor.primary) - if (isNaN(distance)) { - throw new InvalidRequestError('Malformed cursor') - } - return { - primary: distance, - secondary: cursor.secondary, - } - } -}