Skip to main content

reifydb_value/value/constraint/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use serde::{Deserialize, Serialize};
5
6use crate::{
7	error::{ConstraintKind, Error, TypeError},
8	fragment::Fragment,
9	value::{
10		Value, constraint::bytes::MaxBytes, dictionary::DictionaryId, sumtype::SumTypeId, value_type::ValueType,
11	},
12};
13
14pub mod bytes;
15pub mod precision;
16pub mod scale;
17
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct TypeConstraint {
20	base_type: ValueType,
21	constraint: Option<Constraint>,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub enum Constraint {
26	MaxBytes(MaxBytes),
27
28	Dictionary(DictionaryId, ValueType),
29
30	SumType(SumTypeId),
31}
32
33impl TypeConstraint {
34	pub const fn unconstrained(ty: ValueType) -> Self {
35		Self {
36			base_type: ty,
37			constraint: None,
38		}
39	}
40
41	pub fn with_constraint(ty: ValueType, constraint: Constraint) -> Self {
42		Self {
43			base_type: ty,
44			constraint: Some(constraint),
45		}
46	}
47
48	pub fn dictionary(dictionary_id: DictionaryId, id_type: ValueType) -> Self {
49		Self {
50			base_type: ValueType::DictionaryId,
51			constraint: Some(Constraint::Dictionary(dictionary_id, id_type)),
52		}
53	}
54
55	pub fn sumtype(id: SumTypeId) -> Self {
56		Self {
57			base_type: ValueType::Uint1,
58			constraint: Some(Constraint::SumType(id)),
59		}
60	}
61
62	pub fn get_type(&self) -> ValueType {
63		self.base_type.clone()
64	}
65
66	pub fn storage_type(&self) -> ValueType {
67		match (&self.base_type, &self.constraint) {
68			(ValueType::DictionaryId, Some(Constraint::Dictionary(_, id_type))) => id_type.clone(),
69			_ => self.base_type.clone(),
70		}
71	}
72
73	pub fn constraint(&self) -> &Option<Constraint> {
74		&self.constraint
75	}
76
77	pub fn coerce(&self, value: &mut Value) -> Result<(), Error> {
78		let value_type = value.get_type();
79		if !matches!(value, Value::None { .. }) && !same_family(&value_type, self.base_type.inner_type()) {
80			unimplemented!()
81		}
82
83		if matches!(value, Value::None { .. }) {
84			if self.base_type.is_option() {
85				return Ok(());
86			} else {
87				return Err(TypeError::ConstraintViolation {
88					kind: ConstraintKind::NoneNotAllowed {
89						column_type: self.base_type.clone(),
90					},
91					message: format!(
92						"Cannot insert none into non-optional column of type {}. Declare the column as Option({}) to allow none values.",
93						self.base_type, self.base_type
94					),
95					fragment: Fragment::None,
96				}
97				.into());
98			}
99		}
100
101		match (self.base_type.inner_type(), &self.constraint) {
102			(ValueType::Utf8, Some(Constraint::MaxBytes(max))) => {
103				if let Value::Utf8(s) = value {
104					let byte_len = s.len();
105					let max_value: usize = (*max).into();
106					if byte_len > max_value {
107						return Err(TypeError::ConstraintViolation {
108							kind: ConstraintKind::Utf8MaxBytes {
109								actual: byte_len,
110								max: max_value,
111							},
112							message: format!(
113								"UTF8 value exceeds maximum byte length: {} bytes (max: {} bytes)",
114								byte_len, max_value
115							),
116							fragment: Fragment::None,
117						}
118						.into());
119					}
120				}
121			}
122			(ValueType::Blob, Some(Constraint::MaxBytes(max))) => {
123				if let Value::Blob(blob) = value {
124					let byte_len = blob.len();
125					let max_value: usize = (*max).into();
126					if byte_len > max_value {
127						return Err(TypeError::ConstraintViolation {
128							kind: ConstraintKind::BlobMaxBytes {
129								actual: byte_len,
130								max: max_value,
131							},
132							message: format!(
133								"BLOB value exceeds maximum byte length: {} bytes (max: {} bytes)",
134								byte_len, max_value
135							),
136							fragment: Fragment::None,
137						}
138						.into());
139					}
140				}
141			}
142			(
143				ValueType::Decimal {
144					precision,
145					scale,
146				},
147				_,
148			) => {
149				if let Value::Decimal(decimal) = value {
150					if decimal.scale() > scale.value() && decimal.rescale(scale.value()).is_none() {
151						return Err(TypeError::ConstraintViolation {
152							kind: ConstraintKind::DecimalScale {
153								actual: decimal.scale(),
154								max: scale.value(),
155							},
156							message: format!(
157								"DECIMAL value {} has more fraction digits than the column scale {}",
158								decimal,
159								scale.value()
160							),
161							fragment: Fragment::None,
162						}
163						.into());
164					}
165					let digits = decimal.digits().saturating_sub(decimal.scale()) + scale.value();
166					let rescaled = match decimal.fits(precision.value(), scale.value()) {
167						Some(rescaled) => rescaled,
168						None => {
169							return Err(precision_violation(
170								ConstraintKind::DecimalPrecision {
171									actual: digits,
172									max: precision.value(),
173								},
174								"DECIMAL",
175								digits,
176								precision.value(),
177							));
178						}
179					};
180					*decimal = rescaled;
181				}
182			}
183
184			_ => {}
185		}
186
187		Ok(())
188	}
189
190	pub fn is_unconstrained(&self) -> bool {
191		self.constraint.is_none()
192	}
193
194	#[allow(clippy::inherent_to_string)]
195	pub fn to_string(&self) -> String {
196		match &self.constraint {
197			None => format!("{}", self.base_type),
198			Some(Constraint::MaxBytes(max)) => {
199				format!("{}({})", self.base_type, max)
200			}
201			Some(Constraint::Dictionary(dict_id, id_type)) => {
202				format!("DictionaryId(dict={}, {})", dict_id, id_type)
203			}
204			Some(Constraint::SumType(id)) => {
205				format!("SumType({})", id)
206			}
207		}
208	}
209}
210
211fn same_family(value_type: &ValueType, column_type: &ValueType) -> bool {
212	match (value_type, column_type) {
213		(
214			ValueType::Decimal {
215				..
216			},
217			ValueType::Decimal {
218				..
219			},
220		) => true,
221		_ => value_type == column_type,
222	}
223}
224
225fn precision_violation(kind: ConstraintKind, name: &str, actual: u8, max: u8) -> Error {
226	TypeError::ConstraintViolation {
227		kind,
228		message: format!("{} value exceeds maximum precision: {} digits (max: {} digits)", name, actual, max),
229		fragment: Fragment::None,
230	}
231	.into()
232}
233
234#[cfg(test)]
235pub mod tests {
236	use std::str::FromStr;
237
238	use super::*;
239	use crate::value::{
240		constraint::{precision::Precision, scale::Scale},
241		decimal::Decimal,
242	};
243
244	#[test]
245	fn test_unconstrained_type() {
246		let tc = TypeConstraint::unconstrained(ValueType::Utf8);
247		assert_eq!(tc.base_type, ValueType::Utf8);
248		assert_eq!(tc.constraint, None);
249		assert!(tc.is_unconstrained());
250	}
251
252	#[test]
253	fn test_constrained_utf8() {
254		let tc = TypeConstraint::with_constraint(ValueType::Utf8, Constraint::MaxBytes(MaxBytes::new(50)));
255		assert_eq!(tc.base_type, ValueType::Utf8);
256		assert_eq!(tc.constraint, Some(Constraint::MaxBytes(MaxBytes::new(50))));
257		assert!(!tc.is_unconstrained());
258	}
259
260	#[test]
261	fn test_constrained_decimal() {
262		let tc = TypeConstraint::unconstrained(decimal_10_2());
263		assert_eq!(tc.base_type, ValueType::decimal(Precision::new(10), Scale::new(2)));
264		assert_eq!(tc.base_type.precision(), Some(Precision::new(10)));
265		assert_eq!(tc.base_type.scale(), Some(Scale::new(2)));
266		assert_eq!(tc.constraint, None);
267	}
268
269	#[test]
270	fn test_validate_utf8_within_limit() {
271		let tc = TypeConstraint::with_constraint(ValueType::Utf8, Constraint::MaxBytes(MaxBytes::new(10)));
272		let mut value = Value::Utf8("hello".to_string());
273		assert!(tc.coerce(&mut value).is_ok());
274	}
275
276	#[test]
277	fn test_validate_utf8_exceeds_limit() {
278		let tc = TypeConstraint::with_constraint(ValueType::Utf8, Constraint::MaxBytes(MaxBytes::new(5)));
279		let mut value = Value::Utf8("hello world".to_string());
280		assert!(tc.coerce(&mut value).is_err());
281	}
282
283	#[test]
284	fn test_validate_unconstrained() {
285		let tc = TypeConstraint::unconstrained(ValueType::Utf8);
286		let mut value = Value::Utf8("any length string is fine here".to_string());
287		assert!(tc.coerce(&mut value).is_ok());
288	}
289
290	#[test]
291	fn test_validate_none_rejected_for_non_option() {
292		let tc = TypeConstraint::with_constraint(ValueType::Utf8, Constraint::MaxBytes(MaxBytes::new(5)));
293		let mut value = Value::none();
294		assert!(tc.coerce(&mut value).is_err());
295	}
296
297	#[test]
298	fn test_validate_none_accepted_for_option() {
299		let tc = TypeConstraint::unconstrained(ValueType::Option(Box::new(ValueType::Utf8)));
300		let mut value = Value::none();
301		assert!(tc.coerce(&mut value).is_ok());
302	}
303
304	#[test]
305	fn test_to_string() {
306		let tc1 = TypeConstraint::unconstrained(ValueType::Utf8);
307		assert_eq!(tc1.to_string(), "Utf8");
308
309		let tc2 = TypeConstraint::with_constraint(ValueType::Utf8, Constraint::MaxBytes(MaxBytes::new(50)));
310		assert_eq!(tc2.to_string(), "Utf8(50)");
311
312		let tc3 = TypeConstraint::unconstrained(decimal_10_2());
313		assert_eq!(tc3.to_string(), "Decimal(10, 2)");
314	}
315
316	fn decimal_10_2() -> ValueType {
317		ValueType::decimal(Precision::new(10), Scale::new(2))
318	}
319
320	fn coerce_decimal(column: ValueType, text: &str) -> Result<Value, Error> {
321		let mut value = Value::Decimal(Decimal::from_str(text).unwrap());
322		TypeConstraint::unconstrained(column).coerce(&mut value)?;
323		Ok(value)
324	}
325
326	#[test]
327	fn a_decimal_write_is_rescaled_to_the_column_scale_never_rounded() {
328		// Rounding on write would store 1.24 for 1.235 and silently change what the user wrote.
329		assert_eq!(coerce_decimal(decimal_10_2(), "1.2").unwrap().to_string(), "1.20");
330		assert_eq!(coerce_decimal(decimal_10_2(), "1.23").unwrap().to_string(), "1.23");
331		assert_eq!(coerce_decimal(decimal_10_2(), "1.2300").unwrap().to_string(), "1.23");
332		assert_eq!(coerce_decimal(decimal_10_2(), "1.235").unwrap_err().code, "CONSTRAINT_006");
333		assert_eq!(coerce_decimal(decimal_10_2(), "-0.001").unwrap_err().code, "CONSTRAINT_006");
334		let optional = ValueType::Option(Box::new(decimal_10_2()));
335		assert_eq!(coerce_decimal(optional.clone(), "7").unwrap().to_string(), "7.00");
336		assert_eq!(coerce_decimal(optional, "7.001").unwrap_err().code, "CONSTRAINT_006");
337	}
338
339	#[test]
340	fn a_decimal_write_counts_precision_at_the_column_scale() {
341		// 12345678.9 has 9 digits but 10 at scale 2, so a precision check on the raw value lets it through
342		// wrongly.
343		assert_eq!(coerce_decimal(decimal_10_2(), "12345678.99").unwrap().to_string(), "12345678.99");
344		assert_eq!(coerce_decimal(decimal_10_2(), "123456789.9").unwrap_err().code, "CONSTRAINT_005");
345		assert_eq!(coerce_decimal(decimal_10_2(), "123456789").unwrap_err().code, "CONSTRAINT_005");
346		let widest = ValueType::decimal(Precision::MAX, Scale::new(10));
347		assert_eq!(coerce_decimal(widest, &"9".repeat(67)).unwrap_err().code, "CONSTRAINT_005");
348	}
349
350	#[test]
351	fn decimal_writes_are_bounded_by_the_declared_precision() {
352		// A value with one digit too many must be refused, while the widest column must still take 76 nines.
353		let decimal_3_0 = ValueType::decimal(Precision::new(3), Scale::new(0));
354		assert_eq!(coerce_decimal(decimal_3_0.clone(), "-999").unwrap().to_string(), "-999");
355		assert_eq!(coerce_decimal(decimal_3_0, "-1000").unwrap_err().code, "CONSTRAINT_005");
356		let widest = ValueType::decimal(Precision::MAX, Scale::new(0));
357		assert!(coerce_decimal(widest, &"9".repeat(76)).is_ok());
358	}
359}