fix: reject renaming or dropping embedding columns

Renaming or dropping an embedding source or vector column leaves schema metadata pointing at missing fields, causing later `add()` calls to fail.

Reject these operations in local schema evolution before committing any changes. The guard reads Python/Node embedding column metadata and native Rust `lancedb::column_definitions` without loading embedding models. It supports explicit and default Rust vector-column names and respects quoted column paths. Unrelated column changes and nullability updates remain supported.

The restriction is documented in the Python, Rust, and TypeScript APIs. Regression coverage includes both SDK metadata formats, native Rust definitions with explicit and default destinations, atomic rejection of batched changes, and continued embedding generation after reopening.

Fixes #4763

Validation at the exact PR head and GitHub test-merge:

- Hosted macOS Python wheel suite: 3061 passed, 162 skipped, 70 deselected, including all 8 sync and 4 async embedding schema-evolution cases.
- Hosted Windows Rust suite: 1900 library tests passed, 8 embedding integration tests passed, and 107 doctests passed, including the native explicit/default destination and binding guard cases.
- Hosted Linux Python 3.10 rerun: 3084 passed, 159 skipped, 50 deselected; the previously observed remote-fork timeout passed on the replacement job.
- All 10 applicable exact-head workflow runs completed successfully. The existing main-only Windows ARM job was skipped by its workflow condition.
This commit is contained in:
lancedb-gatefixer[bot]
2026-11-03 06:08:33 +08:00
committed by GitHub
parent ea9194a7f3
commit c18aa7b033
8 changed files with 330 additions and 2 deletions
+12
View File
@@ -3280,6 +3280,9 @@ class Table(ABC):
or "nullable". The legacy key "name" is also accepted for renaming.
Unknown keys raise ValueError before any alterations are applied.
Local tables reject renaming source or vector columns used by an
embedding function with ValueError, preserving the embedding binding.
Returns
-------
AlterColumnsResult
@@ -3348,6 +3351,9 @@ class Table(ABC):
"""
Drop columns from the table.
Local tables reject dropping source or vector columns used by an
embedding function with ValueError, preserving the embedding binding.
Parameters
----------
columns : Iterable[str]
@@ -7744,6 +7750,9 @@ class AsyncTable:
or "nullable". The legacy key "name" is also accepted for renaming.
Unknown keys raise ValueError before any alterations are applied.
Local tables reject renaming source or vector columns used by an
embedding function with ValueError, preserving the embedding binding.
Returns
-------
AlterColumnsResult
@@ -7762,6 +7771,9 @@ class AsyncTable:
"""
Drop columns from the table.
Local tables reject dropping source or vector columns used by an
embedding function with ValueError, preserving the embedding binding.
Parameters
----------
columns : Iterable[str]
+72
View File
@@ -33,6 +33,78 @@ def mock_embed_func(input_data):
return [np.random.randn(128).tolist() for _ in range(len(input_data))]
@pytest.fixture
def embedding_schema():
func = MockTextEmbeddingFunction.create()
class Doc(LanceModel):
id: int
text: str = func.SourceField()
vector: Vector(func.ndims()) = func.VectorField()
return Doc
@pytest.mark.parametrize("column", ["text", "vector", "`text`", "`vector`"])
@pytest.mark.parametrize("operation", ["rename", "drop"])
def test_embedding_columns_schema_evolution(
tmp_path, embedding_schema, column, operation
):
db = lancedb.connect(tmp_path)
table = db.create_table("docs", schema=embedding_schema)
table.add([{"id": 1, "text": "hello"}])
schema, version = table.schema, table.version
with pytest.raises(ValueError, match="used by an embedding function"):
if operation == "rename":
table.alter_columns(
{"path": "id", "rename": "doc_id"},
{"path": column, "rename": "renamed"},
)
else:
table.drop_columns(["id", column])
assert table.schema == schema
assert table.version == version
assert table.embedding_functions["vector"].source_column == "text"
reopened = db.open_table("docs")
reopened.add([{"id": 2, "text": "world"}])
assert reopened.count_rows() == 2
assert reopened.to_arrow()["vector"].null_count == 0
# Columns outside the embedding binding can still evolve.
reopened.alter_columns({"path": "id", "rename": "doc_id"})
reopened.drop_columns(["doc_id"])
reopened.add([{"text": "again"}])
assert reopened.count_rows() == 3
@pytest.mark.asyncio
@pytest.mark.parametrize("column", ["text", "vector"])
@pytest.mark.parametrize("operation", ["rename", "drop"])
async def test_async_embedding_columns_schema_evolution(
tmp_path, embedding_schema, column, operation
):
db = await lancedb.connect_async(tmp_path)
table = await db.create_table("docs", schema=embedding_schema)
await table.add([{"id": 1, "text": "hello"}])
schema, version = await table.schema(), await table.version()
with pytest.raises(ValueError, match="used by an embedding function"):
if operation == "rename":
await table.alter_columns({"path": column, "rename": "renamed"})
else:
await table.drop_columns([column])
assert await table.schema() == schema
assert await table.version() == version
assert (await table.embedding_functions())["vector"].source_column == "text"
reopened = await db.open_table("docs")
await reopened.add([{"id": 2, "text": "world"}])
assert await reopened.count_rows() == 2
assert (await reopened.to_arrow())["vector"].null_count == 0
def test_embedding_function(tmp_path):
registry = EmbeddingFunctionRegistry.get_instance()