feat: define a materialized view by its query, with Functions in FROM position (#4190)

A view definition was a structured record under a `kind` tag, one kind
per query shape, and a Function returning `list<struct>` was about to
add a third. That names shapes instead of describing a relation.

A materialized view is now a relation defined by a query, stored as one
canonical SQL string under a format number:

```sql
SELECT columns FROM [ns.]table [, function(args) AS alias | , UNNEST(column) AS alias]
[WHERE predicate] [LIMIT n]
```

Any other clause is refused at parse time. Older readers report the view
as unrefreshable, the pre-format layouts still read, and a legacy view
is rewritten on its next refresh that commits, rebuilt only where its
raw text meant something else under lance's parser.

A Function in FROM position yields one row per element it returns, as a
table function does in any dialect. The server stages its list output in
a hidden table and records that binding beside the query, which stays as
the user wrote it; refresh scans the staging and unnests the column, the
same operator as UNNEST over a list column the table already holds. Row
ids repeat per element, so eviction and incremental append are
unchanged. A local database refuses a Function in FROM position, since
it has no executor.
This commit is contained in:
Wyatt Alt
2026-09-18 12:16:25 -07:00
committed by GitHub
parent 7955c50929
commit 01ee01dbc8
13 changed files with 2262 additions and 460 deletions
@@ -6,64 +6,17 @@
# Interface: MaterializedViewDefinition
The query that defines a materialized view.
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.
## Properties
### filter?
### query
```ts
optional filter: string;
query: string;
```
SQL predicate selecting the source rows the view holds.
***
### inputs
```ts
inputs: string[];
```
Source columns the projections and filter read.
***
### limit?
```ts
optional limit: number;
```
Cap on the number of rows the view holds.
***
### projections
```ts
projections: [string, string][];
```
`[output column, SQL expression]` pairs, in view schema order.
***
### sourceNamespace
```ts
sourceNamespace: string[];
```
Namespace holding the source table; empty is the root namespace.
***
### sourceTable
```ts
sourceTable: string;
```
Name of the source table, in the same database as the view.
The defining query, in the canonical spelling the server stores.
+36 -37
View File
@@ -28,6 +28,39 @@ describe("materialized views", () => {
});
afterEach(() => tmpDir.removeCallback());
it("reads stored queries and legacy layouts", () => {
const read = (stored: string) =>
definitionFromMetadata(new Map([[DEFINITION_META_KEY, stored]]), "v");
const query =
"SELECT id, c.chunk FROM ns.docs, UNNEST(chunks) AS c WHERE id > 1";
expect(read(`{"format":1,"query":${JSON.stringify(query)}}`).query).toBe(
query,
);
// The structured layout written before the format number reads as the
// query it described, under either of its kind tags.
expect(
read(
'{"kind":"namespaced_select","source_table":"people","source_namespace":["ns"],' +
'"projections":[{"output":"name","expression":"`name`"},' +
'{"output":"Shout","expression":"upper(name)"}],"filter":"age >= 18","limit":42}',
).query,
).toBe(
"SELECT `name`, upper(name) AS `Shout` FROM ns.people WHERE age >= 18 LIMIT 42",
);
expect(read('{"kind":"select","source_table":"people"}').query).toBe(
"SELECT * FROM people",
);
// A newer writer's layout is reported, never guessed at.
for (const newer of [
`{"format":2,"query":${JSON.stringify(query)}}`,
'{"kind":"select_v3","source_table":"people"}',
]) {
expect(() => read(newer)).toThrow(/cannot refresh/);
}
});
it("rejects a stored limit a number cannot carry", () => {
const big = new Map([
[
@@ -38,36 +71,6 @@ describe("materialized views", () => {
expect(() => definitionFromMetadata(big, "v")).toThrow(
/too large to represent exactly/,
);
const safe = new Map([
[
DEFINITION_META_KEY,
'{"kind":"select","source_table":"people","limit":42}',
],
]);
expect(definitionFromMetadata(safe, "v").limit).toBe(42);
});
it("reads the namespaced select kind and refuses unknown kinds", () => {
// "namespaced_select" is the namespaced form of "select": same shape, a
// separate kind so readers that predate it refuse instead of resolving
// the source at the root.
const namespaced = new Map([
[
DEFINITION_META_KEY,
'{"kind":"namespaced_select","source_table":"people","source_namespace":["ns"]}',
],
]);
const definition = definitionFromMetadata(namespaced, "v");
expect(definition.sourceTable).toBe("people");
expect(definition.sourceNamespace).toEqual(["ns"]);
const unknown = new Map([
[DEFINITION_META_KEY, '{"kind":"select_v3","source_table":"people"}'],
]);
expect(() => definitionFromMetadata(unknown, "v")).toThrow(
/cannot refresh/,
);
});
it("creates, refreshes and queries a view", async () => {
@@ -88,13 +91,9 @@ describe("materialized views", () => {
});
const view = await db.openMaterializedView("adults");
const definition = await view.definition();
expect(definition.sourceTable).toBe("people");
expect(definition.filter).toBe("age >= 18");
expect(definition.projections).toEqual([
["name", "`name`"],
["age", "`age`"],
]);
expect(definition.inputs).toEqual(["age", "name"]);
expect(definition.query).toBe(
"SELECT name, age FROM people WHERE age >= 18",
);
});
it("refreshes incrementally after an append", async () => {
+60 -33
View File
@@ -7,20 +7,18 @@ import { Table } from "./table";
/** Schema metadata key holding a materialized view's definition. */
export const DEFINITION_META_KEY = "mv.definition";
/** The query that defines a materialized view. */
/** The stored layout this version reads: `{"format": 1, "query": "<SQL>"}`. */
export const DEFINITION_FORMAT = 1;
/**
* 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.
*/
export interface MaterializedViewDefinition {
/** Name of the source table, in the same database as the view. */
sourceTable: string;
/** `[output column, SQL expression]` pairs, in view schema order. */
projections: [string, string][];
/** SQL predicate selecting the source rows the view holds. */
filter?: string;
/** Cap on the number of rows the view holds. */
limit?: number;
/** Source columns the projections and filter read. */
inputs: string[];
/** Namespace holding the source table; empty is the root namespace. */
sourceNamespace: string[];
/** The defining query, in the canonical spelling the server stores. */
query: string;
}
/**
@@ -88,15 +86,21 @@ export function definitionFromJson(
): MaterializedViewDefinition {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON
const value: any = JSON.parse(raw);
// "namespaced_select" keeps older readers from resolving the source at root.
if (
value.kind !== undefined &&
value.kind !== "select" &&
value.kind !== "namespaced_select"
) {
if (value.format !== undefined) {
// A newer writer's layout is reported, never guessed at.
if (!Number.isInteger(value.format) || value.format > DEFINITION_FORMAT) {
throw new Error(
`materialized view '${name}' is stored in format ${value.format}, ` +
"which this version of lancedb cannot refresh",
);
}
return { query: value.query };
}
// The structured layout written before the format number.
if (value.kind !== "select" && value.kind !== "namespaced_select") {
throw new Error(
`materialized view '${name}' is defined by '${value.kind}', which this ` +
"version of lancedb cannot refresh",
`materialized view '${name}' is stored in format kind '${value.kind}', ` +
"which this version of lancedb cannot refresh",
);
}
const limit = value.limit ?? undefined;
@@ -108,18 +112,41 @@ export function definitionFromJson(
`materialized view '${name}' has a stored limit too large to represent exactly`,
);
}
return {
sourceTable: value.source_table,
// biome-ignore lint/suspicious/noExplicitAny: raw JSON
projections: (value.projections ?? []).map((p: any) => [
p.output,
p.expression,
]),
filter: value.filter ?? undefined,
limit,
inputs: value.inputs ?? [],
sourceNamespace: value.source_namespace ?? [],
};
return { query: legacyQuery(value, limit) };
}
function legacyIdent(name: string): string {
return /^[a-z_][a-z0-9_]*$/.test(name)
? name
: `\`${name.replace(/`/g, "``")}\``;
}
/** Render the pre-format structured layout as the query it described. */
// biome-ignore lint/suspicious/noExplicitAny: raw JSON
function legacyQuery(value: any, limit: number | undefined): string {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON
const projections: any[] = value.projections ?? [];
const columns =
projections.length === 0
? "*"
: projections
.map((p) =>
p.expression === p.output || p.expression === `\`${p.output}\``
? p.expression
: `${p.expression} AS ${legacyIdent(p.output)}`,
)
.join(", ");
const table = [...(value.source_namespace ?? []), value.source_table]
.map(legacyIdent)
.join(".");
let query = `SELECT ${columns} FROM ${table}`;
if (value.filter !== undefined && value.filter !== null) {
query += ` WHERE ${value.filter}`;
}
if (limit !== undefined) {
query += ` LIMIT ${limit}`;
}
return query;
}
/**
+1 -1
View File
@@ -479,7 +479,7 @@ impl Table {
let view = lancedb::MaterializedView::from_table(inner)
.await
.default_error()?;
serde_json::to_string(view.definition()).map_err(|err| {
view.definition().to_json().map_err(|err| {
napi::Error::from_reason(format!(
"failed to serialize materialized-view definition: {err}"
))
+67 -39
View File
@@ -7,7 +7,7 @@ maintained by refresh. See ``DBConnection.create_materialized_view``."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union
from .background_loop import LOOP
@@ -29,22 +29,24 @@ 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."""
@dataclass
class MaterializedViewDefinition:
"""The query that defines a materialized view."""
"""The query that defines a materialized view, as stored::
source_table: str
"""Name of the source table, in the same database as the view."""
projections: List[Tuple[str, str]]
"""``(output column, SQL expression)`` pairs, in view schema order."""
filter: Optional[str] = None
"""SQL predicate selecting the source rows the view holds."""
limit: Optional[int] = None
"""Cap on the number of rows the view holds."""
inputs: List[str] = field(default_factory=list)
"""Source columns the projections and filter read."""
source_namespace: List[str] = field(default_factory=list)
"""Namespace holding the source table; empty is the root namespace."""
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.
"""
query: str
"""The defining query, in the canonical spelling the server stores."""
def _definition_from_schema(
@@ -54,38 +56,64 @@ def _definition_from_schema(
raw = metadata.get(DEFINITION_META_KEY)
if raw is None:
raise ValueError(f"Table '{name}' is not a materialized view")
value = json.loads(raw)
return _definition_from_value(json.loads(raw), name)
def _definition_from_json(raw: str, name: str = "") -> MaterializedViewDefinition:
"""Parse the definition native code hands over, in its stored layout."""
return _definition_from_value(json.loads(raw), name)
def _definition_from_value(value: dict, name: str) -> MaterializedViewDefinition:
fmt = value.get("format")
if fmt is not None:
# A newer writer's layout is reported, never guessed at.
if not isinstance(fmt, int) or fmt > DEFINITION_FORMAT:
raise NotImplementedError(
f"materialized view '{name}' is stored in format {fmt}, which "
"this version of lancedb cannot refresh"
)
return MaterializedViewDefinition(query=value["query"])
# The structured layout written before the format number.
kind = value.get("kind")
# "namespaced_select" keeps older readers from resolving the source at root.
if kind not in ("select", "namespaced_select"):
raise NotImplementedError(
f"materialized view '{name}' is defined by '{kind}', which this "
"version of lancedb cannot refresh"
f"materialized view '{name}' is stored in format kind '{kind}', "
"which this version of lancedb cannot refresh"
)
return MaterializedViewDefinition(
source_table=value["source_table"],
projections=[
(p["output"], p["expression"]) for p in value.get("projections", [])
],
filter=value.get("filter"),
limit=value.get("limit"),
inputs=value.get("inputs", []),
source_namespace=value.get("source_namespace", []),
)
return MaterializedViewDefinition(query=_legacy_query(value))
def _definition_from_json(raw: str) -> MaterializedViewDefinition:
value = json.loads(raw)
return MaterializedViewDefinition(
source_table=value["source_table"],
projections=[
(p["output"], p["expression"]) for p in value.get("projections", [])
],
filter=value.get("filter"),
limit=value.get("limit"),
inputs=value.get("inputs", []),
source_namespace=value.get("source_namespace", []),
def _legacy_ident(name: str) -> str:
if name and all(c == "_" or c.islower() or c.isdigit() for c in name):
return name
return _quote_identifier(name)
def _legacy_query(value: dict) -> str:
"""Render the pre-format structured layout as the query it described."""
projections = value.get("projections", [])
if projections:
items = []
for p in projections:
output, expression = p["output"], p["expression"]
if expression in (output, _quote_identifier(output)):
items.append(expression)
else:
items.append(f"{expression} AS {_legacy_ident(output)}")
columns = ", ".join(items)
else:
columns = "*"
table = ".".join(
_legacy_ident(part)
for part in [*value.get("source_namespace", []), value["source_table"]]
)
query = f"SELECT {columns} FROM {table}"
if value.get("filter") is not None:
query += f" WHERE {value['filter']}"
if value.get("limit") is not None:
query += f" LIMIT {value['limit']}"
return query
def _quote_identifier(name: str) -> str:
+34 -24
View File
@@ -217,10 +217,7 @@ def test_definition_round_trips(tmp_path):
view = db.open_materialized_view("adults")
assert view.definition == MaterializedViewDefinition(
source_table="people",
projections=[("name", "`name`"), ("age", "`age`")],
filter="age >= 18",
inputs=["age", "name"],
query="SELECT name, age FROM people WHERE age >= 18"
)
@@ -305,7 +302,7 @@ async def test_async_create_refresh_and_open(tmp_path):
reopened = await db.open_materialized_view("shouts")
definition = await reopened.definition()
assert definition.projections == [("shout", "upper(name)")]
assert definition.query == "SELECT upper(name) AS shout FROM people"
assert await db.list_materialized_views() == ["shouts"]
@@ -423,7 +420,7 @@ def test_namespace_connection_materialized_views(tmp_path):
assert db.list_materialized_views() == ["adults"]
reopened = db.open_materialized_view("adults")
assert reopened.definition.source_table == "people"
assert reopened.definition.query.startswith("SELECT name, age FROM ")
with pytest.raises(ValueError, match="not a materialized view"):
db.open_materialized_view("people")
@@ -458,7 +455,7 @@ async def test_async_namespace_connection_materialized_views(tmp_path):
assert await db.list_materialized_views() == ["adults"]
reopened = await db.open_materialized_view("adults")
assert (await reopened.definition()).source_table == "people"
assert (await reopened.definition()).query.startswith("SELECT name, age FROM ")
# The view's table came through the namespace, not straight from the
# inner connection: a bare inner table carries no namespace context, so
@@ -484,36 +481,49 @@ async def test_async_namespace_connection_materialized_views(tmp_path):
assert await db.list_materialized_views() == []
def test_namespaced_select_kind_is_read_and_unknown_kinds_are_refused():
def test_stored_queries_and_legacy_layouts_are_read():
import json
import pyarrow as pa
from lancedb.materialized_view import _definition_from_schema
def schema_with(definition: dict) -> pa.Schema:
return pa.schema([pa.field("id", pa.int32())]).with_metadata(
def read(definition: dict) -> MaterializedViewDefinition:
schema = pa.schema([pa.field("id", pa.int32())]).with_metadata(
{b"mv.definition": json.dumps(definition).encode()}
)
return _definition_from_schema(schema, "v")
# "namespaced_select" is the namespaced form of "select": same shape,
# a separate kind so readers that predate it refuse instead of
# resolving the source at the root.
definition = _definition_from_schema(
schema_with(
query = "SELECT id, c.chunk FROM ns.docs, UNNEST(chunks) AS c WHERE id > 1"
assert read({"format": 1, "query": query}).query == query
# The structured layout written before the format number reads as the
# query it described, under either of its kind tags.
assert (
read(
{
"kind": "namespaced_select",
"source_table": "people",
"source_namespace": ["ns"],
"projections": [{"output": "name", "expression": "name"}],
"projections": [
{"output": "name", "expression": "`name`"},
{"output": "Shout", "expression": "upper(name)"},
],
"filter": "age >= 18",
"limit": 10,
}
),
"v",
).query
== "SELECT `name`, upper(name) AS `Shout` FROM ns.people "
"WHERE age >= 18 LIMIT 10"
)
assert read({"kind": "select", "source_table": "people"}).query == (
"SELECT * FROM people"
)
assert definition.source_table == "people"
assert definition.source_namespace == ["ns"]
with pytest.raises(NotImplementedError, match="cannot refresh"):
_definition_from_schema(
schema_with({"kind": "select_v3", "source_table": "people"}), "v"
)
# A newer writer's layout is reported, never guessed at.
for newer in (
{"format": 2, "query": query},
{"kind": "select_v3", "source_table": "people"},
):
with pytest.raises(NotImplementedError, match="cannot refresh"):
read(newer)
+1 -1
View File
@@ -1814,7 +1814,7 @@ impl Table {
let view = lancedb::MaterializedView::from_table(inner)
.await
.infer_error()?;
serde_json::to_string(view.definition()).map_err(|err| {
view.definition().to_json().map_err(|err| {
PyRuntimeError::new_err(format!(
"failed to serialize materialized-view definition: {err}"
))
+1 -1
View File
@@ -360,7 +360,7 @@ pub trait Database:
continue;
};
let schema = table.schema().await?;
if crate::materialized_view::materialized_view_kind(schema.metadata())?.is_some() {
if crate::materialized_view::read_definition(schema.metadata())?.is_some() {
names.push(name);
}
}
File diff suppressed because it is too large Load Diff
+633
View File
@@ -0,0 +1,633 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! The SQL a materialized view is defined by. A definition is stored as one
//! canonical query, parsed here into the relational shape refresh maintains:
//!
//! ```sql
//! SELECT <column | expr AS name | *>, ...
//! 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;
//! `UNNEST` does the same for a list column the table already holds.
//!
//! Anything else is a query this engine cannot maintain yet and is refused
//! at parse time, which is also what an older engine does with a newer
//! 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,
};
use datafusion_sql::sqlparser::dialect::GenericDialect;
use datafusion_sql::sqlparser::keywords::{
ALL_KEYWORDS, ALL_KEYWORDS_INDEX, RESERVED_FOR_COLUMN_ALIAS, RESERVED_FOR_IDENTIFIER,
RESERVED_FOR_TABLE_ALIAS,
};
use datafusion_sql::sqlparser::parser::Parser;
use datafusion_sql::sqlparser::tokenizer::{Token, Tokenizer};
use lance_datafusion::planner::Planner;
use super::{LateralSource, MaterializedViewDefinition, ViewLateral, ViewProjection};
use crate::{Error, Result};
fn invalid(message: impl Into<String>) -> Error {
Error::InvalidInput {
message: message.into(),
}
}
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]`";
/// Whether `name` must be delimited to read back as this identifier: bare,
/// the parser would take it as a keyword, or a different spelling.
fn needs_quote(name: &str) -> bool {
let plain = !name.is_empty()
&& name
.chars()
.enumerate()
.all(|(i, c)| c == '_' || c.is_ascii_lowercase() || (i > 0 && c.is_ascii_digit()));
if !plain {
return true;
}
let reserved = ALL_KEYWORDS
.binary_search(&name.to_ascii_uppercase().as_str())
.is_ok_and(|i| {
let keyword = &ALL_KEYWORDS_INDEX[i];
RESERVED_FOR_TABLE_ALIAS.contains(keyword)
|| RESERVED_FOR_COLUMN_ALIAS.contains(keyword)
|| RESERVED_FOR_IDENTIFIER.contains(keyword)
});
if reserved {
return true;
}
// Then the parsers' own judgement: lance's in an expression, where a
// column name is read, and sqlparser's in a `FROM`, where a table's is.
let schema = std::sync::Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
name,
arrow_schema::DataType::Int32,
true,
)]));
let planner = Planner::new(schema);
let column = planner
.parse_expr(&format!("{name} IS NOT NULL"))
.is_ok_and(|expr| Planner::column_names_in_expr(&expr) == [name]);
if !column {
return true;
}
let sql = format!("SELECT 1 FROM {name}");
!matches!(
Parser::parse_sql(&GenericDialect {}, &sql).as_deref(),
Ok([Statement::Query(query)]) if matches!(
query.body.as_ref(),
SetExpr::Select(select) if matches!(
select.from.as_slice(),
[TableWithJoins { relation: TableFactor::Table { name: table, .. }, .. }]
if table.to_string() == name
)
)
)
}
/// `name` as a lance SQL identifier: bare when the parser reads it back
/// unchanged, backtick-delimited otherwise.
pub fn ident_sql(name: &str) -> String {
if needs_quote(name) {
format!("`{}`", name.replace('`', "``"))
} else {
name.to_string()
}
}
/// Rewrite every delimited identifier in `sql` to the form [`ident_sql`]
/// produces, so the same name is spelled one way wherever it appears.
/// Lance's parser delimits with backticks only; a `"name"` is rewritten
/// rather than read as a string.
pub fn canonical_tokens(sql: &str) -> Result<String> {
let tokens = Tokenizer::new(&GenericDialect {}, sql)
.with_unescape(false)
.tokenize()
.map_err(|err| invalid(format!("invalid SQL: {err}")))?;
Ok(tokens
.into_iter()
.map(|token| match token {
Token::Word(word) if word.quote_style == Some('"') => {
ident_sql(&word.value.replace("\"\"", "\""))
}
Token::Word(word) if word.quote_style == Some('`') => {
ident_sql(&word.value.replace("``", "`"))
}
other => other.to_string(),
})
.collect())
}
/// One expression, in the spelling the stored query uses.
pub fn canonical_expr(sql: &str) -> Result<String> {
let text = canonical_tokens(sql)?;
let expr = Parser::new(&GenericDialect {})
.try_with_sql(&text)
.and_then(|mut parser| {
let expr = parser.parse_expr()?;
parser.expect_token(&Token::EOF)?;
Ok(expr)
})
.map_err(|err| invalid(format!("invalid SQL expression '{sql}': {err}")))?;
Ok(expr.to_string())
}
/// The column a bare `SELECT` item names: the last part of a plain or
/// compound identifier, `None` for any other expression.
fn column_ref_name(expr: &Expr) -> Option<String> {
match expr {
Expr::Identifier(ident) => Some(ident.value.clone()),
Expr::CompoundIdentifier(parts) => parts.last().map(|p| p.value.clone()),
_ => None,
}
}
/// Parse `sql` into a definition. The query is re-rendered and compared
/// with what was parsed, so any clause this shape does not carry is
/// refused rather than dropped.
pub fn parse(sql: &str) -> Result<MaterializedViewDefinition> {
let text = canonical_tokens(sql)?;
let mut statements = Parser::parse_sql(&GenericDialect {}, &text)
.map_err(|err| invalid(format!("invalid SQL: {err}")))?;
let query = match (statements.pop(), statements.is_empty()) {
(Some(Statement::Query(query)), true) => normalize_from(*query),
_ => {
return Err(invalid(format!(
"expected a single SELECT statement; {SHAPE}"
)));
}
};
let definition = extract(&query)?;
let rendered = render(&definition);
if canonical_tokens(&query.to_string())? != rendered {
return Err(invalid(format!(
"unsupported clause in the view query; {SHAPE}"
)));
}
Ok(definition)
}
/// One spelling per relation: `FROM t CROSS JOIN UNNEST(..)` is
/// `FROM t, UNNEST(..)`, and the alias always takes `AS`.
fn normalize_from(mut query: Query) -> Query {
if let SetExpr::Select(select) = query.body.as_mut() {
if select.from.len() == 1
&& select.from[0].joins.len() == 1
&& matches!(
select.from[0].joins[0].join_operator,
JoinOperator::CrossJoin(_)
)
&& is_lateral_item(&select.from[0].joins[0].relation)
{
let join = select.from[0].joins.pop().expect("checked above");
select.from.push(TableWithJoins {
relation: join.relation,
joins: Vec::new(),
});
}
// `meta.title AS title` names what `meta.title` already names.
for item in &mut select.projection {
if let SelectItem::ExprWithAlias { expr, alias } = item
&& column_ref_name(expr).as_deref() == Some(alias.value.as_str())
{
*item = SelectItem::UnnamedExpr(expr.clone());
}
}
if let Some(item) = select.from.get_mut(1) {
// `LATERAL f(x) AS c` and `f(x) AS c` are one relation: a function
// in FROM position is lateral by nature.
if let TableFactor::Function {
name, args, alias, ..
} = &item.relation
{
item.relation = TableFactor::Table {
name: name.clone(),
alias: alias.clone(),
args: Some(TableFunctionArgs {
args: args.clone(),
settings: None,
}),
with_hints: Vec::new(),
version: None,
with_ordinality: false,
partitions: Vec::new(),
json_path: None,
sample: None,
index_hints: Vec::new(),
};
}
// `UNNEST(c) e` and `f(x) e` take `AS`.
match &mut item.relation {
TableFactor::UNNEST {
alias: Some(alias), ..
}
| TableFactor::Table {
alias: Some(alias), ..
} => alias.explicit = true,
_ => {}
}
}
}
query
}
fn is_lateral_item(factor: &TableFactor) -> bool {
matches!(
factor,
TableFactor::UNNEST { .. }
| TableFactor::Function { .. }
| TableFactor::Table { args: Some(_), .. }
)
}
fn single_name(name: &ObjectName, what: &str) -> Result<String> {
match name.0.as_slice() {
[ObjectNamePart::Identifier(ident)] => Ok(ident.value.clone()),
_ => Err(invalid(format!(
"{what} must be a single name, not '{name}'"
))),
}
}
fn extract(query: &Query) -> Result<MaterializedViewDefinition> {
let SetExpr::Select(select) = query.body.as_ref() else {
return Err(invalid(format!("expected a SELECT; {SHAPE}")));
};
let mut from = select.from.iter();
let (source_namespace, source_table) = match from.next().map(|f| &f.relation) {
Some(TableFactor::Table { args: Some(_), .. }) => {
return Err(invalid(
"a view reads a table; a Function in FROM position follows it: \
`FROM table, function(args) AS alias`",
));
}
Some(TableFactor::Table {
alias: Some(alias), ..
}) => {
return Err(invalid(format!(
"table aliases are not supported (`AS {}`); refer to columns unqualified",
alias.name
)));
}
Some(TableFactor::Table { name, .. }) => {
let mut parts = Vec::with_capacity(name.0.len());
for part in &name.0 {
match part {
ObjectNamePart::Identifier(ident) => parts.push(ident.value.clone()),
other => return Err(invalid(format!("unsupported table name part '{other}'"))),
}
}
let table = parts.pop().ok_or_else(|| invalid("empty table name"))?;
(parts, table)
}
_ => return Err(invalid(format!("the view must read one table; {SHAPE}"))),
};
let lateral = match from.next().map(|f| &f.relation) {
None => None,
Some(TableFactor::UNNEST {
alias, array_exprs, ..
}) => {
let column = match array_exprs.as_slice() {
[Expr::Identifier(ident)] => ident.value.clone(),
_ => {
return Err(invalid(
"UNNEST takes one top-level list column of the table",
));
}
};
let alias = alias
.as_ref()
.ok_or_else(|| invalid("UNNEST needs an alias: `UNNEST(column) AS alias`"))?;
Some(ViewLateral {
source: LateralSource::Unnest { column },
alias: alias.name.value.clone(),
})
}
Some(TableFactor::Table {
name,
args: Some(TableFunctionArgs { args, .. }),
alias,
..
}) => {
let function = single_name(name, "a Function in FROM position")?;
let mut rendered = Vec::with_capacity(args.len());
for arg in args {
match arg {
FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) => {
rendered.push(expr.to_string())
}
other => {
return Err(invalid(format!(
"'{function}' takes positional expression arguments, not '{other}'"
)));
}
}
}
let alias = alias.as_ref().ok_or_else(|| {
invalid(format!(
"'{function}' in FROM position needs an alias: `{function}(...) AS alias`"
))
})?;
Some(ViewLateral {
source: LateralSource::Function {
name: function,
args: rendered,
},
alias: alias.name.value.clone(),
})
}
Some(_) => return Err(invalid(format!("the view must read one table; {SHAPE}"))),
};
if from.next().is_some() {
return Err(invalid(format!("the view must read one table; {SHAPE}")));
}
let mut projections = Vec::with_capacity(select.projection.len());
for item in &select.projection {
match item {
SelectItem::Wildcard(_) if select.projection.len() == 1 => {
projections.push(ViewProjection::star());
}
SelectItem::Wildcard(_) => {
return Err(invalid("`*` must be the only column selected"));
}
SelectItem::UnnamedExpr(expr) => {
let output = column_ref_name(expr).ok_or_else(|| {
invalid(format!(
"view column `{expr}` needs a name: `{expr} AS name`"
))
})?;
projections.push(ViewProjection {
output,
expression: expr.to_string(),
});
}
SelectItem::ExprWithAlias { expr, alias } => projections.push(ViewProjection {
output: alias.value.clone(),
expression: expr.to_string(),
}),
other => return Err(invalid(format!("unsupported select item '{other}'"))),
}
}
let limit =
match &query.limit_clause {
None => None,
Some(LimitClause::LimitOffset {
limit: Some(Expr::Value(value)),
offset: None,
limit_by,
}) if limit_by.is_empty() => match &value.value {
Value::Number(n, _) => Some(n.parse::<u64>().map_err(|_| {
invalid(format!("view limit {n} is not a non-negative integer"))
})?),
_ => return Err(invalid("view limit must be an integer literal")),
},
Some(_) => return Err(invalid("view limit must be a plain `LIMIT n`")),
};
Ok(MaterializedViewDefinition {
source_table,
source_namespace,
lateral,
projections,
filter: select.selection.as_ref().map(|e| e.to_string()),
limit,
})
}
/// The canonical query for `definition`; [`parse`] reads it back equal.
pub fn render(definition: &MaterializedViewDefinition) -> String {
let mut sql = String::from("SELECT ");
if definition.selects_star() {
sql.push('*');
} else {
let items: Vec<String> = definition
.projections
.iter()
.map(|p| {
let bare = Parser::new(&GenericDialect {})
.try_with_sql(&p.expression)
.and_then(|mut parser| parser.parse_expr())
.ok()
.and_then(|expr| column_ref_name(&expr))
.is_some_and(|name| name == p.output);
if bare {
p.expression.clone()
} else {
format!("{} AS {}", p.expression, ident_sql(&p.output))
}
})
.collect();
sql.push_str(&items.join(", "));
}
sql.push_str(" FROM ");
let table: Vec<String> = definition
.source_namespace
.iter()
.chain(std::iter::once(&definition.source_table))
.map(|part| ident_sql(part))
.collect();
sql.push_str(&table.join("."));
if let Some(lateral) = &definition.lateral {
match &lateral.source {
LateralSource::Unnest { column } => {
sql.push_str(&format!(", UNNEST({})", ident_sql(column)))
}
LateralSource::Function { name, args } => {
sql.push_str(&format!(", {}({})", ident_sql(name), args.join(", ")))
}
}
sql.push_str(&format!(" AS {}", ident_sql(&lateral.alias)));
}
if let Some(filter) = &definition.filter {
sql.push_str(&format!(" WHERE {filter}"));
}
if let Some(limit) = definition.limit {
sql.push_str(&format!(" LIMIT {limit}"));
}
sql
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_query_round_trips_through_its_canonical_form() {
for (sql, canonical) in [
(
r#"select "Name", x*2 as twice from ns.docs where x > 1 limit 5"#,
"SELECT `Name`, x * 2 AS twice FROM ns.docs WHERE x > 1 LIMIT 5",
),
(
"SELECT id, c.chunk, c.ordinal + 1 AS nth FROM docs, UNNEST(chunks) AS c WHERE c.ordinal < 5",
"SELECT id, c.chunk, c.ordinal + 1 AS nth FROM docs, UNNEST(chunks) AS c WHERE c.ordinal < 5",
),
(
"SELECT id FROM docs CROSS JOIN UNNEST(chunks) c",
"SELECT id FROM docs, UNNEST(chunks) AS c",
),
(
"select d.id, c.text from docs, chunk(body, 512) c where c.ordinal < 3",
"SELECT d.id, c.text FROM docs, chunk(body, 512) AS c WHERE c.ordinal < 3",
),
(
"SELECT id, c.text FROM docs CROSS JOIN LATERAL chunk(body) AS c",
"SELECT id, c.text FROM docs, chunk(body) AS c",
),
(
"SELECT id, c.text FROM docs, LATERAL chunk(upper(body)) AS c",
"SELECT id, c.text FROM docs, chunk(upper(body)) AS c",
),
("SELECT * FROM `select`.t", "SELECT * FROM `select`.t"),
(
"SELECT meta.title AS title, id AS id FROM t",
"SELECT meta.title, id FROM t",
),
] {
let definition = parse(sql).unwrap();
assert_eq!(render(&definition), canonical, "{sql}");
assert_eq!(parse(canonical).unwrap(), definition, "{sql}");
}
}
#[test]
fn parsed_parts_are_the_relational_shape() {
let definition =
parse("SELECT id, c.chunk FROM ns.docs, UNNEST(chunks) AS c WHERE id > 1").unwrap();
assert_eq!(definition.source_namespace, ["ns"]);
assert_eq!(definition.source_table, "docs");
assert_eq!(
definition.lateral,
Some(ViewLateral {
source: LateralSource::Unnest {
column: "chunks".into()
},
alias: "c".into()
})
);
let function = parse("SELECT id, c.text FROM docs, chunk(body, 512) AS c").unwrap();
assert_eq!(
function.lateral,
Some(ViewLateral {
source: LateralSource::Function {
name: "chunk".into(),
args: vec!["body".into(), "512".into()]
},
alias: "c".into()
})
);
assert_eq!(
definition.projections,
[
ViewProjection {
output: "id".into(),
expression: "id".into()
},
ViewProjection {
output: "chunk".into(),
expression: "c.chunk".into()
},
]
);
assert_eq!(definition.filter.as_deref(), Some("id > 1"));
assert!(parse("SELECT * FROM t").unwrap().selects_star());
}
/// A clause the engine cannot maintain is refused, never dropped.
#[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",
"SELECT id FROM t JOIN u ON t.id = u.id",
"SELECT id FROM t, u",
"SELECT id FROM t, UNNEST(c)",
"SELECT id FROM t, UNNEST(a.b) AS c",
"SELECT id FROM t, chunk(body)",
"SELECT id FROM t, ns.chunk(body) AS c",
"SELECT id FROM t, chunk(size => 5) AS c",
"SELECT id FROM t AS d, chunk(d.body) AS c",
"SELECT id FROM chunk(body) AS c",
"SELECT id FROM t, chunk(body) AS c, UNNEST(x) AS u",
"SELECT count(*) FROM t",
"SELECT x * 2 FROM t",
"SELECT id FROM t; SELECT id FROM t",
"SELECT *, id FROM t",
"WITH q AS (SELECT 1) SELECT id FROM t",
"SELECT id FROM t HAVING id > 1",
] {
assert!(parse(sql).is_err(), "{sql}");
}
}
/// The canonical spelling is what lance's planner reads back, for the
/// expression forms a view is likely to carry.
#[test]
fn canonical_expressions_plan_in_lance() {
use arrow_schema::{DataType, Field, Schema};
let planner = Planner::new(std::sync::Arc::new(Schema::new(vec![
Field::new("x", DataType::Int32, true),
Field::new("Name", DataType::Utf8, true),
Field::new(
"when",
DataType::Timestamp(arrow_schema::TimeUnit::Microsecond, None),
true,
),
Field::new(
"meta",
DataType::Struct(vec![Field::new("title", DataType::Utf8, true)].into()),
true,
),
])));
for (raw, canonical) in [
("x*2+1", "x * 2 + 1"),
(r#"CAST(x as bigint)"#, "CAST(x AS BIGINT)"),
(r#"upper("Name") like 'A%'"#, "upper(`Name`) LIKE 'A%'"),
(
"x is not null and x between 1 and 3",
"x IS NOT NULL AND x BETWEEN 1 AND 3",
),
("meta.title", "meta.title"),
(r#"`Name` = 'it''s'"#, "`Name` = 'it''s'"),
("x in (1, 2)", "x IN (1, 2)"),
(
"`when` > timestamp '2024-01-01'",
"when > TIMESTAMP '2024-01-01'",
),
("-x", "-x"),
] {
let text = canonical_expr(raw).unwrap();
assert_eq!(text, canonical, "{raw}");
let expr = planner
.parse_expr(&text)
.unwrap_or_else(|e| panic!("{text}: {e}"));
planner
.optimize_expr(expr)
.unwrap_or_else(|e| panic!("{text}: {e}"));
}
}
#[test]
fn identifiers_are_delimited_only_when_the_parser_needs_it() {
assert_eq!(ident_sql("name"), "name");
assert_eq!(ident_sql("Name"), "`Name`");
assert_eq!(ident_sql("select"), "`select`");
assert_eq!(ident_sql("1st"), "`1st`");
assert_eq!(ident_sql("a`b"), "`a``b`");
assert_eq!(canonical_expr(r#""Party" = 'D'"#).unwrap(), "`Party` = 'D'");
assert_eq!(canonical_expr("`name`").unwrap(), "name");
}
}
+648 -69
View File
@@ -25,9 +25,10 @@ use std::time::{SystemTime, UNIX_EPOCH};
use arrow_array::cast::AsArray;
use arrow_array::types::UInt64Type;
use arrow_array::{RecordBatch, UInt64Array, new_null_array};
use arrow_schema::{FieldRef, Schema as ArrowSchema, SchemaRef};
use arrow_schema::{DataType, Field as ArrowField, FieldRef, Schema as ArrowSchema, SchemaRef};
use datafusion::common::ScalarValue;
use datafusion::error::DataFusionError;
use datafusion::physical_expr::PhysicalExpr;
use datafusion::physical_plan::SendableRecordBatchStream;
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use datafusion::prelude::{col, lit};
@@ -41,6 +42,8 @@ use lance::dataset::write::merge_insert::inserted_rows::{
};
use lance::dataset::{CommitBuilder, InsertBuilder, WriteDestination, WriteMode, WriteParams};
use lance_core::{ROW_CREATED_AT_VERSION, ROW_ID, ROW_LAST_UPDATED_AT_VERSION};
use lance_datafusion::planner::Planner;
use lance_file::version::ConcreteFileVersion;
use lance_table::format::Fragment;
use serde::{Deserialize, Serialize};
@@ -131,12 +134,12 @@ pub(crate) async fn execute_refresh(
// The definition a handle cached at open may since have been replaced;
// what refresh executes and what it stamps must be one generation.
let definition = match super::materialized_view_kind(&view_ds.schema().metadata)? {
Some(super::MaterializedViewKind::Select(definition)) => definition,
Some(super::MaterializedViewKind::Unrecognized { kind }) => {
let definition = match super::read_definition(&view_ds.schema().metadata)? {
Some(super::StoredDefinition::Query(definition)) => definition,
Some(super::StoredDefinition::Newer { format }) => {
return Err(Error::NotSupported {
message: format!(
"materialized view '{}' is defined by '{kind}', which this \
"materialized view '{}' is stored in format {format}, which this \
version of lancedb cannot refresh",
view.name()
),
@@ -149,9 +152,10 @@ pub(crate) async fn execute_refresh(
}
};
let definition = &definition;
let staging = super::read_staging(&view_ds.schema().metadata)?;
ensure_no_mem_wal(&view_ds, "materialized view", view.name()).await?;
let source_ds = open_source(view, definition).await?;
let source_ds = open_source(view, definition, staging.as_ref()).await?;
let source_ds = match pinned {
Some(version) => source_ds.checkout_version(version).await?,
None => source_ds,
@@ -164,20 +168,30 @@ pub(crate) async fn execute_refresh(
// require its planned output to be exactly the view's physical schema: a
// definition the stored table cannot represent must not be certified.
let source_schema = Arc::new(ArrowSchema::from(source_ds.schema()));
let projections: Vec<(String, String)> = definition
.projections
.iter()
.map(|p| (p.output.clone(), p.expression.clone()))
.collect();
validate_inputs(&source_ds, definition)?;
let (replanned, planned_fields, _renames) = super::plan(
source_schema,
&definition.source_table,
&definition.source_namespace,
Some(&projections),
definition.filter.as_deref(),
definition.limit,
)?;
let super::Planned {
definition: replanned,
fields: planned_fields,
inputs,
..
} = super::plan(source_schema.clone(), definition, staging.as_ref()).map_err(|e| match e {
// The stored query planned when the view was declared; what changed
// since is the source.
Error::InvalidExpression { column, message } => Error::Schema {
message: format!(
"view column '{column}' no longer plans against '{}' (a source column \
was dropped or renamed): {message}",
definition.source_table
),
},
Error::InvalidInput { message } => Error::Schema {
message: format!(
"the stored query no longer plans against '{}' (a source column was \
dropped or renamed): {message}",
definition.source_table
),
},
e => e,
})?;
let mut planned_fields = planned_fields;
planned_fields.push(arrow_schema::Field::new(
SOURCE_ROW_ID_COLUMN,
@@ -224,14 +238,19 @@ pub(crate) async fn execute_refresh(
),
});
}
let definition_changed =
definition.filter != replanned.filter || definition.inputs != replanned.inputs;
// The stored query is rewritten whenever its stored form differs from
// the current one: a legacy layout, or a spelling the canonicalizer no
// longer produces. Whether the rows change is a separate question: a
// legacy raw filter like `"Party" = 'D'` read the double quotes as a
// string literal, so its watermark certifies different rows than the
// canonical predicate, and only a rebuild can replace them.
let current = definition_to_metadata(&replanned)?;
let persist = view_ds.schema().metadata.get(DEFINITION_META_KEY) != Some(&current);
let unnest = super::physical_unnest(&replanned, staging.as_ref())?;
let definition_changed = !same_meaning(&source_schema, definition, &replanned, unnest.as_ref());
let definition = &replanned;
let persist = persist.then_some(definition);
// A watermark written for a legacy raw filter certifies the rows that
// filter produced, not the canonical predicate above. Rebuild instead of
// accepting or advancing it, and persist the migrated definition in the
// same metadata commit that certifies the replacement rows.
if definition_changed {
return rebuild(
view_native,
@@ -240,6 +259,8 @@ pub(crate) async fn execute_refresh(
source_version,
source_ts,
definition,
&inputs,
unnest.as_ref(),
true,
expected_incarnation,
)
@@ -284,6 +305,7 @@ pub(crate) async fn execute_refresh(
recorded_ts,
full,
definition,
&inputs,
)
.await
{
@@ -296,6 +318,9 @@ pub(crate) async fn execute_refresh(
source_ts,
increment,
definition,
&inputs,
unnest.as_ref(),
persist,
watermark,
expected_incarnation,
)
@@ -311,7 +336,9 @@ pub(crate) async fn execute_refresh(
source_version,
source_ts,
definition,
false,
&inputs,
unnest.as_ref(),
persist.is_some(),
expected_incarnation,
)
.await
@@ -326,7 +353,9 @@ pub(crate) async fn execute_refresh(
source_version,
source_ts,
definition,
false,
&inputs,
unnest.as_ref(),
persist.is_some(),
expected_incarnation,
)
.await
@@ -345,6 +374,7 @@ async fn plan_increment(
recorded_ts: Option<u128>,
full: bool,
definition: &MaterializedViewDefinition,
inputs: &[String],
) -> Option<Increment> {
if full {
return None;
@@ -414,7 +444,7 @@ async fn plan_increment(
});
}
is_pure_append(&old, source_ds, &relevant_field_ids(source_ds, definition)).then(|| Increment {
is_pure_append(&old, source_ds, &relevant_field_ids(source_ds, inputs)).then(|| Increment {
appended: live
.into_iter()
.filter(|f| !old_ids.contains(&f.id))
@@ -567,7 +597,7 @@ fn fragment_signature(metadata: &Fragment, relevant: &HashSet<i32>) -> (u64, Str
}
/// Field ids (with struct descendants) of the source columns the view reads.
fn relevant_field_ids(source: &Dataset, definition: &MaterializedViewDefinition) -> HashSet<i32> {
fn relevant_field_ids(source: &Dataset, inputs: &[String]) -> HashSet<i32> {
fn collect(field: &lance_core::datatypes::Field, ids: &mut HashSet<i32>) {
ids.insert(field.id);
for child in &field.children {
@@ -575,7 +605,7 @@ fn relevant_field_ids(source: &Dataset, definition: &MaterializedViewDefinition)
}
}
let mut ids = HashSet::new();
for input in &definition.inputs {
for input in inputs {
if let Some(field) = source.schema().field(input) {
collect(field, &mut ids);
}
@@ -583,20 +613,46 @@ fn relevant_field_ids(source: &Dataset, definition: &MaterializedViewDefinition)
ids
}
/// Error if a column the view reads no longer exists in the source.
fn validate_inputs(source: &Dataset, definition: &MaterializedViewDefinition) -> Result<()> {
for input in &definition.inputs {
if source.schema().field(input).is_none() {
return Err(Error::Schema {
message: format!(
"source column '{input}' read by the view no longer exists \
(dropped or renamed in '{}')",
definition.source_table
),
});
}
/// Whether two plannings of a view compute the same rows and columns: the
/// same source, unnest and limit, and expressions the planner reads as the
/// same logical expression, whatever their spelling. A definition that does
/// not plan compares as different.
fn same_meaning(
source_schema: &SchemaRef,
stored: &MaterializedViewDefinition,
replanned: &MaterializedViewDefinition,
unnest: Option<&super::ViewUnnest>,
) -> bool {
if stored.source_table != replanned.source_table
|| stored.source_namespace != replanned.source_namespace
|| stored.lateral != replanned.lateral
|| stored.limit != replanned.limit
|| stored.projections.len() != replanned.projections.len()
|| stored.filter.is_some() != replanned.filter.is_some()
{
return false;
}
Ok(())
let schema = match unnest {
None => source_schema.clone(),
Some(unnest) => match super::flattened_schema(source_schema, unnest) {
Ok(schema) => schema,
Err(_) => return false,
},
};
let planner = Planner::new(schema);
let same_expr = |a: &str, b: &str| match (planner.parse_expr(a), planner.parse_expr(b)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
};
stored
.projections
.iter()
.zip(&replanned.projections)
.all(|(a, b)| a.output == b.output && same_expr(&a.expression, &b.expression))
&& match (&stored.filter, &replanned.filter) {
(Some(a), Some(b)) => same_expr(a, b),
_ => true,
}
}
/// Reject MemWAL/LSM state on a refresh participant: un-compacted tiers are
@@ -616,14 +672,27 @@ pub(crate) async fn ensure_no_mem_wal(dataset: &Dataset, role: &str, name: &str)
Ok(())
}
async fn open_source(view: &Table, definition: &MaterializedViewDefinition) -> Result<Dataset> {
/// The table refresh scans: the staging table when the query calls a
/// Function in FROM position, otherwise the query's source.
async fn open_source(
view: &Table,
definition: &MaterializedViewDefinition,
staging: Option<&super::StagingBinding>,
) -> Result<Dataset> {
let database = view.database_opt().ok_or_else(|| Error::InvalidInput {
message: "the view was not opened through a database connection".into(),
})?;
let (name, namespace_path) = match staging {
Some(staging) => (staging.table.clone(), staging.namespace.clone()),
None => (
definition.source_table.clone(),
definition.source_namespace.clone(),
),
};
let source = database
.open_table(OpenTableRequest {
name: definition.source_table.clone(),
namespace_path: definition.source_namespace.clone(),
name,
namespace_path,
index_cache_size: None,
lance_read_params: None,
location: None,
@@ -656,6 +725,9 @@ async fn incremental(
source_ts: u128,
increment: Increment,
definition: &MaterializedViewDefinition,
inputs: &[String],
unnest: Option<&super::ViewUnnest>,
persist: Option<&MaterializedViewDefinition>,
watermark: Option<u64>,
expected_incarnation: Option<&str>,
) -> Result<Option<RefreshMaterializedViewResult>> {
@@ -739,7 +811,7 @@ async fn incremental(
view_ds.clone(),
source_version,
source_ts,
None,
persist,
expected_incarnation,
)
.await?;
@@ -761,7 +833,7 @@ async fn incremental(
published,
source_version,
source_ts,
None,
persist,
expected_incarnation,
)
.await?;
@@ -778,6 +850,8 @@ async fn incremental(
let mut stream = compute_stream(
source_ds,
definition,
inputs,
unnest,
RowScope {
fragments: Some(new_fragments),
// An update rewrites whole fragments, so a fragment new at head
@@ -799,6 +873,8 @@ async fn incremental(
let recomputed = compute_stream(
source_ds,
definition,
inputs,
unnest,
RowScope {
updated_between: Some((watermark_version, source_version)),
..Default::default()
@@ -833,7 +909,7 @@ async fn incremental(
published,
source_version,
source_ts,
None,
persist,
expected_incarnation,
)
.await?;
@@ -883,7 +959,7 @@ async fn incremental(
appended,
source_version,
source_ts,
None,
persist,
expected_incarnation,
)
.await?;
@@ -898,6 +974,8 @@ async fn rebuild(
source_version: u64,
source_ts: u128,
definition: &MaterializedViewDefinition,
inputs: &[String],
unnest: Option<&super::ViewUnnest>,
persist_definition: bool,
expected_incarnation: Option<&str>,
) -> Result<RefreshMaterializedViewResult> {
@@ -906,6 +984,8 @@ async fn rebuild(
let stream = compute_stream(
source_ds,
definition,
inputs,
unnest,
RowScope {
limit: definition.limit,
..Default::default()
@@ -1202,6 +1282,8 @@ async fn only_computed_rewrites_since(view_ds: &Dataset, recorded: u64) -> Resul
async fn compute_stream(
source: &Dataset,
definition: &MaterializedViewDefinition,
inputs: &[String],
unnest: Option<&super::ViewUnnest>,
scope: RowScope,
schema: SchemaRef,
rows_written: Arc<AtomicU64>,
@@ -1233,6 +1315,7 @@ async fn compute_stream(
let clauses: Vec<String> = definition
.filter
.clone()
.filter(|_| unnest.is_none())
.map(|f| format!("({f})"))
.into_iter()
.chain(updated_filter)
@@ -1241,12 +1324,23 @@ async fn compute_stream(
if !clauses.is_empty() {
scanner.filter(&clauses.join(" AND "))?;
}
let transforms: Vec<(&str, &str)> = definition
.projections
.iter()
.map(|p| (p.output.as_str(), p.expression.as_str()))
.collect();
scanner.project_with_transform(&transforms)?;
// An expanded view cannot project or filter in the scan: both read the
// unnested element, which exists only after the per-batch expansion.
let expanded = match unnest {
Some(unnest) => Some(UnnestPlan::new(source, definition, inputs, unnest)?),
None => {
let transforms: Vec<(&str, &str)> = definition
.projections
.iter()
.map(|p| (p.output.as_str(), p.expression.as_str()))
.collect();
scanner.project_with_transform(&transforms)?;
None
}
};
if let Some(expanded) = &expanded {
scanner.project(&expanded.raw_inputs)?;
}
// A scan reads a limit of zero as no limit at all, so a view capped at
// nothing is answered without one.
if limit == Some(0) {
@@ -1265,6 +1359,10 @@ async fn compute_stream(
let out_schema = schema.clone();
let mapped = scanner.try_into_stream().await?.map(move |batch| {
let batch = batch.map_err(|e| DataFusionError::External(Box::new(e)))?;
let batch = match &expanded {
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() {
@@ -1290,6 +1388,192 @@ async fn compute_stream(
Ok(Box::pin(RecordBatchStreamAdapter::new(schema, mapped)))
}
/// 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
/// `read_schema`, where the element sits under the alias.
struct UnnestPlan {
column: String,
raw_inputs: Vec<String>,
read_schema: SchemaRef,
projections: Vec<(String, Arc<dyn PhysicalExpr>)>,
filter: Option<Arc<dyn PhysicalExpr>>,
}
impl UnnestPlan {
fn new(
source: &Dataset,
definition: &MaterializedViewDefinition,
inputs: &[String],
unnest: &super::ViewUnnest,
) -> Result<Self> {
// Whole root columns: a nested input is projected by the expression.
let mut raw_inputs: Vec<String> = inputs
.iter()
.map(|input| super::root(input).to_string())
.chain(std::iter::once(unnest.column.clone()))
.collect();
raw_inputs.sort();
raw_inputs.dedup();
let flattened = super::flattened_schema(&ArrowSchema::from(source.schema()), unnest)?;
// Physical expressions index columns by position, so the schema is
// exactly the scan's output: `raw_inputs` in order, then the row id.
let mut read_fields = Vec::with_capacity(raw_inputs.len() + 1);
for name in &raw_inputs {
let name = if *name == unnest.column {
&unnest.alias
} else {
name
};
let field = flattened
.field_with_name(name)
.map_err(|_| Error::Runtime {
message: format!("source column '{name}' read by the view is missing"),
})?;
read_fields.push(field.clone());
}
read_fields.push(ArrowField::new(ROW_ID, DataType::UInt64, false));
let read_schema = Arc::new(ArrowSchema::new(read_fields));
let planner = Planner::new(read_schema.clone());
let physical = |what: &str, sql: &str| -> Result<Arc<dyn PhysicalExpr>> {
let err = |e: lance::Error| Error::Runtime {
message: format!("{what}: {e}"),
};
let parsed = planner.parse_expr(sql).map_err(err)?;
let optimized = planner.optimize_expr(parsed).map_err(err)?;
planner.create_physical_expr(&optimized).map_err(err)
};
let mut projections = Vec::with_capacity(definition.projections.len());
for projection in &definition.projections {
let expr = physical(
&format!("view column '{}'", projection.output),
&projection.expression,
)?;
projections.push((projection.output.clone(), expr));
}
let filter = definition
.filter
.as_deref()
.map(|sql| physical("view filter", sql))
.transpose()?;
Ok(Self {
column: unnest.column.clone(),
raw_inputs,
read_schema,
projections,
filter,
})
}
fn apply(&self, batch: &RecordBatch) -> datafusion::common::Result<RecordBatch> {
let unnested = unnest_batch(batch, &self.column)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
// Same columns, renamed: the list column is now the element under the alias.
let unnested = RecordBatch::try_new(self.read_schema.clone(), unnested.columns().to_vec())?;
let unnested = match &self.filter {
None => unnested,
Some(filter) => {
let keep = filter
.evaluate(&unnested)?
.into_array(unnested.num_rows())?;
let keep = keep.as_boolean_opt().ok_or_else(|| {
DataFusionError::Internal("view filter did not evaluate to a boolean".into())
})?;
arrow_select::filter::filter_record_batch(&unnested, keep)?
}
};
let mut columns = Vec::with_capacity(self.projections.len() + 1);
for (output, expr) in &self.projections {
let value = expr.evaluate(&unnested)?.into_array(unnested.num_rows())?;
columns.push((output.clone(), value));
}
let row_id = unnested
.column_by_name(ROW_ID)
.expect("scan carries the row id")
.clone();
columns.push((ROW_ID.to_string(), row_id));
Ok(RecordBatch::try_from_iter(columns)?)
}
}
/// Expand `list_column` one row per element, repeating every other column
/// for each element; an empty or null list contributes no rows. The list
/// column is replaced by its element type, so a projection reads the
/// element's fields as `alias.field` after this. This is the row-cardinality
/// step of an `expanded_select` view, applied per batch on the scan stream.
fn unnest_batch(batch: &RecordBatch, list_column: &str) -> Result<RecordBatch> {
use arrow_array::{Array, ListArray, UInt32Array};
use arrow_select::take::take;
let (list_index, _) = batch
.schema()
.column_with_name(list_column)
.ok_or_else(|| Error::Runtime {
message: format!("expansion column '{list_column}' is not in the batch"),
})?;
let list = batch
.column(list_index)
.as_any()
.downcast_ref::<ListArray>()
.ok_or_else(|| Error::Runtime {
message: format!(
"expansion column '{list_column}' is {}, not a list",
batch.column(list_index).data_type()
),
})?;
// One take index per element, naming the source row it came from. A null
// list has no elements; its offsets are equal, so it repeats nothing.
let offsets = list.value_offsets();
let mut repeat = Vec::with_capacity(list.values().len());
for row in 0..list.len() {
if list.is_valid(row) {
let count = (offsets[row + 1] - offsets[row]) as usize;
repeat.extend(std::iter::repeat_n(row as u32, count));
}
}
let repeat = UInt32Array::from(repeat);
// The flattened elements, in the same order as `repeat`: only the ranges
// valid rows cover, so a null row's stale range (if any) is skipped.
let elements = {
let mut ranges = Vec::new();
for row in 0..list.len() {
if list.is_valid(row) {
ranges.extend((offsets[row] as u32)..(offsets[row + 1] as u32));
}
}
take(list.values().as_ref(), &UInt32Array::from(ranges), None)?
};
let mut fields = Vec::with_capacity(batch.num_columns());
let mut columns = Vec::with_capacity(batch.num_columns());
for (index, field) in batch.schema().fields().iter().enumerate() {
if index == list_index {
let element = match field.data_type() {
arrow_schema::DataType::List(element) => element.clone(),
other => {
return Err(Error::Runtime {
message: format!("expansion column '{list_column}' is {other}, not a list"),
});
}
};
fields.push(Arc::new(
arrow_schema::Field::new(field.name(), element.data_type().clone(), true)
.with_metadata(field.metadata().clone()),
));
columns.push(elements.clone());
} else {
fields.push(field.clone());
columns.push(take(batch.column(index).as_ref(), &repeat, None)?);
}
}
let schema = Arc::new(ArrowSchema::new_with_metadata(
fields,
batch.schema().metadata().clone(),
));
Ok(RecordBatch::try_new(schema, columns)?)
}
/// Commit the view's removals and additions as one change, on the exact
/// generation the refresh planned from. Lance rejects an overlapping
/// provenance key, but an unrelated write to the view is not a key conflict,
@@ -1672,6 +1956,247 @@ mod tests {
(conn, source, view)
}
/// The per-batch expansion behind an `expanded_select` view: every
/// element of the list column becomes a row, the other columns repeat
/// for each, and an empty or null list contributes no rows at all --
/// which is exactly "zero rows out" for a table-valued function.
/// Four documents with a `list<struct<chunk, ordinal>>` column named
/// `c`: doc 1 has two chunks, doc 2 none, doc 3 a null list, doc 4 one.
fn chunked_batch() -> RecordBatch {
use arrow_array::builder::{Int32Builder, ListBuilder, StringBuilder, StructBuilder};
use arrow_array::{ArrayRef, Int64Array};
use arrow_schema::{DataType, Field, Fields};
let element_fields = Fields::from(vec![
Field::new("chunk", DataType::Utf8, true),
Field::new("ordinal", DataType::Int32, true),
]);
let mut list = ListBuilder::new(StructBuilder::new(
element_fields,
vec![
Box::new(StringBuilder::new()),
Box::new(Int32Builder::new()),
],
));
for chunks in [Some(vec!["a", "b"]), Some(vec![]), None, Some(vec!["c"])] {
match chunks {
Some(chunks) => {
for (i, c) in chunks.iter().enumerate() {
let s = list.values();
s.field_builder::<StringBuilder>(0).unwrap().append_value(c);
s.field_builder::<Int32Builder>(1)
.unwrap()
.append_value(i as i32);
s.append(true);
}
list.append(true);
}
None => list.append(false),
}
}
let meta = arrow_array::StructArray::from(vec![(
Arc::new(Field::new("title", DataType::Utf8, true)),
Arc::new(arrow_array::StringArray::from(vec!["t1", "t2", "t3", "t4"])) as ArrayRef,
)]);
RecordBatch::try_from_iter(vec![
(
"id",
Arc::new(Int64Array::from(vec![1, 2, 3, 4])) as ArrayRef,
),
("meta", Arc::new(meta) as ArrayRef),
("c", Arc::new(list.finish()) as ArrayRef),
])
.unwrap()
}
#[test]
fn unnest_repeats_siblings_per_element_and_drops_empty_lists() {
use arrow_array::{StringArray, StructArray};
use arrow_schema::{DataType, Field, Fields};
let batch = chunked_batch();
let element_fields = Fields::from(vec![
Field::new("chunk", DataType::Utf8, true),
Field::new("ordinal", DataType::Int32, true),
]);
let out = unnest_batch(&batch, "c").unwrap();
assert_eq!(out.num_rows(), 3, "{out:?}");
let ids: Vec<i64> = out["id"]
.as_primitive::<arrow_array::types::Int64Type>()
.values()
.to_vec();
assert_eq!(ids, [1, 1, 4]);
let element = out["c"].as_any().downcast_ref::<StructArray>().unwrap();
let chunks: Vec<&str> = element
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.unwrap()
.iter()
.flatten()
.collect();
assert_eq!(chunks, ["a", "b", "c"]);
let ordinals: Vec<i32> = element
.column(1)
.as_primitive::<arrow_array::types::Int32Type>()
.values()
.to_vec();
assert_eq!(ordinals, [0, 1, 0]);
// the element column is now the struct itself, not a list of it
assert_eq!(
out.schema().field_with_name("c").unwrap().data_type(),
&DataType::Struct(element_fields)
);
}
/// A Function in FROM position is refreshed from its staging table: the
/// query on the view stays the one the user wrote, the staging binding
/// says which table and list column refresh reads, and rows come out one
/// per element as with UNNEST. Without a staging, a local database
/// refuses the query rather than guess.
#[tokio::test]
async fn a_function_in_from_position_refreshes_from_its_staging() {
use arrow_array::StringArray;
let conn = connect("memory://").execute().await.unwrap();
let staging = conn
.create_table("docs__chunk", chunked_batch())
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap();
let query =
"SELECT id AS doc, e.chunk AS text, e.ordinal FROM docs, chunk(meta.title, 2) AS e";
let definition = MaterializedViewDefinition::from_sql(query).unwrap();
let err = crate::materialized_view::prepare_definition(&staging, definition.clone())
.await
.unwrap_err();
assert!(
matches!(&err, Error::InvalidInput { message } if message.contains("reads 'docs'")),
"{err:?}"
);
let view = crate::materialized_view::prepare_staged_definition(&staging, definition, "c")
.await
.unwrap()
.create("chunks")
.await
.unwrap();
assert_eq!(view.definition().to_sql(), query);
let metadata = view.table().schema().await.unwrap().metadata().clone();
assert_eq!(
crate::materialized_view::read_staging(&metadata).unwrap(),
Some(crate::materialized_view::StagingBinding {
table: "docs__chunk".into(),
namespace: Vec::new(),
column: "c".into(),
})
);
view.refresh().execute().await.unwrap();
assert_eq!(read(view.table(), "ordinal").await, [0, 0, 1]);
let batches = view
.table()
.query()
.select(Select::columns(&["text"]))
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
let out = arrow_select::concat::concat_batches(&batches[0].schema(), &batches).unwrap();
let texts: Vec<&str> = out["text"]
.as_any()
.downcast_ref::<StringArray>()
.unwrap()
.iter()
.flatten()
.collect();
assert_eq!(texts, ["a", "b", "c"]);
// The reopened view reads the same logical query and refreshes
// incrementally from the staging.
staging.add(chunked_batch()).execute().await.unwrap();
let reopened = conn.open_materialized_view("chunks").await.unwrap();
assert_eq!(reopened.definition().to_sql(), query);
let result = reopened.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(read(reopened.table(), "ordinal").await, [0, 0, 0, 0, 1, 1]);
}
/// An expanded view materializes one row per list element, with the
/// projections reading the element through the alias and the other
/// source columns repeated alongside; sources with no elements yield
/// no rows. The lineage is the list column, so a change to it is what
/// drives incremental refresh.
#[tokio::test]
async fn an_expanded_view_materializes_one_row_per_element() {
use arrow_array::{Int64Array, StringArray};
let conn = connect("memory://").execute().await.unwrap();
let source = conn
.create_table("docs", chunked_batch())
.write_options(crate::materialized_view::tests::stable_row_ids())
.execute()
.await
.unwrap();
let mut view = crate::materialized_view::prepare_definition(
&source,
MaterializedViewDefinition::from_sql(
"SELECT id AS doc, meta.title AS title, e.ordinal + 1 AS nth \
FROM docs, UNNEST(c) AS e WHERE e.ordinal < 5",
)
.unwrap(),
)
.await
.unwrap();
// A computed column's input read through the alias is an element
// field; the recorded source input is the list column.
let text = view.input_column("e.chunk").unwrap();
assert!(view.definition.lateral.is_some());
let view = view.create("chunks").await.unwrap();
view.refresh().execute().await.unwrap();
let batches = view
.table()
.query()
.select(Select::columns(&["doc", &text, "title"]))
.execute()
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
let out = arrow_select::concat::concat_batches(&batches[0].schema(), &batches).unwrap();
let docs: Vec<i64> = out["doc"]
.as_any()
.downcast_ref::<Int64Array>()
.unwrap()
.values()
.to_vec();
let strings = |column: &str| -> Vec<String> {
out[column]
.as_any()
.downcast_ref::<StringArray>()
.unwrap()
.iter()
.flatten()
.map(str::to_string)
.collect()
};
assert_eq!(docs, [1, 1, 4]);
assert_eq!(strings(&text), ["a", "b", "c"]);
assert_eq!(strings("title"), ["t1", "t1", "t4"]);
assert_eq!(read(view.table(), "nth").await, [1, 1, 2]);
// Appended documents expand incrementally; the existing rows stay.
source.add(chunked_batch()).execute().await.unwrap();
view.refresh().execute().await.unwrap();
assert_eq!(read(view.table(), "nth").await, [1, 1, 1, 1, 2, 2]);
}
async fn read(table: &Table, column: &str) -> Vec<i32> {
let batches = table
.query()
@@ -1765,13 +2290,62 @@ mod tests {
view.definition().filter.as_deref(),
Some("`PartyAbbrev` = 'D'")
);
assert_eq!(view.definition().inputs, ["PartyAbbrev", "id"]);
let result = view.refresh().execute().await.unwrap();
assert_eq!(result.rows_written, 2);
assert_eq!(read(view.table(), "id").await, vec![1, 3]);
}
/// A legacy layout whose query means what the canonical one means is
/// rewritten in the current layout on the next refresh, without a
/// rebuild: the rows it certified are the rows the query produces.
#[tokio::test]
async fn a_legacy_layout_with_the_same_meaning_is_rewritten_without_a_rebuild() {
let (conn, source, view) = refreshed_doubled(vec![1]).await;
let legacy = serde_json::json!({
"kind": "select",
"source_table": "src",
"projections": [
{"output": "x", "expression": "`x`"},
{"output": "twice", "expression": "x*2"},
],
"inputs": ["x"],
})
.to_string();
let native = view.table().as_native().unwrap();
let mut dataset = native.dataset.get().await.unwrap().as_ref().clone();
let predicted = dataset.version().version + 1;
dataset
.update_schema_metadata([
(DEFINITION_META_KEY.to_string(), Some(legacy)),
(
VIEW_VERSION_META_KEY.to_string(),
Some(predicted.to_string()),
),
])
.await
.unwrap();
native.dataset.update(dataset);
append(&source, vec![2]).await;
let reopened = conn.open_materialized_view("doubled").await.unwrap();
let result = reopened.refresh().execute().await.unwrap();
assert_eq!(result.mode, RefreshMode::Incremental);
assert_eq!(read(reopened.table(), "twice").await, vec![2, 4]);
let stored: serde_json::Value = serde_json::from_str(
&reopened.table().schema().await.unwrap().metadata()[DEFINITION_META_KEY],
)
.unwrap();
assert_eq!(
stored,
serde_json::json!({
"kind": "query",
"format": 1,
"query": "SELECT x, x * 2 AS twice FROM src",
})
);
}
#[tokio::test]
async fn test_legacy_raw_filter_rebuilds_and_persists_canonical_definition() {
let conn = connect("memory://").execute().await.unwrap();
@@ -1797,18 +2371,20 @@ mod tests {
// Model a definition and up-to-date watermark written before filter
// canonicalization was applied to materialized views.
let mut legacy = view.definition().clone();
legacy.filter = Some(r#""PartyAbbrev" = 'D'"#.into());
legacy.inputs = vec!["id".into()];
let legacy = serde_json::json!({
"kind": "select",
"source_table": "legacy_src",
"projections": [{"output": "id", "expression": "id"}],
"filter": r#""PartyAbbrev" = 'D'"#,
"inputs": ["id"],
})
.to_string();
let native = view.table().as_native().unwrap();
let mut dataset = native.dataset.get().await.unwrap().as_ref().clone();
let predicted = dataset.version().version + 1;
dataset
.update_schema_metadata([
(
DEFINITION_META_KEY.to_string(),
Some(definition_to_metadata(&legacy).unwrap()),
),
(DEFINITION_META_KEY.to_string(), Some(legacy)),
(
VIEW_VERSION_META_KEY.to_string(),
Some(predicted.to_string()),
@@ -1831,7 +2407,6 @@ mod tests {
migrated.definition().filter.as_deref(),
Some("`PartyAbbrev` = 'D'")
);
assert_eq!(migrated.definition().inputs, ["PartyAbbrev", "id"]);
assert_eq!(
migrated.refresh().execute().await.unwrap().mode,
RefreshMode::NoOp
@@ -2758,7 +3333,11 @@ mod tests {
source.drop_columns(&["x"]).await.unwrap();
let err = view.refresh().execute().await.unwrap_err();
assert!(matches!(err, Error::Schema { message } if message.contains("'x'")));
assert!(
matches!(&err, Error::Schema { message }
if message.contains("dropped or renamed") && message.contains("No field named x")),
"{err:?}"
);
}
/// A pinned refresh materializes the source as of `version`; catching up
@@ -3063,7 +3642,7 @@ mod tests {
],
filter: None,
limit: None,
inputs: vec!["x".into()],
lateral: None,
};
let mut metadata = HashMap::new();
metadata.insert(
@@ -3097,7 +3676,7 @@ mod tests {
}],
filter: None,
limit: None,
inputs: vec!["x".into()],
lateral: None,
};
let mut metadata = HashMap::new();
metadata.insert(
+10 -5
View File
@@ -2099,8 +2099,10 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
filter: Option<String>,
#[serde(default)]
limit: Option<u64>,
/// The defining query, once the server describes a view by it;
/// takes precedence over the structured fields.
#[serde(default)]
inputs: Vec<String>,
query: Option<String>,
#[serde(default)]
incarnation: Option<String>,
}
@@ -2113,10 +2115,12 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
let response = self.check_table_response(&request_id, response).await?;
let response: DescribeMaterializedViewResponse =
response.json().await.err_to_http(request_id)?;
Ok(MaterializedViewInfo {
definition: MaterializedViewDefinition {
let definition = match response.query {
Some(query) => MaterializedViewDefinition::from_sql(&query)?,
None => MaterializedViewDefinition {
source_table: response.source_table,
source_namespace: response.source_namespace,
lateral: None,
projections: response
.projections
.into_iter()
@@ -2127,8 +2131,10 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
.collect(),
filter: response.filter,
limit: response.limit,
inputs: response.inputs,
},
};
Ok(MaterializedViewInfo {
definition,
incarnation: response.incarnation,
})
}
@@ -12448,7 +12454,6 @@ mod tests {
let view = crate::MaterializedView::from_table(table).await.unwrap();
assert_eq!(view.definition().source_table, "source");
assert_eq!(view.definition().source_namespace, ["analytics"]);
assert_eq!(view.definition().inputs, ["x"]);
assert_eq!(view.incarnation(), Some("inc-1"));
let result = view
+1 -1
View File
@@ -104,7 +104,7 @@ pub(crate) async fn set_lsm_write_spec(table: &NativeTable, spec: LsmWriteSpec)
.into(),
});
}
if crate::materialized_view::materialized_view_kind(&dataset.schema().metadata)?.is_some() {
if crate::materialized_view::read_definition(&dataset.schema().metadata)?.is_some() {
return Err(Error::NotSupported {
message: "an LSM write spec cannot be installed on a materialized view: \
rows in un-compacted tiers are invisible to refresh"