Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ people = pa.table({

config = (
GraphConfig.builder()
.with_node_label("Person", "person_id")
.node_table("Person", "person_id")
.build()
)

Expand Down
4 changes: 2 additions & 2 deletions ci/check_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ def main():

config = (
GraphConfig.builder()
.with_node_label("Person", "id")
.with_relationship("KNOWS", "src", "dst")
.node_table("Person", "id")
.rel_table("KNOWS", "Person", "Person", "src", "dst")
.build()
)
people = pa.table({"id": [1, 2, 3, 4], "name": ["Alice", "Bob", "Carol", "Dave"]})
Expand Down
4 changes: 2 additions & 2 deletions crates/lance-graph-benches/benches/graph_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ fn bench_cypher_execution(c: &mut Criterion) {
// Helper function to create graph config
let make_config = || {
GraphConfig::builder()
.with_node_label("Person", "person_id")
.with_relationship("FRIEND_OF", "person1_id", "person2_id")
.node_table("Person", "person_id")
.rel_table("FRIEND_OF", "Person", "Person", "person1_id", "person2_id")
.build()
.unwrap()
};
Expand Down
80 changes: 59 additions & 21 deletions crates/lance-graph-python/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,12 @@ pub struct GraphConfigBuilder {
inner: lance_graph::config::GraphConfigBuilder,
}

/// Raise a ``DeprecationWarning`` pointing at the caller's line.
fn deprecated(py: Python, message: &std::ffi::CStr) -> PyResult<()> {
let category = py.get_type::<pyo3::exceptions::PyDeprecationWarning>();
PyErr::warn(py, &category, message, 1)
}

#[pymethods]
impl GraphConfigBuilder {
#[new]
Expand All @@ -386,57 +392,89 @@ impl GraphConfigBuilder {
}
}

/// Add a node label mapping
/// Add a node table: the nodes with ``label``, one per row of the dataset of the
/// same name, identified by its ``primary_key`` column.
///
/// Parameters
/// ----------
/// label : str
/// The node label (e.g., "Person")
/// id_field : str
/// The field in the dataset that serves as the node ID
/// The node label, and the name of its dataset (e.g., "Person")
/// primary_key : str
/// The column that identifies each node
///
/// Returns
/// -------
/// GraphConfigBuilder
/// A new builder with the node mapping applied
fn with_node_label(&self, label: &str, id_field: &str) -> Self {
/// A new builder with the node table added
fn node_table(&self, label: &str, primary_key: &str) -> Self {
Self {
inner: self.inner.clone().with_node_label(label, id_field),
inner: self.inner.clone().node_table(label, primary_key),
}
}

/// Add a relationship mapping
/// Add a relationship table: edges of type ``rel_type`` from ``from_table`` nodes to
/// ``to_table`` nodes, one per row of the dataset of the same name.
///
/// Parameters
/// ----------
/// rel_type : str
/// The relationship type (e.g., "KNOWS")
/// source_field : str
/// The field containing source node IDs
/// target_field : str
/// The field containing target node IDs
/// source_label, target_label : str, optional
/// Node labels at each end (like ``FROM Post TO Person``). Needed only for
/// relationship-type alternation (``[:A|B]``) next to nodes whose label
/// depends on the type, and for multi-label nodes. Give both or neither.
/// The relationship type, and the name of its dataset (e.g., "KNOWS")
/// from_table : str
/// The node table at the source end
/// to_table : str
/// The node table at the target end
/// src : str
/// The column holding the source node's key
/// dst : str
/// The column holding the target node's key
///
/// Returns
/// -------
/// GraphConfigBuilder
/// A new builder with the relationship mapping applied
/// A new builder with the relationship table added
fn rel_table(
&self,
rel_type: &str,
from_table: &str,
to_table: &str,
src: &str,
dst: &str,
) -> Self {
Self {
inner: self
.inner
.clone()
.rel_table(rel_type, from_table, to_table, src, dst),
}
}

/// Deprecated: use ``node_table``.
fn with_node_label(&self, py: Python, label: &str, id_field: &str) -> PyResult<Self> {
deprecated(py, c"with_node_label() is deprecated; use node_table()")?;
Ok(self.node_table(label, id_field))
}

/// Deprecated: use ``rel_table``, which also takes the node tables at both ends.
#[pyo3(signature = (rel_type, source_field, target_field, source_label=None, target_label=None))]
fn with_relationship(
&self,
py: Python,
rel_type: &str,
source_field: &str,
target_field: &str,
source_label: Option<&str>,
target_label: Option<&str>,
) -> PyResult<Self> {
deprecated(
py,
c"with_relationship() is deprecated; use rel_table(), which also takes the node tables at both ends",
)?;
#[allow(deprecated)]
let inner = self
.inner
.clone()
.with_relationship(rel_type, source_field, target_field);
#[allow(deprecated)]
let inner = match (source_label, target_label) {
(Some(src), Some(dst)) => inner.with_relationship_endpoints(rel_type, src, dst),
(None, None) => inner,
Expand Down Expand Up @@ -761,7 +799,7 @@ impl CypherQuery {
/// --------
/// >>> from lance_csr import CypherQuery, VectorSearch, DistanceMetric, GraphConfig
/// >>>
/// >>> config = GraphConfig.builder().with_node_label("Document", "id").build()
/// >>> config = GraphConfig.builder().node_table("Document", "id").build()
/// >>> query = CypherQuery("MATCH (d:Document) WHERE d.category = 'tech' RETURN d.id, d.name, d.embedding")
/// >>> query = query.with_config(config)
/// >>>
Expand Down Expand Up @@ -1851,8 +1889,8 @@ impl RecordBatchReader for BlockingBatchReader {
/// >>>
/// >>> # Setup
/// >>> config = GraphConfig.builder() \\
/// ... .with_node_label("Person", "id") \\
/// ... .with_relationship("KNOWS", "src_id", "dst_id") \\
/// ... .node_table("Person", "id") \\
/// ... .rel_table("KNOWS", "Person", "Person", "src_id", "dst_id") \\
/// ... .build()
/// >>>
/// >>> datasets = {
Expand Down
8 changes: 4 additions & 4 deletions crates/lance-graph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ use arrow_schema::{DataType, Field, Schema};
use lance_graph::{CypherQuery, ExecutionStrategy, GraphConfig};

let config = GraphConfig::builder()
.with_node_label("Person", "person_id")
.with_relationship("KNOWS", "src_person_id", "dst_person_id")
.node_table("Person", "person_id")
.rel_table("KNOWS", "Person", "Person", "src_person_id", "dst_person_id")
.build()?;

let schema = Arc::new(Schema::new(vec![
Expand Down Expand Up @@ -63,8 +63,8 @@ Graph mappings are declared with `GraphConfig::builder()`:
use lance_graph::{GraphConfig, NodeMapping, RelationshipMapping};

let config = GraphConfig::builder()
.with_node_label("Person", "person_id")
.with_relationship("KNOWS", "src_person_id", "dst_person_id")
.node_table("Person", "person_id")
.rel_table("KNOWS", "Person", "Person", "src_person_id", "dst_person_id")
.build()?;
```

Expand Down
102 changes: 87 additions & 15 deletions crates/lance-graph/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,33 +214,73 @@ impl GraphConfigBuilder {
Self::default()
}

/// Add a node label mapping
/// A node table: the nodes labelled `label`, one per row of the dataset of the same
/// name, identified by its `primary_key` column.
///
/// Note: Labels are case-insensitive. If you add "Person" and "person", the second
/// will overwrite the first. Keys are stored as lowercase to prevent duplicates.
pub fn with_node_label<S: Into<String>>(mut self, label: S, id_field: S) -> Self {
/// Labels are case-insensitive: adding "Person" and then "person" keeps the second.
pub fn node_table<S: Into<String>>(mut self, label: S, primary_key: S) -> Self {
let label_str = label.into();
let normalized_key = label_str.to_lowercase();
self.node_mappings.insert(
normalized_key,
NodeMapping {
label: label_str, // Keep original case for display
id_field: id_field.into(),
id_field: primary_key.into(),
property_fields: Vec::new(),
filter_conditions: None,
},
);
self
}

/// A relationship table: edges of type `rel_type` from `from_table` nodes to
/// `to_table` nodes, one per row of the dataset of the same name, with the source
/// node's key in column `src` and the target node's key in column `dst`. Both node
/// tables must be defined by the time [`Self::build`] runs.
pub fn rel_table<S: Into<String>>(
mut self,
rel_type: S,
from_table: S,
to_table: S,
src: S,
dst: S,
) -> Self {
let type_str = rel_type.into();
let normalized_key = type_str.to_lowercase();
self.relationship_mappings.insert(
normalized_key,
RelationshipMapping {
relationship_type: type_str, // Keep original case for display
source_id_field: src.into(),
target_id_field: dst.into(),
type_field: None,
property_fields: Vec::new(),
filter_conditions: None,
source_label: Some(from_table.into()),
target_label: Some(to_table.into()),
},
);
self
}

/// Add a node label mapping.
#[deprecated(since = "0.2.0", note = "use `node_table`")]
pub fn with_node_label<S: Into<String>>(self, label: S, id_field: S) -> Self {
self.node_table(label, id_field)
}

/// Add a node mapping with additional configuration
pub fn with_node_mapping(mut self, mapping: NodeMapping) -> Self {
let normalized_key = mapping.label.to_lowercase();
self.node_mappings.insert(normalized_key, mapping);
self
}

/// Add a relationship type mapping
/// Add a relationship type mapping, without endpoint node tables.
#[deprecated(
since = "0.2.0",
note = "use `rel_table`, which also takes the node tables at both ends"
)]
pub fn with_relationship<S: Into<String>>(
mut self,
rel_type: S,
Expand All @@ -267,6 +307,10 @@ impl GraphConfigBuilder {

/// Record the node labels a configured relationship type connects (see
/// [`RelationshipMapping::source_label`]). No-op for an unknown type.
#[deprecated(
since = "0.2.0",
note = "use `rel_table`, which takes the endpoint node tables"
)]
pub fn with_relationship_endpoints<S: Into<String>>(
mut self,
rel_type: &str,
Expand Down Expand Up @@ -389,9 +433,9 @@ mod tests {
#[test]
fn test_graph_config_builder() {
let config = GraphConfig::builder()
.with_node_label("Person", "person_id")
.with_node_label("Company", "company_id")
.with_relationship("WORKS_FOR", "person_id", "company_id")
.node_table("Person", "person_id")
.node_table("Company", "company_id")
.rel_table("WORKS_FOR", "Person", "Company", "person_id", "company_id")
.build()
.unwrap();

Expand All @@ -404,6 +448,32 @@ mod tests {
let works_for_mapping = config.get_relationship_mapping("WORKS_FOR").unwrap();
assert_eq!(works_for_mapping.source_id_field, "person_id");
assert_eq!(works_for_mapping.target_id_field, "company_id");
assert_eq!(works_for_mapping.source_label.as_deref(), Some("Person"));
assert_eq!(works_for_mapping.target_label.as_deref(), Some("Company"));
}

#[test]
fn rel_table_endpoints_must_be_node_tables() {
let result = GraphConfig::builder()
.node_table("Person", "id")
.rel_table("WORKS_FOR", "Person", "Company", "src", "dst")
.build();
let message = result.unwrap_err().to_string();
assert!(message.contains("Company"), "{message}");
}

#[test]
#[allow(deprecated)]
fn deprecated_builder_methods_still_configure_the_graph() {
let config = GraphConfig::builder()
.with_node_label("Person", "id")
.with_relationship("KNOWS", "src", "dst")
.with_relationship_endpoints("KNOWS", "Person", "Person")
.build()
.unwrap();
let knows = config.get_relationship_mapping("knows").unwrap();
assert_eq!(config.get_node_mapping("person").unwrap().id_field, "id");
assert_eq!(knows.source_label.as_deref(), Some("Person"));
}

#[test]
Expand Down Expand Up @@ -436,8 +506,8 @@ mod tests {
fn test_case_insensitive_node_label_lookup() {
// Test that node label lookups are case-insensitive
let config = GraphConfig::builder()
.with_node_label("Person", "person_id")
.with_node_label("Company", "company_id")
.node_table("Person", "person_id")
.node_table("Company", "company_id")
.build()
.unwrap();

Expand Down Expand Up @@ -469,8 +539,10 @@ mod tests {
fn test_case_insensitive_relationship_type_lookup() {
// Test that relationship type lookups are case-insensitive
let config = GraphConfig::builder()
.with_relationship("FOLLOWS", "src_id", "dst_id")
.with_relationship("WORKS_FOR", "person_id", "company_id")
.node_table("Person", "person_id")
.node_table("Company", "company_id")
.rel_table("FOLLOWS", "Person", "Person", "src_id", "dst_id")
.rel_table("WORKS_FOR", "Person", "Company", "person_id", "company_id")
.build()
.unwrap();

Expand Down Expand Up @@ -500,8 +572,8 @@ mod tests {
#[test]
fn test_duplicate_label_different_case_should_overwrite() {
let builder = GraphConfig::builder()
.with_node_label("Person", "id")
.with_node_label("person", "id2"); // Should overwrite first entry
.node_table("Person", "id")
.node_table("person", "id2"); // Should overwrite first entry

// Should have only 1 entry (second overwrites first due to lowercase key normalization)
assert_eq!(builder.node_mappings.len(), 1);
Expand Down
Loading
Loading