Skip to content

🚸 Broaden name deduplication in HasType to search all contexts if no type is passed - #3850

Open
ishitajain9717 wants to merge 11 commits into
mainfrom
fixing_creating_projects
Open

🚸 Broaden name deduplication in HasType to search all contexts if no type is passed#3850
ishitajain9717 wants to merge 11 commits into
mainfrom
fixing_creating_projects

Conversation

@ishitajain9717

@ishitajain9717 ishitajain9717 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Add fallback to search typed objects when root-level deduplication finds nothing. If no type is passed, deduplication first searches root-level objects as before. If nothing is found there, it falls back to searching across all types — preventing silent duplicate creation when a same-named objects exists anywhere in the hierarchy.

Also adds a warning when type=None is explicitly passed for non-type objects, since root-level creation should be intentional and rare.

@falexwolf

Copy link
Copy Markdown
Member

Great!

Also adds a warning when type=None is explicitly passed for non-type objects, since root-level creation should be intentional and rare.

Can you condition this on is_type=False? If is_type=True, then this is likely intentional.

@ishitajain9717

Copy link
Copy Markdown
Contributor Author

Hi,
It is already conditioned on is_type=False.

@ishitajain9717
ishitajain9717 force-pushed the fixing_creating_projects branch from 69224bd to 1b60822 Compare August 12, 2026 13:53
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.98%. Comparing base (112244e) to head (9033d3a).
⚠️ Report is 9 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3850      +/-   ##
==========================================
- Coverage   91.55%   86.98%   -4.58%     
==========================================
  Files          88       88              
  Lines       15747    15845      +98     
==========================================
- Hits        14417    13782     -635     
- Misses       1330     2063     +733     
Files with missing lines Coverage Δ
lamindb/models/schema.py 93.91% <ø> (ø)
lamindb/models/sqlrecord.py 92.16% <100.00%> (+0.08%) ⬆️

... and 16 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deployment URL: https://7dfd74f7.lamindb.pages.dev

@falexwolf falexwolf changed the title Broaden type scoped deduplication to search all contexts when type= is not passed 🚸 Broaden type scoped deduplication to search all contexts when type= is not passed Aug 13, 2026
@falexwolf falexwolf changed the title 🚸 Broaden type scoped deduplication to search all contexts when type= is not passed 🚸 Broaden type-scoped name deduplication to search all contexts if no type is passed Aug 13, 2026
@falexwolf falexwolf changed the title 🚸 Broaden type-scoped name deduplication to search all contexts if no type is passed 🚸 Broaden name deduplication in HasType to search all contexts if no type is passed Aug 13, 2026
@falexwolf falexwolf changed the title 🚸 Broaden name deduplication in HasType to search all contexts if no type is passed 🚸 Broaden name deduplication in HasType to search all contexts if no type is passed Aug 13, 2026
Comment thread lamindb/models/sqlrecord.py Outdated

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?

# 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? 🤔

Comment thread lamindb/models/sqlrecord.py Outdated
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.

Comment thread lamindb/models/sqlrecord.py Outdated
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:

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.

The logic here is incorrect: if "type" is not in kwargs the .get() statement returns UNSET and one enters kwargs.pop() even though there is no key "type"!

IMO the same rationale that would make https://github.com/laminlabs/lamindb/pull/3850/changes#r3782589607 easy would make this easy: replacing kwargs.get() with kwargs["type"] is UNSET.

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, this is logically imprecise. I am correcting it in upcoming changes

def test_name_lookup():
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

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.

Great idea to add the comments here! 🙏

# same type → returns existing typed record
label2 = ln.Record(name="label 1", type=my_type)
assert label2 == label1
# no type passed, only typed record exists → fallback returns the typed one

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.

Great! This is the behavior change now.

@@ -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?

@falexwolf

Copy link
Copy Markdown
Member

Thanks, @ishitajain9717! This is now much better designed than yesterday! 🙏

Can you mark the comments that you resolved as resolved?

Now only very small issues are left, for which I made new comments. ☺️

Comment thread lamindb/models/sqlrecord.py Outdated
type = kwargs["type"]
if type is UNSET:
if type is None:
# explicit type=None → user wants a new root-level record, skip dedup

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.

This is wrong, isn't it? If we pass type=None we need to deduplicate on the root level.

# explicit type=None → search root-level (type IS NULL)
            subset = record.__class__.filter(type__isnull=True)

@ishitajain9717 ishitajain9717 Aug 14, 2026

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 realised it immediately, and I was just correcting it 😅. I was actually reviewing changes

Comment thread tests/pydata/test_record_basics.py Outdated
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, even if a root-level record exists → still skips dedup

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants