@teleporthq/teleport-plugin-next-data-source 0.44.11 → 0.44.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/__tests__/browser-row-policy.test.ts +6 -6
  2. package/__tests__/fetchers.test.ts +1 -1
  3. package/__tests__/items-path.test.ts +3 -2
  4. package/__tests__/search-relevance.test.ts +407 -0
  5. package/dist/cjs/fetchers/csv-file.d.ts.map +1 -1
  6. package/dist/cjs/fetchers/csv-file.js +4 -3
  7. package/dist/cjs/fetchers/csv-file.js.map +1 -1
  8. package/dist/cjs/fetchers/google-sheets.d.ts.map +1 -1
  9. package/dist/cjs/fetchers/google-sheets.js +2 -1
  10. package/dist/cjs/fetchers/google-sheets.js.map +1 -1
  11. package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
  12. package/dist/cjs/fetchers/javascript.js +2 -1
  13. package/dist/cjs/fetchers/javascript.js.map +1 -1
  14. package/dist/cjs/fetchers/mariadb.d.ts.map +1 -1
  15. package/dist/cjs/fetchers/mariadb.js +4 -3
  16. package/dist/cjs/fetchers/mariadb.js.map +1 -1
  17. package/dist/cjs/fetchers/mysql.d.ts.map +1 -1
  18. package/dist/cjs/fetchers/mysql.js +3 -2
  19. package/dist/cjs/fetchers/mysql.js.map +1 -1
  20. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  21. package/dist/cjs/fetchers/postgresql.js +4 -3
  22. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  23. package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
  24. package/dist/cjs/fetchers/redshift.js +3 -2
  25. package/dist/cjs/fetchers/redshift.js.map +1 -1
  26. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  27. package/dist/cjs/fetchers/rest-api.js +3 -2
  28. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  29. package/dist/cjs/fetchers/supabase.d.ts.map +1 -1
  30. package/dist/cjs/fetchers/supabase.js +3 -2
  31. package/dist/cjs/fetchers/supabase.js.map +1 -1
  32. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  33. package/dist/cjs/fetchers/teleport.js +5 -4
  34. package/dist/cjs/fetchers/teleport.js.map +1 -1
  35. package/dist/cjs/fetchers/turso.d.ts.map +1 -1
  36. package/dist/cjs/fetchers/turso.js +2 -1
  37. package/dist/cjs/fetchers/turso.js.map +1 -1
  38. package/dist/cjs/search-relevance.d.ts +81 -0
  39. package/dist/cjs/search-relevance.d.ts.map +1 -0
  40. package/dist/cjs/search-relevance.js +119 -0
  41. package/dist/cjs/search-relevance.js.map +1 -0
  42. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  43. package/dist/esm/fetchers/csv-file.d.ts.map +1 -1
  44. package/dist/esm/fetchers/csv-file.js +4 -3
  45. package/dist/esm/fetchers/csv-file.js.map +1 -1
  46. package/dist/esm/fetchers/google-sheets.d.ts.map +1 -1
  47. package/dist/esm/fetchers/google-sheets.js +2 -1
  48. package/dist/esm/fetchers/google-sheets.js.map +1 -1
  49. package/dist/esm/fetchers/javascript.d.ts.map +1 -1
  50. package/dist/esm/fetchers/javascript.js +2 -1
  51. package/dist/esm/fetchers/javascript.js.map +1 -1
  52. package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
  53. package/dist/esm/fetchers/mariadb.js +4 -3
  54. package/dist/esm/fetchers/mariadb.js.map +1 -1
  55. package/dist/esm/fetchers/mysql.d.ts.map +1 -1
  56. package/dist/esm/fetchers/mysql.js +3 -2
  57. package/dist/esm/fetchers/mysql.js.map +1 -1
  58. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  59. package/dist/esm/fetchers/postgresql.js +4 -3
  60. package/dist/esm/fetchers/postgresql.js.map +1 -1
  61. package/dist/esm/fetchers/redshift.d.ts.map +1 -1
  62. package/dist/esm/fetchers/redshift.js +3 -2
  63. package/dist/esm/fetchers/redshift.js.map +1 -1
  64. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  65. package/dist/esm/fetchers/rest-api.js +3 -2
  66. package/dist/esm/fetchers/rest-api.js.map +1 -1
  67. package/dist/esm/fetchers/supabase.d.ts.map +1 -1
  68. package/dist/esm/fetchers/supabase.js +3 -2
  69. package/dist/esm/fetchers/supabase.js.map +1 -1
  70. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  71. package/dist/esm/fetchers/teleport.js +5 -4
  72. package/dist/esm/fetchers/teleport.js.map +1 -1
  73. package/dist/esm/fetchers/turso.d.ts.map +1 -1
  74. package/dist/esm/fetchers/turso.js +2 -1
  75. package/dist/esm/fetchers/turso.js.map +1 -1
  76. package/dist/esm/search-relevance.d.ts +81 -0
  77. package/dist/esm/search-relevance.d.ts.map +1 -0
  78. package/dist/esm/search-relevance.js +113 -0
  79. package/dist/esm/search-relevance.js.map +1 -0
  80. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  81. package/package.json +2 -2
  82. package/src/fetchers/csv-file.ts +17 -11
  83. package/src/fetchers/google-sheets.ts +15 -12
  84. package/src/fetchers/javascript.ts +12 -12
  85. package/src/fetchers/mariadb.ts +21 -26
  86. package/src/fetchers/mysql.ts +21 -26
  87. package/src/fetchers/postgresql.ts +31 -26
  88. package/src/fetchers/redshift.ts +18 -17
  89. package/src/fetchers/rest-api.ts +11 -11
  90. package/src/fetchers/supabase.ts +27 -20
  91. package/src/fetchers/teleport.ts +31 -26
  92. package/src/fetchers/turso.ts +18 -17
  93. package/src/search-relevance.ts +230 -0
