feat: support blob computed column refresh

This commit is contained in:
Xuanwo
2026-08-28 14:32:29 +08:00
parent 6ab3b9eb30
commit c93911033f
11 changed files with 892 additions and 44 deletions
+3 -1
View File
@@ -352,7 +352,9 @@ class Table:
async def delete(self, filter: Union[str, PyExpr]) -> DeleteResult: ...
async def add_columns(self, columns: list[tuple[str, str]]) -> AddColumnsResult: ...
async def add_computed_columns(
self, columns: list[tuple[str, str]]
self,
columns: list[tuple[str, str]],
blob_columns: Optional[list[tuple[str, str]]] = None,
) -> AddColumnsResult: ...
async def add_function_columns(
self, application_json: str, output_name: Optional[str]
+6 -1
View File
@@ -976,8 +976,13 @@ class RemoteTable(Table):
| None = None,
*,
computed: Dict[str, str] | None = None,
computed_blobs: Dict[str, str] | None = None,
) -> AddColumnsResult:
return LOOP.run(self._table.add_columns(transforms, computed=computed))
return LOOP.run(
self._table.add_columns(
transforms, computed=computed, computed_blobs=computed_blobs
)
)
def refresh_column(self, column: str):
return LOOP.run(self._table.refresh_column(column))
+25 -4
View File
@@ -2143,6 +2143,7 @@ class Table(ABC):
| None = None,
*,
computed: Dict[str, str] | None = None,
computed_blobs: Dict[str, str] | None = None,
):
"""
Add new columns with defined values.
@@ -2184,6 +2185,12 @@ class Table(ABC):
server, and the refresh runs as a server job -- see
[`refresh_column_async`][lancedb.table.Table.refresh_column_async].
Cannot be combined with ``transforms``.
computed_blobs: Dict[str, str], optional
A map of Blob v2 output column names to SQL expressions returning
``LargeBinary`` payload bytes. Blob inputs named by an expression
are materialized as bytes, and refresh stores the result as Blob
v2 so ``blob_columns()`` and Blob read APIs continue to recognize
it. Cannot be combined with ``transforms``.
Returns
-------
@@ -4300,8 +4307,13 @@ class LanceTable(Table):
| None = None,
*,
computed: Dict[str, str] | None = None,
computed_blobs: Dict[str, str] | None = None,
) -> AddColumnsResult:
return LOOP.run(self._table.add_columns(transforms, computed=computed))
return LOOP.run(
self._table.add_columns(
transforms, computed=computed, computed_blobs=computed_blobs
)
)
def refresh_column(self, column: str) -> "RefreshColumnResult":
"""Fill a computed column's unfilled rows. See
@@ -6248,6 +6260,7 @@ class AsyncTable:
| None = None,
*,
computed: dict[str, str] | None = None,
computed_blobs: dict[str, str] | None = None,
) -> AddColumnsResult:
"""
Add new columns with defined values.
@@ -6283,6 +6296,11 @@ class AsyncTable:
On LanceDB Cloud and Enterprise the expression is planned by
the server. Cannot be combined with ``transforms``.
computed_blobs: Dict[str, str], optional
A map of Blob v2 output column names to SQL expressions returning
``LargeBinary`` payload bytes. Blob inputs are materialized as
payload bytes during refresh. Cannot be combined with
``transforms``.
Returns
-------
@@ -6306,7 +6324,7 @@ class AsyncTable:
function_output_name, function_application = next(iter(transforms.items()))
if function_application is not None:
if computed:
if computed or computed_blobs:
raise ValueError(
"add_columns cannot mix a Function application with SQL "
"computed columns"
@@ -6322,12 +6340,15 @@ class AsyncTable:
{isinstance(f, pa.Field) for f in transforms}
):
transforms = pa.schema(transforms)
if computed:
if computed or computed_blobs:
if transforms:
raise ValueError(
"add_columns cannot take both transforms and computed columns"
)
return await self._inner.add_computed_columns(list(computed.items()))
return await self._inner.add_computed_columns(
list((computed or {}).items()),
list((computed_blobs or {}).items()),
)
if transforms is None:
raise ValueError("add_columns requires transforms or computed columns")
if isinstance(transforms, pa.Schema):
+36
View File
@@ -4087,6 +4087,42 @@ def test_computed_column_rejects_transforms_and_computed_together(tmp_path):
table.add_columns({"a": "x + 1"}, computed={"b": "x * 2"})
def test_computed_blob_input_and_explicit_output(tmp_path):
schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")])
db = lancedb.connect(tmp_path)
table = db.create_table("computed_blob", schema=schema)
table.add(
[
{"id": 1, "image": b"hello"},
{"id": 2, "image": b""},
{"id": 3, "image": None},
]
)
table.add_columns(
computed={"payload_copy": "image"},
computed_blobs={"image_copy": "image"},
)
assert table.refresh_column("payload_copy").rows_filled == 2
assert table.refresh_column("image_copy").rows_filled == 2
values = table.to_arrow()["payload_copy"].combine_chunks().to_pylist()
assert values == [b"hello", b"", None]
assert table.blob_columns() == ["image", "image_copy"]
hits = table.search().with_row_id(True).limit(10).to_arrow()
rows = sorted(zip(hits["id"].to_pylist(), hits["_rowid"].to_pylist()))
copied = table.fetch_blobs("image_copy", [row_id for _, row_id in rows])
assert copied.to_pylist() == [b"hello", b"", None]
def test_computed_blob_rejects_eager_transforms(tmp_path):
db = lancedb.connect(tmp_path)
table = db.create_table("computed_blob_mixed", [{"x": 1}])
with pytest.raises(ValueError):
table.add_columns({"a": "x + 1"}, computed_blobs={"b": "x"})
@pytest.mark.asyncio
async def test_computed_column_async(tmp_path):
db = await lancedb.connect_async(tmp_path)
+5
View File
@@ -1575,9 +1575,11 @@ impl Table {
})
}
#[pyo3(signature = (columns, blob_columns=None))]
pub fn add_computed_columns(
self_: PyRef<'_, Self>,
columns: Vec<(String, String)>,
blob_columns: Option<Vec<(String, String)>>,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner_ref()?.clone();
future_into_py(self_.py(), async move {
@@ -1585,6 +1587,9 @@ impl Table {
for (name, expression) in columns {
builder = builder.computed(name, expression);
}
for (name, expression) in blob_columns.unwrap_or_default() {
builder = builder.computed_blob(name, expression);
}
let result = builder.execute().await.infer_error()?;
Ok(AddColumnsResult::from(result))
})