(
table: TableDefinition,
options: { q: string; filter?: Filter; sort?: Sort },
requestId: string
)
| 789 | * index on a text projection is the future accelerator if needed. |
| 790 | */ |
| 791 | export async function findRowMatches( |
| 792 | table: TableDefinition, |
| 793 | options: { q: string; filter?: Filter; sort?: Sort }, |
| 794 | requestId: string |
| 795 | ): Promise<{ matches: FindRowMatch[]; truncated: boolean }> { |
| 796 | const tableName = USER_TABLE_ROWS_SQL_NAME |
| 797 | const columns = table.schema.columns |
| 798 | // Row data is keyed by stable column id, so scan/return JSONB keys as ids. |
| 799 | const columnIds = columns.map(getColumnId) |
| 800 | if (columnIds.length === 0) return { matches: [], truncated: false } |
| 801 | |
| 802 | // Same visibility rule as queryRows: don't surface rows a running delete job will remove. |
| 803 | const deleteMask = await pendingDeleteMask(table) |
| 804 | |
| 805 | const baseConditions = and( |
| 806 | eq(userTableRows.tableId, table.id), |
| 807 | eq(userTableRows.workspaceId, table.workspaceId), |
| 808 | deleteMask |
| 809 | ) |
| 810 | let whereClause: SQL | undefined = baseConditions |
| 811 | if (options.filter && Object.keys(options.filter).length > 0) { |
| 812 | const filterClause = buildFilterClause(options.filter, tableName, columns) |
| 813 | if (filterClause) whereClause = and(baseConditions, filterClause) |
| 814 | } |
| 815 | |
| 816 | const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering') |
| 817 | const orderBySql = buildRowOrderBySql(options.sort, tableName, columns, fractionalOrdering) |
| 818 | const pattern = `%${escapeLikePattern(options.q)}%` |
| 819 | |
| 820 | const result = await db.transaction(async (trx) => { |
| 821 | // Planner flags, not correctness: `enable_* = off` only penalizes a plan shape, so a |
| 822 | // genuinely required sort still runs. Seqscan off keeps the scan inside the tenant's rows |
| 823 | // (the lateral ILIKE is unestimatable, so the planner otherwise walks the whole shared |
| 824 | // relation). On the default order, the remaining flags steer to the already-sorted |
| 825 | // `(table_id, order_key, id)` index walk so the window function streams without a 100MB+ |
| 826 | // disk sort; a custom sort has no index to stream from, so those flags would only distort |
| 827 | // that plan. |
| 828 | await trx.execute(sql`SET LOCAL enable_seqscan = off`) |
| 829 | if (!options.sort) { |
| 830 | await trx.execute(sql`SET LOCAL enable_bitmapscan = off`) |
| 831 | await trx.execute(sql`SET LOCAL enable_sort = off`) |
| 832 | await trx.execute(sql`SET LOCAL max_parallel_workers_per_gather = 0`) |
| 833 | } |
| 834 | return trx.execute<{ |
| 835 | ordinal: string | number |
| 836 | id: string |
| 837 | column_name: string |
| 838 | }>(sql` |
| 839 | WITH ordered AS ( |
| 840 | SELECT id, data, row_number() OVER (ORDER BY ${orderBySql}) - 1 AS ordinal |
| 841 | FROM ${userTableRows} |
| 842 | WHERE ${whereClause} |
| 843 | ) |
| 844 | SELECT o.ordinal, o.id, kv.key AS column_name |
| 845 | FROM ordered o |
| 846 | CROSS JOIN LATERAL jsonb_each_text(o.data) kv |
| 847 | WHERE kv.value ILIKE ${pattern} |
| 848 | AND ${inArray(sql`kv.key`, columnIds)} |
no test coverage detected