pgkiln 0.67.0 → 0.69.0

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 (50) hide show
  1. package/db/migrations/135_fulltext_search.sql +60 -0
  2. package/docs/apex-feature-parity.md +5 -5
  3. package/docs/guide/03-builder.md +12 -2
  4. package/docs/guide/04-pages-and-regions.md +46 -1
  5. package/docs/guide/12-development.md +17 -4
  6. package/examples/hr/README.md +1 -0
  7. package/examples/hr/hr_71_fulltext_search.sql +43 -0
  8. package/package.json +1 -1
  9. package/public/builder.css +15 -0
  10. package/src/builder/advisor.ts +39 -3
  11. package/src/builder/components.ts +14 -1
  12. package/src/builder/fulltext.ts +160 -0
  13. package/src/builder/i18n/de.ts +20 -0
  14. package/src/builder/i18n/en.ts +22 -1
  15. package/src/builder/i18n/es.ts +20 -0
  16. package/src/builder/i18n/fr.ts +20 -0
  17. package/src/builder/i18n/it.ts +1914 -0
  18. package/src/builder/i18n/ja.ts +1914 -0
  19. package/src/builder/i18n/ko.ts +1914 -0
  20. package/src/builder/i18n/nl.ts +20 -0
  21. package/src/builder/i18n/pt.ts +20 -0
  22. package/src/builder/i18n/zh-cn.ts +1914 -0
  23. package/src/builder/i18n/zh-tw.ts +1914 -0
  24. package/src/builder/i18n.ts +13 -3
  25. package/src/builder/object-actions.ts +1 -0
  26. package/src/builder/sql.ts +3 -0
  27. package/src/builder/table-editor.ts +1 -0
  28. package/src/i18n/ar.ts +1 -0
  29. package/src/i18n/cs.ts +1 -0
  30. package/src/i18n/da.ts +1 -0
  31. package/src/i18n/de.ts +1 -0
  32. package/src/i18n/el.ts +1 -0
  33. package/src/i18n/es.ts +1 -0
  34. package/src/i18n/fi.ts +1 -0
  35. package/src/i18n/fr.ts +1 -0
  36. package/src/i18n/he.ts +1 -0
  37. package/src/i18n/it.ts +1 -0
  38. package/src/i18n/ja.ts +1 -0
  39. package/src/i18n/ko.ts +1 -0
  40. package/src/i18n/nb.ts +1 -0
  41. package/src/i18n/pl.ts +1 -0
  42. package/src/i18n/pt.ts +1 -0
  43. package/src/i18n/ru.ts +1 -0
  44. package/src/i18n/sv.ts +1 -0
  45. package/src/i18n/tr.ts +1 -0
  46. package/src/i18n/uk.ts +1 -0
  47. package/src/i18n/zh.ts +1 -0
  48. package/src/i18n.ts +2 -0
  49. package/src/runtime/report.ts +26 -2
  50. package/src/runtime/search.ts +184 -21
@@ -19,20 +19,30 @@ import { de } from './i18n/de.ts';
19
19
  import { en } from './i18n/en.ts';
20
20
  import { es } from './i18n/es.ts';
21
21
  import { fr } from './i18n/fr.ts';
22
+ import { it } from './i18n/it.ts';
23
+ import { ja } from './i18n/ja.ts';
24
+ import { ko } from './i18n/ko.ts';
22
25
  import { nl } from './i18n/nl.ts';
23
26
  import { pt } from './i18n/pt.ts';
27
+ import { zhCN } from './i18n/zh-cn.ts';
28
+ import { zhTW } from './i18n/zh-tw.ts';
24
29
 
25
30
  export type BuilderKey = keyof typeof en;
26
31
  export type BuilderMessages = Record<BuilderKey, string>;
27
32
  export type Params = Record<string, string | number | null | undefined>;
28
33
 
