@teleporthq/teleport-plugin-next-data-source 0.44.7 → 0.44.12

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 (96) hide show
  1. package/__tests__/items-path.test.ts +3 -2
  2. package/__tests__/search-relevance.test.ts +260 -0
  3. package/dist/cjs/fetchers/csv-file.d.ts.map +1 -1
  4. package/dist/cjs/fetchers/csv-file.js +4 -3
  5. package/dist/cjs/fetchers/csv-file.js.map +1 -1
  6. package/dist/cjs/fetchers/firestore.js +1 -1
  7. package/dist/cjs/fetchers/firestore.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 +3 -2
  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 +4 -3
  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 +40 -0
  39. package/dist/cjs/search-relevance.d.ts.map +1 -0
  40. package/dist/cjs/search-relevance.js +46 -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/firestore.js +1 -1
  47. package/dist/esm/fetchers/firestore.js.map +1 -1
  48. package/dist/esm/fetchers/google-sheets.d.ts.map +1 -1
  49. package/dist/esm/fetchers/google-sheets.js +2 -1
  50. package/dist/esm/fetchers/google-sheets.js.map +1 -1
  51. package/dist/esm/fetchers/javascript.d.ts.map +1 -1
  52. package/dist/esm/fetchers/javascript.js +2 -1
  53. package/dist/esm/fetchers/javascript.js.map +1 -1
  54. package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
  55. package/dist/esm/fetchers/mariadb.js +4 -3
  56. package/dist/esm/fetchers/mariadb.js.map +1 -1
  57. package/dist/esm/fetchers/mysql.d.ts.map +1 -1
  58. package/dist/esm/fetchers/mysql.js +3 -2
  59. package/dist/esm/fetchers/mysql.js.map +1 -1
  60. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  61. package/dist/esm/fetchers/postgresql.js +3 -2
  62. package/dist/esm/fetchers/postgresql.js.map +1 -1
  63. package/dist/esm/fetchers/redshift.d.ts.map +1 -1
  64. package/dist/esm/fetchers/redshift.js +3 -2
  65. package/dist/esm/fetchers/redshift.js.map +1 -1
  66. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  67. package/dist/esm/fetchers/rest-api.js +3 -2
  68. package/dist/esm/fetchers/rest-api.js.map +1 -1
  69. package/dist/esm/fetchers/supabase.d.ts.map +1 -1
  70. package/dist/esm/fetchers/supabase.js +3 -2
  71. package/dist/esm/fetchers/supabase.js.map +1 -1
  72. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  73. package/dist/esm/fetchers/teleport.js +4 -3
  74. package/dist/esm/fetchers/teleport.js.map +1 -1
  75. package/dist/esm/fetchers/turso.d.ts.map +1 -1
  76. package/dist/esm/fetchers/turso.js +2 -1
  77. package/dist/esm/fetchers/turso.js.map +1 -1
  78. package/dist/esm/search-relevance.d.ts +40 -0
  79. package/dist/esm/search-relevance.d.ts.map +1 -0
  80. package/dist/esm/search-relevance.js +40 -0
  81. package/dist/esm/search-relevance.js.map +1 -0
  82. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  83. package/package.json +5 -5
  84. package/src/fetchers/csv-file.ts +16 -5
  85. package/src/fetchers/firestore.ts +2 -2
  86. package/src/fetchers/google-sheets.ts +14 -5
  87. package/src/fetchers/javascript.ts +12 -5
  88. package/src/fetchers/mariadb.ts +21 -26
  89. package/src/fetchers/mysql.ts +21 -26
  90. package/src/fetchers/postgresql.ts +24 -24
  91. package/src/fetchers/redshift.ts +18 -17
  92. package/src/fetchers/rest-api.ts +12 -5
  93. package/src/fetchers/supabase.ts +27 -20
  94. package/src/fetchers/teleport.ts +24 -24
  95. package/src/fetchers/turso.ts +20 -19
  96. package/src/search-relevance.ts +96 -0
@@ -5,6 +5,7 @@ import {
5
5
  generateFilterTreeHelpersCode,
6
6
  generateSearchEscapeHelpersCode,
7
7
  } from '../utils'
8
+ import { generateSearchSqlHelpersCode, MARIADB_SEARCH_MATCH_SQL } from '../search-relevance'
8
9
 
