hayson_engine/schema/
partition.rs1use serde_json::Value;
2
3use crate::errors::HaysonPartitionError;
4
5use super::types::TableSchema;
6use super::validate::HaysonRecord;
7
8pub 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
18fn 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
28pub 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}