diff --git a/crates/lance-csr/README.md b/crates/lance-csr/README.md index 740d789..e79078f 100644 --- a/crates/lance-csr/README.md +++ b/crates/lance-csr/README.md @@ -120,12 +120,11 @@ let config = GraphConfig::builder() .build()?; ``` -For Rust callers migrating away from direct mapping-field access, use `get_node_mapping(label)` and -`get_relationship_mapping(type_name)` for case-insensitive lookups. `iter_node_mappings()` and -`iter_relationship_mappings()` yield read-only `(normalized_key, mapping)` pairs; iteration order is -unspecified. Build mappings through `GraphConfigBuilder` so keys are normalized. Public fields remain -available for compatibility until a breaking release; replacing direct writes with builder calls avoids -that future migration. The serialized configuration and Python API are unchanged. +`GraphConfig`'s mapping fields are private, so mappings are built only through `GraphConfigBuilder`, +which normalizes keys. Use `get_node_mapping(label)` and `get_relationship_mapping(type_name)` for +case-insensitive lookups. `iter_node_mappings()` and `iter_relationship_mappings()` yield read-only +`(normalized_key, mapping)` pairs; iteration order is unspecified. The serialized format is unchanged; +deserializing a configuration validates it as `build()` does. The Python API is unchanged. ## Executing Cypher Queries diff --git a/crates/lance-csr/src/config.rs b/crates/lance-csr/src/config.rs index 668791a..b28a63f 100644 --- a/crates/lance-csr/src/config.rs +++ b/crates/lance-csr/src/config.rs @@ -9,37 +9,18 @@ use std::collections::HashMap; /// Configuration for mapping Lance datasets to property graphs /// -/// # Important: Case-Insensitive Keys -/// -/// **WARNING**: `node_mappings` and `relationship_mappings` store keys as **lowercase** for -/// case-insensitive behavior. If you directly insert into these maps, you **must** normalize -/// keys to lowercase to maintain invariants. -/// -/// **Recommended**: Use `GraphConfigBuilder` instead of direct field access. The builder -/// automatically normalizes keys. -/// -/// # Future API Changes -/// -/// These fields may become private in a future major version to enforce invariants. -/// Use the lookup and iterator accessors for reads, and [`GraphConfigBuilder`] for writes. -/// The mapping fields remain public for source compatibility until a breaking release. +/// Node labels and relationship types are case-insensitive: the mappings are keyed by their +/// lowercase form. Build a config with [`GraphConfigBuilder`], which normalizes keys, and read +/// it through the lookup and iterator accessors. Deserialization goes through the builder too, +/// so a deserialized config is validated like a built one. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "GraphConfigBuilder")] pub struct GraphConfig { - /// Mapping of node labels to their field configurations - /// - /// **Keys are stored as lowercase** for case-insensitive lookups. - /// Use `get_node_mapping()` for lookups instead of direct access. - /// - /// TODO: Make this private to enforce key normalization invariants - pub node_mappings: HashMap, + /// Node labels (lowercase) to their field configurations + node_mappings: HashMap, - /// Mapping of relationship types to their field configurations - /// - /// **Keys are stored as lowercase** for case-insensitive lookups. - /// Use `get_relationship_mapping()` for lookups instead of direct access. - /// - /// TODO: Make this private to enforce key normalization invariants - pub relationship_mappings: HashMap, + /// Relationship types (lowercase) to their field configurations + relationship_mappings: HashMap, /// Default node ID field if not specified in mappings pub default_node_id_field: String, @@ -204,7 +185,8 @@ impl GraphConfig { } /// Builder for GraphConfig -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] pub struct GraphConfigBuilder { node_mappings: HashMap, relationship_mappings: HashMap, @@ -365,6 +347,14 @@ impl GraphConfigBuilder { } } +impl TryFrom for GraphConfig { + type Error = GraphError; + + fn try_from(builder: GraphConfigBuilder) -> Result { + builder.build() + } +} + impl NodeMapping { /// Create a new node mapping pub fn new>(label: S, id_field: S) -> Self { @@ -519,6 +509,25 @@ mod tests { assert!(config.validate().is_err()); } + #[test] + fn deserialization_validates_through_the_builder() { + let config = GraphConfig::builder() + .node_table("Person", "id") + .rel_table("KNOWS", "Person", "Person", "src", "dst") + .build() + .unwrap(); + let json = serde_json::to_string(&config).unwrap(); + let restored: GraphConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.get_node_mapping("PERSON").unwrap().label, "Person"); + assert!(restored.get_relationship_mapping("knows").is_some()); + + let unnormalized = json.replace("\"person\":", "\"Person\":"); + let message = serde_json::from_str::(&unnormalized) + .unwrap_err() + .to_string(); + assert!(message.contains("not normalized"), "{message}"); + } + #[test] fn test_node_mapping_with_properties() { let mapping = NodeMapping::new("Person", "id")