870 lines
32 KiB
Rust
870 lines
32 KiB
Rust
//! The Add-table draft and its rules.
|
|
//!
|
|
//! This is a port of the TUI client's `pages/add_table/data.rs` core mechanics
|
|
//! with the terminal-specific parts removed (`ratatui` cursors, the canvas
|
|
//! `DataProvider` projection, and the `tr!` i18n macro). Every rule below —
|
|
//! canonicalisation, field visibility and validation — matches the client so
|
|
//! the two frontends accept and reject exactly the same table definitions.
|
|
//!
|
|
//! What a *column* may be is not here: that is [`crate::schema`], which the
|
|
//! append screen in `admin/table_definition` shares. This module is only what
|
|
//! is true of a table being created — its profile, its name, its columns, and
|
|
//! what identifies one of its rows.
|
|
|
|
use crate::{
|
|
definitions::table_definition::{GeneratedColumnAlias, PostTableDefinitionRequest},
|
|
schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier},
|
|
};
|
|
|
|
/// The compound type that also brings a system column with it. Which columns
|
|
/// it generates is the catalog's answer; the physical `account_id` is not one
|
|
/// of them, and [`TableDraft::preview_rows`] is where that is explained.
|
|
pub(crate) const ACCOUNTING_FIELD_TYPE: &str = "accounting";
|
|
|
|
/// The compound type whose connectors remain tied to their backend names.
|
|
pub(crate) const ACCOUNTING_TRANSFER_FIELD_TYPE: &str = "accounting_transfer";
|
|
|
|
/// The virtual field the ACCOUNTING foreign key is written as. It is a column
|
|
/// like any other once created, so it can be aliased too.
|
|
pub(crate) const ACCOUNT_API_COLUMN: &str = "account";
|
|
|
|
/// An alias asked for one of the columns a definition row generates.
|
|
///
|
|
/// A generated column cannot be named in the column list — the definition row
|
|
/// is named after its own type, and what it expands into is the backend's to
|
|
/// decide. The name is only ever a display name over a physical column, though,
|
|
/// so the request carries the name to use and the column is created under it.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(crate) struct GeneratedAlias {
|
|
/// The generated column's own name, which is what the rename asks for.
|
|
pub source: String,
|
|
/// What the user wants to see instead. Empty means "leave it alone".
|
|
pub alias: String,
|
|
}
|
|
|
|
/// One row of the "Table definition preview" — the schema as it will exist.
|
|
pub(crate) struct PreviewRow {
|
|
pub mark: String,
|
|
pub column: String,
|
|
pub data_type: String,
|
|
pub option: String,
|
|
pub source: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct RelationTableOption {
|
|
pub name: String,
|
|
pub global: bool,
|
|
pub system: bool,
|
|
}
|
|
|
|
/// The whole Add-table page state, minus presentation.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(crate) struct TableDraft {
|
|
/// Profile this table belongs to when an existing one was picked.
|
|
pub profile_name: String,
|
|
/// Profile name typed in when creating a new profile.
|
|
pub profile_name_input: String,
|
|
pub creating_new_profile: bool,
|
|
pub global: bool,
|
|
pub accounting_currency: String,
|
|
|
|
pub table_name: String,
|
|
|
|
/// The column panel: the pending column and the ones already described.
|
|
pub columns: ColumnDraft,
|
|
|
|
/// Tables in the target profile, offered as `link(...)` targets by the
|
|
/// column picker.
|
|
pub relation_tables: Vec<String>,
|
|
pub relation_table_options: Vec<RelationTableOption>,
|
|
/// Columns identifying a row to users, in the order they are shown.
|
|
/// Empty means rows are identified by their id alone.
|
|
pub row_display_columns: Vec<String>,
|
|
|
|
/// Tables already defined in the target profile — a new table may not
|
|
/// reuse one of these names.
|
|
pub existing_profile_tables: Vec<String>,
|
|
|
|
/// Names asked for the generated columns, sent with the request that
|
|
/// creates them.
|
|
pub generated_aliases: Vec<GeneratedAlias>,
|
|
}
|
|
|
|
impl TableDraft {
|
|
/// A draft for a brand-new page load, matching the client's defaults. The
|
|
/// column panel's vocabulary is filled in by the loader, which is the only
|
|
/// thing that knows it.
|
|
pub(crate) fn new() -> Self {
|
|
Self {
|
|
accounting_currency: "EUR".to_string(),
|
|
columns: ColumnDraft::new(ColumnCatalog::default()),
|
|
..Self::default()
|
|
}
|
|
}
|
|
|
|
// ---- field visibility (the same rules the TUI canvas applies) --------
|
|
|
|
pub(crate) fn show_profile_name_input(&self) -> bool {
|
|
self.creating_new_profile && !self.global
|
|
}
|
|
|
|
pub(crate) fn show_accounting_currency(&self) -> bool {
|
|
self.creating_new_profile && !self.global
|
|
}
|
|
|
|
// ---- mutations -------------------------------------------------------
|
|
|
|
/// Removes one column, and drops it from the display columns with it.
|
|
pub(crate) fn remove_column(&mut self, index: usize) -> Result<String, String> {
|
|
let removed = self.columns.remove(index)?;
|
|
self.row_display_columns
|
|
.retain(|display| display != &removed.name);
|
|
Ok(format!("Column `{}` removed.", removed.name))
|
|
}
|
|
|
|
/// Moves one column one place up or down the list.
|
|
///
|
|
/// The display columns keep their own order, which is the order they were
|
|
/// chosen in rather than the order the columns are declared in, so nothing
|
|
/// here touches them.
|
|
pub(crate) fn move_column(&mut self, index: usize, offset: isize) -> Option<String> {
|
|
self.columns.move_column(index, offset)
|
|
}
|
|
|
|
/// Adds or removes one display-column candidate.
|
|
///
|
|
/// Index 0 is `id`, which is not a display column of its own: choosing it
|
|
/// clears the list, since an empty list already means "identified by id".
|
|
/// Any other index toggles that column, appending so the order columns were
|
|
/// chosen in is the order they are shown in.
|
|
pub(crate) fn toggle_row_display_candidate(&mut self, index: usize) {
|
|
if index == 0 {
|
|
self.row_display_columns.clear();
|
|
return;
|
|
}
|
|
// A compound column leaves no column of its own name behind, so it can
|
|
// never identify a row; `row_display_candidates` does not offer one.
|
|
if !self.columns.is_indexable(index - 1) {
|
|
return;
|
|
}
|
|
let Some(column) = self
|
|
.columns
|
|
.added
|
|
.get(index - 1)
|
|
.map(|column| column.name.clone())
|
|
else {
|
|
return;
|
|
};
|
|
match self
|
|
.row_display_columns
|
|
.iter()
|
|
.position(|display| *display == column)
|
|
{
|
|
Some(position) => {
|
|
self.row_display_columns.remove(position);
|
|
}
|
|
None => self.row_display_columns.push(column),
|
|
}
|
|
}
|
|
|
|
/// Records the tables the target profile offers as link targets. A table
|
|
/// cannot link to itself, so its own name is never among them.
|
|
#[cfg(test)]
|
|
pub(crate) fn set_available_relation_tables(&mut self, table_names: Vec<String>) {
|
|
self.set_available_relation_table_options(
|
|
table_names
|
|
.into_iter()
|
|
.map(|name| RelationTableOption {
|
|
name,
|
|
global: false,
|
|
system: false,
|
|
})
|
|
.collect(),
|
|
);
|
|
}
|
|
|
|
pub(crate) fn set_available_relation_table_options(
|
|
&mut self,
|
|
options: Vec<RelationTableOption>,
|
|
) {
|
|
self.relation_table_options = options
|
|
.into_iter()
|
|
.filter(|option| option.name != self.table_name)
|
|
.collect();
|
|
self.relation_tables = self
|
|
.relation_table_options
|
|
.iter()
|
|
.map(|option| option.name.clone())
|
|
.collect();
|
|
}
|
|
|
|
pub(crate) fn global_relation_tables(&self) -> Vec<&str> {
|
|
self.relation_table_options
|
|
.iter()
|
|
.filter(|option| option.global)
|
|
.map(|option| option.name.as_str())
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn user_relation_tables(&self) -> Vec<&str> {
|
|
self.relation_table_options
|
|
.iter()
|
|
.filter(|option| !option.global && !option.system)
|
|
.map(|option| option.name.as_str())
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn system_relation_tables(&self) -> Vec<&str> {
|
|
self.relation_table_options
|
|
.iter()
|
|
.filter(|option| !option.global && option.system)
|
|
.map(|option| option.name.as_str())
|
|
.collect()
|
|
}
|
|
|
|
// ---- generated-column aliases ----------------------------------------
|
|
|
|
/// The generated columns this draft would let the user rename, in the order
|
|
/// they appear in the column list.
|
|
///
|
|
/// Every generated column whose relationship is recorded independently of
|
|
/// its display name. ACCOUNTING_TRANSFER's connectors are the exception:
|
|
/// the backend still resolves those by their fixed names.
|
|
pub(crate) fn aliasable_generated_columns(&self) -> Vec<String> {
|
|
let mut names = Vec::new();
|
|
for (index, column) in self.columns.added.iter().enumerate() {
|
|
if column.data_type == ACCOUNTING_TRANSFER_FIELD_TYPE {
|
|
continue;
|
|
}
|
|
let generated: Vec<String> = self
|
|
.columns
|
|
.generated_columns_of(index)
|
|
.iter()
|
|
.map(|generated| generated.name.clone())
|
|
.collect();
|
|
// A backend that reports `account` with the rest of ACCOUNTING's
|
|
// companions has already named it here; offering it a second time
|
|
// for the foreign key would put two fields on one column, and only
|
|
// the first of the two would be read.
|
|
let account_reported = generated.iter().any(|name| name == ACCOUNT_API_COLUMN);
|
|
names.extend(generated);
|
|
if column.data_type == ACCOUNTING_FIELD_TYPE && !account_reported {
|
|
names.push(ACCOUNT_API_COLUMN.to_string());
|
|
}
|
|
}
|
|
names
|
|
}
|
|
|
|
/// The alias typed for one generated column, empty when none was.
|
|
pub(crate) fn alias_for(&self, source: &str) -> &str {
|
|
self.generated_aliases
|
|
.iter()
|
|
.find(|entry| entry.source == source)
|
|
.map(|entry| entry.alias.trim())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// The name a generated column will be known by: its alias when one was
|
|
/// asked for, otherwise the name the backend gives it.
|
|
pub(crate) fn generated_display_name(&self, source: &str) -> String {
|
|
match self.alias_for(source) {
|
|
"" => source.to_string(),
|
|
alias => alias.to_string(),
|
|
}
|
|
}
|
|
|
|
/// The aliases the request carries: only the generated columns still in the
|
|
/// draft, and only where an alias was actually asked for.
|
|
///
|
|
/// Sending an alias for a column the request does not generate is an error
|
|
/// on the backend rather than a no-op, so a stale entry -- one left behind
|
|
/// by a column since removed -- is dropped here.
|
|
pub(crate) fn generated_alias_requests(&self) -> Vec<GeneratedColumnAlias> {
|
|
self.aliased_generated_columns()
|
|
.into_iter()
|
|
.map(|(generated_name, alias)| GeneratedColumnAlias {
|
|
generated_name,
|
|
alias,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn aliased_generated_columns(&self) -> Vec<(String, String)> {
|
|
self.aliasable_generated_columns()
|
|
.into_iter()
|
|
.filter_map(|source| {
|
|
let alias = self.alias_for(&source);
|
|
(!alias.is_empty() && alias != source).then(|| (source, alias.to_string()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Every alias has to be a legal column name, and has to be free: the table
|
|
/// is about to hold the declared columns, the generated ones and the system
|
|
/// ones, and two columns cannot share a name.
|
|
fn validate_generated_aliases(&self) -> Result<(), String> {
|
|
let generated = self.aliasable_generated_columns();
|
|
let renames = self.aliased_generated_columns();
|
|
|
|
// What the table would hold with every alias applied.
|
|
let mut taken: Vec<String> = vec![
|
|
"id".to_string(),
|
|
"deleted".to_string(),
|
|
"created_at".to_string(),
|
|
];
|
|
taken.extend(self.columns.added.iter().map(|column| column.name.clone()));
|
|
taken.extend(
|
|
generated
|
|
.iter()
|
|
.map(|source| self.generated_display_name(source)),
|
|
);
|
|
|
|
for (source, alias) in &renames {
|
|
if let Some(error) = validate_identifier(alias, "Column alias", true) {
|
|
return Err(error);
|
|
}
|
|
if taken.iter().filter(|name| *name == alias).count() > 1 {
|
|
return Err(format!(
|
|
"Alias `{alias}` for generated column `{source}` is already taken by another column."
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---- derived state ---------------------------------------------------
|
|
|
|
/// The profile name to validate and persist: the typed input while creating
|
|
/// a new profile, otherwise the profile that was picked.
|
|
pub(crate) fn effective_profile_name(&self) -> String {
|
|
if self.creating_new_profile {
|
|
self.profile_name_input.trim().to_string()
|
|
} else {
|
|
self.profile_name.clone()
|
|
}
|
|
}
|
|
|
|
pub(crate) fn table_name_conflicts(&self) -> bool {
|
|
!self.table_name.is_empty()
|
|
&& self
|
|
.existing_profile_tables
|
|
.iter()
|
|
.any(|name| name == &self.table_name)
|
|
}
|
|
|
|
/// Position of `column` among the display columns, counting from 1.
|
|
pub(crate) fn row_display_position(&self, column: &str) -> Option<usize> {
|
|
self.row_display_columns
|
|
.iter()
|
|
.position(|display| display == column)
|
|
.map(|index| index + 1)
|
|
}
|
|
|
|
/// The schema as it will exist: system columns, relation columns, then the
|
|
/// user's own. Mirrors the client's preview pane.
|
|
pub(crate) fn preview_rows(&self) -> Vec<PreviewRow> {
|
|
let mut rows = vec![
|
|
PreviewRow {
|
|
mark: if self.row_display_columns.is_empty() {
|
|
"[x]".to_string()
|
|
} else {
|
|
"[ ]".to_string()
|
|
},
|
|
column: "id".to_string(),
|
|
data_type: "BIGSERIAL".to_string(),
|
|
option: "primary key".to_string(),
|
|
source: "system".to_string(),
|
|
},
|
|
PreviewRow {
|
|
mark: String::new(),
|
|
column: "deleted".to_string(),
|
|
data_type: "BOOLEAN".to_string(),
|
|
option: "default false".to_string(),
|
|
source: "system".to_string(),
|
|
},
|
|
];
|
|
|
|
for (index, column) in self.columns.added.iter().enumerate() {
|
|
rows.push(PreviewRow {
|
|
mark: self
|
|
.row_display_position(&column.name)
|
|
.map(|position| format!("[{position}]"))
|
|
.unwrap_or_else(|| "[ ]".to_string()),
|
|
column: column.name.clone(),
|
|
data_type: column.data_type.clone(),
|
|
option: column.option_label(),
|
|
source: "user".to_string(),
|
|
});
|
|
|
|
// A compound column is a definition row: the columns the table
|
|
// really gets are the ones it expands into, created where the
|
|
// definition row sits. They are listed here so what a compound
|
|
// choice does is visible before the table exists.
|
|
for generated in self.columns.generated_columns_of(index) {
|
|
rows.push(PreviewRow {
|
|
mark: String::new(),
|
|
column: self.generated_display_name(&generated.name),
|
|
data_type: generated.data_type.clone(),
|
|
option: if generated.inherits_currency {
|
|
format!("{}, {}", column.currency, column.money_mode.label())
|
|
} else {
|
|
String::new()
|
|
},
|
|
source: "generated".to_string(),
|
|
});
|
|
}
|
|
|
|
// The one companion the catalog does not report, because it is a
|
|
// system column rather than a user one: ACCOUNTING's foreign key to
|
|
// the profile's accounts, which the data API exposes as the virtual
|
|
// `account` field.
|
|
if column.data_type == ACCOUNTING_FIELD_TYPE {
|
|
rows.push(PreviewRow {
|
|
mark: String::new(),
|
|
column: "account_id".to_string(),
|
|
data_type: "BIGINT".to_string(),
|
|
option: format!(
|
|
"not null, → accounts, written as {}",
|
|
self.generated_display_name(ACCOUNT_API_COLUMN)
|
|
),
|
|
source: "system".to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
rows.push(PreviewRow {
|
|
mark: String::new(),
|
|
column: "created_at".to_string(),
|
|
data_type: "TIMESTAMPTZ".to_string(),
|
|
option: "current time".to_string(),
|
|
source: "system".to_string(),
|
|
});
|
|
rows
|
|
}
|
|
|
|
// ---- validation and submission ---------------------------------------
|
|
|
|
/// Every check the client runs before it will save.
|
|
pub(crate) fn validate(&self) -> Result<(), String> {
|
|
let profile_name = self.effective_profile_name();
|
|
if !self.global && self.creating_new_profile && profile_name.is_empty() {
|
|
return Err("Enter a name for the new profile.".to_string());
|
|
}
|
|
if !self.global && let Some(error) = validate_identifier(&profile_name, "Profile name", false) {
|
|
return Err(error);
|
|
}
|
|
if let Some(error) = validate_accounting_currency(self) {
|
|
return Err(error);
|
|
}
|
|
if let Some(error) = validate_identifier(self.table_name.trim(), "Table name", true) {
|
|
return Err(error);
|
|
}
|
|
if self.table_name_conflicts() {
|
|
return Err(format!(
|
|
"A table named `{}` already exists in profile `{}`.",
|
|
self.table_name, profile_name
|
|
));
|
|
}
|
|
if self.columns.is_empty() {
|
|
return Err("Add at least one column before saving.".to_string());
|
|
}
|
|
self.validate_generated_aliases()?;
|
|
self.columns.validate()
|
|
}
|
|
|
|
pub(crate) fn into_request(mut self) -> Result<PostTableDefinitionRequest, String> {
|
|
self.table_name = self.table_name.trim().to_string();
|
|
self.validate()?;
|
|
|
|
Ok(PostTableDefinitionRequest {
|
|
table_name: self.table_name.clone(),
|
|
profile_name: self.effective_profile_name(),
|
|
columns: proto_columns(&self.columns.added),
|
|
indexes: self.columns.selected_index_names(),
|
|
accounting_currency: if self.creating_new_profile && !self.global {
|
|
self.accounting_currency.trim().to_ascii_uppercase()
|
|
} else {
|
|
String::new()
|
|
},
|
|
row_display_columns: self.row_display_columns.clone(),
|
|
global: self.global,
|
|
// The generated columns are named in the same request that creates
|
|
// them, so the table is never briefly live under names the user did
|
|
// not ask for.
|
|
generated_aliases: self.generated_alias_requests(),
|
|
})
|
|
}
|
|
}
|
|
|
|
pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option<String> {
|
|
if !draft.creating_new_profile || draft.global {
|
|
return None;
|
|
}
|
|
let currency = draft.accounting_currency.to_ascii_uppercase();
|
|
if rusty_money::iso::find(¤cy).is_none() {
|
|
return Some("Accounting currency must be a three-letter ISO-4217 code".to_string());
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::schema::{ColumnDefinition, MoneyMode};
|
|
|
|
fn draft_with_column(name: &str, data_type: &str) -> TableDraft {
|
|
let mut draft = TableDraft::new();
|
|
// The loader fills this in from the backend; a draft under test gets
|
|
// the same vocabulary directly.
|
|
draft.columns.catalog = crate::schema::tests::catalog();
|
|
draft.profile_name = "billing".to_string();
|
|
draft.table_name = "invoice".to_string();
|
|
draft.columns.added.push(ColumnDefinition {
|
|
name: name.to_string(),
|
|
data_type: data_type.to_string(),
|
|
indexed: false,
|
|
quantity_ledger: false,
|
|
money_mode: MoneyMode::Exact,
|
|
currency: if matches!(data_type, "money" | "accounting") {
|
|
"EUR".to_string()
|
|
} else {
|
|
String::new()
|
|
},
|
|
});
|
|
draft
|
|
}
|
|
|
|
#[test]
|
|
fn new_profile_accounting_currency_must_exist_in_the_iso_registry() {
|
|
let mut draft = draft_with_column("total", "int");
|
|
draft.creating_new_profile = true;
|
|
draft.profile_name_input = "billing".to_string();
|
|
|
|
draft.accounting_currency = "AAA".to_string();
|
|
assert!(draft.validate().is_err());
|
|
|
|
draft.accounting_currency = "eur".to_string();
|
|
assert!(draft.validate().is_ok());
|
|
assert_eq!(draft.into_request().unwrap().accounting_currency, "EUR");
|
|
}
|
|
|
|
/// Every column an ACCOUNTING row generates may be aliased, including the
|
|
/// account foreign key the catalog does not report.
|
|
#[test]
|
|
fn accounting_generates_columns_that_can_all_be_aliased() {
|
|
let draft = draft_with_column("accounting", "accounting");
|
|
|
|
assert_eq!(
|
|
draft.aliasable_generated_columns(),
|
|
["name", "tax_point_date", "debit", "credit", "account"]
|
|
);
|
|
}
|
|
|
|
/// ACCOUNTING, PHONE and IBAN companions may be aliased. Transfer
|
|
/// connectors remain fixed because the posting engine resolves their names.
|
|
#[test]
|
|
fn every_recorded_generated_column_is_aliasable() {
|
|
let phone = draft_with_column("work_phone", "phone");
|
|
assert_eq!(
|
|
phone.aliasable_generated_columns(),
|
|
[
|
|
"work_phone_ext",
|
|
"work_phone_type",
|
|
"work_phone_country",
|
|
"work_phone_calling_code",
|
|
]
|
|
);
|
|
|
|
let iban = draft_with_column("bank_account", "iban");
|
|
assert_eq!(
|
|
iban.aliasable_generated_columns(),
|
|
[
|
|
"bank_account_country",
|
|
"bank_account_bban",
|
|
"bank_account_bank_identifier",
|
|
"bank_account_branch_identifier",
|
|
]
|
|
);
|
|
|
|
let transfer = draft_with_column("accounting_transfer", "accounting_transfer");
|
|
assert!(transfer.aliasable_generated_columns().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn an_alias_is_sent_only_when_it_changes_the_name() {
|
|
let mut draft = draft_with_column("accounting", "accounting");
|
|
draft.generated_aliases = vec![
|
|
GeneratedAlias {
|
|
source: "debit".to_string(),
|
|
alias: "md".to_string(),
|
|
},
|
|
GeneratedAlias {
|
|
source: "credit".to_string(),
|
|
alias: "credit".to_string(),
|
|
},
|
|
GeneratedAlias {
|
|
source: "name".to_string(),
|
|
alias: String::new(),
|
|
},
|
|
// A stale pair for a column that is no longer in the draft.
|
|
GeneratedAlias {
|
|
source: "source_period_id".to_string(),
|
|
alias: "start".to_string(),
|
|
},
|
|
];
|
|
|
|
assert_eq!(
|
|
draft.aliased_generated_columns(),
|
|
[("debit".to_string(), "md".to_string())]
|
|
);
|
|
assert_eq!(draft.generated_display_name("debit"), "md");
|
|
assert_eq!(draft.generated_display_name("credit"), "credit");
|
|
assert!(draft.validate().is_ok());
|
|
assert!(
|
|
draft
|
|
.preview_rows()
|
|
.iter()
|
|
.any(|row| row.column == "md" && row.source == "generated")
|
|
);
|
|
|
|
// The request that creates the columns is the request that names them.
|
|
let request = draft.into_request().unwrap();
|
|
assert_eq!(request.generated_aliases.len(), 1);
|
|
assert_eq!(request.generated_aliases[0].generated_name, "debit");
|
|
assert_eq!(request.generated_aliases[0].alias, "md");
|
|
}
|
|
|
|
#[test]
|
|
fn an_alias_must_be_a_legal_and_free_column_name() {
|
|
let mut draft = draft_with_column("accounting", "accounting");
|
|
draft.columns.added.push(ColumnDefinition {
|
|
name: "note".to_string(),
|
|
data_type: "text".to_string(),
|
|
indexed: false,
|
|
quantity_ledger: false,
|
|
money_mode: MoneyMode::Exact,
|
|
currency: String::new(),
|
|
});
|
|
|
|
draft.generated_aliases = vec![GeneratedAlias {
|
|
source: "debit".to_string(),
|
|
alias: "Md".to_string(),
|
|
}];
|
|
assert!(draft.validate().is_err(), "an alias is a column name");
|
|
|
|
draft.generated_aliases[0].alias = "note".to_string();
|
|
assert!(draft.validate().is_err(), "a declared column holds the name");
|
|
|
|
draft.generated_aliases[0].alias = "credit".to_string();
|
|
assert!(draft.validate().is_err(), "another generated column does");
|
|
|
|
draft.generated_aliases[0].alias = "id".to_string();
|
|
assert!(draft.validate().is_err(), "a system column does");
|
|
|
|
draft.generated_aliases[0].alias = "md".to_string();
|
|
assert!(draft.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn existing_profile_sends_no_accounting_currency() {
|
|
let draft = draft_with_column("total", "int");
|
|
let request = draft.into_request().unwrap();
|
|
|
|
assert_eq!(request.accounting_currency, "");
|
|
assert_eq!(request.profile_name, "billing");
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_table_names_in_the_profile_are_refused() {
|
|
let mut draft = draft_with_column("total", "int");
|
|
draft.existing_profile_tables = vec!["invoice".to_string()];
|
|
|
|
assert!(draft.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn reserved_profile_names_are_refused() {
|
|
let mut draft = draft_with_column("total", "int");
|
|
draft.profile_name = "pg_catalog".to_string();
|
|
|
|
assert!(draft.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_table_with_no_columns_is_refused() {
|
|
let mut draft = draft_with_column("total", "int");
|
|
draft.remove_column(0).unwrap();
|
|
|
|
assert!(draft.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_table_never_offers_itself_as_a_link_target() {
|
|
let mut draft = TableDraft::new();
|
|
draft.table_name = "invoice".into();
|
|
draft.set_available_relation_tables(vec!["invoice".into(), "customer".into()]);
|
|
|
|
assert_eq!(draft.relation_tables, vec!["customer".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn row_display_columns_toggle_in_the_order_they_were_chosen() {
|
|
let mut draft = draft_with_column("number", "text");
|
|
draft.columns.added.push(ColumnDefinition {
|
|
name: "issued_on".to_string(),
|
|
data_type: "date".to_string(),
|
|
indexed: false,
|
|
quantity_ledger: false,
|
|
money_mode: MoneyMode::Exact,
|
|
currency: String::new(),
|
|
});
|
|
|
|
draft.toggle_row_display_candidate(2); // issued_on
|
|
draft.toggle_row_display_candidate(1); // number
|
|
assert_eq!(draft.row_display_columns, vec!["issued_on", "number"]);
|
|
assert_eq!(draft.row_display_position("number"), Some(2));
|
|
|
|
// Index 0 is `id`: choosing it clears the list.
|
|
draft.toggle_row_display_candidate(0);
|
|
assert!(draft.row_display_columns.is_empty());
|
|
}
|
|
|
|
/// A compound column expands into schema-managed companions, so there is
|
|
/// no column of that name for a row to be identified by — the builder does
|
|
/// not offer it, and a crafted post cannot choose it either.
|
|
#[test]
|
|
fn a_compound_column_never_identifies_a_row() {
|
|
let mut draft = draft_with_column("number", "text");
|
|
draft.columns.added.push(ColumnDefinition {
|
|
name: "accounting".to_string(),
|
|
data_type: "accounting".to_string(),
|
|
indexed: false,
|
|
quantity_ledger: false,
|
|
money_mode: MoneyMode::Exact,
|
|
currency: "EUR".to_string(),
|
|
});
|
|
|
|
draft.toggle_row_display_candidate(2);
|
|
|
|
assert!(draft.row_display_columns.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn removing_a_column_drops_it_from_the_display_columns() {
|
|
let mut draft = draft_with_column("number", "text");
|
|
draft.toggle_row_display_candidate(1);
|
|
assert_eq!(draft.row_display_columns, vec!["number"]);
|
|
|
|
draft.remove_column(0).unwrap();
|
|
assert!(draft.row_display_columns.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_preview_shows_system_and_user_columns() {
|
|
let draft = draft_with_column("number", "text");
|
|
|
|
let rows = draft.preview_rows();
|
|
let columns = rows
|
|
.iter()
|
|
.map(|row| row.column.as_str())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(columns, vec!["id", "deleted", "number", "created_at"]);
|
|
// No display column chosen, so `id` identifies the row.
|
|
assert_eq!(rows[0].mark, "[x]");
|
|
}
|
|
|
|
/// Moving a column changes the order the table is declared in and nothing
|
|
/// else — the display columns are shown in the order they were chosen in,
|
|
/// which is not this order.
|
|
#[test]
|
|
fn moving_a_column_leaves_the_display_columns_alone() {
|
|
let mut draft = draft_with_column("number", "text");
|
|
draft.columns.added.push(ColumnDefinition {
|
|
name: "issued_on".to_string(),
|
|
data_type: "date".to_string(),
|
|
indexed: false,
|
|
quantity_ledger: false,
|
|
money_mode: MoneyMode::Exact,
|
|
currency: String::new(),
|
|
});
|
|
draft.toggle_row_display_candidate(1); // number
|
|
draft.toggle_row_display_candidate(2); // issued_on
|
|
|
|
draft.move_column(0, 1).unwrap();
|
|
|
|
assert_eq!(
|
|
draft
|
|
.columns
|
|
.added
|
|
.iter()
|
|
.map(|column| column.name.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["issued_on", "number"]
|
|
);
|
|
assert_eq!(draft.row_display_columns, vec!["number", "issued_on"]);
|
|
}
|
|
|
|
/// The point of the preview: a definition row is not one column, and the
|
|
/// columns it stands for are the ones the table will really have.
|
|
#[test]
|
|
fn the_preview_expands_an_accounting_column() {
|
|
let mut draft = draft_with_column("number", "text");
|
|
draft.columns.added.push(ColumnDefinition {
|
|
name: "accounting".to_string(),
|
|
data_type: "accounting".to_string(),
|
|
indexed: false,
|
|
quantity_ledger: false,
|
|
money_mode: MoneyMode::Rounded,
|
|
currency: "CZK".to_string(),
|
|
});
|
|
|
|
let rows = draft.preview_rows();
|
|
let columns = rows
|
|
.iter()
|
|
.map(|row| row.column.as_str())
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
columns,
|
|
vec![
|
|
"id",
|
|
"deleted",
|
|
"number",
|
|
"accounting",
|
|
"name",
|
|
"tax_point_date",
|
|
"debit",
|
|
"credit",
|
|
// The foreign key to the profile's accounts, once as the
|
|
// column the backend reports and once as the physical column
|
|
// it is stored in.
|
|
"account",
|
|
"account_id",
|
|
"created_at",
|
|
]
|
|
);
|
|
|
|
let row = |name: &str| {
|
|
rows.iter()
|
|
.find(|row| row.column == name)
|
|
.unwrap_or_else(|| panic!("`{name}` is missing from the preview"))
|
|
};
|
|
assert_eq!(row("name").source, "generated");
|
|
assert_eq!(row("tax_point_date").data_type, "date");
|
|
// DEBIT and CREDIT are kept in the definition row's own currency.
|
|
assert_eq!(row("debit").option, "CZK, half-up");
|
|
assert_eq!(row("name").option, "");
|
|
assert_eq!(row("account_id").source, "system");
|
|
}
|
|
|
|
#[test]
|
|
fn indexed_columns_become_the_index_list() {
|
|
let mut draft = draft_with_column("number", "text");
|
|
draft.columns.toggle_indexed(0);
|
|
|
|
assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]);
|
|
}
|
|
}
|