LCOV - code coverage report
Current view: top level - antares-sql/src/store/pg - entity.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 88.2 % 1058 933
Test Date: 2026-09-21 10:31:06 Functions: 55.7 % 280 156

            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 serde_json::Value;
      16              : use sqlx::postgres::PgPool;
      17              : use sqlx::Row;
      18              : 
      19              : pub struct PgEntityStore {
      20              :     pool: PgPool,
      21              :     /// When on, every entity write enqueues its change event into the
      22              :     /// outbox INSIDE the write transaction (a crash between commit and
      23              :     /// publish can never lose an event). Off by default: with `bus = local`
      24              :     /// events flow through the in-process hook and undrained rows would only
      25              :     /// grow the table. The broker turns this on when `bus = nats`.
      26              :     outbox: std::sync::atomic::AtomicBool,
      27              : }
      28              : 
      29              : // EntityFilter/Page/QueryOutcome live in `store::filter` (pure data,
      30              : // shared with the wasm32 build); re-exported here so existing paths hold.
      31              : pub use crate::store::filter::{EntityFilter, Page, QueryOutcome};
      32              : 
      33              : /// A bound value. Enumerated because the bind list is built dynamically while
      34              : /// the SQL is assembled — the alternative would be string interpolation.
      35              : enum Bind {
      36              :     Text(String),
      37              :     TextArr(Vec<String>),
      38              :     /// jsonpath; bound as text and cast with `$n::jsonpath` in the SQL
      39              :     Path(String),
      40              :     /// a distance in metres (geoquery `near`)
      41              :     Num(f64),
      42              :     /// LIMIT/OFFSET (pagination pushdown)
      43              :     Int(i64),
      44              : }
      45              : 
      46              : /// The internal doc's members that become extracted columns.
      47              : pub(crate) struct Extracted {
      48              :     types: Vec<String>,
      49              :     scopes: Option<Vec<String>>,
      50              :     created: String,
      51              :     modified: String,
      52              :     expires: Option<String>,
      53              :     /// The default GeoProperty as GeoJSON text, for `ST_GeomFromGeoJSON`.
      54              :     /// `None` (→ SQL NULL) whenever it cannot be represented as ONE geometry.
      55              :     location: Option<String>,
      56              :     /// True when the doc CARRIES the default GeoProperty but `location` could
      57              :     /// not be extracted (multi-instance, non-GeoJSON value). The compiled
      58              :     /// geoquery ORs on this column — index-shaped (BitmapOr), unlike the old
      59              :     /// `location IS NULL OR …` guard which forced a sequential scan — and
      60              :     /// rows without any geoproperty are excluded in SQL, which is exact.
      61              :     location_ambiguous: bool,
      62              : }
      63              : 
      64              : /// The four members every stored kind extracts the same way: `type` and
      65              : /// `scope` as arrays whether the doc spells them as one string or many, and
      66              : /// the two stamps. The temporal tables carry exactly these, entities carry
      67              : /// them plus expiry and location, so this is the one implementation of the
      68              : /// shared half — two copies is how one of them drifts.
      69              : ///
      70              : /// 4.6.3 allows a comma seconds-fraction in requests; the stamps feed
      71              : /// `::timestamptz` casts, which refuse it, so they are canonicalised here and
      72              : /// every extracted column holds the instant the client meant.
      73         3677 : pub(super) fn types_scopes_stamps(
      74         3677 :     doc: &Value,
      75         3677 : ) -> (Vec<String>, Option<Vec<String>>, String, String) {
      76         3713 :     let as_vec = |v: &Value| -> Vec<String> {
      77         3713 :         match v {
      78          179 :             Value::String(s) => vec![s.clone()],
      79         3532 :             Value::Array(a) => a
      80         3532 :                 .iter()
      81         3547 :                 .filter_map(|x| x.as_str().map(str::to_owned))
      82         3532 :                 .collect(),
      83            2 :             _ => vec![],
      84              :         }
      85         3713 :     };
      86         7354 :     let ts = |k: &str| {
      87         7354 :         doc.get(k)
      88         7354 :             .and_then(Value::as_str)
      89         7354 :             .map(|s| antares_store::filter::canonical_datetime(s).into_owned())
      90         7354 :     };
      91         5044 :     let now = || "1970-01-01T00:00:00Z".to_owned(); // caller always stamps; belt only
      92         3677 :     (
      93         3677 :         doc.get("type").map(&as_vec).unwrap_or_default(),
      94         3677 :         doc.get("scope").map(&as_vec),
      95         3677 :         ts("createdAt").unwrap_or_else(now),
      96         3677 :         ts("modifiedAt").unwrap_or_else(now),
      97         3677 :     )
      98         3677 : }
      99              : 
     100         3276 : fn extract(doc: &Value) -> Extracted {
     101         3276 :     let (types, scopes, created, modified) = types_scopes_stamps(doc);
     102         3276 :     let ts = |k: &str| {
     103         3276 :         doc.get(k)
     104         3276 :             .and_then(Value::as_str)
     105         3276 :             .map(|s| antares_store::filter::canonical_datetime(s).into_owned())
     106         3276 :     };
     107         3276 :     let location = crate::compile::geo::extract_location(doc);
     108         3276 :     let location_ambiguous =
     109         3276 :         location.is_none() && doc.get(crate::compile::geo::LOCATION_IRI).is_some();
     110         3276 :     Extracted {
     111         3276 :         types,
     112         3276 :         scopes,
     113         3276 :         created,
     114         3276 :         modified,
     115         3276 :         expires: ts("expiresAt"),
     116         3276 :         location,
     117         3276 :         location_ambiguous,
     118         3276 :     }
     119         3276 : }
     120              : 
     121              : /// The outbox row's event JSON: what the drain turns into a
     122              : /// `ChangeEvent`. Field names are the bus crate's serde names; `seq` and the
     123              : /// claim check are the drain's business.
     124              : #[allow(clippy::too_many_arguments)] // one param per outbox event field
     125           22 : fn change_event(
     126           22 :     tenant: &TenantId,
     127           22 :     op: &str,
     128           22 :     id: &str,
     129           22 :     types: &[String],
     130           22 :     prev: Option<&Value>,
     131           22 :     next: Option<&Value>,
     132           22 :     version: i64,
     133           22 :     incarnation: &str,
     134           22 : ) -> Value {
     135              :     // changed_attrs = top-level attribute IRIs that differ between the
     136              :     // before- and after-images (meta members excluded). Create lists every
     137              :     // attr, delete lists every prior attr.
     138           44 :     fn keys(v: Option<&Value>) -> Vec<&str> {
     139           44 :         v.and_then(Value::as_object)
     140           44 :             .map(|o| {
     141           22 :                 o.keys()
     142           22 :                     .map(String::as_str)
     143          120 :                     .filter(|k| !antares_model::is_meta(k))
     144           22 :                     .collect()
     145           22 :             })
     146           44 :             .unwrap_or_default()
     147           44 :     }
     148           22 :     let mut changed: Vec<&str> = Vec::new();
     149           34 :     for k in keys(prev).into_iter().chain(keys(next)) {
     150           34 :         if !changed.contains(&k) && prev.and_then(|p| p.get(k)) != next.and_then(|n| n.get(k)) {
     151           20 :             changed.push(k);
     152           20 :         }
     153              :     }
     154           22 :     serde_json::json!({
     155           22 :         "tenant": tenant.as_str(),
     156           22 :         "entity_id": id,
     157           22 :         "types": types,
     158           22 :         "op": op,
     159           22 :         "changed_attrs": changed,
     160           22 :         "payload": next,
     161           22 :         "prev_payload": prev,
     162           22 :         "version": version,
     163           22 :         "incarnation": incarnation,
     164              :     })
     165           22 : }
     166              : 
     167              : #[allow(clippy::too_many_arguments)] // one param per outbox event field
     168            4 : async fn enqueue_change(
     169            4 :     tx: &mut sqlx::postgres::PgConnection,
     170            4 :     tenant: &TenantId,
     171            4 :     op: &str,
     172            4 :     id: &str,
     173            4 :     types: &[String],
     174            4 :     prev: Option<&Value>,
     175            4 :     next: Option<&Value>,
     176            4 :     version: i64,
     177            4 :     incarnation: &str,
     178            4 : ) -> Result<(), sqlx::Error> {
     179            4 :     let ev = change_event(tenant, op, id, types, prev, next, version, incarnation);
     180            4 :     super::outbox::enqueue(tx, tenant, &ev).await.map(|_| ())
     181            4 : }
     182              : 
     183              : /// Rows one statement may materialize when SQL narrowed the match set but
     184              : /// did not decide it. 5.5.6 leaves the threshold to the implementation;
     185              : /// this is it for the entity query path, and it is the only bound a request
     186              : /// that carries no page (idPattern, federation, orderBy) has at all.
     187              : pub const MAX_UNDECIDED_ROWS: i64 = 10_000;
     188              : 
     189              : /// The statement, split out of `query` so its pagination shape is
     190              : /// assertable without a database. Either LIMIT/OFFSET over an exactly
     191              : /// decided set (`true`, `count(*) OVER ()` rides along for the pre-LIMIT
     192              : /// total) or the flat `MAX_UNDECIDED_ROWS` safety LIMIT — never no LIMIT.
     193          282 : fn query_sql(
     194          282 :     select: &str,
     195          282 :     wheres: &[String],
     196          282 :     page: Option<&Page>,
     197          282 :     decided: bool,
     198          282 :     binds: &mut Vec<Bind>,
     199          282 : ) -> (String, bool) {
     200          282 :     let wheres = wheres.join(" AND ");
     201          282 :     match page.filter(|_| decided) {
     202              :         // count=true: the window total rides the statement. Otherwise one
     203              :         // row past the page answers "is there a next page" — the window
     204              :         // count made Postgres visit the whole match set before LIMIT
     205              :         // (2.9 s p99 for 20 rows out of 10 000).
     206          135 :         Some(p) => {
     207          135 :             let total = if p.count {
     208           17 :                 ", count(*) OVER () AS total"
     209              :             } else {
     210          118 :                 ""
     211              :             };
     212              :             // Saturating: the caller's limit is only bounded by `i64::MAX`
     213              :             // (`ANTARES_DISCOVERY_SCAN_MAX` clamps to exactly that), and one
     214              :             // past the largest page is still the largest page.
     215          135 :             binds.push(Bind::Int(if p.count {
     216           17 :                 p.limit
     217              :             } else {
     218          118 :                 p.limit.saturating_add(1)
     219              :             }));
     220          135 :             let lim = binds.len();
     221          135 :             binds.push(Bind::Int(p.offset));
     222          135 :             (
     223          135 :                 format!(
     224          135 :                     "SELECT {select} AS entity{total} \
     225          135 :                      FROM entities WHERE {wheres} ORDER BY id LIMIT ${lim} OFFSET ${}",
     226          135 :                     binds.len()
     227          135 :                 ),
     228          135 :                 true,
     229          135 :             )
     230              :         }
     231              :         None => {
     232          147 :             binds.push(Bind::Int(MAX_UNDECIDED_ROWS));
     233          147 :             (
     234          147 :                 format!(
     235          147 :                     "SELECT {select} AS entity FROM entities WHERE {wheres} \
     236          147 :                      ORDER BY id LIMIT ${}",
     237          147 :                     binds.len()
     238          147 :                 ),
     239          147 :                 false,
     240          147 :             )
     241              :         }
     242              :     }
     243          282 : }
     244              : 
     245              : /// Did the safety LIMIT actually cut the match set? A statement that came
     246              : /// back full was truncated at a bound nobody chose, and the caller's
     247              : /// evaluator still has rows to drop, so no complete page can be built from
     248              : /// it. 5.5.6: "When a query operation is producing so many results that can
     249              : /// potentially exhaust client or server resources … implementations shall
     250              : /// raise an error of type TooManyResults." Serving the truncated prefix
     251              : /// would under-report the answer instead.
     252         6624 : pub(crate) fn check_ceiling(paged: bool, rows: usize, ceiling: i64) -> Result<(), sqlx::Error> {
     253         6624 :     if paged || (rows as i64) < ceiling {
     254         6620 :         return Ok(());
     255            4 :     }
     256            4 :     Err(sqlx::Error::Configuration(Box::new(
     257            4 :         NgsiError::TooManyResults(format!(
     258            4 :             "query matched more than {ceiling} entities before filtering; \
     259            4 :              narrow it or request a smaller offset"
     260            4 :         )),
     261            4 :     )))
     262         6624 : }
     263              : 
     264              : /// Recover a spec error the store raised itself from the driver error it
     265              : /// travels in (the store's only error channel is `sqlx::Error`). `None` =
     266              : /// a genuine driver failure, which stays an InternalError at the seam.
     267           10 : pub fn ngsi_error(e: &sqlx::Error) -> Option<&NgsiError> {
     268           10 :     match e {
     269            6 :         sqlx::Error::Configuration(b) => b.downcast_ref::<NgsiError>(),
     270            4 :         _ => None,
     271              :     }
     272           10 : }
     273              : 
     274              : impl PgEntityStore {
     275           60 :     pub fn new(pool: PgPool) -> Self {
     276           60 :         Self {
     277           60 :             pool,
     278           60 :             outbox: std::sync::atomic::AtomicBool::new(false),
     279           60 :         }
     280           60 :     }
     281              : 
     282              :     /// Outbox producer switch — the broker enables this exactly when `bus=nats`.
     283            2 :     pub fn set_outbox(&self, on: bool) {
     284            2 :         self.outbox.store(on, std::sync::atomic::Ordering::Relaxed);
     285            2 :     }
     286              : 
     287         4852 :     fn outbox_on(&self) -> bool {
     288         4852 :         self.outbox.load(std::sync::atomic::Ordering::Relaxed)
     289         4852 :     }
     290              : 
     291              :     /// 5.6.1-shaped create: `false` when the id already exists (→ 409).
     292              :     ///
     293              :     /// 4.22: "expiresAt is defined as the system temporal Property at which a
     294              :     /// certain Entity, Property or Relationship shall become invalid" — an
     295              :     /// entity whose expiry has passed is already invalid, whatever the
     296              :     /// reaping lag, so it must not 409 a create that reads (and GETs) as
     297              :     /// absent. The conflict clause replaces such a row and reports CREATED;
     298              :     /// a live row still takes the DO NOTHING path and `rows_affected() == 0`.
     299         3128 :     pub async fn create(
     300         3128 :         &self,
     301         3128 :         tenant: &TenantId,
     302         3128 :         id: &str,
     303         3128 :         doc: &Value,
     304         3128 :     ) -> Result<bool, sqlx::Error> {
     305         3128 :         let e = extract(doc);
     306         3128 :         let mut tx = super::begin(&self.pool).await?;
     307         3128 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     308         3128 :         crate::store::pg::claim_tenant(&mut tx, tenant).await?;
     309         3128 :         let done = sqlx::query(
     310         3128 :             "INSERT INTO entities
     311         3128 :                (tenant_id, id, entity, types, scopes, created_at, modified_at, expires_at,
     312         3128 :                 location, location_ambiguous)
     313         3128 :              VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8::timestamptz,
     314         3128 :                      CASE WHEN ST_IsValid(try_geomfromgeojson($9))
     315         3128 :                           THEN try_geomfromgeojson($9) END,
     316         3128 :                      $10 OR ($9 IS NOT NULL
     317         3128 :                              AND NOT COALESCE(ST_IsValid(try_geomfromgeojson($9)), false)))
     318         3128 :              ON CONFLICT (tenant_id, id) DO UPDATE SET
     319         3128 :                entity = EXCLUDED.entity, types = EXCLUDED.types,
     320         3128 :                scopes = EXCLUDED.scopes, created_at = EXCLUDED.created_at,
     321         3128 :                modified_at = EXCLUDED.modified_at, expires_at = EXCLUDED.expires_at,
     322         3128 :                location = EXCLUDED.location,
     323         3128 :                location_ambiguous = EXCLUDED.location_ambiguous,
     324         3128 :                version = 1
     325         3128 :              WHERE entities.expires_at IS NOT NULL AND entities.expires_at <= now()",
     326         3128 :         )
     327         3128 :         .bind(tenant.as_str())
     328         3128 :         .bind(id)
     329         3128 :         .bind(doc)
     330         3128 :         .bind(&e.types)
     331         3128 :         .bind(&e.scopes)
     332         3128 :         .bind(&e.created)
     333         3128 :         .bind(&e.modified)
     334         3128 :         .bind(&e.expires)
     335         3128 :         .bind(&e.location)
     336         3128 :         .bind(e.location_ambiguous)
     337         3128 :         .execute(&mut *tx)
     338         3128 :         .await?
     339         3128 :         .rows_affected();
     340         3128 :         if done == 1 && self.outbox_on() {
     341            4 :             enqueue_change(
     342            4 :                 &mut tx,
     343            4 :                 tenant,
     344            4 :                 "create",
     345            4 :                 id,
     346            4 :                 &e.types,
     347            4 :                 None,
     348            4 :                 Some(doc),
     349            4 :                 1,
     350            4 :                 &e.created,
     351            4 :             )
     352            4 :             .await?;
     353         3124 :         }
     354         3128 :         tx.commit().await?;
     355         3128 :         Ok(done == 1)
     356         3128 :     }
     357              : 
     358          147 :     pub async fn get(&self, tenant: &TenantId, id: &str) -> Result<Option<Value>, sqlx::Error> {
     359          147 :         let mut tx = super::begin(&self.pool).await?;
     360          147 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     361              :         // 4.22: expired rows are invalid context until the sweep reaps them
     362          147 :         let row = sqlx::query(
     363          147 :             "SELECT entity FROM entities WHERE tenant_id = $1 AND id = $2
     364          147 :                AND (expires_at IS NULL OR expires_at > now())",
     365          147 :         )
     366          147 :         .bind(tenant.as_str())
     367          147 :         .bind(id)
     368          147 :         .fetch_optional(&mut *tx)
     369          147 :         .await?;
     370          147 :         tx.commit().await?;
     371          147 :         Ok(row.map(|r| r.get::<Value, _>(0)))
     372          147 :     }
     373              : 
     374              :     /// Returns the deleted document (the before-image, captured in the same
     375              :     /// transaction as the DELETE — never re-read outside it), `None` = absent.
     376         1377 :     pub async fn delete(&self, tenant: &TenantId, id: &str) -> Result<Option<Value>, sqlx::Error> {
     377         1377 :         let mut tx = super::begin(&self.pool).await?;
     378         1377 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     379              :         // 4.22: an already-expired row is invalid, so deleting it is a
     380              :         // 404 exactly as retrieving it is — never a 204 for an entity the
     381              :         // API has stopped serving.
     382         1377 :         let row = sqlx::query(
     383         1377 :             "DELETE FROM entities WHERE tenant_id = $1 AND id = $2
     384         1377 :                AND (expires_at IS NULL OR expires_at > now())
     385         1377 :              RETURNING entity, types, version, created_at::text",
     386         1377 :         )
     387         1377 :         .bind(tenant.as_str())
     388         1377 :         .bind(id)
     389         1377 :         .fetch_optional(&mut *tx)
     390         1377 :         .await?;
     391         1377 :         let mut prev_out = None;
     392         1377 :         if let Some(r) = &row {
     393         1284 :             let prev: Value = r.get(0);
     394         1284 :             if self.outbox_on() {
     395            0 :                 let types: Vec<String> = r.get(1);
     396            0 :                 enqueue_change(
     397            0 :                     &mut tx,
     398            0 :                     tenant,
     399            0 :                     "delete",
     400            0 :                     id,
     401            0 :                     &types,
     402            0 :                     Some(&prev),
     403            0 :                     None,
     404            0 :                     r.get::<i64, _>(2),
     405            0 :                     r.get::<&str, _>(3),
     406            0 :                 )
     407            0 :                 .await?;
     408         1284 :             }
     409         1284 :             prev_out = Some(prev);
     410           93 :         }
     411         1377 :         tx.commit().await?;
     412         1377 :         Ok(prev_out)
     413         1377 :     }
     414              : 
     415              :     /// Delete one entity only if `keep` accepts the stored document, under
     416              :     /// one lock: the DELETE itself takes the row lock and produces the
     417              :     /// document that decides, and a refusal rolls the transaction back
     418              :     /// instead of committing it. A `SELECT` followed by a `DELETE` would
     419              :     /// leave the window this exists to close — between them the row can be
     420              :     /// deleted and recreated under the same id, and the delete then lands on
     421              :     /// a document nobody inspected.
     422              :     ///
     423              :     /// 4.22 as in `delete`: an already-expired row is invalid, so it is
     424              :     /// absent here too.
     425         1088 :     pub async fn delete_if(
     426         1088 :         &self,
     427         1088 :         tenant: &TenantId,
     428         1088 :         id: &str,
     429         1088 :         keep: &(dyn for<'v> Fn(&'v Value) -> bool + Sync),
     430         1088 :     ) -> Result<Option<Value>, sqlx::Error> {
     431         1088 :         let mut tx = super::begin(&self.pool).await?;
     432         1088 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     433         1088 :         let row = sqlx::query(
     434         1088 :             "DELETE FROM entities WHERE tenant_id = $1 AND id = $2
     435         1088 :                AND (expires_at IS NULL OR expires_at > now())
     436         1088 :              RETURNING entity, types, version, created_at::text",
     437         1088 :         )
     438         1088 :         .bind(tenant.as_str())
     439         1088 :         .bind(id)
     440         1088 :         .fetch_optional(&mut *tx)
     441         1088 :         .await?;
     442         1088 :         let Some(r) = &row else {
     443          870 :             tx.commit().await?;
     444          870 :             return Ok(None);
     445              :         };
     446          218 :         let prev: Value = r.get(0);
     447          218 :         if !keep(&prev) {
     448            4 :             tx.rollback().await?;
     449            4 :             return Ok(None);
     450          214 :         }
     451          214 :         if self.outbox_on() {
     452            0 :             let types: Vec<String> = r.get(1);
     453            0 :             enqueue_change(
     454            0 :                 &mut tx,
     455            0 :                 tenant,
     456            0 :                 "delete",
     457            0 :                 id,
     458            0 :                 &types,
     459            0 :                 Some(&prev),
     460            0 :                 None,
     461            0 :                 r.get::<i64, _>(2),
     462            0 :                 r.get::<&str, _>(3),
     463            0 :             )
     464            0 :             .await?;
     465          214 :         }
     466          214 :         tx.commit().await?;
     467          214 :         Ok(Some(prev))
     468         1088 :     }
     469              : 
     470              :     /// Query pushdown. The predicates that compile EXACTLY go to
     471              :     /// Postgres; everything else is simply left out of the WHERE clause, so
     472              :     /// the result is always a superset of the answer and the caller's
     473              :     /// in-memory evaluator remains the arbiter. That is the property that
     474              :     /// makes store modes agree: SQL removes rows, it never decides them.
     475              :     ///
     476              :     /// Every value here is a bind. The only text this function
     477              :     /// concatenates is its own operators and `$n` placeholders.
     478              :     ///
     479              :     /// Every statement it builds is bounded: an exactly decided query by the
     480              :     /// caller's page, everything else by `MAX_UNDECIDED_ROWS`. A set that
     481              :     /// reaches that ceiling is refused with TooManyResults (5.5.6) rather
     482              :     /// than returned as a prefix the caller would page over as if complete.
     483          270 :     pub async fn query(
     484          270 :         &self,
     485          270 :         tenant: &TenantId,
     486          270 :         f: &EntityFilter<'_>,
     487          270 :     ) -> Result<QueryOutcome, sqlx::Error> {
     488          270 :         let mut binds: Vec<Bind> = vec![Bind::Text(tenant.as_str().to_owned())];
     489          270 :         let mut wheres = vec![
     490          270 :             "tenant_id = $1".to_owned(),
     491              :             // 4.22: expired entities never enter a result (or a page/total)
     492          270 :             "(expires_at IS NULL OR expires_at > now())".to_owned(),
     493              :         ];
     494              :         // Exactness: ids/types/attrs translate exactly by
     495              :         // construction; q is exact IF it compiles (the compiler's contract);
     496              :         // scopeQ is documented loose-or-equal, geo has a metric residual
     497              :         // (`near` geography vs haversine) — both therefore forfeit
     498              :         // `decided`, they only narrow.
     499          270 :         let mut decided = true;
     500              : 
     501          270 :         if let Some(ids) = f.ids {
     502           81 :             binds.push(Bind::TextArr(ids.iter().map(|s| s.to_string()).collect()));
     503           31 :             wheres.push(format!("id = ANY(${})", binds.len()));
     504          239 :         }
     505              :         // 5.2.33 idPattern: the literal the regex forces is a plain string
     506              :         // test on the id column, exact as a narrowing; the regex itself stays
     507              :         // with the caller, so `decided` is untouched.
     508          270 :         if let Some(l) = f.id_literal {
     509           18 :             binds.push(Bind::Text(l.text.to_owned()));
     510           18 :             wheres.push(if l.anchored {
     511            4 :                 format!("starts_with(id, ${})", binds.len())
     512              :             } else {
     513           14 :                 format!("position(${} in id) > 0", binds.len())
     514              :             });
     515          252 :         }
     516              :         // OR of AND-groups, mirroring the Entity Type Selection Language
     517              :         // (4.17) the caller already parsed: `types @> ARRAY[…]` per group.
     518          270 :         if let Some(groups) = f.types {
     519          149 :             let mut ors = Vec::with_capacity(groups.len());
     520          149 :             for g in groups {
     521          149 :                 binds.push(Bind::TextArr(g.clone()));
     522          149 :                 ors.push(format!("types @> ${}", binds.len()));
     523          149 :             }
     524          149 :             if !ors.is_empty() {
     525          149 :                 wheres.push(format!("({})", ors.join(" OR ")));
     526          149 :             }
     527          121 :         }
     528              :         // `attrs`: the entity carries at least one of them — jsonb `?|`,
     529              :         // exactly the evaluator's `any(|a| doc.get(a).is_some())`.
     530          270 :         if let Some(attrs) = f.attrs {
     531            1 :             binds.push(Bind::TextArr(attrs.to_vec()));
     532            1 :             wheres.push(format!("entity ?| ${}", binds.len()));
     533          269 :         }
     534          270 :         if let Some(node) = f.q {
     535           65 :             match crate::compile::q::compile_q(node, "entity", binds.len() + 1, f.expand) {
     536           51 :                 Some(c) => {
     537           51 :                     wheres.push(c.sql);
     538           51 :                     binds.extend(c.binds.into_iter().map(Bind::Path));
     539           51 :                 }
     540           14 :                 None => decided = false,
     541              :             }
     542          205 :         }
     543          270 :         if let Some(sq) = f.scope_q {
     544           16 :             decided = false;
     545           16 :             if let Some(c) = crate::compile::scope::compile_scope_q(sq, "scopes", binds.len() + 1) {
     546           14 :                 wheres.push(c.sql);
     547           14 :                 binds.extend(c.binds.into_iter().map(Bind::Text));
     548           14 :             }
     549          254 :         }
     550          270 :         if let Some(spec) = f.geo {
     551           16 :             decided = false;
     552              :             // A client may send a self-intersecting polygon. GEOS raises on
     553              :             // one (`side location conflict`), which would turn a query into a
     554              :             // 500 in `postgres` mode while `memory` mode answers happily from
     555              :             // the evaluator. Probing validity once here keeps the two modes
     556              :             // identical: invalid ⇒ no pushdown, evaluator decides. Stored
     557              :             // geometries can't be invalid — the write path NULLs those.
     558           16 :             if self.geometry_is_valid(spec).await {
     559           16 :                 if let Some(c) = crate::compile::geo::compile_geo(spec, "location", binds.len() + 1)
     560           16 :                 {
     561           16 :                     wheres.push(c.sql);
     562           16 :                     // geo binds first, then the numeric ones — the order
     563           16 :                     // `compile_geo` numbered its placeholders in.
     564           16 :                     binds.extend(c.geo_binds.into_iter().map(Bind::Text));
     565           16 :                     binds.extend(c.num_binds.into_iter().map(Bind::Num));
     566           16 :                 }
     567            0 :             }
     568          254 :         }
     569              : 
     570              :         // every bind up to here belongs to the WHERE clause — the count-only
     571              :         // fallback statement below reuses exactly this prefix
     572          270 :         let where_binds = binds.len();
     573              : 
     574              :         // Projection pushdown, only once SQL decides row membership: the
     575              :         // kept doc must only need to feed `repr::apply`, never a re-check.
     576              :         // `pick` keeps listed attrs + every non-attribute member (attribute
     577              :         // keys are expanded IRIs — `http…` — so core members never match the
     578              :         // LIKE and always survive; a non-http attr IRI merely stays
     579              :         // unprojected, which is the safe direction). `omit` drops exactly the
     580              :         // listed top-level IRIs.
     581          270 :         let mut select = "entity".to_owned();
     582          270 :         if decided {
     583          224 :             if let Some(keep) = f.keep_attrs {
     584            2 :                 binds.push(Bind::TextArr(keep.to_vec()));
     585            2 :                 select = format!(
     586            2 :                     "(SELECT COALESCE(jsonb_object_agg(t.k, t.v), '{{}}'::jsonb) \
     587            2 :                       FROM jsonb_each(entity) AS t(k, v) \
     588            2 :                       WHERE t.k NOT LIKE 'http%' OR t.k = ANY(${}))",
     589            2 :                     binds.len()
     590            2 :                 );
     591          222 :             } else if let Some(drop) = f.drop_attrs {
     592            0 :                 binds.push(Bind::TextArr(drop.to_vec()));
     593            0 :                 select = format!(
     594            0 :                     "(SELECT COALESCE(jsonb_object_agg(t.k, t.v), '{{}}'::jsonb) \
     595            0 :                       FROM jsonb_each(entity) AS t(k, v) \
     596            0 :                       WHERE NOT (t.k = ANY(${})))",
     597            0 :                     binds.len()
     598            0 :                 );
     599          222 :             }
     600           46 :         }
     601              : 
     602              :         // Pagination pushdown: ORDER BY id is the store's default order
     603              :         // either way; `count(*) OVER ()` rides the same statement so the
     604              :         // caller gets the pre-LIMIT total for count= and the next/prev
     605              :         // links. When the page cannot be pushed the statement still carries
     606              :         // the safety LIMIT — without one, a single undecided request (any
     607              :         // `scopeQ`, any `georel`, a `q=` shape the compiler declined)
     608              :         // materializes the whole tenant into the Vec below.
     609          270 :         let ceiling = MAX_UNDECIDED_ROWS;
     610          270 :         let (sql, paged) = query_sql(&select, &wheres, f.page.as_ref(), decided, &mut binds);
     611          270 :         let mut tx = super::begin(&self.pool).await?;
     612          270 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     613              :         // sqlx 0.9 makes dynamic SQL opt-in. The assertion holds by
     614              :         // construction: `sql` is built from this function's own literals
     615              :         // plus `$n` placeholders — no caller-supplied text reaches it.
     616              :         // The audit lives here, next to the builder.
     617          270 :         let mut qy = sqlx::query(sqlx::AssertSqlSafe(sql.clone()));
     618         1450 :         for b in &binds {
     619         1450 :             qy = match b {
     620          862 :                 Bind::Text(s) | Bind::Path(s) => qy.bind(s),
     621          183 :                 Bind::TextArr(v) => qy.bind(v),
     622            6 :                 Bind::Num(n) => qy.bind(n),
     623          399 :                 Bind::Int(n) => qy.bind(n),
     624              :             };
     625              :         }
     626              :         // Stream rows, decode each to its Value and
     627              :         // drop the PgRow — the full row set never sits in memory twice.
     628          270 :         let mut docs: Vec<Value> = Vec::new();
     629          270 :         let mut total: Option<i64> = None;
     630          270 :         let counted = paged && f.page.as_ref().is_some_and(|p| p.count);
     631              :         {
     632              :             use futures_util::TryStreamExt;
     633          270 :             let mut stream = qy.fetch(&mut *tx);
     634        13328 :             while let Some(row) = stream.try_next().await? {
     635        13058 :                 if counted && docs.is_empty() {
     636           13 :                     total = Some(row.get::<i64, _>(1));
     637        13045 :                 }
     638        13058 :                 docs.push(row.get::<Value, _>(0));
     639              :             }
     640              :         }
     641          270 :         check_ceiling(paged, docs.len(), ceiling)?;
     642              :         // uncounted page: the extra row says a next page exists; the
     643              :         // total handed back is the smallest one consistent with that,
     644              :         // enough for the next/prev links and never shown as a count
     645          269 :         if let Some(p) = f.page.as_ref().filter(|_| paged && !counted) {
     646          114 :             let more = docs.len() as i64 > p.limit;
     647          114 :             docs.truncate(p.limit as usize);
     648          114 :             total = Some(p.offset + docs.len() as i64 + i64::from(more));
     649          155 :         }
     650              :         // an off-the-end counted page returns zero rows and no window
     651              :         // total — count the match set separately so links/count stay correct
     652          269 :         if counted && total.is_none() {
     653            2 :             let count_sql = format!(
     654              :                 "SELECT count(*) FROM entities WHERE {}",
     655            2 :                 wheres.join(" AND ")
     656              :             );
     657            2 :             let mut cq = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(count_sql));
     658              :             // same wheres ⇒ same bind prefix; stop before the
     659              :             // projection/page binds, which the count statement lacks
     660            4 :             for b in binds.iter().take(where_binds) {
     661            4 :                 cq = match b {
     662            2 :                     Bind::Text(s) | Bind::Path(s) => cq.bind(s),
     663            2 :                     Bind::TextArr(v) => cq.bind(v),
     664            0 :                     Bind::Num(n) => cq.bind(n),
     665            0 :                     Bind::Int(n) => cq.bind(n),
     666              :                 };
     667              :             }
     668            2 :             total = Some(cq.fetch_one(&mut *tx).await?);
     669          267 :         }
     670          269 :         tx.commit().await?;
     671          269 :         Ok(QueryOutcome {
     672          269 :             rows: docs,
     673          269 :             decided,
     674          269 :             paged,
     675          269 :             total,
     676          269 :         })
     677          270 :     }
     678              : 
     679              :     /// One cheap probe: is the client's query geometry OGC-valid? Unparseable
     680              :     /// GeoJSON counts as invalid — same outcome, no pushdown — and reaches
     681              :     /// that answer through `try_geomfromgeojson` (0001_init.sql), so the
     682              :     /// probe itself can never raise.
     683           16 :     async fn geometry_is_valid(&self, spec: &crate::compile::geo::GeoSpec<'_>) -> bool {
     684           16 :         let geojson = serde_json::to_string(&serde_json::json!({
     685           16 :             "type": spec.geometry, "coordinates": spec.coordinates
     686           16 :         }))
     687           16 :         .unwrap_or_default();
     688           16 :         sqlx::query_scalar::<_, bool>("SELECT COALESCE(ST_IsValid(try_geomfromgeojson($1)), false)")
     689           16 :             .bind(&geojson)
     690           16 :             .fetch_one(&self.pool)
     691           16 :             .await
     692           16 :             .unwrap_or(false)
     693           16 :     }
     694              : 
     695              :     /// Id-ordered snapshot for one tenant (the v0 `list` shape — still the
     696              :     /// path for every non-entity kind and for callers with no filter).
     697              :     ///
     698              :     /// Bounded like every other read: the statement carries the same
     699              :     /// `MAX_UNDECIDED_ROWS` safety LIMIT, and a tenant that reaches it is
     700              :     /// refused with TooManyResults (5.5.6) rather than served a silent
     701              :     /// prefix.
     702           20 :     pub async fn list(&self, tenant: &TenantId) -> Result<Vec<Value>, sqlx::Error> {
     703           20 :         let mut tx = super::begin(&self.pool).await?;
     704           20 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     705           20 :         let mut docs: Vec<Value> = Vec::new();
     706              :         {
     707              :             use futures_util::TryStreamExt;
     708           20 :             let mut stream = sqlx::query(
     709              :                 "SELECT entity FROM entities WHERE tenant_id = $1
     710              :                        AND (expires_at IS NULL OR expires_at > now())
     711              :                  ORDER BY id LIMIT $2",
     712              :             )
     713           20 :             .bind(tenant.as_str())
     714           20 :             .bind(MAX_UNDECIDED_ROWS)
     715           20 :             .fetch(&mut *tx);
     716        11261 :             while let Some(row) = stream.try_next().await? {
     717        11241 :                 docs.push(row.get::<Value, _>(0));
     718        11241 :             }
     719              :         }
     720           20 :         tx.commit().await?;
     721           20 :         check_ceiling(false, docs.len(), MAX_UNDECIDED_ROWS)?;
     722           19 :         Ok(docs)
     723           20 :     }
     724              : 
     725              :     /// One id-ordered page of entities: ids strictly greater than `after`,
     726              :     /// at most `limit`.
     727              :     ///
     728              :     /// No ceiling, and no fold of the tenant to build the page.
     729              :     /// `MAX_UNDECIDED_ROWS` on `list` exists so a large tenant cannot be
     730              :     /// materialized into one `Vec`; a page bounds that by construction, so
     731              :     /// refusing here would only break the readers that must see every row
     732              :     /// and have no TooManyResults to raise — 5.9.2.4's registration-vs-entity
     733              :     /// conflict check among them. Keyset over the primary key, not OFFSET:
     734              :     /// the walk runs against a table being written to. `after = None` is
     735              :     /// bound as the empty string, which is below every id because an
     736              :     /// Entity id is a URI and a URI is never empty.
     737              :     ///
     738              :     /// 4.22 at the entity level is in the statement, so a full page is a
     739              :     /// full page: the caller reads a short page as the end of the tenant,
     740              :     /// and a filter applied after the LIMIT would end the walk early.
     741           12 :     pub async fn list_page(
     742           12 :         &self,
     743           12 :         tenant: &TenantId,
     744           12 :         after: Option<&str>,
     745           12 :         limit: i64,
     746           12 :     ) -> Result<Vec<Value>, sqlx::Error> {
     747           12 :         let mut tx = super::begin(&self.pool).await?;
     748           12 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     749           12 :         let rows = sqlx::query(
     750           12 :             "SELECT entity FROM entities WHERE tenant_id = $1 AND id > $2
     751           12 :                    AND (expires_at IS NULL OR expires_at > now())
     752           12 :              ORDER BY id LIMIT $3",
     753           12 :         )
     754           12 :         .bind(tenant.as_str())
     755           12 :         .bind(after.unwrap_or(""))
     756           12 :         .bind(limit)
     757           12 :         .fetch_all(&mut *tx)
     758           12 :         .await?;
     759           12 :         tx.commit().await?;
     760         2019 :         Ok(rows.into_iter().map(|r| r.get::<Value, _>(0)).collect())
     761           12 :     }
     762              : 
     763              :     /// Read-modify-write: row lock via `SELECT … FOR UPDATE`, closure
     764              :     /// applied in Rust, `version` bumped under the lock. Two racing PATCHes
     765              :     /// serialize in Postgres, neither is lost. `Ok(None)` = entity absent.
     766         2545 :     pub async fn mutate<T, E>(
     767         2545 :         &self,
     768         2545 :         tenant: &TenantId,
     769         2545 :         id: &str,
     770         2545 :         f: impl FnOnce(&mut Value) -> Result<T, E>,
     771         2545 :     ) -> Result<Option<Result<T, E>>, sqlx::Error> {
     772         2545 :         let mut tx = super::begin(&self.pool).await?;
     773         2545 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     774              :         // 4.22: an expired row is invalid, so a patch of it is a 404 —
     775              :         // the same answer the retrieve it would follow already gives.
     776         2545 :         let row = sqlx::query(
     777         2545 :             "SELECT entity FROM entities WHERE tenant_id = $1 AND id = $2
     778         2545 :                AND (expires_at IS NULL OR expires_at > now()) FOR UPDATE",
     779         2545 :         )
     780         2545 :         .bind(tenant.as_str())
     781         2545 :         .bind(id)
     782         2545 :         .fetch_optional(&mut *tx)
     783         2545 :         .await?;
     784         2545 :         let Some(row) = row else {
     785         2425 :             tx.commit().await?;
     786         2425 :             return Ok(None);
     787              :         };
     788          120 :         let mut doc: Value = row.get(0);
     789          120 :         let before = self.outbox_on().then(|| doc.clone());
     790          120 :         match f(&mut doc) {
     791          109 :             Ok(t) => {
     792          109 :                 let e = extract(&doc);
     793          109 :                 let updated = sqlx::query(
     794          109 :                     "UPDATE entities SET entity = $3, types = $4, scopes = $5,
     795          109 :                        modified_at = $6::timestamptz, expires_at = $7::timestamptz,
     796          109 :                        location = CASE WHEN ST_IsValid(try_geomfromgeojson($8))
     797          109 :                                        THEN try_geomfromgeojson($8) END,
     798          109 :                        location_ambiguous = $9 OR ($8 IS NOT NULL
     799          109 :                            AND NOT COALESCE(ST_IsValid(try_geomfromgeojson($8)), false)),
     800          109 :                        version = version + 1
     801          109 :                      WHERE tenant_id = $1 AND id = $2
     802          109 :                      RETURNING version, created_at::text",
     803          109 :                 )
     804          109 :                 .bind(tenant.as_str())
     805          109 :                 .bind(id)
     806          109 :                 .bind(&doc)
     807          109 :                 .bind(&e.types)
     808          109 :                 .bind(&e.scopes)
     809          109 :                 .bind(&e.modified)
     810          109 :                 .bind(&e.expires)
     811          109 :                 .bind(&e.location)
     812          109 :                 .bind(e.location_ambiguous)
     813          109 :                 .fetch_one(&mut *tx)
     814          109 :                 .await?;
     815          109 :                 if let Some(before) = &before {
     816            0 :                     enqueue_change(
     817            0 :                         &mut tx,
     818            0 :                         tenant,
     819            0 :                         "update",
     820            0 :                         id,
     821            0 :                         &e.types,
     822            0 :                         Some(before),
     823            0 :                         Some(&doc),
     824            0 :                         updated.get::<i64, _>(0),
     825            0 :                         updated.get::<&str, _>(1),
     826            0 :                     )
     827            0 :                     .await?;
     828          109 :                 }
     829          109 :                 tx.commit().await?;
     830          109 :                 Ok(Some(Ok(t)))
     831              :             }
     832           11 :             Err(e) => {
     833           11 :                 tx.rollback().await?;
     834           11 :                 Ok(Some(Err(e)))
     835              :             }
     836              :         }
     837         2545 :     }
     838              : 
     839              :     /// Batch create: ONE multi-row INSERT for the whole batch (the jsonb
     840              :     /// elements form of UNNEST), one transaction, one commit.
     841              :     /// Returns a created-flag per input item, input order preserved.
     842              :     /// Duplicate ids within one batch are pre-deduped here: `ON CONFLICT`
     843              :     /// raises "cannot affect row a second time" otherwise — the later
     844              :     /// duplicate reports `false` (5.5.11.1: the first instance wins).
     845              :     ///
     846              :     /// 5.6.7.4: "For each of the NGSI-LD Entities included in the input
     847              :     /// Array execute the behaviour defined by clause 5.6.1, but limited to a
     848              :     /// local operation" — so the conflict clause is `create`'s, and an
     849              :     /// entity past its `expiresAt` is absent (4.22) and gets created over
     850              :     /// rather than reported as an id that already exists.
     851            7 :     pub async fn batch_create(
     852            7 :         &self,
     853            7 :         tenant: &TenantId,
     854            7 :         items: &[(String, Value)],
     855            7 :     ) -> Result<Vec<bool>, sqlx::Error> {
     856            7 :         let mut seen = std::collections::HashSet::new();
     857            7 :         let mut payload = Vec::new();
     858            9 :         for (id, doc) in items {
     859            9 :             if seen.insert(id.as_str()) {
     860            8 :                 let e = extract(doc);
     861            8 :                 payload.push(serde_json::json!({
     862            8 :                     "id": id, "doc": doc, "types": e.types, "scopes": e.scopes,
     863            8 :                     "created": e.created, "modified": e.modified, "expires": e.expires,
     864            8 :                     "location": e.location, "loc_ambiguous": e.location_ambiguous,
     865            8 :                 }));
     866            8 :             }
     867              :         }
     868            7 :         let mut tx = super::begin(&self.pool).await?;
     869            7 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     870            7 :         crate::store::pg::claim_tenant(&mut tx, tenant).await?;
     871            7 :         let rows = sqlx::query(
     872            7 :             "INSERT INTO entities
     873            7 :                (tenant_id, id, entity, types, scopes, created_at, modified_at, expires_at,
     874            7 :                 location, location_ambiguous)
     875            7 :              SELECT $1, e->>'id', e->'doc',
     876            7 :                     ARRAY(SELECT jsonb_array_elements_text(e->'types')),
     877            7 :                     CASE WHEN e->'scopes' = 'null'::jsonb THEN NULL
     878            7 :                          ELSE ARRAY(SELECT jsonb_array_elements_text(e->'scopes')) END,
     879            7 :                     (e->>'created')::timestamptz, (e->>'modified')::timestamptz,
     880            7 :                     (e->>'expires')::timestamptz,
     881            7 :                     CASE WHEN ST_IsValid(try_geomfromgeojson(e->>'location'))
     882            7 :                          THEN try_geomfromgeojson(e->>'location') END,
     883            7 :                     COALESCE((e->>'loc_ambiguous')::bool, false)
     884            7 :                       OR (e->>'location' IS NOT NULL
     885            7 :                           AND NOT COALESCE(ST_IsValid(try_geomfromgeojson(e->>'location')), false))
     886            7 :              FROM jsonb_array_elements($2::jsonb) AS e
     887            7 :              ON CONFLICT (tenant_id, id) DO UPDATE SET
     888            7 :                     entity = EXCLUDED.entity, types = EXCLUDED.types,
     889            7 :                     scopes = EXCLUDED.scopes, created_at = EXCLUDED.created_at,
     890            7 :                     modified_at = EXCLUDED.modified_at,
     891            7 :                     expires_at = EXCLUDED.expires_at, location = EXCLUDED.location,
     892            7 :                     location_ambiguous = EXCLUDED.location_ambiguous,
     893            7 :                     version = 1
     894            7 :              WHERE entities.expires_at IS NOT NULL AND entities.expires_at <= now()
     895            7 :              RETURNING id",
     896            7 :         )
     897            7 :         .bind(tenant.as_str())
     898            7 :         .bind(Value::Array(payload))
     899            7 :         .fetch_all(&mut *tx)
     900            7 :         .await?;
     901            7 :         let created_now: std::collections::HashSet<String> =
     902            7 :             rows.into_iter().map(|r| r.get::<String, _>(0)).collect();
     903            7 :         if self.outbox_on() {
     904            0 :             let mut seen_ev = std::collections::HashSet::new();
     905            0 :             let mut events = Vec::new();
     906            0 :             for (id, doc) in items {
     907            0 :                 if created_now.contains(id.as_str()) && seen_ev.insert(id.as_str()) {
     908            0 :                     let e = extract(doc);
     909            0 :                     events.push(change_event(
     910            0 :                         tenant,
     911            0 :                         "create",
     912            0 :                         id,
     913            0 :                         &e.types,
     914            0 :                         None,
     915            0 :                         Some(doc),
     916            0 :                         1,
     917            0 :                         &e.created,
     918            0 :                     ));
     919            0 :                 }
     920              :             }
     921            0 :             super::outbox::enqueue_many(&mut tx, tenant, &events).await?;
     922            7 :         }
     923            7 :         tx.commit().await?;
     924            7 :         let mut created = created_now;
     925              :         // consume-once: a duplicate of a created id still reports false
     926            7 :         Ok(items
     927            7 :             .iter()
     928            9 :             .map(|(id, _)| created.remove(id.as_str()))
     929            7 :             .collect())
     930            7 :     }
     931              : 
     932              :     /// Batch delete: ONE statement, returning each deleted row's previous
     933              :     /// document (the change-hook before-image).
     934              :     ///
     935              :     /// 5.6.10.4: "For each of the NGSI-LD Entity IDs included in the input
     936              :     /// Array execute the behaviour defined by clause 5.6.6, but limited to a
     937              :     /// local operation" — so an entity past its `expiresAt` is absent (4.22)
     938              :     /// and is not deleted here either, exactly as `delete` refuses it.
     939           93 :     pub async fn batch_delete(
     940           93 :         &self,
     941           93 :         tenant: &TenantId,
     942           93 :         ids: &[String],
     943           93 :     ) -> Result<Vec<(String, Value)>, sqlx::Error> {
     944           93 :         let mut tx = super::begin(&self.pool).await?;
     945           93 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
     946           93 :         let rows = sqlx::query(
     947           93 :             "DELETE FROM entities WHERE tenant_id = $1 AND id = ANY($2)
     948           93 :            AND (expires_at IS NULL OR expires_at > now())
     949           93 :          RETURNING id, entity, types, version, created_at::text",
     950           93 :         )
     951           93 :         .bind(tenant.as_str())
     952           93 :         .bind(ids)
     953           93 :         .fetch_all(&mut *tx)
     954           93 :         .await?;
     955           93 :         if self.outbox_on() {
     956            0 :             let events: Vec<Value> = rows
     957            0 :                 .iter()
     958            0 :                 .map(|r| {
     959            0 :                     let id: String = r.get(0);
     960            0 :                     let prev: Value = r.get(1);
     961            0 :                     let types: Vec<String> = r.get(2);
     962            0 :                     change_event(
     963            0 :                         tenant,
     964            0 :                         "delete",
     965            0 :                         &id,
     966            0 :                         &types,
     967            0 :                         Some(&prev),
     968            0 :                         None,
     969            0 :                         r.get::<i64, _>(3),
     970            0 :                         r.get::<&str, _>(4),
     971              :                     )
     972            0 :                 })
     973            0 :                 .collect();
     974            0 :             super::outbox::enqueue_many(&mut tx, tenant, &events).await?;
     975           93 :         }
     976           93 :         tx.commit().await?;
     977           93 :         Ok(rows
     978           93 :             .into_iter()
     979         1210 :             .map(|r| (r.get::<String, _>(0), r.get::<Value, _>(1)))
     980           93 :             .collect())
     981           93 :     }
     982              : 
     983              :     /// Batch upsert in REPLACE semantics — ONE
     984              :     /// `INSERT … ON CONFLICT DO UPDATE` for the whole batch, one transaction.
     985              :     /// Returns a created-flag per input item (input order; duplicates of one
     986              :     /// id report the first outcome). Before-images for the change events are
     987              :     /// captured by a single `FOR UPDATE` select in the same transaction.
     988              :     /// Returns per input item: (created?, before-image) — the before-image is
     989              :     /// for the caller's change hook and comes from the same-tx FOR UPDATE.
     990              :     ///
     991              :     /// 5.6.8.4: "Create the Entity locally if it does not exist (i.e. no
     992              :     /// Entity with the same Entity ID is present) executing the behaviour
     993              :     /// defined by clause 5.6.1, but limited to a local operation" — an
     994              :     /// entity past its `expiresAt` does not exist (4.22), so it takes the
     995              :     /// creation branch: fresh `created_at`, `version` back to 1 and a
     996              :     /// created-flag, which is what splits 201 from 204 at the API.
     997            5 :     pub async fn batch_upsert_replace(
     998            5 :         &self,
     999            5 :         tenant: &TenantId,
    1000            5 :         items: &[(String, Value)],
    1001            5 :     ) -> Result<Vec<(bool, Option<Value>)>, sqlx::Error> {
    1002            5 :         let mut seen = std::collections::HashSet::new();
    1003            5 :         let mut payload = Vec::new();
    1004            5 :         let mut ids = Vec::new();
    1005            8 :         for (id, doc) in items {
    1006            8 :             if seen.insert(id.as_str()) {
    1007            6 :                 let e = extract(doc);
    1008            6 :                 ids.push(id.clone());
    1009            6 :                 payload.push(serde_json::json!({
    1010            6 :                     "id": id, "doc": doc, "types": e.types, "scopes": e.scopes,
    1011            6 :                     "created": e.created, "modified": e.modified, "expires": e.expires,
    1012            6 :                     "location": e.location, "loc_ambiguous": e.location_ambiguous,
    1013            6 :                 }));
    1014            6 :             }
    1015              :         }
    1016            5 :         let mut tx = super::begin(&self.pool).await?;
    1017            5 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
    1018            5 :         crate::store::pg::claim_tenant(&mut tx, tenant).await?;
    1019              :         // lock + before-images in one statement (ordered: stable lock order)
    1020            5 :         let prev_rows = sqlx::query(
    1021            5 :             "SELECT id, entity,
    1022            5 :                     (expires_at IS NOT NULL AND expires_at <= now()) AS expired
    1023            5 :              FROM entities WHERE tenant_id = $1 AND id = ANY($2)
    1024            5 :              ORDER BY id FOR UPDATE",
    1025            5 :         )
    1026            5 :         .bind(tenant.as_str())
    1027            5 :         .bind(&ids)
    1028            5 :         .fetch_all(&mut *tx)
    1029            5 :         .await?;
    1030              :         // 4.22: an expired row is already invalid, so it is not a
    1031              :         // before-image — the upsert that replaces it is a creation and
    1032              :         // its change event has nothing before it. The lock still covers
    1033              :         // the row, so the stable lock order is unchanged.
    1034            5 :         let prevs: std::collections::HashMap<String, Value> = prev_rows
    1035            5 :             .into_iter()
    1036            5 :             .filter(|r| !r.get::<bool, _>(2))
    1037            5 :             .map(|r| (r.get::<String, _>(0), r.get::<Value, _>(1)))
    1038            5 :             .collect();
    1039            5 :         let rows = sqlx::query(
    1040            5 :             "INSERT INTO entities
    1041            5 :                (tenant_id, id, entity, types, scopes, created_at, modified_at, expires_at,
    1042            5 :                 location, location_ambiguous)
    1043            5 :              SELECT $1, e->>'id', e->'doc',
    1044            5 :                     ARRAY(SELECT jsonb_array_elements_text(e->'types')),
    1045            5 :                     CASE WHEN e->'scopes' = 'null'::jsonb THEN NULL
    1046            5 :                          ELSE ARRAY(SELECT jsonb_array_elements_text(e->'scopes')) END,
    1047            5 :                     (e->>'created')::timestamptz, (e->>'modified')::timestamptz,
    1048            5 :                     (e->>'expires')::timestamptz,
    1049            5 :                     CASE WHEN ST_IsValid(try_geomfromgeojson(e->>'location'))
    1050            5 :                          THEN try_geomfromgeojson(e->>'location') END,
    1051            5 :                     COALESCE((e->>'loc_ambiguous')::bool, false)
    1052            5 :                       OR (e->>'location' IS NOT NULL
    1053            5 :                           AND NOT COALESCE(ST_IsValid(try_geomfromgeojson(e->>'location')), false))
    1054            5 :              FROM jsonb_array_elements($2::jsonb) AS e
    1055            5 :              ON CONFLICT (tenant_id, id) DO UPDATE SET
    1056            5 :                     entity = EXCLUDED.entity, types = EXCLUDED.types,
    1057            5 :                     scopes = EXCLUDED.scopes, modified_at = EXCLUDED.modified_at,
    1058            5 :                     expires_at = EXCLUDED.expires_at, location = EXCLUDED.location,
    1059            5 :                     location_ambiguous = EXCLUDED.location_ambiguous,
    1060            5 :                     created_at = CASE WHEN entities.expires_at IS NOT NULL
    1061            5 :                                        AND entities.expires_at <= now()
    1062            5 :                                       THEN EXCLUDED.created_at
    1063            5 :                                       ELSE entities.created_at END,
    1064            5 :                     version = CASE WHEN entities.expires_at IS NOT NULL
    1065            5 :                                     AND entities.expires_at <= now()
    1066            5 :                                    THEN 1 ELSE entities.version + 1 END
    1067            5 :              RETURNING id, (xmax = 0 OR version = 1) AS inserted,
    1068            5 :                        version, created_at::text",
    1069            5 :         )
    1070            5 :         .bind(tenant.as_str())
    1071            5 :         .bind(Value::Array(payload))
    1072            5 :         .fetch_all(&mut *tx)
    1073            5 :         .await?;
    1074            5 :         let mut created: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
    1075            5 :         let mut events = Vec::new();
    1076            6 :         for r in &rows {
    1077            6 :             let id: String = r.get(0);
    1078            6 :             let inserted: bool = r.get(1);
    1079              :             // the id came out of `items`, so the lookup answers; an
    1080              :             // outbox row for a document that is not there would carry no
    1081              :             // payload, so there is nothing to emit either way
    1082            0 :             if let (true, Some(doc)) = (
    1083            6 :                 self.outbox_on(),
    1084            8 :                 items.iter().find(|(i, _)| *i == id).map(|(_, d)| d),
    1085              :             ) {
    1086            0 :                 let e = extract(doc);
    1087            0 :                 events.push(change_event(
    1088            0 :                     tenant,
    1089            0 :                     if inserted { "create" } else { "update" },
    1090            0 :                     &id,
    1091            0 :                     &e.types,
    1092            0 :                     prevs.get(&id),
    1093            0 :                     Some(doc),
    1094            0 :                     r.get::<i64, _>(2),
    1095            0 :                     r.get::<&str, _>(3),
    1096              :                 ));
    1097            6 :             }
    1098            6 :             created.insert(id, inserted);
    1099              :         }
    1100            5 :         super::outbox::enqueue_many(&mut tx, tenant, &events).await?;
    1101            5 :         tx.commit().await?;
    1102            5 :         Ok(items
    1103            5 :             .iter()
    1104            8 :             .map(|(id, _)| {
    1105            8 :                 (
    1106            8 :                     created.get(id).copied().unwrap_or(false),
    1107            8 :                     prevs.get(id).cloned(),
    1108            8 :                 )
    1109            8 :             })
    1110            5 :             .collect())
    1111            5 :     }
    1112              : 
    1113              :     /// Batch read-modify-write — ONE transaction,
    1114              :     /// all rows locked in a single ordered `FOR UPDATE` select, the closure
    1115              :     /// applied per doc in Rust, and ONE multi-row UPDATE writeback. Per-item
    1116              :     /// results align with `ids`: `None` = absent, `Some(Err)` = the closure
    1117              :     /// rejected that item (its write is skipped, the rest proceed).
    1118            5 :     pub async fn batch_mutate<E>(
    1119            5 :         &self,
    1120            5 :         tenant: &TenantId,
    1121            5 :         ids: &[String],
    1122            5 :         mut f: impl FnMut(&str, &mut Value) -> Result<(), E>,
    1123            5 :     ) -> Result<Vec<Option<Result<(), E>>>, sqlx::Error> {
    1124            5 :         let mut tx = super::begin(&self.pool).await?;
    1125            5 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
    1126            5 :         let rows = sqlx::query(
    1127            5 :             // 4.22: an expired entity is invalid, so it is not a row this
    1128            5 :             // update can find — 5.6.9.4 is clause 5.6.3 run per item, and
    1129            5 :             // 5.6.3 on an absent entity is ResourceNotFound. Without the
    1130            5 :             // predicate the batch reported the id as updated, wrote to
    1131            5 :             // it, and emitted a change event for an entity every read
    1132            5 :             // refuses. ORDER BY id keeps the lock order stable.
    1133            5 :             "SELECT id, entity FROM entities
    1134            5 :           WHERE tenant_id = $1 AND id = ANY($2)
    1135            5 :             AND (expires_at IS NULL OR expires_at > now())
    1136            5 :          ORDER BY id FOR UPDATE",
    1137            5 :         )
    1138            5 :         .bind(tenant.as_str())
    1139            5 :         .bind(ids)
    1140            5 :         .fetch_all(&mut *tx)
    1141            5 :         .await?;
    1142            5 :         let mut docs: std::collections::HashMap<String, Value> = rows
    1143            5 :             .into_iter()
    1144            6 :             .map(|r| (r.get::<String, _>(0), r.get::<Value, _>(1)))
    1145            5 :             .collect();
    1146            5 :         let mut results: Vec<Option<Result<(), E>>> = Vec::with_capacity(ids.len());
    1147              :         // 5.6.9.4 is clause 5.6.3 run per item, so a repeated id applies
    1148              :         // its closure once per item onto the SAME document and the last
    1149              :         // application is the state the row must end at. The writeback
    1150              :         // below is one `UPDATE … FROM`, which updates a target row once
    1151              :         // from whichever source row the join happens to reach, so the
    1152              :         // statement is fed one row per id — the document as the whole
    1153              :         // batch left it — rather than one per input item.
    1154            5 :         let mut write_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
    1155            5 :         let mut changed: Vec<(String, Value, Value)> = Vec::new(); // id, before, after
    1156           10 :         for id in ids {
    1157           10 :             match docs.get_mut(id) {
    1158            4 :                 None => results.push(None),
    1159            6 :                 Some(doc) => {
    1160            6 :                     let before = doc.clone();
    1161            6 :                     match f(id, doc) {
    1162            0 :                         Err(e) => {
    1163            0 :                             *doc = before; // closure failed: discard its edits
    1164            0 :                             results.push(Some(Err(e)));
    1165            0 :                         }
    1166              :                         Ok(()) => {
    1167            6 :                             if *doc != before {
    1168            5 :                                 write_ids.insert(id.as_str());
    1169            5 :                                 changed.push((id.clone(), before, doc.clone()));
    1170            5 :                             }
    1171            6 :                             results.push(Some(Ok(())));
    1172              :                         }
    1173              :                     }
    1174              :                 }
    1175              :             }
    1176              :         }
    1177            5 :         let payload: Vec<Value> = write_ids
    1178            5 :             .iter()
    1179            6 :             .filter_map(|id| docs.get(*id).map(|doc| (*id, doc)))
    1180            6 :             .map(|(id, doc)| {
    1181            5 :                 let e = extract(doc);
    1182            5 :                 serde_json::json!({
    1183            5 :                     "id": id, "doc": doc, "types": e.types,
    1184            5 :                     "scopes": e.scopes, "modified": e.modified,
    1185            5 :                     "expires": e.expires, "location": e.location,
    1186            5 :                     "loc_ambiguous": e.location_ambiguous,
    1187              :                 })
    1188            5 :             })
    1189            5 :             .collect();
    1190            5 :         if !payload.is_empty() {
    1191            3 :             let updated = sqlx::query(
    1192            3 :                 "UPDATE entities t SET
    1193            3 :                     entity = e->'doc',
    1194            3 :                     types = ARRAY(SELECT jsonb_array_elements_text(e->'types')),
    1195            3 :                     scopes = CASE WHEN e->'scopes' = 'null'::jsonb THEN NULL
    1196            3 :                                   ELSE ARRAY(SELECT jsonb_array_elements_text(e->'scopes')) END,
    1197            3 :                     modified_at = (e->>'modified')::timestamptz,
    1198            3 :                     expires_at = (e->>'expires')::timestamptz,
    1199            3 :                     location = CASE WHEN ST_IsValid(try_geomfromgeojson(e->>'location'))
    1200            3 :                                     THEN try_geomfromgeojson(e->>'location') END,
    1201            3 :                     location_ambiguous = COALESCE((e->>'loc_ambiguous')::bool, false)
    1202            3 :                       OR (e->>'location' IS NOT NULL
    1203            3 :                           AND NOT COALESCE(ST_IsValid(try_geomfromgeojson(e->>'location')), false)),
    1204            3 :                     version = t.version + 1
    1205            3 :              FROM jsonb_array_elements($2::jsonb) AS e
    1206            3 :              WHERE t.tenant_id = $1 AND t.id = e->>'id'
    1207            3 :              RETURNING t.id, t.version, t.created_at::text",
    1208            3 :             )
    1209            3 :             .bind(tenant.as_str())
    1210            3 :             .bind(Value::Array(payload))
    1211            3 :             .fetch_all(&mut *tx)
    1212            3 :             .await?;
    1213            3 :             if self.outbox_on() {
    1214            0 :                 let meta: std::collections::HashMap<String, (i64, String)> = updated
    1215            0 :                     .into_iter()
    1216            0 :                     .map(|r| {
    1217            0 :                         (
    1218            0 :                             r.get::<String, _>(0),
    1219            0 :                             (r.get::<i64, _>(1), r.get::<String, _>(2)),
    1220            0 :                         )
    1221            0 :                     })
    1222            0 :                     .collect();
    1223            0 :                 let events: Vec<Value> = changed
    1224            0 :                     .iter()
    1225            0 :                     .filter_map(|(id, before, after)| {
    1226            0 :                         let e = extract(after);
    1227            0 :                         let (version, incarnation) = meta.get(id)?;
    1228            0 :                         Some(change_event(
    1229            0 :                             tenant,
    1230            0 :                             "update",
    1231            0 :                             id,
    1232            0 :                             &e.types,
    1233            0 :                             Some(before),
    1234            0 :                             Some(after),
    1235            0 :                             *version,
    1236            0 :                             incarnation,
    1237            0 :                         ))
    1238            0 :                     })
    1239            0 :                     .collect();
    1240            0 :                 super::outbox::enqueue_many(&mut tx, tenant, &events).await?;
    1241            3 :             }
    1242            2 :         }
    1243            5 :         tx.commit().await?;
    1244            5 :         Ok(results)
    1245            5 :     }
    1246              : 
    1247              :     /// Current row version (test hook for the version-monotonicity assertions).
    1248              :     #[cfg(any(test, feature = "test-kit"))]
    1249            4 :     pub async fn version(&self, tenant: &TenantId, id: &str) -> Result<Option<i64>, sqlx::Error> {
    1250            4 :         let mut tx = super::begin(&self.pool).await?;
    1251            4 :         crate::store::pg::set_tenant(&mut tx, tenant).await?;
    1252            4 :         let row = sqlx::query("SELECT version FROM entities WHERE tenant_id = $1 AND id = $2")
    1253            4 :             .bind(tenant.as_str())
    1254            4 :             .bind(id)
    1255            4 :             .fetch_optional(&mut *tx)
    1256            4 :             .await?;
    1257            4 :         tx.commit().await?;
    1258            4 :         Ok(row.map(|r| r.get::<i64, _>(0)))
    1259            4 :     }
    1260              : }
    1261              : 
    1262              : #[cfg(test)]
    1263              : mod tests {
    1264              :     use super::*;
    1265              :     use serde_json::json;
    1266              : 
    1267           12 :     fn wheres() -> Vec<String> {
    1268           12 :         vec!["tenant_id = $1".to_owned()]
    1269           12 :     }
    1270              : 
    1271              :     /// `location_ambiguous` is the single bit that keeps geoquery pushdown
    1272              :     /// honest: `compile_geo` ORs it into every predicate so a row whose
    1273              :     /// default GeoProperty could not be reduced to ONE geometry still reaches
    1274              :     /// the evaluator. It must be true exactly when the doc CARRIES the
    1275              :     /// geoproperty and extraction refused — a row with no geoproperty at all
    1276              :     /// can never match a geoquery and is excluded in SQL, which is the whole
    1277              :     /// point of the column.
    1278              :     #[test]
    1279            2 :     fn location_ambiguous_is_carried_but_unextractable() {
    1280            2 :         let loc = crate::compile::geo::LOCATION_IRI;
    1281            2 :         let point = json!({"type": "Point", "coordinates": [2.29, 48.85]});
    1282              : 
    1283              :         // one default GeoProperty instance with a geometry: extracted, exact
    1284            2 :         let e = extract(&json!({"id": "urn:x", loc: [{"value": point}]}));
    1285            2 :         assert!(e.location.is_some());
    1286            2 :         assert!(!e.location_ambiguous);
    1287              : 
    1288              :         // multi-instance: no single geometry, so the evaluator arbitrates
    1289            2 :         let e = extract(&json!({"id": "urn:x", loc: [{"value": point}, {"value": point}]}));
    1290            2 :         assert!(e.location.is_none());
    1291            2 :         assert!(
    1292            2 :             e.location_ambiguous,
    1293              :             "a multi-instance location must reach the evaluator"
    1294              :         );
    1295              : 
    1296              :         // a GeometryCollection and a non-GeoJSON value are the same case
    1297            6 :         for v in [
    1298            2 :             json!({"type": "GeometryCollection", "geometries": []}),
    1299            2 :             json!("somewhere"),
    1300            2 :             json!({"coordinates": [1, 2]}),
    1301            2 :         ] {
    1302            6 :             let e = extract(&json!({"id": "urn:x", loc: [{"value": v}]}));
    1303            6 :             assert!(e.location.is_none(), "{v} must not become a geometry");
    1304            6 :             assert!(e.location_ambiguous, "{v} must reach the evaluator");
    1305              :         }
    1306              : 
    1307              :         // NO geoproperty: not ambiguous — the row is excluded in SQL
    1308            2 :         let e = extract(&json!({"id": "urn:x", "https://a/speed": [{"value": 1}]}));
    1309            2 :         assert!(e.location.is_none());
    1310            2 :         assert!(
    1311            2 :             !e.location_ambiguous,
    1312              :             "a row without a location must stay excludable in SQL"
    1313              :         );
    1314            2 :     }
    1315              : 
    1316              :     /// The rest of `extract`: `type`/`scope` accept the string form and the
    1317              :     /// array form, an absent `scope` is SQL NULL (never an empty array, which
    1318              :     /// would mean "scoped to nothing"), and a missing system timestamp falls
    1319              :     /// back rather than failing the write.
    1320              :     #[test]
    1321            2 :     fn extract_reads_types_scopes_and_stamps() {
    1322            2 :         let e = extract(&json!({"id": "urn:x", "type": "T", "scope": "/a"}));
    1323            2 :         assert_eq!(e.types, ["T"]);
    1324            2 :         assert_eq!(e.scopes.as_deref(), Some(&["/a".to_owned()][..]));
    1325            2 :         let e = extract(&json!({"id": "urn:x", "type": ["T", "U"], "scope": ["/a", "/b"]}));
    1326            2 :         assert_eq!(e.types, ["T", "U"]);
    1327            2 :         assert_eq!(e.scopes.expect("scopes").len(), 2);
    1328            2 :         let e = extract(&json!({"id": "urn:x"}));
    1329            2 :         assert!(e.types.is_empty());
    1330            2 :         assert!(e.scopes.is_none(), "absent scope must be SQL NULL");
    1331            2 :         assert!(e.expires.is_none());
    1332            2 :         assert_eq!(e.created, "1970-01-01T00:00:00Z");
    1333              :         // a non-string, non-array scope is present but names nothing
    1334            2 :         let e = extract(&json!({"id": "urn:x", "scope": 7}));
    1335            2 :         assert_eq!(e.scopes.as_deref(), Some(&[][..]));
    1336            2 :     }
    1337              : 
    1338              :     /// The outbox row IS the wire contract the drain deserializes, and the
    1339              :     /// drain DELETES any row it cannot decode — a renamed key loses the event
    1340              :     /// silently. Pin the key set and the operation vocabulary here, where the
    1341              :     /// producer lives.
    1342              :     #[test]
    1343            2 :     fn the_change_event_carries_exactly_the_wire_keys() {
    1344            2 :         let t = TenantId::default();
    1345            2 :         let doc = json!({"id": "urn:e", "https://a/speed": [{"value": 1}]});
    1346            2 :         let ev = change_event(
    1347            2 :             &t,
    1348            2 :             "create",
    1349            2 :             "urn:e",
    1350            2 :             &["T".to_owned()],
    1351            2 :             None,
    1352            2 :             Some(&doc),
    1353              :             1,
    1354            2 :             "inc",
    1355              :         );
    1356            2 :         let keys: Vec<&str> = ev
    1357            2 :             .as_object()
    1358            2 :             .expect("object")
    1359            2 :             .keys()
    1360            2 :             .map(String::as_str)
    1361            2 :             .collect();
    1362            2 :         assert_eq!(
    1363              :             keys,
    1364              :             [
    1365              :                 "changed_attrs",
    1366              :                 "entity_id",
    1367              :                 "incarnation",
    1368              :                 "op",
    1369              :                 "payload",
    1370              :                 "prev_payload",
    1371              :                 "tenant",
    1372              :                 "types",
    1373              :                 "version",
    1374              :             ]
    1375              :         );
    1376            2 :         assert_eq!(ev["op"], "create");
    1377            2 :         assert_eq!(ev["tenant"], t.as_str());
    1378            2 :         assert!(ev["prev_payload"].is_null(), "a create has no before-image");
    1379            6 :         for op in ["create", "update", "delete"] {
    1380            6 :             assert_eq!(
    1381            6 :                 change_event(&t, op, "urn:e", &[], None, None, 1, "inc")["op"],
    1382              :                 op
    1383              :             );
    1384              :         }
    1385            2 :     }
    1386              : 
    1387              :     /// `changed_attrs` names the top-level ATTRIBUTES that differ. A create
    1388              :     /// lists every attribute, a delete lists every prior one — and, the half
    1389              :     /// that actually bounds notification traffic, an attribute present in
    1390              :     /// both images with an EQUAL value is not listed, nor is any system
    1391              :     /// member that changed on every write.
    1392              :     #[test]
    1393            2 :     fn changed_attrs_lists_the_differences_and_nothing_else() {
    1394            2 :         let t = TenantId::default();
    1395           10 :         let ev = |prev: Option<&Value>, next: Option<&Value>| {
    1396           10 :             change_event(&t, "update", "urn:e", &[], prev, next, 1, "inc")["changed_attrs"].clone()
    1397           10 :         };
    1398            2 :         let a = json!({"id": "urn:e", "type": ["T"], "createdAt": "t0", "modifiedAt": "t0",
    1399            2 :                        "https://a/x": [{"value": 1}], "https://a/y": [{"value": 2}]});
    1400            2 :         let b = json!({"id": "urn:e", "type": ["T"], "createdAt": "t0", "modifiedAt": "t9",
    1401            2 :                        "https://a/x": [{"value": 1}], "https://a/y": [{"value": 3}]});
    1402            2 :         assert_eq!(ev(Some(&a), Some(&b)), json!(["https://a/y"]));
    1403            2 :         assert_eq!(ev(None, Some(&a)), json!(["https://a/x", "https://a/y"]));
    1404            2 :         assert_eq!(ev(Some(&a), None), json!(["https://a/x", "https://a/y"]));
    1405              :         // identical images change nothing at all
    1406            2 :         assert_eq!(ev(Some(&a), Some(&a)), json!([]));
    1407              :         // and no system member is ever named
    1408            2 :         let ids = json!({"id": "urn:other", "type": ["U"], "scope": "/s",
    1409            2 :                          "modifiedAt": "t1", "deletedAt": "t1", "expiresAt": "t1"});
    1410            2 :         assert_eq!(
    1411            2 :             ev(Some(&a), Some(&ids)),
    1412            2 :             json!(["https://a/x", "https://a/y"])
    1413              :         );
    1414            2 :     }
    1415              : 
    1416            4 :     fn page(offset: i64, limit: i64) -> Page {
    1417            4 :         Page {
    1418            4 :             offset,
    1419            4 :             limit,
    1420            4 :             count: true,
    1421            4 :         }
    1422            4 :     }
    1423              : 
    1424              :     /// 5.5.9.1: a query SQL decided exactly keeps the pushed page — LIMIT
    1425              :     /// and OFFSET, with the window total that feeds count= and the
    1426              :     /// next/prev pointers.
    1427              :     #[test]
    1428            2 :     fn a_decided_page_pushes_limit_and_offset() {
    1429            2 :         let mut binds = vec![Bind::Text("t".to_owned())];
    1430            2 :         let (sql, paged) = query_sql("entity", &wheres(), Some(&page(20, 10)), true, &mut binds);
    1431            2 :         assert!(paged);
    1432            2 :         assert!(sql.contains("LIMIT $2 OFFSET $3"), "{sql}");
    1433            2 :         assert!(sql.contains("count(*) OVER ()"), "{sql}");
    1434            2 :         assert!(matches!(binds[1], Bind::Int(10)));
    1435            2 :         assert!(matches!(binds[2], Bind::Int(20)));
    1436            2 :     }
    1437              : 
    1438              :     /// 6.3.10: without count=true no total is owed, so the statement carries
    1439              :     /// no window count (which forced a walk of the whole match set) and
    1440              :     /// fetches one row past the page to learn whether a next page exists.
    1441              :     #[test]
    1442            2 :     fn an_uncounted_page_fetches_one_extra_row_and_no_window_total() {
    1443            2 :         let mut binds = vec![Bind::Text("t".to_owned())];
    1444            2 :         let p = Page {
    1445            2 :             offset: 20,
    1446            2 :             limit: 10,
    1447            2 :             count: false,
    1448            2 :         };
    1449            2 :         let (sql, paged) = query_sql("entity", &wheres(), Some(&p), true, &mut binds);
    1450            2 :         assert!(paged);
    1451            2 :         assert!(!sql.contains("count(*) OVER ()"), "{sql}");
    1452            2 :         assert!(sql.contains("LIMIT $2 OFFSET $3"), "{sql}");
    1453            2 :         assert!(matches!(binds[1], Bind::Int(11)), "limit + 1 must be bound");
    1454            2 :         assert!(matches!(binds[2], Bind::Int(20)));
    1455            2 :     }
    1456              : 
    1457              :     /// The largest page a caller can build still has to produce a usable
    1458              :     /// LIMIT. `types_attrs.rs` clamps its discovery scan to `i64::MAX` on
    1459              :     /// purpose so the cast is safe; the uncounted path then binds
    1460              :     /// `limit + 1`, which is where that clamp stops being enough. The
    1461              :     /// addition overflows: a panic in a debug build, and in release a wrap
    1462              :     /// to `i64::MIN`, which Postgres refuses as a negative LIMIT.
    1463              :     #[test]
    1464            2 :     fn the_largest_page_still_binds_a_usable_limit() {
    1465            2 :         let mut binds = vec![Bind::Text("t".to_owned())];
    1466            2 :         let p = Page {
    1467            2 :             offset: 0,
    1468            2 :             limit: i64::MAX,
    1469            2 :             count: false,
    1470            2 :         };
    1471            2 :         let (sql, paged) = query_sql("entity", &wheres(), Some(&p), true, &mut binds);
    1472            2 :         assert!(paged);
    1473            2 :         assert!(sql.contains("LIMIT $2"), "{sql}");
    1474            2 :         assert!(
    1475            2 :             matches!(binds[1], Bind::Int(i64::MAX)),
    1476              :             "the bound limit must stay positive"
    1477              :         );
    1478            2 :     }
    1479              : 
    1480              :     /// The undecided path must never leave the statement unbounded: SQL only
    1481              :     /// narrowed the set (`scopeQ`, `georel`, a `q=` the compiler declined),
    1482              :     /// so the rows come back to be filtered — under a LIMIT, and without an
    1483              :     /// OFFSET, which would page over the wrong set.
    1484              :     #[test]
    1485            2 :     fn an_undecided_query_carries_a_safety_limit_and_no_offset() {
    1486            2 :         let mut binds = vec![Bind::Text("t".to_owned())];
    1487            2 :         let (sql, paged) = query_sql("entity", &wheres(), Some(&page(20, 10)), false, &mut binds);
    1488            2 :         assert!(!paged);
    1489            2 :         assert!(sql.contains("LIMIT $2"), "{sql}");
    1490            2 :         assert!(!sql.contains("OFFSET"), "offset cannot be pushed: {sql}");
    1491            2 :         assert!(!sql.contains("count(*) OVER ()"), "{sql}");
    1492              :         // the candidate ceiling, not the page: the caller's evaluator still
    1493              :         // filters these rows, so a page-sized fetch would refuse legitimate
    1494              :         // queries whenever candidates outnumber matches
    1495            2 :         assert!(matches!(binds[1], Bind::Int(MAX_UNDECIDED_ROWS)));
    1496            2 :     }
    1497              : 
    1498              :     /// A caller with no page (idPattern, federation, orderBy) hands the store
    1499              :     /// no bound at all — the ceiling is then the only one.
    1500              :     #[test]
    1501            2 :     fn a_query_without_a_page_falls_back_to_the_ceiling() {
    1502            2 :         let mut binds = vec![Bind::Text("t".to_owned())];
    1503            2 :         let (sql, paged) = query_sql("entity", &wheres(), None, false, &mut binds);
    1504            2 :         assert!(!paged);
    1505            2 :         assert!(sql.contains("LIMIT $2"), "{sql}");
    1506            2 :         assert!(matches!(binds[1], Bind::Int(MAX_UNDECIDED_ROWS)));
    1507              :         // an exactly decided query with no page is materialized whole too
    1508            2 :         let mut binds = vec![Bind::Text("t".to_owned())];
    1509            2 :         let (sql, _) = query_sql("entity", &wheres(), None, true, &mut binds);
    1510            2 :         assert!(sql.contains("LIMIT $2"), "{sql}");
    1511            2 :     }
    1512              : 
    1513              :     /// 5.5.6 / Table 6.3.2-1: reaching the ceiling means the statement was
    1514              :     /// truncated, so the page built from it would silently under-report —
    1515              :     /// the operation is refused with TooManyResults (403) instead.
    1516              :     #[test]
    1517            2 :     fn a_ceiling_hit_is_too_many_results_not_a_short_page() {
    1518            2 :         assert!(check_ceiling(false, 30, 31).is_ok(), "under the ceiling");
    1519            2 :         let err = check_ceiling(false, 31, 31).expect_err("ceiling reached");
    1520            2 :         let ngsi = ngsi_error(&err).expect("the spec error travels in the driver error");
    1521            2 :         assert_eq!(ngsi.kind(), "TooManyResults");
    1522            2 :         assert_eq!(ngsi.status(), 403);
    1523              :         // a pushed page is exact by construction: a full page is a page, not
    1524              :         // a truncation
    1525            2 :         assert!(check_ceiling(true, 1000, 1000).is_ok());
    1526            2 :     }
    1527              : 
    1528              :     /// A real driver failure must stay a driver failure — only the store's
    1529              :     /// own spec errors are recovered, or every 500 would become a 403.
    1530              :     #[test]
    1531            2 :     fn a_driver_error_is_not_mistaken_for_a_spec_error() {
    1532            2 :         assert!(ngsi_error(&sqlx::Error::RowNotFound).is_none());
    1533            2 :         assert!(ngsi_error(&sqlx::Error::PoolTimedOut).is_none());
    1534            2 :         assert!(ngsi_error(&sqlx::Error::Configuration("boom".into())).is_none());
    1535            2 :     }
    1536              : }
        

Generated by: LCOV version 2.0-1