removing decimal with precision and decimal count

This commit is contained in:
Filipriec
2026-08-24 21:31:06 +02:00
parent 16670f4375
commit 5e9b86c1fb
22 changed files with 50 additions and 467 deletions

View File

@@ -57,12 +57,6 @@ pub(crate) struct BuilderForm {
#[serde(default)]
pub link_table_input: String,
#[serde(default)]
pub decimal_precision_input: String,
#[serde(default)]
pub decimal_scale_input: String,
#[serde(default)]
pub fixed_decimal_input: String,
#[serde(default)]
pub column_indexing_input: String,
#[serde(default)]
pub column_quantity_ledger_input: String,
@@ -128,9 +122,6 @@ impl BuilderForm {
temporal_type_input: self.temporal_type_input.clone(),
gtin_type_input: self.gtin_type_input.clone(),
link_table_input: self.link_table_input.clone(),
decimal_precision_input: self.decimal_precision_input.clone(),
decimal_scale_input: self.decimal_scale_input.clone(),
fixed_decimal_input: self.fixed_decimal_input.clone(),
indexing_input: self.column_indexing_input.clone(),
quantity_ledger_input: self.column_quantity_ledger_input.clone(),
required_input: self.column_required_input.clone(),

View File

@@ -245,20 +245,13 @@ mod tests {
assert!(html.contains("Every profile can use this shared table."));
}
/// Fixed-scale decimal is the one intentionally gated type; enabling the
/// advanced control makes it reachable without removing server support.
/// Every type the server accepts has to be reachable from the picker, or
/// the web UI silently offers less than the backend does.
#[test]
fn the_type_picker_offers_the_parameterised_and_interval_types() {
let mut state = page();
let html = render_builder(&state);
fn the_type_picker_offers_interval_and_accounting_types() {
let html = render_builder(&page());
assert!(!html.contains(r#"<option value="decimal""#));
assert!(html.contains(r#"name="fixed_decimal_input""#));
state.draft.columns.fixed_decimal_input = "yes".to_string();
let html = render_builder(&state);
for column_type in ["decimal", "duration", "period", "accounting"] {
for column_type in ["duration", "period", "accounting"] {
assert!(
html.contains(&format!(r#"<option value="{column_type}""#)),
"the type picker is missing {column_type}"
@@ -279,14 +272,6 @@ mod tests {
state.draft.columns.type_input = "gtin".to_string();
assert!(render_builder(&state).contains(r#"name="gtin_type_input""#));
// Decimal reveals its precision and scale.
state.draft.columns.fixed_decimal_input = "yes".to_string();
state.draft.columns.type_input = "decimal".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="decimal_precision_input""#));
assert!(html.contains(r#"name="decimal_scale_input""#));
assert!(html.contains("Advanced fixed-scale decimal"));
// Money reveals its currency and rounding inputs.
state.draft.columns.type_input = "money".to_string();
let html = render_builder(&state);

View File

@@ -63,7 +63,6 @@ const TYPE_DISPLAY_ORDER: &[&str] = &[
"accounting_transfer",
"int",
"bigint",
"decimal",
"numeric",
"temporal",
"duration",
@@ -90,8 +89,6 @@ pub(crate) struct ColumnType {
/// A definition row rather than a column: it expands into schema-managed
/// companions and leaves no column of its own name behind.
pub compound: bool,
/// The name takes a precision and a scale: `decimal(12,3)`.
pub parameterised: bool,
/// The name takes the target table: `link(customer)`.
pub link: bool,
pub requires_currency: bool,
@@ -242,12 +239,6 @@ impl ColumnCatalog {
.is_some_and(|column_type| column_type.creation_only)
}
/// Whether the type takes a precision and a scale.
fn is_parameterised(&self, field_type: &str) -> bool {
self.find(field_type)
.is_some_and(|column_type| column_type.parameterised)
}
fn is_link(&self, field_type: &str) -> bool {
self.find(field_type)
.is_some_and(|column_type| column_type.link)
@@ -258,23 +249,13 @@ impl ColumnCatalog {
/// catalog does not know.
pub(crate) fn sql_type(&self, field_type: &str) -> String {
let field_type = field_type.trim();
match decimal_arguments(&field_type.to_lowercase()) {
// `decimal(12,3)` is stored as its head's SQL type, parameterised.
Some((precision, scale)) => match self.find("decimal") {
Some(column_type) if !column_type.sql_type.is_empty() => {
format!("{}({precision},{scale})", column_type.sql_type)
}
_ => String::new(),
},
None => self
.find(if link_argument(&field_type.to_lowercase()).is_some() {
"link"
} else {
field_type
})
.map(|column_type| column_type.sql_type.clone())
.unwrap_or_default(),
}
self.find(if link_argument(&field_type.to_lowercase()).is_some() {
"link"
} else {
field_type
})
.map(|column_type| column_type.sql_type.clone())
.unwrap_or_default()
}
/// The types a quantity-ledger column may use, spelled for the hint under
@@ -292,14 +273,8 @@ impl ColumnCatalog {
}
fn allows_quantity_ledger(&self, field_type: &str) -> bool {
match decimal_arguments(&field_type.to_lowercase()) {
Some(_) => self
.find("decimal")
.is_some_and(|column_type| column_type.allows_quantity_ledger),
None => self
.find(field_type)
.is_some_and(|column_type| column_type.allows_quantity_ledger),
}
self.find(field_type)
.is_some_and(|column_type| column_type.allows_quantity_ledger)
}
/// Whether the server would accept this as a column's declared type.
@@ -309,15 +284,6 @@ impl ColumnCatalog {
field_type: &str,
) -> Option<String> {
let field_type = field_type.to_lowercase();
if let Some((precision, scale)) = decimal_arguments(&field_type) {
if !self.is_parameterised("decimal") {
return Some(crate::tr!(
locale,
"schema-err-decimal-not-valid"
));
}
return validate_decimal_arguments(locale, precision, scale).err();
}
if let Some(target) = link_argument(&field_type) {
if !self.is_link("link") {
return Some(crate::tr!(locale, "schema-err-link-not-valid"));
@@ -325,12 +291,6 @@ impl ColumnCatalog {
return validate_identifier(locale, target, "label-linked-table", true);
}
match self.find(&field_type) {
// A parameterised type spelled bare is missing its arguments.
Some(column_type) if column_type.parameterised => Some(crate::tr!(
locale,
"schema-err-decimal-args-needed",
"type" => field_type.clone(),
)),
Some(column_type) if column_type.link => {
Some(crate::tr!(locale, "schema-err-link-target-needed"))
}
@@ -349,20 +309,6 @@ impl ColumnCatalog {
}
}
/// Splits `decimal(p,s)` into its arguments, which is the one spelling that is
/// not simply a type name.
fn decimal_arguments(field_type: &str) -> Option<(&str, &str)> {
let arguments = field_type
.strip_prefix("decimal(")
.and_then(|rest| rest.strip_suffix(')'))?;
Some(match arguments.split_once(',') {
Some((precision, scale)) => (precision.trim(), scale.trim()),
// No comma at all: the scale is missing, and the emptiness is what
// `validate_decimal_arguments` reports.
None => (arguments.trim(), ""),
})
}
fn link_argument(field_type: &str) -> Option<&str> {
field_type
.strip_prefix("link(")
@@ -473,12 +419,6 @@ pub(crate) struct ColumnDraft {
pub temporal_type_input: String,
pub gtin_type_input: String,
pub link_table_input: String,
pub decimal_precision_input: String,
pub decimal_scale_input: String,
/// Explicit opt-in for the fixed-scale decimal type. It is intentionally
/// absent from the normal type picker because PostgreSQL coerces values to
/// its declared scale and populated tables cannot later change the type.
pub fixed_decimal_input: String,
pub indexing_input: String,
pub quantity_ledger_input: String,
pub required_input: String,
@@ -532,7 +472,6 @@ impl ColumnDraft {
required_input: "no".to_string(),
rounding_input: "none".to_string(),
currency_input: "EUR".to_string(),
fixed_decimal_input: "no".to_string(),
catalog,
..Self::default()
}
@@ -541,15 +480,7 @@ impl ColumnDraft {
/// The types this panel offers, which is the only place the creation-only
/// rule shows up in the markup.
pub(crate) fn offered_types(&self) -> Vec<String> {
self.catalog
.offered_types(self.creating_table, self.global)
.into_iter()
.filter(|column_type| column_type != "decimal" || self.fixed_decimal_enabled())
.collect()
}
pub(crate) fn fixed_decimal_enabled(&self) -> bool {
self.fixed_decimal_input.trim().eq_ignore_ascii_case("yes")
self.catalog.offered_types(self.creating_table, self.global)
}
pub(crate) fn temporal_types(&self) -> Vec<String> {
@@ -580,10 +511,6 @@ impl ColumnDraft {
self.pending_group().is_some_and(|group| group == "gtin")
}
pub(crate) fn show_decimal_arguments(&self) -> bool {
self.catalog.is_parameterised(&self.type_input)
}
pub(crate) fn show_link_target(&self) -> bool {
self.catalog.is_link(&self.type_input)
}
@@ -642,8 +569,8 @@ impl ColumnDraft {
// ---- the pending column ---------------------------------------------
/// The storable type the pending inputs describe, resolving a group choice
/// and the `decimal` arguments to their canonical form. `None` while the
/// The storable type the pending inputs describe, resolving a group choice.
/// `None` while the
/// choice is still incomplete, `Err` when the follow-up fields are filled
/// in but wrong.
fn canonical_type_input(&self, locale: crate::i18n::Locale) -> Result<Option<String>, String> {
@@ -651,18 +578,6 @@ impl ColumnDraft {
if column_type.is_empty() {
return Ok(None);
}
if self.catalog.is_parameterised(&column_type) {
if !self.fixed_decimal_enabled() {
return Err(crate::tr!(locale, "td-fixed-decimal-enable-first"));
}
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(locale, precision, scale)?;
return Ok(Some(format!("{column_type}({precision},{scale})")));
}
if self.catalog.is_link(&column_type) {
let target = self.link_table_input.trim().to_ascii_lowercase();
return Ok((!target.is_empty()).then(|| format!("{column_type}({target})")));
@@ -812,8 +727,6 @@ impl ColumnDraft {
self.temporal_type_input.clear();
self.gtin_type_input.clear();
self.link_table_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.required_input = "no".to_string();
@@ -1286,67 +1199,6 @@ pub(crate) fn validate_table_name(
None
}
/// 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(
locale: crate::i18n::Locale,
precision: &str,
scale: &str,
) -> Result<(), String> {
let precision = validate_decimal_number(locale, "label-precision", precision)?;
let scale = validate_decimal_number(locale, "label-scale", scale)?;
if precision < 1 {
return Err(crate::tr!(locale, "error-precision-min"));
}
if scale > precision {
return Err(crate::tr!(locale, "error-scale-gt-precision"));
}
Ok(())
}
fn validate_decimal_number(
locale: crate::i18n::Locale,
label_key: &str,
value: &str,
) -> Result<u32, String> {
let label = crate::tr!(locale, label_key);
if value.is_empty() {
return Err(crate::tr!(
locale,
"error-decimal-required",
"label" => label,
));
}
if value.starts_with('+') || value.starts_with('-') {
return Err(crate::tr!(
locale,
"error-decimal-sign",
"label" => label,
));
}
if value.contains('.') {
return Err(crate::tr!(
locale,
"error-decimal-whole",
"label" => label,
));
}
if value.len() > 1 && value.starts_with('0') {
return Err(crate::tr!(
locale,
"error-decimal-leading-zeros",
"label" => label,
));
}
value.parse::<u32>().map_err(|_| {
crate::tr!(
locale,
"error-decimal-whole",
"label" => label,
)
})
}
/// The seam where the rules above meet the generated request types.
pub(crate) fn proto_columns(columns: &[ColumnDefinition]) -> Vec<ProtoColumnDefinition> {
columns
@@ -1372,7 +1224,6 @@ pub(crate) fn column_catalog(column_types: Vec<ProtoColumnType>) -> ColumnCatalo
column_types
.into_iter()
.map(|column_type| ColumnType {
parameterised: column_type.spelling() == ColumnTypeSpelling::Decimal,
link: column_type.spelling() == ColumnTypeSpelling::Link,
name: column_type.name,
sql_type: column_type.sql_type,
@@ -1454,12 +1305,6 @@ pub(crate) struct ColumnForm {
#[serde(default)]
pub link_table_input: String,
#[serde(default)]
pub decimal_precision_input: String,
#[serde(default)]
pub decimal_scale_input: String,
#[serde(default)]
pub fixed_decimal_input: String,
#[serde(default)]
pub column_indexing_input: String,
#[serde(default)]
pub column_quantity_ledger_input: String,
@@ -1503,9 +1348,6 @@ impl ColumnForm {
temporal_type_input: self.temporal_type_input.clone(),
gtin_type_input: self.gtin_type_input.clone(),
link_table_input: self.link_table_input.clone(),
decimal_precision_input: self.decimal_precision_input.clone(),
decimal_scale_input: self.decimal_scale_input.clone(),
fixed_decimal_input: self.fixed_decimal_input.clone(),
indexing_input: self.column_indexing_input.clone(),
quantity_ledger_input: self.column_quantity_ledger_input.clone(),
required_input: self.column_required_input.clone(),
@@ -1606,7 +1448,6 @@ pub(crate) mod tests {
sql_type: "TEXT".to_string(),
declarable: true,
compound: false,
parameterised: false,
link: false,
requires_currency: false,
creation_only: false,
@@ -1690,10 +1531,6 @@ pub(crate) mod tests {
sql_type: "DATE".to_string(),
..grouped("date", "temporal")
},
ColumnType {
parameterised: true,
..numeric("decimal")
},
declarable("duration"),
declarable("email_address"),
grouped("gtin_8", "gtin"),
@@ -1774,10 +1611,6 @@ pub(crate) mod tests {
assert!(offered.contains(&"numeric".to_string()));
assert!(offered.contains(&"account".to_string()));
assert!(offered.contains(&"accounting_transfer".to_string()));
assert!(!offered.contains(&"decimal".to_string()));
let mut advanced = draft();
advanced.fixed_decimal_input = "yes".to_string();
assert!(advanced.offered_types().contains(&"decimal".to_string()));
// Families are one choice, resolved by a follow-up field.
assert!(offered.contains(&"temporal".to_string()));
assert!(offered.contains(&"gtin".to_string()));
@@ -1803,7 +1636,7 @@ pub(crate) mod tests {
}
#[test]
fn temporal_gtin_and_decimal_pickers_resolve_to_canonical_types() {
fn temporal_and_gtin_pickers_resolve_to_canonical_types() {
let mut draft = draft();
draft.name_input = "occurred_at".to_string();
draft.type_input = "temporal".to_string();
@@ -1824,15 +1657,6 @@ pub(crate) mod tests {
draft.add_from_inputs(crate::i18n::Locale::default()).unwrap();
assert_eq!(draft.added[1].data_type, "gtin_13");
draft.name_input = "weight".to_string();
draft.fixed_decimal_input = "yes".to_string();
draft.type_input = "decimal".to_string();
assert_eq!(draft.canonical_type_input(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).unwrap();
assert_eq!(draft.added[2].data_type, "decimal(12,3)");
}
#[test]
@@ -1860,29 +1684,6 @@ pub(crate) mod tests {
);
}
/// 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 = draft();
draft.name_input = "weight".to_string();
draft.fixed_decimal_input = "yes".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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).is_ok());
}
/// `duration` and `period` are storable types on their own — the picker
/// offers them and nothing has to be resolved.
#[test]
@@ -1968,17 +1769,8 @@ pub(crate) mod tests {
assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_ok());
assert!(draft.added[0].quantity_ledger);
// A parameterised decimal counts, through its head.
draft.name_input = "quantity".to_string();
draft.fixed_decimal_input = "yes".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(crate::i18n::Locale::default()).is_ok());
// And the hint under the input names exactly that set.
assert_eq!(draft.quantity_ledger_types(), "BIGINT, DECIMAL, INT, MONEY");
assert_eq!(draft.quantity_ledger_types(), "BIGINT, INT, MONEY, NUMERIC");
}
/// A compound column is a definition row, not a column: it takes its
@@ -2099,7 +1891,6 @@ pub(crate) mod tests {
assert_eq!(catalog.sql_type("instant"), "TIMESTAMPTZ(0)");
assert_eq!(catalog.sql_type("phone_calling_code"), "INTEGER");
assert_eq!(catalog.sql_type("decimal(12,3)"), "NUMERIC(12,3)");
// A compound type has no column, so it has no SQL type of its own.
assert_eq!(catalog.sql_type("accounting"), "");
assert_eq!(catalog.sql_type("nonsense"), "");