9
10
  interface MariaDBConfig {
10
11
  host?: string
@@ -31,6 +32,7 @@ ${generateSafeJSONParseCode()}
31
32
  ${generateFilterTreeHelpersCode()}
32
33
 
33
34
  ${generateSearchEscapeHelpersCode()}
35
+ ${generateSearchSqlHelpersCode(MARIADB_SEARCH_MATCH_SQL)}
34
36
 
35
37
  // Helper function to process filters and build conditions
36
38
  const processFilters = (filters, conditions, queryParams) => {
@@ -110,6 +112,7 @@ export default async function handler(req, res) {
110
112
 
111
113
  const conditions = []
112
114
  const queryParams = []
115
+ let search = null
113
116
 
114
117
  if (query) {
115
118
  let columns = []
@@ -135,14 +138,10 @@ export default async function handler(req, res) {
135
138
  }
136
139
  }
137
140
 
138
- if (columns.length > 0) {
139
- const pattern = "%" + escapeLikePattern(query) + "%"
140
- const searchConditions = columns.map(
141
- (col) =>
142
- "LOWER(CAST(\`" + sanitizeSearchIdentifier(col) + "\` AS CHAR)) LIKE LOWER(?) ESCAPE '|'"
143
- )
144
- columns.forEach(() => queryParams.push(pattern))
145
- conditions.push("(" + searchConditions.join(" OR ") + ")")
141
+ search = buildSearchSql(query, columns)
142
+ if (search) {
143
+ conditions.push(search.where)
144
+ queryParams.push(...search.params)
146
145
  }
147
146
  }
148
147
 
@@ -155,22 +154,24 @@ export default async function handler(req, res) {
155
154
  sql += \` WHERE \${conditions.join(' AND ')}\`
156
155
  }
157
156
 
158
- // Handle sorts - new array format
157
+ // Handle sorts - new array format. A search orders its matches by where
158
+ // the words are first; the sort orders the rows within each of those groups.
159
+ const orderClauses = search && search.rank ? [search.rank] : []
159
160
  if (sorts) {
160
161
  const parsedSorts = safeJSONParse(sorts)
161
162
  if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {
162
- const orderClauses = parsedSorts.map((sort) => {
163
+ orderClauses.push(...parsedSorts.map((sort) => {
163
164
  if (!sort.field) return null
164
165
  const order = (sort.order || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'
165
166
  return \`\\\`\${sort.field}\\\` \${order}\`
166
- }).filter(Boolean)
167
-
168
- if (orderClauses.length > 0) {
169
- sql += \` ORDER BY \${orderClauses.join(', ')}\`
170
- }
167
+ }).filter(Boolean))
171
168
  }
172
169
  } else if (sortBy) {
173
- sql += \` ORDER BY \\\`\${sortBy}\\\` \${(sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'}\`
170
+ orderClauses.push(\`\\\`\${sortBy}\\\` \${(sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'}\`)
171
+ }
172
+ if (orderClauses.length > 0) {
173
+ sql += \` ORDER BY \${orderClauses.join(', ')}\`
174
+ if (search && search.rank) queryParams.push(...search.rankParams)
174
175
  }
175
176
 
176
177
  const limitValue = limit || perPage
@@ -284,16 +285,10 @@ async function getCount(req, res) {
284
285
  }
285
286
  }
286
287
 
287
- if (columns.length > 0) {
288
- const pattern = "%" + escapeLikePattern(query) + "%"
289
- const searchConditions = columns
290
- .map(
291
- (col) =>
292
- "LOWER(CAST(\`" + sanitizeSearchIdentifier(col) + "\` AS CHAR)) LIKE LOWER(?) ESCAPE '|'"
293
- )
294
- .join(" OR ")
295
- conditions.push("(" + searchConditions + ")")
296
- columns.forEach(() => queryParams.push(pattern))
288
+ const search = buildSearchSql(query, columns)
289
+ if (search) {
290
+ conditions.push(search.where)
291
+ queryParams.push(...search.params)
297
292
  }
298
293
  }
299
294
 
@@ -5,6 +5,7 @@ import {
5
5
  generateFilterTreeHelpersCode,
6
6
  generateSearchEscapeHelpersCode,
7
7
  } from '../utils'
8
+ import { generateSearchSqlHelpersCode, MYSQL_SEARCH_MATCH_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_MATCH_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_MATCH_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_MATCH_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,7 @@ ${generateReadGuardCall(tableName, null)}
173
175
  const conditions = []
174
176
  const queryParams = []
175
177
  let paramIndex = 1
178
+ let searchRank = ''
176
179
 
177
180
  if (query) {
178
181
  let columns = []
@@ -205,15 +208,12 @@ ${generateReadGuardCall(tableName, null)}
205
208
  }
206
209
  if (__rowPolicy) columns = __brpVisibleColumns(__rowPolicy, columns)
207
210
 
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 ') + ')')
211
+ const search = buildSearchSql(query, columns, paramIndex)
212
+ if (search) {
213
+ conditions.push(search.where)
214
+ queryParams.push(...search.params)
215
+ paramIndex = search.nextIndex
216
+ searchRank = search.rank
217
217
  }
218
218
  }
219
219
 
@@ -230,8 +230,11 @@ ${generateReadGuardCall(tableName, null)}
230
230
  // Handle sorts - new array format. Two ORDER BY clauses are built: the one
231
231
  // actually used (the products table's price rewrites, rows without a value
232
232
  // last), and a plain-column twin kept only as the fallback below.
233
+ // A search orders its matches by where the words are first; the sort
234
+ // orders the rows within each of those groups.
233
235
  let orderBySql = ''
234
236
  let plainOrderBySql = ''
237
+ const searchOrder = searchRank ? searchRank + ', ' : ''
235
238
  if (sorts) {
236
239
  const parsedSorts = safeJSONParse(sorts)
237
240
  if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {
@@ -245,16 +248,19 @@ ${generateReadGuardCall(tableName, null)}
245
248
  // The row's own key last, so equal rows can't repeat across pages
246
249
  // or vanish between them.
247
250
  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}\`
251
+ orderBySql = \` ORDER BY \${searchOrder}\${orderClauses.join(', ')}\${tiebreak}\`
252
+ plainOrderBySql = \` ORDER BY \${searchOrder}\${plainClauses.join(', ')}\${tiebreak}\`
250
253
  }
251
254
  }
252
255
  } else if (sortBy) {
253
256
  assertRequestColumn(sortBy, 'sort field')
254
257
  const sortByDirection = (sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'
255
258
  const tiebreak = rowOrderTiebreak([sortBy], sortByDirection)
256
- orderBySql = \` ORDER BY \${sortBy} \${sortByDirection}${SORT_NULLS_LAST_SQL}\${tiebreak}\`
257
- plainOrderBySql = \` ORDER BY \${sortBy} \${sortByDirection}\${tiebreak}\`
259
+ orderBySql = \` ORDER BY \${searchOrder}\${sortBy} \${sortByDirection}${SORT_NULLS_LAST_SQL}\${tiebreak}\`
260
+ plainOrderBySql = \` ORDER BY \${searchOrder}\${sortBy} \${sortByDirection}\${tiebreak}\`
261
+ }
262
+ if (!orderBySql && searchRank) {
263
+ orderBySql = plainOrderBySql = ' ORDER BY ' + searchRank + rowOrderTiebreak([], 'ASC')
258
264
  }
259
265
  const usedRewrittenSort = orderBySql !== plainOrderBySql
260
266
 
@@ -379,17 +385,11 @@ ${generateReadGuardCall(tableName, null)}
379
385
  }
380
386
  if (__rowPolicy) columns = __brpVisibleColumns(__rowPolicy, columns)
381
387
 
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 + ')')
388
+ const search = buildSearchSql(query, columns, paramIndex)
389
+ if (search) {
390
+ conditions.push(search.where)
391
+ queryParams.push(...search.params)
392
+ paramIndex = search.nextIndex
393
393
  }
394
394
  }
395
395
 
@@ -5,6 +5,7 @@ import {
5
5
  generateFilterTreeHelpersCode,
6
6
  generateSearchEscapeHelpersCode,
7
7
  } from '../utils'
8
+ import { generateSearchSqlHelpersCode, PG_SEARCH_MATCH_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(PG_SEARCH_MATCH_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,18 +197,18 @@ export default async function handler(req, res) {
194
197
 
195
198
  if (Array.isArray(data)) {
196
199
  if (query && query.trim()) {
197
- const searchQuery = query.toLowerCase()
200
+ const searchTerms = query.toLowerCase().split(' ').filter(Boolean)
198
201
 
199
202
  if (queryColumns) {
200
203
  try {
201
204
  const parsed = safeJSONParse(queryColumns)
202
205
  const columns = Array.isArray(parsed) ? parsed : [parsed]
203
206
  data = data.filter((item) => {
204
- return columns.some((col) => {
207
+ return searchTerms.every((term) => columns.some((col) => {
205
208
  const value = getNestedValue(item, col)
206
209
  if (value === null || value === undefined) return false
207
- return String(value).toLowerCase().includes(searchQuery)
208
- })
210
+ return String(value).toLowerCase().includes(term)
211
+ }))
209
212
  })
210
213
  } catch (err) {
211
214
  console.error('Error parsing queryColumns:', err)
@@ -214,7 +217,7 @@ export default async function handler(req, res) {
214
217
  data = data.filter((item) => {
215
218
  try {
216
219
  const stringified = JSON.stringify(item).toLowerCase()
217
- return stringified.includes(searchQuery)
220
+ return searchTerms.every((term) => stringified.includes(term))
218
221
  } catch {
219
222
  return false
220
223
  }
@@ -310,6 +313,10 @@ export default async function handler(req, res) {
310
313
  })
311
314
  }
312
315
 
316
+ if (query && queryColumns) {
317
+ data = rankBySearch(data, query, safeJSONParse(queryColumns), getNestedValue)
318
+ }
319
+
313
320
  const limitValue = limit || perPage
314
321
  const pageValue = page ? Math.max(1, parseInt(page)) : undefined
315
322
  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 { generateSearchWordsCode } 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
+ ${generateSearchWordsCode()}
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
+ searchWords(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
+ searchWords(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_MATCH_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_MATCH_SQL)}
165
167
  ${generateArrayOverlapSqlCode()}
166
168
  ${getTransformationCode(tableName, transformOptions)}
167
169
  ${getTransformWrapperCode(tableName, transformOptions)}
@@ -346,6 +348,7 @@ ${generateReadGuardCall(tableName, 'req.query && req.query.rawQuery')}
346
348
  const conditions = []
347
349
  const queryParams = []
348
350
  let paramIndex = 1
351
+ let searchRank = ''
349
352
 
350
353
  if (query) {
351
354
  let columns = []
@@ -376,15 +379,12 @@ ${generateReadGuardCall(tableName, 'req.query && req.query.rawQuery')}
376
379
  }
377
380
  if (__rowPolicy) columns = __brpVisibleColumns(__rowPolicy, columns)
378
381
 
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 ') + ')')
382
+ const search = buildSearchSql(query, columns, paramIndex)
383
+ if (search) {
384
+ conditions.push(search.where)
385
+ queryParams.push(...search.params)
386
+ paramIndex = search.nextIndex
387
+ searchRank = search.rank
388
388
  }
389
389
  }
390
390
 
@@ -401,8 +401,11 @@ ${generateReadGuardCall(tableName, 'req.query && req.query.rawQuery')}
401
401
  // actually used (a translatable column in the request's language, the
402
402
  // products table's price rewrites, rows without a value last), and a
403
403
  // plain-column twin kept only as the fallback below.
404
+ // A search orders its matches by where the words are first; the sort
405
+ // orders the rows within each of those groups.
404
406
  let orderBySql = ''
405
407
  let plainOrderBySql = ''
408
+ const searchOrder = searchRank ? searchRank + ', ' : ''
406
409
  if (sorts) {
407
410
  const parsedSorts = safeJSONParse(sorts)
408
411
  if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {
@@ -416,16 +419,19 @@ ${generateReadGuardCall(tableName, 'req.query && req.query.rawQuery')}
416
419
  // The row's own key last, so equal rows can't repeat across pages
417
420
  // or vanish between them.
418
421
  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}\`
422
+ orderBySql = \` ORDER BY \${searchOrder}\${orderClauses.join(', ')}\${tiebreak}\`
423
+ plainOrderBySql = \` ORDER BY \${searchOrder}\${plainClauses.join(', ')}\${tiebreak}\`
421
424
  }
422
425
  }
423
426
  } else if (sortBy) {
424
427
  assertRequestColumn(sortBy, 'sort field')
425
428
  const sortByDirection = (sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'
426
429
  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}\`
430
+ orderBySql = \` ORDER BY \${searchOrder}\${localizedSortFieldSql(sortBy, requestLocale) || sortBy} \${sortByDirection}${SORT_NULLS_LAST_SQL}\${tiebreak}\`
431
+ plainOrderBySql = \` ORDER BY \${searchOrder}\${sortBy} \${sortByDirection}\${tiebreak}\`
432
+ }
433
+ if (!orderBySql && searchRank) {
434
+ orderBySql = plainOrderBySql = ' ORDER BY ' + searchRank + rowOrderTiebreak([], 'ASC')
429
435
  }
430
436
  const usedRewrittenSort = orderBySql !== plainOrderBySql
431
437
 
@@ -566,17 +572,11 @@ ${generateReadGuardCall(tableName, null)}
566
572
  }
567
573
  if (__rowPolicy) columns = __brpVisibleColumns(__rowPolicy, columns)
568
574
 
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 + ')')
575
+ const search = buildSearchSql(query, columns, paramIndex)
576
+ if (search) {
577
+ conditions.push(search.where)
578
+ queryParams.push(...search.params)
579
+ paramIndex = search.nextIndex
580
580
  }
581
581
  }
582
582