Skip to content
Open
5 changes: 4 additions & 1 deletion lamindb/models/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,8 @@ def __init__(
coerce=coerce_dtype,
n_features=n_features,
)
# pop before update_attributes/super so it never reaches Django fields or getattr
type_val = validated_kwargs.pop("_type_val")
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 @@ -775,7 +777,7 @@ def __init__(
validated_kwargs["uid"] = base62_16()

validated_kwargs.update(space_branch_kwargs)
super().__init__(**validated_kwargs)
super().__init__(**validated_kwargs, _type_val=type_val)

def query_schemas(self) -> QuerySet:
"""Query schemas of sub types.
Expand Down Expand Up @@ -859,6 +861,7 @@ def _validate_kwargs_calculate_hash(
"name": name,
"description": description,
"type": None if type is UNSET else type,
"_type_val": type,
"is_type": is_type,
"_dtype_str": dtype,
"otype": otype,
Expand Down
30 changes: 21 additions & 9 deletions lamindb/models/sqlrecord.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ def get_branch_id_for_create(


def suggest_records_with_similar_names(
record: SQLRecord, name_field: str, kwargs
record: SQLRecord, name_field: str, kwargs, type_val=UNSET

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.

Why do we need to introduce type_val? The kwargs have type in them and you can detect whether it matches UNSET. Why is that not possible?

) -> SQLRecord | None:
"""Returns a record if found exact match, otherwise None.

Expand All @@ -624,10 +624,17 @@ 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? 🤔

subset = record.__class__.filter(type__isnull=True)
else:
subset = record.__class__.filter(type=kwargs["type"])
if type_val is None:
# explicit type=None → always create new at root, skip dedup
if not kwargs.get("is_type", False):
logger.warning(
f"Creating a root-level {record.__class__.__name__.lower()} without"
" a type. In most cases, objects should be created under a type."
)
return None
# UNSET: search all type contexts (catches typed records with same name, fixes silent dup bug)
# <object>: search within that type context only
subset = record.__class__ if type_val is UNSET else record.__class__.filter(type=type_val)
else:
subset = record.__class__
exact_match = subset.filter(**{name_field: kwargs[name_field]}).first()
Expand Down Expand Up @@ -1162,10 +1169,15 @@ 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
# capture user intent before stripping: UNSET=not passed, None=explicit None, obj=typed
# Schema pre-computes this (converts UNSET→None for hashing) and passes it via _type_val
# `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 isinstance(self, HasType):
type_val = kwargs.pop("_type_val", kwargs.get("type", UNSET))
if type_val is UNSET:
kwargs.pop("type", None)
else:
type_val = UNSET
if not args:

def resolve_fk_or_id(field_name: str) -> bool:
Expand Down Expand Up @@ -1240,7 +1252,7 @@ def resolve_fk_or_id(field_name: str) -> bool:
):
name_field = getattr(self, "_name_field", "name")
exact_match = suggest_records_with_similar_names(
self, name_field, kwargs
self, name_field, kwargs, type_val
)
if exact_match is not None:
if "version_tag" in kwargs:
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", type=None).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_val=UNSET
)
assert not suggest_records_with_similar_names(
record2, "name", {"name": "Test experiment 123"}
record2, "name", {"name": "Test experiment 123"}, type_val=UNSET
)

queryset = _search(
Expand Down
Loading