Skip to content
Open
1 change: 1 addition & 0 deletions lamindb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@
DB,
)
from .models.save import save
from .models.sqlrecord import UNSET
from . import core
from . import integrations
from . import curators
Expand Down
2 changes: 1 addition & 1 deletion lamindb/models/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,7 @@ def __init__(
| Registry
| list[Registry]
| FieldAttr,
type: Feature | None = None,
type: Feature | None = UNSET,
is_type: bool = False,
unit: str | None = None,
description: str | None = None,
Expand Down
6 changes: 3 additions & 3 deletions lamindb/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from .record import Record
from .run import Run, TracksRun, TracksUpdates, User
from .schema import Schema
from .sqlrecord import BaseSQLRecord, HasType, IsLink, SQLRecord, ValidateFields
from .sqlrecord import UNSET, BaseSQLRecord, HasType, IsLink, SQLRecord, ValidateFields
from .transform import Transform
from .ulabel import ULabel

Expand Down Expand Up @@ -217,7 +217,7 @@ class Meta(SQLRecord.Meta, TracksRun.Meta, TracksUpdates.Meta):
def __init__(
self,
name: str,
type: Reference | None = None,
type: Reference | None = UNSET,
is_type: bool = False,
abbr: str | None = None,
url: str | None = None,
Expand Down Expand Up @@ -449,7 +449,7 @@ class Meta(SQLRecord.Meta, TracksRun.Meta, TracksUpdates.Meta):
def __init__(
self,
name: str,
type: Project | None = None,
type: Project | None = UNSET,
is_type: bool = False,
abbr: str | None = None,
url: str | None = None,
Expand Down
2 changes: 1 addition & 1 deletion lamindb/models/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,7 @@ class Meta(SQLRecord.Meta, TracksRun.Meta, TracksUpdates.Meta):
def __init__(
self,
name: str | None = None,
type: Record | None = None,
type: Record | None = UNSET,
is_type: bool = False,
features: dict[str | Feature, Any] | None = None,
description: str | None = None,
Expand Down
5 changes: 3 additions & 2 deletions lamindb/models/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ def __init__(
name: str | None = None,
description: str | None = None,
itype: str | Registry | FieldAttr | None = None,
type: Schema | None = None,
type: Schema | None = UNSET,
is_type: bool = False,
index: Feature | None = None,
flexible: bool | None = None,
Expand Down 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
33 changes: 26 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 → root-level dedup (type IS NULL)
subset = record.__class__.filter(type__isnull=True)
else:
subset = record.__class__.filter(type=kwargs["type"])
# specific type object → scoped dedup within that type
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 @@ -1213,7 +1217,19 @@ def resolve_fk_or_id(field_name: str) -> bool:
# `kwargs["branch"]` is now always a non-None Branch (explicit or
# the current one), so the record is created on that branch.
kwargs["created_on"] = kwargs["branch"]
# HasType models like Record, ULabel, Feature, Schema each explicitly
# pop and re-inject "type" (defaulting to UNSET) in their own __init__,
# so "type" is already in kwargs for them.
# Project and Reference pass kwargs straight through without touching "type",
# so we inject UNSET here to uphold the contract that kwargs["type"] is
# always readable — and so the strip below can use kwargs["type"] directly.
if isinstance(self, HasType) and "type" not in kwargs:
kwargs["type"] = UNSET

if skip_validation:
# strip UNSET just before Django sees kwargs — FK descriptors reject non-model values
if isinstance(self, HasType) and kwargs["type"] is UNSET:
kwargs.pop("type")
super().__init__(**kwargs)
else:
from ..core._settings import settings
Expand Down Expand Up @@ -1270,6 +1286,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["type"] is UNSET:
kwargs.pop("type")
super().__init__(**kwargs)
if isinstance(self, ValidateFields):
# this will trigger validation against django validators
Expand Down
2 changes: 1 addition & 1 deletion lamindb/models/ulabel.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ class Meta(SQLRecord.Meta, TracksRun.Meta, TracksUpdates.Meta):
def __init__(
self,
name: str,
type: ULabel | None = None,
type: ULabel | None = UNSET,
is_type: bool = False,
description: str | None = None,
reference: str | None = None,
Expand Down
22 changes: 16 additions & 6 deletions tests/pydata/test_record_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,14 +690,24 @@ 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
# explicit type=None → root-level dedup: label1 is typed so not found → new record
label_new = ln.Record(name="label 1", type=None)
assert label_new != label1
assert label_new._state.adding # not yet saved, truly a new record
# explicit type=None, root-level record exists → root-level dedup finds it → returns it
root_label = ln.Record(name="root label 1").save()
label3 = ln.Record(name="root label 1", type=None)
assert label3 == root_label
# no type passed (UNSET) → search all → finds the existing root-level record
label4 = ln.Record(name="root label 1")
assert label4 == root_label
root_label.delete(permanent=True)
label1.delete(permanent=True)
my_type.delete(permanent=True)

Expand Down Expand Up @@ -1194,7 +1204,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