mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-24 12:42:26 +00:00
LanceDB serializes Pydantic rows with `model_dump()`, which omits `Field(exclude=True)`, but currently constructs an Arrow schema containing those same fields. Creating a local table therefore fails when an excluded field is non-nullable, including defaulted transient fields and nested models. Filter unconditionally excluded fields when converting model schemas, including nested structs. Skip excluded source/vector fields when deriving embedding configuration so persistent metadata does not refer to absent columns. Keep `field_names()` as model-field reflection. Tests exercise implicit and explicit schemas, synchronous and asynchronous create/add/reopen/readback, nested fields, defaults and default factories, retained fields, and local deterministic embeddings. Excluded values are not persisted; `to_pydantic` restores declared defaults. Conditional exclusion and readback of required excluded fields without defaults are outside this change. Implemented with assistance from gpt-6.1-sol; final diff reviewed locally. Local validation reuses the native extension matching official based864e7198f. The current-main Pydantic module is byte-identical to that base, but current-main native code has not been rebuilt. Slow/S3 tests, remote services, and other operating systems were not validated. Reproducible commands after preparing the documented development environment (relative `.venv` examples are equivalent commands; actual local runs use absolute read-only environments and fresh task-private working directories): ```sh PYTHONPATH=python/python .venv/bin/python -m pytest python/python/tests/test_pydantic_excluded_fields.py -q -p no:cacheprovider PYTHONPATH=python/python .venv/bin/python -m pytest python/python/tests -m 'not slow and not s3_test' -q -rs -p no:cacheprovider PYTHONPATH=python/python .venv/bin/python -m pytest --doctest-modules python/python/lancedb -q --tb=short (cd python && PYTHONPATH=python ../.venv/bin/pyright --pythonpath ../.venv/bin/python) .venv/bin/ruff check . .venv/bin/ruff format --check . .venv/bin/python -m pre_commit run --files python/python/lancedb/pydantic.py python/python/tests/test_pydantic_excluded_fields.py git diff --checkd864e7198fHEAD ``` Final frozen-source results: - EXCLUDE_TARGET_COMPLETE_ORIGINAL_RED: Python 3.11.14, exit 1, 1.900076000019908 seconds, {"failed"=>7}. - EXCLUDE_TARGET_EMBEDDING: Python 3.11.14, exit 0, 1.3686820000002626 seconds, {"passed"=>7}. - EXCLUDE_TARGET313: Python 3.13.11, exit 0, 1.7214069999754429 seconds, {"passed"=>7}. - EXCLUDE_FULL311: Python 3.11.14, exit 0, 217.56785399999353 seconds, {"passed"=>1531, "skipped"=>36, "deselected"=>68, "warnings"=>251}. - EXCLUDE_FULL313: Python 3.13.11, exit 0, 197.93847399999504 seconds, {"passed"=>1528, "skipped"=>37, "deselected"=>68, "warnings"=>251}. - EXCLUDE_DOCTEST313: Python 3.13.11, exit 0, 11.8951390000002 seconds, {"passed"=>71, "skipped"=>6, "warnings"=>2}. - EXCLUDE_PYRIGHT: Python 3.13.11, exit 0, 5.808195000019623 seconds, {"warnings"=>0, "errors"=>0}. - EXCLUDE_PRECOMMIT: Python 3.11.14, exit 0, 0.5624079999979585 seconds, {"passed"=>4, "skipped"=>2}. - EXCLUDE_RUFF_FINAL: Ruff 0.9.9, exit 0, 0.06722099997568876 seconds, {}. - EXCLUDE_FORMAT_FINAL: Ruff 0.9.9, exit 0, 0.033322000002954155 seconds, {"files_formatted"=>157}. - EXCLUDE_SYNTAX: Python 3.11.14, exit 0, 0.031629999983124435 seconds, {"files_compiled"=>2}. - EXCLUDE_DIFF_CHECK: Git, exit 0, 0.024460999993607402 seconds, {}.
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
import lancedb
|
|
import pytest
|
|
from pydantic import BaseModel, Field
|
|
from lancedb.pydantic import LanceModel
|
|
|
|
|
|
class Details(BaseModel):
|
|
label: str
|
|
transient: str = Field(default="nested default", exclude=True)
|
|
|
|
|
|
class Row(LanceModel):
|
|
id: int
|
|
details: Details
|
|
transient: str = Field(default="local default", exclude=True)
|
|
generated: list[str] = Field(default_factory=lambda: ["local"], exclude=True)
|
|
retained: str = Field(default="persisted", exclude=False)
|
|
|
|
|
|
def rows():
|
|
return [
|
|
Row(
|
|
id=i,
|
|
details=Details(label=str(i), transient="private"),
|
|
transient="private",
|
|
generated=["private"],
|
|
)
|
|
for i in (1, 2)
|
|
]
|
|
|
|
|
|
def assert_schema(schema):
|
|
assert schema.names == ["id", "details", "retained"]
|
|
assert [field.name for field in schema.field("details").type] == ["label"]
|
|
assert Row.field_names() == ["id", "details", "transient", "generated", "retained"]
|
|
|
|
|
|
def assert_restored(restored):
|
|
assert sorted(row.id for row in restored) == [1, 2]
|
|
for row in restored:
|
|
assert row.transient == "local default"
|
|
assert row.generated == ["local"]
|
|
assert row.details.transient == "nested default"
|
|
assert row.details.label == str(row.id)
|
|
assert row.retained == "persisted"
|
|
|
|
|
|
@pytest.mark.parametrize("explicit_schema", [False, True])
|
|
def test_excluded_fields_round_trip(tmp_path, explicit_schema):
|
|
data = rows()
|
|
kwargs = {"schema": Row} if explicit_schema else {}
|
|
db = lancedb.connect(tmp_path)
|
|
table = db.create_table("rows", data=data[:1], **kwargs)
|
|
table.add(data[1:])
|
|
assert_schema(table.schema)
|
|
reopened = lancedb.connect(tmp_path).open_table("rows")
|
|
assert_restored(reopened.search().to_pydantic(Row))
|
|
assert data[0].transient == "private"
|
|
assert data[0].generated == ["private"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("explicit_schema", [False, True])
|
|
async def test_excluded_fields_round_trip_async(tmp_path, explicit_schema):
|
|
data = rows()
|
|
kwargs = {"schema": Row} if explicit_schema else {}
|
|
db = await lancedb.connect_async(tmp_path)
|
|
table = await db.create_table("rows", data=data[:1], **kwargs)
|
|
await table.add(data[1:])
|
|
assert_schema(await table.schema())
|
|
reopened_db = await lancedb.connect_async(tmp_path)
|
|
reopened = await reopened_db.open_table("rows")
|
|
assert_restored(await reopened.query().to_pydantic(Row))
|
|
assert data[0].details.transient == "private"
|
|
|
|
|
|
@pytest.mark.parametrize("excluded", ["transient", "source", "vector"])
|
|
def test_excluded_fields_keep_embedding_metadata_consistent(
|
|
tmp_path, monkeypatch, excluded
|
|
):
|
|
from lancedb.embeddings.base import TextEmbeddingFunction
|
|
from lancedb.embeddings.registry import get_registry
|
|
from lancedb.pydantic import Vector
|
|
|
|
calls = []
|
|
|
|
class LocalEmbedding(TextEmbeddingFunction):
|
|
def ndims(self):
|
|
return 2
|
|
|
|
def generate_embeddings(self, texts):
|
|
calls.extend(texts)
|
|
return [[1.0, 0.0] for _ in texts]
|
|
|
|
registry = get_registry()
|
|
monkeypatch.setattr(registry, "_functions", registry._functions.copy())
|
|
registry.register("test-excluded-fields")(LocalEmbedding)
|
|
function = LocalEmbedding.create(max_retries=0)
|
|
|
|
class EmbeddedRow(LanceModel):
|
|
text: str = function.SourceField(default="alpha", exclude=excluded == "source")
|
|
vector: Vector(2) = function.VectorField(
|
|
default_factory=lambda: [1.0, 0.0], exclude=excluded == "vector"
|
|
)
|
|
transient: str = Field(default="local", exclude=True)
|
|
|
|
configs = EmbeddedRow.parse_embedding_functions()
|
|
assert len(configs) == (1 if excluded == "transient" else 0)
|
|
db = lancedb.connect(tmp_path)
|
|
data = [{"text": "alpha"}] if excluded == "transient" else [EmbeddedRow()]
|
|
table = db.create_table("embeddings", schema=EmbeddedRow, data=data)
|
|
assert "transient" not in table.schema.names
|
|
assert ("text" in table.schema.names) == (excluded != "source")
|
|
assert ("vector" in table.schema.names) == (excluded != "vector")
|
|
reopened = lancedb.connect(tmp_path).open_table("embeddings")
|
|
assert len(reopened.embedding_functions) == len(configs)
|
|
restored = reopened.search().to_pydantic(EmbeddedRow)
|
|
assert restored[0].text == "alpha"
|
|
assert restored[0].vector == [1.0, 0.0]
|
|
assert restored[0].transient == "local"
|
|
if excluded == "transient":
|
|
assert calls == ["alpha"]
|
|
assert reopened.search("alpha").limit(1).to_list()[0]["text"] == "alpha"
|
|
else:
|
|
assert calls == []
|