diff --git a/common/proto/analytics.proto b/common/proto/analytics.proto index aabd4389..2e890b71 100644 --- a/common/proto/analytics.proto +++ b/common/proto/analytics.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package komp_ac.analytics; +import "table_definition.proto"; + import "google/protobuf/struct.proto"; // Runs read-only analytical SQL over runtime-defined profile tables. @@ -37,7 +39,7 @@ message AnalyticsCatalogColumn { string name = 1; string field_type = 2; bool is_system = 3; - string rounding = 4; + komp_ac.table_definition.MoneyRounding rounding = 4; // Canonical ISO-4217 code for MONEY; empty for every other type. string currency = 5; } diff --git a/common/proto/document_data.proto b/common/proto/document_data.proto index 5f6ee18b..b184c24b 100644 --- a/common/proto/document_data.proto +++ b/common/proto/document_data.proto @@ -78,13 +78,19 @@ message GetDocumentsRequest { optional string source_table_name = 5; } +enum DocumentDataChangeKind { + DOCUMENT_DATA_CHANGE_KIND_UNSPECIFIED = 0; + DOCUMENT_DATA_CHANGE_KIND_GENERATED = 1; + DOCUMENT_DATA_CHANGE_KIND_USER_ADJUSTMENT = 2; +} + message Document { int64 snapshot_id = 1; int64 document_id = 2; int64 schema_id = 3; repeated SourceRecord source_records = 4; string data_json = 5; - string change_kind = 6; + DocumentDataChangeKind change_kind = 6; int32 version_number = 7; string created_at = 8; string updated_at = 9; diff --git a/common/proto/exchange_rates.proto b/common/proto/exchange_rates.proto index 3234b96e..7d21fdec 100644 --- a/common/proto/exchange_rates.proto +++ b/common/proto/exchange_rates.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package komp_ac.exchange_rates; +import "table_definition.proto"; + import "common.proto"; // Selects WHICH DATE supplies the rate; this is independent of where the rate @@ -106,7 +108,7 @@ message PreviewDirectConversionResponse { string accounting_currency = 4; string conversion_basis_date = 5; string determination_method = 6; - string rounding_method = 7; + komp_ac.table_definition.MoneyRounding rounding_method = 7; optional string rate_date = 8; optional string accounting_units = 9; optional string foreign_currency = 10; @@ -149,7 +151,7 @@ message ConversionEvidence { string conversion_context = 10; string conversion_basis_date = 11; string determination_method = 12; - string rounding_method = 13; + komp_ac.table_definition.MoneyRounding rounding_method = 13; optional string rate_date = 14; string source_id = 15; optional int64 official_observation_id = 16; diff --git a/common/src/money.rs b/common/src/money.rs index 944931cf..f2b92c7e 100644 --- a/common/src/money.rs +++ b/common/src/money.rs @@ -8,6 +8,49 @@ //! would reject, and each side renders the shared error as its own error type. pub use rusty_money::iso; +pub use crate::proto::komp_ac::table_definition::MoneyRounding; + +impl MoneyRounding { + pub fn as_storage_str(self) -> &'static str { + match self { + Self::None => "none", + Self::HalfUp => "half_up", + } + } +} + +impl std::str::FromStr for MoneyRounding { + type Err = serde::de::value::Error; + + fn from_str(value: &str) -> Result { + match value { + "none" => Ok(Self::None), + "half_up" => Ok(Self::HalfUp), + _ => Err(serde::de::Error::custom(format!("Invalid money rounding: {value}"))), + } + } +} + +impl TryFrom for MoneyRounding { + type Error = serde::de::value::Error; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +pub mod rounding_serde { + use super::MoneyRounding; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(value: &MoneyRounding, serializer: S) -> Result { + serializer.serialize_str(value.as_storage_str()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + String::deserialize(deserializer)?.parse().map_err(serde::de::Error::custom) + } +} /// Resolves a canonical uppercase ISO-4217 alphabetic code to its currency. /// @@ -40,6 +83,34 @@ pub fn iso_currency_codes() -> Vec<&'static str> { mod tests { use super::*; + #[test] + fn rounding_storage_and_json_boundaries_are_strict() { + #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] + struct StoredPolicy { + #[serde(with = "super::rounding_serde")] + rounding: MoneyRounding, + } + + for (text, policy) in [("none", MoneyRounding::None), ("half_up", MoneyRounding::HalfUp)] { + assert_eq!(text.parse::().unwrap(), policy); + assert_eq!(MoneyRounding::try_from(text.to_string()).unwrap(), policy); + assert_eq!(policy.as_storage_str(), text); + let stored = StoredPolicy { rounding: policy }; + let json = serde_json::json!({ "rounding": text }); + assert_eq!(serde_json::to_value(&stored).unwrap(), json); + assert_eq!(serde_json::from_value::(json).unwrap(), stored); + } + for text in ["", "NONE", "HalfUp", "half-up", " half_up", "half_up ", "unknown"] { + assert!(text.parse::().is_err()); + assert!(serde_json::from_value::(serde_json::json!({ "rounding": text })).is_err()); + } + for value in [serde_json::Value::Null, serde_json::json!(0), serde_json::json!(1)] { + assert!(serde_json::from_value::(serde_json::json!({ "rounding": value })).is_err()); + } + assert!(MoneyRounding::try_from(-1).is_err()); + assert!(MoneyRounding::try_from(2).is_err()); + } + #[test] fn canonical_codes_resolve_to_their_currency() { assert_eq!(require_iso_currency_code("EUR").unwrap(), iso::EUR); diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 091b44a1..9b3568c2 100644 Binary files a/common/src/proto/descriptor.bin and b/common/src/proto/descriptor.bin differ diff --git a/common/src/proto/komp_ac.analytics.rs b/common/src/proto/komp_ac.analytics.rs index 32c910d7..989ebb7b 100644 --- a/common/src/proto/komp_ac.analytics.rs +++ b/common/src/proto/komp_ac.analytics.rs @@ -36,8 +36,8 @@ pub struct AnalyticsCatalogColumn { pub field_type: ::prost::alloc::string::String, #[prost(bool, tag = "3")] pub is_system: bool, - #[prost(string, tag = "4")] - pub rounding: ::prost::alloc::string::String, + #[prost(enumeration = "super::table_definition::MoneyRounding", tag = "4")] + pub rounding: i32, /// Canonical ISO-4217 code for MONEY; empty for every other type. #[prost(string, tag = "5")] pub currency: ::prost::alloc::string::String, diff --git a/common/src/proto/komp_ac.document_data.rs b/common/src/proto/komp_ac.document_data.rs index 2d8e2106..18ff0ed4 100644 --- a/common/src/proto/komp_ac.document_data.rs +++ b/common/src/proto/komp_ac.document_data.rs @@ -120,8 +120,8 @@ pub struct Document { pub source_records: ::prost::alloc::vec::Vec, #[prost(string, tag = "5")] pub data_json: ::prost::alloc::string::String, - #[prost(string, tag = "6")] - pub change_kind: ::prost::alloc::string::String, + #[prost(enumeration = "DocumentDataChangeKind", tag = "6")] + pub change_kind: i32, #[prost(int32, tag = "7")] pub version_number: i32, #[prost(string, tag = "8")] @@ -221,6 +221,36 @@ pub struct GetTypstTemplateVersionResponse { #[prost(message, optional, tag = "1")] pub template_version: ::core::option::Option, } +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum DocumentDataChangeKind { + Unspecified = 0, + Generated = 1, + UserAdjustment = 2, +} +impl DocumentDataChangeKind { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "DOCUMENT_DATA_CHANGE_KIND_UNSPECIFIED", + Self::Generated => "DOCUMENT_DATA_CHANGE_KIND_GENERATED", + Self::UserAdjustment => "DOCUMENT_DATA_CHANGE_KIND_USER_ADJUSTMENT", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "DOCUMENT_DATA_CHANGE_KIND_UNSPECIFIED" => Some(Self::Unspecified), + "DOCUMENT_DATA_CHANGE_KIND_GENERATED" => Some(Self::Generated), + "DOCUMENT_DATA_CHANGE_KIND_USER_ADJUSTMENT" => Some(Self::UserAdjustment), + _ => None, + } + } +} /// Generated client implementations. pub mod document_data_service_client { #![allow( diff --git a/common/src/proto/komp_ac.exchange_rates.rs b/common/src/proto/komp_ac.exchange_rates.rs index a37a3aea..c20acb22 100644 --- a/common/src/proto/komp_ac.exchange_rates.rs +++ b/common/src/proto/komp_ac.exchange_rates.rs @@ -59,8 +59,8 @@ pub struct PreviewDirectConversionResponse { pub conversion_basis_date: ::prost::alloc::string::String, #[prost(string, tag = "6")] pub determination_method: ::prost::alloc::string::String, - #[prost(string, tag = "7")] - pub rounding_method: ::prost::alloc::string::String, + #[prost(enumeration = "super::table_definition::MoneyRounding", tag = "7")] + pub rounding_method: i32, #[prost(string, optional, tag = "8")] pub rate_date: ::core::option::Option<::prost::alloc::string::String>, #[prost(string, optional, tag = "9")] @@ -141,8 +141,8 @@ pub struct ConversionEvidence { pub conversion_basis_date: ::prost::alloc::string::String, #[prost(string, tag = "12")] pub determination_method: ::prost::alloc::string::String, - #[prost(string, tag = "13")] - pub rounding_method: ::prost::alloc::string::String, + #[prost(enumeration = "super::table_definition::MoneyRounding", tag = "13")] + pub rounding_method: i32, #[prost(string, optional, tag = "14")] pub rate_date: ::core::option::Option<::prost::alloc::string::String>, #[prost(string, tag = "15")] diff --git a/server b/server index 6d6f241b..cac5233a 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 6d6f241b0b290e0fed5522be0c61a6d1dd4ff8a5 +Subproject commit cac5233a110d778f5e9467131ffddc02e9e14cb2 diff --git a/web/src/pages/analytics/loader.rs b/web/src/pages/analytics/loader.rs index 695d7b74..6f0f5300 100644 --- a/web/src/pages/analytics/loader.rs +++ b/web/src/pages/analytics/loader.rs @@ -2,6 +2,7 @@ //! responses into the view models the templates render. use axum::http::HeaderMap; +use common::money::MoneyRounding; use crate::{ AppState, @@ -60,7 +61,7 @@ pub(crate) async fn load_catalog( .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner(); - Ok(catalog_view(&catalog)) + catalog_view(&catalog) } pub(crate) async fn run_query( @@ -141,15 +142,15 @@ fn value_output(value: crate::analytics::AnalyticsValue) -> serde_json::Value { } } -fn catalog_view(catalog: &GetAnalyticsCatalogResponse) -> CatalogView { - CatalogView { - tables: catalog.tables.iter().map(catalog_table_view).collect(), - llm_context: build_llm_context(catalog), - } +fn catalog_view(catalog: &GetAnalyticsCatalogResponse) -> Result { + Ok(CatalogView { + tables: catalog.tables.iter().map(catalog_table_view).collect::>()?, + llm_context: build_llm_context(catalog)?, + }) } -fn catalog_table_view(table: &AnalyticsTable) -> CatalogTableView { - CatalogTableView { +fn catalog_table_view(table: &AnalyticsTable) -> Result { + Ok(CatalogTableView { name: table.name.clone(), starter_query: format!("SELECT *\nFROM {}\nLIMIT 100;", quote_identifier(&table.name)), columns: table @@ -160,19 +161,21 @@ fn catalog_table_view(table: &AnalyticsTable) -> CatalogTableView { if column.is_system { details.push_str(", system"); } - if !column.rounding.is_empty() { - details.push_str(&format!(", rounding {}", column.rounding)); + let rounding = MoneyRounding::try_from(column.rounding) + .map_err(|error| LoadError::Backend(error.to_string()))?; + if !column.is_system { + details.push_str(&format!(", rounding {}", rounding.as_storage_str())); } if !column.currency.is_empty() { details.push_str(&format!(", currency {}", column.currency)); } - CatalogColumnView { + Ok::<_, LoadError>(CatalogColumnView { name: column.name.clone(), insert_text: quote_identifier(&column.name), details, - } + }) }) - .collect(), + .collect::>()?, links: table .links .iter() @@ -182,10 +185,10 @@ fn catalog_table_view(table: &AnalyticsTable) -> CatalogTableView { required: link.required, }) .collect(), - } + }) } -fn build_llm_context(catalog: &GetAnalyticsCatalogResponse) -> String { +fn build_llm_context(catalog: &GetAnalyticsCatalogResponse) -> Result { let mut text = format!( "Write one read-only PostgreSQL SELECT query for the komp_ac analytics API.\n\ Profile: {}\n\n\ @@ -212,8 +215,10 @@ AVAILABLE ANALYTICS SCHEMA\n", if column.is_system { text.push_str(" [system]"); } - if !column.rounding.is_empty() { - text.push_str(&format!(" [rounding: {}]", column.rounding)); + let rounding = MoneyRounding::try_from(column.rounding) + .map_err(|error| LoadError::Backend(error.to_string()))?; + if !column.is_system { + text.push_str(&format!(" [rounding: {}]", rounding.as_storage_str())); } if !column.currency.is_empty() { text.push_str(&format!(" [currency: {}]", column.currency)); @@ -232,7 +237,7 @@ AVAILABLE ANALYTICS SCHEMA\n", } } } - text + Ok(text) } fn quote_identifier(identifier: &str) -> String {