Skip to content

Commit 8d47497

Browse files
committed
Improved support for custom fields
1 parent 62f6244 commit 8d47497

10 files changed

Lines changed: 604 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/).
66

7+
## 0.1.2
8+
9+
### Fixed
10+
11+
- Custom Django field types (e.g. `CustomIDField(CharField)`) are now resolved
12+
via MRO fallback when their `get_internal_type()` returns a name not in the
13+
field map. The library walks the field's ancestor classes to find a supported
14+
parent type, so models with custom primary key fields no longer lose their
15+
only data field and fail silently.
16+
- Fixed `KeyError` / `ConnectionError` during Tortoise ORM initialization when
17+
a model with only unsupported fields was referenced by ForeignKey from another
18+
model. Failed models are now removed from the internal class name map so
19+
downstream FK references are gracefully skipped instead of crashing.
20+
721
## 0.1.1
822

923
### Fixed

django_tortoise/apps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def ready(self):
7373
for django_model, label, model_info in eligible:
7474
tortoise_model = generate_tortoise_model_full(model_info, class_name_map=class_name_map)
7575
if tortoise_model is None:
76+
class_name_map.pop(django_model, None)
7677
skipped_count += 1
7778
continue
7879

django_tortoise/code_generator.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from collections.abc import Callable
1717
from dataclasses import dataclass, field
1818

19-
from django_tortoise.fields import ON_DELETE_MAP
19+
from django_tortoise.fields import ON_DELETE_MAP, resolve_internal_type
2020
from django_tortoise.introspection import FieldInfo, ModelInfo
2121

