lamindb 1.0.4__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. lamindb/__init__.py +14 -5
  2. lamindb/_artifact.py +174 -57
  3. lamindb/_can_curate.py +27 -8
  4. lamindb/_collection.py +85 -51
  5. lamindb/_feature.py +177 -41
  6. lamindb/_finish.py +222 -81
  7. lamindb/_from_values.py +83 -98
  8. lamindb/_parents.py +4 -4
  9. lamindb/_query_set.py +59 -17
  10. lamindb/_record.py +171 -53
  11. lamindb/_run.py +4 -4
  12. lamindb/_save.py +33 -10
  13. lamindb/_schema.py +135 -38
  14. lamindb/_storage.py +1 -1
  15. lamindb/_tracked.py +106 -0
  16. lamindb/_transform.py +21 -8
  17. lamindb/_ulabel.py +5 -14
  18. lamindb/base/validation.py +2 -6
  19. lamindb/core/__init__.py +13 -14
  20. lamindb/core/_context.py +39 -36
  21. lamindb/core/_data.py +29 -25
  22. lamindb/core/_describe.py +1 -1
  23. lamindb/core/_django.py +1 -1
  24. lamindb/core/_feature_manager.py +54 -44
  25. lamindb/core/_label_manager.py +4 -4
  26. lamindb/core/_mapped_collection.py +20 -7
  27. lamindb/core/datasets/__init__.py +6 -1
  28. lamindb/core/datasets/_core.py +12 -11
  29. lamindb/core/datasets/_small.py +66 -20
  30. lamindb/core/exceptions.py +1 -90
  31. lamindb/core/loaders.py +7 -13
  32. lamindb/core/relations.py +6 -4
  33. lamindb/core/storage/_anndata_accessor.py +41 -0
  34. lamindb/core/storage/_backed_access.py +2 -2
  35. lamindb/core/storage/_pyarrow_dataset.py +25 -15
  36. lamindb/core/storage/_tiledbsoma.py +56 -12
  37. lamindb/core/storage/paths.py +41 -22
  38. lamindb/core/subsettings/_creation_settings.py +4 -16
  39. lamindb/curators/__init__.py +2168 -833
  40. lamindb/curators/_cellxgene_schemas/__init__.py +26 -0
  41. lamindb/curators/_cellxgene_schemas/schema_versions.yml +104 -0
  42. lamindb/errors.py +96 -0
  43. lamindb/integrations/_vitessce.py +3 -3
  44. lamindb/migrations/0069_squashed.py +76 -75
  45. lamindb/migrations/0075_lamindbv1_part5.py +4 -5
  46. lamindb/migrations/0082_alter_feature_dtype.py +21 -0
  47. lamindb/migrations/0083_alter_feature_is_type_alter_flextable_is_type_and_more.py +94 -0
  48. lamindb/migrations/0084_alter_schemafeature_feature_and_more.py +35 -0
  49. lamindb/migrations/0085_alter_feature_is_type_alter_flextable_is_type_and_more.py +63 -0
  50. lamindb/migrations/0086_various.py +95 -0
  51. lamindb/migrations/0087_rename__schemas_m2m_artifact_feature_sets_and_more.py +41 -0
  52. lamindb/migrations/0088_schema_components.py +273 -0
  53. lamindb/migrations/0088_squashed.py +4372 -0
  54. lamindb/models.py +423 -156
  55. {lamindb-1.0.4.dist-info → lamindb-1.1.0.dist-info}/METADATA +10 -7
  56. lamindb-1.1.0.dist-info/RECORD +95 -0
  57. lamindb/curators/_spatial.py +0 -528
  58. lamindb/migrations/0052_squashed.py +0 -1261
  59. lamindb/migrations/0053_alter_featureset_hash_alter_paramvalue_created_by_and_more.py +0 -57
  60. lamindb/migrations/0054_alter_feature_previous_runs_and_more.py +0 -35
  61. lamindb/migrations/0055_artifact_type_artifactparamvalue_and_more.py +0 -61
  62. lamindb/migrations/0056_rename_ulabel_ref_is_name_artifactulabel_label_ref_is_name_and_more.py +0 -22
  63. lamindb/migrations/0057_link_models_latest_report_and_others.py +0 -356
  64. lamindb/migrations/0058_artifact__actions_collection__actions.py +0 -22
  65. lamindb/migrations/0059_alter_artifact__accessor_alter_artifact__hash_type_and_more.py +0 -31
  66. lamindb/migrations/0060_alter_artifact__actions.py +0 -22
  67. lamindb/migrations/0061_alter_collection_meta_artifact_alter_run_environment_and_more.py +0 -45
  68. lamindb/migrations/0062_add_is_latest_field.py +0 -32
  69. lamindb/migrations/0063_populate_latest_field.py +0 -45
  70. lamindb/migrations/0064_alter_artifact_version_alter_collection_version_and_more.py +0 -33
  71. lamindb/migrations/0065_remove_collection_feature_sets_and_more.py +0 -22
  72. lamindb/migrations/0066_alter_artifact__feature_values_and_more.py +0 -352
  73. lamindb/migrations/0067_alter_featurevalue_unique_together_and_more.py +0 -20
  74. lamindb/migrations/0068_alter_artifactulabel_unique_together_and_more.py +0 -20
  75. lamindb/migrations/0069_alter_artifact__accessor_alter_artifact__hash_type_and_more.py +0 -1294
  76. lamindb-1.0.4.dist-info/RECORD +0 -102
  77. {lamindb-1.0.4.dist-info → lamindb-1.1.0.dist-info}/LICENSE +0 -0
  78. {lamindb-1.0.4.dist-info → lamindb-1.1.0.dist-info}/WHEEL +0 -0
