Coverage for fastapi_restly / schemas / _generator.py: 95%

168 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-09 18:41 +0000

1""" 

2Schema generation utilities for auto-generating Pydantic schemas from SQLAlchemy models. 

3""" 

4 

5import enum 

6import sys 

7import types 

8from collections.abc import Collection 

9from datetime import date, datetime, time 

10from decimal import Decimal 

11from typing import Any, ForwardRef, Union, get_args, get_origin 

12from uuid import UUID 

13 

14import pydantic 

15from pydantic import Field 

16from sqlalchemy import Column 

17from sqlalchemy import inspect as sa_inspect 

18from sqlalchemy.orm import ColumnProperty, Mapped, RelationshipProperty 

19 

20from .._mapping import is_mapped_class 

21from ._base import ( 

22 BaseSchema, 

23 IDSchema, 

24 ReadOnly, 

25 TimestampsSchemaMixin, 

26 _is_unresolved, 

27 _own_annotations, 

28) 

29 

30 

31def get_sqlalchemy_field_type(field: Any) -> Any: 

32 """ 

33 Extract the Python type from a SQLAlchemy Mapped field. 

34 

35 :param field: A SQLAlchemy Mapped field. 

36 :returns: The Python type annotation. 

37 """ 

38 # Get the type annotation from the Mapped field 

39 if hasattr(field, "type"): 

40 return field.type 

41 elif hasattr(field, "__origin__"): 

42 return field.__origin__ 

43 else: 

44 # Fallback to Any if we can't determine the type 

45 return Any 

46 

47 

48def is_relationship_field(field: Any) -> bool: 

49 """ 

50 Check if a field is a SQLAlchemy relationship. 

51 

52 :param field: A SQLAlchemy Mapped field. 

53 :returns: True if the field is a relationship, False otherwise. 

54 """ 

55 if isinstance(field, RelationshipProperty): 

56 return True 

57 return isinstance(getattr(field, "property", None), RelationshipProperty) 

58 

59 

60def get_relationship_target_model(field: Any) -> type[Any] | None: 

61 """ 

62 Get the target model class for a relationship field. 

63 

64 :param field: A SQLAlchemy relationship field. 

65 :returns: The target model class, or None if not found. 

66 """ 

67 if not is_relationship_field(field): 

68 return None 

69 

70 # Try to get the target from the relationship property 

71 relationship = field 

72 if not isinstance(relationship, RelationshipProperty): 

73 relationship = getattr(field, "property", None) 

74 

75 if ( 

76 relationship is not None 

77 and hasattr(relationship, "mapper") 

78 and hasattr(relationship.mapper, "class_") 

79 ): 

80 return relationship.mapper.class_ 

81 

82 # Try to get from the type annotation 

83 if hasattr(field, "type"): 83 ↛ 93line 83 didn't jump to line 93 because the condition on line 83 was always true

84 target_type = field.type 

85 if hasattr(target_type, "__origin__") and target_type.__origin__ is list: 

86 # Handle list[Model] case 

87 args = get_args(target_type) 

88 if args: 88 ↛ 93line 88 didn't jump to line 93 because the condition on line 88 was always true

89 return args[0] 

90 elif is_mapped_class(target_type): 90 ↛ 93line 90 didn't jump to line 93 because the condition on line 90 was always true

91 return target_type 

92 

93 return None 

94 

95 

96def _resolve_annotation(owner: type, annotation: Any) -> Any: 

97 """Evaluate a string annotation against ``owner``'s module, or return None.""" 

98 if isinstance(annotation, ForwardRef): 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true

99 annotation = annotation.__forward_arg__ 

100 if isinstance(annotation, str): 

101 module = sys.modules.get(owner.__module__) 

102 # Module names win over class attributes, as in typing.get_type_hints: 

103 # a column named ``date`` must not shadow the ``date`` type. 

104 try: 

105 annotation = eval( 

106 annotation, dict(vars(owner)), dict(vars(module)) if module else {} 

107 ) 

108 except Exception: 

109 return None 

110 return annotation 

111 

112 

113def _model_annotations(model_cls: type, keys: Collection[str]) -> dict[str, Any]: 

114 """Map each annotated name in ``keys`` to its inner type, or None if unresolvable. 

115 

116 ``Mapped[X]`` gives ``X``; a plain annotation (SQLModel) is kept as is. 

117 The class nearest ``model_cls`` on the MRO wins. 

118 """ 

119 annotations: dict[str, Any] = {} 

120 for cls in model_cls.mro(): 

121 for name, annotation in _own_annotations(cls).items(): 

122 if name in annotations or name not in keys: 

123 continue 

124 annotation = _resolve_annotation(cls, annotation) 

125 if get_origin(annotation) is Mapped: 