29
- /** The builder's languages, in the order of the language menu and the sign-in page's links (names in their own language). */
34
+ /**
35
+ * The builder's languages, in the order of the language menu and the sign-in page's links (names in their own
36
+ * language): APEX's ten (sprint 134, SEC-5: Italiano, both Chinese scripts, Japanese, Korean) and Nederlands.
37
+ * The codes are BCP 47 tags, used as they are in <html lang>, the cookie and meta.developer.language.
38
+ */
30
39
  export const BUILDER_LANGUAGES = [
31
- ['de', 'Deutsch'], ['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['nl', 'Nederlands'], ['pt', 'Português'],
40
+ ['de', 'Deutsch'], ['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['it', 'Italiano'], ['nl', 'Nederlands'], ['pt', 'Português'],
41
+ ['zh-CN', '中文 (简体)'], ['zh-TW', '中文 (繁體)'], ['ja', '日本語'], ['ko', '한국어'],
32
42
  ] as const;
33
43
  export type BuilderLang = (typeof BUILDER_LANGUAGES)[number][0];
34
44
 
35
- const MESSAGES: Record<BuilderLang, Readonly<Record<string, string>>> = { en, de, es, fr, nl, pt };
45
+ const MESSAGES: Record<BuilderLang, Readonly<Record<string, string>>> = { en, de, es, fr, it, nl, pt, 'zh-CN': zhCN, 'zh-TW': zhTW, ja, ko };
36
46
 
37
47
  /** The cookie with the sign-in page's language on this device (the builder's path only). */
38
48
  export const LANG_COOKIE = 'pgkiln_blang';
@@ -32,6 +32,7 @@ export function objectMenu(schema: string, o: MenuObj): Raw {
32
32
  ${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, template: o.ref })}`, 'copy', 'Use as template') : ''}
33
33
  ${table ? item(`${BASE}/sql/objects/security?${new URLSearchParams({ o: o.ref })}`, 'shield', 'Security…') : ''}
34
34
  ${table ? item(`${BASE}/sql/load`, 'upload', 'Load data') : ''}
35
+ ${table ? item(`${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: o.ref })}`, 'search', 'Add full-text search…') : ''}
35
36
  ${table ? html`${item(q('index'), 'list', 'Add index')}${item(q('trigger'), 'bolt', 'Add trigger')}${item(q('policy'), 'shield', 'Add policy')}` : ''}
36
37
  ${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', 'REST › Enable / Disable') : ''}
37
38
  ${item(q('ddl'), 'code', 'Quick DDL to the worksheet')}
@@ -19,6 +19,7 @@ import { sqlUtilityRoutes } from './sql-utilities.ts';
19
19
  import { createTemplate, objectBrowserRoutes } from './object-browser.ts';
20
20
  import { objectActionRoutes } from './object-actions.ts';
21
21
  import { tableEditorRoutes } from './table-editor.ts';
22
+ import { fulltextRoutes } from './fulltext.ts';
22
23
  import { dataSecurityRoutes } from './data-security.ts';
23
24
  import { dataModelerRoutes } from './data-modeler.ts';
24
25
  import { autoRestBuilderRoutes } from './autorest.ts';
@@ -134,6 +135,8 @@ export async function sqlRoutes(app: FastifyInstance) {
134
135
  await app.register(objectActionRoutes);
135
136
  // (sprint 95) Table Properties: create, edit or copy a table in a form
136
137
  await app.register(tableEditorRoutes);
138
+ // (sprint 135, AP-2) Table Properties › Add full-text search
139
+ await app.register(fulltextRoutes);
137
140
  await app.register(dataSecurityRoutes);
138
141
  // (sprint 98) the Data Modeler: relational diagrams, their DDL and data dictionary
139
142
  await app.register(dataModelerRoutes);
@@ -142,6 +142,7 @@ function form(s: Session, def: TableDef, opts: { editing: TableDef | null; schem
142
142
  <button class="btn" name="mode" value="preview">${icon('eye')} ${tx('te.preview')}</button>
143
143
  <button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${tx('te.apply')}</button>
144
144
  <button class="btn" name="mode" value="worksheet">${icon('code')} ${tx('te.open_ws')}</button>
145
+ ${editing ? html`<a class="btn" href="${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: opts.ref })}">${icon('search')} ${tx('fts.add')}…</a>` : ''}
145
146
  <a class="btn" href="${editing ? `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema, k: 'table', o: opts.ref })}` : `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema })}`}">${tx('common.cancel')}</a>
146
147
  </div></form>`;
147
148
  }
