web fixes

This commit is contained in:
Priec
2026-08-14 18:46:31 +02:00
parent 0e6207fb44
commit 1c3d292479
13 changed files with 1458 additions and 105 deletions

View File

@@ -30,6 +30,23 @@ use crate::definitions::table_definition::{
/// Anything the server offers that is not named here still appears, after
/// these and in the server's own order, so a newly added type is never hidden
/// by this list being out of date.
/// The compound type that posts a row to the profile's books.
pub(crate) const ACCOUNTING_FIELD_TYPE: &str = "accounting";
/// The compound type that moves a balance between two accounting periods.
pub(crate) const ACCOUNTING_TRANSFER_FIELD_TYPE: &str = "accounting_transfer";
/// The types that only make sense inside one profile.
///
/// Both post to a profile's books, and a global table belongs to every profile
/// at once — there is no one set of books for it to post to, so the server
/// refuses the pair outright rather than picking a profile for them.
const PROFILE_ONLY_TYPES: [&str; 2] = [ACCOUNTING_FIELD_TYPE, ACCOUNTING_TRANSFER_FIELD_TYPE];
/// The profile's chart of accounts. A row reaches it through an ACCOUNTING
/// definition row, never through a link declared by hand.
pub(crate) const ACCOUNTS_TABLE: &str = "accounts";
const TYPE_DISPLAY_ORDER: &[&str] = &[
"text",
"boolean",
@@ -136,12 +153,18 @@ impl ColumnCatalog {
/// `creating_table` is false on the append screen, where the server refuses
/// the creation-only types: they bring schema-managed companion columns
/// that cannot be bolted onto a table that already exists.
pub(crate) fn offered_types(&self, creating_table: bool) -> Vec<String> {
///
/// `global` drops the two types that post to a profile's books, which a
/// table shared by every profile has no single one of.
pub(crate) fn offered_types(&self, creating_table: bool, global: bool) -> Vec<String> {
let mut offered = Vec::new();
for column_type in self.types.iter() {
if !column_type.declarable || (column_type.creation_only && !creating_table) {
continue;
}
if global && PROFILE_ONLY_TYPES.contains(&column_type.name.as_str()) {
continue;
}
let offer = if column_type.group.is_empty() {
&column_type.name
} else {
@@ -324,6 +347,15 @@ fn link_argument(field_type: &str) -> Option<&str> {
.map(str::trim)
}
/// The table a stored column type points at, when it is a link.
///
/// A link is a foreign key, and the server builds an index for every one of
/// them as it creates the table — so this is also the question "is this column
/// already indexed", which is why it is asked in more than one place.
pub(crate) fn link_target(data_type: &str) -> Option<&str> {
link_argument(data_type.trim())
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum MoneyMode {
#[default]
@@ -356,21 +388,40 @@ pub(crate) struct ColumnDefinition {
pub quantity_ledger: bool,
pub money_mode: MoneyMode,
pub currency: String,
/// Whether a row has to carry a value for it. The server records this with
/// the column and refuses a row that leaves it out.
pub required: bool,
}
impl ColumnDefinition {
/// Whether this column is a link, and so already has an index of its own.
pub(crate) fn is_link(&self) -> bool {
link_target(&self.data_type).is_some()
}
/// Whether the table really gets an index on this column — which a link
/// does whether or not anyone asked, because the server indexes every
/// foreign key as it creates the table.
pub(crate) fn is_indexed(&self) -> bool {
self.indexed || self.is_link()
}
/// The `option` cell of the preview, mirroring the client's preview table.
///
/// The currency is shown whenever there is one, which is the same thing as
/// asking the catalog: it is only ever stored for a type that requires it.
pub(crate) fn option_label(&self) -> String {
let has_currency = !self.currency.is_empty();
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(),
let mut options = Vec::new();
if self.required {
options.push("required".to_string());
}
if self.is_indexed() {
options.push("indexed".to_string());
}
if !self.currency.is_empty() {
options.push(format!("{}, {}", self.currency, self.money_mode.label()));
}
options.join(", ")
}
}
@@ -390,6 +441,7 @@ pub(crate) struct ColumnDraft {
pub decimal_scale_input: String,
pub indexing_input: String,
pub quantity_ledger_input: String,
pub required_input: String,
pub rounding_input: String,
pub currency_input: String,
@@ -403,6 +455,16 @@ pub(crate) struct ColumnDraft {
/// False on the append screen: the creation-only types can only be chosen
/// while the table is being created.
pub creating_table: bool,
/// Whether the table these columns belong to is shared by every profile.
/// A shared table has no books of its own, so the types that post to a
/// profile's books are neither offered nor accepted on one.
pub global: bool,
/// The table these columns belong to, which a link among them may not
/// point at. Set by the page: the builder knows the name being typed, and
/// the append screen knows the table it is appending to.
pub table_name: String,
}
impl ColumnDraft {
@@ -423,6 +485,7 @@ impl ColumnDraft {
Self {
indexing_input: "no".to_string(),
quantity_ledger_input: "no".to_string(),
required_input: "no".to_string(),
rounding_input: "none".to_string(),
currency_input: "EUR".to_string(),
catalog,
@@ -433,7 +496,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.catalog.offered_types(self.creating_table, self.global)
}
pub(crate) fn temporal_types(&self) -> Vec<String> {
@@ -483,6 +546,29 @@ impl ColumnDraft {
self.catalog.is_compound(&self.type_input)
}
/// Whether the pending column is one the user chooses an index for.
///
/// Neither a definition row nor a link is: the first leaves no column of
/// its own name behind, and the second is a foreign key, which the server
/// indexes as it creates the table. Asking for an index on a link is not
/// merely redundant — the server refuses the whole table for it, saying
/// the link is indexed automatically.
pub(crate) fn show_indexing(&self) -> bool {
!self.pending_is_compound() && !self.show_link_target()
}
/// A link's index is the server's to make, and the panel says so where the
/// choice would otherwise be.
pub(crate) fn pending_is_auto_indexed(&self) -> bool {
self.show_link_target()
}
/// Whether the pending column may keep a quantity ledger. A shared table
/// has no profile whose ledger it would be kept in.
pub(crate) fn show_quantity_ledger(&self) -> bool {
!self.pending_is_compound() && !self.global
}
/// What the pending choice would add to the table. Empty unless the choice
/// is a compound type.
pub(crate) fn pending_generated_columns(&self) -> &[GeneratedColumn] {
@@ -577,9 +663,26 @@ impl ColumnDraft {
if let Some(error) = self.catalog.validate_field_type(&column_type) {
return Err(error);
}
if self.added.iter().any(|column| column.name == column_name) {
if let Some(error) = self.profile_only_type_error(&column_type) {
return Err(error);
}
if let Some(error) = self.link_target_error(&column_name, &column_type) {
return Err(error);
}
// Against the names the table will really hold, not just the declared
// ones: a definition row's companions are columns too, and the server
// refuses a table where a declared name collides with one of them.
if self.claimed_names().iter().any(|name| name == &column_name) {
return Err(format!("A column named `{column_name}` already exists."));
}
for generated in self.catalog.generated_columns(&column_type) {
let generated = self.companion_name(&column_name, &generated.name);
if self.claimed_names().iter().any(|name| name == &generated) {
return Err(format!(
"`{column_type}` generates a column named `{generated}`, and the table already has one."
));
}
}
let quantity_ledger = self.quantity_ledger_input.trim().eq_ignore_ascii_case("yes");
if quantity_ledger && !self.catalog.allows_quantity_ledger(&column_type) {
@@ -588,6 +691,9 @@ impl ColumnDraft {
self.catalog.quantity_ledger_types()
));
}
if let Some(error) = self.global_quantity_ledger_error(quantity_ledger, &column_name) {
return Err(error);
}
let has_currency = self.catalog.requires_currency(&column_type);
let currency = if has_currency {
@@ -596,11 +702,16 @@ impl ColumnDraft {
String::new()
};
self.added.push(ColumnDefinition {
// A compound column is not a column, so there is nothing to index;
// a link already has an index the server made, and asking for a
// second one is what the server refuses the table for.
indexed: !compound
&& link_target(&column_type).is_none()
&& self.indexing_input.trim().eq_ignore_ascii_case("yes"),
name: column_name.clone(),
data_type: column_type,
// A compound column is not a column, so there is nothing to index.
indexed: !compound && self.indexing_input.trim().eq_ignore_ascii_case("yes"),
quantity_ledger,
required: !compound && self.required_input.trim().eq_ignore_ascii_case("yes"),
money_mode: if has_currency {
MoneyMode::from_input(&self.rounding_input)
} else {
@@ -623,10 +734,91 @@ impl ColumnDraft {
self.decimal_scale_input.clear();
self.indexing_input = "no".to_string();
self.quantity_ledger_input = "no".to_string();
self.required_input = "no".to_string();
self.rounding_input = "none".to_string();
self.currency_input = "EUR".to_string();
}
// ---- the rules a column is held to, wherever it came from ------------
/// Every column name the table will hold: the declared ones and everything
/// the definition rows among them expand into.
///
/// The server checks a name against this set rather than against the
/// declared columns alone, so declaring `debit` beside an ACCOUNTING row is
/// a conflict there. It has to be a conflict here too, or the builder
/// accepts a table the server will refuse.
pub(crate) fn claimed_names(&self) -> Vec<String> {
let mut names = Vec::new();
for (index, column) in self.added.iter().enumerate() {
names.push(column.name.clone());
names.extend(
self.generated_columns_of(index)
.into_iter()
.map(|generated| generated.name),
);
}
names
}
/// What a companion is called on a column of this name — the catalog
/// reports it under the type's own prefix, and the column's name replaces
/// that prefix. The same rule [`Self::generated_columns_of`] applies.
fn companion_name(&self, column_name: &str, generated_name: &str) -> String {
let column_type = self
.added
.iter()
.find(|column| column.name == column_name)
.map(|column| column.data_type.clone());
let prefix = match column_type {
Some(data_type) => format!("{data_type}_"),
None => format!("{}_", self.type_input.trim().to_ascii_lowercase()),
};
match generated_name.strip_prefix(&prefix) {
Some(suffix) => format!("{column_name}_{suffix}"),
None => generated_name.to_string(),
}
}
/// The books a definition row posts to belong to one profile, and a shared
/// table belongs to all of them.
fn profile_only_type_error(&self, column_type: &str) -> Option<String> {
(self.global && PROFILE_ONLY_TYPES.contains(&column_type)).then(|| {
format!(
"A shared table cannot use {}: it posts to one profile's books, and a shared table belongs to every profile.",
column_type.to_uppercase()
)
})
}
fn global_quantity_ledger_error(&self, quantity_ledger: bool, name: &str) -> Option<String> {
(self.global && quantity_ledger).then(|| {
format!(
"Column `{name}`: a shared table cannot keep a quantity ledger, which belongs to one profile."
)
})
}
/// The two link targets the server refuses: the table being created, which
/// does not exist yet, and the chart of accounts, which is reached through
/// an ACCOUNTING row instead.
///
/// `table_name` is empty on the append screen's panel, where the page
/// filters its own table out of the picker and there is nothing to compare
/// against here.
fn link_target_error(&self, column_name: &str, column_type: &str) -> Option<String> {
let target = link_target(column_type)?;
if target == ACCOUNTS_TABLE {
return Some(
"The account relationship is built into ACCOUNTING and cannot be declared as a link."
.to_string(),
);
}
(!self.table_name.is_empty() && target == self.table_name).then(|| {
format!("Link `{column_name}` cannot point at the table being created.")
})
}
// ---- the columns added so far ----------------------------------------
/// Removes one column. The caller is what knows whether anything else
@@ -682,9 +874,20 @@ impl ColumnDraft {
.collect()
}
/// Whether a column can be indexed or identify a row. A compound column
/// leaves no column of its own name behind, so it can do neither.
/// Whether a column is one the user chooses an index for.
///
/// A compound column leaves no column of its own name behind, and a link
/// already has one: the server builds an index for every foreign key and
/// refuses a definition that asks for a second.
pub(crate) fn is_indexable(&self, index: usize) -> bool {
self.added.get(index).is_some_and(|column| {
!self.catalog.is_compound(&column.data_type) && !column.is_link()
})
}
/// Whether a column can identify a row. A compound column leaves no column
/// of its own name behind, so it cannot; a link can.
pub(crate) fn can_identify_row(&self, index: usize) -> bool {
self.added
.get(index)
.is_some_and(|column| !self.catalog.is_compound(&column.data_type))
@@ -699,11 +902,17 @@ impl ColumnDraft {
}
}
/// The indexes the request asks for.
///
/// A link is never among them however the draft was built: the server
/// makes that index itself, and naming it here is refused rather than
/// ignored.
pub(crate) fn selected_index_names(&self) -> Vec<String> {
self.added
.iter()
.filter(|column| column.indexed)
.map(|column| column.name.clone())
.enumerate()
.filter(|(index, column)| column.indexed && self.is_indexable(*index))
.map(|(_, column)| column.name.clone())
.collect()
}
@@ -717,6 +926,7 @@ impl ColumnDraft {
/// 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> {
let claimed = self.claimed_names();
for column in &self.added {
if let Some(error) = validate_identifier(&column.name, "Column name", true) {
return Err(error);
@@ -724,6 +934,29 @@ impl ColumnDraft {
if let Some(error) = self.catalog.validate_field_type(&column.data_type) {
return Err(format!("Column `{}`: {error}", column.name));
}
if let Some(error) = self.profile_only_type_error(&column.data_type) {
return Err(error);
}
if let Some(error) = self.link_target_error(&column.name, &column.data_type) {
return Err(error);
}
if let Some(error) =
self.global_quantity_ledger_error(column.quantity_ledger, &column.name)
{
return Err(error);
}
if column.indexed && column.is_link() {
return Err(format!(
"Link `{}` is indexed automatically and cannot be indexed again.",
column.name
));
}
if column.indexed && self.catalog.is_compound(&column.data_type) {
return Err(format!(
"`{}` is a definition row, not a column, so it cannot be indexed.",
column.name
));
}
if !self.creating_table && self.catalog.is_creation_only(&column.data_type) {
return Err(format!(
"A {} column can only be chosen while the table is being created.",
@@ -750,6 +983,15 @@ impl ColumnDraft {
));
}
}
// Every name the table will hold has to be its own, counting what the
// definition rows generate — which is the set the server checks.
for (position, name) in claimed.iter().enumerate() {
if claimed[position + 1..].contains(name) {
return Err(format!(
"`{name}` names more than one of this table's columns."
));
}
}
Ok(())
}
}
@@ -780,8 +1022,10 @@ pub(crate) fn validate_identifier(
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.len() > MAX_IDENTIFIER_LENGTH {
return Some(format!(
"{label} cannot be longer than {MAX_IDENTIFIER_LENGTH} characters."
));
}
if value
.chars()
@@ -807,6 +1051,60 @@ pub(crate) fn validate_identifier(
None
}
/// Postgres's `NAMEDATALEN - 1`: a longer identifier is truncated, not refused.
const MAX_IDENTIFIER_LENGTH: usize = 63;
/// How long a table name may be.
///
/// Shorter than the 63 an identifier gets, because a table name is not only an
/// identifier: the server names every index on the table after it, and
/// `idx_<table>_<column>_fk` has to fit in 63 as well. What is subtracted is
/// that wrapping plus the longest physical column name there can be.
///
/// This is the server's own arithmetic — `catalog::object_naming` — down to the
/// term it reads out of `common`, so adding a system column there moves the
/// limit here too instead of leaving this behind at a number that was once
/// right.
pub(crate) const MAX_TABLE_NAME_LENGTH: usize = MAX_IDENTIFIER_LENGTH
- "idx_".len()
- "_".len()
- "_fk".len()
- crate::system_column::LONGEST_SYSTEM_COLUMN_NAME;
/// The tables the server provisions for a profile itself. A profile gets these
/// when it is created, so a new table may not claim one of their names.
///
/// Written down here because the backend does not report them: they are
/// constants of three server modules rather than anything the catalog carries.
/// A name the server adds to that set is a name this list will not know about
/// until it is added here as well.
pub(crate) const RESERVED_TABLE_NAMES: [&str; 5] = [
"general_ledger",
"journal_lines",
"quantity_ledger",
ACCOUNTS_TABLE,
"custom_exchange_rates",
];
/// The identifier rules, plus the two a table name alone is held to.
pub(crate) fn validate_table_name(value: &str) -> Option<String> {
if let Some(error) = validate_identifier(value, "Table name", true) {
return Some(error);
}
if value.len() > MAX_TABLE_NAME_LENGTH {
return Some(format!(
"Table name cannot be longer than {MAX_TABLE_NAME_LENGTH} characters, because the \
indexes on its columns are named after it."
));
}
if RESERVED_TABLE_NAMES.contains(&value) {
return Some(format!(
"`{value}` is the name of a table every profile is given, so it cannot be reused."
));
}
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(precision: &str, scale: &str) -> Result<(), String> {
@@ -852,7 +1150,7 @@ pub(crate) fn proto_columns(columns: &[ColumnDefinition]) -> Vec<ProtoColumnDefi
},
quantity_ledger: column.quantity_ledger,
currency: column.currency.clone(),
required: false,
required: column.required,
})
.collect()
}
@@ -943,6 +1241,8 @@ pub(crate) struct ColumnForm {
#[serde(default)]
pub column_quantity_ledger_input: String,
#[serde(default)]
pub column_required_input: String,
#[serde(default)]
pub column_rounding_input: String,
#[serde(default)]
pub column_currency_input: String,
@@ -956,6 +1256,8 @@ pub(crate) struct ColumnForm {
#[serde(default)]
pub column_quantity_ledger: Vec<String>,
#[serde(default)]
pub column_required: Vec<String>,
#[serde(default)]
pub column_rounding: Vec<String>,
#[serde(default)]
pub column_currencies: Vec<String>,
@@ -973,6 +1275,7 @@ impl ColumnForm {
decimal_scale_input: self.decimal_scale_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(),
rounding_input: self.column_rounding_input.clone(),
currency_input: self.column_currency_input.clone(),
added: columns_from_rows(
@@ -980,11 +1283,16 @@ impl ColumnForm {
&self.column_types,
&self.column_indexed,
&self.column_quantity_ledger,
&self.column_required,
&self.column_rounding,
&self.column_currencies,
),
catalog,
creating_table,
// Filled in by the page, which is what knows the table these
// columns are for: the panel's own form carries neither.
global: false,
table_name: String::new(),
}
}
}
@@ -1003,6 +1311,7 @@ pub(crate) fn columns_from_rows(
types: &[String],
indexed: &[String],
quantity_ledger: &[String],
required: &[String],
rounding: &[String],
currencies: &[String],
) -> Vec<ColumnDefinition> {
@@ -1011,6 +1320,7 @@ pub(crate) fn columns_from_rows(
types.len(),
indexed.len(),
quantity_ledger.len(),
required.len(),
rounding.len(),
currencies.len(),
]
@@ -1024,6 +1334,7 @@ pub(crate) fn columns_from_rows(
data_type: types[index].clone(),
indexed: is_yes(&indexed[index]),
quantity_ledger: is_yes(&quantity_ledger[index]),
required: is_yes(&required[index]),
money_mode: MoneyMode::from_input(&rounding[index]),
currency: currencies[index].clone(),
})
@@ -1332,6 +1643,7 @@ pub(crate) mod tests {
data_type: field_type.to_string(),
indexed: false,
quantity_ledger: false,
required: false,
money_mode: MoneyMode::Exact,
currency: String::new(),
});
@@ -1464,6 +1776,7 @@ pub(crate) mod tests {
data_type: "money".to_string(),
indexed: false,
quantity_ledger: false,
required: false,
money_mode: MoneyMode::Exact,
currency: String::new(),
});
@@ -1489,6 +1802,7 @@ pub(crate) mod tests {
data_type: "text".to_string(),
indexed: false,
quantity_ledger: true,
required: false,
money_mode: MoneyMode::Exact,
currency: String::new(),
});
@@ -1530,6 +1844,7 @@ pub(crate) mod tests {
&["text".to_string()],
&["yes".to_string(), "no".to_string()],
&["no".to_string(), "no".to_string()],
&["no".to_string(), "yes".to_string()],
&["exact".to_string(), "half-up".to_string()],
&[String::new(), "EUR".to_string()],
);
@@ -1622,6 +1937,259 @@ pub(crate) mod tests {
assert_eq!(draft.selected_index_names(), vec!["number"]);
}
/// A link is a foreign key, and the server indexes every one of them as it
/// creates the table. Asking for one on top is not redundant — the server
/// refuses the whole definition, saying the link is indexed automatically —
/// so there is no choice to offer and no way to end up having made one.
#[test]
fn a_link_is_indexed_by_the_server_and_never_by_the_user() {
let mut draft = draft();
draft.name_input = "billing_customer".to_string();
draft.type_input = "link".to_string();
draft.link_table_input = "customer".to_string();
draft.indexing_input = "yes".to_string();
// The panel does not offer the choice, and says why instead.
assert!(!draft.show_indexing());
assert!(draft.pending_is_auto_indexed());
draft.add_from_inputs().unwrap();
// Asking anyway leaves the column unindexed, so nothing names it.
assert!(!draft.added[0].indexed);
assert!(!draft.is_indexable(0));
draft.toggle_indexed(0);
assert!(!draft.added[0].indexed);
assert!(draft.selected_index_names().is_empty());
// And it is still reported as indexed, because it is.
assert!(draft.added[0].is_indexed());
assert!(draft.added[0].option_label().contains("indexed"));
// A draft rebuilt from a post that says otherwise is refused rather
// than sent on to be refused by the server.
draft.added[0].indexed = true;
let error = draft.validate().unwrap_err();
assert!(error.contains("indexed automatically"), "{error}");
assert!(draft.selected_index_names().is_empty());
}
/// A definition row is never indexed: it leaves no column of its own name
/// behind. A crafted post that marks one is refused rather than silently
/// dropped from the request, matching how a crafted link is refused.
#[test]
fn a_compound_column_marked_indexed_is_refused_not_dropped() {
let mut draft = draft();
draft.type_input = "accounting".to_string();
draft.currency_input = "EUR".to_string();
draft.add_from_inputs().unwrap();
assert!(!draft.added[0].indexed);
draft.added[0].indexed = true;
let error = draft.validate().unwrap_err();
assert!(error.contains("cannot be indexed"), "{error}");
assert!(draft.selected_index_names().is_empty());
}
/// An ordinary column is still the user's to index.
#[test]
fn every_other_column_still_chooses_its_own_index() {
let mut draft = draft();
draft.name_input = "number".to_string();
draft.type_input = "text".to_string();
assert!(draft.show_indexing());
assert!(!draft.pending_is_auto_indexed());
draft.indexing_input = "yes".to_string();
draft.add_from_inputs().unwrap();
assert!(draft.is_indexable(0));
assert_eq!(draft.selected_index_names(), vec!["number"]);
assert!(draft.validate().is_ok());
}
/// Both types post to one profile's books, and a shared table belongs to
/// every profile at once. The server refuses the pair outright, so they are
/// not offered on a shared table.
#[test]
fn a_shared_table_is_offered_no_column_that_posts_to_a_profiles_books() {
let mut draft = ColumnDraft::new(catalog());
draft.global = true;
let offered = draft.offered_types();
assert!(!offered.contains(&"accounting".to_string()));
assert!(!offered.contains(&"accounting_transfer".to_string()));
assert!(offered.contains(&"money".to_string()));
for field_type in ["accounting", "accounting_transfer"] {
let mut draft = draft.clone();
draft.type_input = field_type.to_string();
let error = draft.add_from_inputs().unwrap_err();
assert!(error.contains("shared table"), "{error}");
}
// And a draft rebuilt from a post that carries one anyway.
draft.added.push(ColumnDefinition {
name: "accounting".to_string(),
data_type: "accounting".to_string(),
indexed: false,
quantity_ledger: false,
required: false,
money_mode: MoneyMode::Exact,
currency: "EUR".to_string(),
});
assert!(draft.validate().is_err());
}
/// A quantity ledger is one profile's, for the same reason.
#[test]
fn a_shared_table_keeps_no_quantity_ledger() {
let mut draft = ColumnDraft::new(catalog());
draft.global = true;
assert!(!draft.show_quantity_ledger());
draft.name_input = "quantity".to_string();
draft.type_input = "int".to_string();
draft.quantity_ledger_input = "yes".to_string();
let error = draft.add_from_inputs().unwrap_err();
assert!(error.contains("quantity ledger"), "{error}");
draft.added.push(ColumnDefinition {
name: "quantity".to_string(),
data_type: "int".to_string(),
indexed: false,
quantity_ledger: true,
required: false,
money_mode: MoneyMode::Exact,
currency: String::new(),
});
assert!(draft.validate().is_err());
// The same column on a profile's own table is fine.
draft.global = false;
assert!(draft.show_quantity_ledger());
assert!(draft.validate().is_ok());
}
/// The table does not exist yet, so it cannot be pointed at — the picker
/// leaves it out, and the rule holds even when the name is typed after the
/// link was added.
#[test]
fn a_link_cannot_point_at_the_table_being_created() {
let mut draft = draft();
draft.table_name = "invoice".to_string();
draft.name_input = "parent".to_string();
draft.type_input = "link".to_string();
draft.link_table_input = "invoice".to_string();
let error = draft.add_from_inputs().unwrap_err();
assert!(error.contains("cannot point at the table"), "{error}");
// Added while the table had another name, then renamed to the target.
draft.table_name = "order".to_string();
draft.add_from_inputs().unwrap();
assert!(draft.validate().is_ok());
draft.table_name = "invoice".to_string();
assert!(draft.validate().is_err());
}
/// A row reaches the chart of accounts through an ACCOUNTING definition
/// row. Declaring the link by hand is refused by the server, so it is
/// refused here.
#[test]
fn the_chart_of_accounts_is_not_a_link_target() {
let mut draft = draft();
draft.name_input = "posted_to".to_string();
draft.type_input = "link".to_string();
draft.link_table_input = ACCOUNTS_TABLE.to_string();
let error = draft.add_from_inputs().unwrap_err();
assert!(error.contains("built into ACCOUNTING"), "{error}");
}
/// The names a definition row generates are the table's columns too, so a
/// declared column may not take one — in either order.
#[test]
fn a_declared_column_cannot_take_a_generated_columns_name() {
let mut draft = draft();
draft.type_input = "accounting".to_string();
draft.currency_input = "EUR".to_string();
draft.add_from_inputs().unwrap();
assert!(draft.claimed_names().contains(&"debit".to_string()));
draft.name_input = "debit".to_string();
draft.type_input = "text".to_string();
assert!(draft.add_from_inputs().is_err(), "ACCOUNTING generates it");
// And the other way round: the declared column first, then the row
// whose expansion would collide with it.
let mut reversed = ColumnDraft::new(catalog());
reversed.name_input = "credit".to_string();
reversed.type_input = "text".to_string();
reversed.add_from_inputs().unwrap();
reversed.type_input = "accounting".to_string();
reversed.currency_input = "EUR".to_string();
let error = reversed.add_from_inputs().unwrap_err();
assert!(error.contains("credit"), "{error}");
// A companion named after its own column follows that column's name,
// so two PHONE columns never collide.
let mut phones = ColumnDraft::new(catalog());
for name in ["home_phone", "work_phone"] {
phones.name_input = name.to_string();
phones.type_input = "phone".to_string();
phones.add_from_inputs().unwrap();
}
assert!(phones.claimed_names().contains(&"work_phone_ext".to_string()));
assert!(phones.validate().is_ok());
}
/// `required` is a column property the server records and enforces on every
/// row written, so it travels with the column like any other.
#[test]
fn a_column_can_be_required() {
let mut draft = draft();
draft.name_input = "number".to_string();
draft.type_input = "text".to_string();
draft.required_input = "yes".to_string();
draft.add_from_inputs().unwrap();
assert!(draft.added[0].required);
assert!(draft.added[0].option_label().contains("required"));
assert!(proto_columns(&draft.added)[0].required);
// And the panel is cleared for the next column, which is not required
// just because the last one was.
assert_eq!(draft.required_input, "no");
}
/// A table name is not simply an identifier: the server names every index
/// on the table after it, so it has less room than a column does.
#[test]
fn a_table_name_is_shorter_than_a_column_name() {
assert_eq!(MAX_TABLE_NAME_LENGTH, 38);
let longest = "t".repeat(MAX_TABLE_NAME_LENGTH);
assert_eq!(validate_table_name(&longest), None);
assert_eq!(validate_identifier(&longest, "Column name", true), None);
let too_long = "t".repeat(MAX_TABLE_NAME_LENGTH + 1);
assert!(validate_table_name(&too_long).is_some());
// Still a perfectly good column name, which is why the two differ.
assert_eq!(validate_identifier(&too_long, "Column name", true), None);
}
/// Every profile is given these tables when it is created, so a new table
/// cannot be named after one of them.
#[test]
fn the_tables_every_profile_is_given_keep_their_names() {
for name in RESERVED_TABLE_NAMES {
let error = validate_table_name(name)
.unwrap_or_else(|| panic!("`{name}` should be reserved"));
assert!(error.contains(name), "{error}");
}
assert_eq!(validate_table_name("invoice"), None);
}
}
#[cfg(test)]