feat: record the namespace a view's unqualified names resolve against

A view outlives the session that declared it, so the namespace path its
query resolved against travels with it, beside the database. Without it
a reader would resolve an unqualified name against its own default and
read a different table.

`ViewDescription` carries `default_namespace_path`; it crosses the wire
as `default_namespace`, a path like `namespace`, absent for the root.
This commit is contained in:
Jack Ye
2026-09-22 10:14:49 -07:00
parent 24628a8bb9
commit 151d507f3d
12 changed files with 70 additions and 8 deletions
Generated
+3 -3
View File
@@ -5756,7 +5756,7 @@ dependencies = [
[[package]]
name = "lancedb"
version = "0.40.0-beta.4"
version = "0.40.0-beta.5"
dependencies = [
"ahash",
"anyhow",
@@ -5853,7 +5853,7 @@ dependencies = [
[[package]]
name = "lancedb-nodejs"
version = "0.40.0-beta.4"
version = "0.40.0-beta.5"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -5878,7 +5878,7 @@ dependencies = [
[[package]]
name = "lancedb-python"
version = "0.40.0-beta.4"
version = "0.40.0-beta.5"
dependencies = [
"arc-swap",
"arrow",
+15
View File
@@ -24,6 +24,21 @@ The database that unqualified table names in [query](ViewDescription.md#query) r
***
### defaultNamespacePath
```ts
defaultNamespacePath: string[];
```
The namespace path those unqualified names resolve against; empty is the
root namespace.
Recorded with the view because it outlives the session that declared it:
a reader resolving the query against its own default namespace could read
a different table than the view was defined over.
***
### name
```ts
+3
View File
@@ -112,6 +112,8 @@ describe("remote connection", () => {
query: "SELECT name FROM people",
// biome-ignore lint/style/useNamingConvention: the wire field is snake_case
default_database: "db",
// biome-ignore lint/style/useNamingConvention: the wire field is snake_case
default_namespace: ["analytics"],
schema: {
fields: [
{ name: "name", nullable: true, type: { type: "utf8" } },
@@ -128,6 +130,7 @@ describe("remote connection", () => {
expect(view.namespacePath).toEqual(["analytics"]);
expect(view.query).toBe("SELECT name FROM people");
expect(view.defaultDatabase).toBe("db");
expect(view.defaultNamespacePath).toEqual(["analytics"]);
expect(view.schema.fields.map((f) => f.name)).toEqual(["name"]);
},
);
+10
View File
@@ -20,6 +20,15 @@ export interface ViewDescription {
query: string;
/** The database that unqualified table names in {@link query} resolve against. */
defaultDatabase: string;
/**
* The namespace path those unqualified names resolve against; empty is the
* root namespace.
*
* Recorded with the view because it outlives the session that declared it:
* a reader resolving the query against its own default namespace could read
* a different table than the view was defined over.
*/
defaultNamespacePath: string[];
/** The schema the defining query resolved to when the view was created. */
schema: Schema;
}
@@ -33,6 +42,7 @@ export function viewDescriptionFromNative(
namespacePath: view.namespacePath,
query: view.query,
defaultDatabase: view.defaultDatabase,
defaultNamespacePath: view.defaultNamespacePath,
schema: tableFromIPC(view.schema).schema,
};
}
+2
View File
@@ -62,6 +62,7 @@ pub struct ViewDescription {
pub namespace_path: Vec<String>,
pub query: String,
pub default_database: String,
pub default_namespace_path: Vec<String>,
/// The view's schema as an empty Arrow IPC file, the way a table reports
/// its own.
pub schema: Buffer,
@@ -74,6 +75,7 @@ impl ViewDescription {
namespace_path: view.namespace_path,
query: view.query,
default_database: view.default_database,
default_namespace_path: view.default_namespace_path,
schema: crate::util::schema_to_buffer(&view.schema)?,
})
}
+2 -2
View File
@@ -170,10 +170,10 @@ class Connection(object):
) -> Tuple[str, int, int]: ...
async def create_view(
self, name: str, query: str, namespace_path: Optional[List[str]] = None
) -> Tuple[str, List[str], str, str, pa.Schema]: ...
) -> Tuple[str, List[str], str, str, List[str], pa.Schema]: ...
async def describe_view(
self, name: str, namespace_path: Optional[List[str]] = None
) -> Tuple[str, List[str], str, str, pa.Schema]: ...
) -> Tuple[str, List[str], str, str, List[str], pa.Schema]: ...
async def drop_view(
self, name: str, namespace_path: Optional[List[str]] = None
) -> None: ...
+10 -2
View File
@@ -98,16 +98,24 @@ from .namespace_utils import (
def _view_description(
described: Tuple[str, List[str], str, str, "pa.Schema"],
described: Tuple[str, List[str], str, str, List[str], "pa.Schema"],
) -> ViewDescription:
"""Name the fields the binding returns positionally."""
name, namespace_path, query, default_database, schema = described
(
name,
namespace_path,
query,
default_database,
default_namespace_path,
schema,
) = described
return ViewDescription(
name=name,
query=query,
default_database=default_database,
schema=schema,
namespace_path=namespace_path,
default_namespace_path=default_namespace_path,
)
+7
View File
@@ -31,3 +31,10 @@ class ViewDescription:
"""The schema the defining query resolved to when the view was created."""
namespace_path: List[str] = field(default_factory=list)
"""The namespace holding the view; empty is the root namespace."""
default_namespace_path: List[str] = field(default_factory=list)
"""The namespace path those unqualified names resolve against.
Recorded with the view because it outlives the session that declared it: a
reader resolving the query against its own default namespace could read a
different table than the view was defined over.
"""
+2
View File
@@ -2766,6 +2766,7 @@ def test_view_crud_addresses_its_own_routes():
"namespace": ["analytics"],
"query": "SELECT name FROM people",
"default_database": "dev",
"default_namespace": ["analytics"],
"schema": {
"fields": [
{"name": "name", "nullable": True, "type": {"type": "utf8"}}
@@ -2785,6 +2786,7 @@ def test_view_crud_addresses_its_own_routes():
assert view.namespace_path == ["analytics"]
assert view.query == "SELECT name FROM people"
assert view.default_database == "dev"
assert view.default_namespace_path == ["analytics"]
assert view.schema == pa.schema([pa.field("name", pa.utf8(), nullable=True)])
described = db.describe_view("adults", namespace_path=["analytics"])
+2 -1
View File
@@ -106,7 +106,7 @@ fn parse_default_namespace_path(path: Option<Bound<'_, PyAny>>) -> PyResult<Vec<
/// A view description on its way to Python: name, namespace, query, default
/// database, and the schema as pyarrow renders it.
type PyViewDescription = (String, Vec<String>, String, String, Py<PyAny>);
type PyViewDescription = (String, Vec<String>, String, String, Vec<String>, Py<PyAny>);
/// A view description as a plain tuple, with the schema converted to the
/// pyarrow schema the caller would get from any other lancedb API. The Python
@@ -120,6 +120,7 @@ fn view_description_to_py(view: lancedb::view::ViewDescription) -> PyResult<PyVi
view.namespace_path,
view.query,
view.default_database,
view.default_namespace_path,
schema,
))
})
+7
View File
@@ -801,6 +801,10 @@ struct RemoteViewDescription {
namespace: Vec<String>,
query: String,
default_database: String,
/// A path, like `namespace`: the root is the absent field rather than a
/// spelling of its own.
#[serde(default)]
default_namespace: Vec<String>,
schema: JsonArrowSchema,
}
@@ -820,6 +824,7 @@ impl RemoteViewDescription {
namespace_path: self.namespace,
query: self.query,
default_database: self.default_database,
default_namespace_path: self.default_namespace,
schema: Arc::new(schema),
})
}
@@ -3853,6 +3858,7 @@ mod tests {
"namespace": namespace,
"query": query,
"default_database": "db",
"default_namespace": namespace,
"schema": lance_namespace::schema::arrow_schema_to_json(&schema).unwrap(),
})
.to_string()
@@ -3885,6 +3891,7 @@ mod tests {
assert_eq!(view.namespace_path, vec!["analytics".to_string()]);
assert_eq!(view.query, "SELECT id FROM people");
assert_eq!(view.default_database, "db");
assert_eq!(view.default_namespace_path, vec!["analytics".to_string()]);
assert_eq!(view.schema.field(0).name(), "id");
}
+7
View File
@@ -32,6 +32,13 @@ pub struct ViewDescription {
/// other than its own, so this is part of what the query means rather
/// than a restatement of where the view lives.
pub default_database: String,
/// The namespace path those unqualified names resolve against; empty is
/// the root namespace.
///
/// Recorded with the view because it outlives the session that declared
/// it: a reader that resolved the query against its own default namespace
/// could read a different table than the view was defined over.
pub default_namespace_path: Vec<String>,
/// The schema the defining query resolved to when the view was created.
pub schema: SchemaRef,
}