Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! PgStore, first slice: entity CRUD over the `entities`
3 : //! table. Same signatures as the in-memory `Store`, awaited: every method is
4 : //! an `async fn` over sqlx, so a caller waiting on the database holds no
5 : //! thread.
6 : //!
7 : //! Extracted columns are computed in Rust at write time (no triggers):
8 : //! `types`, `scopes`, `created_at`, `modified_at`, `expires_at` and
9 : //! `location`, the default GeoProperty, converted by PostGIS itself from
10 : //! bound GeoJSON text (`ST_GeomFromGeoJSON` rather than a geozero
11 : //! dependency — the DB already owns the conversion, and the value still
12 : //! travels as a bind).
13 :
14 : use antares_model::{NgsiError, TenantId};
15 : use antares_ql::{CmpOp, QNode, QValue};
16 : use antares_store::ChangeVersion;
17 : use serde_json::Value;
18 : use sqlx::postgres::PgPool;
19 : use sqlx::Row;
20 :
21 : pub struct PgEntityStore {
22 : pool: PgPool,
23 : /// When on, every entity write enqueues its change event into the
24 : /// outbox INSIDE the write transaction (a crash between commit and
25 : /// publish can never lose an event). Off by default: with `bus = local`
26 : /// events flow through the in-process hook and undrained rows would only
27 : /// grow the table. The broker turns this on when `bus = nats`.
28 : outbox: std::sync::atomic::AtomicBool,
29 : /// When on, the batch writes (create, upsert, the batch read-modify-write)
30 : /// commit with `synchronous_commit = off`, set LOCAL to their own
31 : /// transaction: a crash of Postgres loses at most the last
32 : /// ~3 x `wal_writer_delay` of committed batches, never their atomicity
33 : /// or the order of the WAL. Notifications already sent for a lost batch
34 : /// stay sent. Off by default; the broker sets it from
35 : /// `ANTARES_PG_BATCH_ASYNC_COMMIT` for a feed that rewrites itself every
36 : /// second, where the next batch restores what a crash lost.
37 : async_batch: std::sync::atomic::AtomicBool,
38 : /// The pool the batch writes begin on: `pool` itself, or the write
39 : /// reserve (`ANTARES_PG_WRITE_RESERVE`) when the broker set one,
40 : /// whose connections no read takes.
41 : batch_pool: PgPool,
42 : }
43 :
44 : // EntityFilter/Page/QueryOutcome live in `store::filter` (pure data,
45 : // shared with the wasm32 build); re-exported here so existing paths hold.
46 : pub use crate::store::filter::{EntityFilter, Page, QueryOutcome};
47 :
48 : /// A bound value. Enumerated because the bind list is built dynamically while
49 : /// the SQL is assembled — the alternative would be string interpolation.
50 : enum Bind {
51 : Text(String),
52 : TextArr(Vec<String>),
53 : /// jsonpath; bound as text and cast with `$n::jsonpath` in the SQL
54 : Path(String),
55 : /// a distance in metres (geoquery `near`)
56 : Num(f64),
57 : /// LIMIT/OFFSET (pagination pushdown)
58 : Int(i64),
59 : }
60 :
61 : /// The internal doc's members that become extracted columns.
62 : pub(crate) struct Extracted {
63 : types: Vec<String>,
64 : scopes: Option<Vec<String>>,
65 : created: String,
66 : modified: String,
67 : expires: Option<String>,
68 : /// The default GeoProperty as GeoJSON text, for `ST_GeomFromGeoJSON`.
69 : /// `None` (→ SQL NULL) whenever it cannot be represented as ONE geometry.
70 : location: Option<String>,
71 : /// True when the doc CARRIES the default GeoProperty but `location` could
72 : /// not be extracted (multi-instance, non-GeoJSON value). The compiled
73 : /// geoquery ORs on this column — index-shaped (BitmapOr), unlike the old
74 : /// `location IS NULL OR …` guard which forced a sequential scan — and
75 : /// rows without any geoproperty are excluded in SQL, which is exact.
76 : location_ambiguous: bool,
77 : /// The earliest `expiresAt` of any Attribute instance (4.22), so the
78 : /// sweep finds the rows to prune by index instead of reading every doc.
79 : attrs_expire: Option<String>,
80 : }
81 :
82 : /// The four members every stored kind extracts the same way: `type` and
83 : /// `scope` as arrays whether the doc spells them as one string or many, and
84 : /// the two stamps. The temporal tables carry exactly these, entities carry
85 : /// them plus expiry and location, so this is the one implementation of the
86 : /// shared half — two copies is how one of them drifts.
87 : ///
88 : /// 4.6.3 allows a comma seconds-fraction in requests; the stamps feed
89 : /// `::timestamptz` casts, which refuse it, so they are canonicalised here and
90 : /// every extracted column holds the instant the client meant.
91 46825 : pub(super) fn types_scopes_stamps(
92 46825 : doc: &Value,
93 46825 : ) -> (Vec<String>, Option<Vec<String>>, String, String) {
94 51861 : let as_vec = |v: &Value| -> Vec<String> {
95 51861 : match v {
96 20631 : Value::String(s) => vec![s.clone()],
97 31228 : Value::Array(a) => a
98 31228 : .iter()
99 34290 : .filter_map(|x| x.as_str().map(str::to_owned))
100 31228 : .collect(),
101 2 : _ => vec![],
102 : }
103 51861 : };
104 93650 : let ts = |k: &str| {
105 93650 : doc.get(k)
106 93650 : .and_then(Value::as_str)
107 93650 : .map(|s| antares_store::filter::canonical_datetime(s).into_owned())
108 93650 : };
109 46825 : let now = || "1970-01-01T00:00:00Z".to_owned(); // caller always stamps; belt only
110 46825 : (
111 46825 : doc.get("type").map(&as_vec).unwrap_or_default(),
112 46825 : doc.get("scope").map(&as_vec),
113 46825 : ts("createdAt").unwrap_or_else(now),
114 46825 : ts("modifiedAt").unwrap_or_else(now),
115 46825 : )
116 46825 : }
117 :
118 41798 : fn extract(doc: &Value) -> Extracted {
119 41798 : let (types, scopes, created, modified) = types_scopes_stamps(doc);
120 41798 : let ts = |k: &str| {
121 41798 : doc.get(k)
122 41798 : .and_then(Value::as_str)
123 41798 : .map(|s| antares_store::filter::canonical_datetime(s).into_owned())
124 41798 : };
125 41798 : let location = crate::compile::geo::extract_location(doc);
126 41798 : let location_ambiguous =
127 41798 : location.is_none() && doc.get(crate::compile::geo::LOCATION_IRI).is_some();
128 41798 : Extracted {
129 41798 : types,
130 41798 : scopes,
131 41798 : created,
132 41798 : modified,
133 41798 : expires: ts("expiresAt"),
134 41798 : location,
135 41798 : location_ambiguous,
136 41798 : attrs_expire: first_instance_expiry(doc),
137 41798 : }
138 41798 : }
139 :
140 : /// The earliest instance `expiresAt` of an internal doc's Attributes,
141 : /// canonicalised for the `::timestamptz` cast; an Entity member never counts.
142 41798 : fn first_instance_expiry(doc: &Value) -> Option<String> {
143 41798 : doc.as_object()?
144 41798 : .iter()
145 204256 : .filter(|(k, _)| !antares_model::ENTITY_META_KEYS.contains(&k.as_str()))
146 54735 : .filter_map(|(_, v)| v.as_array())
147 41798 : .flatten()
148 43687 : .filter_map(|inst| inst.get("expiresAt").and_then(Value::as_str))
149 41798 : .min_by_key(|s| antares_model::dt_key(s))
150 41798 : .map(|s| antares_store::filter::canonical_datetime(s).into_owned())
151 41798 : }
152 :
153 : /// The [`ChangeVersion`] of a delete whose `RETURNING` row is `r` (version
154 : /// at 2, `created_at::text` at 3): one past the version it removed, so it
155 : /// orders after that row's last write.
156 2763 : fn deleted_version(r: &sqlx::postgres::PgRow) -> ChangeVersion {
157 2763 : ChangeVersion {
158 2763 : incarnation: r.get(3),
159 2763 : version: r.get::<i64, _>(2).saturating_add(1),
160 2763 : }
161 2763 : }
162 :
163 : /// The outbox row's event JSON: what the drain turns into a
164 : /// `ChangeEvent`. Field names are the bus crate's serde names; `seq` and the
165 : /// claim check are the drain's business.
166 : #[allow(clippy::too_many_arguments)] // one param per outbox event field
167 22 : fn change_event(
168 22 : tenant: &TenantId,
169 22 : op: &str,
170 22 : id: &str,
171 22 : types: &[String],
172 22 : prev: Option<&Value>,
173 22 : next: Option<&Value>,
174 22 : version: i64,
175 22 : incarnation: &str,
176 22 : ) -> Value {
177 : // changed_attrs = top-level attribute IRIs that differ between the
178 : // before- and after-images (meta members excluded). Create lists every
179 : // attr, delete lists every prior attr.
180 44 : fn keys(v: Option<&Value>) -> Vec<&str> {
181 44 : v.and_then(Value::as_object)
182 44 : .map(|o| {
183 22 : o.keys()
184 22 : .map(String::as_str)
185 120 : .filter(|k| !antares_model::is_meta(k))
186 22 : .collect()
187 22 : })
188 44 : .unwrap_or_default()
189 44 : }
190 22 : let mut changed: Vec<&str> = Vec::new();
191 34 : for k in keys(prev).into_iter().chain(keys(next)) {
192 34 : if !changed.contains(&k) && prev.and_then(|p| p.get(k)) != next.and_then(|n| n.get(k)) {
193 20 : changed.push(k);
194 20 : }
195 : }
196 22 : serde_json::json!({
197 22 : "tenant": tenant.as_str(),
198 22 : "entity_id": id,
199 22 : "types": types,
200 22 : "op": op,
201 22 : "changed_attrs": changed,
202 22 : "payload": next,
203 22 : "prev_payload": prev,
204 22 : "version": version,
205 22 : "incarnation": incarnation,
206 : })
207 22 : }
208 :
209 : #[allow(clippy::too_many_arguments)] // one param per outbox event field
210 4 : async fn enqueue_change(
211 4 : tx: &mut sqlx::postgres::PgConnection,
212 4 : tenant: &TenantId,
213 4 : op: &str,
214 4 : id: &str,
215 4 : types: &[String],
216 4 : prev: Option<&Value>,
217 4 : next: Option<&Value>,
218 4 : version: i64,
219 4 : incarnation: &str,
220 4 : ) -> Result<(), sqlx::Error> {
221 4 : let ev = change_event(tenant, op, id, types, prev, next, version, incarnation);
222 4 : super::outbox::enqueue(tx, tenant, &ev).await.map(|_| ())
223 4 : }
224 :
225 : /// Rows one statement may materialize when SQL narrowed the match set but
226 : /// did not decide it. 5.5.6 leaves the threshold to the implementation;
227 : /// this is it for the entity query path, and it is the only bound a request
228 : /// that carries no page (idPattern, federation, orderBy) has at all.
229 : pub const MAX_UNDECIDED_ROWS: i64 = 10_000;
230 :
231 : /// The column `q=` is compiled over: the stored document, or, for a row
232 : /// holding an instance past its `expiresAt` (`attrs_expire_at` past, rare
233 : /// until the sweep rewrites it), the document 4.22 lets a read see: expired
234 : /// top-level instances dropped, an Attribute left with none removed, as
235 : /// `filter::strip_expired` does in memory. A live row pays
236 : /// only the CASE test.
237 : const LIVE_ENTITY: &str = "(CASE WHEN attrs_expire_at IS NULL OR attrs_expire_at >= now() \
238 : THEN entity ELSE (SELECT coalesce(jsonb_object_agg(l.k, l.v), '{}'::jsonb) FROM \
239 : (SELECT x.k, CASE WHEN jsonb_typeof(x.v) = 'array' AND strpos(x.k, ':') > 0 \
240 : THEN (SELECT jsonb_agg(i.v) FROM jsonb_array_elements(x.v) AS i(v) \
241 : WHERE jsonb_typeof(i.v -> 'expiresAt') IS DISTINCT FROM 'string' \
242 : OR try_timestamptz(i.v ->> 'expiresAt') IS NULL \
243 : OR try_timestamptz(i.v ->> 'expiresAt') >= now()) \
244 : ELSE x.v END AS v FROM jsonb_each(entity) AS x(k, v)) AS l \
245 : WHERE l.v IS NOT NULL) END)";
246 :
247 : /// Distinct types a type selection may split into arms; a longer list keeps
248 : /// the plain containment, which reads the Tenant.
249 : const MAX_TYPE_ARMS: usize = 16;
250 :
251 : /// A type selection read as leakproof index arms. `types @> $n` cannot be an
252 : /// index condition: `arraycontains` is not leakproof, so under the tables'
253 : /// RLS Postgres keeps it out of every index condition and pages the whole
254 : /// Tenant through `entities_pkey` (18 req/s; the
255 : /// same for `type=A,B`). The split reads one arm per type the selection
256 : /// names, the Entities whose first type it is (`i_entities_first_type_id`),
257 : /// plus one arm for the few of two or more types whose first type is none
258 : /// of them (`i_entities_multi_type_id`). The arms are disjoint (an Entity
259 : /// has one first type) and their union is exactly the selection. Only a
260 : /// group of one type gets first-type arms: a match of an AND-group (`A;B`)
261 : /// has two types, so the multi-type arm reaches it, where a first-type arm
262 : /// walked every Entity of A to keep none (53 000 s on a deployment).
263 : struct TypeSplit {
264 : /// Bind of each type a one-type group names, one first-type arm each.
265 : firsts: Vec<usize>,
266 : /// Bind of those types as an array, `None` when every group is an
267 : /// AND-group and the multi-type arm alone reads the selection.
268 : named: Option<usize>,
269 : /// The selection itself, `types @> $g` per AND-group, ORed.
270 : selection: String,
271 : /// A selection of one type is decided by `types[1] = $t` alone.
272 : exact_first: bool,
273 : }
274 :
275 : /// The full WHERE of each arm the statement reads: one, or the arms of a
276 : /// `TypeSplit`.
277 5230 : fn arms(wheres: &[String], split: Option<&TypeSplit>) -> Vec<String> {
278 5230 : let base = wheres.join(" AND ");
279 5230 : let Some(s) = split else {
280 2903 : return vec![base];
281 : };
282 2327 : let mut arms: Vec<String> = s
283 2327 : .firsts
284 2327 : .iter()
285 2601 : .map(|n| match s.exact_first {
286 1259 : true => format!("{base} AND types[1] = ${n}"),
287 1336 : false => format!("{base} AND types[1] = ${n} AND {}", s.selection),
288 2595 : })
289 2327 : .collect();
290 2327 : arms.push(match s.named {
291 2129 : Some(n) => format!(
292 : "{base} AND cardinality(types) > 1 AND types[1] <> ALL(${n}) AND {}",
293 : s.selection
294 : ),
295 198 : None => format!("{base} AND cardinality(types) > 1 AND {}", s.selection),
296 : });
297 2327 : arms
298 5230 : }
299 :
300 : /// Does `q` require the Entity to have no `location`, by a top-level
301 : /// `!location` term? Then `location IS NULL` holds for every match: a stored
302 : /// geometry comes from an instance's `value` or `object` (`extract`), which
303 : /// `!location` resolves as present. The conjunct is leakproof, so
304 : /// it reaches `i_entities_type_unlocated` (0018) under RLS, where the negated
305 : /// `@?` read the whole type; the `@?` stays and decides.
306 2129 : fn unlocated(q: &QNode, expand: &dyn Fn(&str) -> String) -> bool {
307 2129 : match q {
308 : QNode::Exists {
309 325 : path,
310 : negated: true,
311 : } => {
312 325 : path.links.is_empty()
313 325 : && path.bracket.is_none()
314 321 : && matches!(path.path.as_slice(), [term] if expand(term) == crate::compile::geo::LOCATION_IRI)
315 : }
316 965 : QNode::And(all) => all.iter().any(|n| unlocated(n, expand)),
317 1331 : _ => false,
318 : }
319 2129 : }
320 :
321 : /// The `name` the core @context gives an Entity (`@vocab` + `name`), the one
322 : /// attribute `entities.name_key` (0020) keys.
323 : /// One IRI; a `name` another @context maps elsewhere filters as before.
324 : const NAME_IRI: &str = "https://uri.etsi.org/ngsi-ld/default-context/name";
325 :
326 : /// The string a top-level `name=="…"` term of `q` requires. Every match then
327 : /// has `name_key` equal to it or NULL (0020: NULL is each shape the key
328 : /// cannot hold), so `(name_key = $n OR name_key IS NULL)` narrows by a
329 : /// leakproof btree under RLS where the `@?` read the whole Tenant;
330 : /// the `@?` stays and decides. A NUL is no Postgres text: no narrowing.
331 2445 : fn named<'q>(q: &'q QNode, expand: &dyn Fn(&str) -> String) -> Option<&'q str> {
332 255 : match q {
333 : QNode::Cmp {
334 255 : path,
335 : op: CmpOp::Eq,
336 255 : value: QValue::Str(want),
337 255 : } if path.links.is_empty()
338 249 : && path.bracket.is_none()
339 234 : && !want.contains('\0')
340 234 : && matches!(path.path.as_slice(), [term] if expand(term) == NAME_IRI) =>
341 : {
342 23 : Some(want)
343 : }
344 1275 : QNode::And(all) => all.iter().find_map(|n| named(n, expand)),
345 1875 : _ => None,
346 : }
347 2445 : }
348 :
349 : /// The Entity's own members, which `entity_value_rows` (0024) gives no row:
350 : /// an `attrs=` naming one is not narrowed.
351 : const OWN_MEMBERS: &[&str] = &[
352 : "id",
353 : "type",
354 : "scope",
355 : "@context",
356 : "createdAt",
357 : "modifiedAt",
358 : "deletedAt",
359 : "expiresAt",
360 : ];
361 :
362 : /// The longest string `entity_values.txt` (0024) keys, in bytes.
363 : const MAX_KEYED_TEXT: usize = 1024;
364 :
365 : /// What a top-level `q` term requires of its Attribute's `entity_values`
366 : /// rows (0024). Every bound is non-strict: a stored number is rounded to
367 : /// float8, monotonically, and the query's f64 is the literal's own value.
368 : #[derive(Debug, PartialEq)]
369 : enum ValueKey<'q> {
370 : Eq(f64),
371 : AtLeast(f64),
372 : AtMost(f64),
373 : Between(f64, f64),
374 : Text(&'q str),
375 : }
376 :
377 635 : fn value_key(op: CmpOp, want: &QValue) -> Option<ValueKey<'_>> {
378 635 : Some(match (op, want) {
379 56 : (CmpOp::Eq, QValue::Num(n)) => ValueKey::Eq(*n),
380 235 : (CmpOp::Gt | CmpOp::Ge, QValue::Num(n)) => ValueKey::AtLeast(*n),
381 31 : (CmpOp::Lt | CmpOp::Le, QValue::Num(n)) => ValueKey::AtMost(*n),
382 13 : (CmpOp::Eq, QValue::Range(lo, hi)) => match (lo.as_ref(), hi.as_ref()) {
383 11 : (QValue::Num(a), QValue::Num(b)) => ValueKey::Between(*a, *b),
384 2 : _ => return None,
385 : },
386 169 : (CmpOp::Eq, QValue::Str(s)) if !s.contains('\0') => ValueKey::Text(s),
387 131 : _ => return None,
388 : })
389 635 : }
390 :
391 : /// The first top-level `q` term `entity_values` keys: its Attribute's IRI
392 : /// and the rows a match holds. Call it only for a `q` that compiled: then
393 : /// the term's `@?` is exact and every row it matches has such a row.
394 1630 : fn valued<'q>(q: &'q QNode, expand: &dyn Fn(&str) -> String) -> Option<(String, ValueKey<'q>)> {
395 639 : match q {
396 639 : QNode::Cmp { path, op, value } if path.links.is_empty() && path.bracket.is_none() => {
397 637 : let [term] = path.path.as_slice() else {
398 2 : return None;
399 : };
400 635 : Some((expand(term), value_key(*op, value)?)).filter(|(iri, _)| !iri.contains('\0'))
401 : }
402 693 : QNode::And(all) => all.iter().find_map(|n| valued(n, expand)),
403 607 : _ => None,
404 : }
405 1630 : }
406 :
407 : /// One past the most Entity ids an `entity_values` probe (0024) hands the
408 : /// page as `id = ANY(...)`. A term matching more is no narrowing
409 : /// worth its primary key reads (about 7 µs each under RLS): the page reads
410 : /// as before, and the probe cost at most this many index entries.
411 : const PROBE_ROWS: i64 = 1001;
412 :
413 : /// A read of `entity_values` (0024) for the ids a term's matches are among,
414 : /// `$1` the Tenant: its statement and binds.
415 : struct Probe {
416 : sql: String,
417 : binds: Vec<Bind>,
418 : }
419 :
420 : impl Probe {
421 733 : fn new(tenant: &TenantId, attr: Bind, cond: &str, mut binds: Vec<Bind>) -> Self {
422 733 : binds.splice(0..0, [Bind::Text(tenant.as_str().to_owned()), attr]);
423 733 : binds.push(Bind::Int(PROBE_ROWS));
424 733 : let sql = format!(
425 : "SELECT id FROM entity_values WHERE tenant_id = $1 AND {cond} LIMIT ${}",
426 733 : binds.len()
427 : );
428 733 : Self { sql, binds }
429 733 : }
430 : }
431 :
432 : /// `valued`'s term as a probe: the rows that satisfy it, or hold 'NaN', the
433 : /// mark of an Attribute whose values were not keyed (0024). `f >= x` keeps
434 : /// 'NaN', which sorts above every number.
435 502 : fn value_probe(tenant: &TenantId, iri: String, key: ValueKey<'_>) -> Probe {
436 502 : let (cond, binds) = match key {
437 56 : ValueKey::Eq(n) => ("f = ANY(ARRAY[$3, 'NaN'::float8])", vec![Bind::Num(n)]),
438 235 : ValueKey::AtLeast(n) => ("f >= $3", vec![Bind::Num(n)]),
439 31 : ValueKey::AtMost(n) => ("(f <= $3 OR f = 'NaN')", vec![Bind::Num(n)]),
440 11 : ValueKey::Between(a, b) => (
441 11 : "(f BETWEEN $3 AND $4 OR f = 'NaN')",
442 11 : vec![Bind::Num(a), Bind::Num(b)],
443 11 : ),
444 169 : ValueKey::Text(s) if s.len() > MAX_KEYED_TEXT => ("f = 'NaN'", vec![]),
445 167 : ValueKey::Text(s) => ("(txt = $3 OR f = 'NaN')", vec![Bind::Text(s.to_owned())]),
446 : };
447 502 : Probe::new(
448 502 : tenant,
449 502 : Bind::Text(iri),
450 502 : &format!("attr = $2 AND {cond}"),
451 502 : binds,
452 : )
453 502 : }
454 :
455 : /// `attrs=` as a probe of presence rows (0024), `None` when it names one of
456 : /// the Entity's own members, which have none.
457 324 : fn attrs_probe(tenant: &TenantId, attrs: &[String]) -> Option<Probe> {
458 396 : if attrs.iter().any(|a| OWN_MEMBERS.contains(&a.as_str())) {
459 93 : return None;
460 231 : }
461 231 : let names = Bind::TextArr(super::storable(attrs));
462 231 : Some(Probe::new(tenant, names, "attr = ANY($2)", vec![]))
463 324 : }
464 :
465 : /// The distinct ids `p` reads, `None` when there are `PROBE_ROWS` rows or
466 : /// more. Unprepared, as the page statement.
467 715 : async fn probe_ids(
468 715 : tx: &mut sqlx::Transaction<'static, sqlx::Postgres>,
469 715 : p: &Probe,
470 715 : ) -> Result<Option<Vec<String>>, sqlx::Error> {
471 715 : let mut qy =
472 715 : sqlx::query_scalar::<_, String>(sqlx::AssertSqlSafe(p.sql.clone())).persistent(false);
473 2640 : for b in &p.binds {
474 2640 : qy = match b {
475 1366 : Bind::Text(s) | Bind::Path(s) => qy.bind(s),
476 229 : Bind::TextArr(v) => qy.bind(v),
477 330 : Bind::Num(n) => qy.bind(n),
478 715 : Bind::Int(n) => qy.bind(n),
479 : };
480 : }
481 715 : let mut ids = qy.fetch_all(&mut **tx).await?;
482 715 : if i64::try_from(ids.len()).map_or(true, |n| n >= PROBE_ROWS) {
483 2 : return Ok(None);
484 713 : }
485 713 : ids.sort_unstable();
486 713 : ids.dedup();
487 713 : Ok(Some(ids))
488 715 : }
489 :
490 : /// The type selection's binds and SQL: `Ok(split)` for at most
491 : /// `MAX_TYPE_ARMS` distinct types, `Err(where)` (the plain containment, or
492 : /// `false` when no group can match) otherwise. A group naming a type with a
493 : /// NUL needs a type no entity has.
494 1960 : fn type_selection(groups: &[Vec<String>], binds: &mut Vec<Bind>) -> Result<TypeSplit, String> {
495 1960 : let groups: Vec<&Vec<String>> = groups
496 1960 : .iter()
497 2869 : .filter(|g| super::storable(g).len() == g.len())
498 1960 : .collect();
499 : // the type of each group naming one (`A;A` is `A`): a group of two
500 : // types is read by the multi-type arm alone
501 1960 : let mut named: Vec<&String> = groups
502 1960 : .iter()
503 3714 : .filter_map(|g| g.first().filter(|f| g.iter().all(|t| t == *f)))
504 1960 : .collect();
505 1960 : named.sort_unstable();
506 1960 : named.dedup();
507 1960 : let ors: Vec<String> = groups
508 1960 : .iter()
509 2866 : .map(|g| {
510 2866 : binds.push(Bind::TextArr((*g).clone()));
511 2866 : format!("types @> ${}", binds.len())
512 2866 : })
513 1960 : .collect();
514 1960 : if ors.is_empty() {
515 3 : return Err("false".to_owned());
516 1957 : }
517 1957 : let selection = format!("({})", ors.join(" OR "));
518 : // an empty group selects every type, which no set of arms can name
519 2832 : if named.len() > MAX_TYPE_ARMS || groups.iter().any(|g| g.is_empty()) {
520 4 : return Err(selection);
521 1953 : }
522 1953 : let firsts = named
523 1953 : .iter()
524 2116 : .map(|t| {
525 2108 : binds.push(Bind::Text((*t).clone()));
526 2108 : binds.len()
527 2108 : })
528 1953 : .collect();
529 1953 : let named = (!named.is_empty()).then(|| {
530 1791 : binds.push(Bind::TextArr(named.into_iter().cloned().collect()));
531 1791 : binds.len()
532 1791 : });
533 : Ok(TypeSplit {
534 1953 : firsts,
535 1953 : named,
536 1953 : selection,
537 1953 : exact_first: matches!(groups.as_slice(), [g] if g.len() == 1),
538 : })
539 1960 : }
540 :
541 : /// count=true's total: a count per arm over exactly the page's WHERE, never
542 : /// a window over the page statement (that kept every matching row, body and
543 : /// all, until the last one was read).
544 382 : fn count_sql(wheres: &[String], split: Option<&TypeSplit>) -> String {
545 382 : let counts: Vec<String> = arms(wheres, split)
546 382 : .iter()
547 869 : .map(|w| format!("(SELECT count(*) FROM entities WHERE {w})"))
548 382 : .collect();
549 382 : format!("SELECT {}", counts.join(" + "))
550 382 : }
551 :
552 : /// `id_prefixes` as SQL: the bounding byte range of every prefix, which the
553 : /// C-collated id index (0027) answers in its order, ANDed with the OR of
554 : /// each prefix's own range. Prefixes with a NUL match nothing (`storable`).
555 28 : fn prefix_ranges(prefixes: &[String], binds: &mut Vec<Bind>) -> String {
556 : const C: &str = "id COLLATE \"C\"";
557 40 : let mut ps: Vec<&String> = prefixes.iter().filter(|p| !p.contains('\0')).collect();
558 28 : ps.sort_unstable();
559 28 : let Some(lo) = ps.first() else {
560 6 : return "false".to_owned();
561 : };
562 58 : let mut range = |lo: &str, hi: Option<String>| {
563 58 : binds.push(Bind::Text(lo.to_owned()));
564 58 : let mut sql = format!("{C} >= ${}", binds.len());
565 58 : if let Some(hi) = hi {
566 58 : binds.push(Bind::Text(hi));
567 58 : sql.push_str(&format!(" AND {C} < ${}", binds.len()));
568 58 : }
569 58 : sql
570 58 : };
571 : // unbounded above when one prefix has no end
572 22 : let hi_end = ps
573 22 : .iter()
574 36 : .map(|p| antares_store::filter::prefix_end(p))
575 22 : .collect::<Option<Vec<_>>>()
576 22 : .and_then(|ends| ends.into_iter().max());
577 22 : let bound = range(lo, hi_end);
578 22 : let each: Vec<String> = ps
579 22 : .iter()
580 36 : .map(|p| format!("({})", range(p, antares_store::filter::prefix_end(p))))
581 22 : .collect();
582 22 : format!("({bound} AND ({}))", each.join(" OR "))
583 28 : }
584 :
585 : /// The statement, split out of `query` so its pagination shape is
586 : /// assertable without a database. Either LIMIT/OFFSET over an exactly
587 : /// decided set or a `query_candidates` walk (`true`; one row past the page answers "is there a next
588 : /// page", the total is `count_sql`'s) or the flat `MAX_UNDECIDED_ROWS`
589 : /// safety LIMIT — never no LIMIT. A split reads each arm in id order up to
590 : /// what the page can need and merges them.
591 4840 : fn query_sql(
592 4840 : select: &str,
593 4840 : wheres: &[String],
594 4840 : split: Option<&TypeSplit>,
595 4840 : page: Option<&Page>,
596 4840 : decided: bool,
597 4840 : order: &str,
598 4840 : binds: &mut Vec<Bind>,
599 4840 : ) -> (String, bool) {
600 4840 : let page = page.filter(|_| decided);
601 : // Saturating: the caller's limit is only bounded by `i64::MAX`
602 : // (`ANTARES_DISCOVERY_SCAN_MAX` clamps to exactly that), and one past
603 : // the largest page is still the largest page.
604 4840 : let limit = page.map_or(MAX_UNDECIDED_ROWS, |p| p.limit.saturating_add(1));
605 4840 : binds.push(Bind::Int(limit));
606 4840 : let lim = binds.len();
607 4840 : let offset = page.map_or(String::new(), |p| {
608 1639 : binds.push(Bind::Int(p.offset));
609 1639 : format!(" OFFSET ${}", binds.len())
610 1639 : });
611 4840 : let arms = arms(wheres, split);
612 : // A deep page skips its OFFSET over ids alone, which the type indexes
613 : // hand back without a heap visit (0013's INCLUDE), and reads only the
614 : // page's documents by primary key (page 50 of a 180k-entity
615 : // type walked 5 000 jsonb rows for 100).
616 4840 : if let Some(p) = page.filter(|p| p.offset > 0) {
617 403 : let ids = match arms.as_slice() {
618 57 : [w] => {
619 57 : format!("SELECT id FROM entities WHERE {w} ORDER BY {order} LIMIT ${lim}{offset}")
620 : }
621 : _ => {
622 346 : binds.push(Bind::Int(limit.saturating_add(p.offset)));
623 346 : let each = binds.len();
624 346 : let parts: Vec<String> = arms
625 346 : .iter()
626 839 : .map(|w| {
627 839 : format!(
628 : "(SELECT id FROM entities WHERE {w} ORDER BY {order} LIMIT ${each})"
629 : )
630 839 : })
631 346 : .collect();
632 346 : format!(
633 : "SELECT id FROM ({}) AS arms ORDER BY {order} LIMIT ${lim}{offset}",
634 346 : parts.join(" UNION ALL ")
635 : )
636 : }
637 : };
638 403 : return (
639 403 : format!(
640 403 : "SELECT {select} AS entity FROM entities \
641 403 : WHERE tenant_id = $1 AND id IN ({ids}) ORDER BY {order}"
642 403 : ),
643 403 : true,
644 403 : );
645 4437 : }
646 4437 : let sql = match arms.as_slice() {
647 2998 : [w] => format!(
648 : "SELECT {select} AS entity FROM entities WHERE {w} ORDER BY {order} LIMIT ${lim}{offset}"
649 : ),
650 : _ => {
651 1439 : let each = match page {
652 816 : Some(p) => {
653 816 : binds.push(Bind::Int(limit.saturating_add(p.offset)));
654 816 : binds.len()
655 : }
656 623 : None => lim,
657 : };
658 1439 : let parts: Vec<String> = arms
659 1439 : .iter()
660 3046 : .map(|w| {
661 3046 : format!("(SELECT id, entity FROM entities WHERE {w} ORDER BY {order} LIMIT ${each})")
662 3046 : })
663 1439 : .collect();
664 1439 : format!(
665 : "SELECT {select} AS entity FROM ({}) AS entities \
666 : ORDER BY {order} LIMIT ${lim}{offset}",
667 1439 : parts.join(" UNION ALL ")
668 : )
669 : }
670 : };
671 4437 : (sql, page.is_some())
672 4840 : }
673 :
674 : /// The total a pushed page shows by itself, `None` when only `count_sql`
675 : /// can tell it. Uncounted, the smallest total consistent with "a next page
676 : /// exists", enough for the next/prev links and never shown as a count;
677 : /// counted, a last page that holds rows (or the first, empty one).
678 1645 : fn page_total(p: &Page, rows: usize, more: bool) -> Option<i64> {
679 1645 : let seen = p.offset.saturating_add(rows as i64);
680 1645 : match (p.count, more) {
681 682 : (false, _) => Some(seen.saturating_add(i64::from(more))),
682 687 : (true, false) if rows > 0 || p.offset == 0 => Some(seen),
683 397 : (true, _) => None,
684 : }
685 1645 : }
686 :
687 : /// Did the safety LIMIT actually cut the match set? A statement that came
688 : /// back full was truncated at a bound nobody chose, and the caller's
689 : /// evaluator still has rows to drop, so no complete page can be built from
690 : /// it. 5.5.6: "When a query operation is producing so many results that can
691 : /// potentially exhaust client or server resources … implementations shall
692 : /// raise an error of type TooManyResults." Serving the truncated prefix
693 : /// would under-report the answer instead.
694 5983 : pub(crate) fn check_ceiling(paged: bool, rows: usize, ceiling: i64) -> Result<(), sqlx::Error> {
695 5983 : if paged || (rows as i64) < ceiling {
696 5977 : return Ok(());
697 6 : }
698 6 : Err(sqlx::Error::Configuration(Box::new(
699 6 : NgsiError::TooManyResults(format!(
700 6 : "query matched more than {ceiling} entities before filtering; \
701 6 : narrow it or request a smaller offset"
702 6 : )),
703 6 : )))
704 5983 : }
705 :
706 : /// Recover a spec error the store raised itself from the driver error it
707 : /// travels in (the store's only error channel is `sqlx::Error`). `None` =
708 : /// a genuine driver failure, which stays an InternalError at the seam.
709 12 : pub fn ngsi_error(e: &sqlx::Error) -> Option<&NgsiError> {
710 12 : match e {
711 8 : sqlx::Error::Configuration(b) => b.downcast_ref::<NgsiError>(),
712 4 : _ => None,
713 : }
714 12 : }
715 :
716 : impl PgEntityStore {
717 166 : pub fn new(pool: PgPool) -> Self {
718 166 : Self {
719 166 : batch_pool: pool.clone(),
720 166 : pool,
721 166 : outbox: std::sync::atomic::AtomicBool::new(false),
722 166 : async_batch: std::sync::atomic::AtomicBool::new(false),
723 166 : }
724 166 : }
725 :
726 : /// Run the batch writes on `reserve` instead of the shared pool.
727 1 : pub fn with_batch_pool(mut self, reserve: PgPool) -> Self {
728 1 : self.batch_pool = reserve;
729 1 : self
730 1 : }
731 :
732 : /// Asynchronous commit for the batch writes (see the field).
733 4 : pub fn set_async_batch_commit(&self, on: bool) {
734 4 : self.async_batch
735 4 : .store(on, std::sync::atomic::Ordering::Relaxed);
736 4 : }
737 :
738 : /// A batch write's transaction, with `synchronous_commit = off` set
739 : /// LOCAL to it when [`Self::set_async_batch_commit`] is on.
740 220 : async fn begin_batch(&self) -> Result<sqlx::Transaction<'static, sqlx::Postgres>, sqlx::Error> {
741 220 : let mut tx = super::begin(&self.batch_pool).await?;
742 219 : if self.async_batch.load(std::sync::atomic::Ordering::Relaxed) {
743 4 : sqlx::query("SET LOCAL synchronous_commit = off")
744 4 : .execute(&mut *tx)
745 4 : .await?;
746 215 : }
747 219 : Ok(tx)
748 220 : }
749 :
750 : /// Outbox producer switch — the broker enables this exactly when `bus=nats`.
751 2 : pub fn set_outbox(&self, on: bool) {
752 2 : self.outbox.store(on, std::sync::atomic::Ordering::Relaxed);
753 2 : }
754 :
755 19845 : fn outbox_on(&self) -> bool {
756 19845 : self.outbox.load(std::sync::atomic::Ordering::Relaxed)
757 19845 : }
758 :
759 : /// 5.6.1-shaped create: `false` when the id already exists (→ 409).
760 : ///
761 : /// 4.22: "expiresAt is defined as the system temporal Property at which a
762 : /// certain Entity, Property or Relationship shall become invalid" — an
763 : /// entity whose expiry has passed is already invalid, whatever the
764 : /// reaping lag, so it must not 409 a create that reads (and GETs) as
765 : /// absent. The conflict clause replaces such a row and reports CREATED;
766 : /// a live row still takes the DO NOTHING path and `rows_affected() == 0`.
767 8749 : pub async fn create(
768 8749 : &self,
769 8749 : tenant: &TenantId,
770 8749 : id: &str,
771 8749 : doc: &Value,
772 8749 : ) -> Result<bool, sqlx::Error> {
773 8749 : Ok(self.create_stamped(tenant, id, doc).await?.is_some())
774 8749 : }
775 :
776 : /// [`Self::create`] with the created row's [`ChangeVersion`] for the
777 : /// change hook, `None` when the id is taken.
778 13413 : pub async fn create_stamped(
779 13413 : &self,
780 13413 : tenant: &TenantId,
781 13413 : id: &str,
782 13413 : doc: &Value,
783 13413 : ) -> Result<Option<ChangeVersion>, sqlx::Error> {
784 13413 : let e = extract(doc);
785 13413 : let mut tx = super::begin(&self.pool).await?;
786 13413 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
787 13413 : crate::store::pg::claim_tenant(&mut tx, tenant).await?;
788 13413 : let created = sqlx::query(
789 13413 : "INSERT INTO entities
790 13413 : (tenant_id, id, entity, types, scopes, created_at, modified_at, expires_at,
791 13413 : location, location_ambiguous, attrs_expire_at)
792 13413 : VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8::timestamptz,
793 13413 : CASE WHEN ST_IsValid(try_geomfromgeojson($9))
794 13413 : THEN try_geomfromgeojson($9) END,
795 13413 : $10 OR ($9 IS NOT NULL
796 13413 : AND NOT COALESCE(ST_IsValid(try_geomfromgeojson($9)), false)),
797 13413 : try_timestamptz($11))
798 13413 : ON CONFLICT (tenant_id, id) DO UPDATE SET
799 13413 : entity = EXCLUDED.entity, types = EXCLUDED.types,
800 13413 : scopes = EXCLUDED.scopes, created_at = EXCLUDED.created_at,
801 13413 : modified_at = EXCLUDED.modified_at, expires_at = EXCLUDED.expires_at,
802 13413 : location = EXCLUDED.location,
803 13413 : location_ambiguous = EXCLUDED.location_ambiguous,
804 13413 : attrs_expire_at = EXCLUDED.attrs_expire_at,
805 13413 : version = 1
806 13413 : WHERE entities.expires_at IS NOT NULL AND entities.expires_at <= now()
807 13413 : RETURNING created_at::text",
808 13413 : )
809 13413 : .bind(tenant.as_str())
810 13413 : .bind(id)
811 13413 : .bind(doc)
812 13413 : .bind(&e.types)
813 13413 : .bind(&e.scopes)
814 13413 : .bind(&e.created)
815 13413 : .bind(&e.modified)
816 13413 : .bind(&e.expires)
817 13413 : .bind(&e.location)
818 13413 : .bind(e.location_ambiguous)
819 13413 : .bind(&e.attrs_expire)
820 13413 : .fetch_optional(&mut *tx)
821 13413 : .await?
822 13413 : .map(|r| ChangeVersion {
823 13401 : incarnation: r.get(0),
824 : version: 1,
825 13401 : });
826 13413 : if created.is_some() && self.outbox_on() {
827 4 : enqueue_change(
828 4 : &mut tx,
829 4 : tenant,
830 4 : "create",
831 4 : id,
832 4 : &e.types,
833 4 : None,
834 4 : Some(doc),
835 4 : 1,
836 4 : &e.created,
837 4 : )
838 4 : .await?;
839 13409 : }
840 13413 : tx.commit().await?;
841 13413 : Ok(created)
842 13413 : }
843 :
844 1320 : pub async fn get(&self, tenant: &TenantId, id: &str) -> Result<Option<Value>, sqlx::Error> {
845 1320 : let mut tx = super::begin(&self.pool).await?;
846 1320 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
847 : // 4.22: expired rows are invalid context until the sweep reaps them
848 1320 : let row = sqlx::query(
849 1320 : "SELECT entity FROM entities WHERE tenant_id = $1 AND id = $2
850 1320 : AND (expires_at IS NULL OR expires_at > now())",
851 1320 : )
852 1320 : .bind(tenant.as_str())
853 1320 : .bind(id)
854 1320 : .fetch_optional(&mut *tx)
855 1320 : .await?;
856 1320 : tx.commit().await?;
857 1320 : Ok(row.map(|r| r.get::<Value, _>(0)))
858 1320 : }
859 :
860 : /// The types the Tenant's live Entities carry, sorted: the
861 : /// names from the type catalogue the writes keep (migration 0012),
862 : /// each kept while the types index finds a live Entity of it.
863 : /// No `entity` document and no pass over the Tenant's rows.
864 86 : pub async fn type_names(&self, tenant: &TenantId) -> Result<Vec<String>, sqlx::Error> {
865 86 : self.catalogued(tenant, None).await
866 76 : }
867 :
868 : /// The catalogued types a live Entity carries, beside one of `any_of`
869 : /// when given. 4.22: expired rows are invalid context until the sweep
870 : /// reaps them.
871 164 : async fn catalogued(
872 164 : &self,
873 164 : tenant: &TenantId,
874 164 : any_of: Option<&[String]>,
875 164 : ) -> Result<Vec<String>, sqlx::Error> {
876 164 : let mut tx = super::begin(&self.pool).await?;
877 157 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
878 : // A type is live when it is some live Entity's first type, which a
879 : // skip scan of the first-type btree reads as one index probe per
880 : // type, or else, beside another, on an Entity of two or more types,
881 : // which the partial GIN alone indexes (0012). The plain types GIN
882 : // built a bitmap of every Entity of each type: 18 ms for five types
883 : // of 20 000, and a generic plan chose a sequential scan for an
884 : // EXISTS on the btree; this is 0.1 ms either way.
885 : const ALL: &str = "WITH RECURSIVE firsts AS (
886 : SELECT (SELECT min(e.types[1]) FROM entities e WHERE e.tenant_id = $1
887 : AND (e.expires_at IS NULL OR e.expires_at > now())) AS ty
888 : UNION ALL
889 : SELECT (SELECT min(e.types[1]) FROM entities e
890 : WHERE e.tenant_id = $1 AND e.types[1] > f.ty
891 : AND (e.expires_at IS NULL OR e.expires_at > now()))
892 : FROM firsts f WHERE f.ty IS NOT NULL)
893 : SELECT c.type FROM entity_types c WHERE c.tenant_id = $1
894 : AND (c.type IN (SELECT ty FROM firsts WHERE ty IS NOT NULL)
895 : OR EXISTS (SELECT 1 FROM entities e WHERE e.tenant_id = c.tenant_id
896 : AND cardinality(e.types) > 1 AND e.types @> ARRAY[c.type]
897 : AND (e.expires_at IS NULL OR e.expires_at > now())))
898 : ORDER BY c.type";
899 : const BESIDE: &str = "SELECT c.type FROM entity_types c WHERE c.tenant_id = $1
900 : AND EXISTS (SELECT 1 FROM entities e WHERE e.tenant_id = c.tenant_id
901 : AND cardinality(e.types) > 1
902 : AND e.types @> ARRAY[c.type] AND e.types && $2
903 : AND (e.expires_at IS NULL OR e.expires_at > now()))
904 : ORDER BY c.type";
905 156 : let names = match any_of {
906 : None => {
907 78 : sqlx::query_scalar(ALL)
908 78 : .bind(tenant.as_str())
909 78 : .fetch_all(&mut *tx)
910 78 : .await?
911 : }
912 78 : Some(of) => {
913 78 : sqlx::query_scalar(BESIDE)
914 78 : .bind(tenant.as_str())
915 78 : .bind(super::storable(of))
916 78 : .fetch_all(&mut *tx)
917 78 : .await?
918 : }
919 : };
920 156 : tx.commit().await?;
921 154 : Ok(names)
922 154 : }
923 :
924 : /// Every (type, member name) pair the Tenant's Entities ever carried,
925 : /// sorted: the attribute catalogue the writes keep (migration
926 : /// 0025). A pair whose last carrier went stays listed; the reader checks
927 : /// each against a live Entity, as it does a catalogued type.
928 68 : pub async fn attr_pairs(
929 68 : &self,
930 68 : tenant: &TenantId,
931 68 : ) -> Result<Vec<(String, String)>, sqlx::Error> {
932 68 : let mut tx = super::begin(&self.pool).await?;
933 68 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
934 68 : let pairs = sqlx::query_as(
935 68 : "SELECT type, attr FROM entity_attrs WHERE tenant_id = $1 ORDER BY type, attr",
936 68 : )
937 68 : .bind(tenant.as_str())
938 68 : .fetch_all(&mut *tx)
939 68 : .await?;
940 68 : tx.commit().await?;
941 68 : Ok(pairs)
942 68 : }
943 :
944 : /// The types of the Tenant's live Entities of two or more types that carry
945 : /// one of `any_of`, sorted: one probe of the multi-type index per
946 : /// catalogued type.
947 78 : pub async fn types_beside(
948 78 : &self,
949 78 : tenant: &TenantId,
950 78 : any_of: &[String],
951 78 : ) -> Result<Vec<String>, sqlx::Error> {
952 78 : self.catalogued(tenant, Some(any_of)).await
953 78 : }
954 :
955 : /// [`antares_store::CurrentStateDriver::entity_id_segments`] over the C-collated id
956 : /// index (0027): one seek per segment, the next one past the segment's
957 : /// own bound, so the cost follows the segments, not the ids.
958 : /// Expired Entities count too: a segment only narrows, never decides.
959 22 : pub async fn id_segments(
960 22 : &self,
961 22 : tenant: &TenantId,
962 22 : lead: &str,
963 22 : sep: char,
964 22 : max: usize,
965 22 : ) -> Result<Option<Vec<String>>, sqlx::Error> {
966 : use antares_store::filter::prefix_end;
967 22 : let Some(end) = prefix_end(lead).filter(|_| !lead.contains('\0')) else {
968 0 : return Ok(None);
969 : };
970 22 : let mut tx = super::begin(&self.pool).await?;
971 22 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
972 22 : let mut from = lead.to_owned();
973 22 : let mut out = Vec::new();
974 : // a step per segment and per id that holds no `sep` after `lead`
975 22 : for _ in 0..=max {
976 56 : let id: Option<String> = sqlx::query_scalar(
977 56 : "SELECT id FROM entities WHERE tenant_id = $1
978 56 : AND id COLLATE \"C\" >= $2 AND id COLLATE \"C\" < $3
979 56 : ORDER BY id COLLATE \"C\" LIMIT 1",
980 56 : )
981 56 : .bind(tenant.as_str())
982 56 : .bind(&from)
983 56 : .bind(&end)
984 56 : .fetch_optional(&mut *tx)
985 56 : .await?;
986 56 : let Some(id) = id else {
987 20 : tx.commit().await?;
988 20 : return Ok(Some(out));
989 : };
990 36 : let at = id.get(lead.len()..).and_then(|rest| rest.find(sep));
991 36 : from = match at.map(|k| &id[..lead.len().saturating_add(k)]) {
992 32 : Some(seg) => {
993 32 : out.push(seg.to_owned());
994 32 : match prefix_end(&format!("{seg}{sep}")) {
995 32 : Some(next) => next,
996 0 : None => break,
997 : }
998 : }
999 : // no id is greater than `id` and less than this one
1000 4 : None => format!("{id}\u{1}"),
1001 : };
1002 : }
1003 2 : tx.commit().await?;
1004 2 : Ok(None)
1005 22 : }
1006 :
1007 : /// Several Entities in one transaction: one per id that exists,
1008 : /// keyed by id. A write's Relationship targets are read this way, so a
1009 : /// fragment with five Relationships costs one BEGIN, not five.
1010 0 : pub async fn get_many(
1011 0 : &self,
1012 0 : tenant: &TenantId,
1013 0 : ids: &[&str],
1014 0 : ) -> Result<std::collections::HashMap<String, Value>, sqlx::Error> {
1015 0 : if ids.is_empty() {
1016 0 : return Ok(std::collections::HashMap::new());
1017 0 : }
1018 0 : let mut tx = super::begin(&self.pool).await?;
1019 0 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
1020 : // 4.22: expired rows are invalid context until the sweep reaps them
1021 0 : let rows = sqlx::query(
1022 0 : "SELECT id, entity FROM entities WHERE tenant_id = $1 AND id = ANY($2)
1023 0 : AND (expires_at IS NULL OR expires_at > now())",
1024 0 : )
1025 0 : .bind(tenant.as_str())
1026 0 : .bind(ids)
1027 0 : .fetch_all(&mut *tx)
1028 0 : .await?;
1029 0 : tx.commit().await?;
1030 0 : Ok(rows
1031 0 : .into_iter()
1032 0 : .map(|r| (r.get::<String, _>(0), r.get::<Value, _>(1)))
1033 0 : .collect())
1034 0 : }
1035 :
1036 : /// Returns the deleted document (the before-image, captured in the same
1037 : /// transaction as the DELETE — never re-read outside it), `None` = absent.
1038 153 : pub async fn delete(&self, tenant: &TenantId, id: &str) -> Result<Option<Value>, sqlx::Error> {
1039 153 : Ok(self.delete_stamped(tenant, id).await?.map(|(prev, _)| prev))
1040 153 : }
1041 :
1042 : /// [`Self::delete`] with the delete's [`ChangeVersion`]: one past the
1043 : /// deleted row's version, in its incarnation.
1044 1549 : pub async fn delete_stamped(
1045 1549 : &self,
1046 1549 : tenant: &TenantId,
1047 1549 : id: &str,
1048 1549 : ) -> Result<Option<(Value, ChangeVersion)>, sqlx::Error> {
1049 1549 : let mut tx = super::begin(&self.pool).await?;
1050 1549 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
1051 : // 4.22: an already-expired row is invalid, so deleting it is a
1052 : // 404 exactly as retrieving it is — never a 204 for an entity the
1053 : // API has stopped serving.
1054 1549 : let row = sqlx::query(
1055 1549 : "DELETE FROM entities WHERE tenant_id = $1 AND id = $2
1056 1549 : AND (expires_at IS NULL OR expires_at > now())
1057 1549 : RETURNING entity, types, version, created_at::text",
1058 1549 : )
1059 1549 : .bind(tenant.as_str())
1060 1549 : .bind(id)
1061 1549 : .fetch_optional(&mut *tx)
1062 1549 : .await?;
1063 1549 : let mut prev_out = None;
1064 1549 : if let Some(r) = &row {
1065 1357 : let prev: Value = r.get(0);
1066 1357 : if self.outbox_on() {
1067 0 : let types: Vec<String> = r.get(1);
1068 0 : enqueue_change(
1069 0 : &mut tx,
1070 0 : tenant,
1071 0 : "delete",
1072 0 : id,
1073 0 : &types,
1074 0 : Some(&prev),
1075 0 : None,
1076 0 : r.get::<i64, _>(2),
1077 0 : r.get::<&str, _>(3),
1078 0 : )
1079 0 : .await?;
1080 1357 : }
1081 1357 : prev_out = Some((prev, deleted_version(r)));
1082 192 : }
1083 1549 : tx.commit().await?;
1084 1549 : Ok(prev_out)
1085 1549 : }
1086 :
1087 : /// Delete one entity only if `keep` accepts the stored document, under
1088 : /// one lock: the DELETE itself takes the row lock and produces the
1089 : /// document that decides, and a refusal rolls the transaction back
1090 : /// instead of committing it. A `SELECT` followed by a `DELETE` would
1091 : /// leave the window this exists to close — between them the row can be
1092 : /// deleted and recreated under the same id, and the delete then lands on
1093 : /// a document nobody inspected.
1094 : ///
1095 : /// 4.22 as in `delete`: an already-expired row is invalid, so it is
1096 : /// absent here too.
1097 0 : pub async fn delete_if(
1098 0 : &self,
1099 0 : tenant: &TenantId,
1100 0 : id: &str,
1101 0 : keep: &(dyn for<'v> Fn(&'v Value) -> bool + Sync),
1102 0 : ) -> Result<Option<Value>, sqlx::Error> {
1103 0 : Ok(self
1104 0 : .delete_if_stamped(tenant, id, keep)
1105 0 : .await?
1106 0 : .map(|(prev, _)| prev))
1107 0 : }
1108 :
1109 : /// [`Self::delete_if`] with the delete's [`ChangeVersion`].
1110 1506 : pub async fn delete_if_stamped(
1111 1506 : &self,
1112 1506 : tenant: &TenantId,
1113 1506 : id: &str,
1114 1506 : keep: &(dyn for<'v> Fn(&'v Value) -> bool + Sync),
1115 1506 : ) -> Result<Option<(Value, ChangeVersion)>, sqlx::Error> {
1116 1506 : let mut tx = super::begin(&self.pool).await?;
1117 1506 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
1118 1506 : let row = sqlx::query(
1119 1506 : "DELETE FROM entities WHERE tenant_id = $1 AND id = $2
1120 1506 : AND (expires_at IS NULL OR expires_at > now())
1121 1506 : RETURNING entity, types, version, created_at::text",
1122 1506 : )
1123 1506 : .bind(tenant.as_str())
1124 1506 : .bind(id)
1125 1506 : .fetch_optional(&mut *tx)
1126 1506 : .await?;
1127 1506 : let Some(r) = &row else {
1128 96 : tx.commit().await?;
1129 96 : return Ok(None);
1130 : };
1131 1410 : let prev: Value = r.get(0);
1132 1410 : if !keep(&prev) {
1133 4 : tx.rollback().await?;
1134 4 : return Ok(None);
1135 1406 : }
1136 1406 : if self.outbox_on() {
1137 0 : let types: Vec<String> = r.get(1);
1138 0 : enqueue_change(
1139 0 : &mut tx,
1140 0 : tenant,
1141 0 : "delete",
1142 0 : id,
1143 0 : &types,
1144 0 : Some(&prev),
1145 0 : None,
1146 0 : r.get::<i64, _>(2),
1147 0 : r.get::<&str, _>(3),
1148 0 : )
1149 0 : .await?;
1150 1406 : }
1151 1406 : let version = deleted_version(r);
1152 1406 : tx.commit().await?;
1153 1406 : Ok(Some((prev, version)))
1154 1506 : }
1155 :
1156 : /// Query pushdown. The predicates that compile EXACTLY go to
1157 : /// Postgres; everything else is simply left out of the WHERE clause, so
1158 : /// the result is always a superset of the answer and the caller's
1159 : /// in-memory evaluator remains the arbiter. That is the property that
1160 : /// makes store modes agree: SQL removes rows, it never decides them.
1161 : ///
1162 : /// Every value here is a bind. The only text this function
1163 : /// concatenates is its own operators and `$n` placeholders.
1164 : ///
1165 : /// Every statement it builds is bounded: an exactly decided query by the
1166 : /// caller's page, everything else by `MAX_UNDECIDED_ROWS`. A set that
1167 : /// reaches that ceiling is refused with TooManyResults (5.5.6) rather
1168 : /// than returned as a prefix the caller would page over as if complete.
1169 4519 : pub async fn query(
1170 4519 : &self,
1171 4519 : tenant: &TenantId,
1172 4519 : f: &EntityFilter<'_>,
1173 4519 : ) -> Result<QueryOutcome, sqlx::Error> {
1174 4519 : self.query_in(tenant, f, false).await
1175 4519 : }
1176 :
1177 : /// `query` for a caller that re-decides every row and walks the match
1178 : /// set in id-ordered pages: `f.page` is pushed over the candidates even
1179 : /// when SQL cannot decide them (`paged` without `decided`), so a page of
1180 : /// a geoquery reads that page's candidates, not up to
1181 : /// `MAX_UNDECIDED_ROWS`.
1182 303 : pub async fn query_candidates(
1183 303 : &self,
1184 303 : tenant: &TenantId,
1185 303 : f: &EntityFilter<'_>,
1186 303 : ) -> Result<QueryOutcome, sqlx::Error> {
1187 303 : self.query_in(tenant, f, true).await
1188 303 : }
1189 :
1190 4822 : async fn query_in(
1191 4822 : &self,
1192 4822 : tenant: &TenantId,
1193 4822 : f: &EntityFilter<'_>,
1194 4822 : candidates: bool,
1195 4822 : ) -> Result<QueryOutcome, sqlx::Error> {
1196 4822 : let mut binds: Vec<Bind> = vec![Bind::Text(tenant.as_str().to_owned())];
1197 4822 : let mut wheres = vec![
1198 4822 : "tenant_id = $1".to_owned(),
1199 : // 4.22: expired entities never enter a result (or a page/total)
1200 4822 : "(expires_at IS NULL OR expires_at > now())".to_owned(),
1201 : ];
1202 : // Exactness: ids/types/attrs translate exactly by
1203 : // construction; q is exact IF it compiles (the compiler's contract);
1204 : // scopeQ is documented loose-or-equal, geo has a metric residual
1205 : // (`near` geography vs haversine) — both therefore forfeit
1206 : // `decided`, they only narrow.
1207 4822 : let mut decided = true;
1208 4822 : let mut probes = Vec::new();
1209 :
1210 4822 : if let Some(ids) = f.ids {
1211 240 : binds.push(Bind::TextArr(super::storable(ids)));
1212 240 : wheres.push(format!("id = ANY(${})", binds.len()));
1213 4582 : }
1214 : // exact id prefixes as byte ranges of the C-collated id
1215 : // index (0027). Their bounding range is that index's condition and
1216 : // its order the walk's, so a probe reads the range and never walks
1217 : // a whole type in id order for a few late matches; each prefix's
1218 : // own range decides.
1219 4822 : let order = match f.id_prefixes {
1220 22 : Some(ps) => {
1221 22 : wheres.push(prefix_ranges(ps, &mut binds));
1222 22 : "id COLLATE \"C\""
1223 : }
1224 4800 : None => "id",
1225 : };
1226 : // keyset: the pkey (tenant_id, id) seeks straight to the next page
1227 4822 : if let Some(after) = f.after_id {
1228 4 : binds.push(Bind::Text(after.to_owned()));
1229 4 : wheres.push(format!("{order} > ${}", binds.len()));
1230 4818 : }
1231 : // 5.2.33 idPattern: the literal the regex forces is a plain string
1232 : // test on the id column, exact as a narrowing; the regex itself stays
1233 : // with the caller, so `decided` is untouched.
1234 4822 : if let Some(l) = f.id_literal {
1235 66 : if l.text.contains('\0') {
1236 2 : // no stored id holds a NUL (`storable`)
1237 2 : wheres.push("false".to_owned());
1238 2 : } else {
1239 64 : binds.push(Bind::Text(l.text.to_owned()));
1240 64 : wheres.push(if l.anchored {
1241 13 : format!("starts_with(id, ${})", binds.len())
1242 : } else {
1243 51 : format!("position(${} in id) > 0", binds.len())
1244 : });
1245 : }
1246 4756 : }
1247 : // a rule's idPatterns, exact where each reads alike as an ARE;
1248 : // a non-leakproof regex under RLS filters the rows the
1249 : // tenant, type and prefix keys found, it reaches no index.
1250 : // `regexp_like`, not `~`: the entity query is planned with its
1251 : // values, and `~`'s estimator reads every fixed character of a
1252 : // dataset pattern as a 1-in-5 filter, so it expects about one match
1253 : // and sorts the whole prefix range (164k Trees, 10 s on a deployment) instead
1254 : // of reading the C-collated id index in order up to the page's LIMIT.
1255 : // A function has no estimator and keeps the default third.
1256 4822 : if let Some(ps) = f.id_patterns {
1257 26 : match ps
1258 26 : .iter()
1259 26 : .map(|p| antares_store::filter::id_pattern_sql(p))
1260 26 : .collect::<Option<Vec<_>>>()
1261 : {
1262 24 : Some(res) => {
1263 24 : let any: Vec<String> = res
1264 24 : .into_iter()
1265 24 : .map(|re| {
1266 24 : binds.push(Bind::Text(re));
1267 24 : format!("regexp_like(id COLLATE \"C\", ${})", binds.len())
1268 24 : })
1269 24 : .collect();
1270 24 : wheres.push(match any.as_slice() {
1271 24 : [] => "false".to_owned(),
1272 24 : _ => format!("({})", any.join(" OR ")),
1273 : });
1274 : }
1275 2 : None => decided = false,
1276 : }
1277 4796 : }
1278 : // OR of AND-groups, mirroring the Entity Type Selection Language
1279 : // (4.17) the caller already parsed, read as `TypeSplit` arms.
1280 4822 : let mut split = None;
1281 4822 : if let Some(groups) = f.types.filter(|g| !g.is_empty()) {
1282 1940 : match type_selection(groups, &mut binds) {
1283 1939 : Ok(s) => split = Some(s),
1284 1 : Err(w) => wheres.push(w),
1285 : }
1286 2882 : }
1287 : // `attrs`: the entity holds at least one of them NOW — the
1288 : // evaluator's `has_live_attr` after 4.22 stripped expired instances.
1289 : // `?|` alone also matched an Attribute whose every instance expired.
1290 : // Only a row with an expired instance (`attrs_expire_at` past, rare)
1291 : // pays for the per-instance check, so the predicate stays decided
1292 : // and the page stays pushed down.
1293 4822 : if let Some(attrs) = f.attrs {
1294 306 : binds.push(Bind::TextArr(super::storable(attrs)));
1295 306 : let n = binds.len();
1296 306 : wheres.push(format!(
1297 306 : "entity ?| ${n} AND (attrs_expire_at IS NULL OR attrs_expire_at >= now() \
1298 306 : OR EXISTS (SELECT 1 FROM unnest(${n}::text[]) AS w(a) \
1299 306 : WHERE entity ? w.a AND (jsonb_typeof(entity -> w.a) <> 'array' \
1300 306 : OR strpos(w.a, ':') = 0 OR jsonb_array_length(entity -> w.a) = 0 \
1301 306 : OR EXISTS (SELECT 1 FROM jsonb_array_elements(entity -> w.a) AS i(v) \
1302 306 : WHERE jsonb_typeof(i.v -> 'expiresAt') IS DISTINCT FROM 'string' \
1303 306 : OR try_timestamptz(i.v ->> 'expiresAt') IS NULL \
1304 306 : OR try_timestamptz(i.v ->> 'expiresAt') >= now()))))"
1305 306 : ));
1306 306 : probes.extend(attrs_probe(tenant, attrs));
1307 4516 : }
1308 4822 : if let Some(node) = f.q {
1309 1146 : match crate::compile::q::compile_q(
1310 1146 : node,
1311 1146 : LIVE_ENTITY,
1312 1146 : binds.len().saturating_add(1),
1313 1146 : f.expand,
1314 1146 : ) {
1315 899 : Some(c) => {
1316 899 : wheres.push(c.sql);
1317 899 : binds.extend(c.binds.into_iter().map(Bind::Path));
1318 899 : if let Some((iri, key)) = valued(node, f.expand) {
1319 486 : probes.push(value_probe(tenant, iri, key));
1320 556 : }
1321 : }
1322 247 : None => decided = false,
1323 : }
1324 1146 : if unlocated(node, f.expand) {
1325 256 : wheres.push("location IS NULL".to_owned());
1326 890 : }
1327 1146 : if let Some(name) = named(node, f.expand) {
1328 17 : binds.push(Bind::Text(name.to_owned()));
1329 17 : wheres.push(format!("(name_key = ${} OR name_key IS NULL)", binds.len()));
1330 1129 : }
1331 3676 : }
1332 4822 : if let Some(sq) = f.scope_q {
1333 308 : decided = false;
1334 305 : if let Some(c) =
1335 308 : crate::compile::scope::compile_scope_q(sq, "scopes", binds.len().saturating_add(1))
1336 305 : {
1337 305 : wheres.push(c.sql);
1338 305 : binds.extend(c.binds.into_iter().map(Bind::Text));
1339 305 : }
1340 4514 : }
1341 4822 : let mut tx = self
1342 4822 : .begin_probed(tenant, &probes, &mut wheres, &mut binds)
1343 4822 : .await?;
1344 4820 : for spec in f.geo {
1345 1617 : decided = false;
1346 : // A client may send a self-intersecting polygon. GEOS raises on
1347 : // one (`side location conflict`), which would turn a query into a
1348 : // 500 in `postgres` mode while `memory` mode answers happily from
1349 : // the evaluator. Probing validity once here keeps the two modes
1350 : // identical: invalid ⇒ no pushdown, evaluator decides. Stored
1351 : // geometries can't be invalid — the write path NULLs those. The
1352 : // probe runs on the query's own transaction, and a failed probe
1353 : // fails the query: a pool timeout answers 503
1354 : // instead of costing the pushdown.
1355 1617 : if geometry_is_valid(&mut tx, spec).await? {
1356 1613 : if let Some(c) = crate::compile::geo::compile_geo(
1357 1613 : spec,
1358 1613 : "location",
1359 1613 : binds.len().saturating_add(1),
1360 1613 : ) {
1361 1609 : wheres.push(c.sql);
1362 1609 : // geo binds first, then the numeric ones, then the cell
1363 1609 : // cover — the order `compile_geo` numbered them in.
1364 1609 : binds.extend(c.geo_binds.into_iter().map(Bind::Text));
1365 1609 : binds.extend(c.num_binds.into_iter().map(Bind::Num));
1366 1609 : binds.extend(c.cell_binds.into_iter().map(Bind::Text));
1367 1609 : }
1368 4 : }
1369 : }
1370 :
1371 : // every bind up to here belongs to the WHERE clause — the count-only
1372 : // fallback statement below reuses exactly this prefix
1373 4820 : let where_binds = binds.len();
1374 :
1375 : // Projection pushdown, only once SQL decides row membership: the
1376 : // kept doc must only need to feed `repr::apply`, never a re-check.
1377 : // `pick` keeps listed attrs + every non-attribute member (attribute
1378 : // keys are expanded IRIs — `http…` — so core members never match the
1379 : // LIKE and always survive; a non-http attr IRI merely stays
1380 : // unprojected, which is the safe direction). `omit` drops exactly the
1381 : // listed top-level IRIs.
1382 4820 : let mut select = "entity".to_owned();
1383 4820 : if decided {
1384 2902 : if let Some(keep) = f.keep_attrs {
1385 148 : binds.push(Bind::TextArr(super::storable(keep)));
1386 148 : select = format!(
1387 148 : "(SELECT COALESCE(jsonb_object_agg(t.k, t.v), '{{}}'::jsonb) \
1388 148 : FROM jsonb_each(entity) AS t(k, v) \
1389 148 : WHERE t.k NOT LIKE 'http%' OR t.k = ANY(${}))",
1390 148 : binds.len()
1391 148 : );
1392 2754 : } else if let Some(drop) = f.drop_attrs {
1393 12 : binds.push(Bind::TextArr(super::storable(drop)));
1394 12 : select = format!(
1395 12 : "(SELECT COALESCE(jsonb_object_agg(t.k, t.v), '{{}}'::jsonb) \
1396 12 : FROM jsonb_each(entity) AS t(k, v) \
1397 12 : WHERE NOT (t.k = ANY(${})))",
1398 12 : binds.len()
1399 12 : );
1400 2742 : }
1401 1918 : }
1402 :
1403 : // Pagination pushdown: ORDER BY id is the store's default order
1404 : // either way; the total for count= comes from `count_sql` below,
1405 : // only when the page itself cannot tell it. When the page cannot be pushed the statement still carries
1406 : // the safety LIMIT — without one, a single undecided request (any
1407 : // `scopeQ`, any `georel`, a `q=` shape the compiler declined)
1408 : // materializes the whole tenant into the Vec below.
1409 4820 : let ceiling = MAX_UNDECIDED_ROWS;
1410 4820 : let (sql, paged) = query_sql(
1411 4820 : &select,
1412 4820 : &wheres,
1413 4820 : split.as_ref(),
1414 4820 : f.page.as_ref(),
1415 4820 : decided || candidates,
1416 4820 : order,
1417 4820 : &mut binds,
1418 : );
1419 : // sqlx 0.9 makes dynamic SQL opt-in. The assertion holds by
1420 : // construction: `sql` is built from this function's own literals
1421 : // plus `$n` placeholders — no caller-supplied text reaches it.
1422 : // The audit lives here, next to the builder.
1423 : // Unprepared: a prepared statement goes to a generic plan after five
1424 : // runs on a connection, and that plan cannot see a selective value:
1425 : // 1.6 s instead of 1.5 ms at 100 000 entities when a GIN still
1426 : // existed. The text varies with every q, geo and
1427 : // projection shape, so the statement cache saved little anyway.
1428 4820 : let mut qy = sqlx::query(sqlx::AssertSqlSafe(sql.clone())).persistent(false);
1429 58137 : for b in &binds {
1430 58137 : qy = match b {
1431 44177 : Bind::Text(s) | Bind::Path(s) => qy.bind(s),
1432 6010 : Bind::TextArr(v) => qy.bind(v),
1433 343 : Bind::Num(n) => qy.bind(n),
1434 7607 : Bind::Int(n) => qy.bind(n),
1435 : };
1436 : }
1437 : // Stream rows, decode each to its Value and
1438 : // drop the PgRow — the full row set never sits in memory twice.
1439 4820 : let mut docs: Vec<Value> = Vec::new();
1440 4820 : let mut total: Option<i64> = None;
1441 : // candidates are no match set to count
1442 4820 : let counted = paged && decided && f.page.as_ref().is_some_and(|p| p.count);
1443 : {
1444 : use futures_util::TryStreamExt;
1445 4820 : let mut stream = qy.fetch(&mut *tx);
1446 42908 : while let Some(row) = stream.try_next().await? {
1447 38088 : docs.push(row.get::<Value, _>(0));
1448 38088 : }
1449 : }
1450 4820 : check_ceiling(paged, docs.len(), ceiling)?;
1451 : // the extra row says a next page exists. Uncounted, the total
1452 : // handed back is the smallest one consistent with that, enough for
1453 : // the next/prev links and never shown as a count; counted, a last
1454 : // page that holds rows is the total itself.
1455 4818 : if let Some(p) = f.page.as_ref().filter(|_| paged) {
1456 1627 : let more = docs.len() as i64 > p.limit;
1457 1627 : docs.truncate(p.limit as usize);
1458 1627 : total = page_total(p, docs.len(), more).filter(|_| decided);
1459 3191 : }
1460 : // count=true with more rows past the page, or an off-the-end page:
1461 : // count the match set under the same WHERE, binds and transaction
1462 4818 : if counted && total.is_none() {
1463 : // unprepared for the same reason as the statement above
1464 378 : let mut cq = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(count_sql(
1465 378 : &wheres,
1466 378 : split.as_ref(),
1467 378 : )))
1468 378 : .persistent(false);
1469 : // same wheres ⇒ same bind prefix; stop before the
1470 : // projection/page binds, which the count statement lacks
1471 2000 : for b in binds.iter().take(where_binds) {
1472 2000 : cq = match b {
1473 911 : Bind::Text(s) | Bind::Path(s) => cq.bind(s),
1474 1089 : Bind::TextArr(v) => cq.bind(v),
1475 0 : Bind::Num(n) => cq.bind(n),
1476 0 : Bind::Int(n) => cq.bind(n),
1477 : };
1478 : }
1479 378 : total = Some(cq.fetch_one(&mut *tx).await?);
1480 4440 : }
1481 4818 : tx.commit().await?;
1482 4818 : Ok(QueryOutcome {
1483 4818 : rows: docs,
1484 4818 : decided,
1485 4818 : paged,
1486 4818 : total,
1487 4818 : })
1488 4822 : }
1489 :
1490 : /// The page's transaction with the Tenant set, and each probe's ids
1491 : /// ANDed to `wheres` as `id = ANY(...)`. A probed page reads in
1492 : /// one snapshot (repeatable read), so no write between the probe and the
1493 : /// page drops a match.
1494 4822 : async fn begin_probed(
1495 4822 : &self,
1496 4822 : tenant: &TenantId,
1497 4822 : probes: &[Probe],
1498 4822 : wheres: &mut Vec<String>,
1499 4822 : binds: &mut Vec<Bind>,
1500 4822 : ) -> Result<sqlx::Transaction<'static, sqlx::Postgres>, sqlx::Error> {
1501 4822 : let mut tx = super::begin(&self.pool).await?;
1502 4820 : if !probes.is_empty() {
1503 715 : sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
1504 715 : .execute(&mut *tx)
1505 715 : .await?;
1506 4105 : }
1507 4820 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
1508 4820 : for p in probes {
1509 715 : if let Some(ids) = probe_ids(&mut tx, p).await? {
1510 713 : binds.push(Bind::TextArr(ids));
1511 713 : wheres.push(format!("id = ANY(${})", binds.len()));
1512 713 : }
1513 : }
1514 4820 : Ok(tx)
1515 4822 : }
1516 :
1517 : /// Id-ordered snapshot for one tenant (the v0 `list` shape — still the
1518 : /// path for every non-entity kind and for callers with no filter).
1519 : ///
1520 : /// Bounded like every other read: the statement carries the same
1521 : /// `MAX_UNDECIDED_ROWS` safety LIMIT, and a tenant that reaches it is
1522 : /// refused with TooManyResults (5.5.6) rather than served a silent
1523 : /// prefix.
1524 36 : pub async fn list(&self, tenant: &TenantId) -> Result<Vec<Value>, sqlx::Error> {
1525 36 : let mut tx = super::begin(&self.pool).await?;
1526 36 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
1527 36 : let mut docs: Vec<Value> = Vec::new();
1528 : {
1529 : use futures_util::TryStreamExt;
1530 36 : let mut stream = sqlx::query(
1531 : "SELECT entity FROM entities WHERE tenant_id = $1
1532 : AND (expires_at IS NULL OR expires_at > now())
1533 : ORDER BY id LIMIT $2",
1534 : )
1535 36 : .bind(tenant.as_str())
1536 36 : .bind(MAX_UNDECIDED_ROWS)
1537 36 : .fetch(&mut *tx);
1538 21306 : while let Some(row) = stream.try_next().await? {
1539 21270 : docs.push(row.get::<Value, _>(0));
1540 21270 : }
1541 : }
1542 36 : tx.commit().await?;
1543 36 : check_ceiling(false, docs.len(), MAX_UNDECIDED_ROWS)?;
1544 34 : Ok(docs)
1545 36 : }
1546 :
1547 : /// One id-ordered page of entities: ids strictly greater than `after`,
1548 : /// at most `limit`.
1549 : ///
1550 : /// No ceiling, and no fold of the tenant to build the page.
1551 : /// `MAX_UNDECIDED_ROWS` on `list` exists so a large tenant cannot be
1552 : /// materialized into one `Vec`; a page bounds that by construction, so
1553 : /// refusing here would only break the readers that must see every row
1554 : /// and have no TooManyResults to raise — 5.9.2.4's registration-vs-entity
1555 : /// conflict check among them. Keyset over the primary key, not OFFSET:
1556 : /// the walk runs against a table being written to. `after = None` is
1557 : /// bound as the empty string, which is below every id because an
1558 : /// Entity id is a URI and a URI is never empty.
1559 : ///
1560 : /// 4.22 at the entity level is in the statement, so a full page is a
1561 : /// full page: the caller reads a short page as the end of the tenant,
1562 : /// and a filter applied after the LIMIT would end the walk early.
1563 66 : pub async fn list_page(
1564 66 : &self,
1565 66 : tenant: &TenantId,
1566 66 : after: Option<&str>,
1567 66 : limit: i64,
1568 66 : ) -> Result<Vec<Value>, sqlx::Error> {
1569 66 : let mut tx = super::begin(&self.pool).await?;
1570 66 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
1571 66 : let rows = sqlx::query(
1572 66 : "SELECT entity FROM entities WHERE tenant_id = $1 AND id > $2
1573 66 : AND (expires_at IS NULL OR expires_at > now())
1574 66 : ORDER BY id LIMIT $3",
1575 66 : )
1576 66 : .bind(tenant.as_str())
1577 66 : .bind(after.unwrap_or(""))
1578 66 : .bind(limit)
1579 66 : .fetch_all(&mut *tx)
1580 66 : .await?;
1581 66 : tx.commit().await?;
1582 4036 : Ok(rows.into_iter().map(|r| r.get::<Value, _>(0)).collect())
1583 66 : }
1584 :
1585 : /// One id-ordered `LIMIT`/`OFFSET` window of the Tenant's live Entities
1586 : /// and their count, in one snapshot: the window bounds the read, so no
1587 : /// row ceiling applies (`CurrentStateDriver::list_slice`).
1588 10 : pub async fn list_slice(
1589 10 : &self,
1590 10 : tenant: &TenantId,
1591 10 : offset: i64,
1592 10 : limit: i64,
1593 10 : ) -> Result<(Vec<Value>, i64), sqlx::Error> {
1594 10 : let mut tx = super::begin(&self.pool).await?;
1595 10 : sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
1596 10 : .execute(&mut *tx)
1597 10 : .await?;
1598 10 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
1599 10 : let rows = sqlx::query(
1600 10 : "SELECT entity FROM entities WHERE tenant_id = $1
1601 10 : AND (expires_at IS NULL OR expires_at > now())
1602 10 : ORDER BY id LIMIT $2 OFFSET $3",
1603 10 : )
1604 10 : .bind(tenant.as_str())
1605 10 : .bind(limit)
1606 10 : .bind(offset)
1607 10 : .fetch_all(&mut *tx)
1608 10 : .await?;
1609 10 : let total: i64 = sqlx::query_scalar(
1610 10 : "SELECT count(*) FROM entities WHERE tenant_id = $1
1611 10 : AND (expires_at IS NULL OR expires_at > now())",
1612 10 : )
1613 10 : .bind(tenant.as_str())
1614 10 : .fetch_one(&mut *tx)
1615 10 : .await?;
1616 10 : tx.commit().await?;
1617 : Ok((
1618 18 : rows.into_iter().map(|r| r.get::<Value, _>(0)).collect(),
1619 10 : total,
1620 : ))
1621 10 : }
1622 :
1623 : /// Read-modify-write: row lock via `SELECT … FOR NO KEY UPDATE`, closure
1624 : /// applied in Rust, `version` bumped under the lock. Two racing PATCHes
1625 : /// serialize in Postgres, neither is lost. `Ok(None)` = entity absent.
1626 : ///
1627 : /// No write changes the key (tenant_id, id), so the lock is the one the
1628 : /// UPDATE itself takes: it serializes writers and leaves the history
1629 : /// append's `FOR KEY SHARE` alone (a `FOR UPDATE` here waited on
1630 : /// every in-flight append and made the append wait on it in turn, until
1631 : /// `lock_timeout` dropped the history of a whole drained request).
1632 178 : pub async fn mutate<T, E>(
1633 178 : &self,
1634 178 : tenant: &TenantId,
1635 178 : id: &str,
1636 178 : f: impl FnOnce(&mut Value) -> Result<T, E>,
1637 178 : ) -> Result<Option<Result<T, E>>, sqlx::Error> {
1638 178 : Ok(self
1639 178 : .mutate_stamped(tenant, id, f)
1640 178 : .await?
1641 178 : .map(|r| r.map(|(t, _)| t)))
1642 178 : }
1643 :
1644 : /// [`Self::mutate`] with the version the write left, read under its
1645 : /// row lock.
1646 3066 : pub async fn mutate_stamped<T, E>(
1647 3066 : &self,
1648 3066 : tenant: &TenantId,
1649 3066 : id: &str,
1650 3066 : f: impl FnOnce(&mut Value) -> Result<T, E>,
1651 3066 : ) -> Result<Option<Result<(T, ChangeVersion), E>>, sqlx::Error> {
1652 3066 : let mut tx = super::begin(&self.pool).await?;
1653 3066 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
1654 : // 4.22: an expired row is invalid, so a patch of it is a 404 —
1655 : // the same answer the retrieve it would follow already gives.
1656 3066 : let row = sqlx::query(
1657 3066 : "SELECT entity FROM entities WHERE tenant_id = $1 AND id = $2
1658 3066 : AND (expires_at IS NULL OR expires_at > now()) FOR NO KEY UPDATE",
1659 3066 : )
1660 3066 : .bind(tenant.as_str())
1661 3066 : .bind(id)
1662 3066 : .fetch_optional(&mut *tx)
1663 3066 : .await?;
1664 3066 : let Some(row) = row else {
1665 2444 : tx.commit().await?;
1666 2444 : return Ok(None);
1667 : };
1668 622 : let mut doc: Value = row.get(0);
1669 622 : let before = self.outbox_on().then(|| doc.clone());
1670 622 : match f(&mut doc) {
1671 602 : Ok(t) => {
1672 602 : let e = extract(&doc);
1673 602 : let updated = sqlx::query(
1674 602 : "UPDATE entities SET entity = $3, types = $4, scopes = $5,
1675 602 : modified_at = $6::timestamptz, expires_at = $7::timestamptz,
1676 602 : location = CASE WHEN ST_IsValid(try_geomfromgeojson($8))
1677 602 : THEN try_geomfromgeojson($8) END,
1678 602 : location_ambiguous = $9 OR ($8 IS NOT NULL
1679 602 : AND NOT COALESCE(ST_IsValid(try_geomfromgeojson($8)), false)),
1680 602 : attrs_expire_at = try_timestamptz($10),
1681 602 : version = version + 1
1682 602 : WHERE tenant_id = $1 AND id = $2
1683 602 : RETURNING version, created_at::text",
1684 602 : )
1685 602 : .bind(tenant.as_str())
1686 602 : .bind(id)
1687 602 : .bind(&doc)
1688 602 : .bind(&e.types)
1689 602 : .bind(&e.scopes)
1690 602 : .bind(&e.modified)
1691 602 : .bind(&e.expires)
1692 602 : .bind(&e.location)
1693 602 : .bind(e.location_ambiguous)
1694 602 : .bind(&e.attrs_expire)
1695 602 : .fetch_one(&mut *tx)
1696 602 : .await?;
1697 602 : if let Some(before) = &before {
1698 0 : enqueue_change(
1699 0 : &mut tx,
1700 0 : tenant,
1701 0 : "update",
1702 0 : id,
1703 0 : &e.types,
1704 0 : Some(before),
1705 0 : Some(&doc),
1706 0 : updated.get::<i64, _>(0),
1707 0 : updated.get::<&str, _>(1),
1708 0 : )
1709 0 : .await?;
1710 602 : }
1711 602 : tx.commit().await?;
1712 602 : Ok(Some(Ok((
1713 602 : t,
1714 602 : ChangeVersion {
1715 602 : version: updated.get(0),
1716 602 : incarnation: updated.get(1),
1717 602 : },
1718 602 : ))))
1719 : }
1720 20 : Err(e) => {
1721 20 : tx.rollback().await?;
1722 20 : Ok(Some(Err(e)))
1723 : }
1724 : }
1725 3066 : }
1726 :
1727 : /// Batch create: ONE multi-row INSERT for the whole batch (the jsonb
1728 : /// elements form of UNNEST), one transaction, one commit.
1729 : /// Returns a created-flag per input item, input order preserved.
1730 : /// Duplicate ids within one batch are pre-deduped here: `ON CONFLICT`
1731 : /// raises "cannot affect row a second time" otherwise — the later
1732 : /// duplicate reports `false` (5.5.11.1: the first instance wins).
1733 : ///
1734 : /// 5.6.7.4: "For each of the NGSI-LD Entities included in the input
1735 : /// Array execute the behaviour defined by clause 5.6.1, but limited to a
1736 : /// local operation" — so the conflict clause is `create`'s, and an
1737 : /// entity past its `expiresAt` is absent (4.22) and gets created over
1738 : /// rather than reported as an id that already exists.
1739 18 : pub async fn batch_create(
1740 18 : &self,
1741 18 : tenant: &TenantId,
1742 18 : items: &[(String, Value)],
1743 18 : ) -> Result<Vec<bool>, sqlx::Error> {
1744 18 : Ok(self
1745 18 : .batch_create_stamped(tenant, items)
1746 18 : .await?
1747 18 : .into_iter()
1748 20012 : .map(|v| v.is_some())
1749 18 : .collect())
1750 18 : }
1751 :
1752 : /// [`Self::batch_create`] with each created row's [`ChangeVersion`].
1753 54 : pub async fn batch_create_stamped(
1754 54 : &self,
1755 54 : tenant: &TenantId,
1756 54 : items: &[(String, Value)],
1757 54 : ) -> Result<Vec<Option<ChangeVersion>>, sqlx::Error> {
1758 54 : let mut seen = std::collections::HashSet::new();
1759 54 : let mut payload = Vec::new();
1760 20066 : for (id, doc) in items {
1761 20066 : if seen.insert(id.as_str()) {
1762 20062 : let e = extract(doc);
1763 20062 : payload.push(serde_json::json!({
1764 20062 : "id": id, "doc": doc, "types": e.types, "scopes": e.scopes,
1765 20062 : "created": e.created, "modified": e.modified, "expires": e.expires,
1766 20062 : "location": e.location, "loc_ambiguous": e.location_ambiguous,
1767 20062 : "attrs_expire": e.attrs_expire,
1768 20062 : }));
1769 20062 : }
1770 : }
1771 54 : let mut tx = self.begin_batch().await?;
1772 54 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
1773 54 : crate::store::pg::claim_tenant(&mut tx, tenant).await?;
1774 54 : let rows = sqlx::query(
1775 54 : "INSERT INTO entities
1776 54 : (tenant_id, id, entity, types, scopes, created_at, modified_at, expires_at,
1777 54 : location, location_ambiguous, attrs_expire_at)
1778 54 : SELECT $1, e->>'id', e->'doc',
1779 54 : ARRAY(SELECT jsonb_array_elements_text(e->'types')),
1780 54 : CASE WHEN e->'scopes' = 'null'::jsonb THEN NULL
1781 54 : ELSE ARRAY(SELECT jsonb_array_elements_text(e->'scopes')) END,
1782 54 : (e->>'created')::timestamptz, (e->>'modified')::timestamptz,
1783 54 : (e->>'expires')::timestamptz,
1784 54 : CASE WHEN ST_IsValid(try_geomfromgeojson(e->>'location'))
1785 54 : THEN try_geomfromgeojson(e->>'location') END,
1786 54 : COALESCE((e->>'loc_ambiguous')::bool, false)
1787 54 : OR (e->>'location' IS NOT NULL
1788 54 : AND NOT COALESCE(ST_IsValid(try_geomfromgeojson(e->>'location')), false)),
1789 54 : try_timestamptz(e->>'attrs_expire')
1790 54 : FROM jsonb_array_elements($2::jsonb) AS e
1791 54 : ON CONFLICT (tenant_id, id) DO UPDATE SET
1792 54 : entity = EXCLUDED.entity, types = EXCLUDED.types,
1793 54 : scopes = EXCLUDED.scopes, created_at = EXCLUDED.created_at,
1794 54 : modified_at = EXCLUDED.modified_at,
1795 54 : expires_at = EXCLUDED.expires_at, location = EXCLUDED.location,
1796 54 : location_ambiguous = EXCLUDED.location_ambiguous,
1797 54 : attrs_expire_at = EXCLUDED.attrs_expire_at,
1798 54 : version = 1
1799 54 : WHERE entities.expires_at IS NOT NULL AND entities.expires_at <= now()
1800 54 : RETURNING id, created_at::text",
1801 54 : )
1802 54 : .bind(tenant.as_str())
1803 54 : .bind(Value::Array(payload))
1804 54 : .fetch_all(&mut *tx)
1805 54 : .await?;
1806 54 : let mut created_now: std::collections::HashMap<String, String> = rows
1807 54 : .into_iter()
1808 20050 : .map(|r| (r.get::<String, _>(0), r.get::<String, _>(1)))
1809 54 : .collect();
1810 54 : if self.outbox_on() {
1811 0 : let mut seen_ev = std::collections::HashSet::new();
1812 0 : let mut events = Vec::new();
1813 0 : for (id, doc) in items {
1814 0 : if created_now.contains_key(id.as_str()) && seen_ev.insert(id.as_str()) {
1815 0 : let e = extract(doc);
1816 0 : events.push(change_event(
1817 0 : tenant,
1818 0 : "create",
1819 0 : id,
1820 0 : &e.types,
1821 0 : None,
1822 0 : Some(doc),
1823 0 : 1,
1824 0 : &e.created,
1825 0 : ));
1826 0 : }
1827 : }
1828 0 : super::outbox::enqueue_many(&mut tx, tenant, &events).await?;
1829 54 : }
1830 54 : tx.commit().await?;
1831 : // consume-once: a duplicate of a created id still reports false
1832 54 : Ok(items
1833 54 : .iter()
1834 20066 : .map(|(id, _)| {
1835 20066 : created_now
1836 20066 : .remove(id.as_str())
1837 20066 : .map(|incarnation| ChangeVersion {
1838 20050 : incarnation,
1839 : version: 1,
1840 20050 : })
1841 20066 : })
1842 54 : .collect())
1843 54 : }
1844 :
1845 : /// Batch delete: ONE statement, returning each deleted row's previous
1846 : /// document (the change-hook before-image).
1847 : ///
1848 : /// 5.6.10.4: "For each of the NGSI-LD Entity IDs included in the input
1849 : /// Array execute the behaviour defined by clause 5.6.6, but limited to a
1850 : /// local operation" — so an entity past its `expiresAt` is absent (4.22)
1851 : /// and is not deleted here either, exactly as `delete` refuses it.
1852 10 : pub async fn batch_delete(
1853 10 : &self,
1854 10 : tenant: &TenantId,
1855 10 : ids: &[String],
1856 10 : ) -> Result<Vec<(String, Value)>, sqlx::Error> {
1857 10 : Ok(self
1858 10 : .batch_delete_stamped(tenant, ids)
1859 10 : .await?
1860 10 : .into_iter()
1861 10 : .map(|(id, prev, _)| (id, prev))
1862 10 : .collect())
1863 10 : }
1864 :
1865 : /// [`Self::batch_delete`] with each delete's [`ChangeVersion`].
1866 176 : pub async fn batch_delete_stamped(
1867 176 : &self,
1868 176 : tenant: &TenantId,
1869 176 : ids: &[String],
1870 176 : ) -> Result<Vec<(String, Value, ChangeVersion)>, sqlx::Error> {
1871 176 : let mut tx = super::begin(&self.pool).await?;
1872 176 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
1873 176 : let rows = sqlx::query(
1874 176 : "DELETE FROM entities WHERE tenant_id = $1 AND id = ANY($2)
1875 176 : AND (expires_at IS NULL OR expires_at > now())
1876 176 : RETURNING id, entity, types, version, created_at::text",
1877 176 : )
1878 176 : .bind(tenant.as_str())
1879 176 : .bind(ids)
1880 176 : .fetch_all(&mut *tx)
1881 176 : .await?;
1882 176 : if self.outbox_on() {
1883 0 : let events: Vec<Value> = rows
1884 0 : .iter()
1885 0 : .map(|r| {
1886 0 : let id: String = r.get(0);
1887 0 : let prev: Value = r.get(1);
1888 0 : let types: Vec<String> = r.get(2);
1889 0 : change_event(
1890 0 : tenant,
1891 0 : "delete",
1892 0 : &id,
1893 0 : &types,
1894 0 : Some(&prev),
1895 0 : None,
1896 0 : r.get::<i64, _>(3),
1897 0 : r.get::<&str, _>(4),
1898 : )
1899 0 : })
1900 0 : .collect();
1901 0 : super::outbox::enqueue_many(&mut tx, tenant, &events).await?;
1902 176 : }
1903 176 : tx.commit().await?;
1904 176 : Ok(rows
1905 176 : .iter()
1906 1468 : .map(|r| {
1907 1468 : let v = ChangeVersion {
1908 1468 : incarnation: r.get(4),
1909 1468 : version: r.get::<i64, _>(3).saturating_add(1),
1910 1468 : };
1911 1468 : (r.get::<String, _>(0), r.get::<Value, _>(1), v)
1912 1468 : })
1913 176 : .collect())
1914 176 : }
1915 :
1916 : /// Batch upsert in REPLACE semantics — ONE
1917 : /// `INSERT … ON CONFLICT DO UPDATE` for the whole batch, one transaction.
1918 : /// Returns a created-flag per input item (input order; duplicates of one
1919 : /// id report the first outcome). Before-images for the change events are
1920 : /// captured by a single `FOR NO KEY UPDATE` select in the same
1921 : /// transaction (see [`Self::mutate`]). Returns per input item: (created?,
1922 : /// before-image) — the before-image is for the caller's change hook and
1923 : /// comes from the same-tx lock.
1924 : ///
1925 : /// 5.6.8.4: "Create the Entity locally if it does not exist (i.e. no
1926 : /// Entity with the same Entity ID is present) executing the behaviour
1927 : /// defined by clause 5.6.1, but limited to a local operation" — an
1928 : /// entity past its `expiresAt` does not exist (4.22), so it takes the
1929 : /// creation branch: fresh `created_at`, `version` back to 1 and a
1930 : /// created-flag, which is what splits 201 from 204 at the API.
1931 24 : pub async fn batch_upsert_replace(
1932 24 : &self,
1933 24 : tenant: &TenantId,
1934 24 : items: &[(String, Value)],
1935 24 : ) -> Result<Vec<(bool, Option<Value>)>, sqlx::Error> {
1936 24 : Ok(self
1937 24 : .batch_upsert_replace_stamped(tenant, items)
1938 24 : .await?
1939 23 : .into_iter()
1940 2657 : .map(|(created, prev, _)| (created, prev))
1941 23 : .collect())
1942 24 : }
1943 :
1944 : /// [`Self::batch_upsert_replace`] with each written row's
1945 : /// [`ChangeVersion`].
1946 82 : pub async fn batch_upsert_replace_stamped(
1947 82 : &self,
1948 82 : tenant: &TenantId,
1949 82 : items: &[(String, Value)],
1950 82 : ) -> Result<Vec<(bool, Option<Value>, Option<ChangeVersion>)>, sqlx::Error> {
1951 82 : let mut seen = std::collections::HashSet::new();
1952 82 : let mut payload = Vec::new();
1953 82 : let mut ids = Vec::new();
1954 2756 : for (id, doc) in items {
1955 2756 : if seen.insert(id.as_str()) {
1956 2752 : let e = extract(doc);
1957 2752 : ids.push(id.clone());
1958 2752 : payload.push(serde_json::json!({
1959 2752 : "id": id, "doc": doc, "types": e.types, "scopes": e.scopes,
1960 2752 : "created": e.created, "modified": e.modified, "expires": e.expires,
1961 2752 : "location": e.location, "loc_ambiguous": e.location_ambiguous,
1962 2752 : "attrs_expire": e.attrs_expire,
1963 2752 : }));
1964 2752 : }
1965 : }
1966 82 : let mut tx = self.begin_batch().await?;
1967 81 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
1968 81 : crate::store::pg::claim_tenant(&mut tx, tenant).await?;
1969 : // lock + before-images in one statement (ordered: stable lock order)
1970 81 : let prev_rows = sqlx::query(
1971 81 : "SELECT id, entity,
1972 81 : (expires_at IS NOT NULL AND expires_at <= now()) AS expired
1973 81 : FROM entities WHERE tenant_id = $1 AND id = ANY($2)
1974 81 : ORDER BY id FOR NO KEY UPDATE",
1975 81 : )
1976 81 : .bind(tenant.as_str())
1977 81 : .bind(&ids)
1978 81 : .fetch_all(&mut *tx)
1979 81 : .await?;
1980 : // 4.22: an expired row is already invalid, so it is not a
1981 : // before-image — the upsert that replaces it is a creation and
1982 : // its change event has nothing before it. The lock still covers
1983 : // the row, so the stable lock order is unchanged.
1984 81 : let prevs: std::collections::HashMap<String, Value> = prev_rows
1985 81 : .into_iter()
1986 86 : .filter(|r| !r.get::<bool, _>(2))
1987 84 : .map(|r| (r.get::<String, _>(0), r.get::<Value, _>(1)))
1988 81 : .collect();
1989 81 : let rows = sqlx::query(
1990 81 : "INSERT INTO entities
1991 81 : (tenant_id, id, entity, types, scopes, created_at, modified_at, expires_at,
1992 81 : location, location_ambiguous, attrs_expire_at)
1993 81 : SELECT $1, e->>'id', e->'doc',
1994 81 : ARRAY(SELECT jsonb_array_elements_text(e->'types')),
1995 81 : CASE WHEN e->'scopes' = 'null'::jsonb THEN NULL
1996 81 : ELSE ARRAY(SELECT jsonb_array_elements_text(e->'scopes')) END,
1997 81 : (e->>'created')::timestamptz, (e->>'modified')::timestamptz,
1998 81 : (e->>'expires')::timestamptz,
1999 81 : CASE WHEN ST_IsValid(try_geomfromgeojson(e->>'location'))
2000 81 : THEN try_geomfromgeojson(e->>'location') END,
2001 81 : COALESCE((e->>'loc_ambiguous')::bool, false)
2002 81 : OR (e->>'location' IS NOT NULL
2003 81 : AND NOT COALESCE(ST_IsValid(try_geomfromgeojson(e->>'location')), false)),
2004 81 : try_timestamptz(e->>'attrs_expire')
2005 81 : FROM jsonb_array_elements($2::jsonb) AS e
2006 81 : ON CONFLICT (tenant_id, id) DO UPDATE SET
2007 81 : entity = EXCLUDED.entity, types = EXCLUDED.types,
2008 81 : scopes = EXCLUDED.scopes, modified_at = EXCLUDED.modified_at,
2009 81 : expires_at = EXCLUDED.expires_at, location = EXCLUDED.location,
2010 81 : location_ambiguous = EXCLUDED.location_ambiguous,
2011 81 : attrs_expire_at = EXCLUDED.attrs_expire_at,
2012 81 : created_at = CASE WHEN entities.expires_at IS NOT NULL
2013 81 : AND entities.expires_at <= now()
2014 81 : THEN EXCLUDED.created_at
2015 81 : ELSE entities.created_at END,
2016 81 : version = CASE WHEN entities.expires_at IS NOT NULL
2017 81 : AND entities.expires_at <= now()
2018 81 : THEN 1 ELSE entities.version + 1 END
2019 81 : RETURNING id, (xmax = 0 OR version = 1) AS inserted,
2020 81 : version, created_at::text",
2021 81 : )
2022 81 : .bind(tenant.as_str())
2023 81 : .bind(Value::Array(payload))
2024 81 : .fetch_all(&mut *tx)
2025 81 : .await?;
2026 81 : let mut created: std::collections::HashMap<String, (bool, ChangeVersion)> =
2027 81 : std::collections::HashMap::new();
2028 81 : let mut events = Vec::new();
2029 2751 : for r in &rows {
2030 2751 : let id: String = r.get(0);
2031 2751 : let inserted: bool = r.get(1);
2032 2751 : let version = ChangeVersion {
2033 2751 : version: r.get(2),
2034 2751 : incarnation: r.get(3),
2035 2751 : };
2036 : // the id came out of `items`, so the lookup answers; an
2037 : // outbox row for a document that is not there would carry no
2038 : // payload, so there is nothing to emit either way
2039 0 : if let (true, Some(doc)) = (
2040 2751 : self.outbox_on(),
2041 1091677 : items.iter().find(|(i, _)| *i == id).map(|(_, d)| d),
2042 : ) {
2043 0 : let e = extract(doc);
2044 0 : events.push(change_event(
2045 0 : tenant,
2046 0 : if inserted { "create" } else { "update" },
2047 0 : &id,
2048 0 : &e.types,
2049 0 : prevs.get(&id),
2050 0 : Some(doc),
2051 0 : r.get::<i64, _>(2),
2052 0 : r.get::<&str, _>(3),
2053 : ));
2054 2751 : }
2055 2751 : created.insert(id, (inserted, version));
2056 : }
2057 81 : super::outbox::enqueue_many(&mut tx, tenant, &events).await?;
2058 81 : tx.commit().await?;
2059 81 : Ok(items
2060 81 : .iter()
2061 2755 : .map(|(id, _)| {
2062 2755 : let (inserted, version) = created
2063 2755 : .get(id)
2064 2755 : .map_or((false, None), |(c, v)| (*c, Some(v.clone())));
2065 2755 : (inserted, prevs.get(id).cloned(), version)
2066 2755 : })
2067 81 : .collect())
2068 82 : }
2069 :
2070 : /// Batch read-modify-write — ONE transaction,
2071 : /// all rows locked in a single ordered `FOR NO KEY UPDATE` select (see
2072 : /// [`Self::mutate`]), the closure
2073 : /// applied per doc in Rust, and ONE multi-row UPDATE writeback. Per-item
2074 : /// results align with `ids`: `None` = absent, `Some(Err)` = the closure
2075 : /// rejected that item (its write is skipped, the rest proceed).
2076 18 : pub async fn batch_mutate<E>(
2077 18 : &self,
2078 18 : tenant: &TenantId,
2079 18 : ids: &[String],
2080 18 : f: impl FnMut(&str, &mut Value) -> Result<(), E>,
2081 18 : ) -> Result<Vec<Option<Result<(), E>>>, sqlx::Error> {
2082 18 : Ok(self.batch_mutate_stamped(tenant, ids, f).await?.0)
2083 18 : }
2084 :
2085 : /// [`Self::batch_mutate`] with the [`ChangeVersion`] of each row written.
2086 : #[allow(clippy::type_complexity)] // the plain results and their versions
2087 84 : pub async fn batch_mutate_stamped<E>(
2088 84 : &self,
2089 84 : tenant: &TenantId,
2090 84 : ids: &[String],
2091 84 : mut f: impl FnMut(&str, &mut Value) -> Result<(), E>,
2092 84 : ) -> Result<
2093 84 : (
2094 84 : Vec<Option<Result<(), E>>>,
2095 84 : std::collections::HashMap<String, ChangeVersion>,
2096 84 : ),
2097 84 : sqlx::Error,
2098 84 : > {
2099 84 : let mut tx = self.begin_batch().await?;
2100 84 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
2101 84 : let rows = sqlx::query(
2102 84 : // 4.22: an expired entity is invalid, so it is not a row this
2103 84 : // update can find — 5.6.9.4 is clause 5.6.3 run per item, and
2104 84 : // 5.6.3 on an absent entity is ResourceNotFound. Without the
2105 84 : // predicate the batch reported the id as updated, wrote to
2106 84 : // it, and emitted a change event for an entity every read
2107 84 : // refuses. ORDER BY id keeps the lock order stable.
2108 84 : "SELECT id, entity FROM entities
2109 84 : WHERE tenant_id = $1 AND id = ANY($2)
2110 84 : AND (expires_at IS NULL OR expires_at > now())
2111 84 : ORDER BY id FOR NO KEY UPDATE",
2112 84 : )
2113 84 : .bind(tenant.as_str())
2114 84 : .bind(ids)
2115 84 : .fetch_all(&mut *tx)
2116 84 : .await?;
2117 84 : let mut docs: std::collections::HashMap<String, Value> = rows
2118 84 : .into_iter()
2119 6934 : .map(|r| (r.get::<String, _>(0), r.get::<Value, _>(1)))
2120 84 : .collect();
2121 84 : let mut results: Vec<Option<Result<(), E>>> = Vec::with_capacity(ids.len());
2122 : // 5.6.9.4 is clause 5.6.3 run per item, so a repeated id applies
2123 : // its closure once per item onto the SAME document and the last
2124 : // application is the state the row must end at. The writeback
2125 : // below is one `UPDATE … FROM`, which updates a target row once
2126 : // from whichever source row the join happens to reach, so the
2127 : // statement is fed one row per id — the document as the whole
2128 : // batch left it — rather than one per input item.
2129 84 : let mut write_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
2130 84 : let mut changed: Vec<(String, Value, Value)> = Vec::new(); // id, before, after
2131 6952 : for id in ids {
2132 6952 : match docs.get_mut(id) {
2133 16 : None => results.push(None),
2134 6936 : Some(doc) => {
2135 6936 : let before = doc.clone();
2136 6936 : match f(id, doc) {
2137 2 : Err(e) => {
2138 2 : *doc = before; // closure failed: discard its edits
2139 2 : results.push(Some(Err(e)));
2140 2 : }
2141 : Ok(()) => {
2142 6934 : if *doc != before {
2143 4932 : write_ids.insert(id.as_str());
2144 4932 : changed.push((id.clone(), before, doc.clone()));
2145 4932 : }
2146 6934 : results.push(Some(Ok(())));
2147 : }
2148 : }
2149 : }
2150 : }
2151 : }
2152 84 : let payload: Vec<Value> = write_ids
2153 84 : .iter()
2154 4932 : .filter_map(|id| docs.get(*id).map(|doc| (*id, doc)))
2155 4932 : .map(|(id, doc)| {
2156 4932 : let e = extract(doc);
2157 4932 : serde_json::json!({
2158 4932 : "id": id, "doc": doc, "types": e.types,
2159 4932 : "scopes": e.scopes, "modified": e.modified,
2160 4932 : "expires": e.expires, "location": e.location,
2161 4932 : "loc_ambiguous": e.location_ambiguous,
2162 4932 : "attrs_expire": e.attrs_expire,
2163 : })
2164 4932 : })
2165 84 : .collect();
2166 84 : let mut versions = std::collections::HashMap::new();
2167 84 : if !payload.is_empty() {
2168 78 : let updated = sqlx::query(
2169 78 : "UPDATE entities t SET
2170 78 : entity = e->'doc',
2171 78 : types = ARRAY(SELECT jsonb_array_elements_text(e->'types')),
2172 78 : scopes = CASE WHEN e->'scopes' = 'null'::jsonb THEN NULL
2173 78 : ELSE ARRAY(SELECT jsonb_array_elements_text(e->'scopes')) END,
2174 78 : modified_at = (e->>'modified')::timestamptz,
2175 78 : expires_at = (e->>'expires')::timestamptz,
2176 78 : location = CASE WHEN ST_IsValid(try_geomfromgeojson(e->>'location'))
2177 78 : THEN try_geomfromgeojson(e->>'location') END,
2178 78 : location_ambiguous = COALESCE((e->>'loc_ambiguous')::bool, false)
2179 78 : OR (e->>'location' IS NOT NULL
2180 78 : AND NOT COALESCE(ST_IsValid(try_geomfromgeojson(e->>'location')), false)),
2181 78 : attrs_expire_at = try_timestamptz(e->>'attrs_expire'),
2182 78 : version = t.version + 1
2183 78 : FROM jsonb_array_elements($2::jsonb) AS e
2184 78 : WHERE t.tenant_id = $1 AND t.id = e->>'id'
2185 78 : RETURNING t.id, t.version, t.created_at::text",
2186 78 : )
2187 78 : .bind(tenant.as_str())
2188 78 : .bind(Value::Array(payload))
2189 78 : .fetch_all(&mut *tx)
2190 78 : .await?;
2191 78 : versions = updated
2192 78 : .into_iter()
2193 4932 : .map(|r| {
2194 4932 : (
2195 4932 : r.get::<String, _>(0),
2196 4932 : ChangeVersion {
2197 4932 : version: r.get(1),
2198 4932 : incarnation: r.get(2),
2199 4932 : },
2200 4932 : )
2201 4932 : })
2202 78 : .collect();
2203 78 : if self.outbox_on() {
2204 0 : let events: Vec<Value> = changed
2205 0 : .iter()
2206 0 : .filter_map(|(id, before, after)| {
2207 0 : let e = extract(after);
2208 0 : let v = versions.get(id)?;
2209 0 : Some(change_event(
2210 0 : tenant,
2211 0 : "update",
2212 0 : id,
2213 0 : &e.types,
2214 0 : Some(before),
2215 0 : Some(after),
2216 0 : v.version,
2217 0 : &v.incarnation,
2218 0 : ))
2219 0 : })
2220 0 : .collect();
2221 0 : super::outbox::enqueue_many(&mut tx, tenant, &events).await?;
2222 78 : }
2223 6 : }
2224 84 : tx.commit().await?;
2225 84 : Ok((results, versions))
2226 84 : }
2227 :
2228 : /// Current row version (test hook for the version-monotonicity assertions).
2229 : #[cfg(any(test, feature = "test-kit"))]
2230 8 : pub async fn version(&self, tenant: &TenantId, id: &str) -> Result<Option<i64>, sqlx::Error> {
2231 8 : let mut tx = super::begin(&self.pool).await?;
2232 8 : crate::store::pg::set_tenant(&mut tx, tenant).await?;
2233 8 : let row = sqlx::query("SELECT version FROM entities WHERE tenant_id = $1 AND id = $2")
2234 8 : .bind(tenant.as_str())
2235 8 : .bind(id)
2236 8 : .fetch_optional(&mut *tx)
2237 8 : .await?;
2238 8 : tx.commit().await?;
2239 8 : Ok(row.map(|r| r.get::<i64, _>(0)))
2240 8 : }
2241 : }
2242 :
2243 : /// 4.22 for the current state: drop every Attribute instance whose
2244 : /// `expiresAt` passed from the stored docs, at most `batch` of them, inside
2245 : /// the sweep's transaction `tx` (service escape on). The rows come by the
2246 : /// `attrs_expire_at` index; each Tenant's are locked and rewritten under that
2247 : /// Tenant, as 0005 grants the escape no UPDATE. `modified_at` stays: an
2248 : /// expiry is no modification, so no change event is owed either. A row a
2249 : /// writer holds is skipped and taken on the next tick. Returns the rows
2250 : /// rewritten.
2251 1007 : pub(super) async fn prune_expired_current(
2252 1007 : tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
2253 1007 : batch: i64,
2254 1007 : ) -> Result<u64, sqlx::Error> {
2255 1007 : let due: Vec<(String, String)> = sqlx::query_as(
2256 1007 : "SELECT tenant_id, id FROM entities
2257 1007 : WHERE attrs_expire_at IS NOT NULL AND attrs_expire_at < now()
2258 1007 : ORDER BY attrs_expire_at LIMIT $1",
2259 1007 : )
2260 1007 : .bind(batch)
2261 1007 : .fetch_all(&mut **tx)
2262 1007 : .await?;
2263 1007 : let mut by_tenant: std::collections::BTreeMap<&str, Vec<&str>> =
2264 1007 : std::collections::BTreeMap::new();
2265 1007 : for (tenant, id) in &due {
2266 19 : by_tenant.entry(tenant).or_default().push(id);
2267 19 : }
2268 1007 : let mut n = 0u64;
2269 1007 : for (tenant, ids) in by_tenant {
2270 16 : sqlx::query(crate::SET_TENANT_SQL)
2271 16 : .bind(tenant)
2272 16 : .execute(&mut **tx)
2273 16 : .await?;
2274 16 : let rows = sqlx::query(
2275 16 : "SELECT id, entity,
2276 16 : to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"')
2277 16 : FROM entities
2278 16 : WHERE tenant_id = $1 AND id = ANY($2::text[]) AND attrs_expire_at < now()
2279 16 : FOR NO KEY UPDATE SKIP LOCKED",
2280 16 : )
2281 16 : .bind(tenant)
2282 16 : .bind(&ids)
2283 16 : .fetch_all(&mut **tx)
2284 16 : .await?;
2285 17 : for r in rows {
2286 17 : let id: String = r.get(0);
2287 17 : let mut doc: Value = r.get(1);
2288 17 : let now: String = r.get(2);
2289 17 : crate::store::mem::prune_expired_instances(&mut doc, &now);
2290 17 : let e = extract(&doc);
2291 17 : let rewritten = sqlx::query(
2292 17 : "UPDATE entities SET entity = $3,
2293 17 : location = CASE WHEN ST_IsValid(try_geomfromgeojson($4))
2294 17 : THEN try_geomfromgeojson($4) END,
2295 17 : location_ambiguous = $5 OR ($4 IS NOT NULL
2296 17 : AND NOT COALESCE(ST_IsValid(try_geomfromgeojson($4)), false)),
2297 17 : attrs_expire_at = try_timestamptz($6),
2298 17 : version = version + 1
2299 17 : WHERE tenant_id = $1 AND id = $2",
2300 17 : )
2301 17 : .bind(tenant)
2302 17 : .bind(&id)
2303 17 : .bind(&doc)
2304 17 : .bind(&e.location)
2305 17 : .bind(e.location_ambiguous)
2306 17 : .bind(&e.attrs_expire)
2307 17 : .execute(&mut **tx)
2308 17 : .await?
2309 17 : .rows_affected();
2310 17 : n = n.saturating_add(rewritten);
2311 : }
2312 : }
2313 1007 : Ok(n)
2314 1007 : }
2315 :
2316 : /// One cheap probe on `tx`: is the client's query geometry valid?
2317 : /// Unparseable GeoJSON counts as invalid — same outcome, no pushdown — and
2318 : /// reaches that answer through `try_geomfromgeojson` (0001_init.sql), so only
2319 : /// the connection can make the probe fail.
2320 1617 : async fn geometry_is_valid(
2321 1617 : tx: &mut sqlx::PgConnection,
2322 1617 : spec: &crate::compile::geo::GeoSpec<'_>,
2323 1617 : ) -> Result<bool, sqlx::Error> {
2324 1617 : let geojson = serde_json::to_string(&serde_json::json!({
2325 1617 : "type": spec.geometry, "coordinates": spec.coordinates
2326 1617 : }))
2327 1617 : .unwrap_or_default();
2328 1617 : sqlx::query_scalar::<_, bool>("SELECT COALESCE(ST_IsValid(try_geomfromgeojson($1)), false)")
2329 1617 : .bind(&geojson)
2330 1617 : .fetch_one(&mut *tx)
2331 1617 : .await
2332 1617 : }
2333 :
2334 : #[cfg(test)]
2335 : mod tests {
2336 : use super::*;
2337 : use serde_json::json;
2338 :
2339 18 : fn wheres() -> Vec<String> {
2340 18 : vec!["tenant_id = $1".to_owned()]
2341 18 : }
2342 :
2343 : /// only a `!location` every match must satisfy narrows to
2344 : /// `location IS NULL`; an alternative, a sub-path or another
2345 : /// geoproperty does not.
2346 : #[test]
2347 2 : fn only_a_required_missing_location_narrows_to_no_geometry() {
2348 8 : let expand = |t: &str| match t {
2349 8 : "location" => crate::compile::geo::LOCATION_IRI.to_owned(),
2350 2 : t => format!("https://x/{t}"),
2351 8 : };
2352 18 : let narrows = |q: &str| unlocated(&antares_ql::parse_q(q).expect("q parses"), &expand);
2353 6 : for q in ["!location", "a>1;!location", "(a==1;!location);b"] {
2354 6 : assert!(narrows(q), "{q}");
2355 : }
2356 12 : for q in [
2357 2 : "location",
2358 2 : "!location|a",
2359 2 : "!location.observedAt",
2360 2 : "!place",
2361 2 : "a;b",
2362 2 : "!location[x]",
2363 2 : ] {
2364 12 : assert!(!narrows(q), "{q}");
2365 : }
2366 2 : }
2367 :
2368 : /// only a `name=="…"` string every match must equal narrows to
2369 : /// `name_key`; an alternative, another operator or value, a sub-path or
2370 : /// another attribute does not.
2371 : /// the top-level terms `entity_values` keys, each probing the
2372 : /// rows a match must hold; everything else is left to the `@?`.
2373 : #[test]
2374 2 : fn a_top_level_value_term_probes_entity_values() {
2375 28 : let expand = |t: &str| format!("https://x/{t}");
2376 2 : let tenant = TenantId::new("t").expect("tenant");
2377 2 : let long = "é".repeat(600);
2378 2 : let long_q = format!(r#"b=="{long}""#);
2379 16 : for (q, attr, cond, nums, text) in [
2380 2 : (
2381 2 : "a==42",
2382 2 : "https://x/a",
2383 2 : "f = ANY(ARRAY[$3, 'NaN'::float8])",
2384 2 : vec![42.0],
2385 2 : None,
2386 2 : ),
2387 2 : ("a>1.5", "https://x/a", "f >= $3", vec![1.5], None),
2388 2 : (
2389 2 : "x==1;a>=2",
2390 2 : "https://x/x",
2391 2 : "f = ANY(ARRAY[$3, 'NaN'::float8])",
2392 2 : vec![1.0],
2393 2 : None,
2394 2 : ),
2395 2 : (
2396 2 : "a<0",
2397 2 : "https://x/a",
2398 2 : "(f <= $3 OR f = 'NaN')",
2399 2 : vec![0.0],
2400 2 : None,
2401 2 : ),
2402 2 : (
2403 2 : "a<=-1",
2404 2 : "https://x/a",
2405 2 : "(f <= $3 OR f = 'NaN')",
2406 2 : vec![-1.0],
2407 2 : None,
2408 2 : ),
2409 2 : (
2410 2 : "a==1..3",
2411 2 : "https://x/a",
2412 2 : "(f BETWEEN $3 AND $4 OR f = 'NaN')",
2413 2 : vec![1.0, 3.0],
2414 2 : None,
2415 2 : ),
2416 2 : (
2417 2 : r#"a=="x""#,
2418 2 : "https://x/a",
2419 2 : "(txt = $3 OR f = 'NaN')",
2420 2 : vec![],
2421 2 : Some("x"),
2422 2 : ),
2423 2 : (long_q.as_str(), "https://x/b", "f = 'NaN'", vec![], None),
2424 2 : ] {
2425 16 : let node = antares_ql::parse_q(q).expect("q parses");
2426 16 : let (iri, key) = valued(&node, &expand).expect(q);
2427 16 : assert_eq!(iri, attr, "{q}");
2428 16 : let p = value_probe(&tenant, iri, key);
2429 16 : let limit = p.binds.len();
2430 16 : assert_eq!(
2431 : p.sql,
2432 16 : format!("SELECT id FROM entity_values WHERE tenant_id = $1 AND attr = $2 AND {cond} LIMIT ${limit}"),
2433 : "{q}"
2434 : );
2435 16 : let texts: Vec<&str> = p
2436 16 : .binds
2437 16 : .iter()
2438 64 : .filter_map(|b| match b {
2439 34 : Bind::Text(t) => Some(t.as_str()),
2440 30 : _ => None,
2441 64 : })
2442 16 : .collect();
2443 16 : assert_eq!(
2444 : texts,
2445 16 : ["t", attr].into_iter().chain(text).collect::<Vec<_>>(),
2446 : "{q}"
2447 : );
2448 16 : let got_nums: Vec<f64> = p
2449 16 : .binds
2450 16 : .iter()
2451 64 : .filter_map(|b| match b {
2452 14 : Bind::Num(n) => Some(*n),
2453 50 : _ => None,
2454 64 : })
2455 16 : .collect();
2456 16 : assert_eq!(got_nums, nums, "{q}");
2457 16 : assert!(matches!(p.binds.last(), Some(Bind::Int(PROBE_ROWS))), "{q}");
2458 : }
2459 22 : for q in [
2460 2 : "a",
2461 2 : "!a",
2462 2 : "a!=1",
2463 2 : "a==1|b==2",
2464 2 : "a==true",
2465 2 : "a==1,2",
2466 2 : r#"a=="x".."y""#,
2467 2 : r#"a~="x""#,
2468 2 : "a.sub==1",
2469 2 : "a[k]==1",
2470 2 : r#"a>"x""#,
2471 2 : ] {
2472 22 : let node = antares_ql::parse_q(q).expect("q parses");
2473 22 : assert_eq!(valued(&node, &expand), None, "{q}");
2474 : }
2475 2 : }
2476 :
2477 : /// `attrs=` probes presence rows unless it names one of the
2478 : /// Entity's own members, which have none.
2479 : #[test]
2480 2 : fn attrs_probe_presence_rows_unless_they_name_an_own_member() {
2481 2 : let tenant = TenantId::new("t").expect("tenant");
2482 2 : let attrs = ["https://x/a".to_owned(), "https://x/b".to_owned()];
2483 2 : let p = attrs_probe(&tenant, &attrs).expect("probe");
2484 2 : assert_eq!(
2485 : p.sql,
2486 : "SELECT id FROM entity_values WHERE tenant_id = $1 AND attr = ANY($2) LIMIT $3"
2487 : );
2488 2 : assert!(matches!(p.binds.get(1), Some(Bind::TextArr(v)) if v.as_slice() == attrs));
2489 16 : for own in OWN_MEMBERS {
2490 16 : let attrs = ["https://x/a".to_owned(), (*own).to_owned()];
2491 16 : assert!(attrs_probe(&tenant, &attrs).is_none(), "{own}");
2492 : }
2493 2 : }
2494 :
2495 : #[test]
2496 2 : fn only_a_required_name_string_narrows_to_the_name_key() {
2497 8 : let expand = |t: &str| match t {
2498 8 : "name" => NAME_IRI.to_owned(),
2499 2 : t => format!("https://x/{t}"),
2500 8 : };
2501 2 : let wants =
2502 24 : |q: &str| named(&antares_ql::parse_q(q).expect("q parses"), &expand).map(str::to_owned);
2503 6 : for (q, want) in [
2504 2 : (r#"name=="Stredočeský""#, "Stredočeský"),
2505 2 : (r#"a>1;name=="x""#, "x"),
2506 2 : (r#"(a==1;name=="");b"#, ""),
2507 2 : ] {
2508 6 : assert_eq!(wants(q).as_deref(), Some(want), "{q}");
2509 : }
2510 18 : for q in [
2511 2 : "name",
2512 2 : r#"name!="x""#,
2513 2 : r#"name=="x"|a==1"#,
2514 2 : "name==1",
2515 2 : r#"name=="x","y""#,
2516 2 : r#"name~="x""#,
2517 2 : r#"name.sub=="x""#,
2518 2 : r#"name[x]=="x""#,
2519 2 : r#"title=="x""#,
2520 2 : ] {
2521 18 : assert_eq!(wants(q), None, "{q}");
2522 : }
2523 2 : }
2524 :
2525 : /// `location_ambiguous` is the single bit that keeps geoquery pushdown
2526 : /// honest: `compile_geo` ORs it into every predicate so a row whose
2527 : /// default GeoProperty could not be reduced to ONE geometry still reaches
2528 : /// the evaluator. It must be true exactly when the doc CARRIES the
2529 : /// geoproperty and extraction refused — a row with no geoproperty at all
2530 : /// can never match a geoquery and is excluded in SQL, which is the whole
2531 : /// point of the column.
2532 : #[test]
2533 2 : fn location_ambiguous_is_carried_but_unextractable() {
2534 2 : let loc = crate::compile::geo::LOCATION_IRI;
2535 2 : let point = json!({"type": "Point", "coordinates": [2.29, 48.85]});
2536 :
2537 : // one default GeoProperty instance with a geometry: extracted, exact
2538 2 : let e = extract(&json!({"id": "urn:x", loc: [{"value": point}]}));
2539 2 : assert!(e.location.is_some());
2540 2 : assert!(!e.location_ambiguous);
2541 :
2542 : // multi-instance: no single geometry, so the evaluator arbitrates
2543 2 : let e = extract(&json!({"id": "urn:x", loc: [{"value": point}, {"value": point}]}));
2544 2 : assert!(e.location.is_none());
2545 2 : assert!(
2546 2 : e.location_ambiguous,
2547 : "a multi-instance location must reach the evaluator"
2548 : );
2549 :
2550 : // a GeometryCollection and a non-GeoJSON value are the same case
2551 6 : for v in [
2552 2 : json!({"type": "GeometryCollection", "geometries": []}),
2553 2 : json!("somewhere"),
2554 2 : json!({"coordinates": [1, 2]}),
2555 2 : ] {
2556 6 : let e = extract(&json!({"id": "urn:x", loc: [{"value": v}]}));
2557 6 : assert!(e.location.is_none(), "{v} must not become a geometry");
2558 6 : assert!(e.location_ambiguous, "{v} must reach the evaluator");
2559 : }
2560 :
2561 : // NO geoproperty: not ambiguous — the row is excluded in SQL
2562 2 : let e = extract(&json!({"id": "urn:x", "https://a/speed": [{"value": 1}]}));
2563 2 : assert!(e.location.is_none());
2564 2 : assert!(
2565 2 : !e.location_ambiguous,
2566 : "a row without a location must stay excludable in SQL"
2567 : );
2568 2 : }
2569 :
2570 : /// The rest of `extract`: `type`/`scope` accept the string form and the
2571 : /// array form, an absent `scope` is SQL NULL (never an empty array, which
2572 : /// would mean "scoped to nothing"), and a missing system timestamp falls
2573 : /// back rather than failing the write.
2574 : #[test]
2575 2 : fn extract_reads_types_scopes_and_stamps() {
2576 2 : let e = extract(&json!({"id": "urn:x", "type": "T", "scope": "/a"}));
2577 2 : assert_eq!(e.types, ["T"]);
2578 2 : assert_eq!(e.scopes.as_deref(), Some(&["/a".to_owned()][..]));
2579 2 : let e = extract(&json!({"id": "urn:x", "type": ["T", "U"], "scope": ["/a", "/b"]}));
2580 2 : assert_eq!(e.types, ["T", "U"]);
2581 2 : assert_eq!(e.scopes.expect("scopes").len(), 2);
2582 2 : let e = extract(&json!({"id": "urn:x"}));
2583 2 : assert!(e.types.is_empty());
2584 2 : assert!(e.scopes.is_none(), "absent scope must be SQL NULL");
2585 2 : assert!(e.expires.is_none());
2586 2 : assert_eq!(e.created, "1970-01-01T00:00:00Z");
2587 : // a non-string, non-array scope is present but names nothing
2588 2 : let e = extract(&json!({"id": "urn:x", "scope": 7}));
2589 2 : assert_eq!(e.scopes.as_deref(), Some(&[][..]));
2590 2 : }
2591 :
2592 : /// The outbox row IS the wire contract the drain deserializes, and the
2593 : /// drain DELETES any row it cannot decode — a renamed key loses the event
2594 : /// silently. Pin the key set and the operation vocabulary here, where the
2595 : /// producer lives.
2596 : #[test]
2597 2 : fn the_change_event_carries_exactly_the_wire_keys() {
2598 2 : let t = TenantId::default();
2599 2 : let doc = json!({"id": "urn:e", "https://a/speed": [{"value": 1}]});
2600 2 : let ev = change_event(
2601 2 : &t,
2602 2 : "create",
2603 2 : "urn:e",
2604 2 : &["T".to_owned()],
2605 2 : None,
2606 2 : Some(&doc),
2607 : 1,
2608 2 : "inc",
2609 : );
2610 2 : let keys: Vec<&str> = ev
2611 2 : .as_object()
2612 2 : .expect("object")
2613 2 : .keys()
2614 2 : .map(String::as_str)
2615 2 : .collect();
2616 2 : assert_eq!(
2617 : keys,
2618 : [
2619 : "changed_attrs",
2620 : "entity_id",
2621 : "incarnation",
2622 : "op",
2623 : "payload",
2624 : "prev_payload",
2625 : "tenant",
2626 : "types",
2627 : "version",
2628 : ]
2629 : );
2630 2 : assert_eq!(ev["op"], "create");
2631 2 : assert_eq!(ev["tenant"], t.as_str());
2632 2 : assert!(ev["prev_payload"].is_null(), "a create has no before-image");
2633 6 : for op in ["create", "update", "delete"] {
2634 6 : assert_eq!(
2635 6 : change_event(&t, op, "urn:e", &[], None, None, 1, "inc")["op"],
2636 : op
2637 : );
2638 : }
2639 2 : }
2640 :
2641 : /// `changed_attrs` names the top-level ATTRIBUTES that differ. A create
2642 : /// lists every attribute, a delete lists every prior one — and, the half
2643 : /// that actually bounds notification traffic, an attribute present in
2644 : /// both images with an EQUAL value is not listed, nor is any system
2645 : /// member that changed on every write.
2646 : #[test]
2647 2 : fn changed_attrs_lists_the_differences_and_nothing_else() {
2648 2 : let t = TenantId::default();
2649 10 : let ev = |prev: Option<&Value>, next: Option<&Value>| {
2650 10 : change_event(&t, "update", "urn:e", &[], prev, next, 1, "inc")["changed_attrs"].clone()
2651 10 : };
2652 2 : let a = json!({"id": "urn:e", "type": ["T"], "createdAt": "t0", "modifiedAt": "t0",
2653 2 : "https://a/x": [{"value": 1}], "https://a/y": [{"value": 2}]});
2654 2 : let b = json!({"id": "urn:e", "type": ["T"], "createdAt": "t0", "modifiedAt": "t9",
2655 2 : "https://a/x": [{"value": 1}], "https://a/y": [{"value": 3}]});
2656 2 : assert_eq!(ev(Some(&a), Some(&b)), json!(["https://a/y"]));
2657 2 : assert_eq!(ev(None, Some(&a)), json!(["https://a/x", "https://a/y"]));
2658 2 : assert_eq!(ev(Some(&a), None), json!(["https://a/x", "https://a/y"]));
2659 : // identical images change nothing at all
2660 2 : assert_eq!(ev(Some(&a), Some(&a)), json!([]));
2661 : // and no system member is ever named
2662 2 : let ids = json!({"id": "urn:other", "type": ["U"], "scope": "/s",
2663 2 : "modifiedAt": "t1", "deletedAt": "t1", "expiresAt": "t1"});
2664 2 : assert_eq!(
2665 2 : ev(Some(&a), Some(&ids)),
2666 2 : json!(["https://a/x", "https://a/y"])
2667 : );
2668 2 : }
2669 :
2670 10 : fn page(offset: i64, limit: i64) -> Page {
2671 10 : Page {
2672 10 : offset,
2673 10 : limit,
2674 10 : count: true,
2675 10 : }
2676 10 : }
2677 :
2678 : /// 5.5.9.1: a query SQL decided exactly keeps the pushed page — LIMIT
2679 : /// and OFFSET. count=true carries no window either: the window
2680 : /// kept every match, body and all, before the LIMIT could cut.
2681 : #[test]
2682 2 : fn a_decided_page_pushes_limit_and_offset() {
2683 2 : let mut binds = vec![Bind::Text("t".to_owned())];
2684 2 : let (sql, paged) = query_sql(
2685 2 : "entity",
2686 2 : &wheres(),
2687 2 : None,
2688 2 : Some(&page(20, 10)),
2689 2 : true,
2690 2 : "id",
2691 2 : &mut binds,
2692 2 : );
2693 2 : assert!(paged);
2694 2 : assert_eq!(
2695 : sql,
2696 : "SELECT entity AS entity FROM entities WHERE tenant_id = $1 AND id IN (\
2697 : SELECT id FROM entities WHERE tenant_id = $1 ORDER BY id LIMIT $2 OFFSET $3) ORDER BY id"
2698 : );
2699 2 : assert!(!sql.contains("OVER"), "{sql}");
2700 2 : assert!(matches!(binds[1], Bind::Int(11)));
2701 2 : assert!(matches!(binds[2], Bind::Int(20)));
2702 : // the first page reads its documents directly
2703 2 : let mut binds = vec![Bind::Text("t".to_owned())];
2704 2 : let (sql, _) = query_sql(
2705 2 : "entity",
2706 2 : &wheres(),
2707 2 : None,
2708 2 : Some(&page(0, 10)),
2709 2 : true,
2710 2 : "id",
2711 2 : &mut binds,
2712 2 : );
2713 2 : assert!(
2714 2 : sql.starts_with("SELECT entity AS entity FROM entities WHERE tenant_id"),
2715 : "{sql}"
2716 : );
2717 2 : assert!(sql.ends_with("ORDER BY id LIMIT $2 OFFSET $3"), "{sql}");
2718 2 : }
2719 :
2720 : /// one named type reads two leakproof index arms, each in id
2721 : /// order and bounded by what the page can need, never `types @>` alone
2722 : /// (which RLS keeps out of every index condition).
2723 : /// id prefixes are one bounding byte range ANDed with each
2724 : /// prefix's own, and a page over them orders by bytes, the C-collated
2725 : /// index's order; no prefix selects nothing.
2726 : #[test]
2727 2 : fn id_prefixes_are_byte_ranges_in_byte_order() {
2728 2 : let mut binds = vec![Bind::Text("t".to_owned())];
2729 2 : let ps = ["b:".to_owned(), "a:".to_owned(), "z\0".to_owned()];
2730 2 : let w = prefix_ranges(&ps, &mut binds);
2731 2 : let c = "id COLLATE \"C\"";
2732 2 : assert_eq!(
2733 : w,
2734 2 : format!(
2735 : "({c} >= $2 AND {c} < $3 AND (({c} >= $4 AND {c} < $5) OR ({c} >= $6 AND {c} < $7)))"
2736 : )
2737 : );
2738 2 : let texts: Vec<&str> = binds
2739 2 : .iter()
2740 14 : .filter_map(|b| match b {
2741 14 : Bind::Text(t) => Some(t.as_str()),
2742 0 : _ => None,
2743 14 : })
2744 2 : .collect();
2745 2 : assert_eq!(texts, ["t", "a:", "b;", "a:", "a;", "b:", "b;"]);
2746 2 : assert_eq!(prefix_ranges(&[], &mut binds), "false");
2747 2 : assert_eq!(prefix_ranges(&["\0".to_owned()], &mut binds), "false");
2748 2 : let mut binds = vec![Bind::Text("t".to_owned())];
2749 2 : let (sql, _) = query_sql(
2750 2 : "entity",
2751 2 : &["tenant_id = $1".to_owned()],
2752 2 : None,
2753 2 : Some(&page(0, 10)),
2754 2 : true,
2755 2 : c,
2756 2 : &mut binds,
2757 2 : );
2758 2 : assert_eq!(
2759 : sql,
2760 2 : format!(
2761 : "SELECT entity AS entity FROM entities WHERE tenant_id = $1 ORDER BY {c} LIMIT $2 OFFSET $3"
2762 : )
2763 : );
2764 2 : }
2765 :
2766 : #[test]
2767 2 : fn one_type_pages_through_two_bounded_arms() {
2768 2 : let mut binds = vec![Bind::Text("t".to_owned())];
2769 2 : let split = type_selection(&[vec!["T".to_owned()]], &mut binds).expect("split");
2770 2 : assert!(matches!(&binds[2], Bind::Text(t) if t == "T"));
2771 2 : let (sql, paged) = query_sql(
2772 2 : "entity",
2773 2 : &wheres(),
2774 2 : Some(&split),
2775 2 : Some(&page(20, 10)),
2776 2 : true,
2777 2 : "id",
2778 2 : &mut binds,
2779 2 : );
2780 2 : assert!(paged);
2781 2 : assert_eq!(
2782 : sql,
2783 : "SELECT entity AS entity FROM entities WHERE tenant_id = $1 AND id IN (SELECT id FROM (\
2784 : (SELECT id FROM entities WHERE tenant_id = $1 AND types[1] = $3 \
2785 : ORDER BY id LIMIT $7) UNION ALL \
2786 : (SELECT id FROM entities WHERE tenant_id = $1 AND cardinality(types) > 1 \
2787 : AND types[1] <> ALL($4) AND (types @> $2) ORDER BY id LIMIT $7)) AS arms \
2788 : ORDER BY id LIMIT $5 OFFSET $6) ORDER BY id"
2789 : );
2790 2 : assert!(matches!(binds[4], Bind::Int(11)));
2791 2 : assert!(matches!(binds[5], Bind::Int(20)));
2792 2 : assert!(
2793 2 : matches!(binds[6], Bind::Int(31)),
2794 : "each arm: offset + limit + 1"
2795 : );
2796 : // the undecided ceiling bounds each arm and the merge alike
2797 2 : let mut binds = vec![Bind::Text("t".to_owned())];
2798 2 : let split = type_selection(&[vec!["T".to_owned()]], &mut binds).expect("split");
2799 2 : let (sql, paged) = query_sql(
2800 2 : "entity",
2801 2 : &wheres(),
2802 2 : Some(&split),
2803 2 : None,
2804 2 : false,
2805 2 : "id",
2806 2 : &mut binds,
2807 2 : );
2808 2 : assert!(!paged);
2809 2 : assert_eq!(sql.matches("LIMIT $5").count(), 3, "{sql}");
2810 2 : assert!(!sql.contains("OFFSET"), "{sql}");
2811 2 : }
2812 :
2813 : /// `type=A,B` and `type=A;B` read one first-type arm per named
2814 : /// type, each filtered by the whole selection unless one type decides
2815 : /// it, plus the multi-type arm; a selection the arms cannot name keeps
2816 : /// the plain containment.
2817 : #[test]
2818 2 : fn a_type_list_reads_one_arm_per_type() {
2819 28 : let t = |s: &str| s.to_owned();
2820 2 : let mut binds = vec![Bind::Text(t("t"))];
2821 2 : let split = type_selection(&[vec![t("B")], vec![t("A")]], &mut binds).expect("split");
2822 2 : let w = arms(&[t("tenant_id = $1")], Some(&split));
2823 2 : assert_eq!(
2824 : w,
2825 : [
2826 : "tenant_id = $1 AND types[1] = $4 AND (types @> $2 OR types @> $3)",
2827 : "tenant_id = $1 AND types[1] = $5 AND (types @> $2 OR types @> $3)",
2828 : "tenant_id = $1 AND cardinality(types) > 1 AND types[1] <> ALL($6) \
2829 : AND (types @> $2 OR types @> $3)",
2830 : ]
2831 : );
2832 2 : assert!(matches!(&binds[3], Bind::Text(x) if x == "A"), "sorted");
2833 2 : assert!(matches!(&binds[5], Bind::TextArr(v) if v == &[t("A"), t("B")]));
2834 :
2835 2 : let mut binds = vec![Bind::Text(t("t"))];
2836 2 : let split = type_selection(&[vec![t("A"), t("B")]], &mut binds).expect("split");
2837 2 : assert!(!split.exact_first, "A;B is not decided by the first type");
2838 :
2839 2 : let mut binds = vec![Bind::Text(t("t"))];
2840 34 : let many: Vec<Vec<String>> = (0..=MAX_TYPE_ARMS).map(|i| vec![format!("T{i}")]).collect();
2841 2 : let w = type_selection(&many, &mut binds)
2842 2 : .err()
2843 2 : .expect("too many arms");
2844 2 : assert!(w.starts_with("(types @> $2 OR"), "{w}");
2845 2 : let mut binds = vec![Bind::Text(t("t"))];
2846 2 : assert!(type_selection(&[vec![t("A")], vec![]], &mut binds).is_err());
2847 2 : let mut binds = vec![Bind::Text(t("t"))];
2848 2 : assert_eq!(
2849 2 : type_selection(&[vec![t("A\0")]], &mut binds)
2850 2 : .err()
2851 2 : .as_deref(),
2852 : Some("false")
2853 : );
2854 2 : }
2855 :
2856 : /// a match of `A;B` has two types, so the multi-type arm alone
2857 : /// reads it; a first-type arm walked every Entity of A and kept none.
2858 : /// A one-type group beside it (`A,B;C`) keeps its first-type arm, and
2859 : /// `A;A` is the one type A.
2860 : #[test]
2861 2 : fn an_and_group_reads_the_multi_type_arm_only() {
2862 28 : let t = |s: &str| s.to_owned();
2863 2 : let mut binds = vec![Bind::Text(t("t"))];
2864 2 : let split = type_selection(&[vec![t("A"), t("B")]], &mut binds).expect("split");
2865 2 : assert_eq!(
2866 2 : arms(&[t("tenant_id = $1")], Some(&split)),
2867 : ["tenant_id = $1 AND cardinality(types) > 1 AND (types @> $2)"]
2868 : );
2869 2 : assert_eq!(binds.len(), 2, "no first-type binds");
2870 :
2871 2 : let mut binds = vec![Bind::Text(t("t"))];
2872 2 : let split =
2873 2 : type_selection(&[vec![t("A")], vec![t("B"), t("C")]], &mut binds).expect("split");
2874 2 : assert_eq!(
2875 2 : arms(&[t("tenant_id = $1")], Some(&split)),
2876 : [
2877 : "tenant_id = $1 AND types[1] = $4 AND (types @> $2 OR types @> $3)",
2878 : "tenant_id = $1 AND cardinality(types) > 1 AND types[1] <> ALL($5) \
2879 : AND (types @> $2 OR types @> $3)",
2880 : ]
2881 : );
2882 2 : assert!(matches!(&binds[3], Bind::Text(x) if x == "A"));
2883 2 : assert!(matches!(&binds[4], Bind::TextArr(v) if v == &[t("A")]));
2884 :
2885 2 : let mut binds = vec![Bind::Text(t("t"))];
2886 2 : let split = type_selection(&[vec![t("A"), t("A")]], &mut binds).expect("split");
2887 2 : assert_eq!(
2888 2 : arms(&[t("tenant_id = $1")], Some(&split)).len(),
2889 : 2,
2890 : "A;A is A"
2891 : );
2892 2 : }
2893 :
2894 : /// count=false never asks for a count; count=true asks only when the
2895 : /// page cannot show the total: more rows past it, or an off-the-end page.
2896 : #[test]
2897 2 : fn only_a_counted_page_that_cannot_show_its_total_asks_for_a_count() {
2898 2 : let p = |offset, count| Page {
2899 18 : offset,
2900 : limit: 10,
2901 18 : count,
2902 18 : };
2903 8 : for (offset, rows, more) in [(0, 10, true), (20, 0, false), (20, 3, false), (0, 0, false)] {
2904 8 : assert!(page_total(&p(offset, false), rows, more).is_some());
2905 : }
2906 2 : assert_eq!(page_total(&p(20, false), 10, true), Some(31));
2907 2 : assert_eq!(page_total(&p(0, true), 10, true), None);
2908 2 : assert_eq!(page_total(&p(20, true), 0, false), None, "off the end");
2909 2 : assert_eq!(page_total(&p(20, true), 3, false), Some(23), "last page");
2910 2 : assert_eq!(
2911 2 : page_total(&p(0, true), 0, false),
2912 : Some(0),
2913 : "nothing matches"
2914 : );
2915 2 : }
2916 :
2917 : /// The count runs over exactly the page's arms: the same WHERE and the
2918 : /// same binds, a count per arm, no window.
2919 : #[test]
2920 2 : fn the_count_reads_the_same_arms_as_the_page() {
2921 2 : let w = vec!["tenant_id = $1".to_owned(), "entity ?| $4".to_owned()];
2922 2 : assert_eq!(
2923 2 : count_sql(&w, None),
2924 : "SELECT (SELECT count(*) FROM entities WHERE tenant_id = $1 AND entity ?| $4)"
2925 : );
2926 2 : let split = TypeSplit {
2927 2 : firsts: vec![2],
2928 2 : named: Some(3),
2929 2 : selection: "(types @> $5)".to_owned(),
2930 2 : exact_first: true,
2931 2 : };
2932 2 : let sql = count_sql(&w, Some(&split));
2933 2 : assert_eq!(
2934 : sql,
2935 : "SELECT (SELECT count(*) FROM entities WHERE tenant_id = $1 AND entity ?| $4 \
2936 : AND types[1] = $2) + (SELECT count(*) FROM entities WHERE tenant_id = $1 \
2937 : AND entity ?| $4 AND cardinality(types) > 1 AND types[1] <> ALL($3) \
2938 : AND (types @> $5))"
2939 : );
2940 2 : }
2941 :
2942 : /// 6.3.10: without count=true no total is owed, so the statement carries
2943 : /// no window count (which forced a walk of the whole match set) and
2944 : /// fetches one row past the page to learn whether a next page exists.
2945 : #[test]
2946 2 : fn an_uncounted_page_fetches_one_extra_row_and_no_window_total() {
2947 2 : let mut binds = vec![Bind::Text("t".to_owned())];
2948 2 : let p = Page {
2949 2 : offset: 20,
2950 2 : limit: 10,
2951 2 : count: false,
2952 2 : };
2953 2 : let (sql, paged) = query_sql("entity", &wheres(), None, Some(&p), true, "id", &mut binds);
2954 2 : assert!(paged);
2955 2 : assert!(!sql.contains("count(*) OVER ()"), "{sql}");
2956 2 : assert!(sql.contains("LIMIT $2 OFFSET $3"), "{sql}");
2957 2 : assert!(matches!(binds[1], Bind::Int(11)), "limit + 1 must be bound");
2958 2 : assert!(matches!(binds[2], Bind::Int(20)));
2959 2 : }
2960 :
2961 : /// The largest page a caller can build still has to produce a usable
2962 : /// LIMIT. `types_attrs.rs` clamps its discovery scan to `i64::MAX` on
2963 : /// purpose so the cast is safe; the uncounted path then binds
2964 : /// `limit + 1`, which is where that clamp stops being enough. The
2965 : /// addition overflows: a panic in a debug build, and in release a wrap
2966 : /// to `i64::MIN`, which Postgres refuses as a negative LIMIT.
2967 : #[test]
2968 2 : fn the_largest_page_still_binds_a_usable_limit() {
2969 2 : let mut binds = vec![Bind::Text("t".to_owned())];
2970 2 : let p = Page {
2971 2 : offset: 0,
2972 2 : limit: i64::MAX,
2973 2 : count: false,
2974 2 : };
2975 2 : let (sql, paged) = query_sql("entity", &wheres(), None, Some(&p), true, "id", &mut binds);
2976 2 : assert!(paged);
2977 2 : assert!(sql.contains("LIMIT $2"), "{sql}");
2978 2 : assert!(
2979 2 : matches!(binds[1], Bind::Int(i64::MAX)),
2980 : "the bound limit must stay positive"
2981 : );
2982 2 : }
2983 :
2984 : /// The undecided path must never leave the statement unbounded: SQL only
2985 : /// narrowed the set (`scopeQ`, `georel`, a `q=` the compiler declined),
2986 : /// so the rows come back to be filtered — under a LIMIT, and without an
2987 : /// OFFSET, which would page over the wrong set.
2988 : #[test]
2989 2 : fn an_undecided_query_carries_a_safety_limit_and_no_offset() {
2990 2 : let mut binds = vec![Bind::Text("t".to_owned())];
2991 2 : let (sql, paged) = query_sql(
2992 2 : "entity",
2993 2 : &wheres(),
2994 2 : None,
2995 2 : Some(&page(20, 10)),
2996 2 : false,
2997 2 : "id",
2998 2 : &mut binds,
2999 2 : );
3000 2 : assert!(!paged);
3001 2 : assert!(sql.contains("LIMIT $2"), "{sql}");
3002 2 : assert!(!sql.contains("OFFSET"), "offset cannot be pushed: {sql}");
3003 2 : assert!(!sql.contains("count(*) OVER ()"), "{sql}");
3004 : // the candidate ceiling, not the page: the caller's evaluator still
3005 : // filters these rows, so a page-sized fetch would refuse legitimate
3006 : // queries whenever candidates outnumber matches
3007 2 : assert!(matches!(binds[1], Bind::Int(MAX_UNDECIDED_ROWS)));
3008 2 : }
3009 :
3010 : /// A caller with no page (idPattern, federation, orderBy) hands the store
3011 : /// no bound at all — the ceiling is then the only one.
3012 : #[test]
3013 2 : fn a_query_without_a_page_falls_back_to_the_ceiling() {
3014 2 : let mut binds = vec![Bind::Text("t".to_owned())];
3015 2 : let (sql, paged) = query_sql("entity", &wheres(), None, None, false, "id", &mut binds);
3016 2 : assert!(!paged);
3017 2 : assert!(sql.contains("LIMIT $2"), "{sql}");
3018 2 : assert!(matches!(binds[1], Bind::Int(MAX_UNDECIDED_ROWS)));
3019 : // an exactly decided query with no page is materialized whole too
3020 2 : let mut binds = vec![Bind::Text("t".to_owned())];
3021 2 : let (sql, _) = query_sql("entity", &wheres(), None, None, true, "id", &mut binds);
3022 2 : assert!(sql.contains("LIMIT $2"), "{sql}");
3023 2 : }
3024 :
3025 : /// 5.5.6 / Table 6.3.2-1: reaching the ceiling means the statement was
3026 : /// truncated, so the page built from it would silently under-report —
3027 : /// the operation is refused with TooManyResults (403) instead.
3028 : #[test]
3029 2 : fn a_ceiling_hit_is_too_many_results_not_a_short_page() {
3030 2 : assert!(check_ceiling(false, 30, 31).is_ok(), "under the ceiling");
3031 2 : let err = check_ceiling(false, 31, 31).expect_err("ceiling reached");
3032 2 : let ngsi = ngsi_error(&err).expect("the spec error travels in the driver error");
3033 2 : assert_eq!(ngsi.kind(), "TooManyResults");
3034 2 : assert_eq!(ngsi.status(), 403);
3035 : // a pushed page is exact by construction: a full page is a page, not
3036 : // a truncation
3037 2 : assert!(check_ceiling(true, 1000, 1000).is_ok());
3038 2 : }
3039 :
3040 : /// A real driver failure must stay a driver failure — only the store's
3041 : /// own spec errors are recovered, or every 500 would become a 403.
3042 : #[test]
3043 2 : fn a_driver_error_is_not_mistaken_for_a_spec_error() {
3044 2 : assert!(ngsi_error(&sqlx::Error::RowNotFound).is_none());
3045 2 : assert!(ngsi_error(&sqlx::Error::PoolTimedOut).is_none());
3046 2 : assert!(ngsi_error(&sqlx::Error::Configuration("boom".into())).is_none());
3047 2 : }
3048 : }
|