use std::collections::{HashMap, HashSet};
use anyhow::{Result, anyhow, bail};
use crate::cli::{DiffCommand, DiffOutputFormat, DiffShow, HeaderCase, JsonMode, OutputFormat, XmlMode};
use crate::input_spec::{InputSpec, parse_input_specs};
use crate::output::{parse_output_path_spec, write_query_result};
pub(crate) struct DiffExecution {
pub(crate) result: query_forge::QueryResult,
pub(crate) has_differences: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum ChangeClass {
Added,
Removed,
Changed,
Unchanged,
}
#[derive(Debug, Clone)]
struct TableData {
columns: Vec<String>,
rows: Vec<Vec<query_forge::QueryValue>>,
index: HashMap<String, usize>,
}
pub(crate) fn run_diff_command(cmd: DiffCommand) -> Result<i32> {
let execution = execute_diff_command(&cmd)?;
let format = cmd.format.map(map_diff_output_format);
let (output_path, output_format_hint) = parse_output_path_spec(cmd.output.as_deref());
write_query_result(
&execution.result,
output_path.as_deref(),
format,
output_format_hint.as_deref(),
)?;
Ok(if execution.has_differences { 1 } else { 0 })
}
pub(crate) fn execute_diff_command(cmd: &DiffCommand) -> Result<DiffExecution> {
let (left, right) = parse_left_right_specs(&cmd.left, &cmd.right)?;
let type_inference_options = resolve_type_inference_options(&cmd);
let input_normalization_options = resolve_input_normalization_options(&cmd);
let extraction_options = resolve_extraction_options(&cmd);
let show = resolve_show_filter(&cmd.show);
let key_columns = normalize_ordered_column_list(&cmd.key)?;
let ignored_columns = normalize_column_set(&cmd.ignore_columns)?;
let (result, has_differences) = if cmd.schema_only {
build_schema_diff_result(
&left,
&right,
&show,
&type_inference_options,
&input_normalization_options,
&extraction_options,
!cmd.no_headers,
)?
} else {
build_data_diff_result(
&left,
&right,
&key_columns,
&show,
&ignored_columns,
cmd.side_by_side,
&type_inference_options,
&input_normalization_options,
&extraction_options,
!cmd.no_headers,
)?
};
Ok(DiffExecution {
result,
has_differences,
})
}
fn parse_left_right_specs(left: &str, right: &str) -> Result<(InputSpec, InputSpec)> {
let specs = parse_input_specs(&[left.to_owned(), right.to_owned()], None)?;
let left = specs
.first()
.ok_or_else(|| anyhow!("missing left dataset specification"))?;
let right = specs
.get(1)
.ok_or_else(|| anyhow!("missing right dataset specification"))?;
Ok((left.clone(), right.clone()))
}
fn build_data_diff_result(
left: &InputSpec,
right: &InputSpec,
key_columns: &[String],
show: &HashSet<ChangeClass>,
ignored_columns: &HashSet<String>,
side_by_side: bool,
type_inference_options: &query_forge::TypeInferenceOptions,
input_normalization_options: &query_forge::InputNormalizationOptions,
extraction_options: &query_forge::ExtractionOptions,
has_headers: bool,
) -> Result<(query_forge::QueryResult, bool)> {
let left_data = load_table_data(
left,
right,
"__left",
"SELECT * FROM \"__left\"",
type_inference_options,
input_normalization_options,
extraction_options,
has_headers,
)?;
let right_data = load_table_data(
left,
right,
"__right",
"SELECT * FROM \"__right\"",
type_inference_options,
input_normalization_options,
extraction_options,
has_headers,
)?;
let left_table = TableData::new(left_data);
let right_table = TableData::new(right_data);
validate_keys(key_columns, &left_table, &right_table)?;
let union_columns = build_union_columns(&left_table.columns, &right_table.columns);
let compare_columns = union_columns
.iter()
.filter(|col| !ignored_columns.contains(*col) && !key_columns.iter().any(|k| k == *col))
.cloned()
.collect::<Vec<_>>();
if side_by_side {
build_side_by_side_diff(
&left_table,
&right_table,
&union_columns,
key_columns,
&compare_columns,
show,
)
} else {
build_standard_diff(
&left_table,
&right_table,
&union_columns,
key_columns,
&compare_columns,
show,
)
}
}
fn build_schema_diff_result(
left: &InputSpec,
right: &InputSpec,
show: &HashSet<ChangeClass>,
type_inference_options: &query_forge::TypeInferenceOptions,
input_normalization_options: &query_forge::InputNormalizationOptions,
extraction_options: &query_forge::ExtractionOptions,
has_headers: bool,
) -> Result<(query_forge::QueryResult, bool)> {
let left_data = load_table_data(
left,
right,
"__left",
"SELECT * FROM \"__left\"",
type_inference_options,
input_normalization_options,
extraction_options,
has_headers,
)?;
let right_data = load_table_data(
left,
right,
"__right",
"SELECT * FROM \"__right\"",
type_inference_options,
input_normalization_options,
extraction_options,
has_headers,
)?;
let left_types = infer_schema_types(&left_data);
let right_types = infer_schema_types(&right_data);
let mut column_order = left_data.columns.clone();
for column in &right_data.columns {
if !column_order.iter().any(|existing| existing == column) {
column_order.push(column.clone());
}
}
let mut rows = Vec::new();
let mut has_differences = false;
for column in column_order {
let left_ty = left_types.get(&column);
let right_ty = right_types.get(&column);
let class = match (left_ty, right_ty) {
(Some(_), None) => ChangeClass::Removed,
(None, Some(_)) => ChangeClass::Added,
(Some(l), Some(r)) if l != r => ChangeClass::Changed,
_ => ChangeClass::Unchanged,
};
if class != ChangeClass::Unchanged {
has_differences = true;
}
if !show.contains(&class) {
continue;
}
rows.push(vec![
query_forge::QueryValue::Text(change_class_name(class).to_owned()),
query_forge::QueryValue::Text(column),
query_forge::QueryValue::Text(left_ty.cloned().unwrap_or_default()),
query_forge::QueryValue::Text(right_ty.cloned().unwrap_or_default()),
]);
}
Ok((
query_forge::QueryResult {
columns: vec![
"_diff".to_owned(),
"column".to_owned(),
"left_type".to_owned(),
"right_type".to_owned(),
],
rows,
},
has_differences,
))
}
fn load_table_data(
left: &InputSpec,
right: &InputSpec,
target_table: &str,
sql: &str,
type_inference_options: &query_forge::TypeInferenceOptions,
input_normalization_options: &query_forge::InputNormalizationOptions,
extraction_options: &query_forge::ExtractionOptions,
has_headers: bool,
) -> Result<query_forge::QueryResult> {
let left_input = query_forge::WorkbookInput {
path: left.path.as_path(),
sheet_name: left.sheet_name.as_deref(),
table_name: Some("__left"),
explicit_format: left.explicit_format.as_deref(),
};
let right_input = query_forge::WorkbookInput {
path: right.path.as_path(),
sheet_name: right.sheet_name.as_deref(),
table_name: Some("__right"),
explicit_format: right.explicit_format.as_deref(),
};
let result = query_forge::run_query_with_params_multi_inputs_and_options_and_normalization(
&[left_input, right_input],
sql,
&[],
type_inference_options,
input_normalization_options,
extraction_options,
has_headers,
)?;
if target_table == "__left" && result.columns.is_empty() {
bail!("left dataset has no columns");
}
if target_table == "__right" && result.columns.is_empty() {
bail!("right dataset has no columns");
}
Ok(result)
}
fn build_standard_diff(
left: &TableData,
right: &TableData,
union_columns: &[String],
key_columns: &[String],
compare_columns: &[String],
show: &HashSet<ChangeClass>,
) -> Result<(query_forge::QueryResult, bool)> {
let positional = key_columns.is_empty();
let mut output_columns = vec!["_diff".to_owned()];
if positional {
output_columns.push("_row_index".to_owned());
}
output_columns.extend(union_columns.iter().cloned());
let mut rows = Vec::new();
let mut has_differences = false;
if positional {
let max_len = left.rows.len().max(right.rows.len());
for index in 0..max_len {
let left_row = left.rows.get(index);
let right_row = right.rows.get(index);
let class = classify_rows(left_row, right_row, left, right, compare_columns);
if class != ChangeClass::Unchanged {
has_differences = true;
}
if !show.contains(&class) {
continue;
}
match class {
ChangeClass::Changed => {
if let Some(row) = left_row {
rows.push(build_standard_row(
"changed-left",
Some(index),
row,
left,
union_columns,
));
}
if let Some(row) = right_row {
rows.push(build_standard_row(
"changed-right",
Some(index),
row,
right,
union_columns,
));
}
}
_ => {
let (label, row, table) = match class {
ChangeClass::Added => ("added", right_row, right),
ChangeClass::Removed => ("removed", left_row, left),
ChangeClass::Unchanged => ("unchanged", left_row, left),
ChangeClass::Changed => unreachable!(),
};
if let Some(row) = row {
rows.push(build_standard_row(label, Some(index), row, table, union_columns));
}
}
}
}
} else {
let (left_map, left_order) = build_keyed_map(left, key_columns)?;
let (right_map, right_order) = build_keyed_map(right, key_columns)?;
let mut key_order = left_order;
for key in right_order {
if !key_order.iter().any(|existing| existing == &key) {
key_order.push(key);
}
}
for key in key_order {
let left_row = left_map.get(&key);
let right_row = right_map.get(&key);
let class = classify_rows(left_row, right_row, left, right, compare_columns);
if class != ChangeClass::Unchanged {
has_differences = true;
}
if !show.contains(&class) {
continue;
}
match class {
ChangeClass::Changed => {
if let Some(row) = left_row {
rows.push(build_standard_row(
"changed-left",
None,
row,
left,
union_columns,
));
}
if let Some(row) = right_row {
rows.push(build_standard_row(
"changed-right",
None,
row,
right,
union_columns,
));
}
}
_ => {
let (label, row, table) = match class {
ChangeClass::Added => ("added", right_row, right),
ChangeClass::Removed => ("removed", left_row, left),
ChangeClass::Unchanged => ("unchanged", left_row, left),
ChangeClass::Changed => unreachable!(),
};
if let Some(row) = row {
rows.push(build_standard_row(label, None, row, table, union_columns));
}
}
}
}
}
Ok((
query_forge::QueryResult {
columns: output_columns,
rows,
},
has_differences,
))
}
fn build_side_by_side_diff(
left: &TableData,
right: &TableData,
union_columns: &[String],
key_columns: &[String],
compare_columns: &[String],
show: &HashSet<ChangeClass>,
) -> Result<(query_forge::QueryResult, bool)> {
let positional = key_columns.is_empty();
let mut output_columns = vec!["_diff".to_owned()];
if positional {
output_columns.push("_row_index".to_owned());
} else {
output_columns.extend(key_columns.iter().cloned());
}
let non_key_columns = union_columns
.iter()
.filter(|column| !key_columns.iter().any(|key| key == *column))
.cloned()
.collect::<Vec<_>>();
for column in &non_key_columns {
output_columns.push(format!("_left_{column}"));
output_columns.push(format!("_right_{column}"));
}
let mut rows = Vec::new();
let mut has_differences = false;
if positional {
let max_len = left.rows.len().max(right.rows.len());
for index in 0..max_len {
let left_row = left.rows.get(index);
let right_row = right.rows.get(index);
let class = classify_rows(left_row, right_row, left, right, compare_columns);
if class != ChangeClass::Unchanged {
has_differences = true;
}
if !show.contains(&class) {
continue;
}
rows.push(build_side_by_side_row(
change_class_name(class),
Some(index),
key_columns,
&non_key_columns,
left_row,
right_row,
left,
right,
));
}
} else {
let (left_map, left_order) = build_keyed_map(left, key_columns)?;
let (right_map, right_order) = build_keyed_map(right, key_columns)?;
let mut key_order = left_order;
for key in right_order {
if !key_order.iter().any(|existing| existing == &key) {
key_order.push(key);
}
}
for key in key_order {
let left_row = left_map.get(&key);
let right_row = right_map.get(&key);
let class = classify_rows(left_row, right_row, left, right, compare_columns);
if class != ChangeClass::Unchanged {
has_differences = true;
}
if !show.contains(&class) {
continue;
}
rows.push(build_side_by_side_row(
change_class_name(class),
None,
key_columns,
&non_key_columns,
left_row,
right_row,
left,
right,
));
}
}
Ok((
query_forge::QueryResult {
columns: output_columns,
rows,
},
has_differences,
))
}
fn build_standard_row(
label: &str,
row_index: Option<usize>,
row: &[query_forge::QueryValue],
table: &TableData,
union_columns: &[String],
) -> Vec<query_forge::QueryValue> {
let mut out = vec![query_forge::QueryValue::Text(label.to_owned())];
if let Some(index) = row_index {
out.push(query_forge::QueryValue::Integer((index + 1) as i64));
}
for column in union_columns {
out.push(value_by_column(row, table, column));
}
out
}
fn build_side_by_side_row(
label: &str,
row_index: Option<usize>,
key_columns: &[String],
non_key_columns: &[String],
left_row: Option<&Vec<query_forge::QueryValue>>,
right_row: Option<&Vec<query_forge::QueryValue>>,
left_table: &TableData,
right_table: &TableData,
) -> Vec<query_forge::QueryValue> {
let mut out = vec![query_forge::QueryValue::Text(label.to_owned())];
if let Some(index) = row_index {
out.push(query_forge::QueryValue::Integer((index + 1) as i64));
} else {
for key in key_columns {
let value = left_row
.map(|row| value_by_column(row, left_table, key))
.or_else(|| right_row.map(|row| value_by_column(row, right_table, key)))
.unwrap_or(query_forge::QueryValue::Null);
out.push(value);
}
}
for column in non_key_columns {
out.push(
left_row
.map(|row| value_by_column(row, left_table, column))
.unwrap_or(query_forge::QueryValue::Null),
);
out.push(
right_row
.map(|row| value_by_column(row, right_table, column))
.unwrap_or(query_forge::QueryValue::Null),
);
}
out
}
fn classify_rows(
left_row: Option<&Vec<query_forge::QueryValue>>,
right_row: Option<&Vec<query_forge::QueryValue>>,
left: &TableData,
right: &TableData,
compare_columns: &[String],
) -> ChangeClass {
match (left_row, right_row) {
(Some(_), None) => ChangeClass::Removed,
(None, Some(_)) => ChangeClass::Added,
(Some(left_row), Some(right_row)) => {
if rows_match(left_row, right_row, left, right, compare_columns) {
ChangeClass::Unchanged
} else {
ChangeClass::Changed
}
}
(None, None) => ChangeClass::Unchanged,
}
}
fn rows_match(
left_row: &[query_forge::QueryValue],
right_row: &[query_forge::QueryValue],
left: &TableData,
right: &TableData,
compare_columns: &[String],
) -> bool {
compare_columns.iter().all(|column| {
let left_value = value_by_column(left_row, left, column);
let right_value = value_by_column(right_row, right, column);
left_value == right_value
})
}
fn build_keyed_map(
table: &TableData,
keys: &[String],
) -> Result<(HashMap<String, Vec<query_forge::QueryValue>>, Vec<String>)> {
let mut map = HashMap::new();
let mut order = Vec::new();
for row in &table.rows {
let key = build_key_signature(row, table, keys);
if map.contains_key(&key) {
bail!("duplicate key detected for --key columns: {}", keys.join(","));
}
map.insert(key.clone(), row.clone());
order.push(key);
}
Ok((map, order))
}
fn build_key_signature(row: &[query_forge::QueryValue], table: &TableData, keys: &[String]) -> String {
keys.iter()
.map(|key| canonical_value(&value_by_column(row, table, key)))
.collect::<Vec<_>>()
.join("\u{1f}")
}
fn canonical_value(value: &query_forge::QueryValue) -> String {
match value {
query_forge::QueryValue::Null => "null".to_owned(),
query_forge::QueryValue::Integer(n) => format!("i:{n}"),
query_forge::QueryValue::Real(n) => format!("r:{n:.17}"),
query_forge::QueryValue::Text(s) => format!("t:{s:?}"),
}
}
fn value_by_column(
row: &[query_forge::QueryValue],
table: &TableData,
column: &str,
) -> query_forge::QueryValue {
table
.index
.get(column)
.and_then(|index| row.get(*index))
.cloned()
.unwrap_or(query_forge::QueryValue::Null)
}
fn infer_schema_types(result: &query_forge::QueryResult) -> HashMap<String, String> {
let mut out = HashMap::new();
for (index, column) in result.columns.iter().enumerate() {
let mut has_integer = false;
let mut has_real = false;
let mut has_text = false;
for row in &result.rows {
match row.get(index) {
Some(query_forge::QueryValue::Integer(_)) => has_integer = true,
Some(query_forge::QueryValue::Real(_)) => has_real = true,
Some(query_forge::QueryValue::Text(_)) => has_text = true,
Some(query_forge::QueryValue::Null) | None => {}
}
}
let inferred = if has_text && (has_integer || has_real) {
"MIXED"
} else if has_text {
"TEXT"
} else if has_real {
"REAL"
} else if has_integer {
"INTEGER"
} else {
"TEXT"
};
out.insert(column.clone(), inferred.to_owned());
}
out
}
fn validate_keys(keys: &[String], left: &TableData, right: &TableData) -> Result<()> {
for key in keys {
if !left.index.contains_key(key) {
bail!("key column '{key}' does not exist in left dataset");
}
if !right.index.contains_key(key) {
bail!("key column '{key}' does not exist in right dataset");
}
}
Ok(())
}
fn build_union_columns(left: &[String], right: &[String]) -> Vec<String> {
let mut columns = left.to_vec();
for column in right {
if !columns.iter().any(|existing| existing == column) {
columns.push(column.clone());
}
}
columns
}
fn normalize_ordered_column_list(values: &[String]) -> Result<Vec<String>> {
let mut out = Vec::new();
for value in values {
let trimmed = value.trim();
if trimmed.is_empty() {
bail!("column names cannot be empty");
}
if !out.iter().any(|existing| existing == trimmed) {
out.push(trimmed.to_owned());
}
}
Ok(out)
}
fn normalize_column_set(values: &[String]) -> Result<HashSet<String>> {
let mut out = HashSet::new();
for value in values {
let trimmed = value.trim();
if trimmed.is_empty() {
bail!("column names cannot be empty");
}
out.insert(trimmed.to_owned());
}
Ok(out)
}
fn resolve_show_filter(values: &[DiffShow]) -> HashSet<ChangeClass> {
if values.iter().any(|value| matches!(value, DiffShow::All)) {
return [
ChangeClass::Added,
ChangeClass::Removed,
ChangeClass::Changed,
ChangeClass::Unchanged,
]
.into_iter()
.collect();
}
values
.iter()
.map(|value| match value {
DiffShow::Added => ChangeClass::Added,
DiffShow::Removed => ChangeClass::Removed,
DiffShow::Changed => ChangeClass::Changed,
DiffShow::Unchanged => ChangeClass::Unchanged,
DiffShow::All => ChangeClass::Unchanged,
})
.collect()
}
fn change_class_name(class: ChangeClass) -> &'static str {
match class {
ChangeClass::Added => "added",
ChangeClass::Removed => "removed",
ChangeClass::Changed => "changed",
ChangeClass::Unchanged => "unchanged",
}
}
fn map_diff_output_format(format: DiffOutputFormat) -> OutputFormat {
match format {
DiffOutputFormat::Table | DiffOutputFormat::Text => OutputFormat::Table,
DiffOutputFormat::Csv => OutputFormat::Csv,
DiffOutputFormat::Json => OutputFormat::Json,
DiffOutputFormat::Jsonl => OutputFormat::Jsonl,
DiffOutputFormat::Markdown => OutputFormat::Markdown,
DiffOutputFormat::Html => OutputFormat::Html,
}
}
fn resolve_type_inference_options(cmd: &DiffCommand) -> query_forge::TypeInferenceOptions {
let defaults = query_forge::TypeInferenceOptions::default();
query_forge::TypeInferenceOptions {
infer_types: !cmd.all_text || cmd.infer_types,
decimal_comma: cmd.decimal_comma,
date_format: cmd.date_format.clone(),
null_values: if cmd.null_values.is_empty() {
defaults.null_values
} else {
cmd.null_values
.iter()
.map(|value| value.trim().to_owned())
.collect()
},
true_values: if cmd.true_values.is_empty() {
defaults.true_values
} else {
cmd.true_values
.iter()
.map(|value| value.trim().to_owned())
.collect()
},
false_values: if cmd.false_values.is_empty() {
defaults.false_values
} else {
cmd.false_values
.iter()
.map(|value| value.trim().to_owned())
.collect()
},
}
}
fn resolve_input_normalization_options(cmd: &DiffCommand) -> query_forge::InputNormalizationOptions {
query_forge::InputNormalizationOptions {
trim: cmd.trim,
skip_empty_rows: cmd.skip_empty_rows,
normalize_headers: cmd.normalize_headers,
header_case: match cmd.header_case {
Some(HeaderCase::Snake) => Some(query_forge::HeaderCase::Snake),
Some(HeaderCase::Camel) => Some(query_forge::HeaderCase::Camel),
Some(HeaderCase::Pascal) => Some(query_forge::HeaderCase::Pascal),
Some(HeaderCase::ScreamingSnake) => Some(query_forge::HeaderCase::ScreamingSnake),
None => None,
},
dedupe_headers: cmd.dedupe_headers,
}
}
fn resolve_extraction_options(cmd: &DiffCommand) -> query_forge::ExtractionOptions {
query_forge::ExtractionOptions {
json_mode: match cmd.json_mode {
Some(JsonMode::Array) | None => query_forge::JsonMode::Array,
Some(JsonMode::Object) => query_forge::JsonMode::Object,
Some(JsonMode::Flatten) => query_forge::JsonMode::Flatten,
},
xml_mode: match cmd.xml_mode {
Some(XmlMode::Rows) | None => query_forge::XmlMode::Rows,
Some(XmlMode::Descendants) => query_forge::XmlMode::Descendants,
Some(XmlMode::Attributes) => query_forge::XmlMode::Attributes,
},
}
}
impl TableData {
fn new(result: query_forge::QueryResult) -> Self {
let index = result
.columns
.iter()
.enumerate()
.map(|(idx, name)| (name.clone(), idx))
.collect();
Self {
columns: result.columns,
rows: result.rows,
index,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn qv_text(value: &str) -> query_forge::QueryValue {
query_forge::QueryValue::Text(value.to_owned())
}
fn table(columns: &[&str], rows: Vec<Vec<query_forge::QueryValue>>) -> TableData {
TableData::new(query_forge::QueryResult {
columns: columns.iter().map(|c| (*c).to_owned()).collect(),
rows,
})
}
#[test]
fn standard_diff_emits_changed_left_and_right_rows() {
let left = table(
&["id", "value"],
vec![vec![query_forge::QueryValue::Integer(1), qv_text("a")]],
);
let right = table(
&["id", "value"],
vec![vec![query_forge::QueryValue::Integer(1), qv_text("b")]],
);
let show = resolve_show_filter(&[DiffShow::All]);
let compare_columns = vec!["value".to_owned()];
let (result, has_differences) = build_standard_diff(
&left,
&right,
&["id".to_owned(), "value".to_owned()],
&["id".to_owned()],
&compare_columns,
&show,
)
.expect("diff");
assert!(has_differences);
assert_eq!(result.rows.len(), 2);
assert_eq!(result.columns[0], "_diff");
assert_eq!(result.rows[0][0], qv_text("changed-left"));
assert_eq!(result.rows[1][0], qv_text("changed-right"));
}
#[test]
fn side_by_side_diff_pairs_columns() {
let left = table(
&["id", "value"],
vec![vec![query_forge::QueryValue::Integer(1), qv_text("a")]],
);
let right = table(
&["id", "value"],
vec![vec![query_forge::QueryValue::Integer(1), qv_text("b")]],
);
let show = resolve_show_filter(&[DiffShow::All]);
let compare_columns = vec!["value".to_owned()];
let (result, has_differences) = build_side_by_side_diff(
&left,
&right,
&["id".to_owned(), "value".to_owned()],
&["id".to_owned()],
&compare_columns,
&show,
)
.expect("side-by-side diff");
assert!(has_differences);
assert_eq!(
result.columns,
vec!["_diff", "id", "_left_value", "_right_value"]
.into_iter()
.map(str::to_owned)
.collect::<Vec<_>>()
);
assert_eq!(result.rows.len(), 1);
assert_eq!(result.rows[0][0], qv_text("changed"));
}
#[test]
fn schema_diff_detects_added_removed_and_changed() {
let left = query_forge::QueryResult {
columns: vec!["id".to_owned(), "amount".to_owned()],
rows: vec![vec![query_forge::QueryValue::Integer(1), qv_text("10")]],
};
let right = query_forge::QueryResult {
columns: vec!["id".to_owned(), "amount".to_owned(), "new_col".to_owned()],
rows: vec![vec![
query_forge::QueryValue::Integer(1),
query_forge::QueryValue::Real(10.0),
qv_text("x"),
]],
};
let left_types = infer_schema_types(&left);
let right_types = infer_schema_types(&right);
assert_eq!(left_types.get("amount").map(String::as_str), Some("TEXT"));
assert_eq!(right_types.get("amount").map(String::as_str), Some("REAL"));
assert_eq!(right_types.get("new_col").map(String::as_str), Some("TEXT"));
}
}