multirow FK

This commit is contained in:
Priec
2026-08-07 22:39:59 +02:00
parent c47e236713
commit 0d78556428
10 changed files with 46 additions and 216 deletions

View File

@@ -8,65 +8,16 @@
//!
//! 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 links, and
//! 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::{
PostTableDefinitionRequest, TableLink as ProtoTableLink,
PostTableDefinitionRequest,
},
schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier},
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum LinkMode {
#[default]
None,
Optional,
Required,
}
impl LinkMode {
pub(crate) fn label(self) -> &'static str {
match self {
Self::None => "none",
Self::Optional => "optional",
Self::Required => "required",
}
}
/// Cycles none → optional → required → none, as `Select` does in the TUI.
pub(crate) fn next(self) -> Self {
match self {
Self::None => Self::Optional,
Self::Optional => Self::Required,
Self::Required => Self::None,
}
}
pub(crate) fn from_label(value: &str) -> Self {
match value.trim() {
"optional" => Self::Optional,
"required" => Self::Required,
_ => Self::None,
}
}
pub(crate) fn is_active(self) -> bool {
!matches!(self, Self::None)
}
pub(crate) fn is_required(self) -> bool {
matches!(self, Self::Required)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct LinkDefinition {
pub linked_table_name: String,
pub mode: LinkMode,
}
/// One row of the "Table definition preview" — the schema as it will exist.
pub(crate) struct PreviewRow {
pub mark: String,
@@ -91,7 +42,9 @@ pub(crate) struct TableDraft {
/// The column panel: the pending column and the ones already described.
pub columns: ColumnDraft,
pub links: Vec<LinkDefinition>,
/// Tables in the target profile, offered as `link(...)` targets by the
/// column picker.
pub relation_tables: Vec<String>,
/// 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>,
@@ -133,12 +86,6 @@ impl TableDraft {
Ok(format!("Column `{}` removed.", removed.name))
}
pub(crate) fn cycle_link_mode(&mut self, index: usize) {
if let Some(link) = self.links.get_mut(index) {
link.mode = link.mode.next();
}
}
/// Adds or removes one display-column candidate.
///
/// Index 0 is `id`, which is not a display column of its own: choosing it
@@ -175,25 +122,12 @@ impl TableDraft {
}
}
/// Rebuilds the link list from the tables available in the target profile,
/// keeping whatever mode each surviving link already had.
/// Records the tables the target profile offers as link targets. A table
/// cannot link to itself, so its own name is never among them.
pub(crate) fn set_available_relation_tables(&mut self, table_names: Vec<String>) {
let previous_modes = self
.links
.iter()
.map(|link| (link.linked_table_name.clone(), link.mode))
.collect::<std::collections::HashMap<_, _>>();
self.links = table_names
self.relation_tables = table_names
.into_iter()
.filter(|table_name| table_name != &self.table_name)
.map(|linked_table_name| LinkDefinition {
mode: previous_modes
.get(&linked_table_name)
.copied()
.unwrap_or(LinkMode::None),
linked_table_name,
})
.collect();
}
@@ -249,20 +183,6 @@ impl TableDraft {
},
];
for link in self.links.iter().filter(|link| link.mode.is_active()) {
rows.push(PreviewRow {
mark: String::new(),
column: format!("{}_id", link.linked_table_name),
data_type: "BIGINT".to_string(),
option: if link.mode.is_required() {
"required".to_string()
} else {
"optional".to_string()
},
source: "relation".to_string(),
});
}
for column in &self.columns.added {
rows.push(PreviewRow {
mark: self
@@ -324,16 +244,6 @@ impl TableDraft {
profile_name: self.effective_profile_name(),
columns: proto_columns(&self.columns.added),
indexes: self.columns.selected_index_names(),
links: self
.links
.iter()
.filter(|link| link.mode.is_active())
.map(|link| ProtoTableLink {
linked_table_name: link.linked_table_name.clone(),
required: link.mode.is_required(),
name: link.linked_table_name.clone(),
})
.collect(),
accounting_currency: if self.creating_new_profile {
self.accounting_currency.trim().to_ascii_uppercase()
} else {
@@ -430,34 +340,12 @@ mod tests {
}
#[test]
fn links_keep_their_mode_when_the_table_list_is_reloaded() {
let mut draft = draft_with_column("total", "int");
draft.set_available_relation_tables(vec!["customer".into(), "project".into()]);
draft.cycle_link_mode(0); // none -> optional
draft.cycle_link_mode(0); // optional -> required
draft.set_available_relation_tables(vec![
"customer".into(),
"project".into(),
"address".into(),
]);
assert_eq!(draft.links[0].mode, LinkMode::Required);
assert_eq!(draft.links[2].mode, LinkMode::None);
let request = draft.into_request().unwrap();
assert_eq!(request.links.len(), 1);
assert_eq!(request.links[0].linked_table_name, "customer");
assert!(request.links[0].required);
}
#[test]
fn a_table_never_links_to_itself() {
let mut draft = draft_with_column("total", "int");
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.links.len(), 1);
assert_eq!(draft.links[0].linked_table_name, "customer");
assert_eq!(draft.relation_tables, vec!["customer".to_string()]);
}
#[test]
@@ -513,10 +401,8 @@ mod tests {
}
#[test]
fn the_preview_shows_system_relation_and_user_columns() {
let mut draft = draft_with_column("number", "text");
draft.set_available_relation_tables(vec!["customer".into()]);
draft.cycle_link_mode(0);
fn the_preview_shows_system_and_user_columns() {
let draft = draft_with_column("number", "text");
let rows = draft.preview_rows();
let columns = rows
@@ -524,11 +410,7 @@ mod tests {
.map(|row| row.column.as_str())
.collect::<Vec<_>>();
assert_eq!(
columns,
vec!["id", "deleted", "customer_id", "number", "created_at"]
);
assert_eq!(rows[2].option, "optional");
assert_eq!(columns, vec!["id", "deleted", "number", "created_at"]);
// No display column chosen, so `id` identifies the row.
assert_eq!(rows[0].mark, "[x]");
}

View File

@@ -80,7 +80,7 @@ pub(crate) async fn load_page(
// A brand-new (or not-yet-named) profile has nothing to link to.
None => {
draft.existing_profile_tables.clear();
draft.links.clear();
draft.relation_tables.clear();
}
}

View File

@@ -142,7 +142,6 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) {
Err(message) => page.error = Some(message),
},
"toggle-index" => page.draft.columns.toggle_indexed(index),
"cycle-link" => page.draft.cycle_link_mode(index),
"toggle-display" => page.draft.toggle_row_display_candidate(index),
_ => {}
}

View File

@@ -1,7 +1,7 @@
//! Wire format for the builder form, and the page state the templates read.
//!
//! HTTP is stateless, so the whole draft travels with every interaction: each
//! already-added column, link and display column is posted back as a set of
//! already-added column and display column is posted back as a set of
//! parallel repeated fields. `serde_html_form` (via `axum_extra::extract::Form`)
//! decodes the repeats into `Vec`s, which `to_draft` zips back into a
//! [`TableDraft`].
@@ -13,7 +13,7 @@
use crate::schema::{ColumnCatalog, ColumnDraft, columns_from_rows};
use super::draft::{LinkDefinition, LinkMode, TableDraft};
use super::draft::TableDraft;
/// The `profile_name` option meaning "create a new profile too".
pub(crate) const NEW_PROFILE: &str = "__new__";
@@ -72,11 +72,9 @@ pub(crate) struct BuilderForm {
#[serde(default)]
pub column_currencies: Vec<String>,
// One entry per link target offered by the profile, in order.
// Tables the profile offers as link targets, in order.
#[serde(default)]
pub link_tables: Vec<String>,
#[serde(default)]
pub link_modes: Vec<String>,
pub relation_tables: Vec<String>,
#[serde(default)]
pub row_display_columns: Vec<String>,
@@ -118,14 +116,6 @@ impl BuilderForm {
creating_table: true,
};
let link_count = self.link_tables.len().min(self.link_modes.len());
let links = (0..link_count)
.map(|index| LinkDefinition {
linked_table_name: self.link_tables[index].clone(),
mode: LinkMode::from_label(&self.link_modes[index]),
})
.collect();
// Drop display columns whose column is gone, so a stale post cannot
// send a display column that no longer exists.
let row_display_columns = self
@@ -146,7 +136,7 @@ impl BuilderForm {
accounting_currency: self.accounting_currency.clone(),
table_name: self.table_name.clone(),
columns,
links,
relation_tables: self.relation_tables.clone(),
row_display_columns,
// Filled in by the loader from the live profile tree, never by the
// client: it is what duplicate table names are checked against.
@@ -240,22 +230,20 @@ mod tests {
column_quantity_ledger: vec!["no".into(), "no".into()],
column_rounding: vec!["exact".into(), "half-up".into()],
column_currencies: vec![String::new(), "EUR".into()],
link_tables: vec!["customer".into(), "project".into()],
link_modes: vec!["required".into(), "none".into()],
relation_tables: vec!["customer".into(), "project".into()],
row_display_columns: vec!["number".into()],
..Default::default()
}
}
#[test]
fn round_trips_columns_links_and_display_columns() {
fn round_trips_columns_relation_tables_and_display_columns() {
let draft = posted_form().to_draft();
assert_eq!(draft.columns.added.len(), 2);
assert!(draft.columns.added[0].indexed);
assert_eq!(draft.columns.added[1].money_mode, MoneyMode::Rounded);
assert_eq!(draft.links[0].mode, LinkMode::Required);
assert_eq!(draft.links[1].mode, LinkMode::None);
assert_eq!(draft.relation_tables, vec!["customer", "project"]);
assert_eq!(draft.row_display_columns, vec!["number"]);
assert!(!draft.creating_new_profile);
}