lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 9cfbe20e86656898cc9fb124c662e29f3d1d32dc
parent 4114fb969a0f8d09911e323b4fa76e8a0e170ab2
Author: triesap <tyson@radroots.org>
Date:   Sun, 12 Apr 2026 05:24:30 +0000

replica_db: add shaped query helpers

Diffstat:
Mcrates/replica_db/src/lib.rs | 12++++++++++--
Acrates/replica_db/src/query.rs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/replica_db/tests/full_mode.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 223 insertions(+), 2 deletions(-)

diff --git a/crates/replica_db/src/lib.rs b/crates/replica_db/src/lib.rs @@ -119,13 +119,17 @@ pub mod migrations; #[cfg(not(feature = "coverage-minimal"))] pub mod models; #[cfg(not(feature = "coverage-minimal"))] +pub mod query; +#[cfg(not(feature = "coverage-minimal"))] pub use backup::{DatabaseBackup, MigrationBackup, SchemaEntry}; #[cfg(not(feature = "coverage-minimal"))] pub use export::{ - REPLICA_DB_EXPORT_VERSION, ReplicaDbExportManifestRs, TableCount, export_manifest, + export_manifest, ReplicaDbExportManifestRs, TableCount, REPLICA_DB_EXPORT_VERSION, }; #[cfg(not(feature = "coverage-minimal"))] pub use models::*; +#[cfg(not(feature = "coverage-minimal"))] +pub use query::ReplicaTradeProductSummaryRow; pub struct ReplicaSql<E: SqlExecutor> { executor: E, @@ -133,7 +137,11 @@ pub struct ReplicaSql<E: SqlExecutor> { impl<E: SqlExecutor> ReplicaSql<E> { pub fn coverage_branch_probe(enabled: bool) -> &'static str { - if enabled { "enabled" } else { "disabled" } + if enabled { + "enabled" + } else { + "disabled" + } } } diff --git a/crates/replica_db/src/query.rs b/crates/replica_db/src/query.rs @@ -0,0 +1,125 @@ +use radroots_sql_core::utils; +use serde::Deserialize; +use serde_json::Value; + +use crate::{ReplicaSql, SqlError, SqlExecutor}; + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct ReplicaTradeProductSummaryRow { + pub id: String, + pub key: String, + pub category: String, + pub title: String, + pub summary: String, + pub qty_amt: i64, + pub qty_unit: String, + pub qty_label: Option<String>, + pub qty_avail: Option<i64>, + pub price_amt: f64, + pub price_currency: String, + pub price_qty_amt: u32, + pub price_qty_unit: String, + pub location_primary: Option<String>, +} + +#[derive(Debug, Clone, Deserialize)] +struct ReplicaFarmDTagRow { + d_tag: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct ReplicaEventFreshnessRow { + last_created_at: Option<i64>, +} + +impl<E: SqlExecutor> ReplicaSql<E> { + pub fn trade_product_lookup( + &self, + lookup: &str, + ) -> Result<Vec<ReplicaTradeProductSummaryRow>, SqlError> { + let sql = "SELECT tp.id, tp.key, tp.category, tp.title, tp.summary, tp.qty_amt, tp.qty_unit, tp.qty_label, tp.qty_avail, tp.price_amt, tp.price_currency, tp.price_qty_amt, tp.price_qty_unit, loc.location_primary \ + FROM trade_product tp \ + LEFT JOIN (\ + SELECT tpl.tb_tp AS trade_product_id, MIN(COALESCE(gl.label, gl.gc_name, gl.gc_admin1_name, gl.gc_country_name, gl.d_tag)) AS location_primary \ + FROM trade_product_location tpl \ + JOIN gcs_location gl ON gl.id = tpl.tb_gl \ + GROUP BY tpl.tb_tp\ + ) loc ON loc.trade_product_id = tp.id \ + WHERE tp.id = ? OR tp.key = ? \ + ORDER BY lower(tp.title) ASC, tp.id ASC;"; + let params_json = utils::to_params_json(vec![ + Value::from(lookup.to_owned()), + Value::from(lookup.to_owned()), + ])?; + let json = self.executor().query_raw(sql, &params_json)?; + serde_json::from_str(&json).map_err(SqlError::from) + } + + pub fn trade_product_search( + &self, + query_terms: &[String], + ) -> Result<Vec<ReplicaTradeProductSummaryRow>, SqlError> { + if query_terms.is_empty() { + return Ok(Vec::new()); + } + + let mut where_clauses = Vec::with_capacity(query_terms.len()); + let mut bind_values = Vec::<Value>::with_capacity(query_terms.len() * 5); + for term in query_terms { + let pattern = format!("%{}%", term.to_lowercase()); + where_clauses.push( + "(lower(tp.title) LIKE ? OR lower(tp.summary) LIKE ? OR lower(tp.category) LIKE ? OR lower(tp.key) LIKE ? OR lower(COALESCE(tp.notes, '')) LIKE ?)" + .to_owned(), + ); + for _ in 0..5 { + bind_values.push(Value::from(pattern.clone())); + } + } + + let sql = format!( + "SELECT tp.id, tp.key, tp.category, tp.title, tp.summary, tp.qty_amt, tp.qty_unit, tp.qty_label, tp.qty_avail, tp.price_amt, tp.price_currency, tp.price_qty_amt, tp.price_qty_unit, loc.location_primary \ + FROM trade_product tp \ + LEFT JOIN (\ + SELECT tpl.tb_tp AS trade_product_id, MIN(COALESCE(gl.label, gl.gc_name, gl.gc_admin1_name, gl.gc_country_name, gl.d_tag)) AS location_primary \ + FROM trade_product_location tpl \ + JOIN gcs_location gl ON gl.id = tpl.tb_gl \ + GROUP BY tpl.tb_tp\ + ) loc ON loc.trade_product_id = tp.id \ + WHERE {} \ + ORDER BY lower(tp.title) ASC, tp.id ASC;", + where_clauses.join(" AND ") + ); + let params_json = utils::to_params_json(bind_values)?; + let json = self.executor().query_raw(&sql, &params_json)?; + serde_json::from_str(&json).map_err(SqlError::from) + } + + pub fn farm_unique_d_tag_by_pubkey( + &self, + seller_pubkey: &str, + ) -> Result<Option<String>, SqlError> { + let sql = "SELECT d_tag FROM farm WHERE pubkey = ? ORDER BY d_tag ASC;"; + let params_json = utils::to_params_json(vec![Value::from(seller_pubkey.to_owned())])?; + let json = self.executor().query_raw(sql, &params_json)?; + let rows: Vec<ReplicaFarmDTagRow> = serde_json::from_str(&json).map_err(SqlError::from)?; + if rows.len() == 1 { + Ok(Some(rows[0].d_tag.clone())) + } else { + Ok(None) + } + } + + pub fn nostr_event_last_created_at(&self) -> Result<Option<u64>, SqlError> { + let json = self.executor().query_raw( + "SELECT MAX(last_created_at) AS last_created_at FROM nostr_event_state WHERE last_created_at IS NOT NULL", + "[]", + )?; + let rows: Vec<ReplicaEventFreshnessRow> = + serde_json::from_str(&json).map_err(SqlError::from)?; + Ok(rows + .into_iter() + .next() + .and_then(|row| row.last_created_at) + .and_then(|value| u64::try_from(value).ok())) + } +} diff --git a/crates/replica_db/tests/full_mode.rs b/crates/replica_db/tests/full_mode.rs @@ -95,6 +95,94 @@ fn open_db() -> ReplicaSql<SqliteExecutor> { } #[test] +fn full_mode_shaped_query_helpers_cover_cli_reads() { + let db = open_db(); + + let farm: IFarmCreate = parse_json(json!({ + "d_tag": "farm-a", + "pubkey": hex64('a'), + "name": "farm a" + })); + db.farm_create(&farm).expect("farm create"); + + let gcs_location: IGcsLocationCreate = parse_json(json!({ + "d_tag": "gcs-a", + "lat": 59.33, + "lng": 18.06, + "geohash": "u6sce4f", + "point": "POINT(18.06 59.33)", + "polygon": "POLYGON((18.06 59.33,18.07 59.33,18.07 59.34,18.06 59.34,18.06 59.33))", + "label": "stockholm" + })); + let gcs_created = db.gcs_location_create(&gcs_location).expect("gcs create").result; + + let trade_product: ITradeProductCreate = parse_json(json!({ + "key": "product-a", + "category": "coffee", + "title": "coffee a", + "summary": "washed lot", + "process": "washed", + "lot": "lot-a", + "profile": "floral", + "year": 2024, + "qty_amt": 100, + "qty_unit": "kg", + "qty_label": "bags", + "qty_avail": 2, + "price_amt": 7.5, + "price_currency": "USD", + "price_qty_amt": 1, + "price_qty_unit": "kg", + "notes": "fresh coffee" + })); + let trade_product_created = db + .trade_product_create(&trade_product) + .expect("trade product create") + .result; + + let product_location_rel: ITradeProductLocationRelation = parse_json(json!({ + "trade_product": { "id": trade_product_created.id }, + "gcs_location": { "id": gcs_created.id } + })); + db.trade_product_location_set(&product_location_rel) + .expect("product location set"); + + let nostr_event_state: INostrEventStateCreate = parse_json(json!({ + "key": "state-a", + "kind": 30023, + "pubkey": hex64('d'), + "d_tag": "listing-a", + "last_event_id": hex64('e'), + "last_created_at": 42, + "content_hash": "hash-a" + })); + db.nostr_event_state_create(&nostr_event_state) + .expect("nostr event state create"); + + let rows = db + .trade_product_search(&["coffee".to_owned()]) + .expect("trade product search"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].key, "product-a"); + assert_eq!(rows[0].location_primary.as_deref(), Some("stockholm")); + + let lookup_rows = db.trade_product_lookup("product-a").expect("trade product lookup"); + assert_eq!(lookup_rows.len(), 1); + assert_eq!(lookup_rows[0].id, trade_product_created.id); + + assert_eq!(db.trade_product_search(&[]).expect("empty search"), Vec::new()); + assert_eq!( + db.farm_unique_d_tag_by_pubkey(hex64('a').as_str()) + .expect("farm unique d tag"), + Some("farm-a".to_owned()) + ); + assert_eq!( + db.nostr_event_last_created_at().expect("nostr event freshness"), + Some(42) + ); +} + +#[test] fn full_mode_crud_and_relation_paths() { let db = open_db();