126 args = get_args(annotation) 

127 annotation = _resolve_annotation(cls, args[0]) if args else None 

128 if annotation is not None and _is_unresolved(annotation): 

129 annotation = None 

130 annotations[name] = annotation 

131 return annotations 

132 

133 

134def _split_optional(annotation: Any) -> tuple[Any, bool]: 

135 """Return ``(type, is_optional)`` with ``None`` removed from a union.""" 

136 if isinstance(annotation, types.UnionType) or get_origin(annotation) is Union: 

137 union_args = get_args(annotation) 

138 if type(None) in union_args: 138 ↛ 141line 138 didn't jump to line 141 because the condition on line 138 was always true

139 non_none_types = [arg for arg in union_args if arg is not type(None)] 

140 return (non_none_types[0] if non_none_types else annotation), True 

141 return annotation, False 

142 

143 

144def get_model_fields(model_cls: type[Any]) -> dict[str, Any]: 

145 """ 

146 Extract field information from a SQLAlchemy model. 

147 

148 The mapper decides which attributes exist; annotations only refine a 

149 column's type. Fields follow annotation order, then the mapper's order. 

150 

151 :param model_cls: A SQLAlchemy model class. 

152 :returns: Dictionary mapping field names to their types and metadata. 

153 :raises TypeError: If a column's Python type can be read from neither its 

154 annotation nor its column type. 

155 """ 

156 fields: dict[str, Any] = {} 

157 

158 mapper = sa_inspect(model_cls) 

159 props = {prop.key: prop for prop in mapper.attrs} 

160 annotations = _model_annotations(model_cls, props.keys()) 

161 names = list(annotations) 

162 names += [name for name in props if name not in annotations] 

163 

164 for name in names: 

165 if name.startswith("_"): 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 continue 

167 prop = props[name] 

168 

169 if isinstance(prop, RelationshipProperty): 

170 target_model = prop.mapper.class_ 

171 # Listed so a caller can tell a relationship from a column. 

172 # derive_schema leaves relationships out. 

173 fields[name] = { 

174 "type": list[target_model] if prop.uselist else target_model, 

175 "is_relationship": True, 

176 "target_model": target_model, 

177 "is_optional": True, 

178 "is_read_only": False, 

179 "default": None, 

180 } 

181 continue 

182 

183 if not isinstance(prop, ColumnProperty): 183 ↛ 185line 183 didn't jump to line 185 because the condition on line 183 was never true

184 # Synonyms and composites are views of attributes listed already. 

185 continue 

186 

187 column = prop.columns[0] 

188 annotation = annotations.get(name) 

189 if annotation is not None: 

190 field_type, is_optional = _split_optional(annotation) 

191 else: 

192 try: 

193 field_type = column.type.python_type 

194 except NotImplementedError: 

195 field_type = object 

196 if field_type is object: 

197 raise TypeError( 

198 f"Cannot determine the Python type of " 

199 f"{model_cls.__name__}.{name} for an auto-generated schema: " 

200 f"it has no annotation that resolves at runtime, and its " 

201 f"column type {column.type!r} has no concrete python_type. Annotate " 

202 f"it as Mapped[...] or set schema= on the view." 

203 ) from None 

204 is_optional = bool(getattr(column, "nullable", False)) 

205 

206 default = None 

207 column_default = getattr(column, "default", None) 

208 server_default = getattr(column, "server_default", None) 

209 if column_default is not None or server_default is not None: 

210 default = column_default or server_default 

211 is_optional = True 

212 

213 fields[name] = { 

214 "type": field_type, 

215 "is_relationship": False, 

216 "target_model": None, 

217 "is_optional": is_optional, 

218 # A column_property over an expression cannot be written. 

219 "is_read_only": not isinstance(column, Column), 

220 "default": default, 

221 } 

222 

223 return fields 

224 

225 

226def derive_schema(model: type[Any], *, name: str | None = None) -> type[BaseSchema]: 

227 """ 

228 Generate a Pydantic schema from a SQLAlchemy model. 

229 

230 This is the same schema that a view generates when it has no ``schema``: 

231 one field per column, and no relationship fields. A foreign key column, 

232 such as ``author_id``, is an ordinary field. The fields ``id``, 

233 ``created_at`` and ``updated_at``, and read-only columns such as a 

234 ``column_property``, are marked ``ReadOnly``. 

235 

236 :param model: The SQLAlchemy model class. 

237 :param name: Name for the generated schema class. Defaults to the 

238 model name followed by ``Schema``, as in ``UserSchema``. 

239 :returns: A Pydantic schema class. 

240 """ 

241 if name is None: 

242 name = f"{model.__name__}Schema" 

243 

244 # Get field information from the model 

245 model_fields = get_model_fields(model) 

