commit fcaa7c3b018252041b8d3e63a16a6e2a0daebe0f
parent 530715c3e1a68a5da7a4fe83eb259e5cc310704c
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 05:25:13 +0000
land local-first find command
Diffstat:
8 files changed, 616 insertions(+), 4 deletions(-)
diff --git a/src/commands/find.rs b/src/commands/find.rs
@@ -0,0 +1,16 @@
+use crate::cli::FindArgs;
+use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView};
+use crate::runtime::RuntimeError;
+use crate::runtime::config::RuntimeConfig;
+
+pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::find::search(config, args)?;
+ Ok(match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::Find(view)),
+ CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::Find(view)),
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::Find(view))
+ }
+ CommandDisposition::InternalError => CommandOutput::internal_error(CommandView::Find(view)),
+ })
+}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
@@ -1,4 +1,5 @@
pub mod doctor;
+pub mod find;
pub mod identity;
pub mod local;
pub mod myc;
@@ -45,7 +46,7 @@ pub fn dispatch(
SignerCommand::Status => Ok(signer::status(config)),
},
Command::Doctor => doctor::report(config, logging),
- Command::Find(_) => unimplemented_command("find"),
+ Command::Find(find_args) => find::search(config, find_args),
Command::Job(job) => match &job.command {
JobCommand::Ls => unimplemented_command("job ls"),
JobCommand::Get(_) => unimplemented_command("job get"),
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -74,6 +74,7 @@ pub enum CommandView {
AccountWhoami(AccountWhoamiView),
ConfigShow(ConfigShowView),
Doctor(DoctorView),
+ Find(FindView),
LocalBackup(LocalBackupView),
LocalExport(LocalExportView),
LocalInit(LocalInitView),
@@ -327,6 +328,71 @@ pub struct LocalReplicaSyncView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct FindView {
+ pub state: String,
+ pub source: String,
+ pub query: String,
+ pub count: usize,
+ pub relay_count: usize,
+ pub replica_db: String,
+ pub freshness: SyncFreshnessView,
+ pub results: Vec<FindResultView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl FindView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FindResultView {
+ pub id: String,
+ pub product_key: String,
+ pub title: String,
+ pub category: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub summary: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub location_primary: Option<String>,
+ pub available: FindQuantityView,
+ pub price: FindPriceView,
+ pub provenance: FindResultProvenanceView,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FindQuantityView {
+ pub total_amount: i64,
+ pub total_unit: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub label: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub available_amount: Option<i64>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FindPriceView {
+ pub amount: f64,
+ pub currency: String,
+ pub per_amount: u32,
+ pub per_unit: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct FindResultProvenanceView {
+ pub origin: String,
+ pub freshness: String,
+ pub relay_count: usize,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct SyncFreshnessView {
pub state: String,
pub display: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -2,8 +2,8 @@ use std::io::{self, Write};
use crate::domain::runtime::{
AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView,
- LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, NetStatusView, RelayListView,
- SyncActionView, SyncStatusView, SyncWatchView,
+ FindView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, NetStatusView,
+ RelayListView, SyncActionView, SyncStatusView, SyncWatchView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::{OutputConfig, OutputFormat};
@@ -82,6 +82,9 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
CommandView::Doctor(view) => {
render_doctor(stdout, view)?;
}
+ CommandView::Find(view) => {
+ render_find(stdout, view)?;
+ }
CommandView::LocalBackup(view) => {
render_local_backup(stdout, view)?;
}
@@ -184,6 +187,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::Find(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::LocalBackup(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -249,6 +256,13 @@ fn render_ndjson_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<()
}
Ok(())
}
+ CommandView::Find(view) => {
+ for result in &view.results {
+ serde_json::to_writer(&mut *stdout, result)?;
+ writeln!(stdout)?;
+ }
+ Ok(())
+ }
CommandView::SyncWatch(view) => {
for frame in &view.frames {
serde_json::to_writer(&mut *stdout, frame)?;
@@ -408,6 +422,76 @@ fn render_doctor(stdout: &mut dyn Write, view: &DoctorView) -> Result<(), Runtim
Ok(())
}
+fn render_find(stdout: &mut dyn Write, view: &FindView) -> Result<(), RuntimeError> {
+ let context = match view.state.as_str() {
+ "unconfigured" => "market · local first · unconfigured".to_owned(),
+ _ => format!(
+ "market · local first · {} result{}",
+ view.count,
+ if view.count == 1 { "" } else { "s" }
+ ),
+ };
+ write_context(stdout, context.as_str())?;
+ writeln!(stdout, "query: {}", view.query)?;
+
+ match view.state.as_str() {
+ "unconfigured" => {
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "reason: {reason}")?;
+ }
+ }
+ _ if view.results.is_empty() => {
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "{reason}")?;
+ }
+ }
+ _ => {
+ let table = Table {
+ headers: &["product", "category", "price", "available", "location"],
+ rows: view
+ .results
+ .iter()
+ .map(|result| {
+ vec![
+ result.title.clone(),
+ result.category.clone(),
+ format_price(
+ result.price.amount,
+ &result.price.currency,
+ result.price.per_amount,
+ &result.price.per_unit,
+ ),
+ format_available(
+ result
+ .available
+ .available_amount
+ .unwrap_or(result.available.total_amount),
+ result
+ .available
+ .label
+ .as_deref()
+ .unwrap_or(result.available.total_unit.as_str()),
+ ),
+ result.location_primary.clone().unwrap_or_default(),
+ ]
+ })
+ .collect(),
+ };
+ render_table(stdout, &table)?;
+ }
+ }
+
+ writeln!(stdout)?;
+ writeln!(
+ stdout,
+ "provenance: local replica · {} · {}",
+ view.freshness.display,
+ relay_count_text(view.relay_count)
+ )?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
fn render_relay_list(stdout: &mut dyn Write, view: &RelayListView) -> Result<(), RuntimeError> {
write_context(
stdout,
@@ -874,6 +958,34 @@ fn render_table(stdout: &mut dyn Write, table: &Table) -> Result<(), RuntimeErro
Ok(())
}
+fn relay_count_text(count: usize) -> String {
+ if count == 1 {
+ "1 relay configured".to_owned()
+ } else {
+ format!("{count} relays configured")
+ }
+}
+
+fn format_price(amount: f64, currency: &str, per_amount: u32, per_unit: &str) -> String {
+ format!(
+ "{} {currency}/{} {per_unit}",
+ trim_decimal(amount),
+ per_amount
+ )
+}
+
+fn format_available(amount: i64, unit: &str) -> String {
+ format!("{amount} {unit}")
+}
+
+fn trim_decimal(value: f64) -> String {
+ let formatted = format!("{value:.2}");
+ formatted
+ .trim_end_matches('0')
+ .trim_end_matches('.')
+ .to_owned()
+}
+
struct Table {
headers: &'static [&'static str],
rows: Vec<Vec<String>>,
@@ -887,6 +999,7 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::AccountWhoami(_) => "account whoami",
CommandView::ConfigShow(_) => "config show",
CommandView::Doctor(_) => "doctor",
+ CommandView::Find(_) => "find",
CommandView::LocalBackup(_) => "local backup",
CommandView::LocalExport(_) => "local export",
CommandView::LocalInit(_) => "local init",
diff --git a/src/runtime/find.rs b/src/runtime/find.rs
@@ -0,0 +1,162 @@
+use radroots_sql_core::{SqlExecutor, SqliteExecutor, utils};
+use serde::Deserialize;
+use serde_json::Value;
+
+use crate::cli::FindArgs;
+use crate::domain::runtime::{
+ FindPriceView, FindQuantityView, FindResultProvenanceView, FindResultView, FindView,
+ SyncFreshnessView,
+};
+use crate::runtime::RuntimeError;
+use crate::runtime::config::RuntimeConfig;
+use crate::runtime::sync::freshness_from_executor;
+
+const FIND_SOURCE: &str = "local replica · local first";
+
+#[derive(Debug, Clone, Deserialize)]
+struct FindRow {
+ id: String,
+ key: String,
+ category: String,
+ title: String,
+ summary: String,
+ qty_amt: i64,
+ qty_unit: String,
+ qty_label: Option<String>,
+ qty_avail: Option<i64>,
+ price_amt: f64,
+ price_currency: String,
+ price_qty_amt: u32,
+ price_qty_unit: String,
+ location_primary: Option<String>,
+}
+
+pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<FindView, RuntimeError> {
+ let query = args.query.join(" ");
+ if !config.local.replica_db_path.exists() {
+ return Ok(FindView {
+ state: "unconfigured".to_owned(),
+ source: FIND_SOURCE.to_owned(),
+ query,
+ count: 0,
+ relay_count: config.relay.urls.len(),
+ replica_db: config.local.replica_db_path.display().to_string(),
+ freshness: SyncFreshnessView {
+ state: "never".to_owned(),
+ display: "never synced".to_owned(),
+ age_seconds: None,
+ last_event_at: None,
+ },
+ results: Vec::new(),
+ reason: Some("local replica database is not initialized".to_owned()),
+ actions: vec!["radroots local init".to_owned()],
+ });
+ }
+
+ let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
+ let freshness = freshness_from_executor(&executor)?;
+ let rows = query_rows(&executor, &args.query)?;
+ let relay_count = config.relay.urls.len();
+ let result_provenance = FindResultProvenanceView {
+ origin: "local_replica.trade_product".to_owned(),
+ freshness: freshness.display.clone(),
+ relay_count,
+ };
+ let results = rows
+ .into_iter()
+ .map(|row| FindResultView {
+ id: row.id,
+ product_key: row.key,
+ title: row.title,
+ category: row.category,
+ summary: non_empty(row.summary),
+ location_primary: row.location_primary.and_then(non_empty),
+ available: FindQuantityView {
+ total_amount: row.qty_amt,
+ total_unit: row.qty_unit,
+ label: row.qty_label.and_then(non_empty),
+ available_amount: row.qty_avail,
+ },
+ price: FindPriceView {
+ amount: row.price_amt,
+ currency: row.price_currency,
+ per_amount: row.price_qty_amt,
+ per_unit: row.price_qty_unit,
+ },
+ provenance: result_provenance.clone(),
+ })
+ .collect::<Vec<_>>();
+
+ let (state, reason, actions) = if results.is_empty() {
+ let actions = if freshness.state == "never" {
+ vec!["radroots sync status".to_owned()]
+ } else {
+ Vec::new()
+ };
+ (
+ "empty".to_owned(),
+ Some(format!("no local market results matched `{query}`")),
+ actions,
+ )
+ } else {
+ ("ready".to_owned(), None, Vec::new())
+ };
+
+ Ok(FindView {
+ state,
+ source: FIND_SOURCE.to_owned(),
+ query,
+ count: results.len(),
+ relay_count,
+ replica_db: config.local.replica_db_path.display().to_string(),
+ freshness,
+ results,
+ reason,
+ actions,
+ })
+}
+
+fn query_rows(
+ executor: &SqliteExecutor,
+ query_terms: &[String],
+) -> Result<Vec<FindRow>, RuntimeError> {
+ 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 raw = executor.query_raw(&sql, ¶ms_json)?;
+ serde_json::from_str(&raw).map_err(RuntimeError::from)
+}
+
+fn non_empty(value: String) -> Option<String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+}
diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs
@@ -1,5 +1,6 @@
pub mod accounts;
pub mod config;
+pub mod find;
pub mod local;
pub mod logging;
pub mod myc;
diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs
@@ -212,7 +212,9 @@ fn inspect_sync(config: &RuntimeConfig) -> Result<SyncSnapshot, RuntimeError> {
})
}
-fn freshness_from_executor(executor: &SqliteExecutor) -> Result<SyncFreshnessView, RuntimeError> {
+pub(crate) fn freshness_from_executor(
+ executor: &SqliteExecutor,
+) -> Result<SyncFreshnessView, RuntimeError> {
let raw = executor.query_raw(
"SELECT MAX(last_created_at) AS last_created_at FROM nostr_event_state WHERE last_created_at IS NOT NULL",
"[]",
diff --git a/tests/find.rs b/tests/find.rs
@@ -0,0 +1,251 @@
+use std::path::Path;
+use std::process::Command;
+
+use assert_cmd::prelude::*;
+use radroots_sql_core::{SqlExecutor, SqliteExecutor};
+use serde_json::{Value, json};
+use tempfile::tempdir;
+
+fn cli_command_in(workdir: &Path) -> Command {
+ let mut command = Command::cargo_bin("radroots").expect("binary");
+ command.current_dir(workdir);
+ command.env("HOME", workdir.join("home"));
+ for key in [
+ "RADROOTS_ENV_FILE",
+ "RADROOTS_OUTPUT",
+ "RADROOTS_CLI_LOGGING_FILTER",
+ "RADROOTS_CLI_LOGGING_OUTPUT_DIR",
+ "RADROOTS_CLI_LOGGING_STDOUT",
+ "RADROOTS_LOG_FILTER",
+ "RADROOTS_LOG_DIR",
+ "RADROOTS_LOG_STDOUT",
+ "RADROOTS_ACCOUNT",
+ "RADROOTS_IDENTITY_PATH",
+ "RADROOTS_SIGNER",
+ "RADROOTS_RELAYS",
+ "RADROOTS_MYC_EXECUTABLE",
+ ] {
+ command.env_remove(key);
+ }
+ command
+}
+
+#[test]
+fn find_reports_unconfigured_when_local_replica_is_missing() {
+ let dir = tempdir().expect("tempdir");
+ let output = cli_command_in(dir.path())
+ .args(["--json", "find", "eggs"])
+ .output()
+ .expect("run find");
+
+ assert_eq!(output.status.code(), Some(3));
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "unconfigured");
+ assert_eq!(json["actions"][0], "radroots local init");
+}
+
+#[test]
+fn find_returns_json_and_ndjson_from_local_market_rows() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ seed_trade_product(
+ dir.path(),
+ "00000000-0000-0000-0000-000000000101",
+ "heirloom-tomato",
+ "produce",
+ "Heirloom Tomato",
+ "Bright red slicing tomatoes",
+ 18,
+ 12,
+ Some("Asheville"),
+ );
+ seed_trade_product(
+ dir.path(),
+ "00000000-0000-0000-0000-000000000102",
+ "tomato-sauce",
+ "prepared",
+ "Tomato Sauce",
+ "Slow cooked tomato sauce",
+ 8,
+ 6,
+ Some("Black Mountain"),
+ );
+
+ let json_output = cli_command_in(dir.path())
+ .args(["--json", "find", "tomato"])
+ .output()
+ .expect("run json find");
+ assert!(json_output.status.success());
+ let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "ready");
+ assert_eq!(json["count"], 2);
+ assert_eq!(
+ json["results"][0]["provenance"]["origin"],
+ "local_replica.trade_product"
+ );
+ assert_eq!(json["results"][0]["location_primary"], "Asheville");
+
+ let ndjson_output = cli_command_in(dir.path())
+ .args(["--ndjson", "find", "tomato"])
+ .output()
+ .expect("run ndjson find");
+ assert!(ndjson_output.status.success());
+ let stdout = String::from_utf8(ndjson_output.stdout).expect("utf8 stdout");
+ let lines = stdout.lines().collect::<Vec<_>>();
+ assert_eq!(lines.len(), 2);
+ assert!(lines[0].contains("\"title\":\"Heirloom Tomato\""));
+ assert!(lines[1].contains("\"title\":\"Tomato Sauce\""));
+}
+
+#[test]
+fn find_human_output_uses_market_table_and_provenance_footer() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ seed_trade_product(
+ dir.path(),
+ "00000000-0000-0000-0000-000000000103",
+ "fresh-eggs",
+ "protein",
+ "Fresh Eggs",
+ "Pasture-raised eggs",
+ 36,
+ 24,
+ Some("Marshall"),
+ );
+
+ let output = cli_command_in(dir.path())
+ .args(["find", "eggs"])
+ .output()
+ .expect("run human find");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("market · local first · 1 result"));
+ assert!(stdout.contains("product"));
+ assert!(stdout.contains("Fresh Eggs"));
+ assert!(stdout.contains("provenance: local replica"));
+}
+
+#[test]
+fn find_reports_empty_results_without_failing() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ let output = cli_command_in(dir.path())
+ .args(["--json", "find", "saffron"])
+ .output()
+ .expect("run empty find");
+
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "empty");
+ assert_eq!(json["count"], 0);
+ assert!(
+ json["reason"]
+ .as_str()
+ .is_some_and(|reason| reason.contains("no local market results matched"))
+ );
+}
+
+fn seed_trade_product(
+ workdir: &Path,
+ product_id: &str,
+ key: &str,
+ category: &str,
+ title: &str,
+ summary: &str,
+ qty_amt: i64,
+ qty_avail: i64,
+ location_label: Option<&str>,
+) {
+ let replica_db = workdir
+ .join("home")
+ .join(".local/share/radroots/replica/replica.sqlite");
+ let executor = SqliteExecutor::open(&replica_db).expect("open replica db");
+ let now = "2026-04-07T00:00:00.000Z";
+ executor
+ .exec(
+ "INSERT INTO trade_product (id, created_at, updated_at, key, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
+ json!([
+ product_id,
+ now,
+ now,
+ key,
+ category,
+ title,
+ summary,
+ "fresh",
+ "lot-a",
+ "standard",
+ 2026,
+ qty_amt,
+ "kg",
+ "kg",
+ qty_avail,
+ 12.5,
+ "USD",
+ 1,
+ "kg",
+ Value::Null
+ ])
+ .to_string()
+ .as_str(),
+ )
+ .expect("insert trade product");
+
+ if let Some(location_label) = location_label {
+ let location_id = format!("11111111-1111-1111-1111-{}", &product_id[24..]);
+ executor
+ .exec(
+ "INSERT INTO gcs_location (id, created_at, updated_at, d_tag, lat, lng, geohash, point, polygon, accuracy, altitude, tag_0, label, area, elevation, soil, climate, gc_id, gc_name, gc_admin1_id, gc_admin1_name, gc_country_id, gc_country_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
+ json!([
+ location_id,
+ now,
+ now,
+ format!("location-{product_id}"),
+ 35.0,
+ -82.0,
+ "dnrj",
+ "POINT(-82 35)",
+ "POLYGON EMPTY",
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ location_label,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ location_label,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ "USA"
+ ])
+ .to_string()
+ .as_str(),
+ )
+ .expect("insert gcs location");
+ executor
+ .exec(
+ "INSERT INTO trade_product_location (tb_tp, tb_gl) VALUES (?, ?);",
+ json!([product_id, location_id]).to_string().as_str(),
+ )
+ .expect("insert trade product location");
+ }
+}