web interface for table_definition improved
This commit is contained in:
895
web/src/schema/mod.rs
Normal file
895
web/src/schema/mod.rs
Normal file
@@ -0,0 +1,895 @@
|
||||
//! The column vocabulary every table-definition screen shares.
|
||||
//!
|
||||
//! Two pages describe columns: `add_table` creates a table out of them, and
|
||||
//! `admin/table_definition` appends them to a table that already exists. The
|
||||
//! rules are the same in both — what types exist, what a name may be, when a
|
||||
//! currency is required — so they live here rather than in either page, and
|
||||
//! neither page is allowed its own copy.
|
||||
//!
|
||||
//! Everything above [`proto_columns`] is proto-free. This module is the piece
|
||||
//! of the web UI that would move into a crate shared with `client` and
|
||||
//! `server`; keeping the generated types out of the rules is what makes that
|
||||
//! move a rename rather than a rewrite.
|
||||
|
||||
use crate::definitions::table_definition::{
|
||||
ColumnDefinition as ProtoColumnDefinition, MoneyRounding,
|
||||
};
|
||||
|
||||
/// Column types offered when a table is created. `temporal`, `gtin` and
|
||||
/// `decimal` are pickers of their own: none is a storable type, each resolves
|
||||
/// to a canonical type below once its follow-up fields are filled in.
|
||||
pub(crate) const COLUMN_TYPES: &[&str] = &[
|
||||
"text",
|
||||
"boolean",
|
||||
"money",
|
||||
"accounting",
|
||||
"int",
|
||||
"bigint",
|
||||
"decimal",
|
||||
"temporal",
|
||||
"duration",
|
||||
"period",
|
||||
"phone",
|
||||
"iban",
|
||||
"email_address",
|
||||
"credit_card",
|
||||
"gtin",
|
||||
];
|
||||
|
||||
/// The same list without `accounting`, which the server only accepts while the
|
||||
/// table is being created — an accounting column brings schema-managed
|
||||
/// companions with it, so it cannot be bolted on afterwards.
|
||||
pub(crate) const APPENDABLE_COLUMN_TYPES: &[&str] = &[
|
||||
"text",
|
||||
"boolean",
|
||||
"money",
|
||||
"int",
|
||||
"bigint",
|
||||
"decimal",
|
||||
"temporal",
|
||||
"duration",
|
||||
"period",
|
||||
"phone",
|
||||
"iban",
|
||||
"email_address",
|
||||
"credit_card",
|
||||
"gtin",
|
||||
];
|
||||
|
||||
pub(crate) const TEMPORAL_TYPES: &[&str] = &["date", "time", "instant", "raw_datetime"];
|
||||
pub(crate) const GTIN_TYPES: &[&str] = &["8", "12", "13", "14"];
|
||||
|
||||
/// Every fixed type the server accepts. `decimal(p,s)` is not here because it
|
||||
/// is parameterised; [`validate_field_type`] checks it separately.
|
||||
const CANONICAL_TYPES: &[&str] = &[
|
||||
"text",
|
||||
"boolean",
|
||||
"date",
|
||||
"time",
|
||||
"instant",
|
||||
"raw_datetime",
|
||||
"duration",
|
||||
"period",
|
||||
"phone",
|
||||
"iban",
|
||||
"email_address",
|
||||
"credit_card",
|
||||
"gtin_8",
|
||||
"gtin_12",
|
||||
"gtin_13",
|
||||
"gtin_14",
|
||||
"money",
|
||||
"accounting",
|
||||
"int",
|
||||
"bigint",
|
||||
];
|
||||
|
||||
/// Whether a column of this type declares a currency.
|
||||
///
|
||||
/// Both MONEY and ACCOUNTING do, which is why this is a named predicate rather
|
||||
/// than an inline comparison: written by hand, the ACCOUNTING half is easy to
|
||||
/// forget, and forgetting it is silent — the currency is still stored and sent,
|
||||
/// it just stops being validated or displayed.
|
||||
pub(crate) fn carries_currency(field_type: &str) -> bool {
|
||||
field_type.eq_ignore_ascii_case("money") || field_type.eq_ignore_ascii_case("accounting")
|
||||
}
|
||||
|
||||
/// Types a quantity-ledger column may use.
|
||||
fn quantity_ledger_type_allowed(field_type: &str) -> bool {
|
||||
matches!(field_type, "int" | "bigint" | "money")
|
||||
|| (field_type.starts_with("decimal(") && field_type.ends_with(')'))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum MoneyMode {
|
||||
#[default]
|
||||
Exact,
|
||||
Rounded,
|
||||
}
|
||||
|
||||
impl MoneyMode {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Exact => "exact",
|
||||
Self::Rounded => "half-up",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_input(value: &str) -> Self {
|
||||
if value.trim().eq_ignore_ascii_case("half-up") {
|
||||
Self::Rounded
|
||||
} else {
|
||||
Self::Exact
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ColumnDefinition {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub indexed: bool,
|
||||
pub quantity_ledger: bool,
|
||||
pub money_mode: MoneyMode,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
impl ColumnDefinition {
|
||||
/// The `option` cell of the preview, mirroring the client's preview table.
|
||||
pub(crate) fn option_label(&self) -> String {
|
||||
let has_currency = carries_currency(&self.data_type);
|
||||
match (self.indexed, has_currency) {
|
||||
(true, true) => format!("indexed, {}, {}", self.currency, self.money_mode.label()),
|
||||
(true, false) => "indexed".to_string(),
|
||||
(false, true) => format!("{}, {}", self.currency, self.money_mode.label()),
|
||||
(false, false) => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The column-input panel and the columns it has produced so far.
|
||||
///
|
||||
/// One pending column is described in the inputs; pressing "add" validates it
|
||||
/// and moves it into `added`. Both screens that describe columns embed one of
|
||||
/// these, which is what keeps their rules identical.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ColumnDraft {
|
||||
pub name_input: String,
|
||||
pub type_input: String,
|
||||
pub temporal_type_input: String,
|
||||
pub gtin_type_input: String,
|
||||
pub decimal_precision_input: String,
|
||||
pub decimal_scale_input: String,
|
||||
pub indexing_input: String,
|
||||
pub quantity_ledger_input: String,
|
||||
pub rounding_input: String,
|
||||
pub currency_input: String,
|
||||
|
||||
pub added: Vec<ColumnDefinition>,
|
||||
|
||||
/// False on the append screen: an ACCOUNTING column can only be chosen
|
||||
/// while the table is being created.
|
||||
pub accounting_allowed: bool,
|
||||
}
|
||||
|
||||
impl ColumnDraft {
|
||||
/// A panel for a table that is being created, where every type applies.
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
accounting_allowed: true,
|
||||
..Self::empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel for appending to an existing table.
|
||||
pub(crate) fn for_append() -> Self {
|
||||
Self {
|
||||
accounting_allowed: false,
|
||||
..Self::empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
indexing_input: "no".to_string(),
|
||||
quantity_ledger_input: "no".to_string(),
|
||||
rounding_input: "none".to_string(),
|
||||
currency_input: "EUR".to_string(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The types this panel offers, which is the only place the accounting
|
||||
/// rule shows up in the markup.
|
||||
pub(crate) fn offered_types(&self) -> &'static [&'static str] {
|
||||
if self.accounting_allowed {
|
||||
COLUMN_TYPES
|
||||
} else {
|
||||
APPENDABLE_COLUMN_TYPES
|
||||
}
|
||||
}
|
||||
|
||||
// ---- field visibility (the same rules the TUI canvas applies) --------
|
||||
|
||||
pub(crate) fn pending_carries_currency(&self) -> bool {
|
||||
carries_currency(self.type_input.trim())
|
||||
}
|
||||
|
||||
pub(crate) fn show_temporal_type(&self) -> bool {
|
||||
self.type_input.trim().eq_ignore_ascii_case("temporal")
|
||||
}
|
||||
|
||||
pub(crate) fn show_gtin_type(&self) -> bool {
|
||||
self.type_input.trim().eq_ignore_ascii_case("gtin")
|
||||
}
|
||||
|
||||
pub(crate) fn show_decimal_arguments(&self) -> bool {
|
||||
self.type_input.trim().eq_ignore_ascii_case("decimal")
|
||||
}
|
||||
|
||||
/// Currency and rounding both apply only to a money column.
|
||||
pub(crate) fn show_money_options(&self) -> bool {
|
||||
self.pending_carries_currency()
|
||||
}
|
||||
|
||||
// ---- the pending column ---------------------------------------------
|
||||
|
||||
/// The storable type the pending inputs describe, resolving the `temporal`,
|
||||
/// `gtin` and `decimal` pickers to their canonical form. `None` while the
|
||||
/// choice is still incomplete, `Err` when the follow-up fields are filled
|
||||
/// in but wrong.
|
||||
fn canonical_type_input(&self) -> Result<Option<String>, String> {
|
||||
let column_type = self.type_input.trim().to_ascii_lowercase();
|
||||
match column_type.as_str() {
|
||||
"temporal" => {
|
||||
let temporal_type = self.temporal_type_input.trim().to_ascii_lowercase();
|
||||
Ok(TEMPORAL_TYPES
|
||||
.contains(&temporal_type.as_str())
|
||||
.then_some(temporal_type))
|
||||
}
|
||||
"gtin" => {
|
||||
let gtin_type = self.gtin_type_input.trim();
|
||||
Ok(GTIN_TYPES
|
||||
.contains(>in_type)
|
||||
.then(|| format!("gtin_{gtin_type}")))
|
||||
}
|
||||
"decimal" => {
|
||||
let precision = self.decimal_precision_input.trim();
|
||||
let scale = self.decimal_scale_input.trim();
|
||||
if precision.is_empty() && scale.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
validate_decimal_arguments(precision, scale)?;
|
||||
Ok(Some(format!("decimal({precision},{scale})")))
|
||||
}
|
||||
"" => Ok(None),
|
||||
_ => Ok(Some(column_type)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends the pending column, then clears the input panel.
|
||||
pub(crate) fn add_from_inputs(&mut self) -> Result<String, String> {
|
||||
let Some(column_type) = self.canonical_type_input()? else {
|
||||
return Err("Both a column name and a column type are required.".to_string());
|
||||
};
|
||||
|
||||
if column_type.eq_ignore_ascii_case("accounting") && !self.accounting_allowed {
|
||||
return Err(
|
||||
"An ACCOUNTING column can only be chosen while the table is being created."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// An accounting column is always named `accounting`.
|
||||
let column_name = if column_type.eq_ignore_ascii_case("accounting") {
|
||||
"accounting".to_string()
|
||||
} else {
|
||||
self.name_input.trim().to_string()
|
||||
};
|
||||
|
||||
if column_name.is_empty() {
|
||||
return Err("Both a column name and a column type are required.".to_string());
|
||||
}
|
||||
if let Some(error) = validate_identifier(&column_name, "Column name", true) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = validate_field_type(&column_type) {
|
||||
return Err(error);
|
||||
}
|
||||
if self.added.iter().any(|column| column.name == column_name) {
|
||||
return Err(format!("A column named `{column_name}` already exists."));
|
||||
}
|
||||
|
||||
let quantity_ledger = self.quantity_ledger_input.trim().eq_ignore_ascii_case("yes");
|
||||
if quantity_ledger && !quantity_ledger_type_allowed(&column_type) {
|
||||
return Err(
|
||||
"Quantity-ledger columns must use INT, BIGINT, DECIMAL, or MONEY".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let has_currency = carries_currency(&column_type);
|
||||
let currency = if has_currency {
|
||||
normalize_currency_input(&self.currency_input)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
self.added.push(ColumnDefinition {
|
||||
name: column_name.clone(),
|
||||
data_type: column_type,
|
||||
indexed: self.indexing_input.trim().eq_ignore_ascii_case("yes"),
|
||||
quantity_ledger,
|
||||
money_mode: if has_currency {
|
||||
MoneyMode::from_input(&self.rounding_input)
|
||||
} else {
|
||||
MoneyMode::Exact
|
||||
},
|
||||
currency,
|
||||
});
|
||||
|
||||
self.clear_inputs();
|
||||
Ok(format!("Column `{column_name}` added."))
|
||||
}
|
||||
|
||||
fn clear_inputs(&mut self) {
|
||||
self.name_input.clear();
|
||||
self.type_input.clear();
|
||||
self.temporal_type_input.clear();
|
||||
self.gtin_type_input.clear();
|
||||
self.decimal_precision_input.clear();
|
||||
self.decimal_scale_input.clear();
|
||||
self.indexing_input = "no".to_string();
|
||||
self.quantity_ledger_input = "no".to_string();
|
||||
self.rounding_input = "none".to_string();
|
||||
self.currency_input = "EUR".to_string();
|
||||
}
|
||||
|
||||
// ---- the columns added so far ----------------------------------------
|
||||
|
||||
/// Removes one column. The caller is what knows whether anything else
|
||||
/// referenced it — `add_table` drops it from the display columns too.
|
||||
pub(crate) fn remove(&mut self, index: usize) -> Result<ColumnDefinition, String> {
|
||||
if index >= self.added.len() {
|
||||
return Err("That column no longer exists.".to_string());
|
||||
}
|
||||
Ok(self.added.remove(index))
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_indexed(&mut self, index: usize) {
|
||||
if let Some(column) = self.added.get_mut(index) {
|
||||
column.indexed = !column.indexed;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn selected_index_names(&self) -> Vec<String> {
|
||||
self.added
|
||||
.iter()
|
||||
.filter(|column| column.indexed)
|
||||
.map(|column| column.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.added.is_empty()
|
||||
}
|
||||
|
||||
/// Re-checks the columns themselves.
|
||||
///
|
||||
/// [`Self::add_from_inputs`] already applies these, but a draft rebuilt
|
||||
/// from a posted form has not been through that path, so this is what a
|
||||
/// tampered-with or truncated post is held to.
|
||||
pub(crate) fn validate(&self) -> Result<(), String> {
|
||||
for column in &self.added {
|
||||
if let Some(error) = validate_identifier(&column.name, "Column name", true) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = validate_field_type(&column.data_type) {
|
||||
return Err(format!("Column `{}`: {error}", column.name));
|
||||
}
|
||||
if !self.accounting_allowed && column.data_type.eq_ignore_ascii_case("accounting") {
|
||||
return Err(
|
||||
"An ACCOUNTING column can only be chosen while the table is being created."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
// The same rule the server enforces: required for a money column,
|
||||
// forbidden for every other type.
|
||||
if carries_currency(&column.data_type) {
|
||||
if let Err(error) = normalize_currency_input(&column.currency) {
|
||||
return Err(format!("Column `{}`: {error}", column.name));
|
||||
}
|
||||
} else if !column.currency.trim().is_empty() {
|
||||
return Err(format!(
|
||||
"Column `{}`: only MONEY and ACCOUNTING columns may declare a currency.",
|
||||
column.name
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_currency_input(value: &str) -> Result<String, String> {
|
||||
let currency = value.trim().to_ascii_uppercase();
|
||||
if rusty_money::iso::find(¤cy).is_none() {
|
||||
return Err("Currency must be a three-letter ISO-4217 code".to_string());
|
||||
}
|
||||
Ok(currency)
|
||||
}
|
||||
|
||||
/// PostgreSQL identifier rules, plus the names this schema reserves.
|
||||
pub(crate) fn validate_identifier(
|
||||
value: &str,
|
||||
label: &str,
|
||||
reject_table_reserved: bool,
|
||||
) -> Option<String> {
|
||||
if value.is_empty() {
|
||||
return Some(format!("{label} cannot be empty."));
|
||||
}
|
||||
if value != value.trim() {
|
||||
return Some(format!("{label} cannot start or end with a space."));
|
||||
}
|
||||
if value.starts_with('_') {
|
||||
return Some(format!("{label} cannot start with an underscore."));
|
||||
}
|
||||
if value.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
return Some(format!("{label} cannot start with a number."));
|
||||
}
|
||||
if value.len() > 63 {
|
||||
return Some(format!("{label} cannot be longer than 63 characters."));
|
||||
}
|
||||
if value
|
||||
.chars()
|
||||
.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_')
|
||||
{
|
||||
return Some(format!(
|
||||
"{label} may only use lowercase letters, digits and underscores."
|
||||
));
|
||||
}
|
||||
if reject_table_reserved
|
||||
&& (value == "id"
|
||||
|| value == "deleted"
|
||||
|| value == "created_at"
|
||||
|| value == "row_revision"
|
||||
|| value.ends_with("_id"))
|
||||
{
|
||||
return Some(format!("{label} uses a reserved name."));
|
||||
}
|
||||
if !reject_table_reserved
|
||||
&& (value == "public" || value == "information_schema" || value.starts_with("pg_"))
|
||||
{
|
||||
return Some("That profile name is reserved by PostgreSQL.".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn validate_field_type(field_type: &str) -> Option<String> {
|
||||
let field_type = field_type.to_lowercase();
|
||||
if CANONICAL_TYPES.contains(&field_type.as_str()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(arguments) = field_type
|
||||
.strip_prefix("decimal(")
|
||||
.and_then(|rest| rest.strip_suffix(')'))
|
||||
{
|
||||
let Some((precision, scale)) = arguments.split_once(',') else {
|
||||
return Some("`decimal` needs both a precision and a scale.".to_string());
|
||||
};
|
||||
return validate_decimal_arguments(precision.trim(), scale.trim()).err();
|
||||
}
|
||||
Some(format!("`{field_type}` is not a valid field type."))
|
||||
}
|
||||
|
||||
/// The precision and scale rules the server applies to `decimal(p,s)`:
|
||||
/// whole numbers, no sign, no leading zeros, `1 <= p` and `s <= p`.
|
||||
fn validate_decimal_arguments(precision: &str, scale: &str) -> Result<(), String> {
|
||||
let precision = validate_decimal_number("Precision", precision)?;
|
||||
let scale = validate_decimal_number("Scale", scale)?;
|
||||
if precision < 1 {
|
||||
return Err("Precision must be at least 1.".to_string());
|
||||
}
|
||||
if scale > precision {
|
||||
return Err("Scale cannot be greater than precision.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_decimal_number(label: &str, value: &str) -> Result<u32, String> {
|
||||
if value.is_empty() {
|
||||
return Err(format!("{label} is required for a decimal column."));
|
||||
}
|
||||
if value.starts_with('+') || value.starts_with('-') {
|
||||
return Err(format!("{label} cannot carry a sign."));
|
||||
}
|
||||
if value.contains('.') {
|
||||
return Err(format!("{label} must be a whole number."));
|
||||
}
|
||||
if value.len() > 1 && value.starts_with('0') {
|
||||
return Err(format!("{label} cannot have leading zeros."));
|
||||
}
|
||||
value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| format!("{label} must be a whole number."))
|
||||
}
|
||||
|
||||
/// The seam where the rules above meet the generated request types.
|
||||
pub(crate) fn proto_columns(columns: &[ColumnDefinition]) -> Vec<ProtoColumnDefinition> {
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| ProtoColumnDefinition {
|
||||
name: column.name.clone(),
|
||||
field_type: column.data_type.clone(),
|
||||
rounding: match column.money_mode {
|
||||
MoneyMode::Rounded => MoneyRounding::HalfUp.into(),
|
||||
MoneyMode::Exact => MoneyRounding::None.into(),
|
||||
},
|
||||
quantity_ledger: column.quantity_ledger,
|
||||
currency: column.currency.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// ISO-4217 codes offered as currency suggestions, matching the client's list.
|
||||
pub(crate) const CURRENCY_CODES: &[&str] = &[
|
||||
"EUR", "CZK", "USD", "AED", "AFN", "ALL", "AMD", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM",
|
||||
"BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN",
|
||||
"BZD", "CAD", "CDF", "CHF", "CLF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "DJF", "DKK",
|
||||
"DOP", "DZD", "EGP", "ERN", "ETB", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GNF",
|
||||
"GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK", "JMD",
|
||||
"JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP",
|
||||
"LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR",
|
||||
"MVR", "MWK", "MXN", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB",
|
||||
"PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", "RUB", "RWF", "SAR", "SBD",
|
||||
"SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL",
|
||||
"THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "UYU", "UYW",
|
||||
"UZS", "VES", "VED", "VND", "VUV", "WST", "XAF", "XAG", "XAU", "XCD", "XDR", "XOF", "XPD",
|
||||
"XPF", "XPT", "YER", "ZAR", "ZMW", "ZWG", "ANG", "CUC", "HRK", "SKK", "SLL", "STD", "ZMK",
|
||||
"ZWL",
|
||||
];
|
||||
|
||||
/// The wire format of the column panel.
|
||||
///
|
||||
/// HTTP is stateless, so the whole panel travels with every interaction: the
|
||||
/// pending inputs as scalars, and each already-added column as a set of
|
||||
/// parallel repeated fields. `serde_html_form` decodes the repeats into
|
||||
/// `Vec`s, which [`Self::to_draft`] zips back into a [`ColumnDraft`].
|
||||
///
|
||||
/// `add_table` posts these same field names as part of its larger form; see
|
||||
/// [`columns_from_rows`], which is what both paths rebuild the list with.
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct ColumnForm {
|
||||
/// Which panel button was pressed.
|
||||
#[serde(default)]
|
||||
pub action: String,
|
||||
/// Row the action applies to, for the per-row buttons.
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
|
||||
#[serde(default)]
|
||||
pub column_name_input: String,
|
||||
#[serde(default)]
|
||||
pub column_type_input: String,
|
||||
#[serde(default)]
|
||||
pub temporal_type_input: String,
|
||||
#[serde(default)]
|
||||
pub gtin_type_input: String,
|
||||
#[serde(default)]
|
||||
pub decimal_precision_input: String,
|
||||
#[serde(default)]
|
||||
pub decimal_scale_input: String,
|
||||
#[serde(default)]
|
||||
pub column_indexing_input: String,
|
||||
#[serde(default)]
|
||||
pub column_quantity_ledger_input: String,
|
||||
#[serde(default)]
|
||||
pub column_rounding_input: String,
|
||||
#[serde(default)]
|
||||
pub column_currency_input: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub column_names: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_types: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_indexed: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_quantity_ledger: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_rounding: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_currencies: Vec<String>,
|
||||
}
|
||||
|
||||
impl ColumnForm {
|
||||
pub(crate) fn to_draft(&self, accounting_allowed: bool) -> ColumnDraft {
|
||||
ColumnDraft {
|
||||
name_input: self.column_name_input.clone(),
|
||||
type_input: self.column_type_input.clone(),
|
||||
temporal_type_input: self.temporal_type_input.clone(),
|
||||
gtin_type_input: self.gtin_type_input.clone(),
|
||||
decimal_precision_input: self.decimal_precision_input.clone(),
|
||||
decimal_scale_input: self.decimal_scale_input.clone(),
|
||||
indexing_input: self.column_indexing_input.clone(),
|
||||
quantity_ledger_input: self.column_quantity_ledger_input.clone(),
|
||||
rounding_input: self.column_rounding_input.clone(),
|
||||
currency_input: self.column_currency_input.clone(),
|
||||
added: columns_from_rows(
|
||||
&self.column_names,
|
||||
&self.column_types,
|
||||
&self.column_indexed,
|
||||
&self.column_quantity_ledger,
|
||||
&self.column_rounding,
|
||||
&self.column_currencies,
|
||||
),
|
||||
accounting_allowed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_yes(value: &str) -> bool {
|
||||
value.trim().eq_ignore_ascii_case("yes")
|
||||
}
|
||||
|
||||
/// Zips the posted column vectors back into column definitions.
|
||||
///
|
||||
/// The vectors are parallel, so a short one — a truncated or tampered-with
|
||||
/// post — simply limits how many columns are reconstructed rather than
|
||||
/// mis-pairing them.
|
||||
pub(crate) fn columns_from_rows(
|
||||
names: &[String],
|
||||
types: &[String],
|
||||
indexed: &[String],
|
||||
quantity_ledger: &[String],
|
||||
rounding: &[String],
|
||||
currencies: &[String],
|
||||
) -> Vec<ColumnDefinition> {
|
||||
let count = [
|
||||
names.len(),
|
||||
types.len(),
|
||||
indexed.len(),
|
||||
quantity_ledger.len(),
|
||||
rounding.len(),
|
||||
currencies.len(),
|
||||
]
|
||||
.into_iter()
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
|
||||
(0..count)
|
||||
.map(|index| ColumnDefinition {
|
||||
name: names[index].clone(),
|
||||
data_type: types[index].clone(),
|
||||
indexed: is_yes(&indexed[index]),
|
||||
quantity_ledger: is_yes(&quantity_ledger[index]),
|
||||
money_mode: MoneyMode::from_input(&rounding[index]),
|
||||
currency: currencies[index].clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn temporal_gtin_and_decimal_pickers_resolve_to_canonical_types() {
|
||||
let mut draft = ColumnDraft::new();
|
||||
draft.name_input = "occurred_at".to_string();
|
||||
draft.type_input = "temporal".to_string();
|
||||
|
||||
// Incomplete while no subtype is chosen.
|
||||
assert_eq!(draft.canonical_type_input().unwrap(), None);
|
||||
assert!(draft.show_temporal_type());
|
||||
|
||||
draft.temporal_type_input = "raw_datetime".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
assert_eq!(draft.added[0].data_type, "raw_datetime");
|
||||
// Inputs are cleared for the next column.
|
||||
assert!(draft.temporal_type_input.is_empty());
|
||||
|
||||
draft.name_input = "barcode".to_string();
|
||||
draft.type_input = "gtin".to_string();
|
||||
draft.gtin_type_input = "13".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
assert_eq!(draft.added[1].data_type, "gtin_13");
|
||||
|
||||
draft.name_input = "weight".to_string();
|
||||
draft.type_input = "decimal".to_string();
|
||||
assert_eq!(draft.canonical_type_input().unwrap(), None);
|
||||
assert!(draft.show_decimal_arguments());
|
||||
draft.decimal_precision_input = "12".to_string();
|
||||
draft.decimal_scale_input = "3".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
assert_eq!(draft.added[2].data_type, "decimal(12,3)");
|
||||
}
|
||||
|
||||
/// The precision and scale rules are the server's, so a draft that would
|
||||
/// be refused there is refused here first.
|
||||
#[test]
|
||||
fn decimal_arguments_follow_the_servers_rules() {
|
||||
let mut draft = ColumnDraft::new();
|
||||
draft.name_input = "weight".to_string();
|
||||
draft.type_input = "decimal".to_string();
|
||||
|
||||
for (precision, scale) in [("0", "0"), ("3", "5"), ("-2", "1"), ("08", "2"), ("4.5", "1")] {
|
||||
draft.decimal_precision_input = precision.to_string();
|
||||
draft.decimal_scale_input = scale.to_string();
|
||||
assert!(
|
||||
draft.add_from_inputs().is_err(),
|
||||
"decimal({precision},{scale}) should be refused"
|
||||
);
|
||||
}
|
||||
|
||||
draft.decimal_precision_input = "10".to_string();
|
||||
draft.decimal_scale_input = "0".to_string();
|
||||
assert!(draft.add_from_inputs().is_ok());
|
||||
}
|
||||
|
||||
/// `duration` and `period` are storable types on their own — the picker
|
||||
/// offers them and nothing has to be resolved.
|
||||
#[test]
|
||||
fn duration_and_period_are_columns_of_their_own() {
|
||||
for field_type in ["duration", "period"] {
|
||||
let mut draft = ColumnDraft::new();
|
||||
draft.name_input = "billing_span".to_string();
|
||||
draft.type_input = field_type.to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
assert_eq!(draft.added[0].data_type, field_type);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_append_panel_refuses_an_accounting_column() {
|
||||
let mut draft = ColumnDraft::for_append();
|
||||
draft.type_input = "accounting".to_string();
|
||||
assert!(draft.add_from_inputs().is_err());
|
||||
assert!(!draft.offered_types().contains(&"accounting"));
|
||||
|
||||
// And again for a draft rebuilt from a posted form, which never went
|
||||
// through `add_from_inputs`.
|
||||
draft.added.push(ColumnDefinition {
|
||||
name: "accounting".to_string(),
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
assert!(draft.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_identifiers_and_types_are_refused_at_add_time() {
|
||||
let mut draft = ColumnDraft::new();
|
||||
draft.type_input = "text".to_string();
|
||||
|
||||
draft.name_input = "Total".to_string();
|
||||
assert!(draft.add_from_inputs().is_err());
|
||||
draft.name_input = "customer_id".to_string();
|
||||
assert!(draft.add_from_inputs().is_err());
|
||||
draft.name_input = "created_at".to_string();
|
||||
assert!(draft.add_from_inputs().is_err());
|
||||
|
||||
draft.name_input = "total".to_string();
|
||||
draft.type_input = "timestamptz".to_string();
|
||||
assert!(draft.add_from_inputs().is_err());
|
||||
|
||||
draft.type_input = "text".to_string();
|
||||
assert!(draft.add_from_inputs().is_ok());
|
||||
// Duplicates are refused too.
|
||||
draft.name_input = "total".to_string();
|
||||
draft.type_input = "text".to_string();
|
||||
assert!(draft.add_from_inputs().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantity_ledger_requires_a_numeric_type() {
|
||||
let mut draft = ColumnDraft::new();
|
||||
draft.name_input = "note".to_string();
|
||||
draft.type_input = "text".to_string();
|
||||
draft.quantity_ledger_input = "yes".to_string();
|
||||
assert!(draft.add_from_inputs().is_err());
|
||||
|
||||
draft.type_input = "int".to_string();
|
||||
assert!(draft.add_from_inputs().is_ok());
|
||||
assert!(draft.added[0].quantity_ledger);
|
||||
|
||||
// A parameterised decimal counts as numeric.
|
||||
draft.name_input = "quantity".to_string();
|
||||
draft.type_input = "decimal".to_string();
|
||||
draft.decimal_precision_input = "12".to_string();
|
||||
draft.decimal_scale_input = "3".to_string();
|
||||
draft.quantity_ledger_input = "yes".to_string();
|
||||
assert!(draft.add_from_inputs().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_column_is_always_named_accounting() {
|
||||
let mut draft = ColumnDraft::new();
|
||||
draft.name_input = "whatever".to_string();
|
||||
draft.type_input = "accounting".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
|
||||
assert_eq!(draft.added[0].name, "accounting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_columns_require_a_valid_currency() {
|
||||
let mut draft = ColumnDraft::new();
|
||||
draft.name_input = "total".to_string();
|
||||
draft.type_input = "money".to_string();
|
||||
draft.currency_input = "EU".to_string();
|
||||
assert!(draft.add_from_inputs().is_err());
|
||||
|
||||
draft.currency_input = "eur".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
assert_eq!(draft.added[0].currency, "EUR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accounting_column_shows_its_currency_too() {
|
||||
let mut draft = ColumnDraft::new();
|
||||
draft.type_input = "accounting".to_string();
|
||||
draft.currency_input = "czk".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
|
||||
assert_eq!(draft.added[0].currency, "CZK");
|
||||
assert_eq!(draft.added[0].option_label(), "CZK, exact");
|
||||
|
||||
draft.toggle_indexed(0);
|
||||
assert_eq!(draft.added[0].option_label(), "indexed, CZK, exact");
|
||||
}
|
||||
|
||||
/// `add_from_inputs` enforces this, but a draft rebuilt from a posted form
|
||||
/// skips that path, so `validate` has to enforce it too.
|
||||
#[test]
|
||||
fn a_rebuilt_draft_is_still_held_to_the_currency_rule() {
|
||||
let mut draft = ColumnDraft::new();
|
||||
draft.added.push(ColumnDefinition {
|
||||
name: "total".to_string(),
|
||||
data_type: "money".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
assert!(draft.validate().is_err());
|
||||
|
||||
draft.added[0].currency = "XYZ".to_string();
|
||||
assert!(draft.validate().is_err());
|
||||
|
||||
draft.added[0].currency = "EUR".to_string();
|
||||
assert!(draft.validate().is_ok());
|
||||
|
||||
// Forbidden on everything else, exactly as the server has it.
|
||||
draft.added[0].data_type = "text".to_string();
|
||||
assert!(draft.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_column_vectors_never_mis_pair() {
|
||||
let columns = columns_from_rows(
|
||||
&["number".to_string(), "total".to_string()],
|
||||
&["text".to_string()],
|
||||
&["yes".to_string(), "no".to_string()],
|
||||
&["no".to_string(), "no".to_string()],
|
||||
&["exact".to_string(), "half-up".to_string()],
|
||||
&[String::new(), "EUR".to_string()],
|
||||
);
|
||||
|
||||
assert_eq!(columns.len(), 1);
|
||||
assert_eq!(columns[0].name, "number");
|
||||
assert_eq!(columns[0].data_type, "text");
|
||||
assert!(columns[0].indexed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_columns_become_the_index_list() {
|
||||
let mut draft = ColumnDraft::new();
|
||||
draft.name_input = "number".to_string();
|
||||
draft.type_input = "text".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
draft.toggle_indexed(0);
|
||||
|
||||
assert_eq!(draft.selected_index_names(), vec!["number"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user