Skip to main content

hayson_engine/schema/
partition.rs

1use serde_json::Value;
2
3use crate::errors::HaysonPartitionError;
4
5use super::types::TableSchema;
6use super::validate::HaysonRecord;
7
8/// The single partition of a table that declares no `partitionBy` — the empty name every
9/// unpartitioned slot, tally and log carries, so both layouts share one code path.
10pub const UNPARTITIONED: &str = "";
11
12const MAX_PARTITION_LEN: usize = 64;
13
14pub fn is_partitioned(schema: &TableSchema) -> bool {
15    schema.partition_by.as_ref().is_some_and(|field| !field.is_empty())
16}
17
18/// Mirrors the TS `PARTITION_VALUE` (`^[a-z0-9_-]{1,64}$`) — a partition value becomes a file name
19/// verbatim, so anything that could escape the directory or collide case-insensitively is out.
20fn names_a_file(value: &str) -> bool {
21    !value.is_empty()
22        && value.len() <= MAX_PARTITION_LEN
23        && value
24            .chars()
25            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
26}
27
28/// The partition `record` belongs to: [`UNPARTITIONED`] for a table with no `partitionBy`, and the
29/// value of the named field otherwise.
30pub fn partition_of(schema: &TableSchema, record: &HaysonRecord) -> Result<String, HaysonPartitionError> {
31    let Some(field) = schema.partition_by.as_ref().filter(|field| !field.is_empty()) else {
32        return Ok(UNPARTITIONED.to_string());
33    };
34    assert_partition_value(schema, record.get(field))
35}
36
37pub fn assert_partition_value(schema: &TableSchema, value: Option<&Value>) -> Result<String, HaysonPartitionError> {
38    let field = schema.partition_by.clone().unwrap_or_default();
39    match value.and_then(Value::as_str) {
40        Some(text) if names_a_file(text) => Ok(text.to_string()),
41        _ => Err(HaysonPartitionError::new(
42            schema.table.clone(),
43            field,
44            format!(
45                "{} cannot name a partition file — expected 1 to 64 characters of a-z, 0-9, \"_\" or \"-\"",
46                match value {
47                    Some(value) => value.to_string(),
48                    None => "undefined".to_string(),
49                }
50            ),
51        )),
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use serde_json::json;
58
59    use super::super::parse::parse_schema;
60    use super::*;
61
62    fn notes_schema(partitioned: bool) -> TableSchema {
63        let mut spec = json!({
64            "schemaVersion": 1,
65            "table": "notes",
66            "primaryKey": "id",
67            "fields": {
68                "id": { "type": "string" },
69                "kind": { "type": "string", "required": true },
70            },
71        });
72        if partitioned {
73            spec.as_object_mut()
74                .unwrap()
75                .insert("partitionBy".into(), json!("kind"));
76        }
77        parse_schema(&spec).unwrap()
78    }
79
80    fn record(kind: Value) -> HaysonRecord {
81        let mut record = HaysonRecord::new();
82        record.insert("id".into(), json!("n1"));
83        record.insert("kind".into(), kind);
84        record
85    }
86
87    #[test]
88    fn an_unpartitioned_table_puts_every_record_in_the_empty_partition() {
89        let schema = notes_schema(false);
90        assert!(!is_partitioned(&schema));
91        assert_eq!(partition_of(&schema, &record(json!("note"))).unwrap(), UNPARTITIONED);
92    }
93
94    #[test]
95    fn a_partitioned_table_reads_the_partition_off_the_named_field() {
96        let schema = notes_schema(true);
97        assert!(is_partitioned(&schema));
98        assert_eq!(partition_of(&schema, &record(json!("adr"))).unwrap(), "adr");
99    }
100
101    #[test]
102    fn refuses_a_value_that_cannot_name_a_file() {
103        let schema = notes_schema(true);
104        for kind in [
105            json!("../escape"),
106            json!("Upper"),
107            json!("has space"),
108            json!(""),
109            json!(7),
110        ] {
111            let error = partition_of(&schema, &record(kind)).unwrap_err();
112            assert_eq!(error.field, "kind");
113            assert!(error.to_string().contains("cannot name a partition file"));
114        }
115        assert!(partition_of(&schema, &HaysonRecord::new()).is_err());
116    }
117
118    #[test]
119    fn refuses_a_value_longer_than_sixty_four_characters_and_takes_one_of_exactly_that_length() {
120        let schema = notes_schema(true);
121        assert_eq!(
122            partition_of(&schema, &record(json!("a".repeat(64)))).unwrap(),
123            "a".repeat(64)
124        );
125        assert!(partition_of(&schema, &record(json!("a".repeat(65)))).is_err());
126    }
127}