add table is better now
This commit is contained in:
939
web/src/pages/add_table/draft.rs
Normal file
939
web/src/pages/add_table/draft.rs
Normal file
@@ -0,0 +1,939 @@
|
||||
//! The Add-table draft and its rules.
|
||||
//!
|
||||
//! This is a port of the TUI client's `pages/add_table/data.rs` core mechanics
|
||||
//! with the terminal-specific parts removed (`ratatui` cursors, the canvas
|
||||
//! `DataProvider` projection, and the `tr!` i18n macro). Every rule below —
|
||||
//! the type catalogue, canonicalisation, field visibility and validation —
|
||||
//! matches the client so the two frontends accept and reject exactly the same
|
||||
//! table definitions.
|
||||
//!
|
||||
//! Keeping it dependency-light is deliberate: this module is the candidate for
|
||||
//! extraction into a crate shared by `client`, `web` and `server`. Everything
|
||||
//! above [`TableDraft::into_request`] is already proto-free; that one method is
|
||||
//! the seam where a shared crate would hand back a plain draft for each
|
||||
//! frontend to map to its own generated request type.
|
||||
|
||||
use crate::definitions::table_definition::{
|
||||
ColumnDefinition as ProtoColumnDefinition, MoneyRounding, PostTableDefinitionRequest,
|
||||
TableLink as ProtoTableLink,
|
||||
};
|
||||
|
||||
/// Column types offered in the type picker. `temporal` and `gtin` are pickers
|
||||
/// of their own: neither is a storable type, each resolves to a subtype below.
|
||||
pub(crate) const COLUMN_TYPES: &[&str] = &[
|
||||
"text",
|
||||
"boolean",
|
||||
"money",
|
||||
"accounting",
|
||||
"int",
|
||||
"temporal",
|
||||
"phone",
|
||||
"iban",
|
||||
"email_address",
|
||||
"credit_card",
|
||||
"gtin",
|
||||
"bigint",
|
||||
];
|
||||
|
||||
pub(crate) const TEMPORAL_TYPES: &[&str] = &["date", "time", "instant", "raw_datetime"];
|
||||
pub(crate) const GTIN_TYPES: &[&str] = &["8", "12", "13", "14"];
|
||||
|
||||
/// Every type the server accepts, i.e. what a canonicalised column may be.
|
||||
const CANONICAL_TYPES: &[&str] = &[
|
||||
"text",
|
||||
"boolean",
|
||||
"date",
|
||||
"time",
|
||||
"instant",
|
||||
"raw_datetime",
|
||||
"phone",
|
||||
"iban",
|
||||
"email_address",
|
||||
"credit_card",
|
||||
"gtin_8",
|
||||
"gtin_12",
|
||||
"gtin_13",
|
||||
"gtin_14",
|
||||
"money",
|
||||
"accounting",
|
||||
"int",
|
||||
"bigint",
|
||||
];
|
||||
|
||||
/// 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",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_input(value: &str) -> Self {
|
||||
if value.trim().eq_ignore_ascii_case("half-up") {
|
||||
Self::Rounded
|
||||
} else {
|
||||
Self::Exact
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum LinkMode {
|
||||
#[default]
|
||||
None,
|
||||
Optional,
|
||||
Required,
|
||||
}
|
||||
|
||||
impl LinkMode {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Optional => "optional",
|
||||
Self::Required => "required",
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycles none → optional → required → none, as `Select` does in the TUI.
|
||||
pub(crate) fn next(self) -> Self {
|
||||
match self {
|
||||
Self::None => Self::Optional,
|
||||
Self::Optional => Self::Required,
|
||||
Self::Required => Self::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_label(value: &str) -> Self {
|
||||
match value.trim() {
|
||||
"optional" => Self::Optional,
|
||||
"required" => Self::Required,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_active(self) -> bool {
|
||||
!matches!(self, Self::None)
|
||||
}
|
||||
|
||||
pub(crate) fn is_required(self) -> bool {
|
||||
matches!(self, Self::Required)
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
impl ColumnDefinition {
|
||||
/// The `option` cell of the preview, mirroring the client's preview table.
|
||||
pub(crate) fn option_label(&self) -> String {
|
||||
let is_money = self.data_type.eq_ignore_ascii_case("money");
|
||||
match (self.indexed, is_money) {
|
||||
(true, true) => format!("indexed, {}", self.money_mode.label()),
|
||||
(true, false) => "indexed".to_string(),
|
||||
(false, true) => self.money_mode.label().to_string(),
|
||||
(false, false) => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct LinkDefinition {
|
||||
pub linked_table_name: String,
|
||||
pub mode: LinkMode,
|
||||
}
|
||||
|
||||
/// One row of the "Table definition preview" — the schema as it will exist.
|
||||
pub(crate) struct PreviewRow {
|
||||
pub mark: String,
|
||||
pub column: String,
|
||||
pub data_type: String,
|
||||
pub option: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// The whole Add-table page state, minus presentation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct TableDraft {
|
||||
/// Profile this table belongs to when an existing one was picked.
|
||||
pub profile_name: String,
|
||||
/// Profile name typed in when creating a new profile.
|
||||
pub profile_name_input: String,
|
||||
pub creating_new_profile: bool,
|
||||
pub accounting_currency: String,
|
||||
|
||||
pub table_name: String,
|
||||
pub base_currency: String,
|
||||
|
||||
// The column-input panel: one pending column being described.
|
||||
pub column_name_input: String,
|
||||
pub column_type_input: String,
|
||||
pub temporal_type_input: String,
|
||||
pub gtin_type_input: String,
|
||||
pub column_indexing_input: String,
|
||||
pub column_quantity_ledger_input: String,
|
||||
pub column_rounding_input: String,
|
||||
|
||||
pub columns: Vec<ColumnDefinition>,
|
||||
pub links: Vec<LinkDefinition>,
|
||||
/// Columns identifying a row to users, in the order they are shown.
|
||||
/// Empty means rows are identified by their id alone.
|
||||
pub row_display_columns: Vec<String>,
|
||||
|
||||
/// Tables already defined in the target profile — a new table may not
|
||||
/// reuse one of these names.
|
||||
pub existing_profile_tables: Vec<String>,
|
||||
}
|
||||
|
||||
impl TableDraft {
|
||||
/// A draft for a brand-new page load, matching the client's defaults.
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
accounting_currency: "EUR".to_string(),
|
||||
base_currency: "EUR".to_string(),
|
||||
column_indexing_input: "no".to_string(),
|
||||
column_quantity_ledger_input: "no".to_string(),
|
||||
column_rounding_input: "none".to_string(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pending-column input -------------------------------------------
|
||||
|
||||
pub(crate) fn is_money_column_input(&self) -> bool {
|
||||
let value = self.column_type_input.trim();
|
||||
value.eq_ignore_ascii_case("money") || value.eq_ignore_ascii_case("accounting")
|
||||
}
|
||||
|
||||
pub(crate) fn is_temporal_column_input(&self) -> bool {
|
||||
self.column_type_input.trim().eq_ignore_ascii_case("temporal")
|
||||
}
|
||||
|
||||
pub(crate) fn is_gtin_column_input(&self) -> bool {
|
||||
self.column_type_input.trim().eq_ignore_ascii_case("gtin")
|
||||
}
|
||||
|
||||
/// The storable type the pending inputs describe, resolving the `temporal`
|
||||
/// and `gtin` pickers to their subtype. `None` while the choice is still
|
||||
/// incomplete.
|
||||
fn canonical_column_type_input(&self) -> Option<String> {
|
||||
let column_type = self.column_type_input.trim().to_ascii_lowercase();
|
||||
match column_type.as_str() {
|
||||
"temporal" => {
|
||||
let temporal_type = self.temporal_type_input.trim().to_ascii_lowercase();
|
||||
TEMPORAL_TYPES
|
||||
.contains(&temporal_type.as_str())
|
||||
.then_some(temporal_type)
|
||||
}
|
||||
"gtin" => {
|
||||
let gtin_type = self.gtin_type_input.trim();
|
||||
GTIN_TYPES
|
||||
.contains(>in_type)
|
||||
.then(|| format!("gtin_{gtin_type}"))
|
||||
}
|
||||
"" => None,
|
||||
_ => Some(column_type),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- field visibility (the same rules the TUI canvas applies) --------
|
||||
|
||||
pub(crate) fn show_profile_name_input(&self) -> bool {
|
||||
self.creating_new_profile
|
||||
}
|
||||
|
||||
pub(crate) fn show_accounting_currency(&self) -> bool {
|
||||
self.creating_new_profile
|
||||
}
|
||||
|
||||
pub(crate) fn show_temporal_type(&self) -> bool {
|
||||
self.is_temporal_column_input()
|
||||
}
|
||||
|
||||
pub(crate) fn show_gtin_type(&self) -> bool {
|
||||
self.is_gtin_column_input()
|
||||
}
|
||||
|
||||
pub(crate) fn show_rounding(&self) -> bool {
|
||||
self.is_money_column_input()
|
||||
}
|
||||
|
||||
pub(crate) fn show_base_currency(&self) -> bool {
|
||||
self.is_money_column_input() || self.money_column_count() > 0
|
||||
}
|
||||
|
||||
// ---- mutations -------------------------------------------------------
|
||||
|
||||
/// Appends the pending column, then clears the input panel.
|
||||
pub(crate) fn add_column_from_inputs(&mut self) -> Result<String, String> {
|
||||
let Some(column_type) = self.canonical_column_type_input() else {
|
||||
return Err("Both a column name and a column type are required.".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.column_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.columns.iter().any(|column| column.name == column_name) {
|
||||
return Err(format!("A column named `{column_name}` already exists."));
|
||||
}
|
||||
|
||||
let quantity_ledger = self
|
||||
.column_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 is_money = column_type.eq_ignore_ascii_case("money")
|
||||
|| column_type.eq_ignore_ascii_case("accounting");
|
||||
self.columns.push(ColumnDefinition {
|
||||
name: column_name.clone(),
|
||||
data_type: column_type,
|
||||
indexed: self
|
||||
.column_indexing_input
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("yes"),
|
||||
quantity_ledger,
|
||||
money_mode: if is_money {
|
||||
MoneyMode::from_input(&self.column_rounding_input)
|
||||
} else {
|
||||
MoneyMode::Exact
|
||||
},
|
||||
});
|
||||
|
||||
self.clear_column_inputs();
|
||||
Ok(format!("Column `{column_name}` added."))
|
||||
}
|
||||
|
||||
fn clear_column_inputs(&mut self) {
|
||||
self.column_name_input.clear();
|
||||
self.column_type_input.clear();
|
||||
self.temporal_type_input.clear();
|
||||
self.gtin_type_input.clear();
|
||||
self.column_indexing_input = "no".to_string();
|
||||
self.column_quantity_ledger_input = "no".to_string();
|
||||
self.column_rounding_input = "none".to_string();
|
||||
}
|
||||
|
||||
/// Removes one column, and drops it from the display columns with it.
|
||||
pub(crate) fn remove_column(&mut self, index: usize) -> Result<String, String> {
|
||||
if index >= self.columns.len() {
|
||||
return Err("That column no longer exists.".to_string());
|
||||
}
|
||||
let removed = self.columns.remove(index);
|
||||
self.row_display_columns
|
||||
.retain(|display| display != &removed.name);
|
||||
Ok(format!("Column `{}` removed.", removed.name))
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_column_indexed(&mut self, index: usize) {
|
||||
if let Some(column) = self.columns.get_mut(index) {
|
||||
column.indexed = !column.indexed;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cycle_link_mode(&mut self, index: usize) {
|
||||
if let Some(link) = self.links.get_mut(index) {
|
||||
link.mode = link.mode.next();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds or removes one display-column candidate.
|
||||
///
|
||||
/// Index 0 is `id`, which is not a display column of its own: choosing it
|
||||
/// clears the list, since an empty list already means "identified by id".
|
||||
/// Any other index toggles that column, appending so the order columns were
|
||||
/// chosen in is the order they are shown in.
|
||||
pub(crate) fn toggle_row_display_candidate(&mut self, index: usize) {
|
||||
if index == 0 {
|
||||
self.row_display_columns.clear();
|
||||
return;
|
||||
}
|
||||
let Some(column) = self.columns.get(index - 1).map(|column| column.name.clone()) else {
|
||||
return;
|
||||
};
|
||||
match self
|
||||
.row_display_columns
|
||||
.iter()
|
||||
.position(|display| *display == column)
|
||||
{
|
||||
Some(position) => {
|
||||
self.row_display_columns.remove(position);
|
||||
}
|
||||
None => self.row_display_columns.push(column),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds the link list from the tables available in the target profile,
|
||||
/// keeping whatever mode each surviving link already had.
|
||||
pub(crate) fn set_available_relation_tables(&mut self, table_names: Vec<String>) {
|
||||
let previous_modes = self
|
||||
.links
|
||||
.iter()
|
||||
.map(|link| (link.linked_table_name.clone(), link.mode))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
self.links = table_names
|
||||
.into_iter()
|
||||
.filter(|table_name| table_name != &self.table_name)
|
||||
.map(|linked_table_name| LinkDefinition {
|
||||
mode: previous_modes
|
||||
.get(&linked_table_name)
|
||||
.copied()
|
||||
.unwrap_or(LinkMode::None),
|
||||
linked_table_name,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
// ---- derived state ---------------------------------------------------
|
||||
|
||||
/// The profile name to validate and persist: the typed input while creating
|
||||
/// a new profile, otherwise the profile that was picked.
|
||||
pub(crate) fn effective_profile_name(&self) -> String {
|
||||
if self.creating_new_profile {
|
||||
self.profile_name_input.trim().to_string()
|
||||
} else {
|
||||
self.profile_name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn table_name_conflicts(&self) -> bool {
|
||||
!self.table_name.is_empty()
|
||||
&& self
|
||||
.existing_profile_tables
|
||||
.iter()
|
||||
.any(|name| name == &self.table_name)
|
||||
}
|
||||
|
||||
pub(crate) fn money_column_count(&self) -> usize {
|
||||
self.columns
|
||||
.iter()
|
||||
.filter(|column| {
|
||||
column.data_type.eq_ignore_ascii_case("money")
|
||||
|| column.data_type.eq_ignore_ascii_case("accounting")
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
pub(crate) fn selected_index_names(&self) -> Vec<String> {
|
||||
self.columns
|
||||
.iter()
|
||||
.filter(|column| column.indexed)
|
||||
.map(|column| column.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Position of `column` among the display columns, counting from 1.
|
||||
pub(crate) fn row_display_position(&self, column: &str) -> Option<usize> {
|
||||
self.row_display_columns
|
||||
.iter()
|
||||
.position(|display| display == column)
|
||||
.map(|index| index + 1)
|
||||
}
|
||||
|
||||
/// The schema as it will exist: system columns, relation columns, then the
|
||||
/// user's own. Mirrors the client's preview pane.
|
||||
pub(crate) fn preview_rows(&self) -> Vec<PreviewRow> {
|
||||
let mut rows = vec![
|
||||
PreviewRow {
|
||||
mark: if self.row_display_columns.is_empty() {
|
||||
"[x]".to_string()
|
||||
} else {
|
||||
"[ ]".to_string()
|
||||
},
|
||||
column: "id".to_string(),
|
||||
data_type: "BIGSERIAL".to_string(),
|
||||
option: "primary key".to_string(),
|
||||
source: "system".to_string(),
|
||||
},
|
||||
PreviewRow {
|
||||
mark: String::new(),
|
||||
column: "deleted".to_string(),
|
||||
data_type: "BOOLEAN".to_string(),
|
||||
option: "default false".to_string(),
|
||||
source: "system".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
for link in self.links.iter().filter(|link| link.mode.is_active()) {
|
||||
rows.push(PreviewRow {
|
||||
mark: String::new(),
|
||||
column: format!("{}_id", link.linked_table_name),
|
||||
data_type: "BIGINT".to_string(),
|
||||
option: if link.mode.is_required() {
|
||||
"required".to_string()
|
||||
} else {
|
||||
"optional".to_string()
|
||||
},
|
||||
source: "relation".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
for column in &self.columns {
|
||||
rows.push(PreviewRow {
|
||||
mark: self
|
||||
.row_display_position(&column.name)
|
||||
.map(|position| format!("[{position}]"))
|
||||
.unwrap_or_else(|| "[ ]".to_string()),
|
||||
column: column.name.clone(),
|
||||
data_type: column.data_type.clone(),
|
||||
option: column.option_label(),
|
||||
source: "user".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
rows.push(PreviewRow {
|
||||
mark: String::new(),
|
||||
column: "created_at".to_string(),
|
||||
data_type: "TIMESTAMPTZ".to_string(),
|
||||
option: "current time".to_string(),
|
||||
source: "system".to_string(),
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
// ---- validation and submission ---------------------------------------
|
||||
|
||||
/// Every check the client runs before it will save.
|
||||
pub(crate) fn validate(&self) -> Result<(), String> {
|
||||
let profile_name = self.effective_profile_name();
|
||||
if self.creating_new_profile && profile_name.is_empty() {
|
||||
return Err("Enter a name for the new profile.".to_string());
|
||||
}
|
||||
if let Some(error) = validate_identifier(&profile_name, "Profile name", false) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = validate_accounting_currency(self) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = validate_identifier(self.table_name.trim(), "Table name", true) {
|
||||
return Err(error);
|
||||
}
|
||||
if self.table_name_conflicts() {
|
||||
return Err(format!(
|
||||
"A table named `{}` already exists in profile `{}`.",
|
||||
self.table_name, profile_name
|
||||
));
|
||||
}
|
||||
if self.columns.is_empty() {
|
||||
return Err("Add at least one column before saving.".to_string());
|
||||
}
|
||||
for column in &self.columns {
|
||||
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 let Some(error) = validate_base_currency(self) {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn into_request(mut self) -> Result<PostTableDefinitionRequest, String> {
|
||||
self.table_name = self.table_name.trim().to_string();
|
||||
self.validate()?;
|
||||
|
||||
Ok(PostTableDefinitionRequest {
|
||||
table_name: self.table_name.clone(),
|
||||
profile_name: self.effective_profile_name(),
|
||||
columns: self
|
||||
.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,
|
||||
})
|
||||
.collect(),
|
||||
indexes: self.selected_index_names(),
|
||||
links: self
|
||||
.links
|
||||
.iter()
|
||||
.filter(|link| link.mode.is_active())
|
||||
.map(|link| ProtoTableLink {
|
||||
linked_table_name: link.linked_table_name.clone(),
|
||||
required: link.mode.is_required(),
|
||||
})
|
||||
.collect(),
|
||||
base_currency: if self.money_column_count() == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
self.base_currency.trim().to_ascii_uppercase()
|
||||
},
|
||||
accounting_currency: if self.creating_new_profile {
|
||||
self.accounting_currency.trim().to_ascii_uppercase()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
row_display_columns: self.row_display_columns.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_base_currency(draft: &TableDraft) -> Option<String> {
|
||||
if draft.money_column_count() == 0 {
|
||||
return None;
|
||||
}
|
||||
let currency = draft.base_currency.trim();
|
||||
if currency.len() != 3 || !currency.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return Some("Base currency must be a three-letter ISO-4217 code".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option<String> {
|
||||
if !draft.creating_new_profile {
|
||||
return None;
|
||||
}
|
||||
let currency = draft.accounting_currency.to_ascii_uppercase();
|
||||
if rusty_money::iso::find(¤cy).is_none() {
|
||||
return Some("Accounting currency must be a three-letter ISO-4217 code".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
Some(format!("`{field_type}` is not a valid field type."))
|
||||
}
|
||||
|
||||
/// 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",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn draft_with_column(name: &str, data_type: &str) -> TableDraft {
|
||||
let mut draft = TableDraft::new();
|
||||
draft.profile_name = "billing".to_string();
|
||||
draft.table_name = "invoice".to_string();
|
||||
draft.columns.push(ColumnDefinition {
|
||||
name: name.to_string(),
|
||||
data_type: data_type.to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
});
|
||||
draft
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_and_gtin_pickers_resolve_to_canonical_types() {
|
||||
let mut draft = TableDraft::new();
|
||||
draft.column_name_input = "occurred_at".to_string();
|
||||
draft.column_type_input = "temporal".to_string();
|
||||
|
||||
// Incomplete while no subtype is chosen.
|
||||
assert!(draft.canonical_column_type_input().is_none());
|
||||
assert!(draft.show_temporal_type());
|
||||
|
||||
draft.temporal_type_input = "raw_datetime".to_string();
|
||||
draft.add_column_from_inputs().unwrap();
|
||||
assert_eq!(draft.columns[0].data_type, "raw_datetime");
|
||||
// Inputs are cleared for the next column.
|
||||
assert!(draft.temporal_type_input.is_empty());
|
||||
|
||||
draft.column_name_input = "barcode".to_string();
|
||||
draft.column_type_input = "gtin".to_string();
|
||||
draft.gtin_type_input = "13".to_string();
|
||||
draft.add_column_from_inputs().unwrap();
|
||||
assert_eq!(draft.columns[1].data_type, "gtin_13");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_identifiers_and_types_are_refused_at_add_time() {
|
||||
let mut draft = TableDraft::new();
|
||||
draft.column_type_input = "text".to_string();
|
||||
|
||||
draft.column_name_input = "Total".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
draft.column_name_input = "customer_id".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
draft.column_name_input = "created_at".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
|
||||
draft.column_name_input = "total".to_string();
|
||||
draft.column_type_input = "timestamptz".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
|
||||
draft.column_type_input = "text".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_ok());
|
||||
// Duplicates are refused too.
|
||||
draft.column_name_input = "total".to_string();
|
||||
draft.column_type_input = "text".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantity_ledger_requires_a_numeric_type() {
|
||||
let mut draft = TableDraft::new();
|
||||
draft.column_name_input = "note".to_string();
|
||||
draft.column_type_input = "text".to_string();
|
||||
draft.column_quantity_ledger_input = "yes".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_err());
|
||||
|
||||
draft.column_type_input = "int".to_string();
|
||||
assert!(draft.add_column_from_inputs().is_ok());
|
||||
assert!(draft.columns[0].quantity_ledger);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_column_is_always_named_accounting() {
|
||||
let mut draft = TableDraft::new();
|
||||
draft.column_name_input = "whatever".to_string();
|
||||
draft.column_type_input = "accounting".to_string();
|
||||
draft.add_column_from_inputs().unwrap();
|
||||
|
||||
assert_eq!(draft.columns[0].name, "accounting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_columns_require_a_valid_base_currency() {
|
||||
let mut draft = draft_with_column("total", "money");
|
||||
draft.base_currency = "EU".to_string();
|
||||
assert!(draft.validate().is_err());
|
||||
|
||||
draft.base_currency = "eur".to_string();
|
||||
assert!(draft.validate().is_ok());
|
||||
assert_eq!(draft.into_request().unwrap().base_currency, "EUR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_profile_accounting_currency_must_exist_in_the_iso_registry() {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
draft.creating_new_profile = true;
|
||||
draft.profile_name_input = "billing".to_string();
|
||||
|
||||
draft.accounting_currency = "AAA".to_string();
|
||||
assert!(draft.validate().is_err());
|
||||
|
||||
draft.accounting_currency = "eur".to_string();
|
||||
assert!(draft.validate().is_ok());
|
||||
assert_eq!(draft.into_request().unwrap().accounting_currency, "EUR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_profile_sends_no_accounting_currency() {
|
||||
let draft = draft_with_column("total", "int");
|
||||
let request = draft.into_request().unwrap();
|
||||
|
||||
assert_eq!(request.accounting_currency, "");
|
||||
assert_eq!(request.profile_name, "billing");
|
||||
// No money column, so no base currency either.
|
||||
assert_eq!(request.base_currency, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_table_names_in_the_profile_are_refused() {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
draft.existing_profile_tables = vec!["invoice".to_string()];
|
||||
|
||||
assert!(draft.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_profile_names_are_refused() {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
draft.profile_name = "pg_catalog".to_string();
|
||||
|
||||
assert!(draft.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_keep_their_mode_when_the_table_list_is_reloaded() {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
draft.set_available_relation_tables(vec!["customer".into(), "project".into()]);
|
||||
draft.cycle_link_mode(0); // none -> optional
|
||||
draft.cycle_link_mode(0); // optional -> required
|
||||
|
||||
draft.set_available_relation_tables(vec![
|
||||
"customer".into(),
|
||||
"project".into(),
|
||||
"address".into(),
|
||||
]);
|
||||
|
||||
assert_eq!(draft.links[0].mode, LinkMode::Required);
|
||||
assert_eq!(draft.links[2].mode, LinkMode::None);
|
||||
|
||||
let request = draft.into_request().unwrap();
|
||||
assert_eq!(request.links.len(), 1);
|
||||
assert_eq!(request.links[0].linked_table_name, "customer");
|
||||
assert!(request.links[0].required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_never_links_to_itself() {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
draft.set_available_relation_tables(vec!["invoice".into(), "customer".into()]);
|
||||
|
||||
assert_eq!(draft.links.len(), 1);
|
||||
assert_eq!(draft.links[0].linked_table_name, "customer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_display_columns_toggle_in_the_order_they_were_chosen() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
draft.columns.push(ColumnDefinition {
|
||||
name: "issued_on".to_string(),
|
||||
data_type: "date".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
});
|
||||
|
||||
draft.toggle_row_display_candidate(2); // issued_on
|
||||
draft.toggle_row_display_candidate(1); // number
|
||||
assert_eq!(draft.row_display_columns, vec!["issued_on", "number"]);
|
||||
assert_eq!(draft.row_display_position("number"), Some(2));
|
||||
|
||||
// Index 0 is `id`: choosing it clears the list.
|
||||
draft.toggle_row_display_candidate(0);
|
||||
assert!(draft.row_display_columns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_column_drops_it_from_the_display_columns() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
draft.toggle_row_display_candidate(1);
|
||||
assert_eq!(draft.row_display_columns, vec!["number"]);
|
||||
|
||||
draft.remove_column(0).unwrap();
|
||||
assert!(draft.row_display_columns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preview_shows_system_relation_and_user_columns() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
draft.set_available_relation_tables(vec!["customer".into()]);
|
||||
draft.cycle_link_mode(0);
|
||||
|
||||
let rows = draft.preview_rows();
|
||||
let columns = rows
|
||||
.iter()
|
||||
.map(|row| row.column.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
columns,
|
||||
vec!["id", "deleted", "customer_id", "number", "created_at"]
|
||||
);
|
||||
assert_eq!(rows[2].option, "optional");
|
||||
// No display column chosen, so `id` identifies the row.
|
||||
assert_eq!(rows[0].mark, "[x]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_columns_become_the_index_list() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
draft.toggle_column_indexed(0);
|
||||
|
||||
assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user