web fixes
This commit is contained in:
@@ -13,16 +13,15 @@
|
||||
|
||||
use crate::{
|
||||
definitions::table_definition::{GeneratedColumnAlias, PostTableDefinitionRequest},
|
||||
schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier},
|
||||
schema::{
|
||||
ColumnCatalog, ColumnDraft, proto_columns, validate_identifier, validate_table_name,
|
||||
},
|
||||
};
|
||||
|
||||
/// 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";
|
||||
pub(crate) use crate::schema::{ACCOUNTING_FIELD_TYPE, ACCOUNTING_TRANSFER_FIELD_TYPE};
|
||||
|
||||
/// 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.
|
||||
@@ -143,17 +142,9 @@ impl TableDraft {
|
||||
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 {
|
||||
// Whatever is not on the candidate list is not the user's to choose,
|
||||
// so a crafted post naming a definition row changes nothing.
|
||||
let Some(column) = self.row_display_column_names().get(index - 1).cloned() else {
|
||||
return;
|
||||
};
|
||||
match self
|
||||
@@ -168,6 +159,38 @@ impl TableDraft {
|
||||
}
|
||||
}
|
||||
|
||||
/// The columns a row may be identified by, in the order the table declares
|
||||
/// them.
|
||||
///
|
||||
/// Every column the table will really hold: the declared ones, and what the
|
||||
/// definition rows among them expand into — under the names they were
|
||||
/// aliased to, which is what the table will carry. A definition row itself
|
||||
/// is not one, having no column of its own name.
|
||||
pub(crate) fn row_display_column_names(&self) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
for (index, column) in self.columns.added.iter().enumerate() {
|
||||
if self.columns.can_identify_row(index) {
|
||||
names.push(column.name.clone());
|
||||
}
|
||||
let generated = self.columns.generated_columns_of(index);
|
||||
let account_reported = generated
|
||||
.iter()
|
||||
.any(|generated| generated.name == ACCOUNT_API_COLUMN);
|
||||
names.extend(
|
||||
generated
|
||||
.iter()
|
||||
.map(|generated| self.generated_display_name(&generated.name)),
|
||||
);
|
||||
// The account link is a column of the table like the rest of
|
||||
// ACCOUNTING's companions, whether or not the catalog reports it
|
||||
// among them.
|
||||
if column.data_type == ACCOUNTING_FIELD_TYPE && !account_reported {
|
||||
names.push(self.generated_display_name(ACCOUNT_API_COLUMN));
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -316,6 +339,7 @@ impl TableDraft {
|
||||
let mut taken: Vec<String> = vec![
|
||||
"id".to_string(),
|
||||
"deleted".to_string(),
|
||||
"row_revision".to_string(),
|
||||
"created_at".to_string(),
|
||||
];
|
||||
taken.extend(self.columns.added.iter().map(|column| column.name.clone()));
|
||||
@@ -388,6 +412,15 @@ impl TableDraft {
|
||||
option: "default false".to_string(),
|
||||
source: "system".to_string(),
|
||||
},
|
||||
// Every managed table carries one, and the preview claims to be the
|
||||
// schema as it will exist.
|
||||
PreviewRow {
|
||||
mark: String::new(),
|
||||
column: "row_revision".to_string(),
|
||||
data_type: "BIGINT".to_string(),
|
||||
option: "not null, default 1".to_string(),
|
||||
source: "system".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
for (index, column) in self.columns.added.iter().enumerate() {
|
||||
@@ -462,14 +495,24 @@ impl TableDraft {
|
||||
if let Some(error) = validate_accounting_currency(self) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = validate_identifier(self.table_name.trim(), "Table name", true) {
|
||||
if let Some(error) = validate_table_name(self.table_name.trim()) {
|
||||
return Err(error);
|
||||
}
|
||||
if self.table_name_conflicts() {
|
||||
return Err(format!(
|
||||
"A table named `{}` already exists in profile `{}`.",
|
||||
self.table_name, profile_name
|
||||
));
|
||||
// A shared table lands in every profile at once, so its name has to
|
||||
// be free in all of them — and naming a profile in that message
|
||||
// would name the wrong thing, there being none.
|
||||
return Err(if self.global {
|
||||
format!(
|
||||
"A table named `{}` already exists. A shared table's name has to be free in every profile.",
|
||||
self.table_name
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"A table named `{}` already exists in profile `{profile_name}`, or is shared by every profile.",
|
||||
self.table_name
|
||||
)
|
||||
});
|
||||
}
|
||||
if self.columns.is_empty() {
|
||||
return Err("Add at least one column before saving.".to_string());
|
||||
@@ -530,6 +573,7 @@ mod tests {
|
||||
data_type: data_type.to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: if matches!(data_type, "money" | "accounting") {
|
||||
"EUR".to_string()
|
||||
@@ -648,6 +692,7 @@ mod tests {
|
||||
data_type: "text".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
@@ -685,7 +730,15 @@ mod tests {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
draft.existing_profile_tables = vec!["invoice".to_string()];
|
||||
|
||||
assert!(draft.validate().is_err());
|
||||
let error = draft.validate().unwrap_err();
|
||||
assert!(error.contains("profile `billing`"), "{error}");
|
||||
|
||||
// A shared table has no profile to name, and its name has to be free
|
||||
// everywhere rather than in one place.
|
||||
draft.global = true;
|
||||
let error = draft.validate().unwrap_err();
|
||||
assert!(!error.contains("profile ``"), "{error}");
|
||||
assert!(error.contains("every profile"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -721,6 +774,7 @@ mod tests {
|
||||
data_type: "date".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
@@ -735,24 +789,49 @@ mod tests {
|
||||
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.
|
||||
/// A definition row expands into schema-managed companions, so there is no
|
||||
/// column of *its* name for a row to be identified by — but the columns it
|
||||
/// expands into are columns like any other, and the server takes them here.
|
||||
/// Offering only the declared ones is what left a table whose only readable
|
||||
/// column was generated with nothing to be shown by.
|
||||
#[test]
|
||||
fn a_compound_column_never_identifies_a_row() {
|
||||
fn a_row_is_identified_by_the_columns_the_table_really_holds() {
|
||||
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,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
|
||||
draft.toggle_row_display_candidate(2);
|
||||
// The declared column, then what the definition row generates — and
|
||||
// never the definition row itself.
|
||||
assert_eq!(
|
||||
draft.row_display_column_names(),
|
||||
["number", "name", "tax_point_date", "debit", "credit", "account"]
|
||||
);
|
||||
assert!(!draft.row_display_column_names().contains(&"accounting".to_string()));
|
||||
|
||||
assert!(draft.row_display_columns.is_empty());
|
||||
// `name` is the second candidate, and choosing it names the generated
|
||||
// column rather than the row it came from.
|
||||
draft.toggle_row_display_candidate(2);
|
||||
assert_eq!(draft.row_display_columns, vec!["name"]);
|
||||
|
||||
// Under the name it was aliased to, which is what the table will carry.
|
||||
draft.toggle_row_display_candidate(2);
|
||||
draft.generated_aliases = vec![GeneratedAlias {
|
||||
source: "name".to_string(),
|
||||
alias: "popis".to_string(),
|
||||
}];
|
||||
draft.toggle_row_display_candidate(2);
|
||||
assert_eq!(draft.row_display_columns, vec!["popis"]);
|
||||
|
||||
// Past the end of the candidate list nothing is chosen.
|
||||
draft.toggle_row_display_candidate(99);
|
||||
assert_eq!(draft.row_display_columns, vec!["popis"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -775,7 +854,10 @@ mod tests {
|
||||
.map(|row| row.column.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(columns, vec!["id", "deleted", "number", "created_at"]);
|
||||
assert_eq!(
|
||||
columns,
|
||||
vec!["id", "deleted", "row_revision", "number", "created_at"]
|
||||
);
|
||||
// No display column chosen, so `id` identifies the row.
|
||||
assert_eq!(rows[0].mark, "[x]");
|
||||
}
|
||||
@@ -791,6 +873,7 @@ mod tests {
|
||||
data_type: "date".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
@@ -821,6 +904,7 @@ mod tests {
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Rounded,
|
||||
currency: "CZK".to_string(),
|
||||
});
|
||||
@@ -836,6 +920,7 @@ mod tests {
|
||||
vec![
|
||||
"id",
|
||||
"deleted",
|
||||
"row_revision",
|
||||
"number",
|
||||
"accounting",
|
||||
"name",
|
||||
@@ -871,4 +956,98 @@ mod tests {
|
||||
|
||||
assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]);
|
||||
}
|
||||
|
||||
/// The request the builder sends is one the server will take: a link
|
||||
/// carries no index of the request's own — the server makes that one — and
|
||||
/// `required` travels with the column it belongs to.
|
||||
#[test]
|
||||
fn the_request_names_no_link_among_its_indexes_and_carries_required() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
draft.columns.added[0].required = true;
|
||||
draft.columns.added.push(ColumnDefinition {
|
||||
name: "billing_customer".to_string(),
|
||||
data_type: "link(customer)".to_string(),
|
||||
// As a tampered post would have it.
|
||||
indexed: true,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
|
||||
// Refused rather than quietly repaired, because the server refuses it.
|
||||
let error = draft.clone().into_request().unwrap_err();
|
||||
assert!(error.contains("indexed automatically"), "{error}");
|
||||
|
||||
draft.columns.added[1].indexed = false;
|
||||
draft.columns.toggle_indexed(0);
|
||||
let request = draft.into_request().unwrap();
|
||||
|
||||
assert_eq!(request.indexes, vec!["number"]);
|
||||
assert!(request.columns[0].required);
|
||||
assert!(!request.columns[1].required);
|
||||
}
|
||||
|
||||
/// A table name has less room than a column name, and the names every
|
||||
/// profile's own tables carry are not free.
|
||||
#[test]
|
||||
fn a_table_name_is_held_to_the_rules_a_table_name_has() {
|
||||
let mut draft = draft_with_column("total", "int");
|
||||
|
||||
draft.table_name = "t".repeat(39);
|
||||
let error = draft.validate().unwrap_err();
|
||||
assert!(error.contains("38 characters"), "{error}");
|
||||
|
||||
draft.table_name = "accounts".to_string();
|
||||
assert!(draft.validate().is_err());
|
||||
draft.table_name = "general_ledger".to_string();
|
||||
assert!(draft.validate().is_err());
|
||||
|
||||
draft.table_name = "invoice".to_string();
|
||||
assert!(draft.validate().is_ok());
|
||||
}
|
||||
|
||||
/// A shared table has no books of its own, so the two definition rows that
|
||||
/// post to a profile's books are refused on one.
|
||||
#[test]
|
||||
fn a_shared_table_refuses_the_columns_that_belong_to_one_profile() {
|
||||
let mut draft = draft_with_column("accounting", "accounting");
|
||||
draft.global = true;
|
||||
draft.columns.global = true;
|
||||
|
||||
assert!(draft.validate().is_err());
|
||||
|
||||
draft.columns.added.clear();
|
||||
draft.columns.added.push(ColumnDefinition {
|
||||
name: "quantity".to_string(),
|
||||
data_type: "int".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: true,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
assert!(draft.validate().is_err());
|
||||
}
|
||||
|
||||
/// The preview is the schema as it will exist, so it carries every system
|
||||
/// column the server puts on a managed table — including the one an alias
|
||||
/// may therefore not take.
|
||||
#[test]
|
||||
fn the_preview_and_the_aliases_know_every_system_column() {
|
||||
let draft = draft_with_column("number", "text");
|
||||
assert!(
|
||||
draft
|
||||
.preview_rows()
|
||||
.iter()
|
||||
.any(|row| row.column == "row_revision" && row.source == "system")
|
||||
);
|
||||
|
||||
let mut draft = draft_with_column("accounting", "accounting");
|
||||
draft.generated_aliases = vec![GeneratedAlias {
|
||||
source: "name".to_string(),
|
||||
alias: "row_revision".to_string(),
|
||||
}];
|
||||
assert!(draft.validate_generated_aliases().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@ pub(crate) async fn load_page(
|
||||
.column_types,
|
||||
);
|
||||
|
||||
// The panel holds a column to the rules of the table it is being described
|
||||
// for, so it is told what that table is on every render rather than left to
|
||||
// read a copy that a `refresh` could have moved on from.
|
||||
draft.columns.global = draft.global;
|
||||
draft.columns.table_name = draft.table_name.trim().to_ascii_lowercase();
|
||||
|
||||
let tree = definitions
|
||||
.get_profile_tree(
|
||||
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,
|
||||
@@ -88,7 +94,7 @@ pub(crate) async fn load_page(
|
||||
.into_iter()
|
||||
// The profile's ledger accounts are not a link target of their own: an
|
||||
// ACCOUNTING column is how a row is posted to one.
|
||||
.filter(|table| table.name != "accounts")
|
||||
.filter(|table| table.name != crate::schema::ACCOUNTS_TABLE)
|
||||
.map(|table| RelationTableOption {
|
||||
name: table.name,
|
||||
global: table.global,
|
||||
|
||||
@@ -176,6 +176,16 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) {
|
||||
},
|
||||
"toggle-index" => page.draft.columns.toggle_indexed(index),
|
||||
"toggle-display" => page.draft.toggle_row_display_candidate(index),
|
||||
// Picking a scope or a type only changes which fields apply, so there
|
||||
// is nothing to do — except when the change has just made a column
|
||||
// already in the list impossible. Switching to the shared scope is the
|
||||
// one that does: the picker stops offering the columns that post to a
|
||||
// profile's books, and one added before the switch is still there.
|
||||
"refresh" => {
|
||||
if let Err(message) = page.draft.columns.validate() {
|
||||
page.error = Some(message);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -197,3 +207,57 @@ fn load_error_response(error: LoadError) -> Response {
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::schema::{ColumnDefinition, MoneyMode};
|
||||
|
||||
fn page_with_accounting() -> AddTablePageState {
|
||||
let mut draft = TableDraft::new();
|
||||
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: "accounting".to_string(),
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
AddTablePageState {
|
||||
nav: crate::ui::Nav::default(),
|
||||
profiles: vec!["billing".to_string()],
|
||||
draft,
|
||||
status: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh() -> BuilderForm {
|
||||
BuilderForm {
|
||||
action: "refresh".to_string(),
|
||||
..BuilderForm::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Switching an ACCOUNTING draft to the shared scope says so at once. Left
|
||||
/// to the save, the column stays in a list whose picker no longer offers it
|
||||
/// and the reason only arrives after the Create button.
|
||||
#[test]
|
||||
fn switching_to_the_shared_scope_reports_a_column_that_cannot_come_along() {
|
||||
let mut page = page_with_accounting();
|
||||
apply_action(&mut page, &refresh());
|
||||
assert!(page.error.is_none(), "nothing is wrong yet");
|
||||
|
||||
// What the loader does with the posted scope before the action runs.
|
||||
page.draft.global = true;
|
||||
page.draft.columns.global = true;
|
||||
apply_action(&mut page, &refresh());
|
||||
|
||||
let error = page.error.expect("the switch is refused out loud");
|
||||
assert!(error.contains("shared table"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ pub(crate) struct BuilderForm {
|
||||
#[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,
|
||||
@@ -75,6 +77,8 @@ pub(crate) struct BuilderForm {
|
||||
#[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>,
|
||||
@@ -113,6 +117,7 @@ impl BuilderForm {
|
||||
decimal_scale_input: self.decimal_scale_input.clone(),
|
||||
indexing_input: self.column_indexing_input.clone(),
|
||||
quantity_ledger_input: self.column_quantity_ledger_input.clone(),
|
||||
required_input: self.column_required_input.clone(),
|
||||
rounding_input: self.column_rounding_input.clone(),
|
||||
currency_input: self.column_currency_input.clone(),
|
||||
added: columns_from_rows(
|
||||
@@ -120,6 +125,7 @@ impl BuilderForm {
|
||||
&self.column_types,
|
||||
&self.column_indexed,
|
||||
&self.column_quantity_ledger,
|
||||
&self.column_required,
|
||||
&self.column_rounding,
|
||||
&self.column_currencies,
|
||||
),
|
||||
@@ -129,6 +135,12 @@ impl BuilderForm {
|
||||
// The table is being created here, so the creation-only types are
|
||||
// on the table.
|
||||
creating_table: true,
|
||||
// What the table will be, so the panel can hold a column to the
|
||||
// rules of the table it is being described for: a shared table
|
||||
// takes no column that posts to one profile's books, and no link
|
||||
// may point at the table being created.
|
||||
global: self.global,
|
||||
table_name: self.table_name.trim().to_ascii_lowercase(),
|
||||
};
|
||||
|
||||
// Drop display columns whose column is gone, so a stale post cannot
|
||||
@@ -206,12 +218,17 @@ impl AddTablePageState {
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for (index, column) in columns.added.iter().enumerate() {
|
||||
// Whether it is indexed has a column of its own, so it is not
|
||||
// repeated here.
|
||||
let mut tags = Vec::new();
|
||||
if column.required {
|
||||
tags.push("required".to_string());
|
||||
}
|
||||
if column.quantity_ledger {
|
||||
tags.push("quantity ledger".to_string());
|
||||
}
|
||||
if !column.option_label().is_empty() {
|
||||
tags.push(column.option_label());
|
||||
if !column.currency.is_empty() {
|
||||
tags.push(format!("{}, {}", column.currency, column.money_mode.label()));
|
||||
}
|
||||
rows.push(ColumnRow {
|
||||
index: Some(index),
|
||||
@@ -220,7 +237,9 @@ impl AddTablePageState {
|
||||
name: column.name.clone(),
|
||||
data_type: column.data_type.clone(),
|
||||
indexable: columns.is_indexable(index),
|
||||
indexed: column.indexed,
|
||||
// A link is indexed whether or not anyone asked: the server
|
||||
// builds an index for every foreign key.
|
||||
indexed: column.is_indexed(),
|
||||
tags,
|
||||
alias_source: None,
|
||||
alias: String::new(),
|
||||
@@ -313,8 +332,17 @@ impl AddTablePageState {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Row-display candidates: `id` first, then every column, matching the
|
||||
/// client's candidate list.
|
||||
/// Row-display candidates: `id` first, then every column the table will
|
||||
/// really hold.
|
||||
///
|
||||
/// That includes the columns a definition row generates — they are columns
|
||||
/// like any other once the table exists, and the server accepts them here.
|
||||
/// The definition row itself is not among them: it leaves no column of its
|
||||
/// own name behind for a row to be shown by.
|
||||
///
|
||||
/// The index is the candidate's place in this list, so the button that
|
||||
/// toggles one names the same column the label does however the list is
|
||||
/// filtered.
|
||||
pub(crate) fn row_display_candidates(&self) -> Vec<RowDisplayCandidate> {
|
||||
let mut candidates = vec![RowDisplayCandidate {
|
||||
index: 0,
|
||||
@@ -325,21 +353,13 @@ impl AddTablePageState {
|
||||
None
|
||||
},
|
||||
}];
|
||||
candidates.extend(
|
||||
self.draft
|
||||
.columns
|
||||
.added
|
||||
.iter()
|
||||
.enumerate()
|
||||
// A compound column expands into schema-managed companions, so
|
||||
// there is no column of that name for a row to be shown by.
|
||||
.filter(|(index, _)| self.draft.columns.is_indexable(*index))
|
||||
.map(|(index, column)| RowDisplayCandidate {
|
||||
index: index + 1,
|
||||
name: column.name.clone(),
|
||||
position: self.draft.row_display_position(&column.name),
|
||||
}),
|
||||
);
|
||||
for name in self.draft.row_display_column_names() {
|
||||
candidates.push(RowDisplayCandidate {
|
||||
index: candidates.len(),
|
||||
position: self.draft.row_display_position(&name),
|
||||
name,
|
||||
});
|
||||
}
|
||||
candidates
|
||||
}
|
||||
}
|
||||
@@ -409,6 +429,7 @@ mod tests {
|
||||
column_types: vec!["text".into(), "money".into()],
|
||||
column_indexed: vec!["yes".into(), "no".into()],
|
||||
column_quantity_ledger: vec!["no".into(), "no".into()],
|
||||
column_required: vec!["yes".into(), "no".into()],
|
||||
column_rounding: vec!["exact".into(), "half-up".into()],
|
||||
column_currencies: vec![String::new(), "EUR".into()],
|
||||
relation_tables: vec!["customer".into(), "project".into()],
|
||||
@@ -480,6 +501,7 @@ mod tests {
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
@@ -523,6 +545,7 @@ mod tests {
|
||||
form.column_types.push("accounting".into());
|
||||
form.column_indexed.push("no".into());
|
||||
form.column_quantity_ledger.push("no".into());
|
||||
form.column_required.push("no".into());
|
||||
form.column_rounding.push("exact".into());
|
||||
form.column_currencies.push("EUR".into());
|
||||
form.generated_alias_sources = vec!["debit".into(), "account".into()];
|
||||
|
||||
@@ -77,6 +77,7 @@ mod tests {
|
||||
data_type: "text".to_string(),
|
||||
indexed: true,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
@@ -136,6 +137,7 @@ mod tests {
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
@@ -172,6 +174,7 @@ mod tests {
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
@@ -199,6 +202,7 @@ mod tests {
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
@@ -314,6 +318,7 @@ mod tests {
|
||||
data_type: "money".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
@@ -337,6 +342,7 @@ mod tests {
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
@@ -387,6 +393,115 @@ mod tests {
|
||||
assert!(html.contains("htmx:beforeSwap"));
|
||||
}
|
||||
|
||||
/// Choosing FKlink takes the Indexing choice away and says who makes the
|
||||
/// index instead. Offering the choice is what sent a definition the server
|
||||
/// refuses outright — a link is indexed automatically.
|
||||
#[test]
|
||||
fn choosing_a_link_replaces_the_index_choice_with_who_makes_it() {
|
||||
let mut state = page();
|
||||
assert!(render_builder(&state).contains(r#"name="column_indexing_input""#));
|
||||
|
||||
state.draft.columns.type_input = "link".to_string();
|
||||
let html = render_builder(&state);
|
||||
|
||||
assert!(!html.contains(r#"name="column_indexing_input""#), "{html}");
|
||||
assert!(html.contains("Indexed automatically"));
|
||||
// The rest of the link's own fields are still there.
|
||||
assert!(html.contains(r#"name="link_table_input""#));
|
||||
assert!(html.contains("Link alias"));
|
||||
}
|
||||
|
||||
/// And a link already in the list is reported as indexed, without a toggle
|
||||
/// that would do nothing.
|
||||
#[test]
|
||||
fn a_link_in_the_column_list_is_shown_as_indexed_by_the_server() {
|
||||
let mut state = page();
|
||||
state.draft.columns.added.push(ColumnDefinition {
|
||||
name: "billing_customer".to_string(),
|
||||
data_type: "link(customer)".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
|
||||
let html = render_builder(&state);
|
||||
|
||||
assert!(html.contains("indexed automatically"), "{html}");
|
||||
// `number` keeps its toggle; the link is offered none.
|
||||
assert!(html.contains(r#""action": "toggle-index", "index": "0""#));
|
||||
assert!(!html.contains(r#""action": "toggle-index", "index": "1""#));
|
||||
}
|
||||
|
||||
/// A column can be made required, and the answer travels with the draft.
|
||||
#[test]
|
||||
fn a_column_can_be_required_and_says_so_in_the_list() {
|
||||
let mut state = page();
|
||||
assert!(render_builder(&state).contains(r#"name="column_required_input""#));
|
||||
|
||||
state.draft.columns.added[0].required = true;
|
||||
let html = render_builder(&state);
|
||||
|
||||
assert!(html.contains(r#"name="column_required" value="yes""#));
|
||||
assert!(html.contains(r#"<span class="tag">required</span>"#));
|
||||
}
|
||||
|
||||
/// A shared table keeps no books, so the columns that post to a profile's
|
||||
/// books are not on offer and the page says why.
|
||||
#[test]
|
||||
fn the_shared_scope_offers_no_column_that_belongs_to_one_profile() {
|
||||
let mut state = page();
|
||||
state.draft.global = true;
|
||||
state.draft.columns.global = true;
|
||||
|
||||
let html = render_builder(&state);
|
||||
|
||||
assert!(!html.contains(r#"<option value="accounting""#), "{html}");
|
||||
assert!(!html.contains(r#"<option value="accounting_transfer""#));
|
||||
assert!(!html.contains(r#"name="column_quantity_ledger_input""#));
|
||||
assert!(html.contains("A shared table keeps no books of its own"));
|
||||
// Everything else is still offered.
|
||||
assert!(html.contains(r#"<option value="money""#));
|
||||
}
|
||||
|
||||
/// A row is identified by the columns the table will really hold, which
|
||||
/// includes what a definition row generates.
|
||||
#[test]
|
||||
fn generated_columns_can_identify_a_row() {
|
||||
let mut state = page();
|
||||
state.draft.columns.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(),
|
||||
});
|
||||
|
||||
let candidates = state
|
||||
.row_display_candidates()
|
||||
.into_iter()
|
||||
.map(|candidate| candidate.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
candidates,
|
||||
[
|
||||
"id",
|
||||
"number",
|
||||
"name",
|
||||
"tax_point_date",
|
||||
"debit",
|
||||
"credit",
|
||||
"account"
|
||||
]
|
||||
);
|
||||
// The definition row is not one of them, having no column of its name.
|
||||
assert!(!candidates.contains(&"accounting".to_string()));
|
||||
}
|
||||
|
||||
/// A load failure has no draft to render, so the dialog is the response.
|
||||
#[test]
|
||||
fn a_load_failure_answers_with_the_dialog() {
|
||||
|
||||
@@ -117,36 +117,42 @@ pub(crate) async fn load_page(
|
||||
// a table this page can act on. Reading the global scope out of the tree
|
||||
// instead is what used to answer "No table chosen" for a table the panel
|
||||
// had just listed.
|
||||
let selected_tables = if inputs.selection.is_global() {
|
||||
table_scope::global_tables(
|
||||
&definitions
|
||||
.get_table_catalog(
|
||||
authenticated_request(headers, GetTableCatalogRequest { profile_name: None })
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner()
|
||||
.tables,
|
||||
let catalog_tables = definitions
|
||||
.get_table_catalog(
|
||||
authenticated_request(headers, GetTableCatalogRequest { profile_name: None })
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner()
|
||||
.tables;
|
||||
let selected_tables = if inputs.selection.is_global() {
|
||||
table_scope::global_tables(&catalog_tables)
|
||||
} else {
|
||||
table_scope::profile_owned_tables(&tree.profiles, &inputs.selection.profile)
|
||||
};
|
||||
let tables = selected_tables
|
||||
.into_iter()
|
||||
.map(|table| TableSummary {
|
||||
name: table.name,
|
||||
table_kind: table.table_kind,
|
||||
global: table.global,
|
||||
// One entry per link, named by the column carrying it, so a table
|
||||
// pointing at one target twice reads as two links.
|
||||
depends_on: table
|
||||
.depends_on
|
||||
.into_iter()
|
||||
.map(|dependency| format!("{} ({})", dependency.table_name, dependency.column_name))
|
||||
.collect(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// A shared table is a link target from every scope, which is why this is
|
||||
// not the list above: browsing a profile shows the profile's own tables,
|
||||
// and linking from one may reach the shared ones too.
|
||||
let link_targets = if inputs.selection.is_global() {
|
||||
table_scope::global_tables(&catalog_tables)
|
||||
} else {
|
||||
table_scope::linkable_tables(&tree.profiles, &catalog_tables, &inputs.selection.profile)
|
||||
};
|
||||
let summary = |table: crate::definitions::table_definition::profile_tree_response::Table| TableSummary {
|
||||
name: table.name,
|
||||
table_kind: table.table_kind,
|
||||
global: table.global,
|
||||
// One entry per link, named by the column carrying it, so a table
|
||||
// pointing at one target twice reads as two links.
|
||||
depends_on: table
|
||||
.depends_on
|
||||
.into_iter()
|
||||
.map(|dependency| format!("{} ({})", dependency.table_name, dependency.column_name))
|
||||
.collect(),
|
||||
};
|
||||
let tables = selected_tables.into_iter().map(summary).collect::<Vec<_>>();
|
||||
let link_targets = link_targets.into_iter().map(summary).collect::<Vec<_>>();
|
||||
|
||||
// A table the scope does not hold is dropped rather than acted on. It is
|
||||
// said out loud, though: dropping it in silence is what left the panel
|
||||
@@ -251,9 +257,19 @@ pub(crate) async fn load_page(
|
||||
false => Vec::new(),
|
||||
};
|
||||
|
||||
// The append panel is held to the rules of the table it is appending to: a
|
||||
// shared table keeps no quantity ledger, and no link may point at the table
|
||||
// itself.
|
||||
inputs.columns.global = inputs.selection.is_global()
|
||||
|| tables
|
||||
.iter()
|
||||
.any(|table| table.name == inputs.selection.table && table.global);
|
||||
inputs.columns.table_name = inputs.selection.table.clone();
|
||||
|
||||
Ok(TableDefinitionPageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
||||
tables,
|
||||
link_targets,
|
||||
detail,
|
||||
history,
|
||||
selection: inputs.selection,
|
||||
|
||||
@@ -261,7 +261,13 @@ impl PageInputs {
|
||||
pub(crate) struct TableDefinitionPageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub selection: Selection,
|
||||
/// The tables of the selected scope — what the workspace browses and acts
|
||||
/// on.
|
||||
pub tables: Vec<TableSummary>,
|
||||
/// What a new link may point at, which is not the same list: a profile's
|
||||
/// table may link to a shared one, and a shared table is not one of the
|
||||
/// profile's own. See `crate::pages::table_scope`.
|
||||
pub link_targets: Vec<TableSummary>,
|
||||
pub detail: Option<TableDetailView>,
|
||||
pub history: Vec<RenameEntry>,
|
||||
pub columns: ColumnDraft,
|
||||
@@ -283,12 +289,14 @@ impl TableDefinitionPageState {
|
||||
self.active == name
|
||||
}
|
||||
|
||||
/// A table cannot link to itself, and the chart of accounts is reached
|
||||
/// through an ACCOUNTING row rather than through a link of one's own.
|
||||
fn eligible_link_target(&self, table: &TableSummary) -> bool {
|
||||
table.name != self.selection.table && table.name != "accounts"
|
||||
table.name != self.selection.table && table.name != crate::schema::ACCOUNTS_TABLE
|
||||
}
|
||||
|
||||
pub(crate) fn global_link_target_tables(&self) -> Vec<&str> {
|
||||
self.tables
|
||||
self.link_targets
|
||||
.iter()
|
||||
.filter(|table| self.eligible_link_target(table) && table.global)
|
||||
.map(|table| table.name.as_str())
|
||||
@@ -296,7 +304,7 @@ impl TableDefinitionPageState {
|
||||
}
|
||||
|
||||
pub(crate) fn user_link_target_tables(&self) -> Vec<&str> {
|
||||
self.tables
|
||||
self.link_targets
|
||||
.iter()
|
||||
.filter(|table| {
|
||||
self.eligible_link_target(table) && !table.global && !table.is_system()
|
||||
@@ -306,7 +314,7 @@ impl TableDefinitionPageState {
|
||||
}
|
||||
|
||||
pub(crate) fn system_link_target_tables(&self) -> Vec<&str> {
|
||||
self.tables
|
||||
self.link_targets
|
||||
.iter()
|
||||
.filter(|table| {
|
||||
self.eligible_link_target(table) && !table.global && table.is_system()
|
||||
|
||||
@@ -213,6 +213,7 @@ mod tests {
|
||||
table: "invoice".to_string(),
|
||||
},
|
||||
tables: vec![table("invoice", "dynamic"), table("accounts", "system")],
|
||||
link_targets: vec![table("invoice", "dynamic"), table("accounts", "system")],
|
||||
detail: Some(TableDetailView {
|
||||
id: 7,
|
||||
row_display_columns: vec!["number".to_string()],
|
||||
@@ -387,6 +388,7 @@ mod tests {
|
||||
data_type: "money".to_string(),
|
||||
indexed: true,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: crate::schema::MoneyMode::Rounded,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
@@ -419,12 +421,14 @@ mod tests {
|
||||
fn the_append_panel_offers_link_targets_and_an_alias() {
|
||||
let mut state = page();
|
||||
state.columns.type_input = "link".to_string();
|
||||
state.tables.push(table("customer", "dynamic"));
|
||||
state.tables.push(TableSummary {
|
||||
// The link targets are their own list: a profile's table may point at
|
||||
// a shared one, which is not among the profile's own tables.
|
||||
state.link_targets.push(table("customer", "dynamic"));
|
||||
state.link_targets.push(TableSummary {
|
||||
global: true,
|
||||
..table("currencies", "dynamic")
|
||||
});
|
||||
state.tables.push(table("audit_log", "system"));
|
||||
state.link_targets.push(table("audit_log", "system"));
|
||||
|
||||
let html = render_column_panel(&state);
|
||||
|
||||
@@ -436,6 +440,76 @@ mod tests {
|
||||
assert!(html.contains(r#"<optgroup label="User-created">"#));
|
||||
assert!(html.contains(r#"<optgroup label="System-created">"#));
|
||||
assert!(!html.contains(r#"<option value="invoice""#));
|
||||
// The chart of accounts is reached through ACCOUNTING, never linked to.
|
||||
assert!(!html.contains(r#"<option value="accounts""#));
|
||||
}
|
||||
|
||||
/// The append screen holds a column to the same rules the builder does,
|
||||
/// because both are the rules the server applies.
|
||||
#[test]
|
||||
fn the_append_panel_leaves_a_links_index_to_the_server() {
|
||||
let mut state = page();
|
||||
assert!(render_column_panel(&state).contains(r#"name="column_indexing_input""#));
|
||||
|
||||
state.columns.type_input = "link".to_string();
|
||||
let html = render_column_panel(&state);
|
||||
|
||||
assert!(!html.contains(r#"name="column_indexing_input""#), "{html}");
|
||||
assert!(html.contains("Indexed automatically"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_append_panel_offers_required_and_reports_it() {
|
||||
let mut state = page();
|
||||
assert!(render_column_panel(&state).contains(r#"name="column_required_input""#));
|
||||
|
||||
state.columns.added.push(crate::schema::ColumnDefinition {
|
||||
name: "issued_on".to_string(),
|
||||
data_type: "date".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: true,
|
||||
money_mode: crate::schema::MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
let html = render_column_panel(&state);
|
||||
|
||||
assert!(html.contains(r#"name="column_required" value="yes""#));
|
||||
assert!(html.contains(r#"<span class="tag">required</span>"#));
|
||||
}
|
||||
|
||||
/// A staged link is reported as indexed rather than offered a toggle that
|
||||
/// would send a request the server refuses.
|
||||
#[test]
|
||||
fn a_staged_link_is_shown_as_indexed_by_the_server() {
|
||||
let mut state = page();
|
||||
state.columns.added.push(crate::schema::ColumnDefinition {
|
||||
name: "billing_customer".to_string(),
|
||||
data_type: "link(customer)".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: crate::schema::MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
|
||||
let html = render_column_panel(&state);
|
||||
|
||||
assert!(html.contains("indexed automatically"), "{html}");
|
||||
assert!(!html.contains(r#""action": "toggle-index", "index": "0""#));
|
||||
}
|
||||
|
||||
/// A shared table keeps no quantity ledger, which belongs to one profile.
|
||||
#[test]
|
||||
fn the_append_panel_offers_no_quantity_ledger_on_a_shared_table() {
|
||||
let mut state = page();
|
||||
assert!(render_column_panel(&state).contains(r#"name="column_quantity_ledger_input""#));
|
||||
|
||||
state.columns.global = true;
|
||||
let html = render_column_panel(&state);
|
||||
|
||||
assert!(!html.contains(r#"name="column_quantity_ledger_input""#), "{html}");
|
||||
assert!(html.contains("keeps no books of its own"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -30,6 +30,23 @@ use crate::definitions::table_definition::{
|
||||
/// Anything the server offers that is not named here still appears, after
|
||||
/// these and in the server's own order, so a newly added type is never hidden
|
||||
/// by this list being out of date.
|
||||
/// The compound type that posts a row to the profile's books.
|
||||
pub(crate) const ACCOUNTING_FIELD_TYPE: &str = "accounting";
|
||||
|
||||
/// The compound type that moves a balance between two accounting periods.
|
||||
pub(crate) const ACCOUNTING_TRANSFER_FIELD_TYPE: &str = "accounting_transfer";
|
||||
|
||||
/// The types that only make sense inside one profile.
|
||||
///
|
||||
/// Both post to a profile's books, and a global table belongs to every profile
|
||||
/// at once — there is no one set of books for it to post to, so the server
|
||||
/// refuses the pair outright rather than picking a profile for them.
|
||||
const PROFILE_ONLY_TYPES: [&str; 2] = [ACCOUNTING_FIELD_TYPE, ACCOUNTING_TRANSFER_FIELD_TYPE];
|
||||
|
||||
/// The profile's chart of accounts. A row reaches it through an ACCOUNTING
|
||||
/// definition row, never through a link declared by hand.
|
||||
pub(crate) const ACCOUNTS_TABLE: &str = "accounts";
|
||||
|
||||
const TYPE_DISPLAY_ORDER: &[&str] = &[
|
||||
"text",
|
||||
"boolean",
|
||||
@@ -136,12 +153,18 @@ impl ColumnCatalog {
|
||||
/// `creating_table` is false on the append screen, where the server refuses
|
||||
/// the creation-only types: they bring schema-managed companion columns
|
||||
/// that cannot be bolted onto a table that already exists.
|
||||
pub(crate) fn offered_types(&self, creating_table: bool) -> Vec<String> {
|
||||
///
|
||||
/// `global` drops the two types that post to a profile's books, which a
|
||||
/// table shared by every profile has no single one of.
|
||||
pub(crate) fn offered_types(&self, creating_table: bool, global: bool) -> Vec<String> {
|
||||
let mut offered = Vec::new();
|
||||
for column_type in self.types.iter() {
|
||||
if !column_type.declarable || (column_type.creation_only && !creating_table) {
|
||||
continue;
|
||||
}
|
||||
if global && PROFILE_ONLY_TYPES.contains(&column_type.name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let offer = if column_type.group.is_empty() {
|
||||
&column_type.name
|
||||
} else {
|
||||
@@ -324,6 +347,15 @@ fn link_argument(field_type: &str) -> Option<&str> {
|
||||
.map(str::trim)
|
||||
}
|
||||
|
||||
/// The table a stored column type points at, when it is a link.
|
||||
///
|
||||
/// A link is a foreign key, and the server builds an index for every one of
|
||||
/// them as it creates the table — so this is also the question "is this column
|
||||
/// already indexed", which is why it is asked in more than one place.
|
||||
pub(crate) fn link_target(data_type: &str) -> Option<&str> {
|
||||
link_argument(data_type.trim())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum MoneyMode {
|
||||
#[default]
|
||||
@@ -356,21 +388,40 @@ pub(crate) struct ColumnDefinition {
|
||||
pub quantity_ledger: bool,
|
||||
pub money_mode: MoneyMode,
|
||||
pub currency: String,
|
||||
/// Whether a row has to carry a value for it. The server records this with
|
||||
/// the column and refuses a row that leaves it out.
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
impl ColumnDefinition {
|
||||
/// Whether this column is a link, and so already has an index of its own.
|
||||
pub(crate) fn is_link(&self) -> bool {
|
||||
link_target(&self.data_type).is_some()
|
||||
}
|
||||
|
||||
/// Whether the table really gets an index on this column — which a link
|
||||
/// does whether or not anyone asked, because the server indexes every
|
||||
/// foreign key as it creates the table.
|
||||
pub(crate) fn is_indexed(&self) -> bool {
|
||||
self.indexed || self.is_link()
|
||||
}
|
||||
|
||||
/// The `option` cell of the preview, mirroring the client's preview table.
|
||||
///
|
||||
/// The currency is shown whenever there is one, which is the same thing as
|
||||
/// asking the catalog: it is only ever stored for a type that requires it.
|
||||
pub(crate) fn option_label(&self) -> String {
|
||||
let has_currency = !self.currency.is_empty();
|
||||
match (self.indexed, has_currency) {
|
||||
(true, true) => format!("indexed, {}, {}", self.currency, self.money_mode.label()),
|
||||
(true, false) => "indexed".to_string(),
|
||||
(false, true) => format!("{}, {}", self.currency, self.money_mode.label()),
|
||||
(false, false) => String::new(),
|
||||
let mut options = Vec::new();
|
||||
if self.required {
|
||||
options.push("required".to_string());
|
||||
}
|
||||
if self.is_indexed() {
|
||||
options.push("indexed".to_string());
|
||||
}
|
||||
if !self.currency.is_empty() {
|
||||
options.push(format!("{}, {}", self.currency, self.money_mode.label()));
|
||||
}
|
||||
options.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +441,7 @@ pub(crate) struct ColumnDraft {
|
||||
pub decimal_scale_input: String,
|
||||
pub indexing_input: String,
|
||||
pub quantity_ledger_input: String,
|
||||
pub required_input: String,
|
||||
pub rounding_input: String,
|
||||
pub currency_input: String,
|
||||
|
||||
@@ -403,6 +455,16 @@ pub(crate) struct ColumnDraft {
|
||||
/// False on the append screen: the creation-only types can only be chosen
|
||||
/// while the table is being created.
|
||||
pub creating_table: bool,
|
||||
|
||||
/// Whether the table these columns belong to is shared by every profile.
|
||||
/// A shared table has no books of its own, so the types that post to a
|
||||
/// profile's books are neither offered nor accepted on one.
|
||||
pub global: bool,
|
||||
|
||||
/// The table these columns belong to, which a link among them may not
|
||||
/// point at. Set by the page: the builder knows the name being typed, and
|
||||
/// the append screen knows the table it is appending to.
|
||||
pub table_name: String,
|
||||
}
|
||||
|
||||
impl ColumnDraft {
|
||||
@@ -423,6 +485,7 @@ impl ColumnDraft {
|
||||
Self {
|
||||
indexing_input: "no".to_string(),
|
||||
quantity_ledger_input: "no".to_string(),
|
||||
required_input: "no".to_string(),
|
||||
rounding_input: "none".to_string(),
|
||||
currency_input: "EUR".to_string(),
|
||||
catalog,
|
||||
@@ -433,7 +496,7 @@ impl ColumnDraft {
|
||||
/// The types this panel offers, which is the only place the creation-only
|
||||
/// rule shows up in the markup.
|
||||
pub(crate) fn offered_types(&self) -> Vec<String> {
|
||||
self.catalog.offered_types(self.creating_table)
|
||||
self.catalog.offered_types(self.creating_table, self.global)
|
||||
}
|
||||
|
||||
pub(crate) fn temporal_types(&self) -> Vec<String> {
|
||||
@@ -483,6 +546,29 @@ impl ColumnDraft {
|
||||
self.catalog.is_compound(&self.type_input)
|
||||
}
|
||||
|
||||
/// Whether the pending column is one the user chooses an index for.
|
||||
///
|
||||
/// Neither a definition row nor a link is: the first leaves no column of
|
||||
/// its own name behind, and the second is a foreign key, which the server
|
||||
/// indexes as it creates the table. Asking for an index on a link is not
|
||||
/// merely redundant — the server refuses the whole table for it, saying
|
||||
/// the link is indexed automatically.
|
||||
pub(crate) fn show_indexing(&self) -> bool {
|
||||
!self.pending_is_compound() && !self.show_link_target()
|
||||
}
|
||||
|
||||
/// A link's index is the server's to make, and the panel says so where the
|
||||
/// choice would otherwise be.
|
||||
pub(crate) fn pending_is_auto_indexed(&self) -> bool {
|
||||
self.show_link_target()
|
||||
}
|
||||
|
||||
/// Whether the pending column may keep a quantity ledger. A shared table
|
||||
/// has no profile whose ledger it would be kept in.
|
||||
pub(crate) fn show_quantity_ledger(&self) -> bool {
|
||||
!self.pending_is_compound() && !self.global
|
||||
}
|
||||
|
||||
/// What the pending choice would add to the table. Empty unless the choice
|
||||
/// is a compound type.
|
||||
pub(crate) fn pending_generated_columns(&self) -> &[GeneratedColumn] {
|
||||
@@ -577,9 +663,26 @@ impl ColumnDraft {
|
||||
if let Some(error) = self.catalog.validate_field_type(&column_type) {
|
||||
return Err(error);
|
||||
}
|
||||
if self.added.iter().any(|column| column.name == column_name) {
|
||||
if let Some(error) = self.profile_only_type_error(&column_type) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = self.link_target_error(&column_name, &column_type) {
|
||||
return Err(error);
|
||||
}
|
||||
// Against the names the table will really hold, not just the declared
|
||||
// ones: a definition row's companions are columns too, and the server
|
||||
// refuses a table where a declared name collides with one of them.
|
||||
if self.claimed_names().iter().any(|name| name == &column_name) {
|
||||
return Err(format!("A column named `{column_name}` already exists."));
|
||||
}
|
||||
for generated in self.catalog.generated_columns(&column_type) {
|
||||
let generated = self.companion_name(&column_name, &generated.name);
|
||||
if self.claimed_names().iter().any(|name| name == &generated) {
|
||||
return Err(format!(
|
||||
"`{column_type}` generates a column named `{generated}`, and the table already has one."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let quantity_ledger = self.quantity_ledger_input.trim().eq_ignore_ascii_case("yes");
|
||||
if quantity_ledger && !self.catalog.allows_quantity_ledger(&column_type) {
|
||||
@@ -588,6 +691,9 @@ impl ColumnDraft {
|
||||
self.catalog.quantity_ledger_types()
|
||||
));
|
||||
}
|
||||
if let Some(error) = self.global_quantity_ledger_error(quantity_ledger, &column_name) {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let has_currency = self.catalog.requires_currency(&column_type);
|
||||
let currency = if has_currency {
|
||||
@@ -596,11 +702,16 @@ impl ColumnDraft {
|
||||
String::new()
|
||||
};
|
||||
self.added.push(ColumnDefinition {
|
||||
// A compound column is not a column, so there is nothing to index;
|
||||
// a link already has an index the server made, and asking for a
|
||||
// second one is what the server refuses the table for.
|
||||
indexed: !compound
|
||||
&& link_target(&column_type).is_none()
|
||||
&& self.indexing_input.trim().eq_ignore_ascii_case("yes"),
|
||||
name: column_name.clone(),
|
||||
data_type: column_type,
|
||||
// A compound column is not a column, so there is nothing to index.
|
||||
indexed: !compound && self.indexing_input.trim().eq_ignore_ascii_case("yes"),
|
||||
quantity_ledger,
|
||||
required: !compound && self.required_input.trim().eq_ignore_ascii_case("yes"),
|
||||
money_mode: if has_currency {
|
||||
MoneyMode::from_input(&self.rounding_input)
|
||||
} else {
|
||||
@@ -623,10 +734,91 @@ impl ColumnDraft {
|
||||
self.decimal_scale_input.clear();
|
||||
self.indexing_input = "no".to_string();
|
||||
self.quantity_ledger_input = "no".to_string();
|
||||
self.required_input = "no".to_string();
|
||||
self.rounding_input = "none".to_string();
|
||||
self.currency_input = "EUR".to_string();
|
||||
}
|
||||
|
||||
// ---- the rules a column is held to, wherever it came from ------------
|
||||
|
||||
/// Every column name the table will hold: the declared ones and everything
|
||||
/// the definition rows among them expand into.
|
||||
///
|
||||
/// The server checks a name against this set rather than against the
|
||||
/// declared columns alone, so declaring `debit` beside an ACCOUNTING row is
|
||||
/// a conflict there. It has to be a conflict here too, or the builder
|
||||
/// accepts a table the server will refuse.
|
||||
pub(crate) fn claimed_names(&self) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
for (index, column) in self.added.iter().enumerate() {
|
||||
names.push(column.name.clone());
|
||||
names.extend(
|
||||
self.generated_columns_of(index)
|
||||
.into_iter()
|
||||
.map(|generated| generated.name),
|
||||
);
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// What a companion is called on a column of this name — the catalog
|
||||
/// reports it under the type's own prefix, and the column's name replaces
|
||||
/// that prefix. The same rule [`Self::generated_columns_of`] applies.
|
||||
fn companion_name(&self, column_name: &str, generated_name: &str) -> String {
|
||||
let column_type = self
|
||||
.added
|
||||
.iter()
|
||||
.find(|column| column.name == column_name)
|
||||
.map(|column| column.data_type.clone());
|
||||
let prefix = match column_type {
|
||||
Some(data_type) => format!("{data_type}_"),
|
||||
None => format!("{}_", self.type_input.trim().to_ascii_lowercase()),
|
||||
};
|
||||
match generated_name.strip_prefix(&prefix) {
|
||||
Some(suffix) => format!("{column_name}_{suffix}"),
|
||||
None => generated_name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The books a definition row posts to belong to one profile, and a shared
|
||||
/// table belongs to all of them.
|
||||
fn profile_only_type_error(&self, column_type: &str) -> Option<String> {
|
||||
(self.global && PROFILE_ONLY_TYPES.contains(&column_type)).then(|| {
|
||||
format!(
|
||||
"A shared table cannot use {}: it posts to one profile's books, and a shared table belongs to every profile.",
|
||||
column_type.to_uppercase()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn global_quantity_ledger_error(&self, quantity_ledger: bool, name: &str) -> Option<String> {
|
||||
(self.global && quantity_ledger).then(|| {
|
||||
format!(
|
||||
"Column `{name}`: a shared table cannot keep a quantity ledger, which belongs to one profile."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The two link targets the server refuses: the table being created, which
|
||||
/// does not exist yet, and the chart of accounts, which is reached through
|
||||
/// an ACCOUNTING row instead.
|
||||
///
|
||||
/// `table_name` is empty on the append screen's panel, where the page
|
||||
/// filters its own table out of the picker and there is nothing to compare
|
||||
/// against here.
|
||||
fn link_target_error(&self, column_name: &str, column_type: &str) -> Option<String> {
|
||||
let target = link_target(column_type)?;
|
||||
if target == ACCOUNTS_TABLE {
|
||||
return Some(
|
||||
"The account relationship is built into ACCOUNTING and cannot be declared as a link."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
(!self.table_name.is_empty() && target == self.table_name).then(|| {
|
||||
format!("Link `{column_name}` cannot point at the table being created.")
|
||||
})
|
||||
}
|
||||
|
||||
// ---- the columns added so far ----------------------------------------
|
||||
|
||||
/// Removes one column. The caller is what knows whether anything else
|
||||
@@ -682,9 +874,20 @@ impl ColumnDraft {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a column can be indexed or identify a row. A compound column
|
||||
/// leaves no column of its own name behind, so it can do neither.
|
||||
/// Whether a column is one the user chooses an index for.
|
||||
///
|
||||
/// A compound column leaves no column of its own name behind, and a link
|
||||
/// already has one: the server builds an index for every foreign key and
|
||||
/// refuses a definition that asks for a second.
|
||||
pub(crate) fn is_indexable(&self, index: usize) -> bool {
|
||||
self.added.get(index).is_some_and(|column| {
|
||||
!self.catalog.is_compound(&column.data_type) && !column.is_link()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a column can identify a row. A compound column leaves no column
|
||||
/// of its own name behind, so it cannot; a link can.
|
||||
pub(crate) fn can_identify_row(&self, index: usize) -> bool {
|
||||
self.added
|
||||
.get(index)
|
||||
.is_some_and(|column| !self.catalog.is_compound(&column.data_type))
|
||||
@@ -699,11 +902,17 @@ impl ColumnDraft {
|
||||
}
|
||||
}
|
||||
|
||||
/// The indexes the request asks for.
|
||||
///
|
||||
/// A link is never among them however the draft was built: the server
|
||||
/// makes that index itself, and naming it here is refused rather than
|
||||
/// ignored.
|
||||
pub(crate) fn selected_index_names(&self) -> Vec<String> {
|
||||
self.added
|
||||
.iter()
|
||||
.filter(|column| column.indexed)
|
||||
.map(|column| column.name.clone())
|
||||
.enumerate()
|
||||
.filter(|(index, column)| column.indexed && self.is_indexable(*index))
|
||||
.map(|(_, column)| column.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -717,6 +926,7 @@ impl ColumnDraft {
|
||||
/// from a posted form has not been through that path, so this is what a
|
||||
/// tampered-with or truncated post is held to.
|
||||
pub(crate) fn validate(&self) -> Result<(), String> {
|
||||
let claimed = self.claimed_names();
|
||||
for column in &self.added {
|
||||
if let Some(error) = validate_identifier(&column.name, "Column name", true) {
|
||||
return Err(error);
|
||||
@@ -724,6 +934,29 @@ impl ColumnDraft {
|
||||
if let Some(error) = self.catalog.validate_field_type(&column.data_type) {
|
||||
return Err(format!("Column `{}`: {error}", column.name));
|
||||
}
|
||||
if let Some(error) = self.profile_only_type_error(&column.data_type) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) = self.link_target_error(&column.name, &column.data_type) {
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(error) =
|
||||
self.global_quantity_ledger_error(column.quantity_ledger, &column.name)
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
if column.indexed && column.is_link() {
|
||||
return Err(format!(
|
||||
"Link `{}` is indexed automatically and cannot be indexed again.",
|
||||
column.name
|
||||
));
|
||||
}
|
||||
if column.indexed && self.catalog.is_compound(&column.data_type) {
|
||||
return Err(format!(
|
||||
"`{}` is a definition row, not a column, so it cannot be indexed.",
|
||||
column.name
|
||||
));
|
||||
}
|
||||
if !self.creating_table && self.catalog.is_creation_only(&column.data_type) {
|
||||
return Err(format!(
|
||||
"A {} column can only be chosen while the table is being created.",
|
||||
@@ -750,6 +983,15 @@ impl ColumnDraft {
|
||||
));
|
||||
}
|
||||
}
|
||||
// Every name the table will hold has to be its own, counting what the
|
||||
// definition rows generate — which is the set the server checks.
|
||||
for (position, name) in claimed.iter().enumerate() {
|
||||
if claimed[position + 1..].contains(name) {
|
||||
return Err(format!(
|
||||
"`{name}` names more than one of this table's columns."
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -780,8 +1022,10 @@ pub(crate) fn validate_identifier(
|
||||
if value.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
return Some(format!("{label} cannot start with a number."));
|
||||
}
|
||||
if value.len() > 63 {
|
||||
return Some(format!("{label} cannot be longer than 63 characters."));
|
||||
if value.len() > MAX_IDENTIFIER_LENGTH {
|
||||
return Some(format!(
|
||||
"{label} cannot be longer than {MAX_IDENTIFIER_LENGTH} characters."
|
||||
));
|
||||
}
|
||||
if value
|
||||
.chars()
|
||||
@@ -807,6 +1051,60 @@ pub(crate) fn validate_identifier(
|
||||
None
|
||||
}
|
||||
|
||||
/// Postgres's `NAMEDATALEN - 1`: a longer identifier is truncated, not refused.
|
||||
const MAX_IDENTIFIER_LENGTH: usize = 63;
|
||||
|
||||
/// How long a table name may be.
|
||||
///
|
||||
/// Shorter than the 63 an identifier gets, because a table name is not only an
|
||||
/// identifier: the server names every index on the table after it, and
|
||||
/// `idx_<table>_<column>_fk` has to fit in 63 as well. What is subtracted is
|
||||
/// that wrapping plus the longest physical column name there can be.
|
||||
///
|
||||
/// This is the server's own arithmetic — `catalog::object_naming` — down to the
|
||||
/// term it reads out of `common`, so adding a system column there moves the
|
||||
/// limit here too instead of leaving this behind at a number that was once
|
||||
/// right.
|
||||
pub(crate) const MAX_TABLE_NAME_LENGTH: usize = MAX_IDENTIFIER_LENGTH
|
||||
- "idx_".len()
|
||||
- "_".len()
|
||||
- "_fk".len()
|
||||
- crate::system_column::LONGEST_SYSTEM_COLUMN_NAME;
|
||||
|
||||
/// The tables the server provisions for a profile itself. A profile gets these
|
||||
/// when it is created, so a new table may not claim one of their names.
|
||||
///
|
||||
/// Written down here because the backend does not report them: they are
|
||||
/// constants of three server modules rather than anything the catalog carries.
|
||||
/// A name the server adds to that set is a name this list will not know about
|
||||
/// until it is added here as well.
|
||||
pub(crate) const RESERVED_TABLE_NAMES: [&str; 5] = [
|
||||
"general_ledger",
|
||||
"journal_lines",
|
||||
"quantity_ledger",
|
||||
ACCOUNTS_TABLE,
|
||||
"custom_exchange_rates",
|
||||
];
|
||||
|
||||
/// The identifier rules, plus the two a table name alone is held to.
|
||||
pub(crate) fn validate_table_name(value: &str) -> Option<String> {
|
||||
if let Some(error) = validate_identifier(value, "Table name", true) {
|
||||
return Some(error);
|
||||
}
|
||||
if value.len() > MAX_TABLE_NAME_LENGTH {
|
||||
return Some(format!(
|
||||
"Table name cannot be longer than {MAX_TABLE_NAME_LENGTH} characters, because the \
|
||||
indexes on its columns are named after it."
|
||||
));
|
||||
}
|
||||
if RESERVED_TABLE_NAMES.contains(&value) {
|
||||
return Some(format!(
|
||||
"`{value}` is the name of a table every profile is given, so it cannot be reused."
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The precision and scale rules the server applies to `decimal(p,s)`:
|
||||
/// whole numbers, no sign, no leading zeros, `1 <= p` and `s <= p`.
|
||||
fn validate_decimal_arguments(precision: &str, scale: &str) -> Result<(), String> {
|
||||
@@ -852,7 +1150,7 @@ pub(crate) fn proto_columns(columns: &[ColumnDefinition]) -> Vec<ProtoColumnDefi
|
||||
},
|
||||
quantity_ledger: column.quantity_ledger,
|
||||
currency: column.currency.clone(),
|
||||
required: false,
|
||||
required: column.required,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -943,6 +1241,8 @@ pub(crate) struct ColumnForm {
|
||||
#[serde(default)]
|
||||
pub column_quantity_ledger_input: String,
|
||||
#[serde(default)]
|
||||
pub column_required_input: String,
|
||||
#[serde(default)]
|
||||
pub column_rounding_input: String,
|
||||
#[serde(default)]
|
||||
pub column_currency_input: String,
|
||||
@@ -956,6 +1256,8 @@ pub(crate) struct ColumnForm {
|
||||
#[serde(default)]
|
||||
pub column_quantity_ledger: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_required: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_rounding: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub column_currencies: Vec<String>,
|
||||
@@ -973,6 +1275,7 @@ impl ColumnForm {
|
||||
decimal_scale_input: self.decimal_scale_input.clone(),
|
||||
indexing_input: self.column_indexing_input.clone(),
|
||||
quantity_ledger_input: self.column_quantity_ledger_input.clone(),
|
||||
required_input: self.column_required_input.clone(),
|
||||
rounding_input: self.column_rounding_input.clone(),
|
||||
currency_input: self.column_currency_input.clone(),
|
||||
added: columns_from_rows(
|
||||
@@ -980,11 +1283,16 @@ impl ColumnForm {
|
||||
&self.column_types,
|
||||
&self.column_indexed,
|
||||
&self.column_quantity_ledger,
|
||||
&self.column_required,
|
||||
&self.column_rounding,
|
||||
&self.column_currencies,
|
||||
),
|
||||
catalog,
|
||||
creating_table,
|
||||
// Filled in by the page, which is what knows the table these
|
||||
// columns are for: the panel's own form carries neither.
|
||||
global: false,
|
||||
table_name: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1311,7 @@ pub(crate) fn columns_from_rows(
|
||||
types: &[String],
|
||||
indexed: &[String],
|
||||
quantity_ledger: &[String],
|
||||
required: &[String],
|
||||
rounding: &[String],
|
||||
currencies: &[String],
|
||||
) -> Vec<ColumnDefinition> {
|
||||
@@ -1011,6 +1320,7 @@ pub(crate) fn columns_from_rows(
|
||||
types.len(),
|
||||
indexed.len(),
|
||||
quantity_ledger.len(),
|
||||
required.len(),
|
||||
rounding.len(),
|
||||
currencies.len(),
|
||||
]
|
||||
@@ -1024,6 +1334,7 @@ pub(crate) fn columns_from_rows(
|
||||
data_type: types[index].clone(),
|
||||
indexed: is_yes(&indexed[index]),
|
||||
quantity_ledger: is_yes(&quantity_ledger[index]),
|
||||
required: is_yes(&required[index]),
|
||||
money_mode: MoneyMode::from_input(&rounding[index]),
|
||||
currency: currencies[index].clone(),
|
||||
})
|
||||
@@ -1332,6 +1643,7 @@ pub(crate) mod tests {
|
||||
data_type: field_type.to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
@@ -1464,6 +1776,7 @@ pub(crate) mod tests {
|
||||
data_type: "money".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
@@ -1489,6 +1802,7 @@ pub(crate) mod tests {
|
||||
data_type: "text".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: true,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
@@ -1530,6 +1844,7 @@ pub(crate) mod tests {
|
||||
&["text".to_string()],
|
||||
&["yes".to_string(), "no".to_string()],
|
||||
&["no".to_string(), "no".to_string()],
|
||||
&["no".to_string(), "yes".to_string()],
|
||||
&["exact".to_string(), "half-up".to_string()],
|
||||
&[String::new(), "EUR".to_string()],
|
||||
);
|
||||
@@ -1622,6 +1937,259 @@ pub(crate) mod tests {
|
||||
|
||||
assert_eq!(draft.selected_index_names(), vec!["number"]);
|
||||
}
|
||||
|
||||
/// A link is a foreign key, and the server indexes every one of them as it
|
||||
/// creates the table. Asking for one on top is not redundant — the server
|
||||
/// refuses the whole definition, saying the link is indexed automatically —
|
||||
/// so there is no choice to offer and no way to end up having made one.
|
||||
#[test]
|
||||
fn a_link_is_indexed_by_the_server_and_never_by_the_user() {
|
||||
let mut draft = draft();
|
||||
draft.name_input = "billing_customer".to_string();
|
||||
draft.type_input = "link".to_string();
|
||||
draft.link_table_input = "customer".to_string();
|
||||
draft.indexing_input = "yes".to_string();
|
||||
|
||||
// The panel does not offer the choice, and says why instead.
|
||||
assert!(!draft.show_indexing());
|
||||
assert!(draft.pending_is_auto_indexed());
|
||||
|
||||
draft.add_from_inputs().unwrap();
|
||||
|
||||
// Asking anyway leaves the column unindexed, so nothing names it.
|
||||
assert!(!draft.added[0].indexed);
|
||||
assert!(!draft.is_indexable(0));
|
||||
draft.toggle_indexed(0);
|
||||
assert!(!draft.added[0].indexed);
|
||||
assert!(draft.selected_index_names().is_empty());
|
||||
|
||||
// And it is still reported as indexed, because it is.
|
||||
assert!(draft.added[0].is_indexed());
|
||||
assert!(draft.added[0].option_label().contains("indexed"));
|
||||
|
||||
// A draft rebuilt from a post that says otherwise is refused rather
|
||||
// than sent on to be refused by the server.
|
||||
draft.added[0].indexed = true;
|
||||
let error = draft.validate().unwrap_err();
|
||||
assert!(error.contains("indexed automatically"), "{error}");
|
||||
assert!(draft.selected_index_names().is_empty());
|
||||
}
|
||||
|
||||
/// A definition row is never indexed: it leaves no column of its own name
|
||||
/// behind. A crafted post that marks one is refused rather than silently
|
||||
/// dropped from the request, matching how a crafted link is refused.
|
||||
#[test]
|
||||
fn a_compound_column_marked_indexed_is_refused_not_dropped() {
|
||||
let mut draft = draft();
|
||||
draft.type_input = "accounting".to_string();
|
||||
draft.currency_input = "EUR".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
assert!(!draft.added[0].indexed);
|
||||
|
||||
draft.added[0].indexed = true;
|
||||
let error = draft.validate().unwrap_err();
|
||||
assert!(error.contains("cannot be indexed"), "{error}");
|
||||
assert!(draft.selected_index_names().is_empty());
|
||||
}
|
||||
|
||||
/// An ordinary column is still the user's to index.
|
||||
#[test]
|
||||
fn every_other_column_still_chooses_its_own_index() {
|
||||
let mut draft = draft();
|
||||
draft.name_input = "number".to_string();
|
||||
draft.type_input = "text".to_string();
|
||||
assert!(draft.show_indexing());
|
||||
assert!(!draft.pending_is_auto_indexed());
|
||||
draft.indexing_input = "yes".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
|
||||
assert!(draft.is_indexable(0));
|
||||
assert_eq!(draft.selected_index_names(), vec!["number"]);
|
||||
assert!(draft.validate().is_ok());
|
||||
}
|
||||
|
||||
/// Both types post to one profile's books, and a shared table belongs to
|
||||
/// every profile at once. The server refuses the pair outright, so they are
|
||||
/// not offered on a shared table.
|
||||
#[test]
|
||||
fn a_shared_table_is_offered_no_column_that_posts_to_a_profiles_books() {
|
||||
let mut draft = ColumnDraft::new(catalog());
|
||||
draft.global = true;
|
||||
|
||||
let offered = draft.offered_types();
|
||||
assert!(!offered.contains(&"accounting".to_string()));
|
||||
assert!(!offered.contains(&"accounting_transfer".to_string()));
|
||||
assert!(offered.contains(&"money".to_string()));
|
||||
|
||||
for field_type in ["accounting", "accounting_transfer"] {
|
||||
let mut draft = draft.clone();
|
||||
draft.type_input = field_type.to_string();
|
||||
let error = draft.add_from_inputs().unwrap_err();
|
||||
assert!(error.contains("shared table"), "{error}");
|
||||
}
|
||||
|
||||
// And a draft rebuilt from a post that carries one anyway.
|
||||
draft.added.push(ColumnDefinition {
|
||||
name: "accounting".to_string(),
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
assert!(draft.validate().is_err());
|
||||
}
|
||||
|
||||
/// A quantity ledger is one profile's, for the same reason.
|
||||
#[test]
|
||||
fn a_shared_table_keeps_no_quantity_ledger() {
|
||||
let mut draft = ColumnDraft::new(catalog());
|
||||
draft.global = true;
|
||||
assert!(!draft.show_quantity_ledger());
|
||||
|
||||
draft.name_input = "quantity".to_string();
|
||||
draft.type_input = "int".to_string();
|
||||
draft.quantity_ledger_input = "yes".to_string();
|
||||
let error = draft.add_from_inputs().unwrap_err();
|
||||
assert!(error.contains("quantity ledger"), "{error}");
|
||||
|
||||
draft.added.push(ColumnDefinition {
|
||||
name: "quantity".to_string(),
|
||||
data_type: "int".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: true,
|
||||
required: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
assert!(draft.validate().is_err());
|
||||
|
||||
// The same column on a profile's own table is fine.
|
||||
draft.global = false;
|
||||
assert!(draft.show_quantity_ledger());
|
||||
assert!(draft.validate().is_ok());
|
||||
}
|
||||
|
||||
/// The table does not exist yet, so it cannot be pointed at — the picker
|
||||
/// leaves it out, and the rule holds even when the name is typed after the
|
||||
/// link was added.
|
||||
#[test]
|
||||
fn a_link_cannot_point_at_the_table_being_created() {
|
||||
let mut draft = draft();
|
||||
draft.table_name = "invoice".to_string();
|
||||
draft.name_input = "parent".to_string();
|
||||
draft.type_input = "link".to_string();
|
||||
draft.link_table_input = "invoice".to_string();
|
||||
|
||||
let error = draft.add_from_inputs().unwrap_err();
|
||||
assert!(error.contains("cannot point at the table"), "{error}");
|
||||
|
||||
// Added while the table had another name, then renamed to the target.
|
||||
draft.table_name = "order".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
assert!(draft.validate().is_ok());
|
||||
draft.table_name = "invoice".to_string();
|
||||
assert!(draft.validate().is_err());
|
||||
}
|
||||
|
||||
/// A row reaches the chart of accounts through an ACCOUNTING definition
|
||||
/// row. Declaring the link by hand is refused by the server, so it is
|
||||
/// refused here.
|
||||
#[test]
|
||||
fn the_chart_of_accounts_is_not_a_link_target() {
|
||||
let mut draft = draft();
|
||||
draft.name_input = "posted_to".to_string();
|
||||
draft.type_input = "link".to_string();
|
||||
draft.link_table_input = ACCOUNTS_TABLE.to_string();
|
||||
|
||||
let error = draft.add_from_inputs().unwrap_err();
|
||||
assert!(error.contains("built into ACCOUNTING"), "{error}");
|
||||
}
|
||||
|
||||
/// The names a definition row generates are the table's columns too, so a
|
||||
/// declared column may not take one — in either order.
|
||||
#[test]
|
||||
fn a_declared_column_cannot_take_a_generated_columns_name() {
|
||||
let mut draft = draft();
|
||||
draft.type_input = "accounting".to_string();
|
||||
draft.currency_input = "EUR".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
assert!(draft.claimed_names().contains(&"debit".to_string()));
|
||||
|
||||
draft.name_input = "debit".to_string();
|
||||
draft.type_input = "text".to_string();
|
||||
assert!(draft.add_from_inputs().is_err(), "ACCOUNTING generates it");
|
||||
|
||||
// And the other way round: the declared column first, then the row
|
||||
// whose expansion would collide with it.
|
||||
let mut reversed = ColumnDraft::new(catalog());
|
||||
reversed.name_input = "credit".to_string();
|
||||
reversed.type_input = "text".to_string();
|
||||
reversed.add_from_inputs().unwrap();
|
||||
reversed.type_input = "accounting".to_string();
|
||||
reversed.currency_input = "EUR".to_string();
|
||||
let error = reversed.add_from_inputs().unwrap_err();
|
||||
assert!(error.contains("credit"), "{error}");
|
||||
|
||||
// A companion named after its own column follows that column's name,
|
||||
// so two PHONE columns never collide.
|
||||
let mut phones = ColumnDraft::new(catalog());
|
||||
for name in ["home_phone", "work_phone"] {
|
||||
phones.name_input = name.to_string();
|
||||
phones.type_input = "phone".to_string();
|
||||
phones.add_from_inputs().unwrap();
|
||||
}
|
||||
assert!(phones.claimed_names().contains(&"work_phone_ext".to_string()));
|
||||
assert!(phones.validate().is_ok());
|
||||
}
|
||||
|
||||
/// `required` is a column property the server records and enforces on every
|
||||
/// row written, so it travels with the column like any other.
|
||||
#[test]
|
||||
fn a_column_can_be_required() {
|
||||
let mut draft = draft();
|
||||
draft.name_input = "number".to_string();
|
||||
draft.type_input = "text".to_string();
|
||||
draft.required_input = "yes".to_string();
|
||||
draft.add_from_inputs().unwrap();
|
||||
|
||||
assert!(draft.added[0].required);
|
||||
assert!(draft.added[0].option_label().contains("required"));
|
||||
assert!(proto_columns(&draft.added)[0].required);
|
||||
|
||||
// And the panel is cleared for the next column, which is not required
|
||||
// just because the last one was.
|
||||
assert_eq!(draft.required_input, "no");
|
||||
}
|
||||
|
||||
/// A table name is not simply an identifier: the server names every index
|
||||
/// on the table after it, so it has less room than a column does.
|
||||
#[test]
|
||||
fn a_table_name_is_shorter_than_a_column_name() {
|
||||
assert_eq!(MAX_TABLE_NAME_LENGTH, 38);
|
||||
|
||||
let longest = "t".repeat(MAX_TABLE_NAME_LENGTH);
|
||||
assert_eq!(validate_table_name(&longest), None);
|
||||
assert_eq!(validate_identifier(&longest, "Column name", true), None);
|
||||
|
||||
let too_long = "t".repeat(MAX_TABLE_NAME_LENGTH + 1);
|
||||
assert!(validate_table_name(&too_long).is_some());
|
||||
// Still a perfectly good column name, which is why the two differ.
|
||||
assert_eq!(validate_identifier(&too_long, "Column name", true), None);
|
||||
}
|
||||
|
||||
/// Every profile is given these tables when it is created, so a new table
|
||||
/// cannot be named after one of them.
|
||||
#[test]
|
||||
fn the_tables_every_profile_is_given_keep_their_names() {
|
||||
for name in RESERVED_TABLE_NAMES {
|
||||
let error = validate_table_name(name)
|
||||
.unwrap_or_else(|| panic!("`{name}` should be reserved"));
|
||||
assert!(error.contains(name), "{error}");
|
||||
}
|
||||
assert_eq!(validate_table_name("invoice"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user