2222
logger = logging.getLogger("django_tortoise")
@@ -402,14 +402,15 @@ def render_field_source(field_info: FieldInfo) -> str | None:
402402
403403
Returns ``None`` if no renderer is registered for the field's internal_type.
404404
"""
405-
renderer = SOURCE_FIELD_MAP.get(field_info.internal_type)
406-
if renderer is None:
405+
resolved_type = resolve_internal_type(field_info, SOURCE_FIELD_MAP)
406+
if resolved_type is None:
407407
logger.warning(
408408
"Unsupported Django field type '%s' on field '%s'. Skipping.",
409409
field_info.internal_type,
410410
field_info.name,
411411
)
412412
return None
413+
renderer = SOURCE_FIELD_MAP[resolved_type]
413414
return renderer(field_info)
414415

415416

django_tortoise/fields.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,21 +66,66 @@ def _common_kwargs(field_info: FieldInfo) -> dict[str, Any]:
6666
return kwargs
6767

6868

69+
def resolve_internal_type(field_info: FieldInfo, field_map: dict[str, Any]) -> str | None:
70+
"""
71+
Resolve a Django field's internal type to a known key in *field_map*.
72+
73+
If ``field_info.internal_type`` is already present in *field_map*, it is
74+
returned immediately. Otherwise, when ``field_info.django_field`` is
75+
available, the function walks the Django field class's MRO looking for
76+
an ancestor whose ``__name__`` is a key in *field_map*.
77+
78+
Returns ``None`` when no match is found (or when ``django_field`` is
79+
``None`` and the type is unknown).
80+
"""
81+
import django.db.models
82+
83+
if field_info.internal_type in field_map:
84+
return field_info.internal_type
85+
86+
if field_info.django_field is None:
87+
return None
88+
89+
field_cls = type(field_info.django_field)
90+
for ancestor in field_cls.__mro__:
91+
# Skip the leaf class itself (already tried via internal_type).
92+
if ancestor is field_cls:
93+
continue
94+
# Skip django.db.models.Field (returns self.__class__.__name__,
95+
# which would give the leaf class name again).
96+
if ancestor is django.db.models.Field:
97+
continue
98+
# Skip object.
99+
if ancestor is object:
100+
continue
101+
if ancestor.__name__ in field_map:
102+
logger.debug(
103+
"Custom field type '%s' on '%s' resolved to '%s' via MRO fallback.",
104+
field_info.internal_type,
105+
field_info.name,
106+
ancestor.__name__,
107+
)
108+
return ancestor.__name__
109+
110+
return None
111+
112+
69113
def convert_field(field_info: FieldInfo) -> tortoise_fields.Field | None:
70114
"""
71115
Convert a FieldInfo to a Tortoise field instance.
72116
73117
Returns None if no converter is registered for the field's internal_type,
74118
logging a warning in that case.
75119
"""
76-
converter = FIELD_MAP.get(field_info.internal_type)
77-
if converter is None:
120+
resolved_type = resolve_internal_type(field_info, FIELD_MAP)
121+
if resolved_type is None:
78122
logger.warning(
79123
"Unsupported Django field type '%s' on field '%s'. Skipping.",
80124
field_info.internal_type,
81125
field_info.name,
82126
)
83127
return None
128+
converter = FIELD_MAP[resolved_type]
84129
return converter(field_info)
85130

86131

django_tortoise/management/commands/generate_tortoise_models.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ def handle(self, *args, **options):
8383
models_by_app = defaultdict(list)
8484
for _django_model, _label, model_info in eligible:
8585
result = render_model_source(model_info, tortoise_app_name, class_name_map)
86-
if result is not None:
87-
models_by_app[model_info.app_label].append(result)
86+
if result is None:
87+
class_name_map.pop(model_info.model_class, None)
88+
continue
89+
models_by_app[model_info.app_label].append(result)
8890

8991
# Create output directory if needed
9092
os.makedirs(output_dir, exist_ok=True)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "django-tortoise-objects"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
description = "Bridge Django ORM models to Tortoise ORM for truly async database access"
55
readme = "README.md"
66
license = {text = "Apache-2.0"}

tests/test_code_generator.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,3 +945,81 @@ def test_on_delete_do_nothing_maps_to_no_action(self):
945945
assert result is not None
946946
_, source = result
947947
assert "OnDelete.NO_ACTION" in source
948+
949+
950+
# ---------------------------------------------------------------------------
951+
# MRO fallback tests for render_field_source
952+
# ---------------------------------------------------------------------------
953+
954+
955+
class TestRenderFieldSourceMROFallback:
956+
"""MRO fallback resolves custom field types in source rendering."""
957+
958+
def test_render_field_source_custom_field_mro_fallback(self):
959+
"""Custom CharField subclass resolves via MRO in source rendering."""
960+
from django.db import models as django_models
961+
962+
class CustomIDField(django_models.CharField):
963+
def get_internal_type(self):
964+
return "CustomIDField"
965+
966+
django_field = CustomIDField(max_length=36)
967+
info = _make_field_info(
968+
name="custom_id",
969+
internal_type="CustomIDField",
970+
max_length=36,
971+
primary_key=True,
972+
django_field=django_field,
973+
)
974+
result = render_field_source(info)
975+
assert result is not None
976+
assert "fields.CharField(" in result
977+
978+
def test_render_field_source_custom_field_no_django_field(self):
979+
"""Unknown type with django_field=None returns None in source rendering."""
980+
info = _make_field_info(
981+
internal_type="CustomUnknownField",
982+
django_field=None,
983+
)
984+
result = render_field_source(info)
985+
assert result is None
986+
987+
def test_render_model_source_with_custom_pk_field(self):
988+
"""Model with custom PK field generates valid ModelSourceResult."""
989+
from django.db import models as django_models
990+
991+
class CustomIDField(django_models.CharField):
992+
def get_internal_type(self):
993+
return "CustomIDField"
994+
995+
django_field = CustomIDField(max_length=36)
996+
997+
fi = _make_field_info(
998+
name="id",
999+
internal_type="CustomIDField",
1000+
column="id",
1001+
max_length=36,
1002+
primary_key=True,
1003+
django_field=django_field,
1004+
)
1005+
1006+
class _DummyModel:
1007+
__name__ = "CustomPKModel"
1008+
__module__ = "tests.testapp.models"
1009+
1010+
model_info = ModelInfo(
1011+
model_class=_DummyModel,
1012+
app_label="test",
1013+
model_name="custompkmodel",
1014+
db_table="test_custompkmodel",
1015+
fields=[fi],
1016+
unique_together=[],
1017+
is_abstract=False,
1018+
is_proxy=False,
1019+
is_managed=True,
1020+
pk_name="id",
1021+
)
1022+
result = render_model_source(model_info, "django_tortoise", {})
1023+
assert result is not None
1024+
assert isinstance(result, ModelSourceResult)
1025+
assert "fields.CharField(" in result.source

0 commit comments

Comments
 (0)