Skip to main content

xlog_logic/
ground_term_encoding.rs

1//! Typed encoding of ground logic terms into relation-column bytes.
2
3use std::fmt;
4
5use xlog_core::{symbol, ScalarType};
6
7use crate::Term;
8
9/// A structured failure produced while encoding a ground term for a scalar column.
10#[derive(Clone, Debug, PartialEq)]
11#[non_exhaustive]
12pub enum GroundTermEncodingError {
13    /// An integer cannot be represented by the requested scalar type.
14    IntegerOutOfRange {
15        /// Requested scalar type.
16        expected: ScalarType,
17        /// Integer value that could not be represented.
18        value: i64,
19    },
20    /// A boolean integer was neither zero nor one.
21    InvalidBooleanInteger {
22        /// Integer supplied for the boolean column.
23        value: i64,
24    },
25    /// A boolean symbol was neither `true` nor `false`.
26    InvalidBooleanSymbol {
27        /// Resolved symbol text supplied for the boolean column.
28        symbol: String,
29    },
30    /// A fact contained a named variable instead of a ground term.
31    Variable {
32        /// Variable name found in the fact.
33        name: String,
34    },
35    /// A fact contained an anonymous wildcard instead of a ground term.
36    Anonymous,
37    /// A fact contained an aggregate expression instead of a ground term.
38    Aggregate,
39    /// The term form is not supported by the requested scalar type.
40    TypeMismatch {
41        /// Requested scalar type.
42        expected: ScalarType,
43        /// Term that did not match the requested scalar type.
44        actual: Term,
45    },
46}
47
48impl fmt::Display for GroundTermEncodingError {
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::IntegerOutOfRange { expected, value } => {
52                let scalar_name = match expected {
53                    ScalarType::U32 => "u32",
54                    ScalarType::U64 => "u64",
55                    ScalarType::I32 => "i32",
56                    ScalarType::I64 => "i64",
57                    ScalarType::F32 => "f32",
58                    ScalarType::F64 => "f64",
59                    ScalarType::Bool => "bool",
60                    ScalarType::Symbol => "symbol",
61                };
62                write!(formatter, "{scalar_name} out of range: {value}")
63            }
64            Self::InvalidBooleanInteger { value } => {
65                write!(formatter, "bool expects 0/1, got {value}")
66            }
67            Self::InvalidBooleanSymbol { symbol } => write!(
68                formatter,
69                "Expected boolean symbol 'true' or 'false', got '{symbol}'"
70            ),
71            Self::Variable { name } => write!(formatter, "Fact cannot contain variable {name}"),
72            Self::Anonymous => formatter.write_str("Fact cannot contain anonymous wildcard '_'"),
73            Self::Aggregate => formatter.write_str("Fact cannot contain aggregate"),
74            Self::TypeMismatch { expected, actual } => write!(
75                formatter,
76                "Type mismatch in fact: expected {expected:?}, got {actual:?}"
77            ),
78        }
79    }
80}
81
82impl std::error::Error for GroundTermEncodingError {}
83
84/// Append the little-endian physical representation of a typed ground term.
85///
86/// Existing bytes in `output` are preserved. If encoding fails, `output` is
87/// left unchanged and the returned structured error identifies the rejected
88/// value or term form.
89pub fn append_ground_term_bytes(
90    output: &mut Vec<u8>,
91    term: &Term,
92    scalar_type: ScalarType,
93) -> Result<(), GroundTermEncodingError> {
94    match (scalar_type, term) {
95        (ScalarType::U32, Term::Integer(value)) => {
96            let encoded =
97                u32::try_from(*value).map_err(|_| GroundTermEncodingError::IntegerOutOfRange {
98                    expected: ScalarType::U32,
99                    value: *value,
100                })?;
101            output.extend_from_slice(&encoded.to_le_bytes());
102        }
103        (ScalarType::U64, Term::Integer(value)) => {
104            let encoded =
105                u64::try_from(*value).map_err(|_| GroundTermEncodingError::IntegerOutOfRange {
106                    expected: ScalarType::U64,
107                    value: *value,
108                })?;
109            output.extend_from_slice(&encoded.to_le_bytes());
110        }
111        (ScalarType::I32, Term::Integer(value)) => {
112            let encoded =
113                i32::try_from(*value).map_err(|_| GroundTermEncodingError::IntegerOutOfRange {
114                    expected: ScalarType::I32,
115                    value: *value,
116                })?;
117            output.extend_from_slice(&encoded.to_le_bytes());
118        }
119        (ScalarType::I64, Term::Integer(value)) => {
120            output.extend_from_slice(&value.to_le_bytes());
121        }
122        (ScalarType::F32, Term::Float(value)) => {
123            output.extend_from_slice(&(*value as f32).to_le_bytes());
124        }
125        (ScalarType::F64, Term::Float(value)) => {
126            output.extend_from_slice(&value.to_le_bytes());
127        }
128        (ScalarType::F32, Term::Integer(value)) => {
129            output.extend_from_slice(&(*value as f32).to_le_bytes());
130        }
131        (ScalarType::F64, Term::Integer(value)) => {
132            output.extend_from_slice(&(*value as f64).to_le_bytes());
133        }
134        (ScalarType::Bool, Term::Integer(value)) => match *value {
135            0 => output.push(0),
136            1 => output.push(1),
137            value => {
138                return Err(GroundTermEncodingError::InvalidBooleanInteger { value });
139            }
140        },
141        (ScalarType::Bool, Term::Symbol(id)) => {
142            let value = symbol::resolve(*id);
143            match value.as_str() {
144                "false" => output.push(0),
145                "true" => output.push(1),
146                _ => {
147                    return Err(GroundTermEncodingError::InvalidBooleanSymbol { symbol: value });
148                }
149            }
150        }
151        (ScalarType::Symbol, Term::String(value)) => {
152            output.extend_from_slice(&symbol::intern(value).to_le_bytes());
153        }
154        (ScalarType::Symbol, Term::Symbol(id)) => {
155            output.extend_from_slice(&id.to_le_bytes());
156        }
157        (_, Term::Variable(name)) => {
158            return Err(GroundTermEncodingError::Variable { name: name.clone() });
159        }
160        (_, Term::Anonymous) => return Err(GroundTermEncodingError::Anonymous),
161        (_, Term::Aggregate(_)) => return Err(GroundTermEncodingError::Aggregate),
162        (expected, actual) => {
163            return Err(GroundTermEncodingError::TypeMismatch {
164                expected,
165                actual: actual.clone(),
166            });
167        }
168    }
169
170    Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::{append_ground_term_bytes, GroundTermEncodingError};
176    use crate::ast::{AggExpr, AggOp};
177    use crate::Term;
178    use xlog_core::{symbol, ScalarType};
179
180    #[test]
181    fn encodes_supported_scalar_terms() {
182        let true_symbol = symbol::intern("true");
183        let false_symbol = symbol::intern("false");
184        let existing_symbol = symbol::intern("existing");
185        let string_symbol = symbol::intern("from-string");
186        let cases = vec![
187            (
188                "u32 integer",
189                ScalarType::U32,
190                Term::Integer(42),
191                42_u32.to_le_bytes().to_vec(),
192            ),
193            (
194                "u64 integer",
195                ScalarType::U64,
196                Term::Integer(43),
197                43_u64.to_le_bytes().to_vec(),
198            ),
199            (
200                "i32 integer",
201                ScalarType::I32,
202                Term::Integer(-44),
203                (-44_i32).to_le_bytes().to_vec(),
204            ),
205            (
206                "i64 integer",
207                ScalarType::I64,
208                Term::Integer(-45),
209                (-45_i64).to_le_bytes().to_vec(),
210            ),
211            (
212                "f32 float",
213                ScalarType::F32,
214                Term::Float(1.5),
215                1.5_f32.to_le_bytes().to_vec(),
216            ),
217            (
218                "f64 float",
219                ScalarType::F64,
220                Term::Float(2.25),
221                2.25_f64.to_le_bytes().to_vec(),
222            ),
223            (
224                "f32 integer",
225                ScalarType::F32,
226                Term::Integer(46),
227                46_f32.to_le_bytes().to_vec(),
228            ),
229            (
230                "f64 integer",
231                ScalarType::F64,
232                Term::Integer(47),
233                47_f64.to_le_bytes().to_vec(),
234            ),
235            ("false integer", ScalarType::Bool, Term::Integer(0), vec![0]),
236            ("true integer", ScalarType::Bool, Term::Integer(1), vec![1]),
237            (
238                "true symbol",
239                ScalarType::Bool,
240                Term::Symbol(true_symbol),
241                vec![1],
242            ),
243            (
244                "false symbol",
245                ScalarType::Bool,
246                Term::Symbol(false_symbol),
247                vec![0],
248            ),
249            (
250                "string symbol",
251                ScalarType::Symbol,
252                Term::String("from-string".to_string()),
253                string_symbol.to_le_bytes().to_vec(),
254            ),
255            (
256                "interned symbol",
257                ScalarType::Symbol,
258                Term::Symbol(existing_symbol),
259                existing_symbol.to_le_bytes().to_vec(),
260            ),
261        ];
262
263        for (label, scalar_type, term, expected) in cases {
264            let mut actual = vec![0xA5];
265            append_ground_term_bytes(&mut actual, &term, scalar_type)
266                .unwrap_or_else(|error| panic!("{label} should encode successfully, got {error}"));
267            let mut expected_with_prefix = vec![0xA5];
268            expected_with_prefix.extend_from_slice(&expected);
269            assert_eq!(actual, expected_with_prefix, "{label}");
270        }
271    }
272
273    #[test]
274    fn rejects_out_of_range_and_non_ground_terms() {
275        let invalid_bool = symbol::intern("not-a-boolean");
276        let cases = vec![
277            (
278                "negative u32",
279                ScalarType::U32,
280                Term::Integer(-1),
281                GroundTermEncodingError::IntegerOutOfRange {
282                    expected: ScalarType::U32,
283                    value: -1,
284                },
285                "u32 out of range: -1",
286            ),
287            (
288                "large u32",
289                ScalarType::U32,
290                Term::Integer(i64::from(u32::MAX) + 1),
291                GroundTermEncodingError::IntegerOutOfRange {
292                    expected: ScalarType::U32,
293                    value: i64::from(u32::MAX) + 1,
294                },
295                "u32 out of range: 4294967296",
296            ),
297            (
298                "negative u64",
299                ScalarType::U64,
300                Term::Integer(-1),
301                GroundTermEncodingError::IntegerOutOfRange {
302                    expected: ScalarType::U64,
303                    value: -1,
304                },
305                "u64 out of range: -1",
306            ),
307            (
308                "small i32",
309                ScalarType::I32,
310                Term::Integer(i64::from(i32::MIN) - 1),
311                GroundTermEncodingError::IntegerOutOfRange {
312                    expected: ScalarType::I32,
313                    value: i64::from(i32::MIN) - 1,
314                },
315                "i32 out of range: -2147483649",
316            ),
317            (
318                "large i32",
319                ScalarType::I32,
320                Term::Integer(i64::from(i32::MAX) + 1),
321                GroundTermEncodingError::IntegerOutOfRange {
322                    expected: ScalarType::I32,
323                    value: i64::from(i32::MAX) + 1,
324                },
325                "i32 out of range: 2147483648",
326            ),
327            (
328                "invalid integer boolean",
329                ScalarType::Bool,
330                Term::Integer(2),
331                GroundTermEncodingError::InvalidBooleanInteger { value: 2 },
332                "bool expects 0/1, got 2",
333            ),
334            (
335                "invalid boolean symbol",
336                ScalarType::Bool,
337                Term::Symbol(invalid_bool),
338                GroundTermEncodingError::InvalidBooleanSymbol {
339                    symbol: "not-a-boolean".to_string(),
340                },
341                "Expected boolean symbol 'true' or 'false', got 'not-a-boolean'",
342            ),
343            (
344                "variable",
345                ScalarType::U32,
346                Term::Variable("X".to_string()),
347                GroundTermEncodingError::Variable {
348                    name: "X".to_string(),
349                },
350                "Fact cannot contain variable X",
351            ),
352            (
353                "anonymous",
354                ScalarType::U32,
355                Term::Anonymous,
356                GroundTermEncodingError::Anonymous,
357                "Fact cannot contain anonymous wildcard '_'",
358            ),
359            (
360                "aggregate",
361                ScalarType::U64,
362                Term::Aggregate(AggExpr {
363                    op: AggOp::Count,
364                    variable: "X".to_string(),
365                }),
366                GroundTermEncodingError::Aggregate,
367                "Fact cannot contain aggregate",
368            ),
369            (
370                "float mismatch",
371                ScalarType::U32,
372                Term::Float(1.5),
373                GroundTermEncodingError::TypeMismatch {
374                    expected: ScalarType::U32,
375                    actual: Term::Float(1.5),
376                },
377                "Type mismatch in fact: expected U32, got Float(1.5)",
378            ),
379            (
380                "string mismatch",
381                ScalarType::U64,
382                Term::String("text".to_string()),
383                GroundTermEncodingError::TypeMismatch {
384                    expected: ScalarType::U64,
385                    actual: Term::String("text".to_string()),
386                },
387                "Type mismatch in fact: expected U64, got String(\"text\")",
388            ),
389            (
390                "list mismatch",
391                ScalarType::U64,
392                Term::List(vec![]),
393                GroundTermEncodingError::TypeMismatch {
394                    expected: ScalarType::U64,
395                    actual: Term::List(vec![]),
396                },
397                "Type mismatch in fact: expected U64, got List([])",
398            ),
399            (
400                "cons mismatch",
401                ScalarType::U64,
402                Term::Cons {
403                    head: Box::new(Term::Integer(1)),
404                    tail: Box::new(Term::List(vec![])),
405                },
406                GroundTermEncodingError::TypeMismatch {
407                    expected: ScalarType::U64,
408                    actual: Term::Cons {
409                        head: Box::new(Term::Integer(1)),
410                        tail: Box::new(Term::List(vec![])),
411                    },
412                },
413                "Type mismatch in fact: expected U64, got Cons { head: Integer(1), tail: List([]) }",
414            ),
415            (
416                "compound mismatch",
417                ScalarType::U64,
418                Term::Compound {
419                    functor: "pair".to_string(),
420                    args: vec![Term::Integer(1), Term::Integer(2)],
421                },
422                GroundTermEncodingError::TypeMismatch {
423                    expected: ScalarType::U64,
424                    actual: Term::Compound {
425                        functor: "pair".to_string(),
426                        args: vec![Term::Integer(1), Term::Integer(2)],
427                    },
428                },
429                "Type mismatch in fact: expected U64, got Compound { functor: \"pair\", args: [Integer(1), Integer(2)] }",
430            ),
431            (
432                "predicate reference mismatch",
433                ScalarType::U64,
434                Term::PredRef("target".to_string()),
435                GroundTermEncodingError::TypeMismatch {
436                    expected: ScalarType::U64,
437                    actual: Term::PredRef("target".to_string()),
438                },
439                "Type mismatch in fact: expected U64, got PredRef(\"target\")",
440            ),
441        ];
442
443        for (label, scalar_type, term, expected_error, expected_message) in cases {
444            let mut output = vec![0x5A];
445            let error = match append_ground_term_bytes(&mut output, &term, scalar_type) {
446                Ok(()) => panic!("{label} unexpectedly encoded"),
447                Err(error) => error,
448            };
449            assert_eq!(error, expected_error, "{label}");
450            assert_eq!(error.to_string(), expected_message, "{label}");
451            assert_eq!(output, vec![0x5A], "{label} mutated output on failure");
452        }
453    }
454}