lamindb/_from_values.py CHANGED
@@ -9,8 +9,6 @@ from lamin_utils import colors, logger
9
9
  from lamindb._query_set import RecordList
10
10
  from lamindb.models import Record
11
11
 
12
- from .core._settings import settings
13
-
14
12
  if TYPE_CHECKING:
15
13
  from collections.abc import Iterable
16
14
 
@@ -29,88 +27,72 @@ def get_or_create_records(
29
27
  mute: bool = False,
30
28
  ) -> RecordList:
31
29
  """Get or create records from iterables."""
32
- registry = field.field.model
30
+ registry = field.field.model # type: ignore
33
31
  if create:
34
- return RecordList([registry(**{field.field.name: value}) for value in iterable])
35
- creation_search_names = settings.creation.search_names
32
+ return RecordList([registry(**{field.field.name: value}) for value in iterable]) # type: ignore
36
33
  organism = _get_organism_record(field, organism)
37
- settings.creation.search_names = False
38
- try:
39
- iterable_idx = index_iterable(iterable)
40
-
41
- # returns existing records & non-existing values
42
- records, nonexist_values, msg = get_existing_records(
43
- iterable_idx=iterable_idx,
44
- field=field,
45
- organism=organism,
46
- mute=mute,
47
- )
34
+ iterable_idx = index_iterable(iterable)
35
+
36
+ # returns existing records & non-existing values
37
+ records, nonexist_values, msg = get_existing_records(
38
+ iterable_idx=iterable_idx,
39
+ field=field,
40
+ organism=organism,
41
+ mute=mute,
42
+ )
48
43
 
