1+ # ruff: noqa: C901
2+ import inspect as stdlib_inspect
3+ import logging
14from collections .abc import Collection , Generator
25from collections .abc import Set as AbstractSet
36from dataclasses import asdict , dataclass , field , replace
4- from functools import singledispatchmethod
7+ from functools import cached_property , singledispatchmethod
58from typing import (
69 Any ,
710 ClassVar ,
4447
4548__all__ = ("SQLAlchemyDTO" ,)
4649
50+ logger = logging .getLogger (__name__ )
51+
4752T = TypeVar ("T" , bound = "Union[DeclarativeBase, Collection[DeclarativeBase]]" )
4853
4954ElementType : TypeAlias = Union [
@@ -231,7 +236,9 @@ def _(
231236 default = Empty ,
232237 ),
233238 default_factory = None ,
234- dto_field = orm_descriptor .info .get (DTO_FIELD_META_KEY , DTOField (mark = Mark .READ_ONLY )),
239+ dto_field = orm_descriptor .info .get (
240+ DTO_FIELD_META_KEY , DTOField (mark = Mark .READ_ONLY )
241+ ), # Mark as read-only
235242 model_name = model_name ,
236243 ),
237244 ]
@@ -260,7 +267,9 @@ def _(
260267 default = Empty ,
261268 ),
262269 default_factory = None ,
263- dto_field = orm_descriptor .info .get (DTO_FIELD_META_KEY , DTOField (mark = Mark .READ_ONLY )),
270+ dto_field = orm_descriptor .info .get (
271+ DTO_FIELD_META_KEY , DTOField (mark = Mark .READ_ONLY )
272+ ), # Mark as read-only
264273 model_name = model_name ,
265274 ),
266275 ]
@@ -275,13 +284,61 @@ def _(
275284 default = Empty ,
276285 ),
277286 default_factory = None ,
278- dto_field = orm_descriptor .info .get (DTO_FIELD_META_KEY , DTOField (mark = Mark .WRITE_ONLY )),
287+ dto_field = orm_descriptor .info .get (
288+ DTO_FIELD_META_KEY , DTOField (mark = Mark .WRITE_ONLY )
289+ ), # Mark as read-only
279290 model_name = model_name ,
280291 ),
281292 )
282293
283294 return field_defs
284295
296+ @classmethod
297+ def get_property_fields (cls , model_type : "type[DeclarativeBase]" ) -> "dict[str, FieldDefinition]" :
298+ """Get fields defined as @property or @cached_property on the model.
299+
300+ Uses inspect.getmembers() to detect properties from the model class and mixins.
301+ Properties are marked read-only; setter support is not implemented.
302+
303+ Args:
304+ model_type: The SQLAlchemy model type to extract properties from.
305+
306+ Returns:
307+ A dictionary mapping property names to their field definitions.
308+ """
309+ namespace = cls .get_model_namespace (model_type )
310+ sqla_internal_properties = {"awaitable_attrs" , "registry" , "metadata" }
311+
312+ properties : dict [str , FieldDefinition ] = {}
313+ for name , member in stdlib_inspect .getmembers (
314+ model_type , predicate = lambda x : isinstance (x , (property , cached_property ))
315+ ):
316+ if name in sqla_internal_properties :
317+ continue
318+
319+ if isinstance (member , cached_property ):
320+ func = member .func
321+ elif isinstance (member , property ):
322+ if member .fget is None :
323+ continue
324+ func = member .fget
325+ else :
326+ continue
327+
328+ try :
329+ sig = ParsedSignature .from_fn (func , namespace )
330+ properties [name ] = replace (sig .return_type , name = name )
331+ except (AttributeError , TypeError , ValueError ) as e :
332+ logger .debug (
333+ "could not parse type hint for property %s.%s: %s, using Any type" ,
334+ model_type .__name__ ,
335+ name ,
336+ e ,
337+ )
338+ properties [name ] = FieldDefinition .from_annotation (Any , name = name )
339+
340+ return properties
341+
285342 @classmethod
286343 def generate_field_definitions (cls , model_type : type [DeclarativeBase ]) -> Generator [DTOFieldDefinition , None , None ]:
287344 """Generate DTO field definitions from a SQLAlchemy model.
@@ -315,6 +372,9 @@ def generate_field_definitions(cls, model_type: type[DeclarativeBase]) -> Genera
315372 skipped_descriptors .add (attr .name )
316373 elif isinstance (attr , str ):
317374 skipped_descriptors .add (attr )
375+
376+ yielded_sqla_keys : set [str ] = set () # Keep track of keys yielded by SQLAlchemy logic
377+
318378 for key , orm_descriptor in mapper .all_orm_descriptors .items ():
319379 if is_hybrid_property := isinstance (orm_descriptor , hybrid_property ):
320380 if orm_descriptor in seen_hybrid_descriptors :
@@ -328,7 +388,12 @@ def generate_field_definitions(cls, model_type: type[DeclarativeBase]) -> Genera
328388 should_skip_descriptor = False
329389 dto_field : Optional [DTOField ] = None
330390 if hasattr (orm_descriptor , "property" ): # pyright: ignore[reportUnknownArgumentType]
331- dto_field = orm_descriptor .property .info .get (DTO_FIELD_META_KEY ) # pyright: ignore
391+ # Access info safely, checking if property exists first
392+ prop = getattr (orm_descriptor , "property" , None ) # pyright: ignore[reportUnknownArgumentType]
393+ if prop and hasattr (prop , "info" ):
394+ dto_field = prop .info .get (DTO_FIELD_META_KEY )
395+ elif hasattr (orm_descriptor , "info" ): # pyright: ignore[reportUnknownArgumentType]
396+ dto_field = orm_descriptor .info .get (DTO_FIELD_META_KEY ) # pyright: ignore[reportUnknownArgumentType,reportUnknownMemberType,reportAttributeAccessIssue,reportUnknownVariableType]
332397
333398 # Case 1
334399 is_field_marked_not_private = dto_field and dto_field .mark is not Mark .PRIVATE # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType]
@@ -353,13 +418,33 @@ def generate_field_definitions(cls, model_type: type[DeclarativeBase]) -> Genera
353418 if should_skip_descriptor :
354419 continue
355420
356- yield from cls .handle_orm_descriptor (
421+ # Yield definitions from SQLAlchemy descriptor handling
422+ definitions = cls .handle_orm_descriptor (
357423 orm_descriptor .extension_type ,
358424 key ,
359425 orm_descriptor ,
360426 model_type_hints ,
361427 model_name ,
362428 )
429+ for definition in definitions :
430+ yielded_sqla_keys .add (definition .name ) # Track yielded key
431+ yield definition
432+
433+ property_fields = cls .get_property_fields (model_type )
434+ for key , property_field_definition in property_fields .items ():
435+ if key .startswith ("_" ) or key in yielded_sqla_keys :
436+ continue
437+
438+ yield DTOFieldDefinition .from_field_definition (
439+ field_definition = replace (
440+ property_field_definition ,
441+ name = key ,
442+ default = Empty ,
443+ ),
444+ model_name = model_name ,
445+ default_factory = None ,
446+ dto_field = DTOField (mark = Mark .READ_ONLY ),
447+ )
363448
364449 @classmethod
365450 def detect_nested_field (cls , field_definition : FieldDefinition ) -> bool :
0 commit comments