Skip to main content

reifydb_export/render/
dml.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::collections::HashMap;
5
6use reifydb_value::value::Value;
7
8use crate::{
9	error::{ExportError, RenderError},
10	model::{NameResolver, ObjectRows},
11	render::{
12		layout::{EnumColumn, LayoutColumn},
13		value::render_value,
14	},
15};
16
17pub fn render_inserts(
18	qualified_object: &str,
19	rows: &ObjectRows,
20	batch_size: usize,
21	layout: &[LayoutColumn],
22	resolver: &NameResolver,
23) -> Result<String, ExportError> {
24	if rows.rows.is_empty() {
25		return Ok(String::new());
26	}
27
28	let index: HashMap<&str, usize> = rows.columns.iter().enumerate().map(|(i, name)| (name.as_str(), i)).collect();
29
30	let batch = batch_size.max(1);
31	let mut out = String::new();
32
33	for chunk in rows.rows.chunks(batch) {
34		out.push_str(&format!("INSERT {} [\n", qualified_object));
35		for (i, row) in chunk.iter().enumerate() {
36			out.push_str("  { ");
37			for (j, column) in layout.iter().enumerate() {
38				if j > 0 {
39					out.push_str(", ");
40				}
41				match column {
42					LayoutColumn::Plain(c) => {
43						let rendered = match cell(&index, row, &c.name) {
44							Some(value) => render_value(value).map_err(|e| {
45								map_value_error(e, qualified_object, &c.name)
46							})?,
47							None => "none".to_string(),
48						};
49						out.push_str(&format!("{}: {}", c.name, rendered));
50					}
51					LayoutColumn::Enum(e) => {
52						let rendered =
53							render_enum_value(e, resolver, &index, row, qualified_object)?;
54						out.push_str(&format!("{}: {}", e.logical_name, rendered));
55					}
56				}
57			}
58			out.push_str(" }");
59			if i + 1 < chunk.len() {
60				out.push(',');
61			}
62			out.push('\n');
63		}
64		out.push_str("];\n");
65	}
66
67	Ok(out)
68}
69
70fn cell<'a>(index: &HashMap<&str, usize>, row: &'a [Value], name: &str) -> Option<&'a Value> {
71	index.get(name).and_then(|&i| row.get(i))
72}
73
74fn render_enum_value(
75	column: &EnumColumn,
76	resolver: &NameResolver,
77	index: &HashMap<&str, usize>,
78	row: &[Value],
79	object: &str,
80) -> Result<String, ExportError> {
81	let qualified = &resolver
82		.sumtype(column.sumtype_id)
83		.ok_or_else(|| ExportError::UnresolvedReference {
84			kind: "sumtype",
85			id: column.sumtype_id,
86			object: object.to_string(),
87		})?
88		.qualified_name;
89
90	let tag = match cell(index, row, &column.tag_column) {
91		None
92		| Some(Value::None {
93			..
94		}) => return Ok("none".to_string()),
95		Some(Value::Uint1(t)) => *t,
96		Some(_) => {
97			return Err(ExportError::UnsupportedValue {
98				object: object.to_string(),
99				column: column.logical_name.clone(),
100				value_type: "enum tag (expected uint1)".to_string(),
101			});
102		}
103	};
104
105	let variant =
106		column.variants.iter().find(|v| v.tag == tag).ok_or_else(|| ExportError::UnresolvedReference {
107			kind: "sumtype variant",
108			id: tag as u64,
109			object: object.to_string(),
110		})?;
111
112	if variant.fields.is_empty() {
113		return Ok(format!("{}::{}", qualified, variant.name));
114	}
115
116	let mut parts = Vec::with_capacity(variant.fields.len());
117	for (field_name, physical) in &variant.fields {
118		let rendered = match cell(index, row, physical) {
119			Some(value) => render_value(value).map_err(|e| map_value_error(e, object, field_name))?,
120			None => "none".to_string(),
121		};
122		parts.push(format!("{}: {}", field_name, rendered));
123	}
124	Ok(format!("{}::{} {{ {} }}", qualified, variant.name, parts.join(", ")))
125}
126
127fn map_value_error(error: RenderError, object: &str, column: &str) -> ExportError {
128	match error {
129		RenderError::UnrepresentableText => ExportError::UnrepresentableText {
130			object: object.to_string(),
131			column: column.to_string(),
132		},
133		RenderError::NonFiniteFloat => ExportError::NonFiniteFloat {
134			object: object.to_string(),
135			column: column.to_string(),
136		},
137		RenderError::Unsupported(value_type) => ExportError::UnsupportedValue {
138			object: object.to_string(),
139			column: column.to_string(),
140			value_type: value_type.to_string(),
141		},
142	}
143}
144
145#[cfg(test)]
146mod tests {
147	use reifydb_core::interface::catalog::{
148		column::{Column, ColumnIndex},
149		id::ColumnId,
150	};
151	use reifydb_value::value::{constraint::TypeConstraint, value_type::ValueType};
152
153	use super::*;
154	use crate::{model::ResolvedSumType, render::layout::EnumVariant};
155
156	fn rows() -> ObjectRows {
157		ObjectRows {
158			columns: vec!["id".to_string(), "name".to_string()],
159			rows: vec![
160				vec![Value::Int4(1), Value::Utf8("Alice".to_string())],
161				vec![Value::Int4(2), Value::Utf8("Bob".to_string())],
162			],
163		}
164	}
165
166	fn plain(names: &[&str]) -> Vec<Column> {
167		names.iter()
168			.enumerate()
169			.map(|(i, n)| Column {
170				id: ColumnId(i as u64),
171				name: n.to_string(),
172				constraint: TypeConstraint::unconstrained(ValueType::Utf8),
173				properties: vec![],
174				index: ColumnIndex(i as u8),
175				auto_increment: false,
176				dictionary_id: None,
177			})
178			.collect()
179	}
180
181	fn layout(columns: &[Column]) -> Vec<LayoutColumn<'_>> {
182		columns.iter().map(LayoutColumn::Plain).collect()
183	}
184
185	#[test]
186	fn renders_batched_record_inserts() {
187		let cols = plain(&["id", "name"]);
188		let out = render_inserts("test::users", &rows(), 500, &layout(&cols), &NameResolver::empty()).unwrap();
189		assert_eq!(out, "INSERT test::users [\n  { id: 1, name: 'Alice' },\n  { id: 2, name: 'Bob' }\n];\n");
190	}
191
192	#[test]
193	fn splits_into_batches() {
194		let cols = plain(&["id", "name"]);
195		let out = render_inserts("test::users", &rows(), 1, &layout(&cols), &NameResolver::empty()).unwrap();
196		let inserts = out.matches("INSERT test::users").count();
197		assert_eq!(inserts, 2);
198	}
199
200	#[test]
201	fn empty_rows_produce_nothing() {
202		let empty = ObjectRows {
203			columns: vec!["id".to_string()],
204			rows: vec![],
205		};
206		let cols = plain(&["id"]);
207		assert_eq!(
208			render_inserts("test::users", &empty, 500, &layout(&cols), &NameResolver::empty()).unwrap(),
209			""
210		);
211	}
212
213	#[test]
214	fn unrepresentable_value_fails_loud_with_location() {
215		let bad = ObjectRows {
216			columns: vec!["note".to_string()],
217			rows: vec![vec![Value::Utf8("both ' and \"".to_string())]],
218		};
219		let cols = plain(&["note"]);
220		assert_eq!(
221			render_inserts("test::notes", &bad, 500, &layout(&cols), &NameResolver::empty()),
222			Err(ExportError::UnrepresentableText {
223				object: "test::notes".to_string(),
224				column: "note".to_string()
225			})
226		);
227	}
228
229	fn resolver_with(id: u64, qualified: &str) -> NameResolver {
230		let mut r = NameResolver::empty();
231		r.sumtypes.insert(
232			id,
233			ResolvedSumType {
234				qualified_name: qualified.to_string(),
235				variants: vec![],
236			},
237		);
238		r
239	}
240
241	#[test]
242	fn reconstructs_unit_enum_value_from_tag() {
243		let id_cols = plain(&["id"]);
244		let enum_col = EnumColumn {
245			logical_name: "state".to_string(),
246			sumtype_id: 9,
247			tag_column: "state_tag".to_string(),
248			variants: vec![
249				EnumVariant {
250					tag: 0,
251					name: "active".to_string(),
252					fields: vec![],
253				},
254				EnumVariant {
255					tag: 1,
256					name: "inactive".to_string(),
257					fields: vec![],
258				},
259			],
260		};
261		let layout = vec![LayoutColumn::Plain(&id_cols[0]), LayoutColumn::Enum(enum_col)];
262		let rows = ObjectRows {
263			columns: vec!["id".to_string(), "state_tag".to_string()],
264			rows: vec![vec![Value::Int4(1), Value::Uint1(0)], vec![Value::Int4(2), Value::Uint1(1)]],
265		};
266		let out =
267			render_inserts("shop::items", &rows, 500, &layout, &resolver_with(9, "shop::status")).unwrap();
268		assert_eq!(
269			out,
270			"INSERT shop::items [\n  { id: 1, state: shop::status::active },\n  { id: 2, state: shop::status::inactive }\n];\n"
271		);
272	}
273
274	#[test]
275	fn reconstructs_structured_enum_value_from_tag_and_fields() {
276		let id_cols = plain(&["id"]);
277		let enum_col = EnumColumn {
278			logical_name: "shape".to_string(),
279			sumtype_id: 9,
280			tag_column: "shape_tag".to_string(),
281			variants: vec![
282				EnumVariant {
283					tag: 0,
284					name: "circle".to_string(),
285					fields: vec![("radius".to_string(), "shape_circle_radius".to_string())],
286				},
287				EnumVariant {
288					tag: 1,
289					name: "rectangle".to_string(),
290					fields: vec![
291						("width".to_string(), "shape_rectangle_width".to_string()),
292						("height".to_string(), "shape_rectangle_height".to_string()),
293					],
294				},
295			],
296		};
297		let layout = vec![LayoutColumn::Plain(&id_cols[0]), LayoutColumn::Enum(enum_col)];
298		let rows = ObjectRows {
299			columns: vec![
300				"id".to_string(),
301				"shape_tag".to_string(),
302				"shape_circle_radius".to_string(),
303				"shape_rectangle_width".to_string(),
304				"shape_rectangle_height".to_string(),
305			],
306			rows: vec![
307				vec![Value::Int4(1), Value::Uint1(0), Value::float8(5.0), Value::none(), Value::none()],
308				vec![
309					Value::Int4(2),
310					Value::Uint1(1),
311					Value::none(),
312					Value::float8(3.0),
313					Value::float8(4.0),
314				],
315			],
316		};
317		let out =
318			render_inserts("shop::shapes", &rows, 500, &layout, &resolver_with(9, "shop::shape")).unwrap();
319		assert_eq!(
320			out,
321			"INSERT shop::shapes [\n  { id: 1, shape: shop::shape::circle { radius: 5 } },\n  { id: 2, shape: shop::shape::rectangle { width: 3, height: 4 } }\n];\n"
322		);
323	}
324}