@teleporthq/teleport-plugin-next-data-source 0.44.12 → 0.44.14
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.
- package/__tests__/browser-row-policy.test.ts +6 -6
- package/__tests__/fetchers.test.ts +1 -1
- package/__tests__/items-path.test.ts +3 -2
- package/__tests__/related-items-transform.test.ts +17 -0
- package/__tests__/search-relevance.test.ts +199 -52
- package/dist/cjs/fetchers/csv-file.d.ts.map +1 -1
- package/dist/cjs/fetchers/csv-file.js +1 -1
- package/dist/cjs/fetchers/csv-file.js.map +1 -1
- package/dist/cjs/fetchers/google-sheets.d.ts.map +1 -1
- package/dist/cjs/fetchers/google-sheets.js +1 -1
- package/dist/cjs/fetchers/google-sheets.js.map +1 -1
- package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
- package/dist/cjs/fetchers/javascript.js +1 -1
- package/dist/cjs/fetchers/javascript.js.map +1 -1
- package/dist/cjs/fetchers/mariadb.js +1 -1
- package/dist/cjs/fetchers/mariadb.js.map +1 -1
- package/dist/cjs/fetchers/mysql.js +1 -1
- package/dist/cjs/fetchers/mysql.js.map +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +3 -3
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/redshift.js +1 -1
- package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
- package/dist/cjs/fetchers/rest-api.js +1 -1
- package/dist/cjs/fetchers/rest-api.js.map +1 -1
- package/dist/cjs/fetchers/supabase.js +2 -2
- package/dist/cjs/fetchers/supabase.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +4 -4
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/turso.js +1 -1
- package/dist/cjs/fetchers/turso.js.map +1 -1
- package/dist/cjs/search-relevance.d.ts +67 -26
- package/dist/cjs/search-relevance.d.ts.map +1 -1
- package/dist/cjs/search-relevance.js +102 -29
- package/dist/cjs/search-relevance.js.map +1 -1
- package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
- package/dist/cjs/transformations/blog-post.js +1 -1
- package/dist/cjs/transformations/blog-post.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/fetchers/csv-file.d.ts.map +1 -1
- package/dist/esm/fetchers/csv-file.js +1 -1
- package/dist/esm/fetchers/csv-file.js.map +1 -1
- package/dist/esm/fetchers/google-sheets.d.ts.map +1 -1
- package/dist/esm/fetchers/google-sheets.js +1 -1
- package/dist/esm/fetchers/google-sheets.js.map +1 -1
- package/dist/esm/fetchers/javascript.d.ts.map +1 -1
- package/dist/esm/fetchers/javascript.js +1 -1
- package/dist/esm/fetchers/javascript.js.map +1 -1
- package/dist/esm/fetchers/mariadb.js +2 -2
- package/dist/esm/fetchers/mariadb.js.map +1 -1
- package/dist/esm/fetchers/mysql.js +2 -2
- package/dist/esm/fetchers/mysql.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +4 -4
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/redshift.js +2 -2
- package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
- package/dist/esm/fetchers/rest-api.js +1 -1
- package/dist/esm/fetchers/rest-api.js.map +1 -1
- package/dist/esm/fetchers/supabase.js +3 -3
- package/dist/esm/fetchers/supabase.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +5 -5
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/turso.js +2 -2
- package/dist/esm/fetchers/turso.js.map +1 -1
- package/dist/esm/search-relevance.d.ts +67 -26
- package/dist/esm/search-relevance.d.ts.map +1 -1
- package/dist/esm/search-relevance.js +100 -27
- package/dist/esm/search-relevance.js.map +1 -1
- package/dist/esm/transformations/blog-post.d.ts.map +1 -1
- package/dist/esm/transformations/blog-post.js +1 -1
- package/dist/esm/transformations/blog-post.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/fetchers/csv-file.ts +6 -11
- package/src/fetchers/google-sheets.ts +6 -12
- package/src/fetchers/javascript.ts +5 -12
- package/src/fetchers/mariadb.ts +2 -2
- package/src/fetchers/mysql.ts +2 -2
- package/src/fetchers/postgresql.ts +9 -4
- package/src/fetchers/redshift.ts +2 -2
- package/src/fetchers/rest-api.ts +4 -11
- package/src/fetchers/supabase.ts +4 -4
- package/src/fetchers/teleport.ts +9 -4
- package/src/fetchers/turso.ts +2 -2
- package/src/search-relevance.ts +195 -61
- package/src/transformations/blog-post.ts +5 -1
package/src/fetchers/supabase.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
generateSearchEscapeHelpersCode,
|
|
6
6
|
generateFilterTreeHelpersCode,
|
|
7
7
|
} from '../utils'
|
|
8
|
-
import {
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
-
|
|
487
|
+
searchTerms(query).forEach((word) => {
|
|
488
488
|
const rawPattern = "%" + word + "%"
|
|
489
489
|
const escapedForPostgrest = rawPattern
|
|
490
490
|
.replace(/\\\\/g, "\\\\\\\\")
|
package/src/fetchers/teleport.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/src/fetchers/turso.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
generateFilterTreeHelpersCode,
|
|
6
6
|
generateSearchEscapeHelpersCode,
|
|
7
7
|
} from '../utils'
|
|
8
|
-
import { generateSearchSqlHelpersCode,
|
|
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(
|
|
50
|
+
${generateSearchSqlHelpersCode(SQLITE_SEARCH_SQL)}
|
|
51
51
|
|
|
52
52
|
${generateDateFormatterCode()}
|
|
53
53
|
|
package/src/search-relevance.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
25
|
-
* nothing to search. `
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
37
|
-
): string => `${
|
|
38
|
-
|
|
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
|
|
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 (
|
|
47
|
-
const patterns =
|
|
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 = (
|
|
52
|
-
if (numbered) return '$' + (firstIndex +
|
|
53
|
-
bound.push(patterns[
|
|
173
|
+
const placeholder = (termIndex, bound) => {
|
|
174
|
+
if (numbered) return '$' + (firstIndex + termIndex)
|
|
175
|
+
bound.push(patterns[termIndex])
|
|
54
176
|
return '?'
|
|
55
177
|
}
|
|
56
|
-
const
|
|
57
|
-
'
|
|
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 =
|
|
60
|
-
'WHEN ' +
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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 => `${
|
|
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
|
|
74
|
-
if (!Array.isArray(columns) || columns.length
|
|
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,
|
|
85
|
-
.sort((a, b) =>
|
|
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 '|'"`
|
|
@@ -197,6 +197,7 @@ function buildBlogPost(record, options) {
|
|
|
197
197
|
assetMap: assetMap,
|
|
198
198
|
currentLanguage: currentLang,
|
|
199
199
|
mainLanguage: mainLang,
|
|
200
|
+
card: true,
|
|
200
201
|
}
|
|
201
202
|
if (relatedPostsById && relatedPostIds.length > 0) {
|
|
202
203
|
for (var rp = 0; rp < relatedPostIds.length; rp++) {
|
|
@@ -241,7 +242,10 @@ function buildBlogPost(record, options) {
|
|
|
241
242
|
id: id,
|
|
242
243
|
title: title,
|
|
243
244
|
slug: slug,
|
|
244
|
-
|
|
245
|
+
// A card of another post carries no body: no card shows one, and six
|
|
246
|
+
// related cards' bodies were most of an article page's data. MUST mirror
|
|
247
|
+
// \`card\` in packages/renderer/src/utils/blog-posts.ts (teleport-gui).
|
|
248
|
+
content: options.card === true ? '' : content,
|
|
245
249
|
excerpt: excerpt,
|
|
246
250
|
status: status,
|
|
247
251
|
category: category,
|