package/src/i18n/ar.ts CHANGED
@@ -424,6 +424,7 @@ export const ar: Record<MessageKey, string> = {
424
424
  'lov.loading': "جارٍ تحميل المزيد…",
425
425
  'da.wait': "يرجى الانتظار…",
426
426
  'lov.no_rows': "لم يُعثر على شيء.",
427
+ 'search.did_you_mean': "هل تقصد:",
427
428
  'error.lov_value': "{label}: اختر قيمة من القائمة.",
428
429
  'item.yes': "نعم",
429
430
  'item.no': "لا",
package/src/i18n/cs.ts CHANGED
@@ -424,6 +424,7 @@ export const cs: Record<MessageKey, string> = {
424
424
  'lov.loading': "Načítání dalších…",
425
425
  'da.wait': "Čekejte prosím…",
426
426
  'lov.no_rows': "Nic nenalezeno.",
427
+ 'search.did_you_mean': "Měli jste na mysli:",
427
428
  'error.lov_value': "{label}: vyberte hodnotu ze seznamu.",
428
429
  'item.yes': "Ano",
429
430
  'item.no': "Ne",
package/src/i18n/da.ts CHANGED
@@ -424,6 +424,7 @@ export const da: Record<MessageKey, string> = {
424
424
  'lov.loading': "Indlæser flere…",
425
425
  'da.wait': "Vent venligst…",
426
426
  'lov.no_rows': "Intet fundet.",
427
+ 'search.did_you_mean': "Mente du:",
427
428
  'error.lov_value': "{label}: vælg en værdi på listen.",
428
429
  'item.yes': "Ja",
429
430
  'item.no': "Nej",
package/src/i18n/de.ts CHANGED
@@ -421,6 +421,7 @@ export const de: Record<MessageKey, string> = {
421
421
  'lov.loading': 'Weitere werden geladen…',
422
422
  'da.wait': 'Bitte warten…',
423
423
  'lov.no_rows': 'Nichts gefunden.',
424
+ 'search.did_you_mean': "Meinten Sie:",
424
425
  'error.lov_value': '{label}: Wählen Sie einen Wert aus der Liste.',
425
426
  'item.yes': 'Ja',
426
427
  'item.no': 'Nein',
package/src/i18n/el.ts CHANGED
@@ -424,6 +424,7 @@ export const el: Record<MessageKey, string> = {
424
424
  'lov.loading': "Φόρτωση περισσότερων…",
425
425
  'da.wait': "Παρακαλώ περιμένετε…",
426
426
  'lov.no_rows': "Δεν βρέθηκε τίποτα.",
427
+ 'search.did_you_mean': "Μήπως εννοούσατε:",
427
428
  'error.lov_value': "{label}: επιλέξτε τιμή από τη λίστα.",
428
429
  'item.yes': "Ναι",
429
430
  'item.no': "Όχι",
package/src/i18n/es.ts CHANGED
@@ -421,6 +421,7 @@ export const es: Record<MessageKey, string> = {
421
421
  'lov.loading': 'Cargando más…',
422
422
  'da.wait': 'Espere, por favor…',
423
423
  'lov.no_rows': 'No se ha encontrado nada.',
424
+ 'search.did_you_mean': "Quizás quiso decir:",
424
425
  'error.lov_value': '{label}: elija un valor de la lista.',
425
426
  'item.yes': 'Sí',
426
427
  'item.no': 'No',
package/src/i18n/fi.ts CHANGED
@@ -424,6 +424,7 @@ export const fi: Record<MessageKey, string> = {
424
424
  'lov.loading': "Ladataan lisää…",
425
425
  'da.wait': "Odota hetki…",
426
426
  'lov.no_rows': "Mitään ei löytynyt.",
427
+ 'search.did_you_mean': "Tarkoititko:",
427
428
  'error.lov_value': "{label}: valitse arvo luettelosta.",
428
429
  'item.yes': "Kyllä",
429
430
  'item.no': "Ei",
package/src/i18n/fr.ts CHANGED
@@ -421,6 +421,7 @@ export const fr: Record<MessageKey, string> = {
421
421
  'lov.loading': 'Chargement de la suite…',
422
422
  'da.wait': 'Veuillez patienter…',
423
423
  'lov.no_rows': 'Aucun résultat.',
424
+ 'search.did_you_mean': "Vouliez-vous dire :",
424
425
  'error.lov_value': '{label} : choisissez une valeur dans la liste.',
425
426
  'item.yes': 'Oui',
426
427
  'item.no': 'Non',
package/src/i18n/he.ts CHANGED
@@ -424,6 +424,7 @@ export const he: Record<MessageKey, string> = {
424
424
  'lov.loading': "טוען עוד…",
425
425
  'da.wait': "נא להמתין…",
426
426
  'lov.no_rows': "לא נמצא דבר.",
427
+ 'search.did_you_mean': "האם התכוונת ל:",
427
428
  'error.lov_value': "{label}: בחרו ערך מהרשימה.",
428
429
  'item.yes': "כן",
429
430
  'item.no': "לא",
package/src/i18n/it.ts CHANGED
@@ -424,6 +424,7 @@ export const it: Record<MessageKey, string> = {
424
424
  'lov.loading': "Caricamento di altri…",
425
425
  'da.wait': "Attendere prego…",
426
426
  'lov.no_rows': "Nessun risultato.",
427
+ 'search.did_you_mean': "Forse cercavi:",
427
428
  'error.lov_value': "{label}: scegli un valore dall'elenco.",
428
429
  'item.yes': "Sì",
429
430
  'item.no': "No",
package/src/i18n/ja.ts CHANGED
@@ -424,6 +424,7 @@ export const ja: Record<MessageKey, string> = {
424
424
  'lov.loading': "さらに読み込み中…",
425
425
  'da.wait': "お待ちください…",
426
426
  'lov.no_rows': "見つかりません。",
427
+ 'search.did_you_mean': "もしかして:",
427
428
  'error.lov_value': "{label}: 一覧から値を選んでください。",
428
429
  'item.yes': "はい",
429
430
  'item.no': "いいえ",
package/src/i18n/ko.ts CHANGED
@@ -424,6 +424,7 @@ export const ko: Record<MessageKey, string> = {
424
424
  'lov.loading': "더 불러오는 중…",
425
425
  'da.wait': "잠시 기다려 주세요…",
426
426
  'lov.no_rows': "찾은 항목이 없습니다.",
427
+ 'search.did_you_mean': "이것을 찾으셨나요:",
427
428
  'error.lov_value': "{label}: 목록에서 값을 선택하세요.",
428
429
  'item.yes': "예",
429
430
  'item.no': "아니요",
package/src/i18n/nb.ts CHANGED
@@ -424,6 +424,7 @@ export const nb: Record<MessageKey, string> = {
424
424
  'lov.loading': "Laster inn flere…",
425
425
  'da.wait': "Vent litt…",
426
426
  'lov.no_rows': "Fant ingenting.",
427
+ 'search.did_you_mean': "Mente du:",
427
428
  'error.lov_value': "{label}: velg en verdi fra listen.",
428
429
  'item.yes': "Ja",
429
430
  'item.no': "Nei",
package/src/i18n/pl.ts CHANGED
@@ -424,6 +424,7 @@ export const pl: Record<MessageKey, string> = {
424
424
  'lov.loading': "Wczytywanie kolejnych…",
425
425
  'da.wait': "Proszę czekać…",
426
426
  'lov.no_rows': "Nic nie znaleziono.",
427
+ 'search.did_you_mean': "Czy chodziło o:",
427
428
  'error.lov_value': "{label}: wybierz wartość z listy.",
428
429
  'item.yes': "Tak",
429
430
  'item.no': "Nie",
package/src/i18n/pt.ts CHANGED
@@ -424,6 +424,7 @@ export const pt: Record<MessageKey, string> = {
424
424
  'lov.loading': "A carregar mais…",
425
425
  'da.wait': "Aguarde, por favor…",
426
426
  'lov.no_rows': "Nada encontrado.",
427
+ 'search.did_you_mean': "Queria dizer:",
427
428
  'error.lov_value': "{label}: escolha um valor da lista.",
428
429
  'item.yes': "Sim",
429
430
  'item.no': "Não",
package/src/i18n/ru.ts CHANGED
@@ -424,6 +424,7 @@ export const ru: Record<MessageKey, string> = {
424
424
  'lov.loading': "Загрузка…",
425
425
  'da.wait': "Подождите…",
426
426
  'lov.no_rows': "Ничего не найдено.",
427
+ 'search.did_you_mean': "Возможно, вы имели в виду:",
427
428
  'error.lov_value': "{label}: выберите значение из списка.",
428
429
  'item.yes': "Да",
429
430
  'item.no': "Нет",
package/src/i18n/sv.ts CHANGED
@@ -424,6 +424,7 @@ export const sv: Record<MessageKey, string> = {
424
424
  'lov.loading': "Läser in fler…",
425
425
  'da.wait': "Vänta…",
426
426
  'lov.no_rows': "Inget hittades.",
427
+ 'search.did_you_mean': "Menade du:",
427
428
  'error.lov_value': "{label}: välj ett värde i listan.",
428
429
  'item.yes': "Ja",
429
430
  'item.no': "Nej",
package/src/i18n/tr.ts CHANGED
@@ -424,6 +424,7 @@ export const tr: Record<MessageKey, string> = {
424
424
  'lov.loading': "Daha fazlası yükleniyor…",
425
425
  'da.wait': "Lütfen bekleyin…",
426
426
  'lov.no_rows': "Hiçbir şey bulunamadı.",
427
+ 'search.did_you_mean': "Bunu mu demek istediniz:",
427
428
  'error.lov_value': "{label}: listeden bir değer seçin.",
428
429
  'item.yes': "Evet",
429
430
  'item.no': "Hayır",
package/src/i18n/uk.ts CHANGED
@@ -424,6 +424,7 @@ export const uk: Record<MessageKey, string> = {
424
424
  'lov.loading': "Завантаження…",
425
425
  'da.wait': "Зачекайте…",
426
426
  'lov.no_rows': "Нічого не знайдено.",
427
+ 'search.did_you_mean': "Можливо, ви мали на увазі:",
427
428
  'error.lov_value': "{label}: виберіть значення зі списку.",
428
429
  'item.yes': "Так",
429
430
  'item.no': "Ні",
package/src/i18n/zh.ts CHANGED
@@ -424,6 +424,7 @@ export const zh: Record<MessageKey, string> = {
424
424
  'lov.loading': "正在加载更多…",
425
425
  'da.wait': "请稍候…",
426
426
  'lov.no_rows': "未找到任何内容。",
427
+ 'search.did_you_mean': "您是不是要找:",
427
428
  'error.lov_value': "{label}:请从列表中选择一个值。",
428
429
  'item.yes': "是",
429
430
  'item.no': "否",
package/src/i18n.ts CHANGED
@@ -451,6 +451,7 @@ const en = {
451
451
  'lov.loading': 'Loading more…',
452
452
  'da.wait': 'Please wait…',
453
453
  'lov.no_rows': 'Nothing found.',
454
+ 'search.did_you_mean': 'Did you mean:',
454
455
  'error.lov_value': '{label}: choose a value from the list.',
455
456
  'item.yes': 'Yes',
456
457
  'item.no': 'No',
@@ -1318,6 +1319,7 @@ const nl: Record<MessageKey, string> = {
1318
1319
  'lov.loading': 'Meer laden…',
1319
1320
  'da.wait': 'Even geduld…',
1320
1321
  'lov.no_rows': 'Niets gevonden.',
1322
+ 'search.did_you_mean': 'Bedoelde u:',
1321
1323
  'error.lov_value': '{label}: kies een waarde uit de lijst.',
1322
1324
  'item.yes': 'Ja',
1323
1325
  'item.no': 'Nee',
@@ -17,6 +17,7 @@ import { REPORT_CHART_KINDS } from './charts.ts';
17
17
  import { ComputeError, computeNameOk, computeSql, type Computation } from './compute.ts';
18
18
  import { GRID_VIEWS, renderView, VIEWS, type View } from './report-views.ts';
19
19
  import { columnTemplates } from './template-components.ts';
20
+ import { textSearchConfig } from './search.ts';
20
21
  import { facetFilterSql, facetFilters, hidesColumns, reportFacetDefs, searchSql, userColumnNames } from './facet-state.ts';
21
22
  import { resolveRestRegion } from './rest-sources.ts';
22
23
  import { checksumValid, signText } from '../security.ts';
@@ -409,6 +410,18 @@ export async function withComputations(ctx: PageContext, r: Region, src: string,
409
410
  return { src: exprs.length ? `select "__s".*, ${exprs.join(', ')} from (\n${src}\n) "__s"` : src, errors };
410
411
  }
411
412
 
413
+ /**
414
+ * (135) The tsvector column a report's search also uses: the one its attributes name (`fulltext`, which may be a
415
+ * hidden column: the developer chose it), else the only tsvector column the user may see; null when there is none.
416
+ */
417
+ export function fulltextColumn(r: Pick<Region, 'config'>, fields: { name: string; dataTypeID: number }[], userCols: Map<string, number>) {
418
+ const TSVECTOR = 3614;
419
+ const named = typeof r.config.fulltext === 'string' ? r.config.fulltext : null;
420
+ if (named) return fields.find((f) => f.name === named && f.dataTypeID === TSVECTOR)?.name ?? null;
421
+ const shown = fields.filter((f) => f.dataTypeID === TSVECTOR && userCols.has(f.name));
422
+ return shown.length === 1 ? shown[0].name : null;
423
+ }
424
+
412
425
  /**
413
426
  * The report's query with the user's search, filters and facets applied:
414
427
  * the source, the WHERE clause, and the result's column names (only looked
@@ -422,14 +435,25 @@ export async function filtered(ctx: PageContext, r: Region, st: ReportState) {
422
435
  const p = new SqlParams();
423
436
  const hides = hidesColumns(r);
424
437
  const facets = facetFilters(ctx.params, r.id, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions), undefined, appDateReader(ctx));
425
- const needCols = st.filters.length || facets.length || st.breakCol || st.aggregates.length || st.highlights.length || st.view !== 'report' || st.area || st.near || keysetColumns(r) || (hides && (st.search || st.sort)) || (st.search && st.searchCol);
438
+ const needCols = st.filters.length || facets.length || st.breakCol || st.aggregates.length || st.highlights.length || st.view !== 'report' || st.area || st.near || keysetColumns(r) || (hides && (st.search || st.sort)) || st.search;
426
439
  // column name → type oid
427
440
  const fields = needCols ? await fieldsOf(ctx, src) : [];
428
441
  const cols = new Map<string, number>(fields.map((f) => [f.name, f.dataTypeID]));
429
442
  // the columns the user may choose (filters, highlights, control break, aggregates, views, sorting): not the hidden ones
430
443
  const userCols = new Map<string, number>(userColumnNames(r, [...cols.keys()]).map((n) => [n, cols.get(n)!]));
431
444
  // (sprint 76) the search in one column the user may see, or in all
432
- if (st.search) where.push(searchSql(st.search, p, st.searchCol && userCols.has(st.searchCol) ? [st.searchCol] : hides ? [...userCols.keys()] : undefined));
445
+ if (st.search) {
446
+ const oneCol = !!st.searchCol && userCols.has(st.searchCol);
447
+ let cond = searchSql(st.search, p, oneCol ? [st.searchCol!] : hides ? [...userCols.keys()] : undefined);
448
+ // (135) a tsvector column in the source: the search also finds rows by full-text search (stemmed words,
449
+ // "phrases", or, -word), with a text search configuration from pg_ts_config as a query parameter
450
+ const fts = oneCol ? null : fulltextColumn(r, fields, userCols);
451
+ if (fts) {
452
+ const cfg = await textSearchConfig(ctx.client!, typeof r.config.fulltext_config === 'string' ? r.config.fulltext_config : null, ctx.app.language);
453
+ cond = `(${cond} or ${q(fts)} @@ websearch_to_tsquery(${p.add(cfg, 'regconfig')}, ${p.add(st.search, 'text')}))`;
454
+ }
455
+ where.push(cond);
456
+ }
433
457
  for (const f of st.filters) if (userCols.has(f.column)) where.push(OPERATORS[f.op].sql(q(f.column), f.value));
434
458
  for (const f of facets) {
435
459
  const cond = facetFilterSql(f, cols, p);
@@ -10,8 +10,15 @@
10
10
  // - sql: a SELECT (binds allowed), wrapped as a subquery;
11
11
  // - list: the entries of a list (Shared Components → Lists) the user may see.
12
12
  //
13
- // Every word of the search text must be found in one of the searchable
14
- // columns (ILIKE, % and _ escaped, the text as a query parameter). The
13
+ // Search type standard: every word of the search text must be found in one
14
+ // of the searchable columns (ILIKE, % and _ escaped, the text as a query
15
+ // parameter). Search type fulltext (135): the source's tsvector matched
16
+ // with websearch_to_tsquery (quotes, or, -word), the best match first
17
+ // (ts_rank_cd), title, subtitle and description highlighted by ts_headline
18
+ // (between private-use markers that only then become <mark>, everything
19
+ // else escaped); the text search configuration is checked against
20
+ // pg_ts_config and passed as a parameter. With pg_trgm installed, a
21
+ // configuration may suggest the closest words when nothing is found. The
15
22
  // queries run as the application's role in the page's transaction, each in a
16
23
  // savepoint, so one configuration that fails shows its error and the others
17
24
  // still answer. Without JavaScript the field is a GET form; app.js searches
@@ -42,6 +49,14 @@ export interface SearchConfig {
42
49
  icon: string | null;
43
50
  link: { page?: number; items?: Record<string, string> } | null;
44
51
  max_results: number;
52
+ /** (135) standard: ILIKE per word · fulltext: PostgreSQL full-text search (later: vector, hybrid) */
53
+ search_type?: 'standard' | 'fulltext';
54
+ /** (135) fulltext: a tsvector column or expression; empty: to_tsvector over the searchable columns */
55
+ fts_vector?: string | null;
56
+ /** (135) fulltext: the text search configuration; empty: the application language's */
57
+ fts_config?: string | null;
58
+ /** (135) with pg_trgm: suggest the closest words when nothing is found */
59
+ did_you_mean?: boolean;
45
60
  }
46
61
 
47
62
  export interface SearchRegionConfig {
@@ -58,6 +73,54 @@ const MAX_TEXT = 200;
58
73
  const COLUMN = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
59
74
  const TABLE = /^([A-Za-z_][A-Za-z0-9_$]{0,62}\.)?[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
60
75
 
76
+ /** A client, a pool or pgkiln's Db: something that runs a query with parameters. */
77
+ type Queryable = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> };
78
+
79
+ /** (135) The search types (later: vector, hybrid). */
80
+ export const SEARCH_TYPES = ['standard', 'fulltext'] as const;
81
+ const TS_CONFIG = /^[a-z_][a-z0-9_]{0,62}$/;
82
+
83
+ /** (135) The text search configuration of a language: pgkiln's languages that PostgreSQL has a stemmer for. */
84
+ const TS_BY_LANGUAGE: Record<string, string> = {
85
+ ar: 'arabic', ca: 'catalan', da: 'danish', de: 'german', el: 'greek', en: 'english', es: 'spanish', eu: 'basque', fi: 'finnish',
86
+ fr: 'french', ga: 'irish', hi: 'hindi', hu: 'hungarian', hy: 'armenian', id: 'indonesian', it: 'italian', lt: 'lithuanian',
87
+ nb: 'norwegian', ne: 'nepali', nl: 'dutch', nn: 'norwegian', no: 'norwegian', pt: 'portuguese', ro: 'romanian', ru: 'russian',
88
+ sr: 'serbian', sv: 'swedish', ta: 'tamil', tr: 'turkish', yi: 'yiddish',
89
+ };
90
+ export const tsConfigFor = (language: string | null | undefined) => TS_BY_LANGUAGE[String(language ?? '').toLowerCase().split(/[-_]/)[0]] ?? 'simple';
91
+
92
+ /**
93
+ * (135) The text search configuration to use: the one asked for, else the language's, if it exists in
94
+ * pg_ts_config; else simple. Only a name that exists is returned (and it is still passed as a parameter).
95
+ */
96
+ export async function textSearchConfig(db: Queryable, wanted: string | null | undefined, language: string | null | undefined): Promise<string> {
97
+ const names = [wanted, tsConfigFor(language)].filter((x): x is string => !!x && TS_CONFIG.test(x));
98
+ if (!names.length) return 'simple';
99
+ const found = (await db.query('select cfgname from pg_catalog.pg_ts_config where cfgname = any($1::text[])', [names])).rows.map((r: { cfgname: string }) => r.cfgname);
100
+ return names.find((n) => found.includes(n)) ?? 'simple';
101
+ }
102
+
103
+ /** (135) The schema of pg_trgm when it is installed (its functions are called schema-qualified), else null. */
104
+ export async function trigramSchema(db: Queryable): Promise<string | null> {
105
+ return (await db.query(`select n.nspname as s from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_trgm'`)).rows[0]?.s ?? null;
106
+ }
107
+
108
+ /** (135) The markers ts_headline puts around a matched word (private-use characters, removed from the text first). */
109
+ export const HL_START = '\uE000';
110
+ export const HL_STOP = '\uE001';
111
+
112
+ /** (135) A ts_headline result as HTML: everything escaped, only the text between the markers in <mark>. */
113
+ export function headlined(value: string): Raw {
114
+ const parts: Raw[] = [];
115
+ let last = 0;
116
+ for (const m of value.matchAll(/\uE000([^\uE000\uE001]*)\uE001/g)) {
117
+ parts.push(html`${value.slice(last, m.index).replace(/[\uE000\uE001]/g, '')}${m[1] ? html`<mark>${m[1]}</mark>` : ''}`);
118
+ last = m.index! + m[0].length;
119
+ }
120
+ parts.push(html`${value.slice(last).replace(/[\uE000\uE001]/g, '')}`);
121
+ return html`${parts}`;
122
+ }
123
+
61
124
  /** The columns a configuration names (for the Advisor's check of its source). */
62
125
  export const configColumns = (c: Pick<SearchConfig, 'title_column' | 'subtitle_column' | 'description_column' | 'badge_column' | 'searchable_columns' | 'icon'>) =>
63
126
  [...new Set([c.title_column, c.subtitle_column, c.description_column, c.badge_column, ...(c.searchable_columns ?? []), /^#(.+)#$/.exec(c.icon ?? '')?.[1]]
@@ -87,6 +150,12 @@ export function searchConfigProblems(c: Partial<SearchConfig>): string[] {
87
150
  if (c.source_type === 'table' && !TABLE.test((c.source ?? '').trim())) out.push('The source of a table configuration is a table or view name, e.g. sales.customers.');
88
151
  if (c.icon && !/^(#[A-Za-z_][A-Za-z0-9_$]*#|[a-z0-9-]{1,40})$/.test(c.icon)) out.push('The icon is an icon name, or #COLUMN# for a column that holds icon names.');
89
152
  if (c.link && (typeof c.link !== 'object' || !Number.isInteger(c.link.page))) out.push('The link is {"page": 3, "items": {"P3_ID": "#ID#"}}.');
153
+ if (c.search_type && !SEARCH_TYPES.includes(c.search_type)) out.push(`The search type is one of ${SEARCH_TYPES.join(', ')}.`);
154
+ if (c.search_type === 'fulltext') {
155
+ if (c.source_type === 'list') out.push('Full-text search needs a table or SQL source.');
156
+ if (c.fts_config && !TS_CONFIG.test(c.fts_config)) out.push('The text search configuration is a name, e.g. english, dutch or simple.');
157
+ if (c.fts_vector && /;|--|\/\*/.test(c.fts_vector)) out.push('The tsvector is one column or expression (no ; or comments).');
158
+ }
90
159
  return out;
91
160
  }
92
161
 
@@ -97,7 +166,8 @@ async function configsOf(ctx: PageContext, r: Region): Promise<SearchConfig[]> {
97
166
  return (
98
167
  await runtime.query<SearchConfig>(
99
168
  `select name, label, seq, source_type, source, searchable_columns, title_column, subtitle_column, description_column,
100
- badge_column, icon, link, max_results
169
+ badge_column, icon, link, max_results,
170
+ search_type, fts_vector, fts_config, did_you_mean
101
171
  from meta.search_config
102
172
  where app_id = $1 and meta.build_option_on(app_id, build_option) and (cardinality($2::text[]) = 0 or name = any($2))
103
173
  order by seq, name`,
@@ -113,6 +183,8 @@ interface Hit {
113
183
  badge: string | null;
114
184
  icon: string | null;
115
185
  href: Raw | null;
186
+ /** (135) full-text search: title, subtitle and description as highlighted by ts_headline */
187
+ headlines?: { title: Raw; subtitle: Raw | null; description: Raw | null };
116
188
  }
117
189
 
118
190
  const text = (v: unknown) => (v === null || v === undefined ? null : typeof v === 'object' ? JSON.stringify(v) : String(v));
@@ -124,21 +196,58 @@ function col(row: Record<string, unknown>, name: string | null) {
124
196
  return key === undefined ? null : text(row[key]);
125
197
  }
126
198
 
127
- /** One configuration's results on a table or a query: up to max_results, and whether there are more. */
128
- async function searchRows(ctx: PageContext, c: SearchConfig, words: string[]): Promise<{ hits: Hit[]; more: boolean }> {
129
- const searched = (c.searchable_columns.length ? c.searchable_columns : [c.title_column, c.subtitle_column, c.description_column]).filter(
130
- (x): x is string => !!x,
131
- );
132
- const conditions = words.map((_, i) => `(${searched.map((s) => `t.${ident(s)}::text ilike $${i + 1} escape '\\'`).join(' or ')})`);
133
- let from: string;
199
+ /** The columns a configuration searches: its searchable columns, else title, subtitle and description. */
200
+ const searchedColumns = (c: SearchConfig) =>
201
+ (c.searchable_columns.length ? c.searchable_columns : [c.title_column, c.subtitle_column, c.description_column]).filter((x): x is string => !!x);
202
+
203
+ /** The FROM of a table or SQL configuration (the source as `t`). */
204
+ async function sourceFrom(ctx: PageContext, c: SearchConfig) {
134
205
  if (c.source_type === 'table') {
135
206
  const table = (await ctx.client!.query('select $1::regclass::text as t', [c.source])).rows[0].t as string;
136
- from = `${table} t`;
137
- } else from = `(${stripSemicolon(applyBinds(c.source, bindValues(ctx)))}) t`;
138
- const sql = `select t.* from ${from} where ${conditions.join(' and ') || 'true'}${c.title_column ? ` order by t.${ident(c.title_column)}` : ''} limit ${c.max_results + 1}`;
139
- const res = await ctx.client!.query(sql, words.map(likePattern));
140
- const rows = res.rows as Record<string, unknown>[];
207
+ return `${table} t`;
208
+ }
209
+ return `(${stripSemicolon(applyBinds(c.source, bindValues(ctx)))}) t`;
210
+ }
211
+
212
+ /**
213
+ * (135) The tsvector of a full-text configuration as SQL over `t`: its column, its expression, or to_tsvector
214
+ * over the searchable columns (`cfg` is the placeholder of the text search configuration).
215
+ */
216
+ export function tsvectorSql(c: Pick<SearchConfig, 'fts_vector' | 'searchable_columns' | 'title_column' | 'subtitle_column' | 'description_column'>, cfg: string) {
217
+ const v = (c.fts_vector ?? '').trim();
218
+ if (v) return COLUMN.test(v) ? `t.${ident(v)}` : `(${v})`;
219
+ const cols = searchedColumns(c as SearchConfig);
220
+ return `to_tsvector(${cfg}, ${cols.map((x) => `coalesce(t.${ident(x)}::text, '')`).join(` || ' ' || `) || `''`})`;
221
+ }
222
+
223
+ /** One configuration's results on a table or a query: up to max_results, and whether there are more. */
224
+ async function searchRows(ctx: PageContext, c: SearchConfig, words: string[], query: string): Promise<{ hits: Hit[]; more: boolean }> {
225
+ const from = await sourceFrom(ctx, c);
226
+ let rows: Record<string, unknown>[];
227
+ const fulltext = c.search_type === 'fulltext';
228
+ if (fulltext) {
229
+ // $1 the text search configuration (a name from pg_ts_config), $2 the user's text, $3 the headline options,
230
+ // $4 the marker characters (taken out of the values first, so only ts_headline's markers become markup)
231
+ const cfg = await textSearchConfig(ctx.client!, c.fts_config, ctx.app.language);
232
+ const vec = tsvectorSql(c, '$1::regconfig');
233
+ const hl = (column: string | null, alias: string, options: string) =>
234
+ column ? `, ts_headline($1::regconfig, translate(s.${ident(column)}::text, $4, ''), "pgkiln$q".query, ${options}) as "pgkiln$${alias}"` : '';
235
+ const sql = `select s.*${hl(c.title_column, 'title', '$3::text || \', HighlightAll=true\'')}${hl(c.subtitle_column, 'subtitle', '$3::text || \', HighlightAll=true\'')}${hl(c.description_column, 'description', '$3::text || \', MaxWords=35, MinWords=15, MaxFragments=2, FragmentDelimiter=" … "\'')}
236
+ from (select t.*, ts_rank_cd(${vec}, "pgkiln$q".query) as "pgkiln$rank"
237
+ from ${from}, websearch_to_tsquery($1::regconfig, $2) "pgkiln$q"(query)
238
+ where ${vec} @@ "pgkiln$q".query
239
+ order by "pgkiln$rank" desc${c.title_column ? `, t.${ident(c.title_column)}` : ''}
240
+ limit ${c.max_results + 1}) s, websearch_to_tsquery($1::regconfig, $2) "pgkiln$q"(query)
241
+ order by s."pgkiln$rank" desc${c.title_column ? `, s.${ident(c.title_column)}` : ''}`;
242
+ rows = (await ctx.client!.query(sql, [cfg, query, `StartSel="${HL_START}", StopSel="${HL_STOP}"`, HL_START + HL_STOP])).rows;
243
+ } else {
244
+ const searched = searchedColumns(c);
245
+ const conditions = words.map((_, i) => `(${searched.map((s) => `t.${ident(s)}::text ilike $${i + 1} escape '\\'`).join(' or ')})`);
246
+ const sql = `select t.* from ${from} where ${conditions.join(' and ') || 'true'}${c.title_column ? ` order by t.${ident(c.title_column)}` : ''} limit ${c.max_results + 1}`;
247
+ rows = (await ctx.client!.query(sql, words.map(likePattern))).rows;
248
+ }
141
249
  const iconCol = /^#(.+)#$/.exec(c.icon ?? '')?.[1] ?? null;
250
+ const h = (row: Record<string, unknown>, alias: string) => (typeof row[`pgkiln$${alias}`] === 'string' ? headlined(row[`pgkiln$${alias}`] as string) : null);
142
251
  return {
143
252
  more: rows.length > c.max_results,
144
253
  hits: rows.slice(0, c.max_results).map((row) => ({
@@ -151,10 +260,37 @@ async function searchRows(ctx: PageContext, c: SearchConfig, words: string[]): P
151
260
  c.link && Number.isInteger(c.link.page)
152
261
  ? linkAttrs(ctx, c.link.page!, fillItems(c.link.items, (name) => col(row, name) ?? undefined))
153
262
  : null,
263
+ ...(fulltext ? { headlines: { title: h(row, 'title') ?? html`${col(row, c.title_column) ?? ''}`, subtitle: h(row, 'subtitle'), description: h(row, 'description') } } : {}),
154
264
  })),
155
265
  };
156
266
  }
157
267
 
268
+ /**
269
+ * (135) "Did you mean": with pg_trgm installed, each search word that is not a word of the configuration's
270
+ * searched columns replaced by the most similar one (similarity 0.3 or more), from the first 10,000 rows of the
271
+ * source. Null when there is nothing better to suggest.
272
+ */
273
+ async function suggestion(ctx: PageContext, c: SearchConfig, words: string[], trgm: string): Promise<string | null> {
274
+ const cols = searchedColumns(c);
275
+ if (!cols.length || !words.length) return null;
276
+ const sim = `${ident(trgm)}.similarity`;
277
+ const res = await ctx.client!.query<{ word: string; best: string | null; known: boolean }>(
278
+ `with v as (select distinct w from (select concat_ws(' ', ${cols.map((x) => `t.${ident(x)}::text`).join(', ')}) as doc from ${await sourceFrom(ctx, c)} limit 10000) d,
279
+ regexp_split_to_table(lower(d.doc), '[^[:alnum:]]+') w where length(w) between 2 and 40)
280
+ select u.word, exists (select from v where v.w = lower(u.word)) as known,
281
+ (select v.w from v where ${sim}(v.w, lower(u.word)) >= 0.3 order by ${sim}(v.w, lower(u.word)) desc, v.w limit 1) as best
282
+ from unnest($1::text[]) with ordinality u(word, n) order by u.n`,
283
+ [words],
284
+ );
285
+ let changed = false;
286
+ const out = res.rows.map((r) => {
287
+ if (r.known || !r.best) return r.word;
288
+ changed = true;
289
+ return r.best;
290
+ });
291
+ return changed ? out.join(' ') : null;
292
+ }
293
+
158
294
  /** One configuration's results on a list: the entries (and their children) the user may see. */
159
295
  async function searchList(ctx: PageContext, c: SearchConfig, words: string[]): Promise<{ hits: Hit[]; more: boolean }> {
160
296
  const nodes = (await listTree(ctx, c.source)) ?? [];
@@ -204,18 +340,23 @@ async function searchResults(ctx: PageContext, r: Region, query: string): Promis
204
340
  const minLength = Number.isInteger(conf.min_length) && conf.min_length! > 0 ? conf.min_length! : 1;
205
341
  if (!words.length || query.trim().length < minLength) return html``;
206
342
  const groups: Raw[] = [];
207
- for (const c of await configsOf(ctx, r)) {
343
+ const configs = await configsOf(ctx, r);
344
+ for (const c of configs) {
208
345
  let body: Raw;
209
346
  try {
210
347
  const problems = searchConfigProblems(c);
211
348
  if (problems.length) throw new Error(`Search configuration ${c.name}: ${problems.join(' ')}`);
212
- const { hits, more } = await savepoint(ctx.client!, () => (c.source_type === 'list' ? searchList(ctx, c, words) : searchRows(ctx, c, words)));
349
+ const { hits, more } = await savepoint(ctx.client!, () => (c.source_type === 'list' ? searchList(ctx, c, words) : searchRows(ctx, c, words, query.trim())));
213
350
  if (!hits.length) continue;
214
351
  body = html`<ul class="search-hits">${hits.map((h) => {
352
+ // (135) full-text results: ts_headline's highlights (stemmed words: "running" marks "run"); else the words
353
+ const title = h.headlines?.title ?? marked(h.title, words);
354
+ const subtitle = h.subtitle ? (h.headlines?.subtitle ?? marked(h.subtitle, words)) : null;
355
+ const description = h.description ? (h.headlines?.description ?? marked(h.description, words)) : null;
215
356
  const inner = html`${h.icon ? html`<span class="search-icon">${icon(h.icon)}</span>` : ''}
216
- <span class="search-text"><span class="search-title">${marked(h.title, words)}</span>${
217
- h.subtitle ? html`<span class="search-subtitle">${marked(h.subtitle, words)}</span>` : ''}${
218
- h.description ? html`<span class="search-description">${marked(h.description, words)}</span>` : ''}</span>
357
+ <span class="search-text"><span class="search-title">${title}</span>${
358
+ subtitle ? html`<span class="search-subtitle">${subtitle}</span>` : ''}${
359
+ description ? html`<span class="search-description">${description}</span>` : ''}</span>
219
360
  ${h.badge ? html`<span class="badge-pill">${marked(h.badge, words)}</span>` : ''}`;
220
361
  return html`<li>${h.href ? html`<a class="search-hit" ${h.href}>${inner}</a>` : html`<div class="search-hit">${inner}</div>`}</li>`;
221
362
  })}</ul>${more ? html`<p class="muted search-more">${ctx.locale.t('region.first_rows', { n: String(c.max_results) })}</p>` : ''}`;
@@ -226,7 +367,29 @@ async function searchResults(ctx: PageContext, r: Region, query: string): Promis
226
367
  groups.push(html`<section class="search-group"><h3>${ctx.locale.tr(c.label)}</h3>${body}</section>`);
227
368
  }
228
369
  // (a configuration that failed shows its error as a group of its own)
229
- return groups.length ? html`${groups}` : html`<p class="muted search-empty">${conf.empty ?? ctx.locale.t('lov.no_rows')}</p>`;
370
+ if (groups.length) return html`${groups}`;
371
+ return html`<p class="muted search-empty">${conf.empty ?? ctx.locale.t('lov.no_rows')}</p>${await didYouMean(ctx, r, configs, words)}`;
372
+ }
373
+
374
+ /** (135) Nothing found: the first suggestion of a configuration that asks for one (pg_trgm installed), as a link. */
375
+ async function didYouMean(ctx: PageContext, r: Region, configs: SearchConfig[], words: string[]): Promise<Raw | ''> {
376
+ const asking = configs.filter((c) => c.did_you_mean && c.source_type !== 'list' && !searchConfigProblems(c).length);
377
+ if (!asking.length) return '';
378
+ const trgm = await trigramSchema(ctx.client!);
379
+ if (!trgm) return '';
380
+ for (const c of asking) {
381
+ // a configuration that fails here suggests nothing (its error was shown, or there was none to show)
382
+ const s = await savepoint(ctx.client!, () => suggestion(ctx, c, words, trgm)).catch(() => null);
383
+ if (!s) continue;
384
+ const conf = (r.config ?? {}) as SearchRegionConfig;
385
+ const label = ctx.locale.t('search.did_you_mean');
386
+ // the region's own field: a link that searches for the suggestion; a page item: the words only
387
+ if (typeof conf.item === 'string' && conf.item) return html`<p class="muted search-suggestion">${label} <b>${s}</b></p>`;
388
+ const params = new URLSearchParams([...ctx.params].filter(([k]) => k !== searchParam(r)));
389
+ params.set(searchParam(r), s);
390
+ return html`<p class="muted search-suggestion">${label} <a href="${ctx.base}/${ctx.page.page_no}?${params}">${s}</a></p>`;
391
+ }
392
+ return '';
230
393
  }
231
394
 
232
395
  export async function renderSearch(ctx: PageContext, r: Region): Promise<Raw> {