Files
komp_ac/web/src/pages/add_table/draft.rs
2026-08-13 19:05:41 +02:00

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(&currency).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"]);
}
}