feat: materialized view declarations on local tables (#3930)

A materialized view is a table whose contents are defined by a query
over
one source table and maintained by refresh rather than by writes.

The declaration half: create_materialized_view(name, source) resolves a
projected, filtered and limited definition against the source schema --
output types come from the DataFusion planner, never the caller -- and
commits an empty table carrying it as kind-tagged JSON in schema
metadata.
The tag lets a kind added later read back as a view this version cannot
refresh rather than as a plain table. Views open and list as ordinary
tables.

Sources must have stable row ids, checked here because the property
cannot
be enabled later: each view row records its source row in
__source_row_id,
and that provenance survives compactions, updates and deletes only when
row
ids are stable.

A view inherits the metadata describing its columns and none governing
how a
table is written, so blob markers carry through while declarations its
always-nullable fields would contradict are stripped. Embedding
configuration is rewritten to the view's column names, and dropped where
it
does not project both ends of a function.


<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
This commit is contained in:
Wyatt Alt
2026-08-21 16:17:03 -07:00
committed by GitHub
parent c7cb0b9afa
commit 01679e37fd
9 changed files with 2406 additions and 117 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ use lance_io::object_store::{StorageOptionsAccessor, StorageOptionsProvider};
mod create_table;
fn merge_storage_options(
pub(crate) fn merge_storage_options(
store_params: &mut ObjectStoreParams,
pairs: impl IntoIterator<Item = (String, String)>,
) {
+240 -62
View File
@@ -765,60 +765,13 @@ impl ListingDatabase {
}
}
/// Extract storage option overrides from the request
fn extract_storage_overrides(
&self,
request: &CreateTableRequest,
) -> Result<(Option<LanceFileVersion>, Option<bool>, Option<bool>)> {
let storage_options = request
.write_options
.lance_write_params
.as_ref()
.and_then(|p| p.store_params.as_ref())
.and_then(|sp| sp.storage_options());
let storage_version_override = storage_options
.and_then(|opts| opts.get(OPT_NEW_TABLE_STORAGE_VERSION))
.map(|s| s.parse::<LanceFileVersion>())
.transpose()?;
let v2_manifest_override = storage_options
.and_then(|opts| opts.get(OPT_NEW_TABLE_V2_MANIFEST_PATHS))
.map(|s| s.parse::<bool>())
.transpose()
.map_err(|_| Error::InvalidInput {
message: "enable_v2_manifest_paths must be a boolean".to_string(),
})?;
let stable_row_ids_override = storage_options
.and_then(|opts| opts.get(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS))
.map(|s| s.parse::<bool>())
.transpose()
.map_err(|_| Error::InvalidInput {
message: "enable_stable_row_ids must be a boolean".to_string(),
})?;
Ok((
storage_version_override,
v2_manifest_override,
stable_row_ids_override,
))
}
/// Prepare write parameters for table creation
fn prepare_write_params(
&self,
request: &CreateTableRequest,
storage_version_override: Option<LanceFileVersion>,
v2_manifest_override: Option<bool>,
stable_row_ids_override: Option<bool>,
mut write_params: lance::dataset::WriteParams,
overrides: NewTableConfig,
) -> lance::dataset::WriteParams {
let mut write_params = request
.write_options
.lance_write_params
.clone()
.unwrap_or_default();
// Only modify the storage options if we actually have something to
// inherit. There is a difference between storage_options=None and
// storage_options=Some({}). Using storage_options=None will cause the
@@ -842,18 +795,21 @@ impl ListingDatabase {
store_params.storage_options_accessor = Some(Arc::new(accessor));
}
write_params.data_storage_version = storage_version_override
write_params.data_storage_version = overrides
.data_storage_version
.or(write_params.data_storage_version)
.or(self.new_table_config.data_storage_version);
if let Some(enable_v2_manifest_paths) =
v2_manifest_override.or(self.new_table_config.enable_v2_manifest_paths)
if let Some(enable_v2_manifest_paths) = overrides
.enable_v2_manifest_paths
.or(self.new_table_config.enable_v2_manifest_paths)
{
write_params.enable_v2_manifest_paths = enable_v2_manifest_paths;
}
let data_schema = request.data.arrow_schema();
if let Some(enable_stable_row_ids) = stable_row_ids_override
if let Some(enable_stable_row_ids) = overrides
.enable_stable_row_ids
.or(self.new_table_config.enable_stable_row_ids)
.or(has_blob_columns(&data_schema).then_some(true))
{
@@ -1048,15 +1004,13 @@ impl Database for ListingDatabase {
.clone()
.unwrap_or_else(|| self.table_uri(&request.name).unwrap());
let (storage_version_override, v2_manifest_override, stable_row_ids_override) =
self.extract_storage_overrides(&request)?;
let write_params = self.prepare_write_params(
&request,
storage_version_override,
v2_manifest_override,
stable_row_ids_override,
);
let mut write_params = request
.write_options
.lance_write_params
.clone()
.unwrap_or_default();
let overrides = take_request_creation_overrides(&mut write_params)?;
let write_params = self.prepare_write_params(&request, write_params, overrides);
let data_schema = request.data.arrow_schema();
@@ -1288,8 +1242,232 @@ impl Database for ListingDatabase {
}
}
/// Parse the request-level `new_table_*` creation keys into overrides and
/// strip them from the store options in one step: every create path that
/// honors them must also keep them out of the object store.
pub(crate) fn take_request_creation_overrides(
params: &mut lance::dataset::WriteParams,
) -> Result<NewTableConfig> {
let storage_options = params
.store_params
.as_ref()
.and_then(|sp| sp.storage_options());
let overrides = NewTableConfig {
data_storage_version: storage_options
.and_then(|opts| opts.get(OPT_NEW_TABLE_STORAGE_VERSION))
.map(|s| s.parse::<LanceFileVersion>())
.transpose()?,
enable_v2_manifest_paths: storage_options
.and_then(|opts| opts.get(OPT_NEW_TABLE_V2_MANIFEST_PATHS))
.map(|s| s.parse::<bool>())
.transpose()
.map_err(|_| Error::InvalidInput {
message: "enable_v2_manifest_paths must be a boolean".to_string(),
})?,
enable_stable_row_ids: storage_options
.and_then(|opts| opts.get(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS))
.map(|s| s.parse::<bool>())
.transpose()
.map_err(|_| Error::InvalidInput {
message: "enable_stable_row_ids must be a boolean".to_string(),
})?,
};
if let Some(store_params) = params.store_params.as_mut() {
strip_new_table_creation_keys(store_params);
}
Ok(overrides)
}
/// Strip the `new_table_*` creation keys from request store options: they are
/// creation config, not credentials, and left in place they fork a fresh
/// store connection for the request.
fn strip_new_table_creation_keys(store_params: &mut ObjectStoreParams) {
let mut options = store_params.storage_options().cloned().unwrap_or_default();
let mut removed = false;
for key in [
OPT_NEW_TABLE_STORAGE_VERSION,
OPT_NEW_TABLE_V2_MANIFEST_PATHS,
OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS,
] {
removed |= options.remove(key).is_some();
}
if !removed {
return;
}
let provider = store_params
.storage_options_accessor
.as_ref()
.and_then(|accessor| accessor.provider().cloned());
store_params.storage_options_accessor = match (options.is_empty(), provider) {
(true, None) => None,
(true, Some(provider)) => Some(Arc::new(StorageOptionsAccessor::with_provider(provider))),
(false, Some(provider)) => Some(Arc::new(
StorageOptionsAccessor::with_initial_and_provider(options, provider),
)),
(false, None) => Some(Arc::new(StorageOptionsAccessor::with_static_options(
options,
))),
};
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn request_level_creation_keys_do_not_fork_the_store() {
use crate::query::ExecutableQuery;
use futures::TryStreamExt;
let db = crate::connect("memory://").execute().await.unwrap();
let batch = arrow_array::record_batch!(("x", Int32, [1, 2])).unwrap();
let store_params = ObjectStoreParams {
storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
HashMap::from([(
OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS.to_string(),
"true".to_string(),
)]),
))),
..Default::default()
};
db.create_table("t", batch)
.write_options(crate::table::WriteOptions {
lance_write_params: Some(lance::dataset::WriteParams {
store_params: Some(store_params),
..Default::default()
}),
})
.execute()
.await
.unwrap();
let table = db.open_table("t").execute().await.unwrap();
let rows: usize = table
.query()
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap()
.iter()
.map(|b| b.num_rows())
.sum();
assert_eq!(rows, 2, "the table must live in the session's store");
}
mod strip_new_table_creation_keys {
use super::super::*;
#[derive(Debug)]
struct EmptyProvider;
#[async_trait::async_trait]
impl StorageOptionsProvider for EmptyProvider {
async fn fetch_storage_options(
&self,
) -> lance_core::Result<Option<HashMap<String, String>>> {
Ok(Some(HashMap::new()))
}
fn provider_id(&self) -> String {
"empty-test-provider".into()
}
}
fn params_with_static(options: &[(&str, &str)]) -> ObjectStoreParams {
ObjectStoreParams {
storage_options_accessor: Some(Arc::new(
StorageOptionsAccessor::with_static_options(
options
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
),
)),
..Default::default()
}
}
#[test]
fn creation_keys_are_removed_and_store_keys_kept() {
let mut params = params_with_static(&[
("region", "us-west-2"),
(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS, "true"),
]);
strip_new_table_creation_keys(&mut params);
let options = params.storage_options().cloned().unwrap();
assert_eq!(options.get("region").map(String::as_str), Some("us-west-2"));
assert!(!options.contains_key(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS));
// Creation keys alone: no accessor survives to fork a store.
let mut params = params_with_static(&[(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS, "true")]);
strip_new_table_creation_keys(&mut params);
assert!(params.storage_options_accessor.is_none());
}
/// A provider must survive every shape of strip: untouched accessors
/// keep their identity, emptied ones still fetch, and residual
/// statics ride along.
#[test]
fn provider_accessors_survive_the_strip() {
let accessor = Arc::new(StorageOptionsAccessor::with_provider(Arc::new(
EmptyProvider,
)));
let mut params = ObjectStoreParams {
storage_options_accessor: Some(accessor.clone()),
..Default::default()
};
strip_new_table_creation_keys(&mut params);
assert!(Arc::ptr_eq(
params.storage_options_accessor.as_ref().unwrap(),
&accessor
));
let mut params = ObjectStoreParams {
storage_options_accessor: Some(Arc::new(
StorageOptionsAccessor::with_initial_and_provider(
HashMap::from([
("region".to_string(), "us-west-2".to_string()),
(
OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS.to_string(),
"true".to_string(),
),
]),
Arc::new(EmptyProvider),
),
)),
..Default::default()
};
strip_new_table_creation_keys(&mut params);
let accessor = params.storage_options_accessor.unwrap();
assert!(accessor.has_provider());
assert_eq!(
accessor
.initial_storage_options()
.and_then(|o| o.get("region").cloned())
.as_deref(),
Some("us-west-2")
);
// Emptied entirely: a first-fetch accessor, not one caching {}.
let mut params = ObjectStoreParams {
storage_options_accessor: Some(Arc::new(
StorageOptionsAccessor::with_initial_and_provider(
HashMap::from([(
OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS.to_string(),
"true".to_string(),
)]),
Arc::new(EmptyProvider),
),
)),
..Default::default()
};
strip_new_table_creation_keys(&mut params);
let accessor = params.storage_options_accessor.unwrap();
assert!(accessor.has_provider());
assert!(accessor.initial_storage_options().is_none());
}
}
use super::*;
use crate::Table;
use crate::arrow::{SendableRecordBatchStream, SimpleRecordBatchStream};
+149 -53
View File
@@ -26,10 +26,7 @@ use lance_table::io::commit::external_manifest::ExternalManifestCommitHandler;
use crate::blob::{ensure_blob_storage_version, has_blob_columns};
use crate::connection::NamespaceClientPushdownOperation;
use crate::database::ReadConsistency;
use crate::database::listing::{
NewTableConfig, OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS, OPT_NEW_TABLE_STORAGE_VERSION,
OPT_NEW_TABLE_V2_MANIFEST_PATHS,
};
use crate::database::listing::{NewTableConfig, take_request_creation_overrides};
use crate::database::read_freshness::{
FreshnessBaselines, ReadFreshnessContextProvider, TableFreshness,
};
@@ -197,69 +194,28 @@ impl LanceNamespaceDatabase {
TableFreshness::new(self.freshness_baselines.clone(), key)
}
fn extract_storage_overrides(
&self,
request: &DbCreateTableRequest,
) -> Result<(
Option<lance_file::version::LanceFileVersion>,
Option<bool>,
Option<bool>,
)> {
let storage_options = request
.write_options
.lance_write_params
.as_ref()
.and_then(|p| p.store_params.as_ref())
.and_then(|sp| sp.storage_options());
let storage_version_override = storage_options
.and_then(|opts| opts.get(OPT_NEW_TABLE_STORAGE_VERSION))
.map(|s| s.parse::<lance_file::version::LanceFileVersion>())
.transpose()?;
let v2_manifest_override = storage_options
.and_then(|opts| opts.get(OPT_NEW_TABLE_V2_MANIFEST_PATHS))
.map(|s| s.parse::<bool>())
.transpose()
.map_err(|_| Error::InvalidInput {
message: "enable_v2_manifest_paths must be a boolean".to_string(),
})?;
let stable_row_ids_override = storage_options
.and_then(|opts| opts.get(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS))
.map(|s| s.parse::<bool>())
.transpose()
.map_err(|_| Error::InvalidInput {
message: "enable_stable_row_ids must be a boolean".to_string(),
})?;
Ok((
storage_version_override,
v2_manifest_override,
stable_row_ids_override,
))
}
fn apply_new_table_config(
&self,
params: &mut lance::dataset::WriteParams,
request: &DbCreateTableRequest,
) -> Result<()> {
let (storage_version_override, v2_manifest_override, stable_row_ids_override) =
self.extract_storage_overrides(request)?;
let overrides = take_request_creation_overrides(params)?;
params.data_storage_version = storage_version_override
params.data_storage_version = overrides
.data_storage_version
.or(params.data_storage_version)
.or(self.new_table_config.data_storage_version);
if let Some(enable_v2_manifest_paths) =
v2_manifest_override.or(self.new_table_config.enable_v2_manifest_paths)
if let Some(enable_v2_manifest_paths) = overrides
.enable_v2_manifest_paths
.or(self.new_table_config.enable_v2_manifest_paths)
{
params.enable_v2_manifest_paths = enable_v2_manifest_paths;
}
let data_schema = request.data.schema();
if let Some(enable_stable_row_ids) = stable_row_ids_override
if let Some(enable_stable_row_ids) = overrides
.enable_stable_row_ids
.or(self.new_table_config.enable_stable_row_ids)
.or(has_blob_columns(data_schema.as_ref()).then_some(true))
{
@@ -644,6 +600,146 @@ mod tests {
RecordBatch::try_new(schema, vec![Arc::new(id_array), Arc::new(name_array)]).unwrap()
}
/// The shared parse-and-sanitize boundary is wired into this path: the
/// request-level creation key must act as an override (the strip itself
/// is covered by the listing tests).
#[tokio::test]
async fn request_level_creation_keys_are_taken_as_overrides() {
use crate::database::listing::OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS;
let tmp_dir = tempdir().unwrap();
let mut properties = HashMap::new();
properties.insert(
"root".to_string(),
tmp_dir.path().to_str().unwrap().to_string(),
);
let db = connect_namespace("dir", properties)
.execute()
.await
.unwrap();
let store_params = ObjectStoreParams {
storage_options_accessor: Some(Arc::new(StorageOptionsAccessor::with_static_options(
HashMap::from([(
OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS.to_string(),
"true".to_string(),
)]),
))),
..Default::default()
};
let table = db
.create_table("t", create_test_data())
.write_options(crate::table::WriteOptions {
lance_write_params: Some(lance::dataset::WriteParams {
store_params: Some(store_params),
..Default::default()
}),
})
.execute()
.await
.unwrap();
let native = table.as_native().unwrap();
assert!(
native
.dataset
.get()
.await
.unwrap()
.manifest
.uses_stable_row_ids(),
"the creation key must be honored as an override"
);
let table = db.open_table("t").execute().await.unwrap();
let rows: usize = table
.query()
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap()
.iter()
.map(|b| b.num_rows())
.sum();
assert_eq!(rows, 5);
}
/// Sanitation on this path: apply must strip the creation keys from the
/// store options while genuine options and the provider survive.
#[tokio::test]
async fn apply_new_table_config_sanitizes_request_store_options() {
use crate::database::listing::OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS;
use lance_io::object_store::StorageOptionsProvider;
#[derive(Debug)]
struct EmptyProvider;
#[async_trait::async_trait]
impl StorageOptionsProvider for EmptyProvider {
async fn fetch_storage_options(
&self,
) -> lance_core::Result<Option<HashMap<String, String>>> {
Ok(Some(HashMap::new()))
}
fn provider_id(&self) -> String {
"empty-test-provider".into()
}
}
let tmp_dir = tempdir().unwrap();
let mut properties = HashMap::new();
properties.insert(
"root".to_string(),
tmp_dir.path().to_str().unwrap().to_string(),
);
let db = LanceNamespaceDatabase::connect_with_new_table_config(
"dir",
properties,
HashMap::new(),
None,
None,
HashSet::new(),
NewTableConfig::default(),
)
.await
.unwrap();
let request = DbCreateTableRequest::new("t".to_string(), Box::new(create_test_data()));
let mut params = lance::dataset::WriteParams {
store_params: Some(ObjectStoreParams {
storage_options_accessor: Some(Arc::new(
StorageOptionsAccessor::with_initial_and_provider(
HashMap::from([
("region".to_string(), "us-west-2".to_string()),
(
OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS.to_string(),
"true".to_string(),
),
]),
Arc::new(EmptyProvider),
),
)),
..Default::default()
}),
..Default::default()
};
db.apply_new_table_config(&mut params, &request).unwrap();
assert!(params.enable_stable_row_ids);
let store_params = params.store_params.unwrap();
let options = store_params.storage_options().cloned().unwrap();
assert!(!options.contains_key(OPT_NEW_TABLE_ENABLE_STABLE_ROW_IDS));
assert_eq!(options.get("region").map(String::as_str), Some("us-west-2"));
assert!(
store_params
.storage_options_accessor
.unwrap()
.has_provider()
);
}
#[tokio::test]
async fn test_namespace_connection_simple() {
// Test that namespace connections work with simple connect_namespace(impl_type, properties)
+2
View File
@@ -77,6 +77,8 @@ pub enum Error {
ColumnAlreadyExists { name: String },
#[snafu(display("Column '{name}' is not a computed column"))]
NotAComputedColumn { name: String },
#[snafu(display("Table '{name}' is not a materialized view"))]
NotAMaterializedView { name: String },
#[snafu(display("Invalid expression for column '{column}': {message}"))]
InvalidExpression { column: String, message: String },
+2
View File
@@ -186,6 +186,7 @@ pub mod index;
pub mod io;
pub mod ipc;
pub mod job;
pub mod materialized_view;
#[cfg(feature = "metrics-otel")]
pub mod metrics_otel;
#[cfg(feature = "polars")]
@@ -210,6 +211,7 @@ pub use function::FunctionVersion;
pub use job::Job;
use lance_index::vector::ApproxMode as LanceApproxMode;
use lance_linalg::distance::DistanceType as LanceDistanceType;
pub use materialized_view::{MaterializedView, MaterializedViewDefinition};
/// Re-export of the [`metrics`](https://docs.rs/metrics) crate facade. Enable
/// the `metrics` feature to publish LanceDB's internal metrics; install any
/// `metrics`-compatible recorder to collect them. See also [`metrics_otel`] for
File diff suppressed because it is too large Load Diff
+14
View File
@@ -10410,6 +10410,20 @@ mod tests {
);
}
#[tokio::test]
async fn test_materialized_view_refused_without_a_request() {
// Materialized views are local-only. The table-level entry the
// bindings use must refuse a remote table before reading its schema,
// so the panicking handler is the assertion.
let table = Table::new_with_handler("my_table", |request| -> http::Response<String> {
panic!("unexpected request: {}", request.url().path())
});
let err = crate::MaterializedView::from_table(table)
.await
.unwrap_err();
assert!(matches!(err, Error::NotSupported { .. }), "got {err:?}");
}
#[tokio::test]
async fn test_create_branch_empty_name_rejected_client_side() {
use lance::dataset::refs::Ref;
+5
View File
@@ -1066,6 +1066,11 @@ impl Table {
self.database.as_ref().unwrap()
}
/// The database this handle was opened through, when it was.
pub fn database_opt(&self) -> Option<&Arc<dyn Database>> {
self.database.as_ref()
}
pub fn embedding_registry(&self) -> &Arc<dyn EmbeddingRegistry> {
&self.embedding_registry
}
+1 -1
View File
@@ -193,7 +193,7 @@ fn declared_expression(dataset: &Dataset, column: &str) -> Result<String> {
///
/// Lance's dialect delimits with backticks, so a double-quoted name would
/// parse as a string literal rather than a column.
fn quote_identifier(name: &str) -> String {
pub(crate) fn quote_identifier(name: &str) -> String {
format!("`{}`", name.replace('`', "``"))
}