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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- lorebase_store-0.2.3.dist-info/WHEEL +4 -0
lorebase_store/reads.py
ADDED
|
@@ -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]
|