49
- # new records to be created based on new values
50
- if len(nonexist_values) > 0:
51
- source_record = None
52
- if from_source:
53
- if isinstance(source, Record):
54
- source_record = source
55
- if not source_record and hasattr(registry, "public"):
56
- if organism is None:
57
- organism = _ensembl_prefix(nonexist_values[0], field, organism)
58
- organism = _get_organism_record(field, organism, force=True)
59
-
60
- if source_record:
61
- from bionty.core._add_ontology import check_source_in_db
62
-
63
- check_source_in_db(registry=registry, source=source_record)
64
-
65
- from_source = not source_record.in_db
66
- elif hasattr(registry, "source_id"):
67
- from_source = True
68
- else:
69
- from_source = False
70
-
71
- if from_source:
72
- records_bionty, unmapped_values = create_records_from_source(
73
- iterable_idx=nonexist_values,
74
- field=field,
75
- organism=organism,
76
- source=source_record,
77
- msg=msg,
78
- mute=mute,
44
+ # new records to be created based on new values
45
+ if len(nonexist_values) > 0:
46
+ source_record = None
47
+ if from_source:
48
+ if isinstance(source, Record):
49
+ source_record = source
50
+ if not source_record and hasattr(registry, "public"):
51
+ if organism is None:
52
+ organism = _ensembl_prefix(nonexist_values[0], field, organism)
53
+ organism = _get_organism_record(field, organism, force=True)
54
+
55
+ if source_record:
56
+ from bionty.core._add_ontology import check_source_in_db
57
+
58
+ check_source_in_db(registry=registry, source=source_record)
59
+
60
+ from_source = not source_record.in_db
61
+ elif hasattr(registry, "source_id"):
62
+ from_source = True
63
+ else:
64
+ from_source = False
65
+
66
+ if from_source:
67
+ records_bionty, unmapped_values = create_records_from_source(
68
+ iterable_idx=nonexist_values,
69
+ field=field,
70
+ organism=organism,
71
+ source=source_record,
72
+ msg=msg,
73
+ mute=mute,
74
+ )
75
+ if len(records_bionty) > 0:
76
+ msg = ""
77
+ for record in records_bionty:
78
+ record._from_source = True
79
+ records += records_bionty
80
+ else:
81
+ unmapped_values = nonexist_values
82
+ # unmapped new_ids will NOT create records
83
+ if len(unmapped_values) > 0:
84
+ if len(msg) > 0 and not mute:
85
+ logger.success(msg)
86
+ s = "" if len(unmapped_values) == 1 else "s"
87
+ print_values = colors.yellow(_format_values(unmapped_values))
88
+ name = registry.__name__
89
+ n_nonval = colors.yellow(f"{len(unmapped_values)} non-validated")
90
+ if not mute:
91
+ logger.warning(
92
+ f"{colors.red('did not create')} {name} record{s} for "
93
+ f"{n_nonval} {colors.italic(f'{field.field.name}{s}')}: {print_values}" # type: ignore
79
94
  )
80
- if len(records_bionty) > 0:
81
- msg = ""
82
- for record in records_bionty:
83
- record._from_source = True
84
- records += records_bionty
85
- else:
86
- unmapped_values = nonexist_values
87
- # unmapped new_ids will NOT create records
88
- if len(unmapped_values) > 0:
89
- if len(msg) > 0 and not mute:
90
- logger.success(msg)
91
- s = "" if len(unmapped_values) == 1 else "s"
92
- print_values = colors.yellow(_format_values(unmapped_values))
93
- name = registry.__name__
94
- n_nonval = colors.yellow(f"{len(unmapped_values)} non-validated")
95
- if not mute:
96
- logger.warning(
97
- f"{colors.red('did not create')} {name} record{s} for "
98
- f"{n_nonval} {colors.italic(f'{field.field.name}{s}')}: {print_values}"
99
- )
100
- # if registry.__get_module_name__() == "bionty" or registry == ULabel:
101
- # if isinstance(iterable, pd.Series):
102
- # feature = iterable.name
103
- # feature_name = None
104
- # if isinstance(feature, str):
105
- # feature_name = feature
106
- # if feature_name is not None:
107
- # if feature_name is not None:
108
- # for record in records:
109
- # record._feature = feature_name
110
- # logger.debug(f"added default feature '{feature_name}'")
111
- return RecordList(records)
112
- finally:
113
- settings.creation.search_names = creation_search_names
95
+ return RecordList(records)
114
96
 
115
97
 
116
98
  def get_existing_records(
@@ -120,10 +102,10 @@ def get_existing_records(
120
102
  mute: bool = False,
121
103
  ):
122
104
  # NOTE: existing records matching is agnostic to the source
123
- model = field.field.model
124
- if organism is None and field.field.name == "ensembl_gene_id":
105
+ model = field.field.model # type: ignore
106
+ if organism is None and field.field.name == "ensembl_gene_id": # type: ignore
125
107
  if len(iterable_idx) > 0:
126
- organism = _ensembl_prefix(iterable_idx[0], field, organism)
108
+ organism = _ensembl_prefix(iterable_idx[0], field, organism) # type: ignore
127
109
  organism = _get_organism_record(field, organism, force=True)
128
110
 
129
111
  # standardize based on the DB reference
@@ -152,6 +134,7 @@ def get_existing_records(
152
134
  is_validated = model.validate(
153
135
  iterable_idx, field=field, organism=organism, mute=True
154
136
  )
137
+
155
138
  if len(is_validated) > 0:
156
139
  validated = iterable_idx[is_validated]
157
140
  else:
@@ -165,7 +148,7 @@ def get_existing_records(
165
148
  msg = (
166
149
  "loaded"
167
150
  f" {colors.green(f'{len(validated)} {model.__name__} record{s}')}"
168
- f" matching {colors.italic(f'{field.field.name}')}: {print_values}"
151
+ f" matching {colors.italic(f'{field.field.name}')}: {print_values}" # type: ignore
169
152
  )
170
153
  if len(syn_mapper) > 0:
171
154
  s = "" if len(syn_mapper) == 1 else "s"
@@ -189,7 +172,7 @@ def get_existing_records(
189
172
  # get all existing records in the db
190
173
  # if necessary, create records for the values in kwargs
191
174
  # k:v -> k:v_record
192
- query = {f"{field.field.name}__in": iterable_idx.values}
175
+ query = {f"{field.field.name}__in": iterable_idx.values} # type: ignore
193
176
  if organism is not None:
194
177
  query["organism"] = organism
195
178
  records = model.filter(**query).list()
@@ -209,7 +192,7 @@ def create_records_from_source(
209
192
  msg: str = "",
210
193
  mute: bool = False,
211
194
  ):
212
- model = field.field.model
195
+ model = field.field.model # type: ignore
213
196
  records: list = []
214
197
  # populate additional fields from bionty
215
198
  from bionty._bionty import get_source_record
@@ -232,11 +215,11 @@ def create_records_from_source(
232
215
  # standardize in the bionty reference
233
216
  # do not inspect synonyms if the field is not name field
234
217
  inspect_synonyms = True
235
- if hasattr(model, "_name_field") and field.field.name != model._name_field:
218
+ if hasattr(model, "_name_field") and field.field.name != model._name_field: # type: ignore
236
219
  inspect_synonyms = False
237
220
  result = public_ontology.inspect(
238
221
  iterable_idx,
239
- field=field.field.name,
222
+ field=field.field.name, # type: ignore
240
223
  mute=True,
241
224
  inspect_synonyms=inspect_synonyms,
242
225
  )
@@ -257,12 +240,14 @@ def create_records_from_source(
257
240
 
258
241
  # create records for values that are found in the bionty reference
259
242
  # matching either field or synonyms
260
- mapped_values = iterable_idx.intersection(bionty_df[field.field.name])
243
+ mapped_values = iterable_idx.intersection(bionty_df[field.field.name]) # type: ignore
261
244
 
262
245
  multi_msg = ""
263
246
  if len(mapped_values) > 0:
264
247
  bionty_kwargs, multi_msg = _bulk_create_dicts_from_df(
265
- keys=mapped_values, column_name=field.field.name, df=bionty_df
248
+ keys=mapped_values,
249
+ column_name=field.field.name, # type: ignore
250
+ df=bionty_df,
266
251
  )
267
252
 
268
253
  if hasattr(model, "organism_id") and organism is None:
@@ -274,7 +259,7 @@ def create_records_from_source(
274
259
  else {"source": source}
275
260
  )
276
261
  for bk in bionty_kwargs:
277
- records.append(model(**bk, **create_kwargs))
262
+ records.append(model(**bk, **create_kwargs, _skip_validation=True))
278
263
 
279
264
  # number of records that matches field (not synonyms)
280
265
  validated = result.validated
@@ -288,7 +273,7 @@ def create_records_from_source(
288
273
  logger.success(
289
274
  "created"
290
275
  f" {colors.purple(f'{len(validated)} {model.__name__} record{s} from Bionty')}"
291
- f" matching {colors.italic(f'{field.field.name}')}: {print_values}"
276
+ f" matching {colors.italic(f'{field.field.name}')}: {print_values}" # type: ignore
292
277
  )
293
278
 
294
279
  # make sure that synonyms logging appears after the field logging
@@ -365,7 +350,7 @@ def _has_organism_field(registry: type[Record]) -> bool:
365
350
  return False
366
351
 
367
352
 
368
- def _get_organism_record(
353
+ def _get_organism_record( # type: ignore
369
354
  field: StrField, organism: str | Record, force: bool = False
370
355
  ) -> Record:
371
356
  """Get organism record.
@@ -375,10 +360,10 @@ def _get_organism_record(
375
360
  organism: the organism to get the record for
376
361
  force: whether to force fetching the organism record
377
362
  """
378
- registry = field.field.model
363
+ registry = field.field.model # type: ignore
379
364
  check = True
380
365
  if not force and hasattr(registry, "_ontology_id_field"):
381
- check = field.field.name != registry._ontology_id_field
366
+ check = field.field.name != registry._ontology_id_field # type: ignore
382
367
  # e.g. bionty.CellMarker has "name" as _ontology_id_field
383
368
  if not registry._ontology_id_field.endswith("id"):
384
369
  check = True
@@ -397,10 +382,10 @@ def _get_organism_record(
397
382
 
398
383
 
399
384
  def _ensembl_prefix(id: str, field: StrField, organism: Record | None) -> str | None:
400
- if field.field.name == "ensembl_gene_id" and organism is None:
385
+ if field.field.name == "ensembl_gene_id" and organism is None: # type: ignore
401
386
  if id.startswith("ENSG"):
402
- organism = "human"
387
+ organism = "human" # type: ignore
403
388
  elif id.startswith("ENSMUSG"):
404
- organism = "mouse"
389
+ organism = "mouse" # type: ignore
405
390
 
406
391
  return organism
lamindb/_parents.py CHANGED
@@ -44,7 +44,7 @@ def _query_relatives(
44
44
  kind: Literal["parents", "children"],
45
45
  cls: type[HasParents],
46
46
  ) -> QuerySet:
47
- relatives = cls.objects.none()
47
+ relatives = cls.objects.none() # type: ignore
48
48
  if len(records) == 0:
49
49
  return relatives
50
50
  for record in records:
@@ -350,9 +350,9 @@ def _record_label(record: Record, field: str | None = None):
350
350
  )
351
351
  elif isinstance(record, Run):
352
352
  if record.transform.description:
353
- name = f'{record.transform.description.replace("&", "&")}'
353
+ name = f"{record.transform.description.replace('&', '&')}"
354
354
  elif record.transform.key:
355
- name = f'{record.transform.key.replace("&", "&")}'
355
+ name = f"{record.transform.key.replace('&', '&')}"
356
356
  else:
357
357
  name = f"{record.transform.uid}"
358
358
  user_display = (
@@ -366,7 +366,7 @@ def _record_label(record: Record, field: str | None = None):
366
366
  rf" user={user_display}<BR/>run={format_field_value(record.started_at)}</FONT>>"
367
367
  )
368
368
  elif isinstance(record, Transform):
369
- name = f'{record.name.replace("&", "&amp;")}'
369
+ name = f"{record.name.replace('&', '&amp;')}"
370
370
  return (
371
371
  rf'<{TRANSFORM_EMOJIS.get(str(record.type), "💫")} {name}<BR/><FONT COLOR="GREY" POINT-SIZE="10"'
372
372
  rf' FACE="Monospace">uid={record.uid}<BR/>type={record.type},'
lamindb/_query_set.py CHANGED
@@ -8,6 +8,7 @@ from collections.abc import Iterable as IterableType
8
8
  from typing import TYPE_CHECKING, Any, Generic, NamedTuple, TypeVar
9
9
 
10
10
  import pandas as pd
11
+ from django.core.exceptions import FieldError
11
12
  from django.db import models
12
13
  from django.db.models import F, ForeignKey, ManyToManyField
13
14
  from django.db.models.fields.related import ForeignObjectRel
@@ -26,7 +27,7 @@ from lamindb.models import (
26
27
  Transform,
27
28
  )
28
29
 
29
- from .core.exceptions import DoesNotExist
30
+ from .errors import DoesNotExist
30
31
 
31
32
  T = TypeVar("T")
32
33
 
@@ -91,14 +92,12 @@ def get_backward_compat_filter_kwargs(queryset, expressions):
91
92
  "n_objects": "n_files",
92
93
  "visibility": "_branch_code", # for convenience (and backward compat <1.0)
93
94
  "transform": "run__transform", # for convenience (and backward compat <1.0)
94
- "feature_sets": "_schemas_m2m",
95
95
  "type": "kind",
96
96
  "_accessor": "otype",
97
97
  }
98
98
  elif queryset.model == Schema:
99
99
  name_mappings = {
100
100
  "registry": "itype",
101
- "artifacts": "_artifacts_m2m", # will raise warning when we start to migrate over
102
101
  }
103
102
  else:
104
103
  return expressions
@@ -114,7 +113,6 @@ def get_backward_compat_filter_kwargs(queryset, expressions):
114
113
  if parts[0] not in {
115
114
  "transform",
116
115
  "visibility",
117
- "feature_sets",
118
116
  "schemas",
119
117
  "artifacts",
120
118
  }:
@@ -203,7 +201,7 @@ def get(
203
201
  qs = QuerySet(model=registry_or_queryset)
204
202
  registry = registry_or_queryset
205
203
  if isinstance(idlike, int):
206
- return super(QuerySet, qs).get(id=idlike)
204
+ return super(QuerySet, qs).get(id=idlike) # type: ignore
207
205
  elif isinstance(idlike, str):
208
206
  qs = qs.filter(uid__startswith=idlike)
209
207
  if issubclass(registry, IsVersioned):
@@ -216,6 +214,9 @@ def get(
216
214
  else:
217
215
  assert idlike is None # noqa: S101
218
216
  expressions = process_expressions(qs, expressions)
217
+ # inject is_latest for consistency with idlike
218
+ if issubclass(registry, IsVersioned) and "is_latest" not in expressions:
219
+ expressions["is_latest"] = True
219
220
  return registry.objects.using(qs.db).get(**expressions)
220
221
 
221
222
 
@@ -537,13 +538,13 @@ class QuerySet(models.QuerySet):
537
538
  elif isinstance(include, str):
538
539
  include = [include]
539
540
  include = get_backward_compat_filter_kwargs(self, include)
540
- field_names = get_basic_field_names(self, include, features)
541
+ field_names = get_basic_field_names(self, include, features) # type: ignore
541
542
 
542
543
  annotate_kwargs = {}
543
544
  if features:
544
545
  annotate_kwargs.update(get_feature_annotate_kwargs(features))
545
546
  if include:
546
- include = include.copy()[::-1]
547
+ include = include.copy()[::-1] # type: ignore
547
548
  include_kwargs = {s: F(s) for s in include if s not in field_names}
548
549
  annotate_kwargs.update(include_kwargs)
549
550
  if annotate_kwargs:
@@ -561,12 +562,6 @@ class QuerySet(models.QuerySet):
561
562
  pk_column_name = pk_name if pk_name in df.columns else f"{pk_name}_id"
562
563
  if pk_column_name in df_reshaped.columns:
563
564
  df_reshaped = df_reshaped.set_index(pk_column_name)
564
-
565
- # Compatibility code
566
- df_reshaped.columns = df_reshaped.columns.str.replace(
567
- r"_schemas_m2m", "feature_sets", regex=True
568
- )
569
-
570
565
  return df_reshaped
571
566
 
572
567
  def delete(self, *args, **kwargs):
@@ -601,17 +596,64 @@ class QuerySet(models.QuerySet):
601
596
  return None
602
597
  return self[0]
603
598
 
599
+ def _handle_unknown_field(self, error: FieldError) -> None:
600
+ """Suggest available fields if an unknown field was passed."""
601
+ if "Cannot resolve keyword" in str(error):
602
+ field = str(error).split("'")[1]
603
+ fields = ", ".join(
604
+ sorted(
605
+ f.name
606
+ for f in self.model._meta.get_fields()
607
+ if not f.name.startswith("_")
608
+ and not f.name.startswith("links_")
609
+ and not f.name.endswith("_id")
610
+ )
611
+ )
612
+ raise FieldError(
613
+ f"Unknown field '{field}'. Available fields: {fields}"
614
+ ) from None
615
+ raise error # pragma: no cover
616
+
604
617
  def get(self, idlike: int | str | None = None, **expressions) -> Record:
605
618
  """Query a single record. Raises error if there are more or none."""
606
- return get(self, idlike, **expressions)
619
+ try:
620
+ return get(self, idlike, **expressions)
621
+ except ValueError as e:
622
+ # Pass through original error for explicit id lookups
623
+ if "Field 'id' expected a number" in str(e):
624
+ if "id" in expressions:
625
+ raise
626
+ field = next(iter(expressions))
627
+ raise FieldError(
628
+ f"Invalid lookup '{expressions[field]}' for {field}. Did you mean {field}__name?"
629
+ ) from None
630
+ raise # pragma: no cover
631
+ except FieldError as e:
632
+ self._handle_unknown_field(e)
633
+ raise # pragma: no cover
607
634
 
608
635
  def filter(self, *queries, **expressions) -> QuerySet:
609
636
  """Query a set of records."""
637
+ # Suggest to use __name for related fields such as id when not passed
638
+ for field, value in expressions.items():
639
+ if (
640
+ isinstance(value, str)
641
+ and value.strip("-").isalpha()
642
+ and "__" not in field
643
+ and hasattr(self.model, field)
644
+ and getattr(self.model, field).field.related_model
645
+ ):
646
+ raise FieldError(
647
+ f"Invalid lookup '{value}' for {field}. Did you mean {field}__name?"
648
+ )
649
+
610
650
  expressions = process_expressions(self, expressions)
611
651
  if len(expressions) > 0:
612
- return super().filter(*queries, **expressions)
613
- else:
614
- return self
652
+ try:
653
+ return super().filter(*queries, **expressions)
654
+ except FieldError as e:
655
+ self._handle_unknown_field(e)
656
+ return self
615
657
 
616
658
  def one(self) -> Record:
617
659
  """Exactly one result. Raises error if there are more or none."""