246 

247 # Determine base classes - start with the most specific ones 

248 bases: list[type] = [] 

249 

250 # Check if model has timestamp fields (inherits from TimestampsMixin) 

251 has_timestamps = "created_at" in model_fields and "updated_at" in model_fields 

252 if has_timestamps: 

253 bases.append(TimestampsSchemaMixin) 

254 

255 # Check if model has an id field (inherits from IDBase) 

256 has_id = "id" in model_fields 

257 if has_id: 

258 bases.append(IDSchema) 

259 

260 # Always include BaseSchema as the base 

261 bases.append(BaseSchema) 

262 

263 # Create field definitions for the schema 

264 field_definitions: dict[str, Any] = {} 

265 read_only_fields: list[str] = [] 

266 

267 for field_name, field_info in model_fields.items(): 

268 if field_info["is_relationship"]: 

269 continue 

270 

271 if ( 

272 field_name in ["id", "created_at", "updated_at"] 

273 or field_info["is_read_only"] 

274 ): 

275 read_only_fields.append(field_name) 

276 

277 pydantic_type = convert_sqlalchemy_type_to_pydantic( 

278 field_info["type"], field_info["is_optional"] 

279 ) 

280 

281 # Add field to definitions - use proper Pydantic field format 

282 # Don't include SQLAlchemy defaults as they're not JSON-serializable 

283 if field_info["is_optional"]: 

284 field_definitions[field_name] = (pydantic_type, Field(default=None)) 

285 else: 

286 field_definitions[field_name] = (pydantic_type, ...) 

287 

288 # Apply ReadOnly annotation to read-only fields 

289 for field_name in read_only_fields: 

290 if field_name in field_definitions: 290 ↛ 289line 290 didn't jump to line 289 because the condition on line 290 was always true

291 original_type, field_info = field_definitions[field_name] 

292 # Apply ReadOnly annotation to the type 

293 field_definitions[field_name] = (ReadOnly[original_type], field_info) 

294 

295 # Create the schema class using pydantic.create_model 

296 # The model's module, so OpenAPI's long names for this class point to 

297 # the project and not to Restly. 

298 schema_cls = pydantic.create_model( # type: ignore[call-overload] 

299 name, 

300 __doc__=f"Auto-generated schema for {model.__name__}", 

301 __base__=tuple(bases), 

302 __module__=model.__module__, 

303 **field_definitions, 

304 ) 

305 

306 return schema_cls 

307 

308 

309def convert_sqlalchemy_type_to_pydantic( 

310 sqlalchemy_type: Any, is_optional: bool = False 

311) -> Any: 

312 """ 

313 Convert a SQLAlchemy type to a Pydantic-compatible type. 

314 

315 :param sqlalchemy_type: The SQLAlchemy type. 

316 :param is_optional: Whether the field is optional. 

317 :returns: A Pydantic-compatible type. 

318 """ 

319 type_name = getattr(sqlalchemy_type, "__name__", str(sqlalchemy_type)) 

320 

321 if sqlalchemy_type is Any: 

322 pydantic_type = Any 

323 elif sqlalchemy_type in ( 

324 str, 

325 int, 

326 float, 

327 bool, 

328 dict, 

329 list, 

330 datetime, 

331 date, 

332 time, 

333 UUID, 

334 Decimal, 

335 ): 

336 pydantic_type = sqlalchemy_type 

337 elif isinstance(sqlalchemy_type, type) and issubclass(sqlalchemy_type, enum.Enum): 

338 pydantic_type = sqlalchemy_type 

339 elif is_mapped_class(sqlalchemy_type): 

340 # Relationship targets are replaced with nested schemas later. 

341 pydantic_type = sqlalchemy_type 

342 elif getattr(sqlalchemy_type, "__origin__", None) is not None: 

343 # Preserve parameterized container types like dict[str, Any] or list[int]. 

344 pydantic_type = sqlalchemy_type 

345 elif type_name in {"Text", "String"}: 

346 pydantic_type = str 

347 elif type_name in {"Integer"}: 

348 pydantic_type = int 

349 elif type_name in {"Float"}: 

350 pydantic_type = float 

351 elif type_name in {"Boolean"}: 

352 pydantic_type = bool 

353 elif type_name in {"DateTime"}: 

354 pydantic_type = datetime 

355 elif type_name in {"Date"}: 

356 pydantic_type = date 

357 elif type_name in {"Time"}: 

358 pydantic_type = time 

359 else: 

360 raise TypeError( 

361 f"Unsupported field type for auto-generated schema: {sqlalchemy_type!r}" 

362 ) 

363 

364 # Handle optional types 

365 if is_optional: 

366 pydantic_type = pydantic_type | None 

367 

368 return pydantic_type