lorebase-keeper 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_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
"""Compile resolved DSL plans to SQL over the materialized head of one branch.
|
|
2
|
+
|
|
3
|
+
Everything comes from the plan, which holds ids and canonical operands; every value, id and
|
|
4
|
+
key reaches Postgres as a bound parameter, never as SQL text, except where a per-Attribute
|
|
5
|
+
partial index must match: comparisons on an indexed Attribute of one Kind render the index
|
|
6
|
+
predicate (Domain, branch, Kind) and the Attribute id as escaped literals and compare the
|
|
7
|
+
index expression (`lorebase_store.indexes.IndexSpec`). Attribute values are read from
|
|
8
|
+
`values` (or `derived`) by Attribute id and cast per type: integer and decimal to numeric,
|
|
9
|
+
float to double precision, date, timestamp and duration to their Postgres types, text types as
|
|
10
|
+
text. Equality on scalar Attributes of one Kind uses JSONB containment (`values @> {...}`) so
|
|
11
|
+
the GIN index on `values` serves it.
|
|
12
|
+
|
|
13
|
+
`via` and `count` compile to correlated subqueries over the Links of the Relation. Relations
|
|
14
|
+
backed by a foreign key Attribute have no stored Links; their Links are read from the `from`
|
|
15
|
+
Records holding the Attribute. A path Derivation reads its stored Links when materialized and
|
|
16
|
+
is composed from its steps otherwise (one lateral read per intermediate Record, bounded by the
|
|
17
|
+
path fan-out).
|
|
18
|
+
|
|
19
|
+
Ordering is keyset based: each ordering term sorts by `(value IS NULL, value)` so that absent
|
|
20
|
+
values come last in both directions, then by Kind id (Trait targets) and key, which makes
|
|
21
|
+
every position unique. `SortKey` carries what a cursor needs to resume after a row.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import date, datetime, timedelta
|
|
29
|
+
from decimal import Decimal
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from sqlalchemy import (
|
|
33
|
+
BigInteger,
|
|
34
|
+
Boolean,
|
|
35
|
+
ColumnElement,
|
|
36
|
+
Date,
|
|
37
|
+
DateTime,
|
|
38
|
+
FromClause,
|
|
39
|
+
Interval,
|
|
40
|
+
Numeric,
|
|
41
|
+
Select,
|
|
42
|
+
Text,
|
|
43
|
+
and_,
|
|
44
|
+
case,
|
|
45
|
+
cast,
|
|
46
|
+
exists,
|
|
47
|
+
false,
|
|
48
|
+
func,
|
|
49
|
+
literal,
|
|
50
|
+
not_,
|
|
51
|
+
or_,
|
|
52
|
+
over,
|
|
53
|
+
select,
|
|
54
|
+
true,
|
|
55
|
+
)
|
|
56
|
+
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, JSONB, REGCONFIG
|
|
57
|
+
from sqlalchemy.sql.elements import KeyedColumnElement
|
|
58
|
+
from sqlalchemy.types import TypeEngine
|
|
59
|
+
|
|
60
|
+
from lorebase_core.model import LinkDirection, PathRelation, Relation, RelationBacking
|
|
61
|
+
from lorebase_core.query import (
|
|
62
|
+
Comparison,
|
|
63
|
+
Condition,
|
|
64
|
+
Conjunction,
|
|
65
|
+
Disjunction,
|
|
66
|
+
FieldRef,
|
|
67
|
+
FieldSource,
|
|
68
|
+
FullText,
|
|
69
|
+
Hop,
|
|
70
|
+
LinkCount,
|
|
71
|
+
LinkExists,
|
|
72
|
+
Negation,
|
|
73
|
+
Operator,
|
|
74
|
+
Ordering,
|
|
75
|
+
Target,
|
|
76
|
+
)
|
|
77
|
+
from lorebase_core.types import AttributeType, decode_value
|
|
78
|
+
from lorebase_store.indexes import IndexMethod, IndexSpec
|
|
79
|
+
from lorebase_store.layout import BranchLayout, lineage_column
|
|
80
|
+
from lorebase_store.settings import TraverseSettings
|
|
81
|
+
|
|
82
|
+
EARTH_RADIUS_M = 6_371_008.8
|
|
83
|
+
"""Mean Earth radius (IUGG) used by `near`."""
|
|
84
|
+
|
|
85
|
+
_TYPED: dict[AttributeType, TypeEngine[Any]] = {
|
|
86
|
+
AttributeType.INTEGER: Numeric(),
|
|
87
|
+
AttributeType.DECIMAL: Numeric(),
|
|
88
|
+
AttributeType.FLOAT: DOUBLE_PRECISION(),
|
|
89
|
+
AttributeType.BOOLEAN: Boolean(),
|
|
90
|
+
AttributeType.DATE: Date(),
|
|
91
|
+
AttributeType.TIMESTAMP: DateTime(timezone=True),
|
|
92
|
+
AttributeType.DURATION: Interval(),
|
|
93
|
+
}
|
|
94
|
+
_SCALAR_JSON = frozenset(
|
|
95
|
+
{AttributeType.ARRAY, AttributeType.JSON, AttributeType.GEO_POINT}
|
|
96
|
+
) # compared as JSONB, never through containment of a scalar
|
|
97
|
+
|
|
98
|
+
_INDEXED_OPERATORS = frozenset(
|
|
99
|
+
{
|
|
100
|
+
Operator.EQ,
|
|
101
|
+
Operator.IN,
|
|
102
|
+
Operator.LT,
|
|
103
|
+
Operator.LTE,
|
|
104
|
+
Operator.GT,
|
|
105
|
+
Operator.GTE,
|
|
106
|
+
Operator.BETWEEN,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
_RANGE_OPERATORS = frozenset(
|
|
110
|
+
{Operator.LT, Operator.LTE, Operator.GT, Operator.GTE, Operator.BETWEEN}
|
|
111
|
+
)
|
|
112
|
+
_TEXT_ORDERED = frozenset(
|
|
113
|
+
{AttributeType.STRING, AttributeType.URI, AttributeType.ENUM, AttributeType.REF}
|
|
114
|
+
)
|
|
115
|
+
"""Text-indexed types this compiler also orders as text; dates, timestamps and durations are
|
|
116
|
+
compared as their SQL types, so ranges on them keep the typed comparison."""
|
|
117
|
+
|
|
118
|
+
_COMPARE = {
|
|
119
|
+
Operator.EQ: lambda a, b: a == b,
|
|
120
|
+
Operator.NE: lambda a, b: a != b,
|
|
121
|
+
Operator.LT: lambda a, b: a < b,
|
|
122
|
+
Operator.LTE: lambda a, b: a <= b,
|
|
123
|
+
Operator.GT: lambda a, b: a > b,
|
|
124
|
+
Operator.GTE: lambda a, b: a >= b,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class LinkSource:
|
|
130
|
+
"""Rows of one Relation seen from one direction: `near` and `far` key columns."""
|
|
131
|
+
|
|
132
|
+
rows: FromClause
|
|
133
|
+
near: ColumnElement[str]
|
|
134
|
+
far: ColumnElement[str]
|
|
135
|
+
values: ColumnElement[Any]
|
|
136
|
+
scope: ColumnElement[bool]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class SortKey:
|
|
141
|
+
"""One column of a keyset ordering."""
|
|
142
|
+
|
|
143
|
+
expression: ColumnElement[Any]
|
|
144
|
+
descending: bool = False
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def type(self) -> TypeEngine[Any]:
|
|
148
|
+
return self.expression.type
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Compiler:
|
|
152
|
+
"""SQL builder for the materialized head of `branch` in `domain_id`.
|
|
153
|
+
|
|
154
|
+
`indexes` are the per-Attribute indexes of the branch schema by `(kind id, attribute
|
|
155
|
+
id)` (`lorebase_store.indexes.plan_indexes`); comparisons on those Attributes repeat the
|
|
156
|
+
index predicate and expression so the partial index can serve them. `layout` gives the
|
|
157
|
+
rows of the branch (`lorebase_store.layout`): the tables of a full branch, the resolved
|
|
158
|
+
rows of an overlay, which keep the table's columns and report the overlay as `branch`.
|
|
159
|
+
`path_fanout` bounds path Derivations composed at read time
|
|
160
|
+
(`store.traverse.path_fanout`).
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
domain_id: str,
|
|
166
|
+
branch: str,
|
|
167
|
+
*,
|
|
168
|
+
text_search_config: str,
|
|
169
|
+
indexes: Mapping[tuple[str, str], IndexSpec] | None = None,
|
|
170
|
+
layout: BranchLayout | None = None,
|
|
171
|
+
path_fanout: int | None = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
self.domain_id = domain_id
|
|
174
|
+
self.branch = branch
|
|
175
|
+
self._path_fanout = path_fanout or TraverseSettings().path_fanout
|
|
176
|
+
self.layout = layout or BranchLayout(domain_id, branch)
|
|
177
|
+
self._config = text_search_config
|
|
178
|
+
self._indexes = dict(indexes or {})
|
|
179
|
+
|
|
180
|
+
# Tables and scopes ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def records(self, name: str | None = None) -> FromClause:
|
|
183
|
+
return self.layout.records(name)
|
|
184
|
+
|
|
185
|
+
def links(self, name: str | None = None) -> FromClause:
|
|
186
|
+
return self.layout.links(name)
|
|
187
|
+
|
|
188
|
+
def record_scope(self, rows: FromClause, target: Target) -> ColumnElement[bool]:
|
|
189
|
+
"""Rows of the target's Kinds on this branch."""
|
|
190
|
+
kinds = target.kind_ids
|
|
191
|
+
kind = (
|
|
192
|
+
rows.c.kind_id == literal(kinds[0])
|
|
193
|
+
if len(kinds) == 1
|
|
194
|
+
else rows.c.kind_id.in_([literal(k) for k in kinds])
|
|
195
|
+
)
|
|
196
|
+
return and_(
|
|
197
|
+
rows.c.domain_id == literal(self.domain_id),
|
|
198
|
+
rows.c.branch == literal(self.branch),
|
|
199
|
+
kind,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def regconfig(self) -> ColumnElement[Any]:
|
|
203
|
+
return cast(literal(self._config), REGCONFIG)
|
|
204
|
+
|
|
205
|
+
def tsquery(self, text_query: str) -> ColumnElement[Any]:
|
|
206
|
+
return func.websearch_to_tsquery(self.regconfig(), literal(text_query))
|
|
207
|
+
|
|
208
|
+
# Fields -----------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def field_json(self, rows: FromClause, ref: FieldRef) -> ColumnElement[Any]:
|
|
211
|
+
"""Canonical value of a field as JSONB (`NULL` when absent)."""
|
|
212
|
+
if ref.source is FieldSource.KEY:
|
|
213
|
+
return func.to_jsonb(rows.c.key)
|
|
214
|
+
if ref.source is FieldSource.TITLE:
|
|
215
|
+
return func.to_jsonb(rows.c.title)
|
|
216
|
+
column = rows.c.derived if ref.source is FieldSource.DERIVED else rows.c["values"]
|
|
217
|
+
return self._per_kind(rows, ref, lambda attribute: json_get(column, attribute))
|
|
218
|
+
|
|
219
|
+
def field_text(self, rows: FromClause, ref: FieldRef) -> ColumnElement[Any]:
|
|
220
|
+
"""Canonical value of a scalar field as text."""
|
|
221
|
+
if ref.source is FieldSource.KEY:
|
|
222
|
+
return rows.c.key
|
|
223
|
+
if ref.source is FieldSource.TITLE:
|
|
224
|
+
return rows.c.title
|
|
225
|
+
column = rows.c.derived if ref.source is FieldSource.DERIVED else rows.c["values"]
|
|
226
|
+
return self._per_kind(rows, ref, lambda attribute: json_text(column, attribute))
|
|
227
|
+
|
|
228
|
+
def field_typed(self, rows: FromClause, ref: FieldRef) -> ColumnElement[Any]:
|
|
229
|
+
"""Field value cast to the SQL type of its Attribute type (text for text types)."""
|
|
230
|
+
text_value = self.field_text(rows, ref)
|
|
231
|
+
sql_type = _TYPED.get(ref.spec.name)
|
|
232
|
+
return text_value if sql_type is None else cast(text_value, sql_type)
|
|
233
|
+
|
|
234
|
+
def _per_kind(self, rows: FromClause, ref: FieldRef, read: Any) -> ColumnElement[Any]:
|
|
235
|
+
items = sorted(ref.attributes.items())
|
|
236
|
+
if not items:
|
|
237
|
+
return literal(None)
|
|
238
|
+
if len(items) == 1:
|
|
239
|
+
return read(items[0][1]) # type: ignore[no-any-return]
|
|
240
|
+
return case(
|
|
241
|
+
*((rows.c.kind_id == literal(kind), read(attribute)) for kind, attribute in items)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Conditions -------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def condition(
|
|
247
|
+
self, rows: FromClause, condition: Condition, target: Target
|
|
248
|
+
) -> ColumnElement[bool]:
|
|
249
|
+
"""SQL predicate of a resolved condition over `rows` holding the target Records."""
|
|
250
|
+
match condition:
|
|
251
|
+
case Conjunction(items=items):
|
|
252
|
+
return and_(*(self.condition(rows, c, target) for c in items))
|
|
253
|
+
case Disjunction(items=items):
|
|
254
|
+
return or_(*(self.condition(rows, c, target) for c in items))
|
|
255
|
+
case Negation(item=item):
|
|
256
|
+
return not_(self.condition(rows, item, target))
|
|
257
|
+
case FullText(text=text_query):
|
|
258
|
+
return rows.c.search.op("@@", return_type=Boolean)(self.tsquery(text_query))
|
|
259
|
+
case Comparison():
|
|
260
|
+
return self.comparison(rows, condition, target)
|
|
261
|
+
case LinkExists(hop=hop, where=where):
|
|
262
|
+
source = self.link_source(hop)
|
|
263
|
+
query = select(literal(1)).select_from(source.rows)
|
|
264
|
+
query = query.where(source.scope, source.near == rows.c.key)
|
|
265
|
+
if len(target.kinds) > 1:
|
|
266
|
+
query = query.where(rows.c.kind_id == literal(hop.near_kind))
|
|
267
|
+
if where is not None:
|
|
268
|
+
far = self.layout.records_alias()
|
|
269
|
+
query = query.join(far, self._far_join(far, source, hop)).where(
|
|
270
|
+
self.condition(far, where, hop.far)
|
|
271
|
+
)
|
|
272
|
+
return exists(query)
|
|
273
|
+
case LinkCount(hop=hop, op=op, operand=operand, where=where):
|
|
274
|
+
source = self.link_source(hop)
|
|
275
|
+
query = select(func.count()).select_from(source.rows)
|
|
276
|
+
query = query.where(source.scope, source.near == rows.c.key)
|
|
277
|
+
if where is not None:
|
|
278
|
+
far = self.layout.records_alias()
|
|
279
|
+
query = query.join(far, self._far_join(far, source, hop)).where(
|
|
280
|
+
self.condition(far, where, hop.far)
|
|
281
|
+
)
|
|
282
|
+
counted = query.scalar_subquery()
|
|
283
|
+
compared = _compare(counted, op, operand)
|
|
284
|
+
if len(target.kinds) > 1:
|
|
285
|
+
return and_(rows.c.kind_id == literal(hop.near_kind), compared)
|
|
286
|
+
return compared
|
|
287
|
+
raise AssertionError(f"unexpected condition {condition!r}") # pragma: no cover
|
|
288
|
+
|
|
289
|
+
def comparison(
|
|
290
|
+
self, rows: FromClause, condition: Comparison, target: Target
|
|
291
|
+
) -> ColumnElement[bool]:
|
|
292
|
+
ref, op, operand = condition.field, condition.op, condition.operand
|
|
293
|
+
spec = ref.spec
|
|
294
|
+
if op is Operator.EXISTS:
|
|
295
|
+
present = self._present(rows, ref)
|
|
296
|
+
return present if operand else not_(present)
|
|
297
|
+
if op is Operator.PREFIX:
|
|
298
|
+
return self.field_text(rows, ref).startswith(str(operand), autoescape=True)
|
|
299
|
+
if op is Operator.MATCH:
|
|
300
|
+
vector = func.to_tsvector(self.regconfig(), self.field_text(rows, ref))
|
|
301
|
+
return vector.op("@@", return_type=Boolean)(self.tsquery(operand))
|
|
302
|
+
if op is Operator.CONTAINS:
|
|
303
|
+
return self.field_json(rows, ref).contains(_jsonb(list(operand)))
|
|
304
|
+
if op is Operator.NEAR:
|
|
305
|
+
return self._near(rows, ref, operand)
|
|
306
|
+
indexed = self._indexed(rows, condition, target)
|
|
307
|
+
if indexed is not None:
|
|
308
|
+
return indexed
|
|
309
|
+
if (
|
|
310
|
+
op is Operator.EQ
|
|
311
|
+
and ref.source in (FieldSource.VALUE, FieldSource.DERIVED)
|
|
312
|
+
and len(ref.attributes) == 1
|
|
313
|
+
and spec.name not in _SCALAR_JSON
|
|
314
|
+
):
|
|
315
|
+
column = rows.c.derived if ref.source is FieldSource.DERIVED else rows.c["values"]
|
|
316
|
+
((_, attribute),) = ref.attributes.items()
|
|
317
|
+
return column.contains(_jsonb({attribute: operand}))
|
|
318
|
+
typed = self.field_typed(rows, ref)
|
|
319
|
+
if op is Operator.IN:
|
|
320
|
+
return typed.in_([_bind(spec, value, typed) for value in operand])
|
|
321
|
+
if op is Operator.BETWEEN:
|
|
322
|
+
low, high = operand
|
|
323
|
+
return typed.between(_bind(spec, low, typed), _bind(spec, high, typed))
|
|
324
|
+
return _compare(typed, op, _bind(spec, operand, typed))
|
|
325
|
+
|
|
326
|
+
def _indexed(
|
|
327
|
+
self, rows: FromClause, condition: Comparison, target: Target
|
|
328
|
+
) -> ColumnElement[bool] | None:
|
|
329
|
+
"""A comparison in the shape of the Attribute's partial index, `None` when it has
|
|
330
|
+
no index or the index cannot serve the operator.
|
|
331
|
+
|
|
332
|
+
The index predicate (Domain, branch and Kind) and the Attribute id are rendered as
|
|
333
|
+
literals: a partial index matches only a query that repeats its literals. Equality
|
|
334
|
+
adds `IS NOT NULL` so that absent values compare false, as with containment.
|
|
335
|
+
"""
|
|
336
|
+
ref, op, operand = condition.field, condition.op, condition.operand
|
|
337
|
+
if ref.source is not FieldSource.VALUE or len(ref.attributes) != 1:
|
|
338
|
+
return None
|
|
339
|
+
((kind_id, attribute_id),) = ref.attributes.items()
|
|
340
|
+
spec = self._indexes.get((kind_id, attribute_id))
|
|
341
|
+
if spec is None or target.kind_ids != (kind_id,) or op not in _INDEXED_OPERATORS:
|
|
342
|
+
return None
|
|
343
|
+
if op is Operator.IN and spec.method is not IndexMethod.BTREE:
|
|
344
|
+
return None
|
|
345
|
+
if op in _RANGE_OPERATORS and not (
|
|
346
|
+
spec.ordered and (spec.cast is not None or spec.attribute_type in _TEXT_ORDERED)
|
|
347
|
+
):
|
|
348
|
+
return None
|
|
349
|
+
expression = _index_expression(rows, spec)
|
|
350
|
+
compared: ColumnElement[bool]
|
|
351
|
+
if op is Operator.EQ:
|
|
352
|
+
compared = and_(expression == _index_value(spec, operand), expression.is_not(None))
|
|
353
|
+
elif op is Operator.IN:
|
|
354
|
+
compared = expression.in_([_index_value(spec, value) for value in operand])
|
|
355
|
+
elif op is Operator.BETWEEN:
|
|
356
|
+
low, high = operand
|
|
357
|
+
compared = expression.between(_index_value(spec, low), _index_value(spec, high))
|
|
358
|
+
else:
|
|
359
|
+
compared = _COMPARE[op](expression, _index_value(spec, operand))
|
|
360
|
+
return and_(
|
|
361
|
+
rows.c.domain_id == literal(self.domain_id, Text(), literal_execute=True),
|
|
362
|
+
rows.c.branch == literal(self.branch, Text(), literal_execute=True),
|
|
363
|
+
rows.c.kind_id == literal(kind_id, Text(), literal_execute=True),
|
|
364
|
+
compared,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def _present(self, rows: FromClause, ref: FieldRef) -> ColumnElement[bool]:
|
|
368
|
+
if ref.source is FieldSource.KEY:
|
|
369
|
+
return true()
|
|
370
|
+
if ref.source is FieldSource.TITLE:
|
|
371
|
+
return rows.c.title.is_not(None)
|
|
372
|
+
column = rows.c.derived if ref.source is FieldSource.DERIVED else rows.c["values"]
|
|
373
|
+
if len(ref.attributes) == 1:
|
|
374
|
+
((_, attribute),) = ref.attributes.items()
|
|
375
|
+
present: ColumnElement[bool] = column.has_key(attribute)
|
|
376
|
+
return present
|
|
377
|
+
return self.field_json(rows, ref).is_not(None)
|
|
378
|
+
|
|
379
|
+
def _near(
|
|
380
|
+
self, rows: FromClause, ref: FieldRef, operand: tuple[float, float, float]
|
|
381
|
+
) -> ColumnElement[bool]:
|
|
382
|
+
lat, lon, radius = operand
|
|
383
|
+
point = self.field_json(rows, ref)
|
|
384
|
+
rlat = func.radians(cast(json_text(point, "lat"), DOUBLE_PRECISION))
|
|
385
|
+
rlon = func.radians(cast(json_text(point, "lon"), DOUBLE_PRECISION))
|
|
386
|
+
qlat = func.radians(literal(lat, DOUBLE_PRECISION))
|
|
387
|
+
qlon = func.radians(literal(lon, DOUBLE_PRECISION))
|
|
388
|
+
haversine = func.power(func.sin((rlat - qlat) / 2), 2) + func.cos(rlat) * func.cos(
|
|
389
|
+
qlat
|
|
390
|
+
) * func.power(func.sin((rlon - qlon) / 2), 2)
|
|
391
|
+
distance = literal(2 * EARTH_RADIUS_M, DOUBLE_PRECISION) * func.asin(func.sqrt(haversine))
|
|
392
|
+
return distance <= literal(radius, DOUBLE_PRECISION)
|
|
393
|
+
|
|
394
|
+
# Links ------------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
def link_source(self, hop: Hop) -> LinkSource:
|
|
397
|
+
"""Links of the hop's Relation with `near` on the side the hop starts from.
|
|
398
|
+
|
|
399
|
+
A materialized path Derivation reads its stored Links like a Relation (its id is the
|
|
400
|
+
`relation_id`); any other path is composed from its steps (`_path_source`).
|
|
401
|
+
"""
|
|
402
|
+
if hop.path is not None and not hop.path.materialized:
|
|
403
|
+
return self._path_source(hop.path, hop.direction)
|
|
404
|
+
return self._relation_source(hop.relation, hop.direction)
|
|
405
|
+
|
|
406
|
+
def _relation_source(self, relation: Relation, direction: LinkDirection) -> LinkSource:
|
|
407
|
+
forward = direction is LinkDirection.FORWARD
|
|
408
|
+
if relation.backing is RelationBacking.FOREIGN_KEY:
|
|
409
|
+
assert relation.foreign_key_attribute is not None
|
|
410
|
+
rows = self.layout.records_alias()
|
|
411
|
+
attribute = relation.foreign_key_attribute
|
|
412
|
+
target_key = json_text(rows.c["values"], attribute)
|
|
413
|
+
scope = and_(
|
|
414
|
+
rows.c.domain_id == literal(self.domain_id),
|
|
415
|
+
rows.c.branch == literal(self.branch),
|
|
416
|
+
rows.c.kind_id == literal(relation.from_kind),
|
|
417
|
+
rows.c["values"].has_key(attribute),
|
|
418
|
+
)
|
|
419
|
+
near, far = (rows.c.key, target_key) if forward else (target_key, rows.c.key)
|
|
420
|
+
return LinkSource(rows, near, far, _jsonb({}), scope)
|
|
421
|
+
links = self.layout.links_alias()
|
|
422
|
+
scope = and_(
|
|
423
|
+
links.c.domain_id == literal(self.domain_id),
|
|
424
|
+
links.c.branch == literal(self.branch),
|
|
425
|
+
links.c.relation_id == literal(relation.id),
|
|
426
|
+
)
|
|
427
|
+
near, far = (
|
|
428
|
+
(links.c.from_key, links.c.to_key) if forward else (links.c.to_key, links.c.from_key)
|
|
429
|
+
)
|
|
430
|
+
return LinkSource(links, near, far, links.c["values"], scope)
|
|
431
|
+
|
|
432
|
+
def _path_source(self, path: PathRelation, direction: LinkDirection) -> LinkSource:
|
|
433
|
+
"""The pairs of a path Derivation composed from its steps
|
|
434
|
+
(`lorebase_core.model.paths`): every Link of the first step, then per intermediate
|
|
435
|
+
Record a lateral read of at most `path_fanout` Links, smallest far keys first. The
|
|
436
|
+
pairs are computed forward whatever the direction, so both directions read the same
|
|
437
|
+
Links; the inverse direction swaps the columns."""
|
|
438
|
+
first = path.steps[0]
|
|
439
|
+
start = self._relation_source(first.relation, first.direction)
|
|
440
|
+
joined: FromClause = start.rows
|
|
441
|
+
reached: ColumnElement[str] = start.far
|
|
442
|
+
for index, step in enumerate(path.steps[1:], start=1):
|
|
443
|
+
source = self._relation_source(step.relation, step.direction)
|
|
444
|
+
hop = (
|
|
445
|
+
select(source.far.label("far"))
|
|
446
|
+
.select_from(source.rows)
|
|
447
|
+
.where(source.scope, source.near == reached)
|
|
448
|
+
.order_by(source.far)
|
|
449
|
+
.limit(self._path_fanout)
|
|
450
|
+
.lateral(f"path_step_{index}")
|
|
451
|
+
)
|
|
452
|
+
joined = joined.join(hop, true())
|
|
453
|
+
reached = hop.c.far
|
|
454
|
+
pairs = (
|
|
455
|
+
select(start.near.label("from_key"), reached.label("to_key"))
|
|
456
|
+
.select_from(joined)
|
|
457
|
+
.where(start.scope)
|
|
458
|
+
.distinct()
|
|
459
|
+
.subquery()
|
|
460
|
+
)
|
|
461
|
+
forward = direction is LinkDirection.FORWARD
|
|
462
|
+
near, far = (
|
|
463
|
+
(pairs.c.from_key, pairs.c.to_key) if forward else (pairs.c.to_key, pairs.c.from_key)
|
|
464
|
+
)
|
|
465
|
+
return LinkSource(pairs, near, far, _jsonb({}), true())
|
|
466
|
+
|
|
467
|
+
def _far_join(self, far: FromClause, source: LinkSource, hop: Hop) -> ColumnElement[bool]:
|
|
468
|
+
return and_(
|
|
469
|
+
far.c.domain_id == literal(self.domain_id),
|
|
470
|
+
far.c.branch == literal(self.branch),
|
|
471
|
+
far.c.kind_id == literal(hop.far.kind_ids[0]),
|
|
472
|
+
far.c.key == source.far,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
def neighbors(
|
|
476
|
+
self,
|
|
477
|
+
hop: Hop,
|
|
478
|
+
near_keys: Sequence[str],
|
|
479
|
+
*,
|
|
480
|
+
where: Condition | None = None,
|
|
481
|
+
per_near: int | None = None,
|
|
482
|
+
limit: int | None = None,
|
|
483
|
+
) -> Select[Any]:
|
|
484
|
+
"""Links from `near_keys` along `hop` joined to the far Records.
|
|
485
|
+
|
|
486
|
+
Columns: `near_key`, `far_key`, `link_values` and the far Record columns. `per_near`
|
|
487
|
+
keeps the first Links (by far key) of each near key; `limit` caps the whole result.
|
|
488
|
+
"""
|
|
489
|
+
source = self.link_source(hop)
|
|
490
|
+
far = self.records("far")
|
|
491
|
+
columns: list[ColumnElement[Any]] = [
|
|
492
|
+
source.near.label("near_key"),
|
|
493
|
+
source.far.label("far_key"),
|
|
494
|
+
source.values.label("link_values"),
|
|
495
|
+
*record_columns(far),
|
|
496
|
+
]
|
|
497
|
+
if per_near is not None:
|
|
498
|
+
columns.append(
|
|
499
|
+
over(func.row_number(), partition_by=source.near, order_by=source.far).label(
|
|
500
|
+
"position"
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
query = (
|
|
504
|
+
select(*columns)
|
|
505
|
+
.select_from(source.rows)
|
|
506
|
+
.join(far, self._far_join(far, source, hop))
|
|
507
|
+
.where(source.scope, source.near.in_([literal(k) for k in near_keys]))
|
|
508
|
+
)
|
|
509
|
+
if where is not None:
|
|
510
|
+
query = query.where(self.condition(far, where, hop.far))
|
|
511
|
+
if per_near is not None:
|
|
512
|
+
inner = query.subquery("ranked")
|
|
513
|
+
query = select(*(c for c in inner.c if c.name != "position")).where(
|
|
514
|
+
inner.c.position <= literal(per_near)
|
|
515
|
+
)
|
|
516
|
+
query = query.order_by(inner.c.near_key, inner.c.far_key)
|
|
517
|
+
else:
|
|
518
|
+
query = query.order_by(source.near, source.far)
|
|
519
|
+
if limit is not None:
|
|
520
|
+
query = query.limit(limit)
|
|
521
|
+
return query
|
|
522
|
+
|
|
523
|
+
# Ordering ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def sort_keys(
|
|
526
|
+
self, rows: FromClause, order: Sequence[Ordering], target: Target
|
|
527
|
+
) -> list[SortKey]:
|
|
528
|
+
"""Keyset columns: per term the absence flag and the value, then Kind and key."""
|
|
529
|
+
keys: list[SortKey] = []
|
|
530
|
+
for term in order:
|
|
531
|
+
if term.field.source is FieldSource.KEY:
|
|
532
|
+
keys.append(SortKey(rows.c.key, term.descending))
|
|
533
|
+
continue
|
|
534
|
+
typed = self.field_typed(rows, term.field)
|
|
535
|
+
keys.append(SortKey(typed.is_(None)))
|
|
536
|
+
keys.append(SortKey(typed, term.descending))
|
|
537
|
+
if len(target.kinds) > 1:
|
|
538
|
+
keys.append(SortKey(rows.c.kind_id))
|
|
539
|
+
if not any(k.expression is rows.c.key for k in keys):
|
|
540
|
+
keys.append(SortKey(rows.c.key))
|
|
541
|
+
return keys
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def json_get(column: ColumnElement[Any], key: str) -> ColumnElement[Any]:
|
|
545
|
+
"""`column -> key` with the key bound as a parameter (JSONB result)."""
|
|
546
|
+
return column.op("->", return_type=JSONB)(literal(key, Text()))
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def json_text(column: ColumnElement[Any], key: str) -> ColumnElement[Any]:
|
|
550
|
+
"""`column ->> key` with the key bound as a parameter (text result)."""
|
|
551
|
+
return column.op("->>", return_type=Text)(literal(key, Text()))
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def record_columns(rows: FromClause) -> list[KeyedColumnElement[Any]]:
|
|
555
|
+
"""Columns of a materialized Record read by the API."""
|
|
556
|
+
return [
|
|
557
|
+
rows.c.kind_id,
|
|
558
|
+
rows.c.key,
|
|
559
|
+
rows.c.title,
|
|
560
|
+
rows.c["values"],
|
|
561
|
+
rows.c.derived,
|
|
562
|
+
rows.c.object_hash,
|
|
563
|
+
lineage_column(rows),
|
|
564
|
+
rows.c.updated_revision,
|
|
565
|
+
rows.c.updated_at,
|
|
566
|
+
]
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def order_clauses(keys: Sequence[SortKey]) -> list[ColumnElement[Any]]:
|
|
570
|
+
return [k.expression.desc() if k.descending else k.expression.asc() for k in keys]
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def after(keys: Sequence[SortKey], values: Sequence[Any]) -> ColumnElement[bool]:
|
|
574
|
+
"""Rows strictly after the position `values` in the keyset order of `keys`."""
|
|
575
|
+
if len(keys) != len(values):
|
|
576
|
+
raise ValueError("cursor does not match the ordering")
|
|
577
|
+
alternatives: list[ColumnElement[bool]] = []
|
|
578
|
+
equal: list[ColumnElement[bool]] = []
|
|
579
|
+
for key, value in zip(keys, values, strict=True):
|
|
580
|
+
if value is None:
|
|
581
|
+
beyond: ColumnElement[bool] = false()
|
|
582
|
+
same: ColumnElement[bool] = key.expression.is_(None)
|
|
583
|
+
else:
|
|
584
|
+
bound = literal(value, key.type)
|
|
585
|
+
beyond = key.expression < bound if key.descending else key.expression > bound
|
|
586
|
+
same = key.expression == bound
|
|
587
|
+
alternatives.append(and_(*equal, beyond) if equal else beyond)
|
|
588
|
+
equal.append(same)
|
|
589
|
+
return or_(*alternatives)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _compare(left: ColumnElement[Any], op: Operator, right: Any) -> ColumnElement[bool]:
|
|
593
|
+
if op is Operator.BETWEEN:
|
|
594
|
+
low, high = right
|
|
595
|
+
return left.between(literal(low), literal(high))
|
|
596
|
+
if not isinstance(right, ColumnElement):
|
|
597
|
+
right = literal(right)
|
|
598
|
+
result: ColumnElement[bool] = _COMPARE[op](left, right)
|
|
599
|
+
return result
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
_INDEX_CASTS: dict[str, TypeEngine[Any]] = {
|
|
603
|
+
"bigint": BigInteger(),
|
|
604
|
+
"numeric": Numeric(),
|
|
605
|
+
"double precision": DOUBLE_PRECISION(),
|
|
606
|
+
"boolean": Boolean(),
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _index_expression(rows: FromClause, spec: IndexSpec) -> ColumnElement[Any]:
|
|
611
|
+
"""`spec.expression` over `rows`, with the Attribute id as a literal."""
|
|
612
|
+
raw = rows.c["values"].op("->>", return_type=Text)(
|
|
613
|
+
literal(spec.attribute_id, Text(), literal_execute=True)
|
|
614
|
+
)
|
|
615
|
+
if spec.digest:
|
|
616
|
+
return func.md5(raw)
|
|
617
|
+
if spec.cast is not None:
|
|
618
|
+
return cast(raw, _INDEX_CASTS[spec.cast])
|
|
619
|
+
return raw
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _index_value(spec: IndexSpec, canonical: Any) -> ColumnElement[Any]:
|
|
623
|
+
"""A canonical operand compared with `_index_expression` (`spec.value_sql`)."""
|
|
624
|
+
if isinstance(canonical, bool):
|
|
625
|
+
canonical = "true" if canonical else "false"
|
|
626
|
+
value = literal(str(canonical), Text())
|
|
627
|
+
if spec.digest:
|
|
628
|
+
return func.md5(value)
|
|
629
|
+
if spec.cast is not None:
|
|
630
|
+
return cast(value, _INDEX_CASTS[spec.cast])
|
|
631
|
+
return value
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _jsonb(value: Any) -> ColumnElement[Any]:
|
|
635
|
+
return literal(value, JSONB)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _bind(spec: Any, canonical: Any, typed: ColumnElement[Any]) -> ColumnElement[Any]:
|
|
639
|
+
"""A canonical operand as a parameter of the field's SQL type."""
|
|
640
|
+
value = decode_value(spec, canonical) if spec.name in _TYPED else canonical
|
|
641
|
+
return literal(value, typed.type if spec.name in _TYPED else Text())
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def cursor_value(value: Any) -> Any:
|
|
645
|
+
"""A sort value as JSON for a cursor (see `from_cursor_value`)."""
|
|
646
|
+
if isinstance(value, Decimal):
|
|
647
|
+
return str(value)
|
|
648
|
+
if isinstance(value, datetime | date):
|
|
649
|
+
return value.isoformat()
|
|
650
|
+
if isinstance(value, timedelta):
|
|
651
|
+
return (value.days * 86_400 + value.seconds) * 1_000_000 + value.microseconds
|
|
652
|
+
return value
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def from_cursor_value(value: Any, sql_type: TypeEngine[Any]) -> Any:
|
|
656
|
+
"""Inverse of `cursor_value` for a column of `sql_type`; `ValueError` when malformed."""
|
|
657
|
+
if value is None:
|
|
658
|
+
return None
|
|
659
|
+
try:
|
|
660
|
+
if isinstance(sql_type, Boolean):
|
|
661
|
+
if not isinstance(value, bool):
|
|
662
|
+
raise ValueError("expected a boolean")
|
|
663
|
+
return value
|
|
664
|
+
if isinstance(sql_type, Numeric) and not isinstance(sql_type, DOUBLE_PRECISION):
|
|
665
|
+
return Decimal(str(value))
|
|
666
|
+
if isinstance(sql_type, DOUBLE_PRECISION):
|
|
667
|
+
return float(value)
|
|
668
|
+
if isinstance(sql_type, DateTime):
|
|
669
|
+
return datetime.fromisoformat(str(value))
|
|
670
|
+
if isinstance(sql_type, Date):
|
|
671
|
+
return date.fromisoformat(str(value))
|
|
672
|
+
if isinstance(sql_type, Interval):
|
|
673
|
+
return timedelta(microseconds=int(value))
|
|
674
|
+
except (ArithmeticError, TypeError) as error:
|
|
675
|
+
raise ValueError(f"malformed cursor value: {error}") from None
|
|
676
|
+
if not isinstance(value, str):
|
|
677
|
+
raise ValueError("expected text")
|
|
678
|
+
return value
|