lorebase-store 0.2.3__py3-none-any.whl

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 (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,1491 @@
1
+ """Read primitives on the materialized head of a branch (design 8).
2
+
3
+ Each primitive is one SQL statement shaped for an index scan, with stable text so the asyncpg
4
+ driver reuses one prepared statement per shape. The caller runs them in its own transaction
5
+ after `scope_to_domain` (RLS). For a response that must be consistent with the Revision it
6
+ reports, run the reads of one request in a `REPEATABLE READ` transaction.
7
+
8
+ - `head_revision`: the branch head, one primary-key read; the value for `ETag` and `304`.
9
+ - `head_schema`: the schema at the head through the per-process `SchemaCache`.
10
+ - `get_record`, `list_records`, `hydrate_records`: primary-key lookups and keyset pages.
11
+ - `links_of_record`: Links in either direction, stored or foreign-key backed.
12
+ - `links_to_record`: one page of the Links pointing at a Record along one Relation.
13
+ - `query_by_attribute`: comparisons served by the per-Attribute partial indexes.
14
+ - `search_records`: full-text search over the GIN index, ranking a bounded candidate set
15
+ (sized per query from the word lexicon with `CommonTerms`, and reported `truncated` when
16
+ the cap cut the ranking); with `PartialMatching`, a query whose words do not all occur
17
+ together falls back to Records that hold some of them (`partial_terms`). With the BM25
18
+ backend (`lorebase_store.bm25`) the BM25 index ranks instead, through `lb_search_bm25`.
19
+ - `search_corrections`: typo corrections of query words from the branch's word lexicon.
20
+
21
+ Incoming foreign-key Links, attribute queries and search compare with functions Postgres does
22
+ not mark leakproof, so under row-level security plain SQL cannot use their indexes. With
23
+ `store.reads.use_definer_functions` (the default) they run through the security definer
24
+ functions of revisions 0005, 0018 and 0035 (`lb_links_in`, `lb_query_by_attribute`,
25
+ `lb_search_bounded`, `lb_search_corrections`, and `lb_search_bm25` of revision 0037), which
26
+ take the Domain as an argument: only
27
+ these primitives call them, with the Domain their caller has already authorized (store schema
28
+ notes). Otherwise the same statements run as plain SQL.
29
+
30
+ Every primitive that reads rows takes the branch as a name or as its `BranchLayout`
31
+ (`lorebase_store.layout`); a name costs one more primary-key read to find the layout. On an
32
+ overlay branch the statements read its resolved rows (overlay rows over the base rows as of
33
+ the fork point) and the definer functions are their `_overlay` variants (revision `0010`);
34
+ on a full branch they are unchanged.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import logging
40
+ import re
41
+ from collections.abc import Callable, Iterable, Sequence
42
+ from dataclasses import dataclass
43
+ from decimal import Decimal
44
+ from enum import StrEnum
45
+ from functools import lru_cache
46
+ from typing import Any
47
+
48
+ from sqlalchemy import LargeBinary, Table, TextClause, TextualSelect, column, text
49
+ from sqlalchemy.dialects.postgresql import JSONB
50
+ from sqlalchemy.ext.asyncio import AsyncSession
51
+
52
+ from lorebase_core.model import Relation, RelationBacking, Schema
53
+ from lorebase_store import bm25
54
+ from lorebase_store.bm25 import SearchBackend
55
+ from lorebase_store.indexes import IndexSpec, plan_indexes, predicate_sql, sql_literal, values_text
56
+ from lorebase_store.layout import BranchLayout, escape_colons, resolve_layout
57
+ from lorebase_store.models import metadata
58
+ from lorebase_store.repositories.materialized import RecordRow
59
+ from lorebase_store.runtime import StoreRuntime, default_runtime
60
+ from lorebase_store.telemetry import record_definer_call
61
+
62
+ log = logging.getLogger(__name__)
63
+
64
+ MAT_RECORDS: Table = metadata.tables["mat_records"]
65
+ MAT_LINKS: Table = metadata.tables["mat_links"]
66
+
67
+ RECORD_NAMES = (
68
+ "kind_id",
69
+ "key",
70
+ "values",
71
+ "title",
72
+ "object_hash",
73
+ "lineage",
74
+ "updated_revision",
75
+ "updated_at",
76
+ )
77
+
78
+
79
+ def lineage_sql(value: str, domain: str = "CAST(:domain AS text)") -> str:
80
+ """`value` (a map column of `mat_records` or `mat_links`) as the map it stands for: with
81
+ shared lineage maps (revision 0036) a column may hold a reference to the map instead."""
82
+ return f"lb_lineage({domain}, {value})"
83
+
84
+
85
+ def record_columns(alias: str | None = "m") -> str:
86
+ """The columns of `RECORD_NAMES` from `alias` (bare names without one), lineage resolved
87
+ with the bound `:domain`."""
88
+ prefix = "" if alias is None else f"{alias}."
89
+ columns = [f'{prefix}"{name}"' for name in RECORD_NAMES]
90
+ return ", ".join(
91
+ f'{lineage_sql(column)} AS "{name}"' if name == "lineage" else column
92
+ for name, column in zip(RECORD_NAMES, columns, strict=True)
93
+ )
94
+
95
+
96
+ RECORD_COLUMNS = record_columns()
97
+
98
+
99
+ def _records(sql: str) -> TextualSelect:
100
+ """A statement returning Record columns, typed so JSON comes back decoded."""
101
+ return text(sql).columns(*(MAT_RECORDS.c[name] for name in RECORD_NAMES))
102
+
103
+
104
+ class Direction(StrEnum):
105
+ """Which Links of a Record to follow: outgoing (`from`), incoming (`to`) or both."""
106
+
107
+ OUT = "out"
108
+ IN = "in"
109
+ BOTH = "both"
110
+
111
+ @property
112
+ def outgoing(self) -> bool:
113
+ return self is not Direction.IN
114
+
115
+ @property
116
+ def incoming(self) -> bool:
117
+ return self is not Direction.OUT
118
+
119
+
120
+ class Comparison(StrEnum):
121
+ EQ = "eq"
122
+ LT = "lt"
123
+ LTE = "lte"
124
+ GT = "gt"
125
+ GTE = "gte"
126
+
127
+
128
+ _OPERATORS = {
129
+ Comparison.EQ: "=",
130
+ Comparison.LT: "<",
131
+ Comparison.LTE: "<=",
132
+ Comparison.GT: ">",
133
+ Comparison.GTE: ">=",
134
+ }
135
+
136
+
137
+ @dataclass(frozen=True, slots=True)
138
+ class HeadSchema:
139
+ revision: bytes
140
+ schema: Schema
141
+
142
+
143
+ @dataclass(frozen=True, slots=True)
144
+ class LinkRef:
145
+ """A Link at the head; foreign-key backed Links have no values and no object hash."""
146
+
147
+ relation_id: str
148
+ from_key: str
149
+ to_key: str
150
+ values: dict[str, Any]
151
+ object_hash: bytes | None
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class SearchHit:
156
+ """A search match; `exact` is false when it matched only through a typo correction,
157
+ `partial` is true when it holds only some of the query's terms (`PartialMatching`)."""
158
+
159
+ kind_id: str
160
+ key: str
161
+ title: str | None
162
+ score: float
163
+ exact: bool = True
164
+ partial: bool = False
165
+
166
+
167
+ def _runtime(runtime: StoreRuntime | None) -> StoreRuntime:
168
+ return runtime or default_runtime()
169
+
170
+
171
+ def _definer(runtime: StoreRuntime | None) -> bool:
172
+ """Whether reads go through the security definer functions (`store.reads`)."""
173
+ return _runtime(runtime).settings.reads.use_definer_functions
174
+
175
+
176
+ def _function(name: str, layout: BranchLayout) -> str:
177
+ """A security definer read function, or its overlay variant on an overlay branch, for a
178
+ statement about to call it (the call is counted)."""
179
+ function = f"{name}_overlay" if layout.overlay else name
180
+ record_definer_call(function)
181
+ return function
182
+
183
+
184
+ def _cap(limit: int, maximum: int) -> int:
185
+ if limit < 1:
186
+ raise ValueError("limit must be positive")
187
+ return min(limit, maximum)
188
+
189
+
190
+ # Heads and schemas ------------------------------------------------------------------------------
191
+
192
+ _HEAD = text("SELECT head FROM refs WHERE domain_id = :domain AND name = :branch")
193
+ _HEAD_SCHEMA = text(
194
+ """
195
+ SELECT r.head, s.compiled
196
+ FROM refs r
197
+ JOIN mat_schema s ON s.domain_id = r.domain_id AND s.branch = r.name
198
+ WHERE r.domain_id = :domain AND r.name = :branch
199
+ """
200
+ ).columns(column("head", LargeBinary), column("compiled", JSONB))
201
+
202
+
203
+ async def head_revision(session: AsyncSession, domain_id: str, branch: str) -> bytes | None:
204
+ """Head Revision id of a branch (one primary-key read), `None` when it does not exist.
205
+
206
+ The id is a strong validator: an `ETag` derived from it (plus the caller's mask profile)
207
+ answers `If-None-Match` with `304` before any other read.
208
+ """
209
+ params = {"domain": domain_id, "branch": branch}
210
+ found: bytes | None = (await session.execute(_HEAD, params)).scalar_one_or_none()
211
+ return found
212
+
213
+
214
+ async def head_schema(
215
+ session: AsyncSession, domain_id: str, branch: str, *, runtime: StoreRuntime | None = None
216
+ ) -> HeadSchema | None:
217
+ """Head Revision and schema of a branch; the schema comes from the cache when it can."""
218
+ cache = _runtime(runtime).schema_cache
219
+ head = await head_revision(session, domain_id, branch)
220
+ if head is None:
221
+ return None
222
+ cached = cache.get(domain_id, head)
223
+ if cached is not None:
224
+ return HeadSchema(head, cached)
225
+ row = (
226
+ await session.execute(_HEAD_SCHEMA, {"domain": domain_id, "branch": branch})
227
+ ).one_or_none()
228
+ if row is None:
229
+ return None
230
+ schema = Schema.model_validate(row.compiled)
231
+ cache.put(domain_id, row.head, schema)
232
+ return HeadSchema(row.head, schema)
233
+
234
+
235
+ # Records ----------------------------------------------------------------------------------------
236
+
237
+ _SCOPE = "m.domain_id = :domain AND m.branch = :branch"
238
+
239
+
240
+ @dataclass(frozen=True, slots=True)
241
+ class _RecordStatements:
242
+ get: TextualSelect
243
+ page: TextualSelect
244
+ page_after: TextualSelect
245
+ hydrate: TextualSelect
246
+
247
+
248
+ @lru_cache(maxsize=256)
249
+ def _record_statements(records: str) -> _RecordStatements:
250
+ """Key and page reads over the FROM item `records` (aliased `m`)."""
251
+ return _RecordStatements(
252
+ get=_records(
253
+ f"SELECT {RECORD_COLUMNS} FROM {records} "
254
+ f"WHERE {_SCOPE} AND m.kind_id = :kind AND m.key = :key"
255
+ ),
256
+ page=_records(
257
+ f"SELECT {RECORD_COLUMNS} FROM {records} "
258
+ f"WHERE {_SCOPE} AND m.kind_id = :kind ORDER BY m.key LIMIT :limit"
259
+ ),
260
+ page_after=_records(
261
+ f"SELECT {RECORD_COLUMNS} FROM {records} "
262
+ f"WHERE {_SCOPE} AND m.kind_id = :kind AND m.key > :after ORDER BY m.key LIMIT :limit"
263
+ ),
264
+ hydrate=_records(
265
+ f"""
266
+ SELECT {RECORD_COLUMNS}
267
+ FROM unnest(CAST(:kinds AS text[]), CAST(:keys AS text[])) AS i(kind_id, key)
268
+ JOIN {records} ON {_SCOPE} AND m.kind_id = i.kind_id AND m.key = i.key
269
+ """
270
+ ),
271
+ )
272
+
273
+
274
+ def _statements(layout: BranchLayout) -> _RecordStatements:
275
+ if layout.overlay:
276
+ return _overlay_statements(layout)
277
+ return _record_statements(layout.records_from("m"))
278
+
279
+
280
+ @lru_cache(maxsize=256)
281
+ def _overlay_statements(layout: BranchLayout) -> _RecordStatements:
282
+ """Key and page reads of an overlay: pages are taken from each half (overlay rows, base
283
+ rows) in key order and merged, so a page reads at most twice its size; hydration looks
284
+ each identity up laterally (LIMIT 1 keeps the lookup from being turned into a join with
285
+ every row), so its keys reach both halves as index conditions."""
286
+ kind = " AND m.kind_id = :kind"
287
+ page = " ORDER BY m.key LIMIT :limit"
288
+ return _RecordStatements(
289
+ get=_record_statements(layout.records_from("m")).get,
290
+ page=_records(
291
+ f"SELECT {RECORD_COLUMNS} FROM {layout.records_arms(kind + page)} "
292
+ "ORDER BY m.key LIMIT :limit"
293
+ ),
294
+ page_after=_records(
295
+ f"SELECT {RECORD_COLUMNS} FROM "
296
+ f"{layout.records_arms(kind + ' AND m.key > :after' + page)} "
297
+ "ORDER BY m.key LIMIT :limit"
298
+ ),
299
+ hydrate=_records(
300
+ f"""
301
+ SELECT {RECORD_COLUMNS}
302
+ FROM unnest(CAST(:kinds AS text[]), CAST(:keys AS text[])) AS i(kind_id, key)
303
+ CROSS JOIN LATERAL (
304
+ SELECT {RECORD_COLUMNS} FROM {layout.records_from("m")}
305
+ WHERE {_SCOPE} AND m.kind_id = i.kind_id AND m.key = i.key LIMIT 1) AS m
306
+ """
307
+ ),
308
+ )
309
+
310
+
311
+ async def get_record(
312
+ session: AsyncSession, domain_id: str, branch: str | BranchLayout, kind_id: str, key: str
313
+ ) -> RecordRow | None:
314
+ layout = await resolve_layout(session, domain_id, branch)
315
+ params = {"domain": domain_id, "branch": layout.branch, "kind": kind_id, "key": key}
316
+ found = (await session.execute(_statements(layout).get, params)).mappings().one_or_none()
317
+ return None if found is None else RecordRow(**found)
318
+
319
+
320
+ async def list_records(
321
+ session: AsyncSession,
322
+ domain_id: str,
323
+ branch: str | BranchLayout,
324
+ kind_id: str,
325
+ *,
326
+ limit: int,
327
+ after: str | None = None,
328
+ runtime: StoreRuntime | None = None,
329
+ ) -> list[RecordRow]:
330
+ """A page of a Kind's Records in key order, starting after the key `after`."""
331
+ layout = await resolve_layout(session, domain_id, branch)
332
+ params: dict[str, Any] = {
333
+ "domain": domain_id,
334
+ "branch": layout.branch,
335
+ "kind": kind_id,
336
+ "limit": _cap(limit, _runtime(runtime).settings.reads.max_page_rows),
337
+ }
338
+ statement = _statements(layout).page
339
+ if after is not None:
340
+ statement, params["after"] = _statements(layout).page_after, after
341
+ return [RecordRow(**row) for row in (await session.execute(statement, params)).mappings()]
342
+
343
+
344
+ async def hydrate_records(
345
+ session: AsyncSession,
346
+ domain_id: str,
347
+ branch: str | BranchLayout,
348
+ identities: Iterable[tuple[str, str]],
349
+ *,
350
+ runtime: StoreRuntime | None = None,
351
+ ) -> dict[tuple[str, str], RecordRow]:
352
+ """Records by `(kind_id, key)` in one statement per `reads.max_hydrate_keys` identities."""
353
+ unique = list(dict.fromkeys(identities))
354
+ batch = _runtime(runtime).settings.reads.max_hydrate_keys
355
+ found: dict[tuple[str, str], RecordRow] = {}
356
+ if not unique:
357
+ return found
358
+ layout = await resolve_layout(session, domain_id, branch)
359
+ statement = _statements(layout).hydrate
360
+ for start in range(0, len(unique), batch):
361
+ chunk = unique[start : start + batch]
362
+ params = {
363
+ "domain": domain_id,
364
+ "branch": layout.branch,
365
+ "kinds": [kind for kind, _ in chunk],
366
+ "keys": [key for _, key in chunk],
367
+ }
368
+ for row in (await session.execute(statement, params)).mappings():
369
+ record = RecordRow(**row)
370
+ found[(record.kind_id, record.key)] = record
371
+ return found
372
+
373
+
374
+ # Links ------------------------------------------------------------------------------------------
375
+
376
+ _LINK_SELECT = 'relation_id, from_key, to_key, "values", object_hash'
377
+
378
+
379
+ def _relations(
380
+ schema: Schema, kind_id: str, direction: Direction, relation_ids: Sequence[str] | None
381
+ ) -> tuple[list[Relation], list[Relation]]:
382
+ """Relations leaving and entering `kind_id`, restricted to `relation_ids` when given."""
383
+ wanted = None if relation_ids is None else set(relation_ids)
384
+ outgoing: list[Relation] = []
385
+ incoming: list[Relation] = []
386
+ for relation in sorted(schema.relations.values(), key=lambda r: r.id):
387
+ if wanted is not None and relation.id not in wanted:
388
+ continue
389
+ if direction.outgoing and relation.from_kind == kind_id:
390
+ outgoing.append(relation)
391
+ if direction.incoming and relation.to_kind == kind_id:
392
+ incoming.append(relation)
393
+ return outgoing, incoming
394
+
395
+
396
+ def stored_relation_ids(relations: Iterable[Relation]) -> list[str]:
397
+ return [r.id for r in relations if r.backing is RelationBacking.EXPLICIT]
398
+
399
+
400
+ def foreign_key_relations(relations: Iterable[Relation]) -> list[Relation]:
401
+ return [
402
+ r
403
+ for r in relations
404
+ if r.backing is RelationBacking.FOREIGN_KEY and r.foreign_key_attribute is not None
405
+ ]
406
+
407
+
408
+ def foreign_key_inbound_sql(
409
+ relation: Relation,
410
+ domain_id: str,
411
+ branch: str,
412
+ *,
413
+ target: str,
414
+ limit: str,
415
+ records: str = "mat_records",
416
+ ) -> str:
417
+ """Links of a foreign-key Relation that point at `target` (an SQL expression), read from
418
+ the FROM item `records` (`BranchLayout.records_from()`).
419
+
420
+ The literal predicate and expression match the Relation's per-Attribute index.
421
+ """
422
+ assert relation.foreign_key_attribute is not None
423
+ predicate = escape_colons(predicate_sql(domain_id, branch, relation.from_kind))
424
+ return (
425
+ f"SELECT CAST({sql_literal(relation.id)} AS text) AS relation_id, key AS from_key, "
426
+ f"{values_text(relation.foreign_key_attribute)} AS to_key, "
427
+ f"CAST('{{}}' AS jsonb) AS \"values\", CAST(NULL AS bytea) AS object_hash "
428
+ f"FROM {records} WHERE {predicate} "
429
+ f"AND {values_text(relation.foreign_key_attribute)} = {target} LIMIT {limit}"
430
+ )
431
+
432
+
433
+ async def links_of_record(
434
+ session: AsyncSession,
435
+ domain_id: str,
436
+ branch: str | BranchLayout,
437
+ schema: Schema,
438
+ kind_id: str,
439
+ key: str,
440
+ *,
441
+ direction: Direction = Direction.BOTH,
442
+ relation_ids: Sequence[str] | None = None,
443
+ limit: int,
444
+ runtime: StoreRuntime | None = None,
445
+ ) -> list[LinkRef]:
446
+ """Links of one Record ordered by Relation, from key and to key, at most `limit`."""
447
+ outgoing, incoming = _relations(schema, kind_id, direction, relation_ids)
448
+ layout = await resolve_layout(session, domain_id, branch)
449
+ links_from, records_from = layout.links_from(), layout.records_from("m")
450
+ params: dict[str, Any] = {
451
+ "domain": domain_id,
452
+ "branch": layout.branch,
453
+ "kind": kind_id,
454
+ "key": key,
455
+ "limit": _cap(limit, _runtime(runtime).settings.reads.max_page_rows),
456
+ }
457
+ parts: list[str] = []
458
+ if stored_out := stored_relation_ids(outgoing):
459
+ params["out_rels"] = stored_out
460
+ parts.append(
461
+ f"SELECT {_LINK_SELECT} FROM {links_from} WHERE domain_id = :domain "
462
+ "AND branch = :branch AND relation_id = ANY(CAST(:out_rels AS text[])) "
463
+ "AND from_key = :key LIMIT :limit"
464
+ )
465
+ if stored_in := stored_relation_ids(incoming):
466
+ params["in_rels"] = stored_in
467
+ parts.append(
468
+ f"SELECT {_LINK_SELECT} FROM {links_from} WHERE domain_id = :domain "
469
+ "AND branch = :branch AND relation_id = ANY(CAST(:in_rels AS text[])) "
470
+ "AND to_key = :key LIMIT :limit"
471
+ )
472
+ if foreign_out := foreign_key_relations(outgoing):
473
+ params["fk_rels"] = [r.id for r in foreign_out]
474
+ params["fk_attributes"] = [r.foreign_key_attribute for r in foreign_out]
475
+ parts.append(
476
+ 'SELECT r.relation_id, m.key AS from_key, m."values" ->> r.attribute AS to_key, '
477
+ "CAST('{}' AS jsonb) AS \"values\", CAST(NULL AS bytea) AS object_hash "
478
+ f"FROM {records_from} CROSS JOIN unnest(CAST(:fk_rels AS text[]), "
479
+ "CAST(:fk_attributes AS text[])) AS r(relation_id, attribute) "
480
+ f"WHERE {_SCOPE} AND m.kind_id = :kind AND m.key = :key "
481
+ 'AND m."values" ->> r.attribute IS NOT NULL'
482
+ )
483
+ definer = _definer(runtime)
484
+ for index, relation in enumerate(foreign_key_relations(incoming)):
485
+ if not definer:
486
+ parts.append(
487
+ foreign_key_inbound_sql(
488
+ relation,
489
+ domain_id,
490
+ layout.branch,
491
+ target=":key",
492
+ limit=":limit",
493
+ records=layout.records_from(),
494
+ )
495
+ )
496
+ continue
497
+ params[f"fk_in_{index}"] = relation.id
498
+ params[f"fk_in_kind_{index}"] = relation.from_kind
499
+ params[f"fk_in_attribute_{index}"] = relation.foreign_key_attribute
500
+ parts.append(
501
+ f"SELECT {_LINK_SELECT} FROM {_function('lb_links_in', layout)}(:domain, :branch, "
502
+ f":fk_in_{index}, :key, CAST(:limit AS integer), NULL, :fk_in_kind_{index}, "
503
+ f":fk_in_attribute_{index})"
504
+ )
505
+ if not parts:
506
+ return []
507
+ union = " UNION ALL ".join(f"({part})" for part in parts)
508
+ statement = _links(
509
+ f"SELECT * FROM ({union}) AS links ORDER BY relation_id, from_key, to_key LIMIT :limit"
510
+ )
511
+ return [LinkRef(**row) for row in (await session.execute(statement, params)).mappings()]
512
+
513
+
514
+ def _links(sql: str) -> TextualSelect:
515
+ """A statement returning Link columns, typed so JSON comes back decoded."""
516
+ return text(sql).columns(
517
+ MAT_LINKS.c.relation_id,
518
+ MAT_LINKS.c.from_key,
519
+ MAT_LINKS.c.to_key,
520
+ MAT_LINKS.c["values"],
521
+ MAT_LINKS.c.object_hash,
522
+ )
523
+
524
+
525
+ _LINKS_IN = {
526
+ function: _links(
527
+ f"SELECT * FROM {function}(:domain, :branch, :relation, :to_key, "
528
+ "CAST(:limit AS integer), :after, :from_kind, :attribute)"
529
+ )
530
+ for function in ("lb_links_in", "lb_links_in_overlay")
531
+ }
532
+
533
+
534
+ def _plain_links_in(layout: BranchLayout, relation: Relation, *, after: bool) -> str:
535
+ """`lb_links_in` as plain SQL (parameters `:relation`, `:to_key`, `:limit`, `:after`)."""
536
+ if relation.backing is not RelationBacking.FOREIGN_KEY:
537
+ return (
538
+ f"SELECT {_LINK_SELECT} FROM {layout.links_from()} WHERE domain_id = :domain "
539
+ "AND branch = :branch AND relation_id = :relation AND to_key = :to_key"
540
+ f"{' AND from_key > :after' if after else ''} ORDER BY from_key LIMIT :limit"
541
+ )
542
+ assert relation.foreign_key_attribute is not None
543
+ predicate = escape_colons(predicate_sql(layout.domain_id, layout.branch, relation.from_kind))
544
+ return (
545
+ "SELECT CAST(:relation AS text) AS relation_id, key AS from_key, "
546
+ "CAST(:to_key AS text) AS to_key, CAST('{}' AS jsonb) AS \"values\", "
547
+ f"CAST(NULL AS bytea) AS object_hash FROM {layout.records_from()} WHERE {predicate} "
548
+ f"AND {values_text(relation.foreign_key_attribute)} = :to_key"
549
+ f"{' AND key > :after' if after else ''} ORDER BY key LIMIT :limit"
550
+ )
551
+
552
+
553
+ async def links_to_record(
554
+ session: AsyncSession,
555
+ domain_id: str,
556
+ branch: str | BranchLayout,
557
+ relation: Relation,
558
+ to_key: str,
559
+ *,
560
+ limit: int,
561
+ after: str | None = None,
562
+ runtime: StoreRuntime | None = None,
563
+ ) -> list[LinkRef]:
564
+ """Links of `relation` pointing at the Record `to_key`, in from-key order after `after`.
565
+
566
+ Stored Links are read through the `(domain_id, branch, relation_id, to_key)` index,
567
+ foreign-key backed ones through the per-Attribute index of the from Kind.
568
+ """
569
+ foreign = relation.backing is RelationBacking.FOREIGN_KEY
570
+ if foreign and relation.foreign_key_attribute is None:
571
+ return []
572
+ layout = await resolve_layout(session, domain_id, branch)
573
+ params: dict[str, Any] = {
574
+ "domain": domain_id,
575
+ "branch": layout.branch,
576
+ "relation": relation.id,
577
+ "to_key": to_key,
578
+ "limit": _cap(limit, _runtime(runtime).settings.reads.max_page_rows),
579
+ }
580
+ if _definer(runtime):
581
+ params.update(
582
+ after=after,
583
+ from_kind=relation.from_kind if foreign else None,
584
+ attribute=relation.foreign_key_attribute if foreign else None,
585
+ )
586
+ statement = _LINKS_IN[_function("lb_links_in", layout)]
587
+ else:
588
+ if after is not None:
589
+ params["after"] = after
590
+ statement = _links(_plain_links_in(layout, relation, after=after is not None))
591
+ return [LinkRef(**row) for row in (await session.execute(statement, params)).mappings()]
592
+
593
+
594
+ # Attribute queries ------------------------------------------------------------------------------
595
+
596
+ AttributeValue = str | int | float | bool | Decimal
597
+
598
+
599
+ def _value_text(value: AttributeValue) -> str:
600
+ if isinstance(value, bool):
601
+ return "true" if value else "false"
602
+ return str(value)
603
+
604
+
605
+ def index_spec(schema: Schema, kind_id: str, attribute_id: str) -> IndexSpec | None:
606
+ """The per-Attribute index of an Attribute, `None` when it has none."""
607
+ return plan_indexes(schema).by_attribute().get((kind_id, attribute_id))
608
+
609
+
610
+ async def query_by_attribute(
611
+ session: AsyncSession,
612
+ domain_id: str,
613
+ branch: str | BranchLayout,
614
+ spec: IndexSpec,
615
+ comparison: Comparison,
616
+ value: AttributeValue,
617
+ *,
618
+ limit: int,
619
+ after: tuple[AttributeValue, str] | None = None,
620
+ runtime: StoreRuntime | None = None,
621
+ ) -> list[RecordRow]:
622
+ """Records of `spec`'s Kind whose Attribute compares with `value`, through its index.
623
+
624
+ Equality pages in key order (`after` = `(value, key)` of the last row); ranges page in
625
+ `(value, key)` order and need an ordered index (`spec.ordered`). `value` is canonical.
626
+ """
627
+ if comparison is not Comparison.EQ and not spec.ordered:
628
+ raise ValueError(f"the index of {spec.attribute_id} supports equality only")
629
+ layout = await resolve_layout(session, domain_id, branch)
630
+ expression = spec.expression
631
+ params: dict[str, Any] = {
632
+ "domain": domain_id,
633
+ "value": _value_text(value),
634
+ "limit": _cap(limit, _runtime(runtime).settings.reads.max_page_rows),
635
+ }
636
+ if _definer(runtime):
637
+ ranged = comparison is not Comparison.EQ
638
+ params.update(
639
+ domain=domain_id,
640
+ branch=layout.branch,
641
+ kind=spec.kind_id,
642
+ attribute=spec.attribute_id,
643
+ type=spec.value_type,
644
+ op=comparison.value,
645
+ after_value=_value_text(after[0]) if after is not None and ranged else None,
646
+ after_key=None if after is None else after[1],
647
+ )
648
+ statement = _QUERY_BY_ATTRIBUTE[_function("lb_query_by_attribute", layout)]
649
+ found = (await session.execute(statement, params)).mappings()
650
+ return [RecordRow(**row) for row in found]
651
+ where = [
652
+ f"m.kind_id = {sql_literal(spec.kind_id)}" if layout.overlay else "",
653
+ f"{expression} {_OPERATORS[comparison]} {spec.value_sql(':value')}",
654
+ ]
655
+ if not layout.overlay:
656
+ where[0] = escape_colons(spec.predicate_sql(domain_id, layout.branch))
657
+ if comparison is Comparison.EQ:
658
+ order = "m.key"
659
+ if after is not None:
660
+ where.append("m.key > :after_key")
661
+ params["after_key"] = after[1]
662
+ else:
663
+ order = f"{expression}, m.key"
664
+ if after is not None:
665
+ where.append(f"({expression}, m.key) > ({spec.value_sql(':after_value')}, :after_key)")
666
+ params["after_value"], params["after_key"] = _value_text(after[0]), after[1]
667
+ if layout.overlay:
668
+ # Each half (overlay rows, base rows through the base's partial index) is ordered and
669
+ # cut to the page before the halves are merged.
670
+ tail = f" AND {' AND '.join(where)} ORDER BY {order} LIMIT :limit"
671
+ source = layout.records_arms(tail)
672
+ statement = _records(f"SELECT {RECORD_COLUMNS} FROM {source} ORDER BY {order} LIMIT :limit")
673
+ else:
674
+ statement = _records(
675
+ f"SELECT {RECORD_COLUMNS} FROM {layout.records_from('m')} "
676
+ f"WHERE {' AND '.join(where)} ORDER BY {order} LIMIT :limit"
677
+ )
678
+ return [RecordRow(**row) for row in (await session.execute(statement, params)).mappings()]
679
+
680
+
681
+ _QUERY_BY_ATTRIBUTE = {
682
+ function: _records(
683
+ f"SELECT {record_columns(None)} FROM {function}(:domain, :branch, :kind, :attribute, "
684
+ ":type, :op, "
685
+ ":value, CAST(:limit AS integer), :after_value, :after_key)"
686
+ )
687
+ for function in ("lb_query_by_attribute", "lb_query_by_attribute_overlay")
688
+ }
689
+
690
+
691
+ # Full-text search -------------------------------------------------------------------------------
692
+
693
+ _QUERY = "websearch_to_tsquery(CAST(:config AS regconfig), :query)"
694
+
695
+
696
+ @dataclass(frozen=True, slots=True)
697
+ class TypoTolerance:
698
+ """Typo-tolerant search through the words of the branch (`mat_lexicon`, revision 0018).
699
+
700
+ A query word of at least `min_length` characters that the branch does not hold (nor its
701
+ base, for an overlay) is corrected to its `candidates` closest words at least `threshold`
702
+ similar by trigrams (`pg_trgm`). Search then matches the word or any of them; hits that
703
+ match only through a correction rank after the others, at `weight` times their rank.
704
+ """
705
+
706
+ threshold: float
707
+ weight: float
708
+ candidates: int
709
+ min_length: int
710
+
711
+
712
+ @dataclass(frozen=True, slots=True)
713
+ class Correction:
714
+ """A query word (a lexeme of the text search configuration) and its replacements, best
715
+ first."""
716
+
717
+ word: str
718
+ replacements: tuple[str, ...]
719
+
720
+
721
+ _WORDS = (
722
+ "SELECT DISTINCT replace(replace(t.m[2], '''''', ''''), '\\\\', '\\') AS word "
723
+ "FROM regexp_matches(CAST(" + _QUERY + " AS text), '(!?)''((?:[^'']|'''')*)''', 'g') "
724
+ "AS t(m) WHERE t.m[1] = ''"
725
+ )
726
+ """The lexemes of the query as full-text search reads it (tsquery output doubles quotes and
727
+ backslashes), but negated ones, as in `lb_search_corrections`."""
728
+
729
+ _CORRECTIONS_SQL = text(
730
+ f"""
731
+ SELECT w.word, c.word AS replacement
732
+ FROM ({_WORDS}) AS w
733
+ CROSS JOIN LATERAL (
734
+ SELECT l.word, max(similarity(l.word, w.word)) AS similarity, sum(l.records) AS records
735
+ FROM mat_lexicon l
736
+ WHERE l.domain_id = :domain AND l.branch = ANY(CAST(:branches AS text[]))
737
+ AND l.records > 0 AND l.word % w.word
738
+ AND (CAST(:kinds AS text[]) IS NULL OR l.kind_id = ANY(CAST(:kinds AS text[])))
739
+ GROUP BY l.word
740
+ ORDER BY 2 DESC, 3 DESC, l.word
741
+ LIMIT :candidates) AS c
742
+ WHERE char_length(w.word) >= :min_length AND lb_lexicon_word(w.word)
743
+ AND NOT EXISTS (
744
+ SELECT 1 FROM mat_lexicon x
745
+ WHERE x.domain_id = :domain AND x.branch = ANY(CAST(:branches AS text[]))
746
+ AND x.word = w.word AND x.records > 0
747
+ AND (CAST(:kinds AS text[]) IS NULL OR x.kind_id = ANY(CAST(:kinds AS text[]))))
748
+ ORDER BY w.word, c.similarity DESC, c.records DESC, c.word
749
+ """
750
+ )
751
+ _CORRECTIONS_DEFINER = text(
752
+ "SELECT word, replacement FROM lb_search_corrections(:domain, :branch, "
753
+ "CAST(:kinds AS text[]), CAST(:config AS regconfig), :query, CAST(:threshold AS real), "
754
+ "CAST(:candidates AS integer), CAST(:min_length AS integer))"
755
+ )
756
+ _SIMILARITY_THRESHOLD = text(
757
+ "SELECT set_config('pg_trgm.similarity_threshold', "
758
+ "CAST(CAST(:threshold AS real) AS text), true)"
759
+ )
760
+
761
+
762
+ async def search_corrections(
763
+ session: AsyncSession,
764
+ domain_id: str,
765
+ branch: str | BranchLayout,
766
+ query: str,
767
+ typos: TypoTolerance,
768
+ *,
769
+ kind_ids: Sequence[str] | None = None,
770
+ runtime: StoreRuntime | None = None,
771
+ ) -> list[Correction]:
772
+ """Corrections of the words of `query` (web search syntax) the Records of `kind_ids`
773
+ (`None`: every Kind) on the branch do not hold, from words of those Kinds, in word order;
774
+ empty when every word is known (or too short, has a digit or is negated).
775
+
776
+ The plain SQL path sets `pg_trgm.similarity_threshold` for the transaction, the function
777
+ only for its own call.
778
+ """
779
+ if typos.candidates < 1:
780
+ return []
781
+ settings = _runtime(runtime).settings
782
+ layout = await resolve_layout(session, domain_id, branch)
783
+ params: dict[str, Any] = {
784
+ "domain": domain_id,
785
+ "config": settings.materialize.text_search_config,
786
+ "query": query,
787
+ "candidates": _cap(typos.candidates, settings.reads.max_search_rows),
788
+ "min_length": typos.min_length,
789
+ "kinds": None if kind_ids is None else list(kind_ids),
790
+ }
791
+ if _definer(runtime):
792
+ record_definer_call("lb_search_corrections")
793
+ params.update(branch=layout.branch, threshold=typos.threshold)
794
+ statement = _CORRECTIONS_DEFINER
795
+ else:
796
+ await session.execute(_SIMILARITY_THRESHOLD, {"threshold": typos.threshold})
797
+ params["branches"] = [layout.branch, *([layout.base] if layout.base else [])]
798
+ statement = _CORRECTIONS_SQL
799
+ found: dict[str, list[str]] = {}
800
+ for word, replacement in (await session.execute(statement, params)).all():
801
+ found.setdefault(word, []).append(replacement)
802
+ return [Correction(word, tuple(replacements)) for word, replacements in found.items()]
803
+
804
+
805
+ def _corrected_query(corrections: int, query: str = ":query") -> str:
806
+ """The query (web search syntax; `query` is its SQL expression) with the words of
807
+ `corrections` replacements rewritten, as SQL text over `:word_<n>` and
808
+ `:replacements_<n>` (`lb_lexeme_query`, as `lb_search_corrected` does)."""
809
+ fixed = f"websearch_to_tsquery(CAST(:config AS regconfig), {query})"
810
+ for n in range(corrections):
811
+ fixed = (
812
+ f"ts_rewrite({fixed}, lb_lexeme_query(:word_{n}), "
813
+ f"lb_lexeme_query(:word_{n}, CAST(:replacements_{n} AS text[])))"
814
+ )
815
+ return fixed
816
+
817
+
818
+ def search_sql(*, by_kind: bool, corrections: int = 0, records: str = "mat_records m") -> str:
819
+ """Full-text search: the GIN index finds at most `:candidates` matches of the branch, and
820
+ only those are ranked (`ts_rank_cd` reads each candidate's vector).
821
+
822
+ Parameters: `:domain`, `:branch`, `:config`, `:query` (web search syntax), `:candidates`,
823
+ `:limit`, `:kinds` (text array) when `by_kind`. With `corrections`, `:word_<n>`,
824
+ `:replacements_<n>` (text array) for each and `:weight`: up to `:candidates` more rows
825
+ that match only the corrected query come after the exact matches, ranked at `:weight`
826
+ times their rank. `records` is the FROM item of the rows, aliased `m`
827
+ (`BranchLayout.records_from("m")`). Every row's `truncated` says whether the candidates
828
+ of either kind filled `:candidates` (revision 0035, `lb_search_bounded`).
829
+ """
830
+ kinds = "AND m.kind_id = ANY(CAST(:kinds AS text[])) " if by_kind else ""
831
+
832
+ def rows(exact: bool, match: str) -> str:
833
+ return (
834
+ f"SELECT m.kind_id, m.key, m.title, m.search, {str(exact).lower()} AS exact "
835
+ f"FROM {records} WHERE {_SCOPE} {kinds}AND {match} LIMIT :candidates"
836
+ )
837
+
838
+ if not corrections:
839
+ return (
840
+ f"SELECT c.kind_id, c.key, c.title, "
841
+ f"CAST(ts_rank_cd(c.search, {_QUERY}) AS real) AS score, c.exact, "
842
+ "count(*) OVER () >= :candidates AS truncated "
843
+ f"FROM ({rows(True, f'm.search @@ {_QUERY}')}) AS c "
844
+ f"ORDER BY score DESC, c.kind_id, c.key LIMIT :limit"
845
+ )
846
+ fixed = _corrected_query(corrections)
847
+ score = (
848
+ f"CASE WHEN c.exact THEN ts_rank_cd(c.search, {_QUERY}) "
849
+ f"ELSE CAST(:weight AS real) * ts_rank_cd(c.search, {fixed}) END"
850
+ )
851
+ return (
852
+ f"SELECT c.kind_id, c.key, c.title, CAST({score} AS real) AS score, c.exact, "
853
+ "(count(*) FILTER (WHERE c.exact) OVER () >= :candidates "
854
+ "OR count(*) FILTER (WHERE NOT c.exact) OVER () >= :candidates) AS truncated "
855
+ f"FROM (({rows(True, f'm.search @@ {_QUERY}')}) UNION ALL "
856
+ f"({rows(False, f'm.search @@ {fixed} AND NOT m.search @@ {_QUERY}')})) AS c "
857
+ f"ORDER BY c.exact DESC, score DESC, c.kind_id, c.key LIMIT :limit"
858
+ )
859
+
860
+
861
+ @lru_cache(maxsize=256)
862
+ def _search(records: str, by_kind: bool, corrections: int) -> TextClause:
863
+ return text(search_sql(by_kind=by_kind, corrections=corrections, records=records))
864
+
865
+
866
+ _SEARCH_DEFINER = {
867
+ function: text(
868
+ f"SELECT * FROM {function}(:domain, :branch, CAST(:kinds AS text[]), "
869
+ "CAST(:config AS regconfig), :query, CAST(:candidates AS integer), "
870
+ "CAST(:limit AS integer), CAST(:words AS text[]), CAST(:replacements AS text[]), "
871
+ "CAST(:weight AS real))"
872
+ )
873
+ for function in ("lb_search_bounded", "lb_search_bounded_overlay")
874
+ }
875
+
876
+
877
+ _BM25_DEFINER = {
878
+ function: text(
879
+ f"SELECT * FROM {function}(:domain, :branch, CAST(:kinds AS text[]), "
880
+ "CAST(:config AS regconfig), :query, CAST(:candidates AS integer), "
881
+ "CAST(:limit AS integer), CAST(:match AS jsonb), CAST(:match_exact AS boolean), "
882
+ "CAST(:words AS text[]), CAST(:replacements AS text[]), CAST(:weight AS real), "
883
+ "CAST(:fixed AS jsonb), CAST(:fixed_exact AS boolean))"
884
+ )
885
+ for function in ("lb_search_bm25", "lb_search_bm25_overlay")
886
+ }
887
+
888
+
889
+ @lru_cache(maxsize=64)
890
+ def _tsqueries(corrections: int) -> TextClause:
891
+ """The query as full-text search reads it, and with its corrections, as tsquery text."""
892
+ return text(f"SELECT CAST({_QUERY} AS text), CAST({_corrected_query(corrections)} AS text)")
893
+
894
+
895
+ SUPERSET_FIRST = 4
896
+ """A superset query first ranks this many times the limit of candidates..."""
897
+ SUPERSET_MIN = 64
898
+ """...and at least this many."""
899
+
900
+
901
+ @dataclass(frozen=True, slots=True)
902
+ class _Bm25Plan:
903
+ """BM25 queries of a search: `match` for the query as written (`None`: it has no lexeme
904
+ and matches nothing), `fixed` for the hits found only through a correction."""
905
+
906
+ match: bm25.Bm25Query | None
907
+ fixed: bm25.Bm25Query | None = None
908
+
909
+
910
+ async def _bm25_plan(
911
+ session: AsyncSession, query: str, fixes: Sequence[Correction], config: str
912
+ ) -> _Bm25Plan | None:
913
+ """The BM25 queries of `query`, or `None` when it has no translation the definer
914
+ functions accept (one round trip)."""
915
+ params: dict[str, Any] = {"config": config, "query": query}
916
+ for n, correction in enumerate(fixes):
917
+ params[f"word_{n}"] = correction.word
918
+ params[f"replacements_{n}"] = list(correction.replacements)
919
+ written, corrected = (await session.execute(_tsqueries(len(fixes)), params)).one()
920
+ if not written:
921
+ return _Bm25Plan(None)
922
+ match = bm25.translate(written)
923
+ if match is None:
924
+ return None
925
+ if not fixes or corrected == written:
926
+ return _Bm25Plan(match)
927
+ widened = bm25.translate(corrected)
928
+ fixed = None if widened is None else bm25.corrected_only(match, widened)
929
+ return None if fixed is None else _Bm25Plan(match, fixed)
930
+
931
+
932
+ async def _bm25_rows(
933
+ session: AsyncSession,
934
+ layout: BranchLayout,
935
+ params: dict[str, Any],
936
+ fixes: Sequence[Correction],
937
+ ) -> list[Any] | None:
938
+ """Rows of `lb_search_bm25` for the definer parameters of `search_records`; `None` when
939
+ the query has no BM25 translation (the caller ranks it the default way)."""
940
+ plan = await _bm25_plan(session, params["query"], fixes, params["config"])
941
+ if plan is None:
942
+ return None
943
+ if plan.match is None:
944
+ return []
945
+ function = _function("lb_search_bm25", layout)
946
+ values = {
947
+ **params,
948
+ "match": plan.match.json(),
949
+ "match_exact": plan.match.exact,
950
+ "fixed": None if plan.fixed is None else plan.fixed.json(),
951
+ "fixed_exact": True if plan.fixed is None else plan.fixed.exact,
952
+ }
953
+ # A superset query (a phrase) is rechecked over its first candidates by score; the rows
954
+ # holding the words in sequence rank first, so a few times the limit is usually enough
955
+ # for an exact answer, and only a cut one is asked again with every candidate.
956
+ superset = not plan.match.exact or (plan.fixed is not None and not plan.fixed.exact)
957
+ every = int(values["candidates"])
958
+ first = (
959
+ min(every, max(SUPERSET_FIRST * int(values["limit"]), SUPERSET_MIN)) if superset else every
960
+ )
961
+ rows = list(
962
+ (await session.execute(_BM25_DEFINER[function], {**values, "candidates": first})).all()
963
+ )
964
+ if first < every and not _bm25_failed(rows) and (not rows or rows[0][5]):
965
+ record_definer_call(function)
966
+ rows = list((await session.execute(_BM25_DEFINER[function], values)).all())
967
+ failed = _bm25_failed(rows)
968
+ if failed is not None:
969
+ # The index could not serve the call (dropped since `bm25.ready` looked, the library
970
+ # not loaded): the function caught the error, so the transaction goes on.
971
+ log.warning("BM25 search failed (%s: %s); ranking the default way", failed[1], failed[2])
972
+ bm25.forget()
973
+ return None
974
+ return rows
975
+
976
+
977
+ def _bm25_failed(rows: Sequence[Any]) -> Any | None:
978
+ """The error row of `lb_search_bm25` (no Kind; SQLSTATE and message), if any."""
979
+ return next((row for row in rows if row[0] is None), None)
980
+
981
+
982
+ class SearchHits(list[SearchHit]):
983
+ """Hits of `search_records`, best first. `truncated`: a candidate cap cut the ranking, so
984
+ the hits are the best of the first matches in index order, not of every match.
985
+ `backend`: what ranked them (`bm25` only when the BM25 index served every statement)."""
986
+
987
+ def __init__(
988
+ self,
989
+ hits: Iterable[SearchHit] = (),
990
+ *,
991
+ truncated: bool = False,
992
+ backend: SearchBackend = "postgres",
993
+ ) -> None:
994
+ super().__init__(hits)
995
+ self.truncated = truncated
996
+ self.backend = backend
997
+
998
+
999
+ @dataclass(frozen=True, slots=True)
1000
+ class CommonTerms:
1001
+ """How many matches search ranks, chosen per query from the word lexicon (`mat_lexicon`
1002
+ counts, the base's added on an overlay).
1003
+
1004
+ The lexicon gives an upper bound of the matches of a query: a lexeme matches at most the
1005
+ Records counted for it, `a & b` at most the fewer of the two, `a | b` at most their sum
1006
+ (a negation or a word the lexicon does not hold, one with a digit, leaves the bound
1007
+ open). Within `docs`, every match is ranked (`docs` candidates: an exact ranking).
1008
+ Otherwise, when every term is very common, only the first `candidates` matches in index
1009
+ order are ranked and the result says it is truncated, so that callers narrow the query.
1010
+ """
1011
+
1012
+ docs: int
1013
+ candidates: int
1014
+
1015
+
1016
+ _TERM_COUNTS_SQL = """
1017
+ SELECT q.query, w.word, (
1018
+ SELECT sum(l.records) FROM mat_lexicon l
1019
+ WHERE l.domain_id = :domain AND l.branch = ANY(CAST(:branches AS text[]))
1020
+ {kinds}AND l.word = w.word AND l.records > 0) AS records
1021
+ FROM (SELECT CAST(websearch_to_tsquery(CAST(:config AS regconfig), :query) AS text) AS query) q
1022
+ LEFT JOIN LATERAL (
1023
+ SELECT DISTINCT x.word FROM ({words} UNION ALL
1024
+ SELECT unnest(CAST(:extra AS text[]))) AS x) AS w ON true
1025
+ """
1026
+ _TERM_COUNTS = {
1027
+ by_kind: text(
1028
+ _TERM_COUNTS_SQL.format(
1029
+ kinds="AND l.kind_id = ANY(CAST(:kinds AS text[])) " if by_kind else "",
1030
+ words=_WORDS.replace("CAST(" + _QUERY + " AS text)", "q.query"),
1031
+ )
1032
+ )
1033
+ for by_kind in (False, True)
1034
+ }
1035
+ """The query as full-text search reads it (tsquery text), and the Records of the branch that
1036
+ hold each of its lexemes but the negated ones, and each word of `:extra` (one row each)."""
1037
+
1038
+ _TSQUERY_TOKENS = re.compile(r"'((?:[^']|'')*)'(?::[*A-D]+)?|<\d+>|<->|[&|!()]")
1039
+
1040
+
1041
+ def _lexicon_word(word: str) -> bool:
1042
+ """Whether the lexicon holds `word` when the branch does (`lb_lexicon_word`)."""
1043
+ return not any(c.isdigit() for c in word) and len(word) <= 64
1044
+
1045
+
1046
+ @dataclass(frozen=True, slots=True)
1047
+ class MatchBound:
1048
+ """Upper bounds of the Records a query matches (`None`: the lexicon cannot say)."""
1049
+
1050
+ query: int | None
1051
+ """Of the query itself."""
1052
+ any_term: int | None
1053
+ """Of any of its positive lexemes, the alternatives of partial matching."""
1054
+
1055
+
1056
+ def match_bound(tsquery: str, records: Callable[[str], int | None]) -> MatchBound:
1057
+ """The bounds of `tsquery` (its text form) from `records(lexeme)`, the Records holding a
1058
+ lexeme (`None` when unknown). Operators bind as tsquery prints them: `!`, then `<->` and
1059
+ `<N>`, then `&`, then `|`."""
1060
+ tokens: list[str | re.Match[str]] = [
1061
+ m if m.group(1) is not None else m.group(0) for m in _TSQUERY_TOKENS.finditer(tsquery)
1062
+ ]
1063
+ if not tokens:
1064
+ return MatchBound(0, 0)
1065
+ position = 0
1066
+ positive: list[int | None] = []
1067
+
1068
+ def peek() -> str | re.Match[str] | None:
1069
+ return tokens[position] if position < len(tokens) else None
1070
+
1071
+ def take() -> str | re.Match[str]:
1072
+ nonlocal position
1073
+ position += 1
1074
+ return tokens[position - 1]
1075
+
1076
+ def operand(negated: bool) -> int | None:
1077
+ token = take()
1078
+ if token == "!":
1079
+ operand(not negated)
1080
+ return None # NOT x: open
1081
+ if token == "(":
1082
+ found = alternatives(negated)
1083
+ take() # ")"
1084
+ return found
1085
+ assert isinstance(token, re.Match)
1086
+ count = records(token.group(1).replace("''", "'"))
1087
+ if not negated:
1088
+ positive.append(count)
1089
+ return count
1090
+
1091
+ def conjunction(negated: bool, operator: str) -> int | None:
1092
+ inner = (lambda n: conjunction(n, "<")) if operator == "&" else operand
1093
+ bound = inner(negated)
1094
+ while isinstance(token := peek(), str) and token.startswith(operator):
1095
+ take()
1096
+ other = inner(negated)
1097
+ # x AND y: the smaller bound; an open side leaves the other.
1098
+ bound = other if bound is None else bound if other is None else min(bound, other)
1099
+ return bound
1100
+
1101
+ def alternatives(negated: bool) -> int | None:
1102
+ bound = conjunction(negated, "&")
1103
+ while peek() == "|":
1104
+ take()
1105
+ other = conjunction(negated, "&")
1106
+ bound = None if bound is None or other is None else bound + other
1107
+ return bound
1108
+
1109
+ bound = alternatives(False)
1110
+ any_term = None if None in positive else sum(c for c in positive if c is not None)
1111
+ return MatchBound(bound, any_term)
1112
+
1113
+
1114
+ async def _match_bound(
1115
+ session: AsyncSession,
1116
+ layout: BranchLayout,
1117
+ query: str,
1118
+ fixes: Sequence[Correction],
1119
+ kind_ids: Sequence[str] | None,
1120
+ config: str,
1121
+ ) -> MatchBound:
1122
+ """`match_bound` of `query` with the lexicon's counts, each corrected word counting the
1123
+ Records of its replacements too (one round trip)."""
1124
+ extra = sorted({r for c in fixes for r in c.replacements})
1125
+ params: dict[str, Any] = {
1126
+ "domain": layout.domain_id,
1127
+ "branches": [layout.branch, *([layout.base] if layout.base else [])],
1128
+ "config": config,
1129
+ "query": query,
1130
+ "extra": extra,
1131
+ }
1132
+ if kind_ids is not None:
1133
+ params["kinds"] = list(kind_ids)
1134
+ rows = (await session.execute(_TERM_COUNTS[kind_ids is not None], params)).all()
1135
+ if not rows:
1136
+ return MatchBound(0, 0)
1137
+ tsquery = rows[0][0] or ""
1138
+ counts = {word: int(found or 0) for _, word, found in rows if word is not None}
1139
+ widened = {c.word: c.replacements for c in fixes}
1140
+
1141
+ def records(word: str) -> int | None:
1142
+ if not _lexicon_word(word):
1143
+ return None
1144
+ return counts.get(word, 0) + sum(counts.get(r, 0) for r in widened.get(word, ()))
1145
+
1146
+ return match_bound(tsquery, records)
1147
+
1148
+
1149
+ def _candidates(wanted: int, ceiling: int) -> int:
1150
+ """The candidates argument for `wanted` matches: one more, so that the statement fills it
1151
+ (`truncated`) only when more matches exist than were wanted."""
1152
+ return max(1, min(wanted + 1, ceiling))
1153
+
1154
+
1155
+ def _wanted(bound: int | None, common: CommonTerms) -> int:
1156
+ """Matches to rank: all of them when the bound is within `common.docs`, else a sample."""
1157
+ return common.docs if bound is not None and bound <= common.docs else common.candidates
1158
+
1159
+
1160
+ @dataclass(frozen=True, slots=True)
1161
+ class PartialMatching:
1162
+ """Ranked partial matching of multi-word queries.
1163
+
1164
+ Search matches every term of the query first. When that finds fewer than `min_results`
1165
+ Records, the Records holding at least one term follow (`partial_terms`): at most
1166
+ `candidates` of them, best by `ts_rank_cd` of the terms joined by OR, are scored
1167
+ `weight * (rank + term_bonus * matched terms)` and ranked after every full match.
1168
+ `stop_words` names a text search configuration whose stop words are left out of the
1169
+ terms (`None`: only those of the search configuration, which drops them anyway).
1170
+ """
1171
+
1172
+ min_results: int
1173
+ weight: float
1174
+ term_bonus: float
1175
+ candidates: int
1176
+ stop_words: str | None = None
1177
+
1178
+
1179
+ @dataclass(frozen=True, slots=True)
1180
+ class PartialQuery:
1181
+ """The positive terms of a query (words and quoted phrases) and its negated ones."""
1182
+
1183
+ terms: tuple[str, ...]
1184
+ negated: tuple[str, ...] = ()
1185
+
1186
+ @property
1187
+ def text(self) -> str:
1188
+ """The query matching any term, in web search syntax, each alternative carrying the
1189
+ negations of the original."""
1190
+ suffix = "".join(f" {n}" for n in self.negated)
1191
+ return " or ".join(f"{t}{suffix}" for t in self.terms)
1192
+
1193
+
1194
+ _TOKENS = re.compile(r'(-?)"([^"]*)"?|([^\s"]+)')
1195
+
1196
+
1197
+ def partial_query(query: str) -> PartialQuery | None:
1198
+ """The terms of `query` (web search syntax), or `None` when it has no positive term.
1199
+ Negations (`-word`, `-"a phrase"`) apply to every alternative and `or` is dropped; a term
1200
+ repeated in another case counts once."""
1201
+ terms: dict[str, str] = {}
1202
+ negated: list[str] = []
1203
+ for sign, phrase, word in _TOKENS.findall(query):
1204
+ if word:
1205
+ if word.lower() == "or" or word == "-":
1206
+ continue
1207
+ if word.startswith("-"):
1208
+ negated.append(word)
1209
+ continue
1210
+ term = word
1211
+ else:
1212
+ if not phrase.strip():
1213
+ continue
1214
+ term = f'"{phrase.strip()}"'
1215
+ if sign:
1216
+ negated.append(f"-{term}")
1217
+ continue
1218
+ terms.setdefault(term.lower(), term)
1219
+ if not terms:
1220
+ return None
1221
+ return PartialQuery(tuple(terms.values()), tuple(negated))
1222
+
1223
+
1224
+ _CONTENT_TERMS = text(
1225
+ "SELECT t.term FROM unnest(CAST(:terms AS text[])) WITH ORDINALITY AS t(term, n) "
1226
+ "WHERE numnode(websearch_to_tsquery(CAST(:config AS regconfig), t.term)) > 0 ORDER BY t.n"
1227
+ )
1228
+
1229
+
1230
+ async def partial_terms(
1231
+ session: AsyncSession, query: str, stop_words: str | None
1232
+ ) -> PartialQuery | None:
1233
+ """The terms partial matching searches for `query`: `None` when it has fewer than two
1234
+ terms (a partial match would be a full one) or none is left once the stop words of the
1235
+ configuration `stop_words` are dropped (one round trip)."""
1236
+ found = partial_query(query)
1237
+ if found is None or len(found.terms) < 2:
1238
+ return None
1239
+ if stop_words is None:
1240
+ return found
1241
+ rows = await session.execute(_CONTENT_TERMS, {"terms": list(found.terms), "config": stop_words})
1242
+ kept = tuple(rows.scalars())
1243
+ return PartialQuery(kept, found.negated) if kept else None
1244
+
1245
+
1246
+ def _matched_sql(records: str, corrections: int, *, overlay: bool) -> str:
1247
+ """How many of `:terms` (each in web search syntax, its corrected words widened as in
1248
+ `search_sql`) each Record of `:kinds`/`:keys` holds, read by primary key."""
1249
+ fixed = _corrected_query(corrections, "t.term")
1250
+ matched = (
1251
+ f"(SELECT count(*) FROM unnest(CAST(:terms AS text[])) AS t(term) "
1252
+ f"WHERE m.search @@ {fixed})"
1253
+ )
1254
+ identities = "unnest(CAST(:kinds AS text[]), CAST(:keys AS text[])) AS i(kind_id, key)"
1255
+ if overlay:
1256
+ # Each identity looked up laterally reaches both halves of the overlay by key.
1257
+ return (
1258
+ f"SELECT i.kind_id, i.key, m.matched FROM {identities} "
1259
+ f"CROSS JOIN LATERAL (SELECT {matched} AS matched FROM {records} "
1260
+ f"WHERE {_SCOPE} AND m.kind_id = i.kind_id AND m.key = i.key LIMIT 1) AS m"
1261
+ )
1262
+ return (
1263
+ f"SELECT i.kind_id, i.key, {matched} AS matched FROM {identities} "
1264
+ f"JOIN {records} ON {_SCOPE} AND m.kind_id = i.kind_id AND m.key = i.key"
1265
+ )
1266
+
1267
+
1268
+ @lru_cache(maxsize=256)
1269
+ def _matched(records: str, corrections: int, overlay: bool) -> TextClause:
1270
+ return text(_matched_sql(records, corrections, overlay=overlay))
1271
+
1272
+
1273
+ async def search_records(
1274
+ session: AsyncSession,
1275
+ domain_id: str,
1276
+ branch: str | BranchLayout,
1277
+ query: str,
1278
+ *,
1279
+ limit: int,
1280
+ kind_ids: Sequence[str] | None = None,
1281
+ corrections: Sequence[Correction] = (),
1282
+ correction_weight: float = 1.0,
1283
+ partial: PartialMatching | None = None,
1284
+ common: CommonTerms | None = None,
1285
+ backend: SearchBackend = "postgres",
1286
+ runtime: StoreRuntime | None = None,
1287
+ ) -> SearchHits:
1288
+ """Best `limit` lexical matches of `query` (web search syntax) on the branch head.
1289
+
1290
+ `kind_ids` restricts the Kinds searched (`None`: every Kind). `corrections`
1291
+ (`search_corrections`) widens each corrected word to its replacements: hits found only
1292
+ that way have `exact` false and come after the others, ranked at `correction_weight`
1293
+ times their rank. With `partial`, fewer than `partial.min_results` hits are followed by
1294
+ Records that hold only some of the terms (`partial` true; `PartialMatching`).
1295
+
1296
+ At most `store.reads.search_candidates` matches are ranked, or with `common` as many as
1297
+ the word lexicon says the query needs (`CommonTerms`, one more round trip); the result
1298
+ is `truncated` when a statement filled its candidates.
1299
+
1300
+ `backend="bm25"` ranks with the BM25 index (`lorebase_store.bm25`, `lb_search_bm25`)
1301
+ when the database has a valid one and the definer functions are in use; otherwise, and
1302
+ for a query it cannot translate, the default ranking above. BM25 ranks every match: only
1303
+ queries it matches as a superset (phrases) rank their first candidates, `common.candidates`
1304
+ or `store.reads.search_candidates` of them, and are `truncated` when that cut the result.
1305
+ """
1306
+ settings = _runtime(runtime).settings
1307
+ layout = await resolve_layout(session, domain_id, branch)
1308
+ fixes = [c for c in corrections if c.replacements]
1309
+ cap = _cap(limit, settings.reads.max_search_rows)
1310
+ config = settings.materialize.text_search_config
1311
+ ceiling = settings.reads.definer_ceilings.candidates
1312
+ definer = _definer(runtime)
1313
+ ranked: SearchBackend = "postgres"
1314
+ if backend == "bm25" and definer and await bm25.ready(session):
1315
+ ranked = "bm25"
1316
+ bound = None
1317
+ wanted = settings.reads.search_candidates
1318
+ if common is not None and ranked == "bm25":
1319
+ wanted = common.candidates
1320
+ elif common is not None:
1321
+ bound = await _match_bound(session, layout, query, fixes, kind_ids, config)
1322
+ wanted = _wanted(bound.query, common)
1323
+ params: dict[str, Any] = {
1324
+ "domain": domain_id,
1325
+ "branch": layout.branch,
1326
+ "config": config,
1327
+ "candidates": _candidates(wanted, ceiling),
1328
+ "weight": correction_weight,
1329
+ }
1330
+ kinds = None if kind_ids is None else list(kind_ids)
1331
+ if definer:
1332
+ params.update(
1333
+ kinds=kinds,
1334
+ words=[c.word for c in fixes for _ in c.replacements],
1335
+ replacements=[r for c in fixes for r in c.replacements],
1336
+ )
1337
+ else:
1338
+ for n, correction in enumerate(fixes):
1339
+ params[f"word_{n}"] = correction.word
1340
+ params[f"replacements_{n}"] = list(correction.replacements)
1341
+ if kinds is not None:
1342
+ params["kinds"] = kinds
1343
+
1344
+ async def rank(given: dict[str, Any]) -> list[Any]:
1345
+ """One search statement: the BM25 function when it serves the query, else the
1346
+ default one (and every later statement of this search too)."""
1347
+ nonlocal ranked, bound
1348
+ if ranked == "bm25":
1349
+ found = await _bm25_rows(session, layout, given, fixes)
1350
+ if found is not None:
1351
+ return found
1352
+ ranked = "postgres"
1353
+ if common is not None and bound is None:
1354
+ # As without BM25: the candidates the lexicon says the query needs.
1355
+ bound = await _match_bound(session, layout, query, fixes, kind_ids, config)
1356
+ wanted = _wanted(bound.query if given["query"] == query else bound.any_term, common)
1357
+ given = {**given, "candidates": _candidates(wanted, ceiling)}
1358
+ if definer:
1359
+ statement = _SEARCH_DEFINER[_function("lb_search_bounded", layout)]
1360
+ else:
1361
+ records = layout.records_from("m", probe=False)
1362
+ statement = _search(records, kinds is not None, len(fixes))
1363
+ return list((await session.execute(statement, given)).all())
1364
+
1365
+ rows = await rank({**params, "query": query, "limit": cap})
1366
+ hits = SearchHits(
1367
+ (
1368
+ SearchHit(kind_id, key, title, float(score), bool(exact))
1369
+ for kind_id, key, title, score, exact, _ in rows
1370
+ ),
1371
+ truncated=bool(rows and rows[0][5]),
1372
+ backend=ranked,
1373
+ )
1374
+ if partial is None or len(hits) >= min(partial.min_results, cap):
1375
+ return hits
1376
+ alternatives = await partial_terms(session, query, partial.stop_words)
1377
+ if alternatives is None:
1378
+ return hits
1379
+ found = {(h.kind_id, h.key) for h in hits}
1380
+ others: dict[str, Any] = {
1381
+ "query": alternatives.text,
1382
+ "limit": _cap(partial.candidates, settings.reads.max_search_rows),
1383
+ }
1384
+ if common is not None and bound is not None:
1385
+ others["candidates"] = _candidates(_wanted(bound.any_term, common), ceiling)
1386
+ found_rows = await rank({**params, **others})
1387
+ truncated = hits.truncated or bool(found_rows and found_rows[0][5])
1388
+ matches = [
1389
+ (kind_id, key, title, float(score), bool(exact))
1390
+ for kind_id, key, title, score, exact, _ in found_rows
1391
+ if (kind_id, key) not in found
1392
+ ]
1393
+ if not matches:
1394
+ return SearchHits(hits, truncated=truncated, backend=ranked)
1395
+ matched = await _matched_terms(
1396
+ session,
1397
+ domain_id,
1398
+ layout,
1399
+ [(c[0], c[1]) for c in matches],
1400
+ alternatives,
1401
+ fixes,
1402
+ params["config"],
1403
+ )
1404
+ scored = [
1405
+ SearchHit(
1406
+ kind_id,
1407
+ key,
1408
+ title,
1409
+ partial.weight * (rank + partial.term_bonus * matched.get((kind_id, key), 0)),
1410
+ exact,
1411
+ partial=True,
1412
+ )
1413
+ for kind_id, key, title, rank, exact in matches
1414
+ ]
1415
+ scored.sort(key=lambda h: (-h.score, h.kind_id, h.key))
1416
+ return SearchHits([*hits, *scored[: cap - len(hits)]], truncated=truncated, backend=ranked)
1417
+
1418
+
1419
+ async def _matched_terms(
1420
+ session: AsyncSession,
1421
+ domain_id: str,
1422
+ layout: BranchLayout,
1423
+ identities: Sequence[tuple[str, str]],
1424
+ alternatives: PartialQuery,
1425
+ fixes: Sequence[Correction],
1426
+ config: str,
1427
+ ) -> dict[tuple[str, str], int]:
1428
+ """Terms of `alternatives` each Record holds (corrections applied), by primary key."""
1429
+ params: dict[str, Any] = {
1430
+ "domain": domain_id,
1431
+ "branch": layout.branch,
1432
+ "config": config,
1433
+ "kinds": [kind for kind, _ in identities],
1434
+ "keys": [key for _, key in identities],
1435
+ "terms": list(alternatives.terms),
1436
+ }
1437
+ for n, correction in enumerate(fixes):
1438
+ params[f"word_{n}"] = correction.word
1439
+ params[f"replacements_{n}"] = list(correction.replacements)
1440
+ statement = _matched(layout.records_from("m"), len(fixes), layout.overlay)
1441
+ rows = (await session.execute(statement, params)).all()
1442
+ return {(kind_id, key): int(count) for kind_id, key, count in rows}
1443
+
1444
+
1445
+ # Title matching (entity resolution) -------------------------------------------------------------
1446
+
1447
+ _SIMILAR_TITLES = text(
1448
+ "SELECT m.kind_id, m.key, m.title, "
1449
+ "CAST(GREATEST(CASE WHEN lower(m.title) = lower(:query) THEN 1 ELSE 0 END, "
1450
+ "similarity(m.title, :query)) AS real) AS score "
1451
+ f"FROM mat_records m WHERE {_SCOPE} AND m.kind_id = ANY(CAST(:kinds AS text[])) "
1452
+ "AND m.title IS NOT NULL "
1453
+ "AND (lower(m.title) = lower(:query) OR similarity(m.title, :query) >= :threshold) "
1454
+ "ORDER BY score DESC, m.kind_id, m.key LIMIT :limit"
1455
+ )
1456
+
1457
+
1458
+ async def similar_titles(
1459
+ session: AsyncSession,
1460
+ domain_id: str,
1461
+ branch: str,
1462
+ query: str,
1463
+ *,
1464
+ kind_ids: Sequence[str],
1465
+ threshold: float,
1466
+ limit: int,
1467
+ runtime: StoreRuntime | None = None,
1468
+ ) -> list[SearchHit]:
1469
+ """Records of `kind_ids` whose title equals `query` ignoring case (score 1) or is at least
1470
+ `threshold` similar to it by trigrams (`pg_trgm`), best first.
1471
+
1472
+ Meant for entity resolution in batch jobs: under row-level security the trigram index
1473
+ cannot serve the condition, so the statement scans the titles of the named Kinds.
1474
+ """
1475
+ if not kind_ids or not query.strip():
1476
+ return []
1477
+ settings = _runtime(runtime).settings
1478
+ rows = (
1479
+ await session.execute(
1480
+ _SIMILAR_TITLES,
1481
+ {
1482
+ "domain": domain_id,
1483
+ "branch": branch,
1484
+ "kinds": list(kind_ids),
1485
+ "query": query,
1486
+ "threshold": threshold,
1487
+ "limit": _cap(limit, settings.reads.max_search_rows),
1488
+ },
1489
+ )
1490
+ ).all()
1491
+ return [SearchHit(kind_id, key, title, float(score)) for kind_id, key, title, score in rows]