mirror of
https://github.com/lancedb/lancedb.git
synced 2026-09-30 00:45:37 +00:00
feat: group a materialized view by GROUP BY (#4222)
A view could only map source rows one to one, or one to many through a FROM-position item, so nothing could see all the rows sharing a key. A view query now takes `GROUP BY expr, ...` with aggregate projections, planned and executed by DataFusion over the lance scan. A group spans fragments, so a grouped view is recomputed in full whenever its source changes; each row's provenance is its group's smallest source row id. Such a query is stored as definition format 2, so a reader that predates grouping reports the view as unrefreshable instead of failing to parse it. Stacked on #4190.
This commit is contained in:
@@ -8,8 +8,8 @@
|
||||
|
||||
The query that defines a materialized view, as stored:
|
||||
`SELECT columns FROM [ns.]table [, function(args) AS alias | , UNNEST(column) AS alias]
|
||||
[WHERE predicate] [LIMIT n]`. A Function in `FROM` position yields one row
|
||||
per element it returns.
|
||||
[WHERE predicate] [GROUP BY expr, ...] [LIMIT n]`. A Function in `FROM`
|
||||
position yields one row per element it returns.
|
||||
|
||||
## Properties
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("materialized views", () => {
|
||||
|
||||
// A newer writer's layout is reported, never guessed at.
|
||||
for (const newer of [
|
||||
`{"format":2,"query":${JSON.stringify(query)}}`,
|
||||
`{"format":3,"query":${JSON.stringify(query)}}`,
|
||||
'{"kind":"select_v3","source_table":"people"}',
|
||||
]) {
|
||||
expect(() => read(newer)).toThrow(/cannot refresh/);
|
||||
|
||||
@@ -7,14 +7,17 @@ import { Table } from "./table";
|
||||
/** Schema metadata key holding a materialized view's definition. */
|
||||
export const DEFINITION_META_KEY = "mv.definition";
|
||||
|
||||
/** The stored layout this version reads: `{"format": 1, "query": "<SQL>"}`. */
|
||||
export const DEFINITION_FORMAT = 1;
|
||||
/**
|
||||
* The newest stored layout this version reads: `{"format": N, "query": "<SQL>"}`,
|
||||
* format 2 being a query with `GROUP BY`.
|
||||
*/
|
||||
export const DEFINITION_FORMAT = 2;
|
||||
|
||||
/**
|
||||
* The query that defines a materialized view, as stored:
|
||||
* `SELECT columns FROM [ns.]table [, function(args) AS alias | , UNNEST(column) AS alias]
|
||||
* [WHERE predicate] [LIMIT n]`. A Function in `FROM` position yields one row
|
||||
* per element it returns.
|
||||
* [WHERE predicate] [GROUP BY expr, ...] [LIMIT n]`. A Function in `FROM`
|
||||
* position yields one row per element it returns.
|
||||
*/
|
||||
export interface MaterializedViewDefinition {
|
||||
/** The defining query, in the canonical spelling the server stores. */
|
||||
|
||||
@@ -29,9 +29,10 @@ SelectArg = Union[
|
||||
]
|
||||
|
||||
|
||||
DEFINITION_FORMAT = 1
|
||||
"""The stored layout this version reads: ``{"format": 1, "query": "<SQL>"}``.
|
||||
A ``kind`` key beside it is for readers older than the format number."""
|
||||
DEFINITION_FORMAT = 2
|
||||
"""The newest stored layout this version reads: ``{"format": N, "query": "<SQL>"}``,
|
||||
format 2 being a query with ``GROUP BY``. A ``kind`` key beside it is for
|
||||
readers older than the format number."""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -40,7 +41,7 @@ class MaterializedViewDefinition:
|
||||
|
||||
SELECT columns
|
||||
FROM [ns.]table [, function(args) AS alias | , UNNEST(column) AS alias]
|
||||
[WHERE predicate] [LIMIT n]
|
||||
[WHERE predicate] [GROUP BY expr, ...] [LIMIT n]
|
||||
|
||||
A Function in ``FROM`` position yields one row per element it returns.
|
||||
"""
|
||||
|
||||
@@ -522,7 +522,7 @@ def test_stored_queries_and_legacy_layouts_are_read():
|
||||
|
||||
# A newer writer's layout is reported, never guessed at.
|
||||
for newer in (
|
||||
{"format": 2, "query": query},
|
||||
{"format": 3, "query": query},
|
||||
{"kind": "select_v3", "source_table": "people"},
|
||||
):
|
||||
with pytest.raises(NotImplementedError, match="cannot refresh"):
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! query this version cannot maintain reads back as unrefreshable, not as a
|
||||
//! plain table. Queries, indexes and search work on the view unchanged.
|
||||
|
||||
mod grouped;
|
||||
mod query;
|
||||
pub mod refresh;
|
||||
|
||||
@@ -79,13 +80,21 @@ const EMBEDDING_FUNCTIONS_META_KEY: &str = "embedding_functions";
|
||||
/// produces, which is what lets a query embed its own text.
|
||||
const COLUMN_DEFINITIONS_META_KEY: &str = "lancedb::column_definitions";
|
||||
|
||||
/// The layout this version writes under [`DEFINITION_META_KEY`]:
|
||||
/// `{"format": 1, "query": "<SQL>"}`, the query as
|
||||
/// [`MaterializedViewDefinition::to_sql`] renders it. A reader refuses a
|
||||
/// newer format rather than guess at it. The layout also carries
|
||||
/// `"kind": "query"`, which readers older than the format number report
|
||||
/// as an unrefreshable view instead of failing to read the metadata.
|
||||
pub const DEFINITION_FORMAT: u64 = 1;
|
||||
/// The newest layout this version reads under [`DEFINITION_META_KEY`]:
|
||||
/// `{"format": N, "query": "<SQL>"}`, the query as
|
||||
/// [`MaterializedViewDefinition::to_sql`] renders it. Format 2 is a query
|
||||
/// with `GROUP BY`; every other query is written as format 1, so a reader
|
||||
/// that predates grouping reports a grouped view as unrefreshable rather
|
||||
/// than failing to parse it. A reader refuses a newer format rather than
|
||||
/// guess at it. The layout also carries `"kind": "query"`, which readers
|
||||
/// older than the format number report as an unrefreshable view instead of
|
||||
/// failing to read the metadata.
|
||||
pub const DEFINITION_FORMAT: u64 = 2;
|
||||
|
||||
/// The format `definition` is written in; see [`DEFINITION_FORMAT`].
|
||||
fn format_of(definition: &MaterializedViewDefinition) -> u64 {
|
||||
if definition.is_grouped() { 2 } else { 1 }
|
||||
}
|
||||
|
||||
/// Legacy `kind` tag of the structured layout written before
|
||||
/// [`DEFINITION_FORMAT`] existed; still read, never written.
|
||||
@@ -240,6 +249,9 @@ pub struct MaterializedViewDefinition {
|
||||
pub projections: Vec<ViewProjection>,
|
||||
/// SQL predicate selecting the rows the view holds.
|
||||
pub filter: Option<String>,
|
||||
/// `GROUP BY` expressions. A grouped view holds one row per group and is
|
||||
/// recomputed in full whenever its source changes.
|
||||
pub group_by: Vec<String>,
|
||||
/// Cap on the number of rows the view holds, in materialization order.
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
@@ -250,7 +262,7 @@ impl MaterializedViewDefinition {
|
||||
/// ```sql
|
||||
/// SELECT <column | expr AS name | *>, ...
|
||||
/// FROM [ns.]table [, function(args) AS alias | , UNNEST(column) AS alias]
|
||||
/// [WHERE predicate] [LIMIT n]
|
||||
/// [WHERE predicate] [GROUP BY expr, ...] [LIMIT n]
|
||||
/// ```
|
||||
///
|
||||
/// A Function in `FROM` position yields one row per element it returns;
|
||||
@@ -283,6 +295,11 @@ impl MaterializedViewDefinition {
|
||||
definition_to_metadata(self)
|
||||
}
|
||||
|
||||
/// Whether the query has a `GROUP BY`.
|
||||
pub fn is_grouped(&self) -> bool {
|
||||
!self.group_by.is_empty()
|
||||
}
|
||||
|
||||
/// Whether the query is `SELECT *`.
|
||||
pub fn selects_star(&self) -> bool {
|
||||
matches!(self.projections.as_slice(), [p] if *p == ViewProjection::star())
|
||||
@@ -359,7 +376,7 @@ struct LegacyDefinition {
|
||||
pub(crate) fn definition_to_metadata(definition: &MaterializedViewDefinition) -> Result<String> {
|
||||
Ok(serde_json::json!({
|
||||
"kind": "query",
|
||||
"format": DEFINITION_FORMAT,
|
||||
"format": format_of(definition),
|
||||
"query": definition.to_sql(),
|
||||
})
|
||||
.to_string())
|
||||
@@ -389,6 +406,13 @@ pub fn read_definition(metadata: &HashMap<String, String>) -> Result<Option<Stor
|
||||
return Err(unreadable(&"missing query"));
|
||||
};
|
||||
let definition = MaterializedViewDefinition::from_sql(sql).map_err(|e| unreadable(&e))?;
|
||||
// No correct writer tags a query below the format its shape needs.
|
||||
if format < format_of(&definition) {
|
||||
return Err(unreadable(&format!(
|
||||
"format {format} cannot carry this query; it needs {}",
|
||||
format_of(&definition)
|
||||
)));
|
||||
}
|
||||
return Ok(Some(StoredDefinition::Query(definition)));
|
||||
}
|
||||
let kind = value
|
||||
@@ -415,6 +439,7 @@ pub fn read_definition(metadata: &HashMap<String, String>) -> Result<Option<Stor
|
||||
lateral: None,
|
||||
projections: legacy.projections,
|
||||
filter: legacy.filter,
|
||||
group_by: Vec::new(),
|
||||
limit: legacy.limit,
|
||||
})))
|
||||
}
|
||||
@@ -476,6 +501,9 @@ pub(crate) fn plan(
|
||||
},
|
||||
err => err,
|
||||
})?;
|
||||
if definition.is_grouped() {
|
||||
return grouped::plan(source_schema, definition, filter);
|
||||
}
|
||||
// Projections are typed against the source, or for an unnested view
|
||||
// against the source with the list column replaced by its element
|
||||
// under the alias, where `c.chunk` is an ordinary nested path.
|
||||
@@ -616,34 +644,7 @@ pub(crate) fn plan(
|
||||
}
|
||||
|
||||
if let Some(filter) = filter.as_deref() {
|
||||
let expr = planner
|
||||
.parse_filter(filter)
|
||||
.map_err(|e| Error::InvalidInput {
|
||||
message: format!("invalid view filter: {e}"),
|
||||
})?;
|
||||
ensure_immutable(&expr, |message| Error::InvalidInput {
|
||||
message: format!("invalid view filter: {message}"),
|
||||
})?;
|
||||
let filter_inputs = resolve_inputs(&source_schema, &expr, |message| Error::InvalidInput {
|
||||
message: format!("invalid view filter: {message}"),
|
||||
})?;
|
||||
// A committed filter has to be usable as a predicate.
|
||||
let read_schema = project_schema(&source_schema, &filter_inputs);
|
||||
let data_type = Planner::new(read_schema.clone())
|
||||
.create_physical_expr(&expr)
|
||||
.map_err(|e| Error::InvalidInput {
|
||||
message: format!("invalid view filter: {e}"),
|
||||
})?
|
||||
.data_type(read_schema.as_ref())
|
||||
.map_err(|e| Error::InvalidInput {
|
||||
message: format!("invalid view filter: {e}"),
|
||||
})?;
|
||||
if data_type != DataType::Boolean {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!("view filter must be a boolean predicate, not {data_type}"),
|
||||
});
|
||||
}
|
||||
inputs.extend(filter_inputs);
|
||||
inputs.extend(plan_filter(&source_schema, filter)?);
|
||||
}
|
||||
|
||||
let definition = MaterializedViewDefinition {
|
||||
@@ -655,6 +656,7 @@ pub(crate) fn plan(
|
||||
.map(|(output, expression)| ViewProjection { output, expression })
|
||||
.collect(),
|
||||
filter,
|
||||
group_by: Vec::new(),
|
||||
limit,
|
||||
};
|
||||
let mut inputs: Vec<String> = inputs
|
||||
@@ -671,6 +673,39 @@ pub(crate) fn plan(
|
||||
})
|
||||
}
|
||||
|
||||
/// Check that `filter` is an immutable boolean predicate over `source_schema`,
|
||||
/// returning the columns it reads.
|
||||
pub(crate) fn plan_filter(source_schema: &SchemaRef, filter: &str) -> Result<Vec<String>> {
|
||||
let expr = Planner::new(source_schema.clone())
|
||||
.parse_filter(filter)
|
||||
.map_err(|e| Error::InvalidInput {
|
||||
message: format!("invalid view filter: {e}"),
|
||||
})?;
|
||||
ensure_immutable(&expr, |message| Error::InvalidInput {
|
||||
message: format!("invalid view filter: {message}"),
|
||||
})?;
|
||||
let filter_inputs = resolve_inputs(source_schema, &expr, |message| Error::InvalidInput {
|
||||
message: format!("invalid view filter: {message}"),
|
||||
})?;
|
||||
// A committed filter has to be usable as a predicate.
|
||||
let read_schema = project_schema(source_schema, &filter_inputs);
|
||||
let data_type = Planner::new(read_schema.clone())
|
||||
.create_physical_expr(&expr)
|
||||
.map_err(|e| Error::InvalidInput {
|
||||
message: format!("invalid view filter: {e}"),
|
||||
})?
|
||||
.data_type(read_schema.as_ref())
|
||||
.map_err(|e| Error::InvalidInput {
|
||||
message: format!("invalid view filter: {e}"),
|
||||
})?;
|
||||
if data_type != DataType::Boolean {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!("view filter must be a boolean predicate, not {data_type}"),
|
||||
});
|
||||
}
|
||||
Ok(filter_inputs)
|
||||
}
|
||||
|
||||
/// The schema a projection over an unnested view is planned against: the
|
||||
/// source's, with the list column replaced by its element type under the
|
||||
/// alias.
|
||||
@@ -1046,6 +1081,15 @@ impl PreparedDeclaration {
|
||||
/// read: the column the view projects it to, if any, otherwise an
|
||||
/// internal projection added here, named by [`input_column_name`].
|
||||
pub fn input_column(&mut self, source_column: &str) -> Result<String> {
|
||||
// A grouped view's rows are groups; no source row carries a value into one.
|
||||
if self.definition.is_grouped() {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!(
|
||||
"a computed column on a grouped view cannot read source column \
|
||||
'{source_column}'; declare it on a view over the grouped view"
|
||||
),
|
||||
});
|
||||
}
|
||||
if let Some(output) = self.lineage.get(source_column).and_then(|o| o.first()) {
|
||||
return Ok(output.clone());
|
||||
}
|
||||
@@ -1357,6 +1401,7 @@ pub async fn prepare_declaration(
|
||||
.collect(),
|
||||
},
|
||||
filter: filter.map(str::to_string),
|
||||
group_by: Vec::new(),
|
||||
limit,
|
||||
};
|
||||
prepare_definition(source, definition).await
|
||||
@@ -1383,6 +1428,26 @@ pub async fn prepare_declaration(
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// A grouped view holds one row per group and is recomputed in full on
|
||||
/// every refresh after a source change:
|
||||
///
|
||||
/// ```
|
||||
/// # #![recursion_limit = "256"]
|
||||
/// use lancedb::materialized_view::{MaterializedViewDefinition, prepare_definition};
|
||||
///
|
||||
/// # async fn declare(events: &lancedb::Table) -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let definition = MaterializedViewDefinition::from_sql(
|
||||
/// "SELECT kind, count(*) AS n, array_agg(id) AS ids FROM events GROUP BY kind",
|
||||
/// )?;
|
||||
/// let view = prepare_definition(events, definition)
|
||||
/// .await?
|
||||
/// .create("events_by_kind")
|
||||
/// .await?;
|
||||
/// view.refresh().execute().await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn prepare_definition(
|
||||
source: &Table,
|
||||
definition: MaterializedViewDefinition,
|
||||
@@ -2067,6 +2132,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
filter: Some("age >= 18".into()),
|
||||
group_by: Vec::new(),
|
||||
limit: Some(10),
|
||||
lateral: None,
|
||||
}
|
||||
@@ -3110,6 +3176,7 @@ mod tests {
|
||||
expression: "name".to_string(),
|
||||
}],
|
||||
filter: None,
|
||||
group_by: Vec::new(),
|
||||
limit: None,
|
||||
}
|
||||
}
|
||||
@@ -3187,8 +3254,8 @@ mod tests {
|
||||
#[test]
|
||||
fn a_newer_format_is_reported_not_guessed() {
|
||||
assert_eq!(
|
||||
read(r#"{"format":2,"query":"SELECT name FROM people"}"#).unwrap(),
|
||||
Some(StoredDefinition::Newer { format: "2".into() })
|
||||
read(r#"{"format":3,"query":"SELECT name FROM people"}"#).unwrap(),
|
||||
Some(StoredDefinition::Newer { format: "3".into() })
|
||||
);
|
||||
assert_eq!(
|
||||
read(r#"{"kind":"join"}"#).unwrap(),
|
||||
@@ -3201,6 +3268,7 @@ mod tests {
|
||||
r#"{"format":"one"}"#,
|
||||
r#"{"format":1}"#,
|
||||
r#"{"format":1,"query":"SELECT name FROM people GROUP BY name"}"#,
|
||||
r#"{"format":2,"query":"SELECT name FROM people ORDER BY name"}"#,
|
||||
] {
|
||||
assert!(read(stored).is_err(), "{stored}");
|
||||
}
|
||||
@@ -3399,7 +3467,7 @@ mod tests {
|
||||
// The stored definition is the query alone; bindings live beside it.
|
||||
let stored: serde_json::Value =
|
||||
serde_json::from_str(&schema.metadata()[DEFINITION_META_KEY]).unwrap();
|
||||
assert_eq!(stored["format"], DEFINITION_FORMAT);
|
||||
assert_eq!(stored["format"], 1);
|
||||
assert_eq!(view.definition().projections.len(), 2);
|
||||
assert_eq!(view.table().count_rows(None).await.unwrap(), 0);
|
||||
assert_eq!(conn.open_materialized_view("v").await.unwrap().name(), "v");
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Views with `GROUP BY`: one row per group, planned and executed by
|
||||
//! DataFusion, and recomputed in full whenever the source changes, since a
|
||||
//! group spans fragments.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema, SchemaRef};
|
||||
use datafusion::catalog::default_table_source::provider_as_source;
|
||||
use datafusion::datasource::empty::EmptyTable;
|
||||
use datafusion::execution::SessionStateBuilder;
|
||||
use datafusion::execution::context::SessionState;
|
||||
use datafusion::physical_plan::SendableRecordBatchStream;
|
||||
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
|
||||
use datafusion::prelude::SessionContext;
|
||||
use datafusion_common::TableReference;
|
||||
use datafusion_common::config::ConfigOptions;
|
||||
use datafusion_common::tree_node::{TreeNode, TreeNodeRecursion};
|
||||
use datafusion_expr::planner::{ContextProvider, ExprPlanner};
|
||||
use datafusion_expr::{
|
||||
AggregateUDF, HigherOrderUDF, LogicalPlan, ScalarUDF, TableSource, WindowUDF,
|
||||
};
|
||||
use datafusion_sql::planner::SqlToRel;
|
||||
use datafusion_sql::sqlparser::dialect::GenericDialect;
|
||||
use datafusion_sql::sqlparser::parser::Parser;
|
||||
use futures::StreamExt;
|
||||
use lance::Dataset;
|
||||
use lance_core::ROW_ID;
|
||||
use lance_datafusion::exec::SessionContextExt;
|
||||
|
||||
use super::refresh::to_view_batch;
|
||||
use super::{
|
||||
MaterializedViewDefinition, Planned, SOURCE_ROW_ID_COLUMN, ViewProjection, ensure_immutable,
|
||||
plan_filter, query, without_declarations,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The name the source is planned under; never visible to the user.
|
||||
const SOURCE: &str = "__source";
|
||||
|
||||
fn session() -> SessionState {
|
||||
SessionStateBuilder::new().with_default_features().build()
|
||||
}
|
||||
|
||||
/// The query DataFusion runs: the view's projections plus the group's
|
||||
/// smallest source row id, which stands as the row's provenance. The filter
|
||||
/// is not here; the lance scan applies it, as for any other view.
|
||||
fn sql(definition: &MaterializedViewDefinition) -> String {
|
||||
let items: Vec<String> = definition
|
||||
.projections
|
||||
.iter()
|
||||
.map(|p| format!("{} AS {}", p.expression, query::ident_sql(&p.output)))
|
||||
.collect();
|
||||
format!(
|
||||
"SELECT {}, min({ROW_ID}) AS {ROW_ID} FROM {SOURCE} GROUP BY {}",
|
||||
items.join(", "),
|
||||
definition.group_by.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
struct Provider<'a> {
|
||||
state: &'a SessionState,
|
||||
source: Arc<dyn TableSource>,
|
||||
}
|
||||
|
||||
impl ContextProvider for Provider<'_> {
|
||||
fn get_table_source(
|
||||
&self,
|
||||
name: TableReference,
|
||||
) -> datafusion_common::Result<Arc<dyn TableSource>> {
|
||||
if name.schema().is_none() && name.table() == SOURCE {
|
||||
Ok(self.source.clone())
|
||||
} else {
|
||||
datafusion_common::plan_err!("a grouped view reads only its source, not '{name}'")
|
||||
}
|
||||
}
|
||||
fn get_expr_planners(&self) -> &[Arc<dyn ExprPlanner>] {
|
||||
self.state.expr_planners()
|
||||
}
|
||||
fn get_function_meta(&self, name: &str) -> Option<Arc<ScalarUDF>> {
|
||||
self.state.scalar_functions().get(name).cloned()
|
||||
}
|
||||
fn get_higher_order_meta(&self, name: &str) -> Option<Arc<HigherOrderUDF>> {
|
||||
self.state.higher_order_functions().get(name).cloned()
|
||||
}
|
||||
fn get_aggregate_meta(&self, name: &str) -> Option<Arc<AggregateUDF>> {
|
||||
self.state.aggregate_functions().get(name).cloned()
|
||||
}
|
||||
fn get_window_meta(&self, name: &str) -> Option<Arc<WindowUDF>> {
|
||||
self.state.window_functions().get(name).cloned()
|
||||
}
|
||||
fn get_variable_type(&self, _: &[String]) -> Option<DataType> {
|
||||
None
|
||||
}
|
||||
fn options(&self) -> &ConfigOptions {
|
||||
self.state.config_options()
|
||||
}
|
||||
fn udf_names(&self) -> Vec<String> {
|
||||
self.state.scalar_functions().keys().cloned().collect()
|
||||
}
|
||||
fn higher_order_function_names(&self) -> Vec<String> {
|
||||
self.state
|
||||
.higher_order_functions()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
fn udaf_names(&self) -> Vec<String> {
|
||||
self.state.aggregate_functions().keys().cloned().collect()
|
||||
}
|
||||
fn udwf_names(&self) -> Vec<String> {
|
||||
self.state.window_functions().keys().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn logical_plan(
|
||||
state: &SessionState,
|
||||
source: Arc<dyn TableSource>,
|
||||
definition: &MaterializedViewDefinition,
|
||||
) -> Result<LogicalPlan> {
|
||||
let invalid = |e: &dyn std::fmt::Display| Error::InvalidInput {
|
||||
message: format!("invalid grouped view: {e}"),
|
||||
};
|
||||
let statement = Parser::parse_sql(&GenericDialect {}, &sql(definition))
|
||||
.map_err(|e| invalid(&e))?
|
||||
.pop()
|
||||
.ok_or_else(|| invalid(&"empty query"))?;
|
||||
SqlToRel::new(&Provider { state, source })
|
||||
.sql_statement_to_plan(statement)
|
||||
.map_err(|e| invalid(&e))
|
||||
}
|
||||
|
||||
/// Plan a grouped definition against the source schema. `filter` is the
|
||||
/// canonical filter, already spelled the way [`super::plan`] stores it.
|
||||
pub(super) fn plan(
|
||||
source_schema: SchemaRef,
|
||||
definition: &MaterializedViewDefinition,
|
||||
filter: Option<String>,
|
||||
) -> Result<Planned> {
|
||||
query::check_grouping(definition)?;
|
||||
let mut inputs = match &filter {
|
||||
Some(filter) => plan_filter(&source_schema, filter)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut projections: Vec<ViewProjection> = Vec::with_capacity(definition.projections.len());
|
||||
for p in &definition.projections {
|
||||
if p.output == SOURCE_ROW_ID_COLUMN || p.output == ROW_ID {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!("view column name '{}' is reserved", p.output),
|
||||
});
|
||||
}
|
||||
if projections.iter().any(|seen| seen.output == p.output) {
|
||||
return Err(Error::ColumnAlreadyExists {
|
||||
name: p.output.clone(),
|
||||
});
|
||||
}
|
||||
let expression =
|
||||
query::canonical_expr(&p.expression).map_err(|e| Error::InvalidExpression {
|
||||
column: p.output.clone(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
projections.push(ViewProjection {
|
||||
output: p.output.clone(),
|
||||
expression,
|
||||
});
|
||||
}
|
||||
let group_by = definition
|
||||
.group_by
|
||||
.iter()
|
||||
.map(|key| query::canonical_expr(key))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let definition = MaterializedViewDefinition {
|
||||
projections,
|
||||
filter,
|
||||
group_by,
|
||||
..definition.clone()
|
||||
};
|
||||
|
||||
let mut fields = source_schema.fields().to_vec();
|
||||
fields.push(Arc::new(ArrowField::new(ROW_ID, DataType::UInt64, false)));
|
||||
let empty = provider_as_source(Arc::new(EmptyTable::new(Arc::new(ArrowSchema::new(
|
||||
fields,
|
||||
)))));
|
||||
let planned = logical_plan(&session(), empty, &definition)?;
|
||||
|
||||
let mut exprs = Vec::new();
|
||||
planned
|
||||
.apply(|node| {
|
||||
exprs.extend(node.expressions());
|
||||
Ok(TreeNodeRecursion::Continue)
|
||||
})
|
||||
.map_err(|e| Error::InvalidInput {
|
||||
message: format!("invalid grouped view: {e}"),
|
||||
})?;
|
||||
for expr in &exprs {
|
||||
ensure_immutable(expr, |message| Error::InvalidInput {
|
||||
message: format!("invalid grouped view: {message}"),
|
||||
})?;
|
||||
inputs.extend(
|
||||
expr.column_refs()
|
||||
.into_iter()
|
||||
.map(|c| c.name.clone())
|
||||
.filter(|name| name != ROW_ID && source_schema.field_with_name(name).is_ok()),
|
||||
);
|
||||
}
|
||||
inputs.sort();
|
||||
inputs.dedup();
|
||||
|
||||
let fields = planned
|
||||
.schema()
|
||||
.fields()
|
||||
.iter()
|
||||
.filter(|f| f.name() != ROW_ID)
|
||||
.map(|f| without_declarations(f))
|
||||
.collect();
|
||||
Ok(Planned {
|
||||
definition,
|
||||
fields,
|
||||
lineage: HashMap::new(),
|
||||
inputs,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every group of `source`, as view rows in `schema`.
|
||||
pub(super) async fn stream(
|
||||
source: &Dataset,
|
||||
definition: &MaterializedViewDefinition,
|
||||
inputs: &[String],
|
||||
schema: SchemaRef,
|
||||
rows_written: Arc<AtomicU64>,
|
||||
) -> Result<SendableRecordBatchStream> {
|
||||
let mut scanner = source.scan();
|
||||
scanner.with_row_id();
|
||||
if let Some(filter) = &definition.filter {
|
||||
scanner.filter(filter)?;
|
||||
}
|
||||
scanner.project(inputs)?;
|
||||
let scan: SendableRecordBatchStream = scanner.try_into_stream().await?.into();
|
||||
|
||||
let state = session();
|
||||
let ctx = SessionContext::new_with_state(state.clone());
|
||||
let source = ctx.read_one_shot(scan)?.into_view();
|
||||
let planned = logical_plan(&state, provider_as_source(source), definition)?;
|
||||
let groups = ctx
|
||||
.execute_logical_plan(planned)
|
||||
.await?
|
||||
.execute_stream()
|
||||
.await?;
|
||||
|
||||
let out_schema = schema.clone();
|
||||
let mapped = groups.map(move |batch| {
|
||||
let batch = to_view_batch(&batch?, &out_schema)?;
|
||||
rows_written.fetch_add(batch.num_rows() as u64, Ordering::Relaxed);
|
||||
Ok(batch)
|
||||
});
|
||||
Ok(Box::pin(RecordBatchStreamAdapter::new(schema, mapped)))
|
||||
}
|
||||
@@ -18,8 +18,9 @@
|
||||
//! query: fail closed, never materialize it at the wrong cardinality.
|
||||
|
||||
use datafusion_sql::sqlparser::ast::{
|
||||
Expr, FunctionArg, FunctionArgExpr, JoinOperator, LimitClause, ObjectName, ObjectNamePart,
|
||||
Query, SelectItem, SetExpr, Statement, TableFactor, TableFunctionArgs, TableWithJoins, Value,
|
||||
Expr, FunctionArg, FunctionArgExpr, GroupByExpr, JoinOperator, LimitClause, ObjectName,
|
||||
ObjectNamePart, Query, SelectItem, SetExpr, Statement, TableFactor, TableFunctionArgs,
|
||||
TableWithJoins, Value,
|
||||
};
|
||||
use datafusion_sql::sqlparser::dialect::GenericDialect;
|
||||
use datafusion_sql::sqlparser::keywords::{
|
||||
@@ -40,7 +41,8 @@ fn invalid(message: impl Into<String>) -> Error {
|
||||
}
|
||||
|
||||
const SHAPE: &str = "a materialized view is defined by `SELECT columns FROM table \
|
||||
[, function(args) AS alias | , UNNEST(column) AS alias] [WHERE predicate] [LIMIT n]`";
|
||||
[, function(args) AS alias | , UNNEST(column) AS alias] [WHERE predicate] \
|
||||
[GROUP BY expr, ...] [LIMIT n]`";
|
||||
|
||||
/// Whether `name` must be delimited to read back as this identifier: bare,
|
||||
/// the parser would take it as a keyword, or a different spelling.
|
||||
@@ -394,14 +396,59 @@ fn extract(query: &Query) -> Result<MaterializedViewDefinition> {
|
||||
Some(_) => return Err(invalid("view limit must be a plain `LIMIT n`")),
|
||||
};
|
||||
|
||||
Ok(MaterializedViewDefinition {
|
||||
if select.having.is_some() {
|
||||
return Err(invalid("HAVING is not supported in a view query"));
|
||||
}
|
||||
let group_by = match &select.group_by {
|
||||
GroupByExpr::Expressions(exprs, modifiers)
|
||||
if modifiers.is_empty()
|
||||
&& !exprs.iter().any(|e| {
|
||||
matches!(e, Expr::Rollup(_) | Expr::Cube(_) | Expr::GroupingSets(_))
|
||||
}) =>
|
||||
{
|
||||
exprs.iter().map(|e| e.to_string()).collect()
|
||||
}
|
||||
_ => {
|
||||
return Err(invalid(
|
||||
"a view groups by a plain `GROUP BY expr, ...` list",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let definition = MaterializedViewDefinition {
|
||||
source_table,
|
||||
source_namespace,
|
||||
lateral,
|
||||
projections,
|
||||
filter: select.selection.as_ref().map(|e| e.to_string()),
|
||||
group_by,
|
||||
limit,
|
||||
})
|
||||
};
|
||||
check_grouping(&definition)?;
|
||||
Ok(definition)
|
||||
}
|
||||
|
||||
/// The clauses a grouped view cannot combine with: each would have to be
|
||||
/// maintained per group rather than per source row.
|
||||
pub fn check_grouping(definition: &MaterializedViewDefinition) -> Result<()> {
|
||||
if !definition.is_grouped() {
|
||||
return Ok(());
|
||||
}
|
||||
if definition.lateral.is_some() {
|
||||
return Err(invalid(
|
||||
"GROUP BY cannot be combined with a FROM-position item; group in one view \
|
||||
and expand in a view over it",
|
||||
));
|
||||
}
|
||||
if definition.selects_star() {
|
||||
return Err(invalid(
|
||||
"a grouped view selects its keys and aggregates, not `*`",
|
||||
));
|
||||
}
|
||||
if definition.limit.is_some() {
|
||||
return Err(invalid("LIMIT is not supported together with GROUP BY"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The canonical query for `definition`; [`parse`] reads it back equal.
|
||||
@@ -451,6 +498,9 @@ pub fn render(definition: &MaterializedViewDefinition) -> String {
|
||||
if let Some(filter) = &definition.filter {
|
||||
sql.push_str(&format!(" WHERE {filter}"));
|
||||
}
|
||||
if definition.is_grouped() {
|
||||
sql.push_str(&format!(" GROUP BY {}", definition.group_by.join(", ")));
|
||||
}
|
||||
if let Some(limit) = definition.limit {
|
||||
sql.push_str(&format!(" LIMIT {limit}"));
|
||||
}
|
||||
@@ -489,6 +539,10 @@ mod tests {
|
||||
"SELECT id, c.text FROM docs, chunk(upper(body)) AS c",
|
||||
),
|
||||
("SELECT * FROM `select`.t", "SELECT * FROM `select`.t"),
|
||||
(
|
||||
"select k, array_agg(id) as ids, count(*) n from t where x > 1 group by k",
|
||||
"SELECT k, array_agg(id) AS ids, count(*) AS n FROM t WHERE x > 1 GROUP BY k",
|
||||
),
|
||||
(
|
||||
"SELECT meta.title AS title, id AS id FROM t",
|
||||
"SELECT meta.title, id FROM t",
|
||||
@@ -547,7 +601,6 @@ mod tests {
|
||||
#[test]
|
||||
fn unsupported_clauses_are_refused() {
|
||||
for sql in [
|
||||
"SELECT id FROM t GROUP BY id",
|
||||
"SELECT id FROM t ORDER BY id",
|
||||
"SELECT DISTINCT id FROM t",
|
||||
"SELECT id FROM t LIMIT 5 OFFSET 2",
|
||||
@@ -567,6 +620,12 @@ mod tests {
|
||||
"SELECT *, id FROM t",
|
||||
"WITH q AS (SELECT 1) SELECT id FROM t",
|
||||
"SELECT id FROM t HAVING id > 1",
|
||||
"SELECT k, count(*) AS n FROM t GROUP BY k HAVING count(*) > 1",
|
||||
"SELECT k FROM t GROUP BY ALL",
|
||||
"SELECT k FROM t GROUP BY ROLLUP (k)",
|
||||
"SELECT * FROM t GROUP BY k",
|
||||
"SELECT k FROM t GROUP BY k LIMIT 5",
|
||||
"SELECT k, c.x FROM t, UNNEST(xs) AS c GROUP BY k",
|
||||
] {
|
||||
assert!(parse(sql).is_err(), "{sql}");
|
||||
}
|
||||
|
||||
@@ -297,6 +297,22 @@ pub(crate) async fn execute_refresh(
|
||||
});
|
||||
}
|
||||
|
||||
// A group spans fragments: there is no per-fragment increment.
|
||||
if definition.is_grouped() {
|
||||
return rebuild(
|
||||
view_native,
|
||||
&view_ds,
|
||||
&source_ds,
|
||||
source_version,
|
||||
source_ts,
|
||||
definition,
|
||||
&inputs,
|
||||
None,
|
||||
persist.is_some(),
|
||||
expected_incarnation,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let watermark = watermark.filter(|_| view_intact);
|
||||
match plan_increment(
|
||||
&source_ds,
|
||||
@@ -629,9 +645,17 @@ fn same_meaning(
|
||||
|| stored.limit != replanned.limit
|
||||
|| stored.projections.len() != replanned.projections.len()
|
||||
|| stored.filter.is_some() != replanned.filter.is_some()
|
||||
|| stored.group_by.len() != replanned.group_by.len()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Lance's planner does not read aggregates; a grouped definition is
|
||||
// compared in its canonical spelling, which planning produced.
|
||||
if stored.is_grouped() {
|
||||
return stored.projections == replanned.projections
|
||||
&& stored.group_by == replanned.group_by
|
||||
&& stored.filter == replanned.filter;
|
||||
}
|
||||
let schema = match unnest {
|
||||
None => source_schema.clone(),
|
||||
Some(unnest) => match super::flattened_schema(source_schema, unnest) {
|
||||
@@ -1288,6 +1312,9 @@ async fn compute_stream(
|
||||
schema: SchemaRef,
|
||||
rows_written: Arc<AtomicU64>,
|
||||
) -> Result<SendableRecordBatchStream> {
|
||||
if definition.is_grouped() {
|
||||
return super::grouped::stream(source, definition, inputs, schema, rows_written).await;
|
||||
}
|
||||
let RowScope {
|
||||
fragments,
|
||||
created_after,
|
||||
@@ -1363,31 +1390,42 @@ async fn compute_stream(
|
||||
None => batch,
|
||||
Some(expanded) => expanded.apply(&batch)?,
|
||||
};
|
||||
let mut columns = Vec::with_capacity(out_schema.fields().len());
|
||||
for field in out_schema.fields() {
|
||||
if computed_column_from_field(field).is_some() {
|
||||
columns.push(new_null_array(field.data_type(), batch.num_rows()));
|
||||
continue;
|
||||
}
|
||||
let name = if field.name() == SOURCE_ROW_ID_COLUMN {
|
||||
ROW_ID
|
||||
} else {
|
||||
field.name()
|
||||
};
|
||||
let column = batch.column_by_name(name).ok_or_else(|| {
|
||||
DataFusionError::Internal(format!(
|
||||
"view column '{}' is not produced by the view's definition",
|
||||
field.name()
|
||||
))
|
||||
})?;
|
||||
columns.push(column.clone());
|
||||
}
|
||||
let batch = to_view_batch(&batch, &out_schema)?;
|
||||
rows_written.fetch_add(batch.num_rows() as u64, Ordering::Relaxed);
|
||||
Ok(RecordBatch::try_new(out_schema.clone(), columns)?)
|
||||
Ok(batch)
|
||||
});
|
||||
Ok(Box::pin(RecordBatchStreamAdapter::new(schema, mapped)))
|
||||
}
|
||||
|
||||
/// `batch` in the view's physical schema: the row id becomes the
|
||||
/// provenance column and computed columns are written NULL for their owner
|
||||
/// to fill.
|
||||
pub(super) fn to_view_batch(
|
||||
batch: &RecordBatch,
|
||||
out_schema: &SchemaRef,
|
||||
) -> datafusion::common::Result<RecordBatch> {
|
||||
let mut columns = Vec::with_capacity(out_schema.fields().len());
|
||||
for field in out_schema.fields() {
|
||||
if computed_column_from_field(field).is_some() {
|
||||
columns.push(new_null_array(field.data_type(), batch.num_rows()));
|
||||
continue;
|
||||
}
|
||||
let name = if field.name() == SOURCE_ROW_ID_COLUMN {
|
||||
ROW_ID
|
||||
} else {
|
||||
field.name()
|
||||
};
|
||||
let column = batch.column_by_name(name).ok_or_else(|| {
|
||||
DataFusionError::Internal(format!(
|
||||
"view column '{}' is not produced by the view's definition",
|
||||
field.name()
|
||||
))
|
||||
})?;
|
||||
columns.push(column.clone());
|
||||
}
|
||||
Ok(RecordBatch::try_new(out_schema.clone(), columns)?)
|
||||
}
|
||||
|
||||
/// The post-scan half of an unnested view's refresh: the scan reads
|
||||
/// `raw_inputs` plus the row id, and each batch is unnested on the list
|
||||
/// column, filtered, then projected by expressions typed against
|
||||
@@ -3641,6 +3679,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
filter: None,
|
||||
group_by: Vec::new(),
|
||||
limit: None,
|
||||
lateral: None,
|
||||
};
|
||||
@@ -3675,6 +3714,7 @@ mod tests {
|
||||
expression: "x".into(),
|
||||
}],
|
||||
filter: None,
|
||||
group_by: Vec::new(),
|
||||
limit: None,
|
||||
lateral: None,
|
||||
};
|
||||
@@ -4304,4 +4344,149 @@ mod tests {
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
async fn grouped_source(conn: &Connection) -> Table {
|
||||
conn.create_table(
|
||||
"src",
|
||||
record_batch!(("k", Int32, [1, 1, 2, 3]), ("x", Int32, [10, 11, 20, 30])).unwrap(),
|
||||
)
|
||||
.write_options(crate::materialized_view::tests::stable_row_ids())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn declare(source: &Table, name: &str, sql: &str) -> Result<MaterializedView> {
|
||||
crate::materialized_view::prepare_definition(
|
||||
source,
|
||||
MaterializedViewDefinition::from_sql(sql)?,
|
||||
)
|
||||
.await?
|
||||
.create(name)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rows of `table` as `columns` rendered to text, sorted.
|
||||
async fn rows(table: &Table, columns: &[&str]) -> Vec<String> {
|
||||
let batches = table
|
||||
.query()
|
||||
.select(Select::columns(columns))
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rows = Vec::new();
|
||||
for batch in &batches {
|
||||
let formatters: Vec<_> = batch
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
arrow_cast::display::ArrayFormatter::try_new(c.as_ref(), &Default::default())
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
for row in 0..batch.num_rows() {
|
||||
let cells: Vec<String> = formatters
|
||||
.iter()
|
||||
.map(|f| f.value(row).to_string())
|
||||
.collect();
|
||||
rows.push(cells.join(" "));
|
||||
}
|
||||
}
|
||||
rows.sort();
|
||||
rows
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_grouped_view_holds_one_row_per_group() {
|
||||
let conn = connect("memory://").execute().await.unwrap();
|
||||
let source = grouped_source(&conn).await;
|
||||
let view = declare(
|
||||
&source,
|
||||
"groups",
|
||||
"SELECT k, array_agg(x) AS xs, sum(x) AS total FROM src WHERE x < 30 GROUP BY k",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
view.definition().to_sql(),
|
||||
"SELECT k, array_agg(x) AS xs, sum(x) AS total FROM src WHERE x < 30 GROUP BY k"
|
||||
);
|
||||
|
||||
let result = view.refresh().execute().await.unwrap();
|
||||
assert_eq!(result.mode, RefreshMode::Rebuild);
|
||||
assert_eq!(result.rows_written, 2);
|
||||
assert_eq!(rows(view.table(), &["k", "total"]).await, ["1 21", "2 20"]);
|
||||
let unchanged = view.refresh().execute().await.unwrap();
|
||||
assert_eq!(unchanged.mode, RefreshMode::NoOp);
|
||||
|
||||
// Any source change recomputes every group.
|
||||
source
|
||||
.add(record_batch!(("k", Int32, [2]), ("x", Int32, [5])).unwrap())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let result = view.refresh().execute().await.unwrap();
|
||||
assert_eq!(result.mode, RefreshMode::Rebuild);
|
||||
assert_eq!(rows(view.table(), &["k", "total"]).await, ["1 21", "2 25"]);
|
||||
|
||||
// A view over the groups expands them again.
|
||||
let expanded = declare(
|
||||
view.table(),
|
||||
"elements",
|
||||
"SELECT k, e AS x FROM groups, UNNEST(xs) AS e",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
expanded.refresh().execute().await.unwrap();
|
||||
assert_eq!(
|
||||
rows(expanded.table(), &["k", "x"]).await,
|
||||
["1 10", "1 11", "2 20", "2 5"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Provenance is one source row per group, so the view's row-id keys
|
||||
/// stay unique.
|
||||
#[tokio::test]
|
||||
async fn a_grouped_view_records_one_source_row_per_group() {
|
||||
let conn = connect("memory://").execute().await.unwrap();
|
||||
let source = grouped_source(&conn).await;
|
||||
let view = declare(
|
||||
&source,
|
||||
"groups",
|
||||
"SELECT k, count(*) AS n FROM src GROUP BY k",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
view.refresh().execute().await.unwrap();
|
||||
let ids = rows(view.table(), &[SOURCE_ROW_ID_COLUMN]).await;
|
||||
assert_eq!(ids.len(), 3);
|
||||
assert_eq!(ids.iter().collect::<HashSet<_>>().len(), 3, "{ids:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_grouped_view_is_planned_at_declaration() {
|
||||
let conn = connect("memory://").execute().await.unwrap();
|
||||
let source = grouped_source(&conn).await;
|
||||
for sql in [
|
||||
// x is neither a key nor aggregated
|
||||
"SELECT k, x FROM src GROUP BY k",
|
||||
"SELECT k, random() AS r FROM src GROUP BY k",
|
||||
"SELECT k, count(*) AS n FROM src GROUP BY missing",
|
||||
"SELECT k, count(*) AS __source_row_id FROM src GROUP BY k",
|
||||
"SELECT k, count(*) AS k FROM src GROUP BY k",
|
||||
] {
|
||||
assert!(declare(&source, "v", sql).await.is_err(), "{sql}");
|
||||
}
|
||||
let mut grouped = crate::materialized_view::prepare_definition(
|
||||
&source,
|
||||
MaterializedViewDefinition::from_sql("SELECT k, count(*) AS n FROM src GROUP BY k")
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(grouped.input_column("x").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2144,6 +2144,7 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
|
||||
})
|
||||
.collect(),
|
||||
filter: response.filter,
|
||||
group_by: Vec::new(),
|
||||
limit: response.limit,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user