From a6877d5e3b2fea568fdc6c7a800296f33fa809cd Mon Sep 17 00:00:00 2001 From: Will Jones Date: Mon, 31 Aug 2026 13:50:56 -0700 Subject: [PATCH 1/4] feat(namespace)!: accept multiple columns for the merge insert on key Merge insert through the namespace API only accepted a single column as the match key, so composite-key upserts were impossible even though `MergeInsertBuilder::try_new` has always taken a list of join columns. `MergeInsertIntoTableRequest.on` is now a list, and the directory and REST implementations pass it straight through to the builder. The REST client repeats the `on` query parameter once per column; the adapter collects the repeated values from the raw query string, since `serde_urlencoded` (which backs axum's `Query`) cannot deserialize a sequence. Requests crossing JNI and pyo3 are deserialized through `LenientMergeInsertIntoTableRequest`, which promotes a scalar `on` to a one-element list so a Java or Python SDK build predating this change keeps working against current native code. `lance-namespace-reqwest-client` is pinned to the spec branch until 0.12 is released. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 5 +- Cargo.toml | 5 +- java/lance-jni/Cargo.lock | 5 +- java/lance-jni/src/namespace.rs | 17 +- python/Cargo.lock | 5 +- python/src/namespace.rs | 22 ++- rust/lance-namespace-impls/src/dir.rs | 171 +++++++++++++++++- rust/lance-namespace-impls/src/lib.rs | 39 ++++ rust/lance-namespace-impls/src/rest.rs | 12 +- .../lance-namespace-impls/src/rest_adapter.rs | 123 ++++++++++++- rust/lance-namespace/Cargo.toml | 2 + rust/lance-namespace/src/compat.rs | 83 +++++++++ rust/lance-namespace/src/lib.rs | 3 + 13 files changed, 455 insertions(+), 37 deletions(-) create mode 100644 rust/lance-namespace/src/compat.rs diff --git a/Cargo.lock b/Cargo.lock index 208d948f948..85041f5b1fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4981,6 +4981,8 @@ dependencies = [ "bytes", "lance-core", "lance-namespace-reqwest-client", + "serde", + "serde_json", "snafu", ] @@ -5049,8 +5051,7 @@ dependencies = [ [[package]] name = "lance-namespace-reqwest-client" version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d06b1fbb5d41f93bc652b61e2872af92e8a6c5f6b4ce8839a8ecfa05365d359" +source = "git+https://github.com/wjones127/lance-namespace?branch=will%2Fent-2084-merge-insert-multi-column-on#f2d9ca427775e8678a76200d9a67c612f8de50f2" dependencies = [ "reqwest 0.12.28", "serde", diff --git a/Cargo.toml b/Cargo.toml index ae090a16a83..cdd213f1bb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,10 @@ lance-io = { version = "=12.0.0-beta.11", path = "./rust/lance-io", default-feat lance-linalg = { version = "=12.0.0-beta.11", path = "./rust/lance-linalg" } lance-namespace = { version = "=12.0.0-beta.11", path = "./rust/lance-namespace" } lance-namespace-impls = { version = "=12.0.0-beta.11", path = "./rust/lance-namespace-impls" } -lance-namespace-reqwest-client = "0.11.1" +# TODO: switch back to a crates.io version once lance-namespace 0.12 is released. +# Pinned to https://github.com/lance-format/lance-namespace/pull/363, which makes +# `MergeInsertIntoTableRequest.on` a list. +lance-namespace-reqwest-client = { git = "https://github.com/wjones127/lance-namespace", branch = "will/ent-2084-merge-insert-multi-column-on" } lance-select = { version = "=12.0.0-beta.11", path = "./rust/lance-select" } lance-tokenizer = { version = "=12.0.0-beta.11", path = "./rust/lance-tokenizer" } lance-table = { version = "=12.0.0-beta.11", path = "./rust/lance-table" } diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index b5fc52ab403..02496af5ad8 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -4149,6 +4149,8 @@ dependencies = [ "bytes", "lance-core", "lance-namespace-reqwest-client", + "serde", + "serde_json", "snafu", ] @@ -4189,8 +4191,7 @@ dependencies = [ [[package]] name = "lance-namespace-reqwest-client" version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d06b1fbb5d41f93bc652b61e2872af92e8a6c5f6b4ce8839a8ecfa05365d359" +source = "git+https://github.com/wjones127/lance-namespace?branch=will%2Fent-2084-merge-insert-multi-column-on#f2d9ca427775e8678a76200d9a67c612f8de50f2" dependencies = [ "reqwest 0.12.28", "serde", diff --git a/java/lance-jni/src/namespace.rs b/java/lance-jni/src/namespace.rs index 9978fcfa7a2..db870cef754 100644 --- a/java/lance-jni/src/namespace.rs +++ b/java/lance-jni/src/namespace.rs @@ -10,6 +10,7 @@ use jni::JNIEnv; use jni::objects::{GlobalRef, JByteArray, JMap, JObject, JString, JValue}; use jni::sys::{jbyteArray, jlong, jobject, jstring}; use lance_namespace::LanceNamespace as LanceNamespaceTrait; +use lance_namespace::LenientMergeInsertIntoTableRequest; use lance_namespace::models::*; use lance_namespace_impls::{ ConnectBuilder, DirectoryNamespace, DirectoryNamespaceBuilder, DynamicContextProvider, @@ -1914,8 +1915,12 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_mergeInsertIn handle, request_json, request_data, - |namespace_client, req, data| { - block_on(namespace_client.inner.merge_insert_into_table(req, data)) + |namespace_client, req: LenientMergeInsertIntoTableRequest, data| { + block_on( + namespace_client + .inner + .merge_insert_into_table(req.into(), data), + ) } ), std::ptr::null_mut() @@ -2864,8 +2869,12 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_mergeInsertIntoTab handle, request_json, request_data, - |namespace_client, req, data| { - block_on(namespace_client.inner.merge_insert_into_table(req, data)) + |namespace_client, req: LenientMergeInsertIntoTableRequest, data| { + block_on( + namespace_client + .inner + .merge_insert_into_table(req.into(), data), + ) } ), std::ptr::null_mut() diff --git a/python/Cargo.lock b/python/Cargo.lock index 028ba40866c..c385f1baa73 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -4454,6 +4454,8 @@ dependencies = [ "bytes", "lance-core", "lance-namespace-reqwest-client", + "serde", + "serde_json", "snafu", ] @@ -4494,8 +4496,7 @@ dependencies = [ [[package]] name = "lance-namespace-reqwest-client" version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d06b1fbb5d41f93bc652b61e2872af92e8a6c5f6b4ce8839a8ecfa05365d359" +source = "git+https://github.com/wjones127/lance-namespace?branch=will%2Fent-2084-merge-insert-multi-column-on#f2d9ca427775e8678a76200d9a67c612f8de50f2" dependencies = [ "reqwest 0.12.28", "serde", diff --git a/python/src/namespace.rs b/python/src/namespace.rs index e88ff40de2c..0f6e8edfa0f 100644 --- a/python/src/namespace.rs +++ b/python/src/namespace.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use async_trait::async_trait; use bytes::Bytes; use lance_namespace::LanceNamespace as LanceNamespaceTrait; +use lance_namespace::LenientMergeInsertIntoTableRequest; use lance_namespace::models::{ AlterTableAddColumnsRequest, AlterTableAlterColumnsRequest, AlterTableBackfillColumnsRequest, AlterTableDropColumnsRequest, AlterTransactionRequest, AnalyzeTableQueryPlanRequest, @@ -19,10 +20,9 @@ use lance_namespace::models::{ DescribeTableVersionResponse, DescribeTransactionRequest, DropTableIndexRequest, ExplainTableQueryPlanRequest, GetTableStatsRequest, GetTableTagVersionRequest, InsertIntoTableRequest, ListTableIndicesRequest, ListTableTagsRequest, - ListTableVersionsRequest, ListTableVersionsResponse, ListTablesRequest, - MergeInsertIntoTableRequest, QueryTableRequest, RefreshMaterializedViewRequest, - RestoreTableRequest, UpdateTableRequest, UpdateTableSchemaMetadataRequest, - UpdateTableTagRequest, + ListTableVersionsRequest, ListTableVersionsResponse, ListTablesRequest, QueryTableRequest, + RefreshMaterializedViewRequest, RestoreTableRequest, UpdateTableRequest, + UpdateTableSchemaMetadataRequest, UpdateTableTagRequest, }; use lance_namespace_impls::RestNamespaceBuilder; use lance_namespace_impls::{ConnectBuilder, RestAdapter, RestAdapterConfig, RestAdapterHandle}; @@ -460,10 +460,13 @@ impl PyDirectoryNamespace { request: &Bound<'_, PyAny>, request_data: &Bound<'_, PyBytes>, ) -> PyResult> { - let request: MergeInsertIntoTableRequest = depythonize(request)?; + let request: LenientMergeInsertIntoTableRequest = depythonize(request)?; let data = Bytes::copy_from_slice(request_data.as_bytes()); let response = crate::rt() - .block_on(Some(py), self.inner.merge_insert_into_table(request, data))? + .block_on( + Some(py), + self.inner.merge_insert_into_table(request.into(), data), + )? .infer_error()?; pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } @@ -1160,10 +1163,13 @@ impl PyRestNamespace { request: &Bound<'_, PyAny>, request_data: &Bound<'_, PyBytes>, ) -> PyResult> { - let request: MergeInsertIntoTableRequest = depythonize(request)?; + let request: LenientMergeInsertIntoTableRequest = depythonize(request)?; let data = Bytes::copy_from_slice(request_data.as_bytes()); let response = crate::rt() - .block_on(Some(py), self.inner.merge_insert_into_table(request, data))? + .block_on( + Some(py), + self.inner.merge_insert_into_table(request.into(), data), + )? .infer_error()?; pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index 89f1897b1c1..8cd72ca063f 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -52,6 +52,7 @@ use std::sync::{Arc, Mutex}; use tokio::sync::OnceCell; use crate::context::DynamicContextProvider; +use crate::merge_insert_on_columns; use lance_namespace::models::{ AlterTableAddColumnsRequest, AlterTableAddColumnsResponse, AlterTableAlterColumnsRequest, AlterTableAlterColumnsResponse, AlterTableDropColumnsRequest, AlterTableDropColumnsResponse, @@ -5115,11 +5116,7 @@ impl LanceNamespace for DirectoryNamespace { ) -> Result { self.record_op("merge_insert_into_table"); let table_uri = self.resolve_table_location(&request.id).await?; - let on = request.on.as_ref().ok_or_else(|| { - lance_core::Error::from(NamespaceError::InvalidInput { - message: "'on' field is required for merge_insert_into_table".to_string(), - }) - })?; + let on = merge_insert_on_columns(request.on.as_deref(), "merge_insert_into_table")?; let table_has_manifests = self.table_uri_has_actual_manifests(&table_uri).await?; let (reader, num_rows) = @@ -5145,8 +5142,8 @@ impl LanceNamespace for DirectoryNamespace { .await?, ); - let mut merge_builder = MergeInsertBuilder::try_new(dataset.clone(), vec![on.clone()]) - .map_err(|e| { + let mut merge_builder = + MergeInsertBuilder::try_new(dataset.clone(), on.to_vec()).map_err(|e| { lance_core::Error::from(NamespaceError::InvalidInput { message: format!("Failed to create merge_insert_into_table builder: {}", e), }) @@ -11384,7 +11381,7 @@ mod tests { let mut merge_req = MergeInsertIntoTableRequest::new(); merge_req.id = Some(vec!["test_table".to_string()]); - merge_req.on = Some("id".to_string()); + merge_req.on = Some(vec!["id".to_string()]); let response = namespace .merge_insert_into_table( merge_req, @@ -11435,7 +11432,7 @@ mod tests { let mut merge_req = MergeInsertIntoTableRequest::new(); merge_req.id = Some(vec!["test_table".to_string()]); - merge_req.on = Some("id".to_string()); + merge_req.on = Some(vec!["id".to_string()]); let response = namespace .merge_insert_into_table( merge_req, @@ -11463,6 +11460,162 @@ mod tests { ); } + /// `(region, id, value)` rows, for merge inserts keyed on `region` + `id`. + fn create_composite_key_ipc_data(rows: &[(&str, i32, &str)]) -> Vec { + use arrow::array::{Int32Array, StringArray}; + use arrow::datatypes::{DataType, Field, Schema as ArrowSchema}; + use arrow::record_batch::RecordBatch; + + let schema = Arc::new(ArrowSchema::new(vec![ + Field::new("region", DataType::Utf8, false), + Field::new("id", DataType::Int32, false), + Field::new("value", DataType::Utf8, false), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(StringArray::from_iter_values( + rows.iter().map(|(region, _, _)| *region), + )), + Arc::new(Int32Array::from_iter_values( + rows.iter().map(|(_, id, _)| *id), + )), + Arc::new(StringArray::from_iter_values( + rows.iter().map(|(_, _, value)| *value), + )), + ], + ) + .unwrap(); + create_ipc_data_from_batches(schema, vec![batch]) + } + + #[tokio::test] + async fn test_merge_insert_matches_on_every_column_of_a_composite_key() { + use lance_namespace::models::{DeclareTableRequest, MergeInsertIntoTableRequest}; + + let temp_dir = TempStdDir::default(); + let temp_path = temp_dir.to_str().unwrap(); + let namespace = DirectoryNamespaceBuilder::new(temp_path) + .manifest_enabled(false) + .build() + .await + .unwrap(); + + let mut declare_req = DeclareTableRequest::new(); + declare_req.id = Some(vec!["test_table".to_string()]); + namespace.declare_table(declare_req).await.unwrap(); + + let seed = create_composite_key_ipc_data(&[("us", 1, "a"), ("us", 2, "b"), ("eu", 1, "c")]); + let mut merge_req = MergeInsertIntoTableRequest::new(); + merge_req.id = Some(vec!["test_table".to_string()]); + merge_req.on = Some(vec!["region".to_string(), "id".to_string()]); + namespace + .merge_insert_into_table(merge_req, bytes::Bytes::from(seed)) + .await + .unwrap(); + + // ("us", 1) matches an existing row; ("eu", 2) matches nothing even though a row + // with region "eu" and a row with id 2 both exist. + let mut merge_req = MergeInsertIntoTableRequest::new(); + merge_req.id = Some(vec!["test_table".to_string()]); + merge_req.on = Some(vec!["region".to_string(), "id".to_string()]); + merge_req.when_matched_update_all = Some(true); + let response = namespace + .merge_insert_into_table( + merge_req, + bytes::Bytes::from(create_composite_key_ipc_data(&[ + ("us", 1, "updated"), + ("eu", 2, "inserted"), + ])), + ) + .await + .unwrap(); + + assert_eq!(response.num_updated_rows, Some(1)); + assert_eq!(response.num_inserted_rows, Some(1)); + + let dataset = Dataset::open(&format!("{}/test_table.lance", temp_path)) + .await + .unwrap(); + let batch = dataset.scan().try_into_batch().await.unwrap(); + let regions = batch["region"] + .as_any() + .downcast_ref::() + .unwrap(); + let ids = batch["id"] + .as_any() + .downcast_ref::() + .unwrap(); + let values = batch["value"] + .as_any() + .downcast_ref::() + .unwrap(); + let mut rows: Vec<(&str, i32, &str)> = (0..batch.num_rows()) + .map(|row| (regions.value(row), ids.value(row), values.value(row))) + .collect(); + rows.sort_unstable(); + assert_eq!( + rows, + vec![ + // ("eu", 1) keeps its value: matching on `id` alone would have clobbered it. + ("eu", 1, "c"), + ("eu", 2, "inserted"), + ("us", 1, "updated"), + ("us", 2, "b"), + ] + ); + } + + #[rstest::rstest] + #[case::missing(None, "'on' field is required")] + #[case::empty(Some(vec![]), "must name at least one column")] + #[case::duplicate( + Some(vec!["region".to_string(), "region".to_string()]), + "names column 'region' more than once" + )] + #[tokio::test] + async fn test_merge_insert_rejects_an_invalid_on_key( + #[case] on: Option>, + #[case] expected_message: &str, + ) { + use lance_namespace::models::{DeclareTableRequest, MergeInsertIntoTableRequest}; + + let temp_dir = TempStdDir::default(); + let temp_path = temp_dir.to_str().unwrap(); + let namespace = DirectoryNamespaceBuilder::new(temp_path) + .manifest_enabled(false) + .build() + .await + .unwrap(); + + let mut declare_req = DeclareTableRequest::new(); + declare_req.id = Some(vec!["test_table".to_string()]); + namespace.declare_table(declare_req).await.unwrap(); + + let mut merge_req = MergeInsertIntoTableRequest::new(); + merge_req.id = Some(vec!["test_table".to_string()]); + merge_req.on = on; + let error = namespace + .merge_insert_into_table( + merge_req, + bytes::Bytes::from(create_composite_key_ipc_data(&[("us", 1, "a")])), + ) + .await + .unwrap_err(); + + let lance_core::Error::Namespace { source, .. } = &error else { + panic!("expected a Namespace error, got: {}", error); + }; + let ns_err = source + .downcast_ref::() + .expect("expected a NamespaceError source"); + assert_eq!(ns_err.code(), lance_namespace::ErrorCode::InvalidInput); + assert!( + error.to_string().contains(expected_message), + "unexpected error message: {error}" + ); + } + #[tokio::test] async fn test_declare_table_with_manifest() { use lance_namespace::models::{ diff --git a/rust/lance-namespace-impls/src/lib.rs b/rust/lance-namespace-impls/src/lib.rs index 58e29aca5ef..2fbfc5fe452 100644 --- a/rust/lance-namespace-impls/src/lib.rs +++ b/rust/lance-namespace-impls/src/lib.rs @@ -71,6 +71,10 @@ //! # } //! ``` +use std::collections::HashSet; + +use lance_namespace::NamespaceError; + pub mod connect; pub mod context; pub mod credentials; @@ -116,3 +120,38 @@ pub use rest::{RestNamespace, RestNamespaceBuilder}; #[cfg(feature = "rest-adapter")] pub use rest_adapter::{RestAdapter, RestAdapterConfig, RestAdapterHandle}; + +/// Validate the `on` match key of a merge insert request. +/// +/// The columns form a composite key, so an empty list matches nothing and a repeated +/// column adds a redundant equality to the join. +pub(crate) fn merge_insert_on_columns<'a>( + on: Option<&'a [String]>, + operation: &str, +) -> lance_core::Result<&'a [String]> { + let on = on.ok_or_else(|| { + lance_core::Error::from(NamespaceError::InvalidInput { + message: format!("'on' field is required for {}", operation), + }) + })?; + + if on.is_empty() { + return Err(NamespaceError::InvalidInput { + message: format!("'on' field must name at least one column for {}", operation), + } + .into()); + } + + let mut seen = HashSet::with_capacity(on.len()); + if let Some(duplicate) = on.iter().find(|column| !seen.insert(*column)) { + return Err(NamespaceError::InvalidInput { + message: format!( + "'on' field for {} names column '{}' more than once: {:?}", + operation, duplicate, on + ), + } + .into()); + } + + Ok(on) +} diff --git a/rust/lance-namespace-impls/src/rest.rs b/rust/lance-namespace-impls/src/rest.rs index 7bdaac59ec0..c8501a2867b 100644 --- a/rust/lance-namespace-impls/src/rest.rs +++ b/rust/lance-namespace-impls/src/rest.rs @@ -8,6 +8,7 @@ use std::str::FromStr; use std::sync::Arc; use crate::OpsMetrics; +use crate::merge_insert_on_columns; use async_trait::async_trait; use bytes::Bytes; @@ -1011,14 +1012,13 @@ impl LanceNamespace for RestNamespace { let id = object_id_str(&request.id, &self.delimiter)?; let encoded_id = urlencode(&id); - let on = request.on.as_deref().ok_or_else(|| { - lance_core::Error::from(NamespaceError::InvalidInput { - message: "'on' field is required for merge insert".to_string(), - }) - })?; + let on = merge_insert_on_columns(request.on.as_deref(), "merge_insert_into_table")?; let path = format!("/v1/table/{}/merge_insert", encoded_id); - let mut query = vec![("delimiter", self.delimiter.as_str()), ("on", on)]; + // The `on` query parameter uses `style: form, explode: true`, so a composite key + // repeats the parameter once per column. + let mut query = vec![("delimiter", self.delimiter.as_str())]; + query.extend(on.iter().map(|column| ("on", column.as_str()))); let when_matched_update_all_str; if let Some(v) = request.when_matched_update_all { diff --git a/rust/lance-namespace-impls/src/rest_adapter.rs b/rust/lance-namespace-impls/src/rest_adapter.rs index 44ebd866810..10dcdf0a925 100644 --- a/rust/lance-namespace-impls/src/rest_adapter.rs +++ b/rust/lance-namespace-impls/src/rest_adapter.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use axum::{ Json, Router, ServiceExt, body::Bytes, - extract::{FromRequest, Path, Query, Request, State}, + extract::{FromRequest, Path, Query, RawQuery, Request, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, @@ -22,6 +22,7 @@ use tokio::sync::watch; use tower::Layer; use tower_http::normalize_path::NormalizePathLayer; use tower_http::trace::TraceLayer; +use url::form_urlencoded; use lance_core::{Error, Result}; use lance_namespace::LanceNamespace; @@ -699,10 +700,12 @@ async fn insert_into_table( } } +/// `on` is absent here on purpose: it repeats once per column of a composite match key, +/// and `serde_urlencoded` (what axum's `Query` is built on) cannot deserialize a sequence. +/// It is collected from the raw query string by [`merge_insert_on_params`] instead. #[derive(Debug, Deserialize)] struct MergeInsertQuery { delimiter: Option, - on: Option, when_matched_update_all: Option, when_matched_update_all_filt: Option, when_not_matched_insert_all: Option, @@ -712,16 +715,25 @@ struct MergeInsertQuery { use_index: Option, } +fn merge_insert_on_params(raw_query: Option<&str>) -> Option> { + let on: Vec = form_urlencoded::parse(raw_query?.as_bytes()) + .filter(|(key, _)| key == "on") + .map(|(_, value)| value.into_owned()) + .collect(); + (!on.is_empty()).then_some(on) +} + async fn merge_insert_into_table( State(backend): State>, headers: HeaderMap, Path(id): Path, Query(params): Query, + RawQuery(raw_query): RawQuery, body: Bytes, ) -> Response { let request = MergeInsertIntoTableRequest { id: Some(parse_id(&id, params.delimiter.as_deref())), - on: params.on, + on: merge_insert_on_params(raw_query.as_deref()), when_matched_update_all: params.when_matched_update_all, when_matched_update_all_filt: params.when_matched_update_all_filt, when_not_matched_insert_all: params.when_not_matched_insert_all, @@ -1503,6 +1515,25 @@ mod tests { assert_eq!(id, vec!["table"]); } + #[test] + fn test_merge_insert_on_params() { + assert_eq!(merge_insert_on_params(None), None); + assert_eq!(merge_insert_on_params(Some("delimiter=%24")), None); + assert_eq!( + merge_insert_on_params(Some("on=id&use_index=true")), + Some(vec!["id".to_string()]) + ); + assert_eq!( + merge_insert_on_params(Some("on=region&use_index=true&on=id")), + Some(vec!["region".to_string(), "id".to_string()]) + ); + // A backtick-quoted field path arrives percent-encoded. + assert_eq!( + merge_insert_on_params(Some("on=%60a.b%60&on=nested.leaf")), + Some(vec!["`a.b`".to_string(), "nested.leaf".to_string()]) + ); + } + // ============================================================================ // Integration Tests // ============================================================================ @@ -3061,6 +3092,92 @@ mod tests { assert_eq!(a_col.values(), &[100, 200]); } + /// `(region, id, value)` rows, for merge inserts keyed on `region` + `id`. + fn create_composite_key_arrow_data(rows: &[(&str, i32, &str)]) -> Bytes { + use arrow::array::{Int32Array, StringArray}; + use arrow::datatypes::{DataType, Field, Schema}; + use arrow::ipc::writer::StreamWriter; + use arrow::record_batch::RecordBatch; + + let schema = Arc::new(Schema::new(vec![ + Field::new("region", DataType::Utf8, false), + Field::new("id", DataType::Int32, false), + Field::new("value", DataType::Utf8, false), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(StringArray::from_iter_values( + rows.iter().map(|(region, _, _)| *region), + )), + Arc::new(Int32Array::from_iter_values( + rows.iter().map(|(_, id, _)| *id), + )), + Arc::new(StringArray::from_iter_values( + rows.iter().map(|(_, _, value)| *value), + )), + ], + ) + .unwrap(); + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &schema).unwrap(); + writer.write(&batch).unwrap(); + writer.finish().unwrap(); + } + Bytes::from(buffer) + } + + /// A composite match key survives the round trip through the REST client and the + /// adapter's query string, which repeats `on` once per column. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_merge_insert_composite_key_round_trip() { + use lance_namespace::LanceNamespace; + + let fixture = RestServerFixture::new().await; + let table_id = vec!["merge_ns".to_string(), "merge_table".to_string()]; + let on = vec!["region".to_string(), "id".to_string()]; + + let mut create_ns = CreateNamespaceRequest::new(); + create_ns.id = Some(vec!["merge_ns".to_string()]); + fixture.namespace.create_namespace(create_ns).await.unwrap(); + + let create_table_req = CreateTableRequest { + id: Some(table_id.clone()), + mode: Some("Create".to_string()), + ..Default::default() + }; + fixture + .namespace + .create_table( + create_table_req, + create_composite_key_arrow_data(&[ + ("us", 1, "a"), + ("us", 2, "b"), + ("eu", 1, "c"), + ]), + ) + .await + .unwrap(); + + let mut merge_req = MergeInsertIntoTableRequest::new(); + merge_req.id = Some(table_id); + merge_req.on = Some(on); + merge_req.when_matched_update_all = Some(true); + let response = fixture + .namespace + .merge_insert_into_table( + merge_req, + create_composite_key_arrow_data(&[("us", 1, "updated"), ("eu", 2, "inserted")]), + ) + .await + .unwrap(); + + assert_eq!(response.num_updated_rows, Some(1)); + assert_eq!(response.num_inserted_rows, Some(1)); + } + // ============================================================================ // DynamicContextProvider Integration Test // ============================================================================ diff --git a/rust/lance-namespace/Cargo.toml b/rust/lance-namespace/Cargo.toml index cd32c8f611e..ceac8ffa501 100644 --- a/rust/lance-namespace/Cargo.toml +++ b/rust/lance-namespace/Cargo.toml @@ -16,6 +16,8 @@ async-trait.workspace = true bytes.workspace = true arrow.workspace = true lance-core.workspace = true +serde.workspace = true +serde_json.workspace = true snafu.workspace = true lance-namespace-reqwest-client.workspace = true diff --git a/rust/lance-namespace/src/compat.rs b/rust/lance-namespace/src/compat.rs new file mode 100644 index 00000000000..85a88be5edd --- /dev/null +++ b/rust/lance-namespace/src/compat.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Deserialization shims that keep older SDK builds working against current native code. + +use serde::Deserialize; +use serde::de::{Deserializer, Error as _}; + +use crate::models::MergeInsertIntoTableRequest; + +/// A [`MergeInsertIntoTableRequest`] whose `on` field also accepts a bare string. +/// +/// Java and Python SDK requests reach the Rust implementations as JSON across JNI and +/// pyo3, so a jar or wheel built against lance-namespace 0.11 or earlier sends +/// `"on": "id"` where the current model expects `"on": ["id"]`. Deserializing through +/// this type promotes a scalar to a one-element list so those callers keep working. +/// +/// This is inbound only. A Java namespace implementation called *from* Rust still needs +/// a jar matching the current model. +#[derive(Debug, Clone)] +pub struct LenientMergeInsertIntoTableRequest(MergeInsertIntoTableRequest); + +impl From for MergeInsertIntoTableRequest { + fn from(request: LenientMergeInsertIntoTableRequest) -> Self { + request.0 + } +} + +impl<'de> Deserialize<'de> for LenientMergeInsertIntoTableRequest { + fn deserialize>(deserializer: D) -> Result { + let mut value = serde_json::Value::deserialize(deserializer)?; + if let Some(on) = value.get_mut("on") + && let Some(column) = on.as_str() + { + *on = serde_json::json!([column]); + } + serde_json::from_value(value) + .map(Self) + .map_err(D::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(json: &str) -> MergeInsertIntoTableRequest { + serde_json::from_str::(json) + .unwrap() + .into() + } + + #[test] + fn scalar_on_is_promoted_to_a_single_column_key() { + let request = parse(r#"{"id": ["t"], "on": "id"}"#); + assert_eq!(request.on, Some(vec!["id".to_string()])); + assert_eq!(request.id, Some(vec!["t".to_string()])); + } + + #[test] + fn list_on_is_preserved() { + let request = parse(r#"{"id": ["t"], "on": ["a", "b"], "use_index": true}"#); + assert_eq!(request.on, Some(vec!["a".to_string(), "b".to_string()])); + assert_eq!(request.use_index, Some(true)); + } + + #[test] + fn absent_and_null_on_stay_absent() { + assert_eq!(parse(r#"{"id": ["t"]}"#).on, None); + assert_eq!(parse(r#"{"id": ["t"], "on": null}"#).on, None); + } + + #[test] + fn a_non_string_non_list_on_is_still_rejected() { + let error = + serde_json::from_str::(r#"{"id": ["t"], "on": 7}"#) + .unwrap_err(); + assert!( + error.to_string().contains("invalid type"), + "unexpected error: {error}" + ); + } +} diff --git a/rust/lance-namespace/src/lib.rs b/rust/lance-namespace/src/lib.rs index 6fd9a9b7ab2..659f350296e 100644 --- a/rust/lance-namespace/src/lib.rs +++ b/rust/lance-namespace/src/lib.rs @@ -15,6 +15,7 @@ //! See [`error::ErrorCode`] for the list of error codes and //! [`error::NamespaceError`] for the error types. +pub mod compat; pub mod error; pub mod namespace; pub mod schema; @@ -26,6 +27,8 @@ pub use namespace::LanceNamespace; // Re-export error types pub use error::{ErrorCode, NamespaceError, Result as NamespaceResult}; +pub use compat::LenientMergeInsertIntoTableRequest; + // Re-export reqwest client for convenience pub use lance_namespace_reqwest_client as reqwest_client; From 8613e49fe1a2caae2edc5d5a0dd3d411155f5d88 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Mon, 31 Aug 2026 14:06:28 -0700 Subject: [PATCH 2/4] refactor(namespace): replace the merge insert compat newtype with a function `LenientMergeInsertIntoTableRequest` named the serde quirk rather than the reason the shim exists. Replace it with `compat::merge_insert_request_from_json`, which takes a `serde_json::Value` and returns the model. The bridge sites deserialize to `Value` and convert, which reads shorter at all four and keeps the compat framing visible in the path. Co-Authored-By: Claude Opus 5 (1M context) --- java/lance-jni/src/namespace.rs | 20 +++++-------- python/src/namespace.rs | 16 ++++------ rust/lance-namespace/src/compat.rs | 47 ++++++++++++------------------ rust/lance-namespace/src/lib.rs | 2 -- 4 files changed, 30 insertions(+), 55 deletions(-) diff --git a/java/lance-jni/src/namespace.rs b/java/lance-jni/src/namespace.rs index db870cef754..9a670af0e2a 100644 --- a/java/lance-jni/src/namespace.rs +++ b/java/lance-jni/src/namespace.rs @@ -10,7 +10,7 @@ use jni::JNIEnv; use jni::objects::{GlobalRef, JByteArray, JMap, JObject, JString, JValue}; use jni::sys::{jbyteArray, jlong, jobject, jstring}; use lance_namespace::LanceNamespace as LanceNamespaceTrait; -use lance_namespace::LenientMergeInsertIntoTableRequest; +use lance_namespace::compat::merge_insert_request_from_json; use lance_namespace::models::*; use lance_namespace_impls::{ ConnectBuilder, DirectoryNamespace, DirectoryNamespaceBuilder, DynamicContextProvider, @@ -1915,12 +1915,9 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_mergeInsertIn handle, request_json, request_data, - |namespace_client, req: LenientMergeInsertIntoTableRequest, data| { - block_on( - namespace_client - .inner - .merge_insert_into_table(req.into(), data), - ) + |namespace_client, req: serde_json::Value, data| { + let req = merge_insert_request_from_json(req)?; + block_on(namespace_client.inner.merge_insert_into_table(req, data)) } ), std::ptr::null_mut() @@ -2869,12 +2866,9 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_mergeInsertIntoTab handle, request_json, request_data, - |namespace_client, req: LenientMergeInsertIntoTableRequest, data| { - block_on( - namespace_client - .inner - .merge_insert_into_table(req.into(), data), - ) + |namespace_client, req: serde_json::Value, data| { + let req = merge_insert_request_from_json(req)?; + block_on(namespace_client.inner.merge_insert_into_table(req, data)) } ), std::ptr::null_mut() diff --git a/python/src/namespace.rs b/python/src/namespace.rs index 0f6e8edfa0f..996d3f92ca7 100644 --- a/python/src/namespace.rs +++ b/python/src/namespace.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use async_trait::async_trait; use bytes::Bytes; use lance_namespace::LanceNamespace as LanceNamespaceTrait; -use lance_namespace::LenientMergeInsertIntoTableRequest; +use lance_namespace::compat::merge_insert_request_from_json; use lance_namespace::models::{ AlterTableAddColumnsRequest, AlterTableAlterColumnsRequest, AlterTableBackfillColumnsRequest, AlterTableDropColumnsRequest, AlterTransactionRequest, AnalyzeTableQueryPlanRequest, @@ -460,13 +460,10 @@ impl PyDirectoryNamespace { request: &Bound<'_, PyAny>, request_data: &Bound<'_, PyBytes>, ) -> PyResult> { - let request: LenientMergeInsertIntoTableRequest = depythonize(request)?; + let request = merge_insert_request_from_json(depythonize(request)?).infer_error()?; let data = Bytes::copy_from_slice(request_data.as_bytes()); let response = crate::rt() - .block_on( - Some(py), - self.inner.merge_insert_into_table(request.into(), data), - )? + .block_on(Some(py), self.inner.merge_insert_into_table(request, data))? .infer_error()?; pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } @@ -1163,13 +1160,10 @@ impl PyRestNamespace { request: &Bound<'_, PyAny>, request_data: &Bound<'_, PyBytes>, ) -> PyResult> { - let request: LenientMergeInsertIntoTableRequest = depythonize(request)?; + let request = merge_insert_request_from_json(depythonize(request)?).infer_error()?; let data = Bytes::copy_from_slice(request_data.as_bytes()); let response = crate::rt() - .block_on( - Some(py), - self.inner.merge_insert_into_table(request.into(), data), - )? + .block_on(Some(py), self.inner.merge_insert_into_table(request, data))? .infer_error()?; pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } diff --git a/rust/lance-namespace/src/compat.rs b/rust/lance-namespace/src/compat.rs index 85a88be5edd..57c381831f8 100644 --- a/rust/lance-namespace/src/compat.rs +++ b/rust/lance-namespace/src/compat.rs @@ -3,41 +3,33 @@ //! Deserialization shims that keep older SDK builds working against current native code. -use serde::Deserialize; -use serde::de::{Deserializer, Error as _}; - +use crate::error::NamespaceError; use crate::models::MergeInsertIntoTableRequest; -/// A [`MergeInsertIntoTableRequest`] whose `on` field also accepts a bare string. +/// Deserialize a [`MergeInsertIntoTableRequest`] whose `on` field may be a bare string. /// /// Java and Python SDK requests reach the Rust implementations as JSON across JNI and /// pyo3, so a jar or wheel built against lance-namespace 0.11 or earlier sends -/// `"on": "id"` where the current model expects `"on": ["id"]`. Deserializing through -/// this type promotes a scalar to a one-element list so those callers keep working. +/// `"on": "id"` where the current model expects `"on": ["id"]`. A scalar is promoted to +/// a one-element list so those callers keep working. /// /// This is inbound only. A Java namespace implementation called *from* Rust still needs /// a jar matching the current model. -#[derive(Debug, Clone)] -pub struct LenientMergeInsertIntoTableRequest(MergeInsertIntoTableRequest); - -impl From for MergeInsertIntoTableRequest { - fn from(request: LenientMergeInsertIntoTableRequest) -> Self { - request.0 +pub fn merge_insert_request_from_json( + mut value: serde_json::Value, +) -> crate::Result { + if let Some(on) = value.get_mut("on") + && let Some(column) = on.as_str() + { + *on = serde_json::json!([column]); } -} -impl<'de> Deserialize<'de> for LenientMergeInsertIntoTableRequest { - fn deserialize>(deserializer: D) -> Result { - let mut value = serde_json::Value::deserialize(deserializer)?; - if let Some(on) = value.get_mut("on") - && let Some(column) = on.as_str() - { - *on = serde_json::json!([column]); + serde_json::from_value(value).map_err(|e| { + NamespaceError::InvalidInput { + message: format!("Failed to parse merge_insert_into_table request: {}", e), } - serde_json::from_value(value) - .map(Self) - .map_err(D::Error::custom) - } + .into() + }) } #[cfg(test)] @@ -45,9 +37,7 @@ mod tests { use super::*; fn parse(json: &str) -> MergeInsertIntoTableRequest { - serde_json::from_str::(json) - .unwrap() - .into() + merge_insert_request_from_json(serde_json::from_str(json).unwrap()).unwrap() } #[test] @@ -73,8 +63,7 @@ mod tests { #[test] fn a_non_string_non_list_on_is_still_rejected() { let error = - serde_json::from_str::(r#"{"id": ["t"], "on": 7}"#) - .unwrap_err(); + merge_insert_request_from_json(serde_json::json!({"id": ["t"], "on": 7})).unwrap_err(); assert!( error.to_string().contains("invalid type"), "unexpected error: {error}" diff --git a/rust/lance-namespace/src/lib.rs b/rust/lance-namespace/src/lib.rs index 659f350296e..2fc34e03549 100644 --- a/rust/lance-namespace/src/lib.rs +++ b/rust/lance-namespace/src/lib.rs @@ -27,8 +27,6 @@ pub use namespace::LanceNamespace; // Re-export error types pub use error::{ErrorCode, NamespaceError, Result as NamespaceResult}; -pub use compat::LenientMergeInsertIntoTableRequest; - // Re-export reqwest client for convenience pub use lance_namespace_reqwest_client as reqwest_client; From 52f9f6627d203f7c946de9f03d8a6f83cea4fe09 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Mon, 31 Aug 2026 15:14:11 -0700 Subject: [PATCH 3/4] test(namespace): cover NULL key columns in a composite merge insert key Core switches NULL join semantics on the arity of the match key: a single-column key treats NULL as equal to NULL, a composite key uses standard SQL equality where it is not. Composite keys are reachable through the namespace API for the first time here, so pin the behavior down. Makes `region` nullable in the shared test data helper and pulls the row readback into `read_composite_key_rows`, now that two tests scan the result. Co-Authored-By: Claude Opus 5 (1M context) --- rust/lance-namespace-impls/src/dir.rs | 140 ++++++++++++++++++++------ 1 file changed, 111 insertions(+), 29 deletions(-) diff --git a/rust/lance-namespace-impls/src/dir.rs b/rust/lance-namespace-impls/src/dir.rs index 8cd72ca063f..80c800d05e0 100644 --- a/rust/lance-namespace-impls/src/dir.rs +++ b/rust/lance-namespace-impls/src/dir.rs @@ -11461,20 +11461,22 @@ mod tests { } /// `(region, id, value)` rows, for merge inserts keyed on `region` + `id`. - fn create_composite_key_ipc_data(rows: &[(&str, i32, &str)]) -> Vec { + /// + /// `region` is nullable so tests can cover a NULL in one half of the key. + fn create_composite_key_ipc_data(rows: &[(Option<&str>, i32, &str)]) -> Vec { use arrow::array::{Int32Array, StringArray}; use arrow::datatypes::{DataType, Field, Schema as ArrowSchema}; use arrow::record_batch::RecordBatch; let schema = Arc::new(ArrowSchema::new(vec![ - Field::new("region", DataType::Utf8, false), + Field::new("region", DataType::Utf8, true), Field::new("id", DataType::Int32, false), Field::new("value", DataType::Utf8, false), ])); let batch = RecordBatch::try_new( schema.clone(), vec![ - Arc::new(StringArray::from_iter_values( + Arc::new(StringArray::from_iter( rows.iter().map(|(region, _, _)| *region), )), Arc::new(Int32Array::from_iter_values( @@ -11489,6 +11491,42 @@ mod tests { create_ipc_data_from_batches(schema, vec![batch]) } + /// `test_table`'s rows as `(region, id, value)`, sorted for a stable comparison. + async fn read_composite_key_rows(root: &str) -> Vec<(Option, i32, String)> { + use arrow::array::Array; + + let dataset = Dataset::open(&format!("{}/test_table.lance", root)) + .await + .unwrap(); + let batch = dataset.scan().try_into_batch().await.unwrap(); + let regions = batch["region"] + .as_any() + .downcast_ref::() + .unwrap(); + let ids = batch["id"] + .as_any() + .downcast_ref::() + .unwrap(); + let values = batch["value"] + .as_any() + .downcast_ref::() + .unwrap(); + + let mut rows: Vec<_> = (0..batch.num_rows()) + .map(|row| { + ( + regions + .is_valid(row) + .then(|| regions.value(row).to_string()), + ids.value(row), + values.value(row).to_string(), + ) + }) + .collect(); + rows.sort_unstable(); + rows + } + #[tokio::test] async fn test_merge_insert_matches_on_every_column_of_a_composite_key() { use lance_namespace::models::{DeclareTableRequest, MergeInsertIntoTableRequest}; @@ -11505,7 +11543,11 @@ mod tests { declare_req.id = Some(vec!["test_table".to_string()]); namespace.declare_table(declare_req).await.unwrap(); - let seed = create_composite_key_ipc_data(&[("us", 1, "a"), ("us", 2, "b"), ("eu", 1, "c")]); + let seed = create_composite_key_ipc_data(&[ + (Some("us"), 1, "a"), + (Some("us"), 2, "b"), + (Some("eu"), 1, "c"), + ]); let mut merge_req = MergeInsertIntoTableRequest::new(); merge_req.id = Some(vec!["test_table".to_string()]); merge_req.on = Some(vec!["region".to_string(), "id".to_string()]); @@ -11524,8 +11566,8 @@ mod tests { .merge_insert_into_table( merge_req, bytes::Bytes::from(create_composite_key_ipc_data(&[ - ("us", 1, "updated"), - ("eu", 2, "inserted"), + (Some("us"), 1, "updated"), + (Some("eu"), 2, "inserted"), ])), ) .await @@ -11534,34 +11576,74 @@ mod tests { assert_eq!(response.num_updated_rows, Some(1)); assert_eq!(response.num_inserted_rows, Some(1)); - let dataset = Dataset::open(&format!("{}/test_table.lance", temp_path)) + assert_eq!( + read_composite_key_rows(temp_path).await, + vec![ + // ("eu", 1) keeps its value: matching on `id` alone would have clobbered it. + (Some("eu".into()), 1, "c".into()), + (Some("eu".into()), 2, "inserted".into()), + (Some("us".into()), 1, "updated".into()), + (Some("us".into()), 2, "b".into()), + ] + ); + } + + /// Core switches NULL join semantics on the arity of the match key + /// (`merge_insert.rs`, `NullEquality`): a single-column key treats NULL as equal to + /// NULL, while a composite key uses standard SQL equality, under which it is not. So + /// adding a second key column changes whether NULL-keyed rows match at all. + #[tokio::test] + async fn test_merge_insert_composite_key_never_matches_a_null_key_column() { + use lance_namespace::models::{DeclareTableRequest, MergeInsertIntoTableRequest}; + + let temp_dir = TempStdDir::default(); + let temp_path = temp_dir.to_str().unwrap(); + let namespace = DirectoryNamespaceBuilder::new(temp_path) + .manifest_enabled(false) + .build() .await .unwrap(); - let batch = dataset.scan().try_into_batch().await.unwrap(); - let regions = batch["region"] - .as_any() - .downcast_ref::() - .unwrap(); - let ids = batch["id"] - .as_any() - .downcast_ref::() + + let mut declare_req = DeclareTableRequest::new(); + declare_req.id = Some(vec!["test_table".to_string()]); + namespace.declare_table(declare_req).await.unwrap(); + + let mut merge_req = MergeInsertIntoTableRequest::new(); + merge_req.id = Some(vec!["test_table".to_string()]); + merge_req.on = Some(vec!["region".to_string(), "id".to_string()]); + namespace + .merge_insert_into_table( + merge_req, + bytes::Bytes::from(create_composite_key_ipc_data(&[ + (None, 1, "seeded"), + (Some("us"), 1, "us-seeded"), + ])), + ) + .await .unwrap(); - let values = batch["value"] - .as_any() - .downcast_ref::() + + let mut merge_req = MergeInsertIntoTableRequest::new(); + merge_req.id = Some(vec!["test_table".to_string()]); + merge_req.on = Some(vec!["region".to_string(), "id".to_string()]); + merge_req.when_matched_update_all = Some(true); + let response = namespace + .merge_insert_into_table( + merge_req, + bytes::Bytes::from(create_composite_key_ipc_data(&[(None, 1, "not-a-match")])), + ) + .await .unwrap(); - let mut rows: Vec<(&str, i32, &str)> = (0..batch.num_rows()) - .map(|row| (regions.value(row), ids.value(row), values.value(row))) - .collect(); - rows.sort_unstable(); + + // The incoming row is byte-identical to the seeded one, and still does not match. + assert_eq!(response.num_updated_rows, Some(0)); + assert_eq!(response.num_inserted_rows, Some(1)); + assert_eq!( - rows, + read_composite_key_rows(temp_path).await, vec![ - // ("eu", 1) keeps its value: matching on `id` alone would have clobbered it. - ("eu", 1, "c"), - ("eu", 2, "inserted"), - ("us", 1, "updated"), - ("us", 2, "b"), + (None, 1, "not-a-match".into()), + (None, 1, "seeded".into()), + (Some("us".into()), 1, "us-seeded".into()), ] ); } @@ -11598,7 +11680,7 @@ mod tests { let error = namespace .merge_insert_into_table( merge_req, - bytes::Bytes::from(create_composite_key_ipc_data(&[("us", 1, "a")])), + bytes::Bytes::from(create_composite_key_ipc_data(&[(Some("us"), 1, "a")])), ) .await .unwrap_err(); From 09eee13b1be3aa1b8955992c7597cd5432f7ba80 Mon Sep 17 00:00:00 2001 From: Will Jones Date: Wed, 2 Sep 2026 08:33:06 -0700 Subject: [PATCH 4/4] build(deps): depend on the released lance-namespace 0.12.0 Replace the temporary git-branch pin now that the spec change is released, and refresh all three lockfiles. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 5 +++-- Cargo.toml | 5 +---- java/lance-jni/Cargo.lock | 5 +++-- python/Cargo.lock | 5 +++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85041f5b1fc..38444045990 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5050,8 +5050,9 @@ dependencies = [ [[package]] name = "lance-namespace-reqwest-client" -version = "0.11.1" -source = "git+https://github.com/wjones127/lance-namespace?branch=will%2Fent-2084-merge-insert-multi-column-on#f2d9ca427775e8678a76200d9a67c612f8de50f2" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d23e54b1634d5bbb434f8dd33dc3c05f6e58d876a9a27b3b4aef58ddbe11af" dependencies = [ "reqwest 0.12.28", "serde", diff --git a/Cargo.toml b/Cargo.toml index cdd213f1bb7..2f6046faa05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,10 +73,7 @@ lance-io = { version = "=12.0.0-beta.11", path = "./rust/lance-io", default-feat lance-linalg = { version = "=12.0.0-beta.11", path = "./rust/lance-linalg" } lance-namespace = { version = "=12.0.0-beta.11", path = "./rust/lance-namespace" } lance-namespace-impls = { version = "=12.0.0-beta.11", path = "./rust/lance-namespace-impls" } -# TODO: switch back to a crates.io version once lance-namespace 0.12 is released. -# Pinned to https://github.com/lance-format/lance-namespace/pull/363, which makes -# `MergeInsertIntoTableRequest.on` a list. -lance-namespace-reqwest-client = { git = "https://github.com/wjones127/lance-namespace", branch = "will/ent-2084-merge-insert-multi-column-on" } +lance-namespace-reqwest-client = "0.12.0" lance-select = { version = "=12.0.0-beta.11", path = "./rust/lance-select" } lance-tokenizer = { version = "=12.0.0-beta.11", path = "./rust/lance-tokenizer" } lance-table = { version = "=12.0.0-beta.11", path = "./rust/lance-table" } diff --git a/java/lance-jni/Cargo.lock b/java/lance-jni/Cargo.lock index 02496af5ad8..9b24703dc7f 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -4190,8 +4190,9 @@ dependencies = [ [[package]] name = "lance-namespace-reqwest-client" -version = "0.11.1" -source = "git+https://github.com/wjones127/lance-namespace?branch=will%2Fent-2084-merge-insert-multi-column-on#f2d9ca427775e8678a76200d9a67c612f8de50f2" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d23e54b1634d5bbb434f8dd33dc3c05f6e58d876a9a27b3b4aef58ddbe11af" dependencies = [ "reqwest 0.12.28", "serde", diff --git a/python/Cargo.lock b/python/Cargo.lock index c385f1baa73..ef387e01568 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -4495,8 +4495,9 @@ dependencies = [ [[package]] name = "lance-namespace-reqwest-client" -version = "0.11.1" -source = "git+https://github.com/wjones127/lance-namespace?branch=will%2Fent-2084-merge-insert-multi-column-on#f2d9ca427775e8678a76200d9a67c612f8de50f2" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d23e54b1634d5bbb434f8dd33dc3c05f6e58d876a9a27b3b4aef58ddbe11af" dependencies = [ "reqwest 0.12.28", "serde",