feat: cascade option on materialized view refresh

A view over a view could only be brought fully up to date by refreshing
each view in the lineage by hand, in order. `cascade` on the refresh
builder, Python `refresh`/`refresh_async` and Node `refresh` does that in
one call: every upstream view is refreshed furthest first, each reading
the version the one before it left, then the named view. `full` applies
to the named view only.

Locally the lineage is walked through each view's source, and each hop is
refreshed against the incarnation read in that walk. Remote tables send
`cascade` in the refresh request, only when set, so older servers that
reject unknown fields are unaffected by plain refreshes.
This commit is contained in:
Wyatt Alt
2026-09-24 19:09:12 +00:00
parent 604b1b583b
commit d96151c385
13 changed files with 297 additions and 33 deletions
+17
View File
@@ -112,6 +112,23 @@ describe("materialized views", () => {
expect((await view.refresh()).mode).toBe("no_op");
});
it("cascades a refresh through upstream views", async () => {
await db.createMaterializedView("adults", "people", {
where: "age >= 18",
withNoData: true,
});
const seniors = await db.createMaterializedView("seniors", "adults", {
where: "age >= 65",
withNoData: true,
});
expect(Number((await seniors.refresh()).rowsWritten)).toBe(0);
expect(Number((await seniors.refresh({ cascade: true })).rowsWritten)).toBe(
1,
);
expect(await (await db.openTable("adults")).countRows()).toBe(2);
});
it("lists views and rejects non-views", async () => {
await db.createMaterializedView("adults", "people", {
where: "age >= 18",
+5 -1
View File
@@ -190,7 +190,9 @@ export class MaterializedView {
* The refresh is incremental when the source's changes can be reconciled
* into the view -- rows added, changed or removed since the last one --
* and otherwise rebuilds. `full` forces a rebuild; `sourceVersion`
* refreshes to that source version instead of the latest.
* refreshes to that source version instead of the latest. `cascade` first
* refreshes every upstream view, furthest first, each reading the version
* the one before it left; `full` applies to this view only.
*
* Concurrent refreshes of one view do not duplicate its rows. Two that
* plan the same source rows conflict on commit, and the loser throws
@@ -199,11 +201,13 @@ export class MaterializedView {
async refresh(options?: {
full?: boolean;
sourceVersion?: number;
cascade?: boolean;
}): Promise<RefreshMaterializedViewResult> {
validateNonNegativeInteger(options?.sourceVersion, "sourceVersion");
return await this.inner.refreshMaterializedView(
options?.full,
options?.sourceVersion,
options?.cascade,
);
}
}
+7 -1
View File
@@ -670,6 +670,7 @@ export abstract class Table {
abstract refreshMaterializedView(
full?: boolean,
sourceVersion?: number,
cascade?: boolean,
): Promise<RefreshMaterializedViewResult>;
/** @ignore */
@@ -1397,8 +1398,13 @@ export class LocalTable extends Table {
async refreshMaterializedView(
full?: boolean,
sourceVersion?: number,
cascade?: boolean,
): Promise<RefreshMaterializedViewResult> {
return await this.inner.refreshMaterializedView(full, sourceVersion);
return await this.inner.refreshMaterializedView(
full,
sourceVersion,
cascade,
);
}
async materializedViewDefinition(): Promise<string> {
+5 -1
View File
@@ -458,11 +458,15 @@ impl Table {
&self,
full: Option<bool>,
source_version: Option<i64>,
cascade: Option<bool>,
) -> napi::Result<RefreshMaterializedViewResult> {
let view = lancedb::MaterializedView::from_table(self.inner_ref()?.clone())
.await
.default_error()?;
let mut builder = view.refresh().full(full.unwrap_or(false));
let mut builder = view
.refresh()
.full(full.unwrap_or(false))
.cascade(cascade.unwrap_or(false));
if let Some(version) = source_version {
let version = u64::try_from(version).map_err(|_| {
napi::Error::from_reason("sourceVersion must be a non-negative integer")