django-tortoise-objects 0.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.
@@ -0,0 +1,77 @@
1
+ """
2
+ Settings loading from Django's ``TORTOISE_OBJECTS`` configuration.
3
+
4
+ Provides ``get_config()`` which merges user-supplied settings with sensible
5
+ defaults. The ``TORTOISE_OBJECTS`` dict in Django settings can override any
6
+ of the keys defined in ``DEFAULTS``.
7
+
8
+ Also provides ``should_include()`` for model inclusion/exclusion filtering
9
+ based on fnmatch patterns.
10
+ """
11
+
12
+ import fnmatch
13
+ import logging
14
+ from typing import Any
15
+
16
+ from django.conf import settings
17
+
18
+ logger = logging.getLogger("django_tortoise")
19
+
20
+ # Default configuration values
21
+ DEFAULTS: dict[str, Any] = {
22
+ "INCLUDE_MODELS": None, # None means all models are included
23
+ "EXCLUDE_MODELS": None, # None means no models are excluded
24
+ "DB_ENGINE_MAP": {}, # Custom DB engine -> Tortoise backend overrides
25
+ "CONNECTION_POOL": {}, # Connection pool configuration overrides
26
+ "LOG_LEVEL": "WARNING", # Logging level for django_tortoise logger
27
+ }
28
+
29
+
30
+ def get_config() -> dict[str, Any]:
31
+ """
32
+ Load TORTOISE_OBJECTS from Django settings, merged with defaults.
33
+
34
+ User-supplied keys in ``settings.TORTOISE_OBJECTS`` override the
35
+ corresponding keys in ``DEFAULTS``. Keys not present in the user
36
+ config fall back to their default values.
37
+
38
+ Returns:
39
+ A dict containing the merged configuration.
40
+ """
41
+ user_config: dict[str, Any] = getattr(settings, "TORTOISE_OBJECTS", {})
42
+ config = {**DEFAULTS, **user_config}
43
+ return config
44
+
45
+
46
+ def should_include(
47
+ label: str,
48
+ include_patterns: list[str] | None,
49
+ exclude_patterns: list[str] | None,
50
+ ) -> bool:
51
+ """
52
+ Check if a model label matches include/exclude patterns.
53
+
54
+ EXCLUDE takes precedence over INCLUDE.
55
+
56
+ Args:
57
+ label: Model label in ``"app_label.ModelName"`` format.
58
+ include_patterns: List of fnmatch patterns for models to include.
59
+ ``None`` means include all.
60
+ exclude_patterns: List of fnmatch patterns for models to exclude.
61
+ ``None`` means exclude none.
62
+
63
+ Returns:
64
+ True if the model should be included.
65
+ """
66
+ # If excluded, always skip
67
+ if exclude_patterns is not None:
68
+ for pattern in exclude_patterns:
69
+ if fnmatch.fnmatch(label, pattern):
70
+ return False
71
+
72
+ # If include is specified (including empty list), model must match at least one pattern
73
+ if include_patterns is not None:
74
+ return any(fnmatch.fnmatch(label, pattern) for pattern in include_patterns)
75
+
76
+ # No include specified (None) = include all
77
+ return True
@@ -0,0 +1,91 @@
1
+ """
2
+ Database configuration translation.
3
+
4
+ Translates Django ``DATABASES`` settings into the Tortoise ORM configuration
5
+ format, mapping database backends and connection parameters.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any
10
+
11
+ from django.conf import settings
12
+
13
+ from django_tortoise.conf import get_config
14
+ from django_tortoise.exceptions import UnsupportedBackendError
15
+
16
+ logger = logging.getLogger("django_tortoise")
17
+
18
+ DEFAULT_ENGINE_MAP: dict[str, str] = {
19
+ "django.db.backends.postgresql": "tortoise.backends.psycopg",
20
+ "django.db.backends.mysql": "tortoise.backends.asyncmy",
21
+ "django.db.backends.sqlite3": "tortoise.backends.sqlite",
22
+ }
23
+
24
+
25
+ def build_tortoise_config(tortoise_app_name: str = "django_tortoise") -> dict[str, Any]:
26
+ """
27
+ Build a complete Tortoise ORM config dict from Django settings.
28
+
29
+ Reads ``settings.DATABASES`` and the ``TORTOISE_OBJECTS`` configuration
30
+ to produce a dict suitable for passing to ``Tortoise.init(config=...)``.
31
+ """
32
+ config = get_config()
33
+ user_engine_map: dict[str, str] = config.get("DB_ENGINE_MAP", {})
34
+ pool_config: dict[str, dict[str, Any]] = config.get("CONNECTION_POOL", {})
35
+ engine_map = {**DEFAULT_ENGINE_MAP, **user_engine_map}
36
+
37
+ django_databases: dict[str, dict[str, Any]] = settings.DATABASES
38
+ tortoise_connections: dict[str, dict[str, Any]] = {}
39
+
40
+ for alias, db_conf in django_databases.items():
41
+ engine = db_conf.get("ENGINE", "")
42
+ tortoise_engine = engine_map.get(engine)
43
+
44
+ if tortoise_engine is None:
45
+ raise UnsupportedBackendError(
46
+ f"Django database backend '{engine}' (alias '{alias}') "
47
+ f"has no Tortoise ORM equivalent. Supported: {list(engine_map.keys())}"
48
+ )
49
+
50
+ credentials = _build_credentials(engine, db_conf)
51
+
52
+ # Apply connection pool overrides
53
+ alias_pool = pool_config.get(alias, {})
54
+ if alias_pool:
55
+ credentials.update(alias_pool)
56
+
57
+ tortoise_connections[alias] = {
58
+ "engine": tortoise_engine,
59
+ "credentials": credentials,
60
+ }
61
+
62
+ tortoise_config: dict[str, Any] = {
63
+ "connections": tortoise_connections,
64
+ "apps": {
65
+ tortoise_app_name: {
66
+ "models": ["django_tortoise._models"],
67
+ "default_connection": "default",
68
+ }
69
+ },
70
+ "use_tz": getattr(settings, "USE_TZ", False),
71
+ "timezone": getattr(settings, "TIME_ZONE", "UTC"),
72
+ }
73
+
74
+ return tortoise_config
75
+
76
+
77
+ def _build_credentials(engine: str, db_conf: dict[str, Any]) -> dict[str, Any]:
78
+ """Build Tortoise credentials dict from a Django DB config entry."""
79
+ if "sqlite" in engine:
80
+ return {"file_path": db_conf.get("NAME", ":memory:")}
81
+
82
+ # PostgreSQL and MySQL use the same credential keys
83
+ credentials: dict[str, Any] = {
84
+ "host": db_conf.get("HOST", "localhost"),
85
+ "port": int(db_conf.get("PORT", 5432 if "postgresql" in engine else 3306)),
86
+ "user": db_conf.get("USER", ""),
87
+ "password": db_conf.get("PASSWORD", ""),
88
+ "database": db_conf.get("NAME", ""),
89
+ }
90
+
91
+ return credentials
@@ -0,0 +1,26 @@
1
+ """
2
+ Custom exceptions for django-tortoise-objects.
3
+
4
+ All exceptions inherit from ``DjangoTortoiseError`` to allow callers to
5
+ catch library-specific errors with a single base class.
6
+ """
7
+
8
+
9
+ class DjangoTortoiseError(Exception):
10
+ """Base exception for django-tortoise-objects."""
11
+
12
+
13
+ class ConnectionError(DjangoTortoiseError):
14
+ """Raised when Tortoise DB connection fails during lazy init."""
15
+
16
+
17
+ class ConfigurationError(DjangoTortoiseError):
18
+ """Raised for invalid configuration in TORTOISE_OBJECTS settings."""
19
+
20
+
21
+ class UnsupportedFieldError(DjangoTortoiseError):
22
+ """Raised/logged when a Django field type has no Tortoise mapping."""
23
+
24
+
25
+ class UnsupportedBackendError(DjangoTortoiseError):
26
+ """Raised when Django DB backend has no Tortoise equivalent."""
@@ -0,0 +1,505 @@
1
+ """
2
+ Field type mapping registry.
3
+
4
+ Maps Django field type strings (from ``get_internal_type()``) to Tortoise ORM
5
+ field constructors with correct parameter translation.
6
+
7
+ The registry key is the string returned by Django's ``Field.get_internal_type()``.
8
+ A decorator-based registration pattern allows adding new mappings cleanly.
9
+ """
10
+
11
+ import logging
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ from tortoise import fields as tortoise_fields
16
+
17
+ from django_tortoise.introspection import FieldInfo
18
+
19
+ logger = logging.getLogger("django_tortoise")
20
+
21
+ # Type alias for the converter function signature
22
+ # Converter takes FieldInfo and returns a Tortoise Field instance
23
+ FieldConverter = Callable[[FieldInfo], tortoise_fields.Field]
24
+
25
+ # The main registry: Django internal_type string -> converter function
26
+ FIELD_MAP: dict[str, FieldConverter] = {}
27
+
28
+
29
+ def register_field(internal_type: str):
30
+ """Decorator to register a field converter for a Django internal_type string."""
31
+
32
+ def decorator(func: FieldConverter) -> FieldConverter:
33
+ FIELD_MAP[internal_type] = func
34
+ return func
35
+
36
+ return decorator
37
+
38
+
39
+ def _common_kwargs(field_info: FieldInfo) -> dict[str, Any]:
40
+ """
41
+ Extract common kwargs shared by most Tortoise fields.
42
+
43
+ Maps null, unique, db_index, primary_key, source_field (when DB column
44
+ differs from field name), and default values.
45
+ """
46
+ kwargs: dict[str, Any] = {}
47
+ if field_info.null:
48
+ kwargs["null"] = True
49
+ if field_info.unique:
50
+ kwargs["unique"] = True
51
+ if field_info.db_index:
52
+ kwargs["db_index"] = True
53
+ if field_info.primary_key:
54
+ kwargs["primary_key"] = True
55
+ # Map db_column -> source_field
56
+ if field_info.column and field_info.column != field_info.name:
57
+ kwargs["source_field"] = field_info.column
58
+ # Handle defaults
59
+ # Bug fix: when has_default is True, always set the default, even if it's None.
60
+ # A field with has_default=True and default=None is a valid Django pattern
61
+ # (e.g., nullable fields with default=None).
62
+ if field_info.has_default:
63
+ default = field_info.default
64
+ kwargs["default"] = default
65
+ return kwargs
66
+
67
+
68
+ def convert_field(field_info: FieldInfo) -> tortoise_fields.Field | None:
69
+ """
70
+ Convert a FieldInfo to a Tortoise field instance.
71
+
72
+ Returns None if no converter is registered for the field's internal_type,
73
+ logging a warning in that case.
74
+ """
75
+ converter = FIELD_MAP.get(field_info.internal_type)
76
+ if converter is None:
77
+ logger.warning(
78
+ "Unsupported Django field type '%s' on field '%s'. Skipping.",
79
+ field_info.internal_type,
80
+ field_info.name,
81
+ )
82
+ return None
83
+ return converter(field_info)
84
+
85
+
86
+ def _try_enum_field(info: FieldInfo) -> tortoise_fields.Field | None:
87
+ """Return an IntEnumField or CharEnumField if the field has an enum_type, else None."""
88
+ if info.enum_type is None:
89
+ return None
90
+ kwargs = _common_kwargs(info)
91
+ if issubclass(info.enum_type, int):
92
+ return tortoise_fields.IntEnumField(info.enum_type, **kwargs) # type: ignore[type-var,return-value]
93
+ if issubclass(info.enum_type, str):
94
+ kwargs["max_length"] = info.max_length or 255
95
+ return tortoise_fields.CharEnumField(info.enum_type, **kwargs) # type: ignore[type-var,return-value]
96
+ return None
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Auto fields (primary key, generated)
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ @register_field("AutoField")
105
+ def _auto_field(info: FieldInfo):
106
+ return tortoise_fields.IntField(primary_key=True, generated=True)
107
+
108
+
109
+ @register_field("BigAutoField")
110
+ def _big_auto_field(info: FieldInfo):
111
+ return tortoise_fields.BigIntField(primary_key=True, generated=True)
112
+
113
+
114
+ @register_field("SmallAutoField")
115
+ def _small_auto_field(info: FieldInfo):
116
+ return tortoise_fields.SmallIntField(primary_key=True, generated=True)
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Integer fields
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ @register_field("IntegerField")
125
+ def _int_field(info: FieldInfo):
126
+ return _try_enum_field(info) or tortoise_fields.IntField(**_common_kwargs(info))
127
+
128
+
129
+ @register_field("BigIntegerField")
130
+ def _bigint_field(info: FieldInfo):
131
+ return _try_enum_field(info) or tortoise_fields.BigIntField(**_common_kwargs(info))
132
+
133
+
134
+ @register_field("SmallIntegerField")
135
+ def _smallint_field(info: FieldInfo):
136
+ return _try_enum_field(info) or tortoise_fields.SmallIntField(**_common_kwargs(info))
137
+
138
+
139
+ @register_field("PositiveIntegerField")
140
+ def _pos_int(info: FieldInfo):
141
+ return _try_enum_field(info) or tortoise_fields.IntField(**_common_kwargs(info))
142
+
143
+
144
+ @register_field("PositiveBigIntegerField")
145
+ def _pos_bigint(info: FieldInfo):
146
+ return _try_enum_field(info) or tortoise_fields.BigIntField(**_common_kwargs(info))
147
+
148
+
149
+ @register_field("PositiveSmallIntegerField")
150
+ def _pos_smallint(info: FieldInfo):
151
+ return _try_enum_field(info) or tortoise_fields.SmallIntField(**_common_kwargs(info))
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # String fields
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ @register_field("CharField")
160
+ def _char_field(info: FieldInfo):
161
+ enum_field = _try_enum_field(info)
162
+ if enum_field is not None:
163
+ return enum_field
164
+ kwargs = _common_kwargs(info)
165
+ kwargs["max_length"] = info.max_length or 255
166
+ return tortoise_fields.CharField(**kwargs)
167
+
168
+
169
+ @register_field("TextField")
170
+ def _text_field(info: FieldInfo):
171
+ return tortoise_fields.TextField(**_common_kwargs(info))
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Boolean field
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ @register_field("BooleanField")
180
+ def _bool_field(info: FieldInfo):
181
+ return tortoise_fields.BooleanField(**_common_kwargs(info))
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Date/Time fields
186
+ # ---------------------------------------------------------------------------
187
+
188
+
189
+ @register_field("DateField")
190
+ def _date_field(info: FieldInfo):
191
+ return tortoise_fields.DateField(**_common_kwargs(info))
192
+
193
+
194
+ @register_field("DateTimeField")
195
+ def _datetime_field(info: FieldInfo):
196
+ return tortoise_fields.DatetimeField(**_common_kwargs(info))
197
+
198
+
199
+ @register_field("TimeField")
200
+ def _time_field(info: FieldInfo):
201
+ return tortoise_fields.TimeField(**_common_kwargs(info))
202
+
203
+
204
+ @register_field("DurationField")
205
+ def _duration_field(info: FieldInfo):
206
+ return tortoise_fields.TimeDeltaField(**_common_kwargs(info))
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Numeric fields
211
+ # ---------------------------------------------------------------------------
212
+
213
+
214
+ @register_field("DecimalField")
215
+ def _decimal_field(info: FieldInfo):
216
+ kwargs = _common_kwargs(info)
217
+ kwargs["max_digits"] = info.max_digits
218
+ kwargs["decimal_places"] = info.decimal_places
219
+ return tortoise_fields.DecimalField(**kwargs)
220
+
221
+
222
+ @register_field("FloatField")
223
+ def _float_field(info: FieldInfo):
224
+ return tortoise_fields.FloatField(**_common_kwargs(info))
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Binary / UUID / JSON fields
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ @register_field("BinaryField")
233
+ def _binary_field(info: FieldInfo):
234
+ return tortoise_fields.BinaryField(**_common_kwargs(info))
235
+
236
+
237
+ @register_field("UUIDField")
238
+ def _uuid_field(info: FieldInfo):
239
+ return tortoise_fields.UUIDField(**_common_kwargs(info))
240
+
241
+
242
+ @register_field("JSONField")
243
+ def _json_field(info: FieldInfo):
244
+ return tortoise_fields.JSONField(**_common_kwargs(info))
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # File / path fields (approximate mappings -- store path as CharField)
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ @register_field("FileField")
253
+ def _file_field(info: FieldInfo):
254
+ logger.info("Mapping FileField '%s' to CharField (stores path).", info.name)
255
+ kwargs = _common_kwargs(info)
256
+ kwargs["max_length"] = info.max_length or 100
257
+ return tortoise_fields.CharField(**kwargs)
258
+
259
+
260
+ # NOTE: ImageField's get_internal_type() returns "CharField", so this converter
261
+ # is only reached if a custom subclass overrides get_internal_type() to return
262
+ # "ImageField". Registered for completeness and forward-compatibility.
263
+ @register_field("ImageField")
264
+ def _image_field(info: FieldInfo):
265
+ logger.info("Mapping ImageField '%s' to CharField (stores path).", info.name)
266
+ kwargs = _common_kwargs(info)
267
+ kwargs["max_length"] = info.max_length or 100
268
+ return tortoise_fields.CharField(**kwargs)
269
+
270
+
271
+ @register_field("FilePathField")
272
+ def _filepath_field(info: FieldInfo):
273
+ logger.info("Mapping FilePathField '%s' to CharField.", info.name)
274
+ kwargs = _common_kwargs(info)
275
+ kwargs["max_length"] = info.max_length or 100
276
+ return tortoise_fields.CharField(**kwargs)
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Specialised string fields (approximate mappings)
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ @register_field("SlugField")
285
+ def _slug_field(info: FieldInfo):
286
+ kwargs = _common_kwargs(info)
287
+ kwargs["max_length"] = info.max_length or 50
288
+ return tortoise_fields.CharField(**kwargs)
289
+
290
+
291
+ # NOTE: EmailField's get_internal_type() returns "CharField", so this converter
292
+ # is only reached if a custom subclass overrides get_internal_type() to return
293
+ # "EmailField". Registered for completeness and forward-compatibility.
294
+ @register_field("EmailField")
295
+ def _email_field(info: FieldInfo):
296
+ kwargs = _common_kwargs(info)
297
+ kwargs["max_length"] = info.max_length or 254
298
+ return tortoise_fields.CharField(**kwargs)
299
+
300
+
301
+ # NOTE: URLField's get_internal_type() returns "CharField", so this converter
302
+ # is only reached if a custom subclass overrides get_internal_type() to return
303
+ # "URLField". Registered for completeness and forward-compatibility.
304
+ @register_field("URLField")
305
+ def _url_field(info: FieldInfo):
306
+ kwargs = _common_kwargs(info)
307
+ kwargs["max_length"] = info.max_length or 200
308
+ return tortoise_fields.CharField(**kwargs)
309
+
310
+
311
+ @register_field("GenericIPAddressField")
312
+ def _ip_field(info: FieldInfo):
313
+ kwargs = _common_kwargs(info)
314
+ kwargs["max_length"] = 39
315
+ return tortoise_fields.CharField(**kwargs)
316
+
317
+
318
+ # ---------------------------------------------------------------------------
319
+ # Relational field mapping
320
+ # ---------------------------------------------------------------------------
321
+
322
+
323
+ # Lazy import to avoid importing tortoise.fields.relational at module level
324
+ def _get_on_delete_enum():
325
+ """Lazy import of Tortoise OnDelete enum."""
326
+ from tortoise.fields.relational import OnDelete
327
+
328
+ return OnDelete
329
+
330
+
331
+ # Django on_delete name -> Tortoise OnDelete enum member name
332
+ ON_DELETE_MAP: dict[str, str] = {
333
+ "CASCADE": "CASCADE",
334
+ "SET_NULL": "SET_NULL",
335
+ "SET_DEFAULT": "SET_DEFAULT",
336
+ "PROTECT": "RESTRICT", # Tortoise has RESTRICT, not PROTECT
337
+ "RESTRICT": "RESTRICT",
338
+ "DO_NOTHING": "NO_ACTION", # Tortoise uses NO_ACTION, not DO_NOTHING
339
+ }
340
+
341
+
342
+ def _map_on_delete(django_on_delete: str | None):
343
+ """Map a Django on_delete name to the Tortoise OnDelete enum value."""
344
+ OnDelete = _get_on_delete_enum()
345
+ if django_on_delete is None:
346
+ return OnDelete.CASCADE
347
+ mapped_name = ON_DELETE_MAP.get(django_on_delete, "CASCADE")
348
+ return OnDelete[mapped_name]
349
+
350
+
351
+ def convert_relation_field(
352
+ field_info: FieldInfo,
353
+ tortoise_app_name: str = "django_tortoise",
354
+ ) -> tuple[str, object] | None:
355
+ """
356
+ Convert a relational FieldInfo to a Tortoise relation field.
357
+
358
+ Uses the model registry to resolve the target model. Returns
359
+ ``(field_name, tortoise_field)`` or ``None`` if the relation
360
+ target is unavailable (e.g., excluded model).
361
+ """
362
+ from django_tortoise.registry import model_registry
363
+
364
+ target_model = field_info.related_model
365
+ if target_model is None:
366
+ logger.warning(
367
+ "Relation field '%s' has no related model. Skipping.",
368
+ field_info.name,
369
+ )
370
+ return None
371
+
372
+ if not model_registry.is_registered(target_model):
373
+ logger.warning(
374
+ "Relation field '%s' points to unregistered/excluded model '%s'. Skipping.",
375
+ field_info.name,
376
+ field_info.related_model_label,
377
+ )
378
+ return None
379
+
380
+ target_tortoise = model_registry.get_tortoise_model(target_model)
381
+ if target_tortoise is None:
382
+ return None
383
+ target_ref = f"{tortoise_app_name}.{target_tortoise.__name__}"
384
+
385
+ return _convert_relation_to_field(field_info, target_ref)
386
+
387
+
388
+ def convert_relation_field_by_name(
389
+ field_info: FieldInfo,
390
+ tortoise_app_name: str = "django_tortoise",
391
+ class_name_map: dict[type, str] | None = None,
392
+ ) -> tuple[str, object] | None:
393
+ """
394
+ Convert a relational FieldInfo using a pre-computed class name map.
395
+
396
+ This variant does not require the target Tortoise model to exist yet.
397
+ It uses *class_name_map* (Django model class -> Tortoise class name)
398
+ to build the string reference. Returns ``(field_name, tortoise_field)``
399
+ or ``None`` if the target is not in the map.
400
+ """
401
+ target_model = field_info.related_model
402
+ if target_model is None:
403
+ logger.warning(
404
+ "Relation field '%s' has no related model. Skipping.",
405
+ field_info.name,
406
+ )
407
+ return None
408
+
409
+ if class_name_map is None:
410
+ class_name_map = {}
411
+
412
+ tortoise_class_name = class_name_map.get(target_model)
413
+ if tortoise_class_name is None:
414
+ logger.warning(
415
+ "Relation field '%s' points to unregistered/excluded model '%s'. Skipping.",
416
+ field_info.name,
417
+ field_info.related_model_label,
418
+ )
419
+ return None
420
+
421
+ target_ref = f"{tortoise_app_name}.{tortoise_class_name}"
422
+ return _convert_relation_to_field(field_info, target_ref)
423
+
424
+
425
+ def _convert_relation_to_field(field_info: FieldInfo, target_ref: str) -> tuple[str, object] | None:
426
+ """Dispatch to the appropriate relational field builder."""
427
+ internal_type = field_info.internal_type
428
+
429
+ if internal_type == "ForeignKey":
430
+ return _build_fk(field_info, target_ref)
431
+ elif internal_type == "OneToOneField":
432
+ return _build_o2o(field_info, target_ref)
433
+ elif field_info.many_to_many:
434
+ return _build_m2m(field_info, target_ref)
435
+
436
+ return None
437
+
438
+
439
+ def _build_fk(info: FieldInfo, target_ref: str) -> tuple[str, object]:
440
+ """Build a Tortoise ForeignKeyField from introspected field info."""
441
+ on_delete = _map_on_delete(info.on_delete)
442
+ related_name: str | bool | None = None
443
+ if info.related_name and info.related_name != "+":
444
+ related_name = info.related_name
445
+ else:
446
+ # Tortoise requires a related_name or False to disable.
447
+ # Use False to avoid auto-generated reverse accessors clashing.
448
+ related_name = False
449
+
450
+ kwargs: dict[str, Any] = {}
451
+ if info.column:
452
+ kwargs["source_field"] = info.column
453
+ if info.null:
454
+ kwargs["null"] = True
455
+ return (
456
+ info.name,
457
+ tortoise_fields.ForeignKeyField(
458
+ target_ref,
459
+ related_name=related_name, # type: ignore[arg-type]
460
+ on_delete=on_delete,
461
+ **kwargs,
462
+ ),
463
+ )
464
+
465
+
466
+ def _build_o2o(info: FieldInfo, target_ref: str) -> tuple[str, object]:
467
+ """Build a Tortoise OneToOneField from introspected field info."""
468
+ on_delete = _map_on_delete(info.on_delete)
469
+ related_name: str | bool | None = None
470
+ if info.related_name and info.related_name != "+":
471
+ related_name = info.related_name
472
+ else:
473
+ related_name = False
474
+
475
+ kwargs: dict[str, Any] = {}
476
+ if info.column:
477
+ kwargs["source_field"] = info.column
478
+ if info.null:
479
+ kwargs["null"] = True
480
+ return (
481
+ info.name,
482
+ tortoise_fields.OneToOneField(
483
+ target_ref,
484
+ related_name=related_name, # type: ignore[arg-type]
485
+ on_delete=on_delete,
486
+ **kwargs,
487
+ ),
488
+ )
489
+
490
+
491
+ def _build_m2m(info: FieldInfo, target_ref: str) -> tuple[str, object]:
492
+ """Build a Tortoise ManyToManyField from introspected field info."""
493
+ related_name: str | bool | None = None
494
+ if info.related_name and info.related_name != "+":
495
+ related_name = info.related_name
496
+ else:
497
+ related_name = False
498
+
499
+ kwargs: dict[str, Any] = {}
500
+ if info.through_db_table:
501
+ kwargs["through"] = info.through_db_table
502
+ return (
503
+ info.name,
504
+ tortoise_fields.ManyToManyField(target_ref, related_name=related_name, **kwargs), # type: ignore[arg-type]
505
+ )