Files
komp_ac/web/src/schema/mod.rs

2461 lines
94 KiB
Rust

//! 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.
//!
//! The vocabulary itself is not written down here: it is read from the
//! backend's `ListColumnTypes` endpoint into a [`ColumnCatalog`], which every
//! rule below asks. A type the server adds is therefore offered by both
//! screens without a change here, and a type it stops accepting disappears
//! from both.
//!
//! 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 std::sync::Arc;
use crate::definitions::table_definition::{
ColumnDefinition as ProtoColumnDefinition, ColumnTypeSpelling, MoneyRounding,
list_column_types_response::ColumnType as ProtoColumnType,
};
/// The order the type picker offers the types it knows about in — the common
/// ones first, rather than the alphabetical order the endpoint returns.
///
/// 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 LEDGER_ACCOUNTS_TABLE: &str = "ledger_accounts";
const TYPE_DISPLAY_ORDER: &[&str] = &[
"text",
"boolean",
"money",
"accounting",
"accounting_transfer",
"int",
"bigint",
"decimal",
"numeric",
"temporal",
"duration",
"period",
"phone",
"iban",
"email_address",
"credit_card",
"gtin",
"link",
];
/// One column type as the backend describes it.
///
/// A local mirror of the endpoint's message: the rules below are written
/// against this rather than the generated type, which is what keeps them
/// proto-free.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ColumnType {
pub name: String,
/// The PostgreSQL type it maps to; empty for a compound type.
pub sql_type: String,
pub declarable: bool,
/// 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,
/// Only choosable while the table is being created.
pub creation_only: bool,
pub allows_quantity_ledger: bool,
/// Groups several types behind one choice in the picker; empty when the
/// type stands on its own.
pub group: String,
/// What a compound type expands into, in the order the server creates the
/// columns. Empty for every other type.
pub generated_columns: Vec<GeneratedColumn>,
}
/// One column a compound type expands into, as the backend describes it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GeneratedColumn {
pub name: String,
pub data_type: String,
/// The column is kept in the currency and rounding declared on the
/// definition row.
pub inherits_currency: bool,
}
/// Every column type the backend accepts, as one screen's picker reads it.
///
/// Cheap to clone: the panel travels with every request, and each of them
/// carries the catalog it was rendered from.
#[derive(Clone, Debug, Default)]
pub(crate) struct ColumnCatalog {
types: Arc<[ColumnType]>,
}
impl ColumnCatalog {
pub(crate) fn new(types: Vec<ColumnType>) -> Self {
Self {
types: types.into(),
}
}
/// Whether the catalog has been read from the backend yet. A handler that
/// has to validate a draft before it loads the rest of the page fetches
/// the catalog itself; the loader asks this so it does not fetch it twice.
pub(crate) fn is_loaded(&self) -> bool {
!self.types.is_empty()
}
fn find(&self, name: &str) -> Option<&ColumnType> {
self.types
.iter()
.find(|column_type| column_type.name.eq_ignore_ascii_case(name.trim()))
}
/// Whether `name` is a group the picker offers instead of the types in it —
/// `temporal` for the date and time types, `gtin` for the GTIN lengths.
fn is_group(&self, name: &str) -> bool {
let name = name.trim();
!name.is_empty()
&& self
.types
.iter()
.any(|column_type| column_type.group.eq_ignore_ascii_case(name))
}
/// The types the picker offers, groups collapsed to their group name.
///
/// `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.
///
/// `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 {
&column_type.group
};
if !offered.iter().any(|existing| existing == offer) {
offered.push(offer.clone());
}
}
offered.sort_by_key(|offer| {
TYPE_DISPLAY_ORDER
.iter()
.position(|known| known == offer)
.unwrap_or(TYPE_DISPLAY_ORDER.len())
});
offered
}
/// The members of one group, as the follow-up picker offers them. A GTIN
/// length is offered as `13` rather than `gtin_13`, which is what
/// [`ColumnDraft::canonical_type_input`] puts back together.
pub(crate) fn group_members(&self, group: &str) -> Vec<String> {
self.types
.iter()
.filter(|column_type| column_type.declarable && column_type.group == group)
.map(|column_type| {
column_type
.name
.strip_prefix(&format!("{group}_"))
.unwrap_or(&column_type.name)
.to_string()
})
.collect()
}
/// Whether a column of this type declares a currency.
///
/// Both MONEY and ACCOUNTING do, which is why this is asked rather than
/// compared inline: 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 requires_currency(&self, field_type: &str) -> bool {
self.find(field_type)
.is_some_and(|column_type| column_type.requires_currency)
}
/// A compound type is a definition row: it expands into schema-managed
/// companion columns, so no column of its own name survives. It can
/// therefore never be indexed and never identify a row.
pub(crate) fn is_compound(&self, field_type: &str) -> bool {
self.find(field_type)
.is_some_and(|column_type| column_type.compound)
}
/// The columns a compound type expands into, so a screen can show what
/// choosing it will really add. The names are the server's, not this
/// crate's: it reports them with the type, and they are fixed.
pub(crate) fn generated_columns(&self, field_type: &str) -> &[GeneratedColumn] {
self.find(field_type)
.map(|column_type| column_type.generated_columns.as_slice())
.unwrap_or_default()
}
fn is_creation_only(&self, field_type: &str) -> bool {
self.find(field_type)
.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)
}
/// The PostgreSQL type a column is stored as, for the definitions the
/// backend reports back. Empty for a compound type and for any type this
/// 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(),
}
}
/// The types a quantity-ledger column may use, spelled for the hint under
/// the input. Read from the catalog so the hint cannot claim a set the
/// server does not accept.
pub(crate) fn quantity_ledger_types(&self) -> String {
let mut names = self
.types
.iter()
.filter(|column_type| column_type.declarable && column_type.allows_quantity_ledger)
.map(|column_type| column_type.name.to_uppercase())
.collect::<Vec<_>>();
names.sort();
names.join(", ")
}
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),
}
}
/// Whether the server would accept this as a column's declared type.
pub(crate) fn validate_field_type(
&self,
locale: crate::i18n::Locale,
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"));
}
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"))
}
Some(column_type) if column_type.declarable => None,
Some(_) => Some(crate::tr!(
locale,
"schema-err-generated-type",
"type" => field_type.clone(),
)),
None => Some(crate::tr!(
locale,
"schema-err-invalid-type",
"type" => field_type.clone(),
)),
}
}
}
/// 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(")
.and_then(|rest| rest.strip_suffix(')'))
.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]
Exact,
Rounded,
}
impl MoneyMode {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Exact => "exact",
Self::Rounded => "half-up",
}
}
/// The display name shown in badges and the preview, in the request's
/// language. `label` stays the wire value; this is what a reader sees.
pub(crate) fn display_label(self, locale: &crate::i18n::Locale) -> String {
match self {
Self::Exact => crate::tr!(*locale, "td-money-exact"),
Self::Rounded => crate::tr!(*locale, "td-money-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,
/// 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, locale: &crate::i18n::Locale) -> String {
let mut options = Vec::new();
if self.required {
options.push(crate::tr!(*locale, "column-flag-required"));
}
if self.is_indexed() {
options.push(crate::tr!(*locale, "builder-option-indexed"));
}
if !self.currency.is_empty() {
options.push(format!(
"{}, {}",
self.currency,
self.money_mode.display_label(locale),
));
}
options.join(", ")
}
}
/// 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 link_table_input: String,
pub decimal_precision_input: String,
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,
pub added: Vec<ColumnDefinition>,
/// The vocabulary this panel offers and validates against. Filled in by
/// the page's loader from `ListColumnTypes`; empty until then, which
/// refuses every type rather than guessing at one.
pub catalog: ColumnCatalog,
/// 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,
/// Set when the post that rebuilt `added` had ragged column vectors, so
/// validation can say that rather than describe the truncated result.
pub ragged: Option<RaggedColumns>,
}
impl ColumnDraft {
/// A panel for a table that is being created, where every type applies.
pub(crate) fn new(catalog: ColumnCatalog) -> Self {
Self {
creating_table: true,
..Self::empty(catalog)
}
}
/// A panel for appending to an existing table.
pub(crate) fn for_append(catalog: ColumnCatalog) -> Self {
Self::empty(catalog)
}
fn empty(catalog: ColumnCatalog) -> Self {
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,
..Self::default()
}
}
/// 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)
}
pub(crate) fn temporal_types(&self) -> Vec<String> {
self.catalog.group_members("temporal")
}
pub(crate) fn gtin_types(&self) -> Vec<String> {
self.catalog.group_members("gtin")
}
/// The types a quantity-ledger column may use, for the hint under the
/// input.
pub(crate) fn quantity_ledger_types(&self) -> String {
self.catalog.quantity_ledger_types()
}
// ---- field visibility (the same rules the TUI canvas applies) --------
pub(crate) fn pending_carries_currency(&self) -> bool {
self.catalog.requires_currency(&self.type_input)
}
pub(crate) fn show_temporal_type(&self) -> bool {
self.pending_group().is_some_and(|group| group == "temporal")
}
pub(crate) fn show_gtin_type(&self) -> bool {
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)
}
/// Currency and rounding both apply only to a money column.
pub(crate) fn show_money_options(&self) -> bool {
self.pending_carries_currency()
}
/// A compound column is named after its type, so there is nothing to type
/// in: the panel shows what it will generate instead of a name field.
pub(crate) fn pending_is_compound(&self) -> bool {
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] {
self.catalog.generated_columns(&self.type_input)
}
/// The name a compound definition row takes, which is its own type.
pub(crate) fn pending_compound_name(&self) -> String {
self.type_input.trim().to_ascii_lowercase()
}
/// The group the pending choice names, when it names one rather than a
/// type — which is what asks for a follow-up field.
fn pending_group(&self) -> Option<String> {
let group = self.type_input.trim().to_ascii_lowercase();
self.catalog.is_group(&group).then_some(group)
}
// ---- 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
/// 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> {
let column_type = self.type_input.trim().to_ascii_lowercase();
if column_type.is_empty() {
return Ok(None);
}
if self.catalog.is_parameterised(&column_type) {
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})")));
}
let Some(group) = self.pending_group() else {
return Ok(Some(column_type));
};
// A group is chosen by its member, which is spelled without the group
// prefix wherever the catalog carries one.
let member = match group.as_str() {
"gtin" => self.gtin_type_input.trim(),
_ => self.temporal_type_input.trim(),
}
.to_ascii_lowercase();
Ok(self
.catalog
.group_members(&group)
.contains(&member)
.then(|| match self.catalog.find(&member) {
Some(column_type) => column_type.name.clone(),
None => format!("{group}_{member}"),
}))
}
/// Appends the pending column, then clears the input panel.
pub(crate) fn add_from_inputs(
&mut self,
locale: crate::i18n::Locale,
) -> Result<String, String> {
let Some(column_type) = self.canonical_type_input(locale)? else {
return Err(crate::tr!(
locale,
"schema-err-both-name-type"
));
};
if !self.creating_table && self.catalog.is_creation_only(&column_type) {
return Err(crate::tr!(
locale,
"schema-err-creation-only",
"type" => column_type.to_uppercase(),
));
}
// A compound column expands into schema-managed companions and leaves
// no column of its own behind, so its name is its type.
let compound = self.catalog.is_compound(&column_type);
let column_name = if compound {
column_type.clone()
} else {
self.name_input.trim().to_string()
};
if column_name.is_empty() {
return Err(crate::tr!(
locale,
"schema-err-both-name-type"
));
}
if let Some(error) =
validate_identifier(locale, &column_name, "label-column-name", true)
{
return Err(error);
}
if let Some(error) = self.catalog.validate_field_type(locale, &column_type) {
return Err(error);
}
if let Some(error) = self.profile_only_type_error(locale, &column_type) {
return Err(error);
}
if let Some(error) = self.link_target_error(locale, &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(crate::tr!(
locale,
"schema-err-column-exists",
"name" => column_name.clone(),
));
}
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(crate::tr!(
locale,
"schema-err-generated-exists",
"type" => column_type.clone(),
"generated" => generated,
));
}
}
let quantity_ledger = self.quantity_ledger_input.trim().eq_ignore_ascii_case("yes");
if quantity_ledger && !self.catalog.allows_quantity_ledger(&column_type) {
return Err(crate::tr!(
locale,
"schema-err-ql-types",
"types" => self.catalog.quantity_ledger_types(),
));
}
if let Some(error) =
self.global_quantity_ledger_error(locale, quantity_ledger, &column_name)
{
return Err(error);
}
let has_currency = self.catalog.requires_currency(&column_type);
let currency = if has_currency {
normalize_currency_input(locale, &self.currency_input)?
} else {
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,
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 {
MoneyMode::Exact
},
currency,
});
self.clear_inputs();
Ok(crate::tr!(
locale,
"schema-status-column-added",
"name" => column_name,
))
}
fn clear_inputs(&mut self) {
self.name_input.clear();
self.type_input.clear();
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();
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,
locale: crate::i18n::Locale,
column_type: &str,
) -> Option<String> {
(self.global && PROFILE_ONLY_TYPES.contains(&column_type)).then(|| {
crate::tr!(
locale,
"schema-err-profile-only-type",
"type" => column_type.to_uppercase(),
)
})
}
fn global_quantity_ledger_error(
&self,
locale: crate::i18n::Locale,
quantity_ledger: bool,
name: &str,
) -> Option<String> {
(self.global && quantity_ledger).then(|| {
crate::tr!(
locale,
"schema-err-shared-ql",
"name" => name.to_string(),
)
})
}
/// 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,
locale: crate::i18n::Locale,
column_name: &str,
column_type: &str,
) -> Option<String> {
let target = link_target(column_type)?;
if target == LEDGER_ACCOUNTS_TABLE {
return Some(crate::tr!(locale, "schema-err-account-link"));
}
(!self.table_name.is_empty() && target == self.table_name).then(|| {
crate::tr!(
locale,
"schema-err-link-self",
"column" => column_name.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,
locale: crate::i18n::Locale,
index: usize,
) -> Result<ColumnDefinition, String> {
if index >= self.added.len() {
return Err(crate::tr!(locale, "schema-err-column-gone"));
}
Ok(self.added.remove(index))
}
/// Moves one column one place towards the front or the back of the list.
///
/// The order is the order the columns are declared in, which is the order
/// the table gets them in — including a compound column, whose generated
/// companions are created where its definition row sits. Moving the
/// definition row therefore moves the whole block it expands into.
///
/// A move off either end is not an error: the buttons for it are not
/// rendered, and a crafted post asking for one leaves the order alone.
pub(crate) fn move_column(
&mut self,
locale: crate::i18n::Locale,
index: usize,
offset: isize,
) -> Option<String> {
let target = index.checked_add_signed(offset)?;
if index >= self.added.len() || target >= self.added.len() {
return None;
}
self.added.swap(index, target);
Some(if offset < 0 {
crate::tr!(
locale,
"schema-status-column-moved-up",
"name" => self.added[target].name.clone(),
)
} else {
crate::tr!(
locale,
"schema-status-column-moved-down",
"name" => self.added[target].name.clone(),
)
})
}
/// What an already-added column expands into — empty unless it is a
/// compound one. This is what the column list shows underneath it, so the
/// columns a definition row brings are visible while the table is still
/// being described.
pub(crate) fn generated_columns_of(&self, index: usize) -> Vec<GeneratedColumn> {
let Some(column) = self.added.get(index) else {
return Vec::new();
};
let generated = self.catalog.generated_columns(&column.data_type);
let default_prefix = format!("{}_", column.data_type);
generated
.iter()
.cloned()
.map(|mut companion| {
if let Some(suffix) = companion.name.strip_prefix(&default_prefix) {
companion.name = format!("{}_{}", column.name, suffix);
}
companion
})
.collect()
}
/// 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))
}
pub(crate) fn toggle_indexed(&mut self, index: usize) {
if !self.is_indexable(index) {
return;
}
if let Some(column) = self.added.get_mut(index) {
column.indexed = !column.indexed;
}
}
/// 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()
.enumerate()
.filter(|(index, column)| column.indexed && self.is_indexable(*index))
.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, locale: crate::i18n::Locale) -> Result<(), String> {
// Before anything about the columns themselves: if the post was
// ragged, `added` is not what was sent, and every message below would
// be describing a list the caller never posted.
if let Some(ragged) = self.ragged {
return Err(ragged.message(locale));
}
let claimed = self.claimed_names();
for column in &self.added {
if let Some(error) =
validate_identifier(locale, &column.name, "label-column-name", true)
{
return Err(error);
}
if let Some(error) = self.catalog.validate_field_type(locale, &column.data_type) {
return Err(crate::tr!(
locale,
"schema-err-column-prefix",
"name" => column.name.clone(),
"error" => error,
));
}
if let Some(error) = self.profile_only_type_error(locale, &column.data_type) {
return Err(error);
}
if let Some(error) =
self.link_target_error(locale, &column.name, &column.data_type)
{
return Err(error);
}
if let Some(error) =
self.global_quantity_ledger_error(locale, column.quantity_ledger, &column.name)
{
return Err(error);
}
if column.indexed && column.is_link() {
return Err(crate::tr!(
locale,
"schema-err-link-indexed",
"name" => column.name.clone(),
));
}
if column.indexed && self.catalog.is_compound(&column.data_type) {
return Err(crate::tr!(
locale,
"schema-err-definition-indexed",
"name" => column.name.clone(),
));
}
if !self.creating_table && self.catalog.is_creation_only(&column.data_type) {
return Err(crate::tr!(
locale,
"schema-err-creation-only",
"type" => column.data_type.to_uppercase(),
));
}
if column.quantity_ledger && !self.catalog.allows_quantity_ledger(&column.data_type) {
return Err(crate::tr!(
locale,
"schema-err-column-ql-types",
"name" => column.name.clone(),
"types" => self.catalog.quantity_ledger_types(),
));
}
// The same rule the server enforces: required for a money column,
// forbidden for every other type.
if self.catalog.requires_currency(&column.data_type) {
if let Err(error) = normalize_currency_input(locale, &column.currency) {
return Err(crate::tr!(
locale,
"schema-err-column-prefix",
"name" => column.name.clone(),
"error" => error,
));
}
} else if !column.currency.trim().is_empty() {
return Err(crate::tr!(
locale,
"schema-err-currency-only",
"name" => column.name.clone(),
));
}
}
// 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(crate::tr!(
locale,
"schema-err-name-twice",
"name" => name.clone(),
));
}
}
Ok(())
}
}
pub(crate) fn normalize_currency_input(
locale: crate::i18n::Locale,
value: &str,
) -> Result<String, String> {
let currency = value.trim().to_ascii_uppercase();
if rusty_money::iso::find(&currency).is_none() {
return Err(crate::tr!(locale, "schema-err-currency-code"));
}
Ok(currency)
}
/// PostgreSQL identifier rules, plus the names this schema reserves.
pub(crate) fn validate_identifier(
locale: crate::i18n::Locale,
value: &str,
label_key: &str,
reject_table_reserved: bool,
) -> Option<String> {
let label = crate::tr!(locale, label_key);
if value.is_empty() {
return Some(crate::tr!(
locale,
"error-identifier-empty",
"label" => label,
));
}
if value != value.trim() {
return Some(crate::tr!(
locale,
"error-identifier-whitespace",
"label" => label,
));
}
if value.starts_with('_') {
return Some(crate::tr!(
locale,
"error-identifier-underscore",
"label" => label,
));
}
if value.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Some(crate::tr!(
locale,
"error-identifier-number",
"label" => label,
));
}
if value.len() > MAX_IDENTIFIER_LENGTH {
return Some(crate::tr!(
locale,
"error-identifier-too-long",
"label" => label,
"limit" => MAX_IDENTIFIER_LENGTH as i64,
));
}
if value
.chars()
.any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_')
{
return Some(crate::tr!(
locale,
"error-identifier-charset",
"label" => label,
));
}
// Only the system columns are reserved. The `_id` suffix is free: no
// column name is derived from a table name any more, so it collides with
// nothing.
if reject_table_reserved
&& matches!(value, "id" | "deleted" | "created_at" | "row_revision")
{
return Some(crate::tr!(
locale,
"error-identifier-reserved",
"label" => label,
));
}
if !reject_table_reserved
&& (value == "public" || value == "information_schema" || value.starts_with("pg_"))
{
return Some(crate::tr!(locale, "schema-err-profile-reserved"));
}
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",
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(
locale: crate::i18n::Locale,
value: &str,
) -> Option<String> {
if let Some(error) = validate_identifier(locale, value, "label-table-name", true) {
return Some(error);
}
if value.len() > MAX_TABLE_NAME_LENGTH {
// The limit is arithmetic, not a literal: it moves when a system column
// is added. The message reads it rather than restating it.
return Some(crate::tr!(
locale,
"schema-err-table-name-too-long",
"limit" => MAX_TABLE_NAME_LENGTH as i64,
));
}
if RESERVED_TABLE_NAMES.contains(&value) {
return Some(crate::tr!(
locale,
"schema-err-table-name-reserved",
"name" => value.to_string(),
));
}
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
.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(),
required: column.required,
})
.collect()
}
/// The other half of that seam: the `ListColumnTypes` response as the rules
/// above read it.
pub(crate) fn column_catalog(column_types: Vec<ProtoColumnType>) -> ColumnCatalog {
ColumnCatalog::new(
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,
declarable: column_type.declarable,
compound: column_type.compound,
requires_currency: column_type.requires_currency,
creation_only: column_type.creation_only,
allows_quantity_ledger: column_type.allows_quantity_ledger,
group: column_type.group,
generated_columns: column_type
.generated_columns
.into_iter()
.map(|generated| GeneratedColumn {
name: generated.name,
data_type: generated.field_type,
inherits_currency: generated.inherits_currency,
})
.collect(),
})
.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 link_table_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_required_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_required: Vec<String>,
#[serde(default)]
pub column_rounding: Vec<String>,
#[serde(default)]
pub column_currencies: Vec<String>,
}
impl ColumnForm {
pub(crate) fn to_draft(&self, catalog: ColumnCatalog, creating_table: bool) -> ColumnDraft {
let (added, ragged) = columns_from_rows(
&self.column_names,
&self.column_types,
&self.column_indexed,
&self.column_quantity_ledger,
&self.column_required,
&self.column_rounding,
&self.column_currencies,
);
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(),
link_table_input: self.link_table_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(),
required_input: self.column_required_input.clone(),
rounding_input: self.column_rounding_input.clone(),
currency_input: self.column_currency_input.clone(),
added,
ragged,
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(),
}
}
}
fn is_yes(value: &str) -> bool {
value.trim().eq_ignore_ascii_case("yes")
}
/// A post whose parallel column vectors were not all the same length: `kept`
/// columns could be rebuilt, while the longest vector carried `posted`.
///
/// The page itself always writes all seven values per staged column, so this
/// only happens to something posting the form by hand — and it used to be
/// invisible, surfacing as "Add at least one column before saving" when the
/// short vector was empty, or as silently dropped columns when it was not.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct RaggedColumns {
pub kept: usize,
pub posted: usize,
}
impl RaggedColumns {
pub(crate) fn message(self, locale: crate::i18n::Locale) -> String {
crate::tr!(
locale,
"draft-err-ragged-columns",
"posted" => self.posted as i64,
"kept" => self.kept as i64,
)
}
}
/// 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. The mismatch is reported alongside, so the draft can say
/// so instead of quietly holding fewer columns than were sent.
pub(crate) fn columns_from_rows(
names: &[String],
types: &[String],
indexed: &[String],
quantity_ledger: &[String],
required: &[String],
rounding: &[String],
currencies: &[String],
) -> (Vec<ColumnDefinition>, Option<RaggedColumns>) {
let lengths = [
names.len(),
types.len(),
indexed.len(),
quantity_ledger.len(),
required.len(),
rounding.len(),
currencies.len(),
];
let count = lengths.into_iter().min().unwrap_or(0);
let longest = lengths.into_iter().max().unwrap_or(0);
let ragged = (longest > count).then_some(RaggedColumns {
kept: count,
posted: longest,
});
let columns = (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]),
required: is_yes(&required[index]),
money_mode: MoneyMode::from_input(&rounding[index]),
currency: currencies[index].clone(),
})
.collect();
(columns, ragged)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
fn declarable(name: &str) -> ColumnType {
ColumnType {
name: name.to_string(),
sql_type: "TEXT".to_string(),
declarable: true,
compound: false,
parameterised: false,
link: false,
requires_currency: false,
creation_only: false,
allows_quantity_ledger: false,
group: String::new(),
generated_columns: Vec::new(),
}
}
fn generated_column(name: &str, data_type: &str, inherits_currency: bool) -> GeneratedColumn {
GeneratedColumn {
name: name.to_string(),
data_type: data_type.to_string(),
inherits_currency,
}
}
fn grouped(name: &str, group: &str) -> ColumnType {
ColumnType {
group: group.to_string(),
..declarable(name)
}
}
fn compound(name: &str) -> ColumnType {
ColumnType {
sql_type: String::new(),
compound: true,
creation_only: true,
..declarable(name)
}
}
fn numeric(name: &str) -> ColumnType {
ColumnType {
sql_type: "NUMERIC".to_string(),
allows_quantity_ledger: true,
..declarable(name)
}
}
/// The catalog as `ListColumnTypes` reports it: alphabetical, and carrying
/// the types a client may not declare as well as the ones it may.
pub(crate) fn catalog() -> ColumnCatalog {
ColumnCatalog::new(vec![
ColumnType {
requires_currency: true,
// The companions the server reports with the type, in the
// order it creates them.
generated_columns: vec![
generated_column("name", "text", false),
generated_column("tax_point_date", "date", false),
generated_column("debit", "money", true),
generated_column("credit", "money", true),
// The foreign key to the profile's accounts, which the
// backend reports with the rest of them.
generated_column("account", "link(ledger_accounts)", false),
],
..compound("accounting")
},
ColumnType {
generated_columns: vec![
generated_column("source_period_id", "bigint", false),
generated_column("target_period_id", "bigint", false),
],
..compound("accounting_transfer")
},
ColumnType {
sql_type: "TIMESTAMPTZ(0)".to_string(),
..grouped("instant", "temporal")
},
numeric("bigint"),
declarable("boolean"),
declarable("credit_card"),
ColumnType {
sql_type: "DATE".to_string(),
..grouped("date", "temporal")
},
ColumnType {
parameterised: true,
..numeric("decimal")
},
declarable("duration"),
declarable("email_address"),
grouped("gtin_8", "gtin"),
grouped("gtin_12", "gtin"),
grouped("gtin_13", "gtin"),
grouped("gtin_14", "gtin"),
ColumnType {
generated_columns: vec![
generated_column("iban_country", "iban_country", false),
generated_column("iban_bban", "iban_bban", false),
generated_column(
"iban_bank_identifier",
"iban_bank_identifier",
false,
),
generated_column(
"iban_branch_identifier",
"iban_branch_identifier",
false,
),
],
..declarable("iban")
},
ColumnType {
declarable: false,
..declarable("iban_bban")
},
numeric("int"),
ColumnType {
link: true,
sql_type: "BIGINT".to_string(),
..declarable("link")
},
ColumnType {
requires_currency: true,
..numeric("money")
},
ColumnType {
sql_type: "NUMERIC".to_string(),
..declarable("numeric")
},
declarable("period"),
ColumnType {
generated_columns: vec![
generated_column("phone_ext", "phone_extension", false),
generated_column("phone_type", "phone_type", false),
generated_column("phone_country", "phone_country", false),
generated_column(
"phone_calling_code",
"phone_calling_code",
false,
),
],
..declarable("phone")
},
ColumnType {
declarable: false,
sql_type: "INTEGER".to_string(),
..declarable("phone_calling_code")
},
grouped("raw_datetime", "temporal"),
declarable("text"),
grouped("time", "temporal"),
])
}
fn draft() -> ColumnDraft {
ColumnDraft::new(catalog())
}
/// The picker offers what the server says it accepts — including the types
/// this crate never had a list of — with the families collapsed to one
/// choice each and the companions left out.
#[test]
fn the_picker_is_the_servers_vocabulary() {
let offered = draft().offered_types();
assert!(offered.contains(&"numeric".to_string()));
assert!(offered.contains(&"accounting_transfer".to_string()));
assert!(offered.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()));
assert!(!offered.contains(&"gtin_13".to_string()));
assert!(!offered.contains(&"date".to_string()));
// Server-generated companions are never offered.
assert!(!offered.contains(&"phone_calling_code".to_string()));
assert!(!offered.contains(&"iban_bban".to_string()));
// The common types lead, whatever order the endpoint returned.
assert_eq!(offered[0], "text");
}
/// Every creation-only type disappears from the append panel, which is now
/// more than just ACCOUNTING.
#[test]
fn the_append_panel_offers_no_creation_only_type() {
let appendable = ColumnDraft::for_append(catalog()).offered_types();
assert!(!appendable.contains(&"accounting".to_string()));
assert!(!appendable.contains(&"accounting_transfer".to_string()));
assert!(appendable.contains(&"money".to_string()));
}
#[test]
fn temporal_gtin_and_decimal_pickers_resolve_to_canonical_types() {
let mut draft = draft();
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(crate::i18n::Locale::default()).unwrap(), None);
assert!(draft.show_temporal_type());
draft.temporal_type_input = "raw_datetime".to_string();
draft.add_from_inputs(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(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]
fn link_picker_combines_the_alias_with_its_target_table() {
let mut draft = draft();
draft.name_input = "billing_customer".to_string();
draft.type_input = "link".to_string();
assert!(draft.show_link_target());
assert_eq!(draft.canonical_type_input(crate::i18n::Locale::default()).unwrap(), None);
draft.link_table_input = "customer".to_string();
draft.add_from_inputs(crate::i18n::Locale::default()).unwrap();
assert_eq!(draft.added[0].name, "billing_customer");
assert_eq!(draft.added[0].data_type, "link(customer)");
assert_eq!(draft.catalog.sql_type("link(customer)"), "BIGINT");
}
#[test]
fn bare_link_type_is_rejected_without_a_target() {
assert_eq!(
draft().catalog.validate_field_type(crate::i18n::Locale::default(), "link"),
Some("`link` needs a referenced table.".to_string())
);
}
/// 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.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]
fn duration_and_period_are_columns_of_their_own() {
for field_type in ["duration", "period"] {
let mut draft = draft();
draft.name_input = "billing_span".to_string();
draft.type_input = field_type.to_string();
draft.add_from_inputs(crate::i18n::Locale::default()).unwrap();
assert_eq!(draft.added[0].data_type, field_type);
}
}
#[test]
fn an_append_panel_refuses_a_creation_only_column() {
for field_type in ["accounting", "accounting_transfer"] {
let mut draft = ColumnDraft::for_append(catalog());
draft.type_input = field_type.to_string();
assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err());
// And again for a draft rebuilt from a posted form, which never
// went through `add_from_inputs`.
draft.added.push(ColumnDefinition {
name: field_type.to_string(),
data_type: field_type.to_string(),
indexed: false,
quantity_ledger: false,
required: false,
money_mode: MoneyMode::Exact,
currency: String::new(),
});
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
}
}
#[test]
fn invalid_identifiers_and_types_are_refused_at_add_time() {
let mut draft = draft();
draft.type_input = "text".to_string();
draft.name_input = "Total".to_string();
assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err());
draft.name_input = "created_at".to_string();
assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err());
draft.name_input = "total".to_string();
draft.type_input = "timestamptz".to_string();
assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err());
draft.type_input = "text".to_string();
assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_ok());
// Duplicates are refused too.
draft.name_input = "total".to_string();
draft.type_input = "text".to_string();
assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err());
}
/// A column type the backend generates itself is not one a client may
/// declare, even though the catalog describes it.
#[test]
fn a_generated_companion_type_cannot_be_declared() {
let mut draft = draft();
draft.name_input = "country_code".to_string();
draft.type_input = "phone_calling_code".to_string();
assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err());
assert!(
catalog()
.validate_field_type(crate::i18n::Locale::default(), "phone_calling_code")
.is_some()
);
}
#[test]
fn quantity_ledger_follows_the_catalog() {
let mut draft = draft();
draft.name_input = "note".to_string();
draft.type_input = "text".to_string();
draft.quantity_ledger_input = "yes".to_string();
assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err());
draft.type_input = "int".to_string();
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.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");
}
/// A compound column is a definition row, not a column: it takes its
/// type's name and there is nothing to index.
#[test]
fn a_compound_column_is_named_after_its_type_and_never_indexed() {
for field_type in ["accounting", "accounting_transfer"] {
let mut draft = draft();
draft.name_input = "whatever".to_string();
draft.type_input = field_type.to_string();
draft.indexing_input = "yes".to_string();
if field_type == "accounting" {
draft.currency_input = "EUR".to_string();
}
draft.add_from_inputs(crate::i18n::Locale::default()).unwrap();
assert_eq!(draft.added[0].name, field_type);
assert!(!draft.added[0].indexed);
assert!(!draft.is_indexable(0));
draft.toggle_indexed(0);
assert!(!draft.added[0].indexed);
}
}
#[test]
fn money_columns_require_a_valid_currency() {
let mut draft = draft();
draft.name_input = "total".to_string();
draft.type_input = "money".to_string();
draft.currency_input = "EU".to_string();
assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err());
draft.currency_input = "eur".to_string();
draft.add_from_inputs(crate::i18n::Locale::default()).unwrap();
assert_eq!(draft.added[0].currency, "EUR");
}
/// ACCOUNTING carries a currency and ACCOUNTING_TRANSFER does not, which
/// is the catalog's answer rather than this crate's.
#[test]
fn currency_follows_the_catalog_rather_than_the_type_name() {
let mut draft = draft();
draft.type_input = "accounting".to_string();
draft.currency_input = "czk".to_string();
draft.add_from_inputs(crate::i18n::Locale::default()).unwrap();
assert_eq!(draft.added[0].currency, "CZK");
assert_eq!(draft.added[0].option_label(&crate::i18n::Locale::default()), "CZK, exact");
draft.type_input = "accounting_transfer".to_string();
draft.currency_input = "czk".to_string();
draft.add_from_inputs(crate::i18n::Locale::default()).unwrap();
assert_eq!(draft.added[1].currency, "");
}
/// `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 = draft();
draft.added.push(ColumnDefinition {
name: "total".to_string(),
data_type: "money".to_string(),
indexed: false,
quantity_ledger: false,
required: false,
money_mode: MoneyMode::Exact,
currency: String::new(),
});
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
draft.added[0].currency = "XYZ".to_string();
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
draft.added[0].currency = "EUR".to_string();
assert!(draft.validate(crate::i18n::Locale::default()).is_ok());
// Forbidden on everything else, exactly as the server has it.
draft.added[0].data_type = "text".to_string();
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
}
/// And to the quantity-ledger rule, which the panel applies at add time.
#[test]
fn a_rebuilt_draft_is_still_held_to_the_quantity_ledger_rule() {
let mut draft = draft();
draft.added.push(ColumnDefinition {
name: "note".to_string(),
data_type: "text".to_string(),
indexed: false,
quantity_ledger: true,
required: false,
money_mode: MoneyMode::Exact,
currency: String::new(),
});
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
}
/// With no catalog there is no vocabulary, so nothing is accepted rather
/// than a guess being made at what the server takes.
#[test]
fn an_unloaded_catalog_refuses_every_type() {
let mut draft = ColumnDraft::new(ColumnCatalog::default());
draft.name_input = "number".to_string();
draft.type_input = "text".to_string();
assert!(!draft.catalog.is_loaded());
assert!(draft.offered_types().is_empty());
assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err());
}
/// The catalog also explains the types `GetProfileDetails` reports back,
/// including the companions a client may not declare.
#[test]
fn the_catalog_names_the_sql_type_behind_a_column() {
let catalog = catalog();
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"), "");
}
#[test]
fn mismatched_column_vectors_never_mis_pair() {
let (columns, ragged) = columns_from_rows(
&["number".to_string(), "total".to_string()],
&["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()],
);
assert_eq!(columns.len(), 1);
assert_eq!(columns[0].name, "number");
assert_eq!(columns[0].data_type, "text");
assert!(columns[0].indexed);
// And the truncation is reported rather than passed off as the draft
// the caller posted: two columns were sent, one survived the zip.
assert_eq!(ragged, Some(RaggedColumns { kept: 1, posted: 2 }));
}
/// The message a ragged post gets. It used to be "Add at least one column
/// before saving", which describes the *result* of dropping the caller's
/// columns instead of the mismatch that dropped them.
#[test]
fn a_ragged_post_says_how_many_columns_went_missing() {
let (columns, ragged) = columns_from_rows(
&["number".to_string(), "total".to_string()],
&[],
&[],
&[],
&[],
&[],
&[],
);
assert!(columns.is_empty());
let ragged = ragged.expect("two names and no other vector is ragged");
let message = ragged.message(crate::i18n::Locale::default());
assert!(message.contains('2'), "{message}");
assert!(!message.contains("at least one"), "{message}");
}
/// The declared order is the order the table gets its columns in, so it is
/// the user's to change — and a move off either end changes nothing rather
/// than wrapping around or panicking.
#[test]
fn a_column_moves_one_place_and_stops_at_the_ends() {
let mut draft = draft();
for name in ["number", "issued_on", "total"] {
draft.name_input = name.to_string();
draft.type_input = "text".to_string();
draft.add_from_inputs(crate::i18n::Locale::default()).unwrap();
}
let names = |draft: &ColumnDraft| {
draft
.added
.iter()
.map(|column| column.name.clone())
.collect::<Vec<_>>()
};
assert_eq!(
draft.move_column(crate::i18n::Locale::default(), 2, -1),
Some("Column `total` moved up.".to_string())
);
assert_eq!(names(&draft), ["number", "total", "issued_on"]);
draft.move_column(crate::i18n::Locale::default(), 0, 1).unwrap();
assert_eq!(names(&draft), ["total", "number", "issued_on"]);
// Off either end, and past the end of the list entirely.
assert_eq!(draft.move_column(crate::i18n::Locale::default(), 0, -1), None);
assert_eq!(draft.move_column(crate::i18n::Locale::default(), 2, 1), None);
assert_eq!(draft.move_column(crate::i18n::Locale::default(), 9, -1), None);
assert_eq!(names(&draft), ["total", "number", "issued_on"]);
}
/// The columns a definition row expands into are the server's answer,
/// carried by the catalog rather than written down here.
#[test]
fn a_compound_column_reports_what_it_expands_into() {
let mut draft = draft();
draft.type_input = "accounting".to_string();
assert!(draft.pending_is_compound());
assert_eq!(draft.pending_compound_name(), "accounting");
assert_eq!(
draft
.pending_generated_columns()
.iter()
.map(|generated| generated.name.as_str())
.collect::<Vec<_>>(),
["name", "tax_point_date", "debit", "credit", "account"]
);
draft.add_from_inputs(crate::i18n::Locale::default()).unwrap();
assert_eq!(draft.generated_columns_of(0).len(), 5);
// DEBIT and CREDIT are the two kept in the declared currency.
assert_eq!(
draft
.generated_columns_of(0)
.iter()
.filter(|generated| generated.inherits_currency)
.count(),
2
);
// An ordinary column expands into nothing, and its name is its own.
draft.name_input = "number".to_string();
draft.type_input = "text".to_string();
assert!(!draft.pending_is_compound());
draft.add_from_inputs(crate::i18n::Locale::default()).unwrap();
assert!(draft.generated_columns_of(1).is_empty());
}
#[test]
fn indexed_columns_become_the_index_list() {
let mut draft = draft();
draft.name_input = "number".to_string();
draft.type_input = "text".to_string();
draft.add_from_inputs(crate::i18n::Locale::default()).unwrap();
draft.toggle_indexed(0);
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(crate::i18n::Locale::default()).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(&crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).unwrap();
assert!(!draft.added[0].indexed);
draft.added[0].indexed = true;
let error = draft.validate(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).unwrap();
assert!(draft.is_indexable(0));
assert_eq!(draft.selected_index_names(), vec!["number"]);
assert!(draft.validate(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).is_err());
// The same column on a profile's own table is fine.
draft.global = false;
assert!(draft.show_quantity_ledger());
assert!(draft.validate(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).unwrap();
assert!(draft.validate(crate::i18n::Locale::default()).is_ok());
draft.table_name = "invoice".to_string();
assert!(draft.validate(crate::i18n::Locale::default()).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 = LEDGER_ACCOUNTS_TABLE.to_string();
let error = draft.add_from_inputs(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).unwrap();
reversed.type_input = "accounting".to_string();
reversed.currency_input = "EUR".to_string();
let error = reversed.add_from_inputs(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).unwrap();
}
assert!(phones.claimed_names().contains(&"work_phone_ext".to_string()));
assert!(phones.validate(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).unwrap();
assert!(draft.added[0].required);
assert!(draft.added[0].option_label(&crate::i18n::Locale::default()).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(crate::i18n::Locale::default(), &longest), None);
assert_eq!(validate_identifier(crate::i18n::Locale::default(), &longest, "Column name", true), None);
let too_long = "t".repeat(MAX_TABLE_NAME_LENGTH + 1);
assert!(validate_table_name(crate::i18n::Locale::default(), &too_long).is_some());
// Still a perfectly good column name, which is why the two differ.
assert_eq!(validate_identifier(crate::i18n::Locale::default(), &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(crate::i18n::Locale::default(), name)
.unwrap_or_else(|| panic!("`{name}` should be reserved"));
assert!(error.contains(name), "{error}");
}
assert_eq!(validate_table_name(crate::i18n::Locale::default(), "invoice"), None);
}
}
#[cfg(test)]
mod link_alias_tests {
use super::validate_identifier;
/// The `_id` suffix is an ordinary part of a column's name: nothing is
/// derived from a table name any more.
#[test]
fn a_column_name_may_end_in_id() {
assert_eq!(validate_identifier(crate::i18n::Locale::default(), "external_id", "Column name", true), None);
}
/// The system columns stay reserved, since they share one namespace with
/// user columns in a data request.
#[test]
fn the_system_columns_stay_reserved() {
for name in ["id", "deleted", "created_at", "row_revision"] {
assert!(
validate_identifier(crate::i18n::Locale::default(), name, "Column name", true).is_some(),
"`{name}` must stay reserved"
);
}
}
}