@teleporthq/teleport-plugin-next-data-source 0.44.12 → 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 (81) 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 +199 -52
  5. package/dist/cjs/fetchers/csv-file.d.ts.map +1 -1
  6. package/dist/cjs/fetchers/csv-file.js +1 -1
  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 +1 -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 +1 -1
  13. package/dist/cjs/fetchers/javascript.js.map +1 -1
  14. package/dist/cjs/fetchers/mariadb.js +1 -1
  15. package/dist/cjs/fetchers/mariadb.js.map +1 -1
  16. package/dist/cjs/fetchers/mysql.js +1 -1
  17. package/dist/cjs/fetchers/mysql.js.map +1 -1
  18. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  19. package/dist/cjs/fetchers/postgresql.js +3 -3
  20. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  21. package/dist/cjs/fetchers/redshift.js +1 -1
  22. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  23. package/dist/cjs/fetchers/rest-api.js +1 -1
  24. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  25. package/dist/cjs/fetchers/supabase.js +2 -2
  26. package/dist/cjs/fetchers/supabase.js.map +1 -1
  27. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  28. package/dist/cjs/fetchers/teleport.js +4 -4
  29. package/dist/cjs/fetchers/teleport.js.map +1 -1
  30. package/dist/cjs/fetchers/turso.js +1 -1
  31. package/dist/cjs/fetchers/turso.js.map +1 -1
  32. package/dist/cjs/search-relevance.d.ts +67 -26
  33. package/dist/cjs/search-relevance.d.ts.map +1 -1
  34. package/dist/cjs/search-relevance.js +102 -29
  35. package/dist/cjs/search-relevance.js.map +1 -1
  36. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  37. package/dist/esm/fetchers/csv-file.d.ts.map +1 -1
  38. package/dist/esm/fetchers/csv-file.js +1 -1
  39. package/dist/esm/fetchers/csv-file.js.map +1 -1
  40. package/dist/esm/fetchers/google-sheets.d.ts.map +1 -1
  41. package/dist/esm/fetchers/google-sheets.js +1 -1
  42. package/dist/esm/fetchers/google-sheets.js.map +1 -1
  43. package/dist/esm/fetchers/javascript.d.ts.map +1 -1
  44. package/dist/esm/fetchers/javascript.js +1 -1
  45. package/dist/esm/fetchers/javascript.js.map +1 -1
  46. package/dist/esm/fetchers/mariadb.js +2 -2
  47. package/dist/esm/fetchers/mariadb.js.map +1 -1
  48. package/dist/esm/fetchers/mysql.js +2 -2
  49. package/dist/esm/fetchers/mysql.js.map +1 -1
  50. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  51. package/dist/esm/fetchers/postgresql.js +4 -4
  52. package/dist/esm/fetchers/postgresql.js.map +1 -1
  53. package/dist/esm/fetchers/redshift.js +2 -2
  54. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  55. package/dist/esm/fetchers/rest-api.js +1 -1
  56. package/dist/esm/fetchers/rest-api.js.map +1 -1
  57. package/dist/esm/fetchers/supabase.js +3 -3
  58. package/dist/esm/fetchers/supabase.js.map +1 -1
  59. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  60. package/dist/esm/fetchers/teleport.js +5 -5
  61. package/dist/esm/fetchers/teleport.js.map +1 -1
  62. package/dist/esm/fetchers/turso.js +2 -2
  63. package/dist/esm/fetchers/turso.js.map +1 -1
  64. package/dist/esm/search-relevance.d.ts +67 -26
  65. package/dist/esm/search-relevance.d.ts.map +1 -1
  66. package/dist/esm/search-relevance.js +100 -27
  67. package/dist/esm/search-relevance.js.map +1 -1
  68. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +2 -2
  70. package/src/fetchers/csv-file.ts +6 -11
  71. package/src/fetchers/google-sheets.ts +6 -12
  72. package/src/fetchers/javascript.ts +5 -12
  73. package/src/fetchers/mariadb.ts +2 -2
  74. package/src/fetchers/mysql.ts +2 -2
  75. package/src/fetchers/postgresql.ts +9 -4
  76. package/src/fetchers/redshift.ts +2 -2
  77. package/src/fetchers/rest-api.ts +4 -11
  78. package/src/fetchers/supabase.ts +4 -4
  79. package/src/fetchers/teleport.ts +9 -4
  80. package/src/fetchers/turso.ts +2 -2
  81. package/src/search-relevance.ts +195 -61
