fix: reject empty column alterations and unknown Python keys (#4417)

Fixes #4390

Lance accepts column alterations with every change field unset, so a
path-only Python call could commit a new local version without changing
the schema. The Python binding also silently discarded unknown keys such
as `nulable`.

Validate every alteration in the shared Rust `Table` API before
dispatching to either backend, and reject unknown dictionary keys in the
Python binding while preserving the legacy `name` alias. Invalid batches
leave local schema and version unchanged and send no remote request.

Validation:

- Reproduced both reported calls on the original extension: versions
advanced from 1 to 2 to 3 with an unchanged schema; all 13 new
invalid-input Python cases failed before the fix.
- Rebuilt the extension with `uv run --extra tests --extra dev maturin
develop --extras tests,dev`; all 18 focused Python cases pass across
sync/async and local/mocked remote tables.
- `cargo test --quiet --features remote -p lancedb --lib alter_column`:
all eight tests pass, including both new regressions.
- `cargo clippy --quiet --features remote --tests --examples` passes
across the workspace.
- `cargo fmt --all`, `ruff check .`, and `ruff format .` pass.
- `typos` 1.26.0 passes across the repository; intentional misspellings
in regression fixtures use the existing line-specific spelling
exemptions.

<!-- lance-gatekeeper-fix:v1 agent=36a547cd11108898893456042e38628b
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
This commit is contained in:
lancedb-gatefixer[bot]
2026-10-03 06:15:15 -05:00
committed by GitHub
parent 57a952135b
commit de19ecbf63
7 changed files with 215 additions and 5 deletions
+71
View File
@@ -119,6 +119,77 @@ async def test_async_remote_db():
assert table_names == []
@pytest.mark.parametrize(
"alteration, match",
[
({"path": "id"}, "One of rename, nullable or data_type"),
(
{"path": "id", "nulable": False}, # spellchecker:disable-line
"Unknown column alteration key 'nulable'", # spellchecker:disable-line
),
(
{
"path": "id",
"rename": "new_id",
"nulable": False, # spellchecker:disable-line
},
"Unknown column alteration key 'nulable'", # spellchecker:disable-line
),
],
)
def test_remote_alter_columns_rejects_invalid_before_request(alteration, match):
requests = []
def handler(request):
requests.append(request.path)
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
with mock_lancedb_connection(handler) as db:
table = db.open_table("test")
requests.clear()
with pytest.raises(ValueError, match=match):
table.alter_columns(alteration)
assert requests == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"alteration, match",
[
({"path": "id"}, "One of rename, nullable or data_type"),
(
{"path": "id", "nulable": False}, # spellchecker:disable-line
"Unknown column alteration key 'nulable'", # spellchecker:disable-line
),
],
)
async def test_async_remote_alter_columns_rejects_invalid_before_request(
alteration, match
):
requests = []
def handler(request):
requests.append(request.path)
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
async with mock_lancedb_connection_async(handler) as db:
table = await db.open_table("test")
requests.clear()
with pytest.raises(ValueError, match=match):
await table.alter_columns(alteration)
assert requests == []
@pytest.mark.asyncio
async def test_async_checkout():
def handler(request):