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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. 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