|
|
|
|
@@ -15,11 +15,14 @@
|
|
|
|
|
//! destination columns. From there the import is the strict one it always was —
|
|
|
|
|
//! types, validations, scripts, links and permissions are all the server's.
|
|
|
|
|
|
|
|
|
|
use std::collections::HashSet;
|
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
|
|
|
|
|
|
use crate::{i18n::Locale, tr};
|
|
|
|
|
|
|
|
|
|
use super::super::common::csv::{parse_csv, write_record};
|
|
|
|
|
use super::super::common::{
|
|
|
|
|
csv::{parse_csv, write_record},
|
|
|
|
|
schema::is_system_column,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// The uploaded file: its header, and its data.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
@@ -99,9 +102,17 @@ pub(crate) fn read_mapping(
|
|
|
|
|
chosen: &[(String, Option<usize>)],
|
|
|
|
|
source: &Source,
|
|
|
|
|
) -> Result<Vec<Assignment>, String> {
|
|
|
|
|
let mut used = HashSet::new();
|
|
|
|
|
let mut used_sources = HashSet::new();
|
|
|
|
|
let mut used_destinations = HashSet::new();
|
|
|
|
|
let mut assignments = Vec::new();
|
|
|
|
|
for (column, position) in chosen {
|
|
|
|
|
if !used_destinations.insert(column.as_str()) {
|
|
|
|
|
return Err(tr!(
|
|
|
|
|
locale,
|
|
|
|
|
"import-err-destination-used-twice",
|
|
|
|
|
"column" => column.clone(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let Some(index) = position else { continue };
|
|
|
|
|
// A position the file does not have — the CSV was edited after the
|
|
|
|
|
// mapping was built, or the form was tampered with.
|
|
|
|
|
@@ -117,7 +128,7 @@ pub(crate) fn read_mapping(
|
|
|
|
|
// One source column feeding two destinations is more often a misclick
|
|
|
|
|
// than an intention, and the page stops offering a position once it is
|
|
|
|
|
// taken. Saying so beats copying a value into two columns quietly.
|
|
|
|
|
if !used.insert(*index) {
|
|
|
|
|
if !used_sources.insert(*index) {
|
|
|
|
|
return Err(tr!(
|
|
|
|
|
locale,
|
|
|
|
|
"import-err-source-used-twice",
|
|
|
|
|
@@ -140,13 +151,15 @@ pub(crate) fn read_mapping(
|
|
|
|
|
pub(crate) struct Prepared {
|
|
|
|
|
/// The destination header, in the order the mapping step lists the columns.
|
|
|
|
|
pub columns: Vec<String>,
|
|
|
|
|
/// Data rows holding only the mapped values, aligned to `columns`.
|
|
|
|
|
/// Data rows aligned to `columns`: mapped values plus empty normal form
|
|
|
|
|
/// fields for destinations with no mapping.
|
|
|
|
|
pub rows: Vec<Vec<String>>,
|
|
|
|
|
/// The source positions no destination takes, from 1.
|
|
|
|
|
pub ignored: Vec<usize>,
|
|
|
|
|
/// Destination columns this import does not write. The server fills them
|
|
|
|
|
/// with their defaults, or refuses the row if it cannot.
|
|
|
|
|
pub omitted: Vec<String>,
|
|
|
|
|
/// Destination columns with no source mapping. They are included with an
|
|
|
|
|
/// empty value, which the importer converts to NULL just like an empty
|
|
|
|
|
/// field in the normal client form.
|
|
|
|
|
pub empty: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Prepared {
|
|
|
|
|
@@ -168,24 +181,39 @@ impl Prepared {
|
|
|
|
|
/// Applies the mapping: the values that were asked for, under the names they
|
|
|
|
|
/// were asked for.
|
|
|
|
|
///
|
|
|
|
|
/// `writable` is every destination column the table offers, so the result can
|
|
|
|
|
/// say which ones this import leaves alone.
|
|
|
|
|
/// `writable` is every destination column the table offers. Normal form fields
|
|
|
|
|
/// are always included: an unmapped one becomes NULL, exactly as when the user
|
|
|
|
|
/// leaves that field empty in the client form. An unmapped system column is
|
|
|
|
|
/// omitted so its server-owned default still applies (`deleted = false`).
|
|
|
|
|
pub(crate) fn prepare(
|
|
|
|
|
assignments: &[Assignment],
|
|
|
|
|
source: &Source,
|
|
|
|
|
writable: &[String],
|
|
|
|
|
) -> Prepared {
|
|
|
|
|
let columns = assignments
|
|
|
|
|
let sources_by_column = assignments
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|assignment| assignment.column.clone())
|
|
|
|
|
.map(|assignment| (assignment.column.as_str(), assignment.source))
|
|
|
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
|
let columns = writable
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|column| {
|
|
|
|
|
!is_system_column(column) || sources_by_column.contains_key(column.as_str())
|
|
|
|
|
})
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let rows = source
|
|
|
|
|
.rows
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|row| {
|
|
|
|
|
assignments
|
|
|
|
|
columns
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|assignment| row.get(assignment.source).cloned().unwrap_or_default())
|
|
|
|
|
.map(|column| {
|
|
|
|
|
sources_by_column
|
|
|
|
|
.get(column.as_str())
|
|
|
|
|
.and_then(|position| row.get(*position))
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
@@ -199,9 +227,9 @@ pub(crate) fn prepare(
|
|
|
|
|
.filter(|index| !taken.contains(index))
|
|
|
|
|
.map(|index| index + 1)
|
|
|
|
|
.collect(),
|
|
|
|
|
omitted: writable
|
|
|
|
|
empty: columns
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|column| !columns.contains(column))
|
|
|
|
|
.filter(|column| !sources_by_column.contains_key(column.as_str()))
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect(),
|
|
|
|
|
columns,
|
|
|
|
|
@@ -267,7 +295,7 @@ mod tests {
|
|
|
|
|
/// The whole point, end to end: three destinations take three positions,
|
|
|
|
|
/// the other three source columns go nowhere.
|
|
|
|
|
#[test]
|
|
|
|
|
fn only_the_mapped_destinations_are_prepared() {
|
|
|
|
|
fn mapped_values_land_and_unmapped_form_fields_are_empty() {
|
|
|
|
|
let source = example();
|
|
|
|
|
let writable = strings(&["a", "b", "c", "note"]);
|
|
|
|
|
let assignments = read_mapping(
|
|
|
|
|
@@ -283,14 +311,14 @@ mod tests {
|
|
|
|
|
.unwrap();
|
|
|
|
|
let prepared = prepare(&assignments, &source, &writable);
|
|
|
|
|
|
|
|
|
|
assert_eq!(prepared.columns, strings(&["a", "b", "c"]));
|
|
|
|
|
assert_eq!(prepared.columns, strings(&["a", "b", "c", "note"]));
|
|
|
|
|
assert_eq!(prepared.ignored, vec![1, 2, 6]);
|
|
|
|
|
assert_eq!(prepared.omitted, strings(&["note"]));
|
|
|
|
|
assert_eq!(prepared.empty, strings(&["note"]));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
canonical_csv(&prepared),
|
|
|
|
|
"\"a\",\"b\",\"c\"\n\
|
|
|
|
|
\"value-a\",\"value-b\",\"value-c\"\n\
|
|
|
|
|
\"other-a\",\"other-b\",\"other-c\"\n"
|
|
|
|
|
"\"a\",\"b\",\"c\",\"note\"\n\
|
|
|
|
|
\"value-a\",\"value-b\",\"value-c\",\"\"\n\
|
|
|
|
|
\"other-a\",\"other-b\",\"other-c\",\"\"\n"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -314,9 +342,34 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
canonical_csv(&prepared),
|
|
|
|
|
"\"a\",\"c\"\n\"unused\",\"x1\"\n\"unused\",\"y1\"\n"
|
|
|
|
|
"\"a\",\"b\",\"c\"\n\"unused\",\"\",\"x1\"\n\"unused\",\"\",\"y1\"\n"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(prepared.empty, strings(&["b"]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `deleted` is not a normal form field. When it is not mapped, leaving it
|
|
|
|
|
/// out lets the server use the same FALSE default as an ordinary form post.
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_unmapped_system_column_keeps_its_server_default() {
|
|
|
|
|
let source = example();
|
|
|
|
|
let assignments = read_mapping(
|
|
|
|
|
Locale::default(),
|
|
|
|
|
&[("a".to_string(), Some(2))],
|
|
|
|
|
&source,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let prepared = prepare(
|
|
|
|
|
&assignments,
|
|
|
|
|
&source,
|
|
|
|
|
&strings(&["deleted", "a", "note"]),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(prepared.columns, strings(&["a", "note"]));
|
|
|
|
|
assert_eq!(prepared.empty, strings(&["note"]));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
canonical_csv(&prepared),
|
|
|
|
|
"\"a\",\"note\"\n\"value-a\",\"\"\n\"other-a\",\"\"\n"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(prepared.omitted, strings(&["b"]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// What is downloaded and what is imported are the same file. The import
|
|
|
|
|
@@ -377,6 +430,20 @@ mod tests {
|
|
|
|
|
assert!(error.contains("hl") && error.contains('3'), "{error}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A modified form cannot name one destination twice. Without this check,
|
|
|
|
|
/// the final HashMap would silently keep only the second value.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_destination_cannot_be_used_twice() {
|
|
|
|
|
let source = example();
|
|
|
|
|
let error = read_mapping(
|
|
|
|
|
Locale::default(),
|
|
|
|
|
&[("a".to_string(), Some(2)), ("a".to_string(), Some(3))],
|
|
|
|
|
&source,
|
|
|
|
|
)
|
|
|
|
|
.expect_err("destination a cannot take both source positions");
|
|
|
|
|
assert!(error.contains('a'), "{error}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A position the file does not have is refused rather than read as blank.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_position_past_the_end_of_the_file_is_refused() {
|
|
|
|
|
|