@@ -5,7 +5,7 @@ import {
5
5
  generateSearchEscapeHelpersCode,
6
6
  generateFilterTreeHelpersCode,
7
7
  } from '../utils'
8
- import { generateSearchWordsCode } from '../search-relevance'
8
+ import { generateSearchTermsCode } from '../search-relevance'
9
9
 
10
10
  export const validateSupabaseConfig = (
11
11
  config: Record<string, unknown>
@@ -132,7 +132,7 @@ ${generateSafeJSONParseCode()}
132
132
  ${generateFilterTreeHelpersCode()}
133
133
 
134
134
  ${generateSearchEscapeHelpersCode()}
135
- ${generateSearchWordsCode()}
135
+ ${generateSearchTermsCode(false)}
136
136
 
137
137
  // Helper function to process filter values
138
138
  const processFilterValue = (value) => {
@@ -358,7 +358,7 @@ export default async function handler(req, res) {
358
358
  // raw % / _ in the user input still act as wildcards in this
359
359
  // backend; the canvas preview path escapes them explicitly. Each
360
360
  // typed word gets its own .or(), and PostgREST ANDs them.
361
- searchWords(query).forEach((word) => {
361
+ searchTerms(query).forEach((word) => {
362
362
  const rawPattern = "%" + word + "%"
363
363
  const escapedForPostgrest = rawPattern
364
364
  .replace(/\\\\/g, "\\\\\\\\")
@@ -484,7 +484,7 @@ async function getCount(req, res) {
484
484
  // Mirror the fetch handler: sanitize identifiers, wrap the
485
485
  // pattern in double quotes for PostgREST .or() DSL, and escape
486
486
  // backslashes / quotes in the user's search term, one .or() per word.
487
- searchWords(query).forEach((word) => {
487
+ searchTerms(query).forEach((word) => {
488
488
  const rawPattern = "%" + word + "%"
489
489
  const escapedForPostgrest = rawPattern
490
490
  .replace(/\\\\/g, "\\\\\\\\")
@@ -20,7 +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
+ import { generateSearchSqlHelpersCode, PG_SEARCH_SQL } from '../search-relevance'
24
24
  import { PG_CLIENT_IMPORT } from './pg-client-import'
25
25
 
26
26
  interface TeleportDBConfig {
@@ -163,7 +163,7 @@ ${generateSafeJSONParseCode()}
163
163
  ${generateFilterTreeHelpersCode()}
164
164
 
165
165
  ${generateSearchEscapeHelpersCode()}
166
- ${generateSearchSqlHelpersCode(PG_SEARCH_MATCH_SQL)}
166
+ ${generateSearchSqlHelpersCode(PG_SEARCH_SQL)}
167
167
  ${generateArrayOverlapSqlCode()}
168
168
  ${getTransformationCode(tableName, transformOptions)}
169
169
  ${getTransformWrapperCode(tableName, transformOptions)}
@@ -349,6 +349,7 @@ ${generateReadGuardCall(tableName, 'req.query && req.query.rawQuery')}
349
349
  const queryParams = []
350
350
  let paramIndex = 1
351
351
  let searchRank = ''
352
+ let searchFrom = ''
352
353
 
353
354
  if (query) {
354
355
  let columns = []
@@ -385,13 +386,15 @@ ${generateReadGuardCall(tableName, 'req.query && req.query.rawQuery')}
385
386
  queryParams.push(...search.params)
386
387
  paramIndex = search.nextIndex
387
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 ')}\`
@@ -542,6 +545,7 @@ ${generateReadGuardCall(tableName, null)}
542
545
  const conditions = []
543
546
  const queryParams = []
544
547
  let paramIndex = 1
548
+ let searchFrom = ''
545
549
 
546
550
  if (query) {
547
551
  let columns = []
@@ -577,13 +581,14 @@ ${generateReadGuardCall(tableName, null)}
577
581
  conditions.push(search.where)
578
582
  queryParams.push(...search.params)
579
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,7 +5,7 @@ import {
5
5
  generateFilterTreeHelpersCode,
6
6
  generateSearchEscapeHelpersCode,
7
7
  } from '../utils'
8
- import { generateSearchSqlHelpersCode, SQLITE_SEARCH_MATCH_SQL } from '../search-relevance'
8
+ import { generateSearchSqlHelpersCode, SQLITE_SEARCH_SQL } from '../search-relevance'
9
9
 
10
10
  export const validateTursoConfig = (
11
11
  config: Record<string, unknown>
@@ -47,7 +47,7 @@ ${generateSafeJSONParseCode()}
47
47
  ${generateFilterTreeHelpersCode()}
48
48
 
49
49
  ${generateSearchEscapeHelpersCode()}
50
- ${generateSearchSqlHelpersCode(SQLITE_SEARCH_MATCH_SQL)}
50
+ ${generateSearchSqlHelpersCode(SQLITE_SEARCH_SQL)}
51
51
 
52
52
  ${generateDateFormatterCode()}
53
53
 
@@ -1,96 +1,230 @@
1
1
  /**
2
2
  * How a list's search box narrows and orders the rows of a generated route.
3
3
  *
4
- * Every typed word must appear in one of the searched columns, so "create
5
- * form" finds "How to create a form". The matches are then ordered by where
6
- * the words are: a row ranks by the first searched column, in the order the
7
- * list searches them, that holds every word (a title match before a summary
8
- * match before a body match), and a row whose words are spread over several
9
- * columns comes after all of those. The list's own sort orders the rows
10
- * within each of those groups.
4
+ * The typed text is split into terms at white space, letter case aside, and
5
+ * the punctuation around a term is dropped ("form?" looks for "form",
6
+ * "\"create" for "create"). Every term must appear in one of the searched
7
+ * columns, so "create form" finds "How to create a form".
8
+ *
9
+ * Where the stored text is read the same way as the typed text (the
10
+ * TeleportHQ database and other Postgres sources, and the routes that search
11
+ * rows in memory), punctuation inside a term counts as a space on both sides
12
+ * ("e-commerce" finds "E-Commerce", "what's" finds "what’s"), and HTML tags
13
+ * and entities count as spaces too, so a word that is only in a rich-text
14
+ * body's markup ("strong", "href", "amp") never matches.
15
+ *
16
+ * The matches are ordered column by column, in the order the list searches
17
+ * them: by how much of the typed text the first column (a title) holds, then
18
+ * the second (a summary), and so on. A term counts 3 as a whole word, 2 at
19
+ * the start of a word ("form" in "forms") and 1 inside one ("form" in
20
+ * "platform"). The list's own sort orders the rows that score alike.
21
+ */
22
+
23
+ /**
24
+ * Characters a search reads as a space: punctuation, and line breaks and
25
+ * tabs, so a word at the end of a line is still a whole word.
11
26
  */
27
+ export const SEARCH_PUNCTUATION =
28
+ '\t\n\v\f\r!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~\u00a0\u00a1\u00ab\u00b7\u00bb\u00bf' +
29
+ '\u2010\u2011\u2012\u2013\u2014\u2015\u2018\u2019\u201a\u201b\u201c\u201d\u201e\u2022\u2026\u2039\u203a'
30
+
31
+ /** A JavaScript string literal that survives being pasted into any generated file. */
32
+ const jsString = (value: string): string =>
33
+ JSON.stringify(value).replace(
34
+ /[\u007f-\uffff`$]/g,
35
+ (char) => '\\u' + ('0000' + char.charCodeAt(0).toString(16)).slice(-4)
36
+ )
12
37
 
13
- /** `searchWords(query)`: the typed words, lowercased, each once. */
14
- export const generateSearchWordsCode = (): string => `const searchWords = (query) => {
15
- const words = []
16
- String(query == null ? '' : query).toLowerCase().split(/\\s+/).forEach((word) => {
17
- if (word && words.indexOf(word) === -1) words.push(word)
38
+ /**
39
+ * One Postgres regular expression, as a string literal, for everything a
40
+ * normalised search reads as a space: a tag, an entity, or one of
41
+ * SEARCH_PUNCTUATION. The bracket expression puts `]` first and `-` last,
42
+ * escapes the backslash, and puts `[` before `-` so it never opens a class.
43
+ * One pass: `translate()` is several times slower over long multibyte text.
44
+ */
45
+ const PG_SPACES_REGEX_SQL = `'${(
46
+ '<[^>]*>|&[a-z0-9#]+;|[]' +
47
+ SEARCH_PUNCTUATION.split('')
48
+ .filter((char) => !']-\\['.includes(char))
49
+ .join('') +
50
+ '\\\\[-]'
51
+ ).replace(/'/g, "''")}'`
52
+
53
+ /**
54
+ * `searchTerms(query)`: the typed terms, lowercased, each once. `normalised`
55
+ * also reads the punctuation inside a term as a space, for the routes whose
56
+ * stored text is read the same way; otherwise only the punctuation around a
57
+ * term is dropped, so "e-commerce" still finds the raw text "e-commerce".
58
+ */
59
+ export const generateSearchTermsCode = (
60
+ normalised: boolean
61
+ ): string => `const SEARCH_PUNCTUATION = ${jsString(SEARCH_PUNCTUATION)}
62
+ const searchPunctuationToSpaces = (text) => {
63
+ let out = ''
64
+ for (const char of text) out += SEARCH_PUNCTUATION.indexOf(char) === -1 ? char : ' '
65
+ return out
66
+ }
67
+ const searchTerms = (query) => {
68
+ const terms = []
69
+ String(query == null ? '' : query).toLowerCase().split(/\\s+/).forEach((token) => {
70
+ const spaced = searchPunctuationToSpaces(token)
71
+ ${
72
+ normalised
73
+ ? ` const term = spaced.trim().replace(/\\s+/g, ' ')`
74
+ : ` const start = spaced.length - spaced.trimStart().length
75
+ const term = token.slice(start, start + spaced.trim().length)`
76
+ }
77
+ if (term && terms.indexOf(term) === -1) terms.push(term)
18
78
  })
19
- return words
79
+ return terms
20
80
  }`
21
81
 
82
+ /**
83
+ * How one SQL dialect reads a column for a search. `textSql` is the source
84
+ * of `(column) => sql`, the column's text as the search reads it: lower case,
85
+ * with a space at each end so a whole word and a word start can be told
86
+ * apart. `likeSql` is the source of `(text, placeholder, before, after) =>
87
+ * sql`, testing `text LIKE before + term + after` with the term bound to the
88
+ * placeholder. `normalised` dialects also read tags, entities and punctuation
89
+ * in the stored text as spaces; `lateral` ones work each column's text out
90
+ * once per row, in a LATERAL subquery the route joins after its table.
91
+ */
92
+ export interface SearchSqlDialect {
93
+ textSql: string
94
+ likeSql: string
95
+ normalised: boolean
96
+ lateral: boolean
97
+ }
98
+
99
+ const PG_LIKE_SQL = `(text, placeholder, before, after) => text + " LIKE ('" + before + "' || " + placeholder + "::text || '" + after + "') ESCAPE '|'"`
100
+
101
+ export const PG_SEARCH_SQL: SearchSqlDialect = {
102
+ textSql: `(column) => "(' ' || regexp_replace(lower(\\"" + column + "\\"::text), " + SEARCH_SPACES_REGEX_SQL + ", ' ', 'g') || ' ')"`,
103
+ likeSql: PG_LIKE_SQL,
104
+ normalised: true,
105
+ lateral: true,
106
+ }
107
+
108
+ export const REDSHIFT_SEARCH_SQL: SearchSqlDialect = {
109
+ textSql: `(column) => "(' ' || lower(\\"" + column + "\\"::text) || ' ')"`,
110
+ likeSql: PG_LIKE_SQL,
111
+ normalised: false,
112
+ lateral: false,
113
+ }
114
+
115
+ const CONCAT_LIKE_SQL = `(text, placeholder, before, after) => text + " LIKE CONCAT('" + before + "', " + placeholder + ", '" + after + "') ESCAPE '|'"`
116
+
117
+ export const MYSQL_SEARCH_SQL: SearchSqlDialect = {
118
+ textSql: `(column) => "CONCAT(' ', LOWER(CAST(" + mysql.escapeId(column) + " AS CHAR)), ' ')"`,
119
+ likeSql: CONCAT_LIKE_SQL,
120
+ normalised: false,
121
+ lateral: false,
122
+ }
123
+
124
+ export const MARIADB_SEARCH_SQL: SearchSqlDialect = {
125
+ textSql: `(column) => "CONCAT(' ', LOWER(CAST(\\\`" + column + "\\\` AS CHAR)), ' ')"`,
126
+ likeSql: CONCAT_LIKE_SQL,
127
+ normalised: false,
128
+ lateral: false,
129
+ }
130
+
131
+ export const SQLITE_SEARCH_SQL: SearchSqlDialect = {
132
+ textSql: `(column) => "(' ' || LOWER(CAST(\\"" + column + "\\" AS TEXT)) || ' ')"`,
133
+ likeSql: `(text, placeholder, before, after) => text + " LIKE ('" + before + "' || " + placeholder + " || '" + after + "') ESCAPE '|'"`,
134
+ normalised: false,
135
+ lateral: false,
136
+ }
137
+
22
138
  /**
23
139
  * `buildSearchSql(query, columns, firstIndex)` for the SQL routes:
24
- * `{ where, params, rank, rankParams, nextIndex }`, or null when there is
25
- * nothing to search. `matchSql` is the dialect's case-insensitive substring
26
- * test, `(column, placeholder) => sql`. With `firstIndex` the placeholders are
27
- * numbered (`$n`) and each word's pattern is bound once, the rank reusing it;
28
- * without it they are positional (`?`), every occurrence binds its own copy,
29
- * and `rankParams` go after the parameters of the whole WHERE, as the ORDER BY
30
- * comes after it. `rank` is '' with fewer than two columns.
140
+ * `{ where, params, from, rank, rankParams, nextIndex }`, or null when there
141
+ * is nothing to search. `from` is the LATERAL join a `lateral` dialect's
142
+ * route appends after its table (selecting `table.*`, so the worked-out
143
+ * texts never reach a row), '' otherwise. `rank` is the ORDER BY keys, one
144
+ * per searched column, best first.
145
+ *
146
+ * With `firstIndex` the placeholders are numbered (`$n`) and each term is
147
+ * bound once, the rank reusing it; without it they are positional (`?`),
148
+ * every occurrence binds its own copy, and `rankParams` go after the
149
+ * parameters of the whole WHERE, as the ORDER BY comes after it.
31
150
  *
32
151
  * Uses `escapeLikePattern` and `sanitizeSearchIdentifier`
33
152
  * (`generateSearchEscapeHelpersCode`).
34
153
  */
35
154
  export const generateSearchSqlHelpersCode = (
36
- matchSql: string
37
- ): string => `${generateSearchWordsCode()}
38
- const searchMatchSql = ${matchSql}
155
+ dialect: SearchSqlDialect
156
+ ): string => `${generateSearchTermsCode(dialect.normalised)}
157
+ ${
158
+ dialect.normalised ? `const SEARCH_SPACES_REGEX_SQL = ${jsString(PG_SPACES_REGEX_SQL)}\n` : ''
159
+ }const searchTextSql = ${dialect.textSql}
160
+ const searchLikeSql = ${dialect.likeSql}
39
161
  const buildSearchSql = (query, columns, firstIndex) => {
40
- const words = searchWords(query)
162
+ const terms = searchTerms(query)
41
163
  const searched = []
42
164
  ;(Array.isArray(columns) ? columns : []).forEach((column) => {
43
165
  const name = sanitizeSearchIdentifier(column)
44
166
  if (searched.indexOf(name) === -1) searched.push(name)
45
167
  })
46
- if (words.length === 0 || searched.length === 0) return null
47
- const patterns = words.map((word) => '%' + escapeLikePattern(word) + '%')
168
+ if (terms.length === 0 || searched.length === 0) return null
169
+ const patterns = terms.map((term) => escapeLikePattern(term))
48
170
  const numbered = typeof firstIndex === 'number'
49
171
  const params = numbered ? patterns.slice() : []
50
172
  const rankParams = []
51
- const placeholder = (wordIndex, bound) => {
52
- if (numbered) return '$' + (firstIndex + wordIndex)
53
- bound.push(patterns[wordIndex])
173
+ const placeholder = (termIndex, bound) => {
174
+ if (numbered) return '$' + (firstIndex + termIndex)
175
+ bound.push(patterns[termIndex])
54
176
  return '?'
55
177
  }
56
- const where = '(' + patterns.map((pattern, wordIndex) =>
57
- '(' + searched.map((column) => searchMatchSql(column, placeholder(wordIndex, params))).join(' OR ') + ')'
178
+ const texts = searched.map((column, position) => ${
179
+ dialect.lateral ? `'"__tq_search"."__tq_search_' + position + '"'` : 'searchTextSql(column)'
180
+ })
181
+ const from = ${
182
+ dialect.lateral
183
+ ? `' CROSS JOIN LATERAL (SELECT ' + searched.map((column, position) => searchTextSql(column) + ' AS "__tq_search_' + position + '"').join(', ') + ' OFFSET 0) AS "__tq_search"'`
184
+ : `''`
185
+ }
186
+ const where = '(' + patterns.map((pattern, termIndex) =>
187
+ '(' + texts.map((text) => searchLikeSql(text, placeholder(termIndex, params), '%', '%')).join(' OR ') + ')'
58
188
  ).join(' AND ') + ')'
59
- const rank = searched.length < 2 ? '' : 'CASE ' + searched.map((column, position) =>
60
- 'WHEN ' + patterns.map((pattern, wordIndex) => searchMatchSql(column, placeholder(wordIndex, rankParams))).join(' AND ') + ' THEN ' + position
61
- ).join(' ') + ' ELSE ' + searched.length + ' END'
62
- return { where, params, rank, rankParams, nextIndex: numbered ? firstIndex + patterns.length : firstIndex }
189
+ const rank = texts.map((text) => '(' + patterns.map((pattern, termIndex) =>
190
+ 'CASE WHEN ' + searchLikeSql(text, placeholder(termIndex, rankParams), '% ', ' %') + ' THEN 3' +
191
+ ' WHEN ' + searchLikeSql(text, placeholder(termIndex, rankParams), '% ', '%') + ' THEN 2' +
192
+ ' WHEN ' + searchLikeSql(text, placeholder(termIndex, rankParams), '%', '%') + ' THEN 1 ELSE 0 END'
193
+ ).join(' + ') + ') DESC').join(', ')
194
+ return { where, params, from, rank, rankParams, nextIndex: numbered ? firstIndex + patterns.length : firstIndex }
63
195
  }`
64
196
 
65
197
  /**
66
- * `rankBySearch(rows, query, columns, valueOf)` for the routes that search
67
- * rows in memory: the same order as the SQL routes' rank, applied after the
68
- * list's sort (a stable sort keeps it within each group) and before the page
69
- * is cut.
198
+ * For the routes that search rows in memory: `matchesSearch(row, query,
199
+ * columns, valueOf)`, true when every term is in one of the columns, and
200
+ * `rankBySearch(rows, query, columns, valueOf)`, the same order as the SQL
201
+ * routes' rank, applied after the list's sort (a stable sort keeps it among
202
+ * rows that score alike) and before the page is cut. Stored text is read
203
+ * the normalised way: tags, entities and punctuation as spaces.
70
204
  */
71
- export const generateSearchRankHelperCode = (): string => `${generateSearchWordsCode()}
205
+ export const generateSearchRankHelperCode = (): string => `${generateSearchTermsCode(true)}
206
+ const searchText = (value) => {
207
+ const text = value === null || value === undefined ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
208
+ return ' ' + searchPunctuationToSpaces(text.toLowerCase().replace(/<[^>]*>|&[a-z0-9#]+;/g, ' ')) + ' '
209
+ }
210
+ const searchScore = (text, terms) => terms.reduce((score, term) =>
211
+ score + (text.indexOf(' ' + term + ' ') !== -1 ? 3 : text.indexOf(' ' + term) !== -1 ? 2 : text.indexOf(term) !== -1 ? 1 : 0), 0)
212
+ const matchesSearch = (row, query, columns, valueOf) => {
213
+ const terms = searchTerms(query)
214
+ if (terms.length === 0) return true
215
+ const texts = (Array.isArray(columns) ? columns : []).map((column) => searchText(valueOf(row, column)))
216
+ return terms.every((term) => texts.some((text) => text.indexOf(term) !== -1))
217
+ }
72
218
  const rankBySearch = (rows, query, columns, valueOf) => {
73
- const words = searchWords(query)
74
- if (!Array.isArray(columns) || columns.length < 2 || words.length === 0) return rows
75
- const tierOf = (row) => {
76
- for (let position = 0; position < columns.length; position++) {
77
- const value = valueOf(row, columns[position])
78
- const text = value === null || value === undefined ? '' : String(value).toLowerCase()
79
- if (words.every((word) => text.includes(word))) return position
80
- }
81
- return columns.length
82
- }
219
+ const terms = searchTerms(query)
220
+ if (!Array.isArray(columns) || columns.length === 0 || terms.length === 0) return rows
83
221
  return rows
84
- .map((row, index) => ({ row, index, tier: tierOf(row) }))
85
- .sort((a, b) => a.tier - b.tier || a.index - b.index)
222
+ .map((row, index) => ({ row, index, scores: columns.map((column) => searchScore(searchText(valueOf(row, column)), terms)) }))
223
+ .sort((a, b) => {
224
+ for (let position = 0; position < a.scores.length; position++) {
225
+ if (a.scores[position] !== b.scores[position]) return b.scores[position] - a.scores[position]
226
+ }
227
+ return a.index - b.index
228
+ })
86
229
  .map((entry) => entry.row)
87
230
  }`
88
-
89
- /** Each dialect's case-insensitive substring match, for `generateSearchSqlHelpersCode`. */
90
- export const PG_SEARCH_MATCH_SQL = `(column, placeholder) => '"' + column + '"::text ILIKE ' + placeholder + " ESCAPE '|'"`
91
-
92
- export const MYSQL_SEARCH_MATCH_SQL = `(column, placeholder) => 'LOWER(CAST(' + mysql.escapeId(column) + ' AS CHAR)) LIKE LOWER(' + placeholder + ") ESCAPE '|'"`
93
-
94
- export const MARIADB_SEARCH_MATCH_SQL = `(column, placeholder) => 'LOWER(CAST(\`' + column + '\` AS CHAR)) LIKE LOWER(' + placeholder + ") ESCAPE '|'"`
95
-
96
- export const SQLITE_SEARCH_MATCH_SQL = `(column, placeholder) => 'LOWER(CAST("' + column + '" AS TEXT)) LIKE LOWER(' + placeholder + ") ESCAPE '|'"`