@@ -5,6 +5,7 @@ import {
5
5
  generateFilterTreeHelpersCode,
6
6
  generateSearchEscapeHelpersCode,
7
7
  } from '../utils'
8
+ import { generateSearchSqlHelpersCode, MYSQL_SEARCH_SQL } from '../search-relevance'
8
9
 
9
10
  interface MySQLConfig {
10
11
  host?: string
@@ -68,6 +69,7 @@ ${generateSafeJSONParseCode()}
68
69
  ${generateFilterTreeHelpersCode()}
69
70
 
70
71
  ${generateSearchEscapeHelpersCode()}
72
+ ${generateSearchSqlHelpersCode(MYSQL_SEARCH_SQL)}
71
73
 
72
74
  // Helper function to process filters and build conditions
73
75
  const processFilters = (filters, conditions, queryParams) => {
@@ -119,6 +121,7 @@ export default async function handler(req, res) {
119
121
 
120
122
  const conditions = []
121
123
  const queryParams = []
124
+ let search = null
122
125
 
123
126
  if (query) {
124
127
  let columns = []
@@ -144,14 +147,10 @@ export default async function handler(req, res) {
144
147
  }
145
148
  }
146
149
 
147
- if (columns.length > 0) {
148
- const pattern = "%" + escapeLikePattern(query) + "%"
149
- const searchConditions = columns.map(
150
- (col) =>
151
- "LOWER(CAST(" + mysql.escapeId(sanitizeSearchIdentifier(col)) + " AS CHAR)) LIKE LOWER(?) ESCAPE '|'"
152
- )
153
- columns.forEach(() => queryParams.push(pattern))
154
- conditions.push("(" + searchConditions.join(" OR ") + ")")
150
+ search = buildSearchSql(query, columns)
151
+ if (search) {
152
+ conditions.push(search.where)
153
+ queryParams.push(...search.params)
155
154
  }
156
155
  }
157
156
 
@@ -164,22 +163,24 @@ export default async function handler(req, res) {
164
163
  sql += \` WHERE \${conditions.join(' AND ')}\`
165
164
  }
166
165
 
167
- // Handle sorts - new array format
166
+ // Handle sorts - new array format. A search orders its matches by where
167
+ // the words are first; the sort orders the rows within each of those groups.
168
+ const orderClauses = search && search.rank ? [search.rank] : []
168
169
  if (sorts) {
169
170
  const parsedSorts = safeJSONParse(sorts)
170
171
  if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {
171
- const orderClauses = parsedSorts.map((sort) => {
172
+ orderClauses.push(...parsedSorts.map((sort) => {
172
173
  if (!sort.field) return null
173
174
  const order = (sort.order || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'
174
175
  return \`\${mysql.escapeId(sort.field)} \${order}\`
175
- }).filter(Boolean)
176
-
177
- if (orderClauses.length > 0) {
178
- sql += \` ORDER BY \${orderClauses.join(', ')}\`
179
- }
176
+ }).filter(Boolean))
180
177
  }
181
178
  } else if (sortBy) {
182
- sql += \` ORDER BY \${mysql.escapeId(sortBy)} \${(sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'}\`
179
+ orderClauses.push(\`\${mysql.escapeId(sortBy)} \${(sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'}\`)
180
+ }
181
+ if (orderClauses.length > 0) {
182
+ sql += \` ORDER BY \${orderClauses.join(', ')}\`
183
+ if (search && search.rank) queryParams.push(...search.rankParams)
183
184
  }
184
185
 
185
186
  const limitValue = limit || perPage
@@ -263,16 +264,10 @@ async function getCount(req, res) {
263
264
  }
264
265
  }
265
266
 
266
- if (columns.length > 0) {
267
- const pattern = "%" + escapeLikePattern(query) + "%"
268
- const searchConditions = columns
269
- .map(
270
- (col) =>
271
- "LOWER(CAST(" + mysql.escapeId(sanitizeSearchIdentifier(col)) + " AS CHAR)) LIKE LOWER(?) ESCAPE '|'"
272
- )
273
- .join(" OR ")
274
- conditions.push("(" + searchConditions + ")")
275
- columns.forEach(() => queryParams.push(pattern))
267
+ const search = buildSearchSql(query, columns)
268
+ if (search) {
269
+ conditions.push(search.where)
270
+ queryParams.push(...search.params)
276
271
  }
277
272
  }
278
273
 
@@ -10,6 +10,7 @@ import { SORT_NULLS_LAST_SQL } from '../list-sort-order'
10
10
  import { generateRowOrderTiebreakHelper, rowIdentityColumn } from '../row-order'
11
11
  import { generateProductFilterClauseHelper } from '../product-filter-fields'
12
12
  import { generateReadGuardCall, generateTableAccessPreamble } from './utils/table-access-guard'
13
+ import { generateSearchSqlHelpersCode, PG_SEARCH_SQL } from '../search-relevance'
13
14
  import { generateRequestSqlGuardsCode } from './utils/request-sql-guards'
14
15
  import { generateArrayOverlapSqlCode } from '../array-overlap-sql'
15
16
  import { PG_CLIENT_IMPORT } from './pg-client-import'
@@ -72,6 +73,7 @@ ${generateSafeJSONParseCode()}
72
73
  ${generateFilterTreeHelpersCode()}
73
74
 
74
75
  ${generateSearchEscapeHelpersCode()}
76
+ ${generateSearchSqlHelpersCode(PG_SEARCH_SQL)}
75
77
  ${generateArrayOverlapSqlCode()}
76
78
 
77
79
  // Builds one SQL clause for the whole filter tree, so an OR group nested in
@@ -173,6 +175,8 @@ ${generateReadGuardCall(tableName, null)}
173
175
  const conditions = []
174
176
  const queryParams = []
175
177
  let paramIndex = 1
178
+ let searchRank = ''
179
+ let searchFrom = ''
176
180
 
177
181
  if (query) {
178
182
  let columns = []
@@ -205,15 +209,13 @@ ${generateReadGuardCall(tableName, null)}
205
209
  }
206
210
  if (__rowPolicy) columns = __brpVisibleColumns(__rowPolicy, columns)
207
211
 
208
- if (columns.length > 0) {
209
- const pattern = '%' + escapeLikePattern(query) + '%'
210
- const placeholder = '$' + paramIndex
211
- paramIndex++
212
- queryParams.push(pattern)
213
- const searchConditions = columns.map(
214
- (col) => '"' + sanitizeSearchIdentifier(col) + '"::text ILIKE ' + placeholder + " ESCAPE '|'"
215
- )
216
- conditions.push('(' + searchConditions.join(' OR ') + ')')
212
+ const search = buildSearchSql(query, columns, paramIndex)
213
+ if (search) {
214
+ conditions.push(search.where)
215
+ queryParams.push(...search.params)
216
+ paramIndex = search.nextIndex
217
+ searchRank = search.rank
218
+ searchFrom = search.from
217
219
  }
218
220
  }
219
221
 
@@ -221,7 +223,8 @@ ${generateReadGuardCall(tableName, null)}
221
223
  paramIndex = processFilters(filters, conditions, queryParams, paramIndex)
222
224
  if (__rowPolicy) conditions.push('(' + __rowPolicy.predicate + ')')
223
225
 
224
- let sql = \`SELECT * FROM "${tableName}"\`
226
+ // A search's worked-out texts stay out of the rows: only the table's own columns.
227
+ let sql = \`SELECT \${searchFrom ? '"${tableName}".*' : '*'} FROM "${tableName}"\${searchFrom}\`
225
228
 
226
229
  if (conditions.length > 0) {
227
230
  sql += \` WHERE \${conditions.join(' AND ')}\`
@@ -230,8 +233,11 @@ ${generateReadGuardCall(tableName, null)}
230
233
  // Handle sorts - new array format. Two ORDER BY clauses are built: the one
231
234
  // actually used (the products table's price rewrites, rows without a value
232
235
  // last), and a plain-column twin kept only as the fallback below.
236
+ // A search orders its matches by where the words are first; the sort
237
+ // orders the rows within each of those groups.
233
238
  let orderBySql = ''
234
239
  let plainOrderBySql = ''
240
+ const searchOrder = searchRank ? searchRank + ', ' : ''
235
241
  if (sorts) {
236
242
  const parsedSorts = safeJSONParse(sorts)
237
243
  if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {
@@ -245,16 +251,19 @@ ${generateReadGuardCall(tableName, null)}
245
251
  // The row's own key last, so equal rows can't repeat across pages
246
252
  // or vanish between them.
247
253
  const tiebreak = rowOrderTiebreak(valid.map((sort) => sort.field), orderOf(valid[valid.length - 1]))
248
- orderBySql = \` ORDER BY \${orderClauses.join(', ')}\${tiebreak}\`
249
- plainOrderBySql = \` ORDER BY \${plainClauses.join(', ')}\${tiebreak}\`
254
+ orderBySql = \` ORDER BY \${searchOrder}\${orderClauses.join(', ')}\${tiebreak}\`
255
+ plainOrderBySql = \` ORDER BY \${searchOrder}\${plainClauses.join(', ')}\${tiebreak}\`
250
256
  }
251
257
  }
252
258
  } else if (sortBy) {
253
259
  assertRequestColumn(sortBy, 'sort field')
254
260
  const sortByDirection = (sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'
255
261
  const tiebreak = rowOrderTiebreak([sortBy], sortByDirection)
256
- orderBySql = \` ORDER BY \${sortBy} \${sortByDirection}${SORT_NULLS_LAST_SQL}\${tiebreak}\`
257
- plainOrderBySql = \` ORDER BY \${sortBy} \${sortByDirection}\${tiebreak}\`
262
+ orderBySql = \` ORDER BY \${searchOrder}\${sortBy} \${sortByDirection}${SORT_NULLS_LAST_SQL}\${tiebreak}\`
263
+ plainOrderBySql = \` ORDER BY \${searchOrder}\${sortBy} \${sortByDirection}\${tiebreak}\`
264
+ }
265
+ if (!orderBySql && searchRank) {
266
+ orderBySql = plainOrderBySql = ' ORDER BY ' + searchRank + rowOrderTiebreak([], 'ASC')
258
267
  }
259
268
  const usedRewrittenSort = orderBySql !== plainOrderBySql
260
269
 
@@ -346,6 +355,7 @@ ${generateReadGuardCall(tableName, null)}
346
355
  const conditions = []
347
356
  const queryParams = []
348
357
  let paramIndex = 1
358
+ let searchFrom = ''
349
359
 
350
360
  if (query) {
351
361
  let columns = []
@@ -379,17 +389,12 @@ ${generateReadGuardCall(tableName, null)}
379
389
  }
380
390
  if (__rowPolicy) columns = __brpVisibleColumns(__rowPolicy, columns)
381
391
 
382
- if (columns.length > 0) {
383
- const pattern = '%' + escapeLikePattern(query) + '%'
384
- const placeholder = '$' + paramIndex
385
- paramIndex++
386
- queryParams.push(pattern)
387
- const searchConditions = columns
388
- .map(
389
- (col) => '"' + sanitizeSearchIdentifier(col) + '"::text ILIKE ' + placeholder + " ESCAPE '|'"
390
- )
391
- .join(' OR ')
392
- conditions.push('(' + searchConditions + ')')
392
+ const search = buildSearchSql(query, columns, paramIndex)
393
+ if (search) {
394
+ conditions.push(search.where)
395
+ queryParams.push(...search.params)
396
+ paramIndex = search.nextIndex
397
+ searchFrom = search.from
393
398
  }
394
399
  }
395
400
 
@@ -397,7 +402,7 @@ ${generateReadGuardCall(tableName, null)}
397
402
  paramIndex = processFilters(filters, conditions, queryParams, paramIndex)
398
403
  if (__rowPolicy) conditions.push('(' + __rowPolicy.predicate + ')')
399
404
 
400
- let countSql = \`SELECT COUNT(*) FROM "${tableName}"\`
405
+ let countSql = \`SELECT COUNT(*) FROM "${tableName}"\${searchFrom}\`
401
406
  if (conditions.length > 0) {
402
407
  countSql += \` WHERE \${conditions.join(' AND ')}\`
403
408
  }
@@ -5,6 +5,7 @@ import {
5
5
  generateFilterTreeHelpersCode,
6
6
  generateSearchEscapeHelpersCode,
7
7
  } from '../utils'
8
+ import { generateSearchSqlHelpersCode, REDSHIFT_SEARCH_SQL } from '../search-relevance'
8
9
 
9
10
  interface RedshiftConfig {
10
11
  host?: string
@@ -60,6 +61,7 @@ ${generateSafeJSONParseCode()}
60
61
  ${generateFilterTreeHelpersCode()}
61
62
 
62
63
  ${generateSearchEscapeHelpersCode()}
64
+ ${generateSearchSqlHelpersCode(REDSHIFT_SEARCH_SQL)}
63
65
 
64
66
  ${generateDateFormatterCode()}
65
67
 
@@ -75,6 +77,7 @@ export default async function handler(req, res) {
75
77
  const conditions = []
76
78
  const queryParams = []
77
79
  let paramIndex = 1
80
+ let search = null
78
81
 
79
82
  if (query) {
80
83
  let columns = []
@@ -100,15 +103,11 @@ export default async function handler(req, res) {
100
103
  }
101
104
  }
102
105
 
103
- if (columns.length > 0) {
104
- const pattern = '%' + escapeLikePattern(query) + '%'
105
- const placeholder = '$' + paramIndex
106
- paramIndex++
107
- queryParams.push(pattern)
108
- const searchConditions = columns.map(
109
- (col) => '"' + sanitizeSearchIdentifier(col) + '"::text ILIKE ' + placeholder + " ESCAPE '|'"
110
- )
111
- conditions.push('(' + searchConditions.join(' OR ') + ')')
106
+ search = buildSearchSql(query, columns, paramIndex)
107
+ if (search) {
108
+ conditions.push(search.where)
109
+ queryParams.push(...search.params)
110
+ paramIndex = search.nextIndex
112
111
  }
113
112
  }
114
113
 
@@ -166,22 +165,24 @@ export default async function handler(req, res) {
166
165
  sql += \` WHERE \${conditions.join(' AND ')}\`
167
166
  }
168
167
 
169
- // Handle sorts - new array format
168
+ // Handle sorts - new array format. A search orders its matches by where
169
+ // the words are first; the sort orders the rows within each of those groups.
170
+ const orderClauses = search && search.rank ? [search.rank] : []
170
171
  if (sorts) {
171
172
  const parsedSorts = safeJSONParse(sorts)
172
173
  if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {
173
- const orderClauses = parsedSorts.map((sort) => {
174
+ orderClauses.push(...parsedSorts.map((sort) => {
174
175
  if (!sort.field) return null
175
176
  const order = (sort.order || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'
176
177
  return \`\${sanitizeIdentifier(sort.field)} \${order}\`
177
- }).filter(Boolean)
178
-
179
- if (orderClauses.length > 0) {
180
- sql += \` ORDER BY \${orderClauses.join(', ')}\`
181
- }
178
+ }).filter(Boolean))
182
179
  }
183
180
  } else if (sortBy) {
184
- sql += \` ORDER BY \${sanitizeIdentifier(sortBy)} \${(sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'}\`
181
+ orderClauses.push(\`\${sanitizeIdentifier(sortBy)} \${(sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'}\`)
182
+ }
183
+ if (orderClauses.length > 0) {
184
+ sql += \` ORDER BY \${orderClauses.join(', ')}\`
185
+ if (search && search.rank) queryParams.push(...search.rankParams)
185
186
  }
186
187
 
187
188
  const limitValue = limit || perPage
@@ -5,6 +5,7 @@ import {
5
5
  generateSafeJSONParseCode,
6
6
  generateFilterTreeHelpersCode,
7
7
  } from '../utils'
8
+ import { generateSearchRankHelperCode } from '../search-relevance'
8
9
  import {
9
10
  RESTAPICacheConfig,
10
11
  generateRESTAPIResponseCacheCode,
@@ -124,6 +125,8 @@ export const generateRESTAPIFetcher = (config: Record<string, unknown>): string
124
125
 
125
126
  ${generateSafeJSONParseCode()}
126
127
 
128
+ ${generateSearchRankHelperCode()}
129
+
127
130
  ${generateDateFormatterCode()}
128
131
 
129
132
  ${generateSortFilterHelperCode()}
@@ -194,27 +197,20 @@ export default async function handler(req, res) {
194
197
 
195
198
  if (Array.isArray(data)) {
196
199
  if (query && query.trim()) {
197
- const searchTerms = query.toLowerCase().split(' ').filter(Boolean)
198
-
199
200
  if (queryColumns) {
200
201
  try {
201
202
  const parsed = safeJSONParse(queryColumns)
202
203
  const columns = Array.isArray(parsed) ? parsed : [parsed]
203
- data = data.filter((item) => {
204
- return searchTerms.every((term) => columns.some((col) => {
205
- const value = getNestedValue(item, col)
206
- if (value === null || value === undefined) return false
207
- return String(value).toLowerCase().includes(term)
208
- }))
209
- })
204
+ data = data.filter((item) => matchesSearch(item, query, columns, getNestedValue))
210
205
  } catch (err) {
211
206
  console.error('Error parsing queryColumns:', err)
212
207
  }
213
208
  } else {
209
+ const terms = searchTerms(query)
214
210
  data = data.filter((item) => {
215
211
  try {
216
- const stringified = JSON.stringify(item).toLowerCase()
217
- return searchTerms.every((term) => stringified.includes(term))
212
+ const text = searchText(item)
213
+ return terms.every((term) => text.indexOf(term) !== -1)
218
214
  } catch {
219
215
  return false
220
216
  }
@@ -310,6 +306,10 @@ export default async function handler(req, res) {
310
306
  })
311
307
  }
312
308
 
309
+ if (query && queryColumns) {
310
+ data = rankBySearch(data, query, safeJSONParse(queryColumns), getNestedValue)
311
+ }
312
+
313
313
  const limitValue = limit || perPage
314
314
  const pageValue = page ? Math.max(1, parseInt(page)) : undefined
315
315
  const offsetValue = offset !== undefined ? Math.max(0, parseInt(offset)) : (pageValue && perPage ? (pageValue - 1) * Math.max(1, parseInt(perPage)) : 0)
@@ -5,6 +5,7 @@ import {
5
5
  generateSearchEscapeHelpersCode,
6
6
  generateFilterTreeHelpersCode,
7
7
  } from '../utils'
8
+ import { generateSearchTermsCode } from '../search-relevance'
8
9
 
9
10
  export const validateSupabaseConfig = (
10
11
  config: Record<string, unknown>
@@ -131,6 +132,7 @@ ${generateSafeJSONParseCode()}
131
132
  ${generateFilterTreeHelpersCode()}
132
133
 
133
134
  ${generateSearchEscapeHelpersCode()}
135
+ ${generateSearchTermsCode(false)}
134
136
 
135
137
  // Helper function to process filter values
136
138
  const processFilterValue = (value) => {
@@ -354,16 +356,19 @@ export default async function handler(req, res) {
354
356
  // shared identifier sanitizer to reject injection. Note:
355
357
  // PostgREST .ilike. does not expose a LIKE ESCAPE clause, so
356
358
  // raw % / _ in the user input still act as wildcards in this
357
- // backend; the canvas preview path escapes them explicitly.
358
- const rawPattern = "%" + String(query) + "%"
359
- const escapedForPostgrest = rawPattern
360
- .replace(/\\\\/g, "\\\\\\\\")
361
- .replace(/"/g, '\\\\"')
362
- const searchPattern = '"' + escapedForPostgrest + '"'
363
- const orConditions = columns
364
- .map((col) => sanitizeSearchIdentifier(col) + ".ilike." + searchPattern)
365
- .join(",")
366
- queryRef = queryRef.or(orConditions)
359
+ // backend; the canvas preview path escapes them explicitly. Each
360
+ // typed word gets its own .or(), and PostgREST ANDs them.
361
+ searchTerms(query).forEach((word) => {
362
+ const rawPattern = "%" + word + "%"
363
+ const escapedForPostgrest = rawPattern
364
+ .replace(/\\\\/g, "\\\\\\\\")
365
+ .replace(/"/g, '\\\\"')
366
+ const searchPattern = '"' + escapedForPostgrest + '"'
367
+ const orConditions = columns
368
+ .map((col) => sanitizeSearchIdentifier(col) + ".ilike." + searchPattern)
369
+ .join(",")
370
+ queryRef = queryRef.or(orConditions)
371
+ })
367
372
  }
368
373
  }
369
374
 
@@ -478,16 +483,18 @@ async function getCount(req, res) {
478
483
  if (columns.length > 0) {
479
484
  // Mirror the fetch handler: sanitize identifiers, wrap the
480
485
  // pattern in double quotes for PostgREST .or() DSL, and escape
481
- // backslashes / quotes in the user's search term.
482
- const rawPattern = "%" + String(query) + "%"
483
- const escapedForPostgrest = rawPattern
484
- .replace(/\\\\/g, "\\\\\\\\")
485
- .replace(/"/g, '\\\\"')
486
- const searchPattern = '"' + escapedForPostgrest + '"'
487
- const orConditions = columns
488
- .map((col) => sanitizeSearchIdentifier(col) + ".ilike." + searchPattern)
489
- .join(",")
490
- countQuery = countQuery.or(orConditions)
486
+ // backslashes / quotes in the user's search term, one .or() per word.
487
+ searchTerms(query).forEach((word) => {
488
+ const rawPattern = "%" + word + "%"
489
+ const escapedForPostgrest = rawPattern
490
+ .replace(/\\\\/g, "\\\\\\\\")
491
+ .replace(/"/g, '\\\\"')
492
+ const searchPattern = '"' + escapedForPostgrest + '"'
493
+ const orConditions = columns
494
+ .map((col) => sanitizeSearchIdentifier(col) + ".ilike." + searchPattern)
495
+ .join(",")
496
+ countQuery = countQuery.or(orConditions)
497
+ })
491
498
  }
492
499
  }
493
500
 
@@ -20,6 +20,7 @@ import { REQUEST_LOCALE_PARAM } from '../request-locale'
20
20
  import { generateReadGuardCall, generateTableAccessPreamble } from './utils/table-access-guard'
21
21
  import { generateRequestSqlGuardsCode } from './utils/request-sql-guards'
22
22
  import { generateArrayOverlapSqlCode } from '../array-overlap-sql'
23
+ import { generateSearchSqlHelpersCode, PG_SEARCH_SQL } from '../search-relevance'
23
24
  import { PG_CLIENT_IMPORT } from './pg-client-import'
24
25
 
25
26
  interface TeleportDBConfig {
@@ -162,6 +163,7 @@ ${generateSafeJSONParseCode()}
162
163
  ${generateFilterTreeHelpersCode()}
163
164
 
164
165
  ${generateSearchEscapeHelpersCode()}
166
+ ${generateSearchSqlHelpersCode(PG_SEARCH_SQL)}
165
167
  ${generateArrayOverlapSqlCode()}
166
168
  ${getTransformationCode(tableName, transformOptions)}
167
169
  ${getTransformWrapperCode(tableName, transformOptions)}
@@ -346,6 +348,8 @@ ${generateReadGuardCall(tableName, 'req.query && req.query.rawQuery')}
346
348
  const conditions = []
347
349
  const queryParams = []
348
350
  let paramIndex = 1
351
+ let searchRank = ''
352
+ let searchFrom = ''
349
353
 
350
354
  if (query) {
351
355
  let columns = []
@@ -376,22 +380,21 @@ ${generateReadGuardCall(tableName, 'req.query && req.query.rawQuery')}
376
380
  }
377
381
  if (__rowPolicy) columns = __brpVisibleColumns(__rowPolicy, columns)
378
382
 
379
- if (columns.length > 0) {
380
- const pattern = '%' + escapeLikePattern(query) + '%'
381
- const placeholder = '$' + paramIndex
382
- paramIndex++
383
- queryParams.push(pattern)
384
- const searchConditions = columns.map(
385
- (col) => '"' + sanitizeSearchIdentifier(col) + '"::text ILIKE ' + placeholder + " ESCAPE '|'"
386
- )
387
- conditions.push('(' + searchConditions.join(' OR ') + ')')
383
+ const search = buildSearchSql(query, columns, paramIndex)
384
+ if (search) {
385
+ conditions.push(search.where)
386
+ queryParams.push(...search.params)
387
+ paramIndex = search.nextIndex
388
+ searchRank = search.rank
389
+ searchFrom = search.from
388
390
  }
389
391
  }
390
392
 
391
393
  paramIndex = processFilters(filters, conditions, queryParams, paramIndex)
392
394
  if (__rowPolicy) conditions.push('(' + __rowPolicy.predicate + ')')
393
395
 
394
- let sql = \`SELECT * FROM ${tableName}\`
396
+ // A search's worked-out texts stay out of the rows: only the table's own columns.
397
+ let sql = \`SELECT \${searchFrom ? '${tableName}.*' : '*'} FROM ${tableName}\${searchFrom}\`
395
398
 
396
399
  if (conditions.length > 0) {
397
400
  sql += \` WHERE \${conditions.join(' AND ')}\`
@@ -401,8 +404,11 @@ ${generateReadGuardCall(tableName, 'req.query && req.query.rawQuery')}
401
404
  // actually used (a translatable column in the request's language, the
402
405
  // products table's price rewrites, rows without a value last), and a
403
406
  // plain-column twin kept only as the fallback below.
407
+ // A search orders its matches by where the words are first; the sort
408
+ // orders the rows within each of those groups.
404
409
  let orderBySql = ''
405
410
  let plainOrderBySql = ''
411
+ const searchOrder = searchRank ? searchRank + ', ' : ''
406
412
  if (sorts) {
407
413
  const parsedSorts = safeJSONParse(sorts)
408
414
  if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {
@@ -416,16 +422,19 @@ ${generateReadGuardCall(tableName, 'req.query && req.query.rawQuery')}
416
422
  // The row's own key last, so equal rows can't repeat across pages
417
423
  // or vanish between them.
418
424
  const tiebreak = rowOrderTiebreak(valid.map((sort) => sort.field), orderOf(valid[valid.length - 1]))
419
- orderBySql = \` ORDER BY \${orderClauses.join(', ')}\${tiebreak}\`
420
- plainOrderBySql = \` ORDER BY \${plainClauses.join(', ')}\${tiebreak}\`
425
+ orderBySql = \` ORDER BY \${searchOrder}\${orderClauses.join(', ')}\${tiebreak}\`
426
+ plainOrderBySql = \` ORDER BY \${searchOrder}\${plainClauses.join(', ')}\${tiebreak}\`
421
427
  }
422
428
  }
423
429
  } else if (sortBy) {
424
430
  assertRequestColumn(sortBy, 'sort field')
425
431
  const sortByDirection = (sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'
426
432
  const tiebreak = rowOrderTiebreak([sortBy], sortByDirection)
427
- orderBySql = \` ORDER BY \${localizedSortFieldSql(sortBy, requestLocale) || sortBy} \${sortByDirection}${SORT_NULLS_LAST_SQL}\${tiebreak}\`
428
- plainOrderBySql = \` ORDER BY \${sortBy} \${sortByDirection}\${tiebreak}\`
433
+ orderBySql = \` ORDER BY \${searchOrder}\${localizedSortFieldSql(sortBy, requestLocale) || sortBy} \${sortByDirection}${SORT_NULLS_LAST_SQL}\${tiebreak}\`
434
+ plainOrderBySql = \` ORDER BY \${searchOrder}\${sortBy} \${sortByDirection}\${tiebreak}\`
435
+ }
436
+ if (!orderBySql && searchRank) {
437
+ orderBySql = plainOrderBySql = ' ORDER BY ' + searchRank + rowOrderTiebreak([], 'ASC')
429
438
  }
430
439
  const usedRewrittenSort = orderBySql !== plainOrderBySql
431
440
 
@@ -536,6 +545,7 @@ ${generateReadGuardCall(tableName, null)}
536
545
  const conditions = []
537
546
  const queryParams = []
538
547
  let paramIndex = 1
548
+ let searchFrom = ''
539
549
 
540
550
  if (query) {
541
551
  let columns = []
@@ -566,24 +576,19 @@ ${generateReadGuardCall(tableName, null)}
566
576
  }
567
577
  if (__rowPolicy) columns = __brpVisibleColumns(__rowPolicy, columns)
568
578
 
569
- if (columns.length > 0) {
570
- const pattern = '%' + escapeLikePattern(query) + '%'
571
- const placeholder = '$' + paramIndex
572
- paramIndex++
573
- queryParams.push(pattern)
574
- const searchConditions = columns
575
- .map(
576
- (col) => '"' + sanitizeSearchIdentifier(col) + '"::text ILIKE ' + placeholder + " ESCAPE '|'"
577
- )
578
- .join(' OR ')
579
- conditions.push('(' + searchConditions + ')')
579
+ const search = buildSearchSql(query, columns, paramIndex)
580
+ if (search) {
581
+ conditions.push(search.where)
582
+ queryParams.push(...search.params)
583
+ paramIndex = search.nextIndex
584
+ searchFrom = search.from
580
585
  }
581
586
  }
582
587
 
583
588
  paramIndex = processFilters(filters, conditions, queryParams, paramIndex)
584
589
  if (__rowPolicy) conditions.push('(' + __rowPolicy.predicate + ')')
585
590
 
586
- let countSql = \`SELECT COUNT(*) FROM ${tableName}\`
591
+ let countSql = \`SELECT COUNT(*) FROM ${tableName}\${searchFrom}\`
587
592
  if (conditions.length > 0) {
588
593
  countSql += \` WHERE \${conditions.join(' AND ')}\`
589
594
  }
@@ -5,6 +5,7 @@ import {
5
5
  generateFilterTreeHelpersCode,
6
6
  generateSearchEscapeHelpersCode,
7
7
  } from '../utils'
8
+ import { generateSearchSqlHelpersCode, SQLITE_SEARCH_SQL } from '../search-relevance'
8
9
 
9
10
  export const validateTursoConfig = (
10
11
  config: Record<string, unknown>
@@ -46,6 +47,7 @@ ${generateSafeJSONParseCode()}
46
47
  ${generateFilterTreeHelpersCode()}
47
48
 
48
49
  ${generateSearchEscapeHelpersCode()}
50
+ ${generateSearchSqlHelpersCode(SQLITE_SEARCH_SQL)}
49
51
 
50
52
  ${generateDateFormatterCode()}
51
53
 
@@ -63,6 +65,7 @@ export default async function handler(req, res) {
63
65
  const whereClauses = []
64
66
  const queryParams = []
65
67
  let searchQueryColumns = null
68
+ let search = null
66
69
 
67
70
  if (query) {
68
71
  if (queryColumns) {
@@ -70,15 +73,11 @@ export default async function handler(req, res) {
70
73
  const columns = Array.isArray(parsed) ? parsed : [parsed]
71
74
  // Cast columns to TEXT and LOWER both sides so the match is
72
75
  // case-insensitive regardless of SQLite collation.
73
- const pattern = "%" + escapeLikePattern(query) + "%"
74
- const searchConditions = columns.map(
75
- (col) =>
76
- 'LOWER(CAST("' + sanitizeSearchIdentifier(col) + '" AS TEXT)) LIKE LOWER(?) ESCAPE ' + "'|'"
77
- )
78
- whereClauses.push("(" + searchConditions.join(" OR ") + ")")
79
- columns.forEach(() => {
80
- queryParams.push(pattern)
81
- })
76
+ search = buildSearchSql(query, columns)
77
+ if (search) {
78
+ whereClauses.push(search.where)
79
+ queryParams.push(...search.params)
80
+ }
82
81
  } else {
83
82
  // Store query for post-filtering if columns not specified
84
83
  searchQueryColumns = query
@@ -135,23 +134,25 @@ export default async function handler(req, res) {
135
134
  sql += \` WHERE \${whereClauses.join(' AND ')}\`
136
135
  }
137
136
 
138
- // Handle sorts - new array format
137
+ // Handle sorts - new array format. A search orders its matches by where
138
+ // the words are first; the sort orders the rows within each of those groups.
139
+ const orderClauses = search && search.rank ? [search.rank] : []
139
140
  if (sorts) {
140
141
  const parsedSorts = safeJSONParse(sorts)
141
142
  if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {
142
- const orderClauses = parsedSorts.map((sort) => {
143
+ orderClauses.push(...parsedSorts.map((sort) => {
143
144
  if (!sort.field) return null
144
145
  const order = (sort.order || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'
145
146
  return \`\${sanitizeIdentifier(sort.field)} \${order}\`
146
- }).filter(Boolean)
147
-
148
- if (orderClauses.length > 0) {
149
- sql += \` ORDER BY \${orderClauses.join(', ')}\`
150
- }
147
+ }).filter(Boolean))
151
148
  }
152
149
  } else if (sortBy) {
153
150
  const sortOrderValue = (sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'
154
- sql += \` ORDER BY \${sanitizeIdentifier(sortBy)} \${sortOrderValue}\`
151
+ orderClauses.push(\`\${sanitizeIdentifier(sortBy)} \${sortOrderValue}\`)
152
+ }
153
+ if (orderClauses.length > 0) {
154
+ sql += \` ORDER BY \${orderClauses.join(', ')}\`
155
+ if (search && search.rank) queryParams.push(...search.rankParams)
155
156
  }
156
157
 
157
158
  const limitValue = limit || perPage