Skip to content
Open
3 changes: 2 additions & 1 deletion lamindb/models/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,7 @@ def __init__(
coerce=coerce_dtype,
n_features=n_features,
)
# pop before update_attributes/super so it never reaches Django fields or getattr
if not features and not slots and not is_type and not itype:
raise InvalidArgument(
"Please pass features or slots or itype or set is_type=True"
Expand Down Expand Up @@ -858,7 +859,7 @@ def _validate_kwargs_calculate_hash(
validated_kwargs = {
"name": name,
"description": description,
"type": None if type is UNSET else type,
"type": type,
"is_type": is_type,
"_dtype_str": dtype,
"otype": otype,
Expand Down
29 changes: 22 additions & 7 deletions lamindb/models/sqlrecord.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ def init_self_from_db(

def update_attributes(record: SQLRecord, attributes: dict[str, str]):
for key, value in attributes.items():
if getattr(record, key) != value and value is not None:
if value is not None and value is not UNSET and getattr(record, key) != value:
if key not in {"uid", "_dtype_str", "otype", "hash"}:
logger.warning(f"updated {key} from {getattr(record, key)} to {value}")
setattr(record, key, value)
Expand Down Expand Up @@ -624,10 +624,18 @@ def suggest_records_with_similar_names(
# the below needs to be .first() because there might be multiple records with the same
# name field in case the record is versioned (e.g. for Transform key)
if isinstance(record, HasType):
if kwargs.get("type", None) is None:

@falexwolf falexwolf Aug 13, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have expected something like this:

type = kwargs.get("type", UNSET)
if type is UNSET:
     subset = record.__class__.filter()
elif type is None:
    subset = record.__class__.filter(type__isnull=True)
else:
    subset = record.__class__.filter(type=kwargs["type"])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a question whether we should use kwargs["type"] instead of kwargs.get() because we might now actually have a contract that guarantees the presence of type since it would never be popped? 🤔

# "type" is always present in kwargs at this point (Solution A contract)
type = kwargs["type"]
if type is UNSET:
# user passed nothing → search all type contexts
# catches typed records with same name, fixes silent dup bug
subset = record.__class__.filter()
elif type is None:
# explicit type=None → search root-level (type IS NULL)
subset = record.__class__.filter(type__isnull=True)
else:
subset = record.__class__.filter(type=kwargs["type"])
# specific type → search within that type context only
subset = record.__class__.filter(type=type)
else:
subset = record.__class__
exact_match = subset.filter(**{name_field: kwargs[name_field]}).first()
Expand Down Expand Up @@ -1162,10 +1170,6 @@ class Meta:

def __init__(self, *args, **kwargs):
skip_validation = kwargs.pop("_skip_validation", False)
# strip sentinel before validate_fields and Django's Model.__init__ see it
# `is` never calls __eq__, so FeaturePredicate objects are safe
if isinstance(self, HasType) and kwargs.get("type", UNSET) is UNSET:
kwargs.pop("type", None)
if not args:

def resolve_fk_or_id(field_name: str) -> bool:
Expand Down Expand Up @@ -1214,13 +1218,21 @@ def resolve_fk_or_id(field_name: str) -> bool:
# the current one), so the record is created on that branch.
kwargs["created_on"] = kwargs["branch"]
if skip_validation:
# strip UNSET just before Django sees kwargs — FK descriptors reject non-model values
if isinstance(self, HasType) and kwargs.get("type", UNSET) is UNSET:
Comment thread
ishitajain9717 marked this conversation as resolved.
Outdated
kwargs.pop("type", None)
super().__init__(**kwargs)
else:
from ..core._settings import settings
from .can_curate import CanCurate
from .collection import Collection
from .transform import Transform

# ensure "type" is always present in kwargs for HasType models so that

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you understand why this is necessary? My assumption is that every HasType model populates type=UNSET or whatever the user passes. How can it be that there are cases where you need this defensive line?

If this is AI generated: Can you remove it and see if the tests pass?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the injection is needed for Project and Reference. Unlike Record, ULabel, Feature, and Schema, which explicitly pop and re-inject type (defaulting to UNSET) in their own constructors, Project and Reference have init methods that pass args, kwargs straight through to super(). So if a user calls Project(name="foo") without type, the key is simply absent from kwargs when BaseSQLRecord receives it.
Please suggest if it makes sense

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's great that you found this!

Project and Reference have init methods that pass args, kwargs straight through to super().

Hmmm. If that's what's happening then we should see type: UNSET in the kwargs, dict, right? I think your reasoning might be incorrect here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that we should always see type: UNSET in kwargs — that's the contract. But the problem is that UNSET is an internal sentinel. So "type" only ends up in kwargs if the model's init explicitly injects it. Do you agree?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I missed something in the previous PR: I understood that we changed type = None to type = UNSET in the previous PR. This isn't an internal but a user-facing change. If it wasn't user-facing, how would a user learn about the difference of passing type = None versus not passing type?

I'm convinced that every HasType registry needs to expose type = UNSET to the user also in the docs; and of course, if the user doesn't pass type that needs to get passed into the downstream calling cascade. That should IMO be automatic since UNSET is the default value of the constructor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with that, this should be a user facing change, and should also be mentioned in the docs specifically. I would make the change now

# suggest_records_with_similar_names can use kwargs["type"] unconditionally
if isinstance(self, HasType) and "type" not in kwargs:
kwargs["type"] = UNSET

validate_fields(self, kwargs)

# do not search for names if an id is passed; this is important
Expand Down Expand Up @@ -1270,6 +1282,9 @@ def resolve_fk_or_id(field_name: str) -> bool:
# track original values after replacing with the existing record
self._populate_tracked_fields()
return None
# strip UNSET just before Django sees kwargs — FK descriptors reject non-model values
if isinstance(self, HasType) and kwargs.get("type", UNSET) is UNSET:
kwargs.pop("type", None)
super().__init__(**kwargs)
if isinstance(self, ValidateFields):
# this will trigger validation against django validators
Expand Down
15 changes: 9 additions & 6 deletions tests/pydata/test_record_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,14 +690,17 @@ def test_feature_manager_raise_not_validated_values():
def test_name_lookup():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't we now missing a test here where the user explicitly passes type=None?

my_type = ln.Record(name="MyType", is_type=True).save()
label1 = ln.Record(name="label 1", type=my_type).save()
# same type → returns existing typed record
Comment thread
ishitajain9717 marked this conversation as resolved.
label2 = ln.Record(name="label 1", type=my_type)
assert label2 == label1
# no type passed, only typed record exists → fallback returns the typed one
Comment thread
ishitajain9717 marked this conversation as resolved.
label2 = ln.Record(name="label 1")
assert label2 != label1
label2.save()
label3 = ln.Record(name="label 1")
assert label3 == label2
label2.delete(permanent=True)
assert label2 == label1
# root-level record exists → no-type search finds root first before typed
root_label = ln.Record(name="root label 1").save()
label3 = ln.Record(name="root label 1")
assert label3 == root_label
root_label.delete(permanent=True)
label1.delete(permanent=True)
my_type.delete(permanent=True)

Expand Down Expand Up @@ -1194,7 +1197,7 @@ def test_record_features_add_remove_values():

# test passing ISO-format date string for date

test_record2 = ln.Record(name="test_record").save()
test_record2 = ln.Record(name="test_record_2").save()
# we could also test different ways of formatting but don't yet do that
# in to_dataframe() we enforce ISO format already
feature_date = ln.Feature.get(name="feature_date")
Expand Down
5 changes: 3 additions & 2 deletions tests/pydata/test_sqlrecord.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from lamindb.errors import FieldValidationError
from lamindb.models import sqlrecord as sqlrecord_module
from lamindb.models.sqlrecord import (
UNSET,
_get_record_kwargs,
_search,
check_key,
Expand Down Expand Up @@ -287,10 +288,10 @@ def test_suggest_similar_names():
assert ln.Record(name="Test experiment 1").uid == record1.uid

assert suggest_records_with_similar_names(
record1, "name", {"name": "Test experiment 1"}
record1, "name", {"name": "Test experiment 1", "type": UNSET}
)
assert not suggest_records_with_similar_names(
record2, "name", {"name": "Test experiment 123"}
record2, "name", {"name": "Test experiment 123", "type": UNSET}
)

queryset = _search(
Expand Down
Loading