fastapi-rtk 0.2.60__py3-none-any.whl → 1.0.18__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 (40) hide show
  1. fastapi_rtk/__init__.py +0 -1
  2. fastapi_rtk/_version.py +1 -0
  3. fastapi_rtk/api/model_rest_api.py +182 -87
  4. fastapi_rtk/auth/auth.py +0 -9
  5. fastapi_rtk/backends/sqla/db.py +32 -7
  6. fastapi_rtk/backends/sqla/filters.py +16 -0
  7. fastapi_rtk/backends/sqla/interface.py +11 -62
  8. fastapi_rtk/backends/sqla/model.py +16 -1
  9. fastapi_rtk/bases/db.py +20 -2
  10. fastapi_rtk/bases/file_manager.py +12 -0
  11. fastapi_rtk/bases/filter.py +1 -1
  12. fastapi_rtk/cli/cli.py +61 -0
  13. fastapi_rtk/cli/commands/security.py +6 -6
  14. fastapi_rtk/const.py +1 -1
  15. fastapi_rtk/db.py +3 -0
  16. fastapi_rtk/dependencies.py +110 -64
  17. fastapi_rtk/fastapi_react_toolkit.py +123 -172
  18. fastapi_rtk/file_managers/s3_file_manager.py +63 -32
  19. fastapi_rtk/lang/messages.pot +12 -12
  20. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.mo +0 -0
  21. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.po +12 -12
  22. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.mo +0 -0
  23. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.po +12 -12
  24. fastapi_rtk/manager.py +10 -14
  25. fastapi_rtk/schemas.py +6 -4
  26. fastapi_rtk/security/sqla/apis.py +20 -5
  27. fastapi_rtk/security/sqla/models.py +8 -23
  28. fastapi_rtk/security/sqla/security_manager.py +367 -10
  29. fastapi_rtk/utils/async_task_runner.py +119 -30
  30. fastapi_rtk/utils/csv_json_converter.py +242 -39
  31. fastapi_rtk/utils/hooks.py +7 -4
  32. fastapi_rtk/utils/self_dependencies.py +1 -1
  33. fastapi_rtk/version.py +6 -1
  34. fastapi_rtk-1.0.18.dist-info/METADATA +28 -0
  35. {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.18.dist-info}/RECORD +38 -38
  36. {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.18.dist-info}/WHEEL +1 -2
  37. fastapi_rtk-0.2.60.dist-info/METADATA +0 -25
  38. fastapi_rtk-0.2.60.dist-info/top_level.txt +0 -1
  39. {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.18.dist-info}/entry_points.txt +0 -0
  40. {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.18.dist-info}/licenses/LICENSE +0 -0
fastapi_rtk/__init__.py CHANGED
@@ -156,7 +156,6 @@ __all__ = [
156
156
  "docs",
157
157
  # .dependencies
158
158
  "set_global_user",
159
- "permissions",
160
159
  "current_permissions",
161
160
  "has_access_dependency",
162
161
  # .exceptions
@@ -0,0 +1 @@
1
+ __version__ = "1.0.18"
@@ -1,5 +1,4 @@
1
1
  import asyncio
2
- import copy
3
2
  import csv
4
3
  import enum
5
4
  import re
@@ -206,6 +205,19 @@ class ModelRestApi(BaseApi):
206
205
  """
207
206
  The list of routes to exclude from protection. Defaults to `[]`.
208
207
  """
208
+ expose_params: dict[AVAILABLE_ROUTES, dict[str, Any]] = lazy(lambda: dict())
209
+ """
210
+ Additional parameters to pass to the `expose` decorator for each route. See `expose` decorator for more information.
211
+
212
+ Example:
213
+ ```python
214
+ expose_params = {
215
+ 'image': {
216
+ 'description': 'Custom description for image endpoint',
217
+ },
218
+ }
219
+ ```
220
+ """
209
221
  base_order: Tuple[str, Literal["asc", "desc"]] | None = None
210
222
  """
211
223
  The default order for the list endpoint. Set this to set the default order for the list endpoint.
@@ -243,9 +255,7 @@ class ModelRestApi(BaseApi):
243
255
 
244
256
  Example:
245
257
  ```python
246
- opr_filters = [
247
- [FilterEqualOnNameAndAge],
248
- ]
258
+ opr_filters = [FilterEqualOnNameAndAge]
249
259
  ```
250
260
  """
251
261
  label_columns = lazy(lambda: dict[str, str]())
@@ -266,6 +276,8 @@ class ModelRestApi(BaseApi):
266
276
  {col: col for col in self.order_columns},
267
277
  type=str,
268
278
  )
279
+ if self.order_columns
280
+ else typing.Annotated[str, pydantic.AfterValidator(lambda _: None)]
269
281
  )
270
282
  """
271
283
  The enum for the order columns in the list endpoint.
@@ -450,6 +462,17 @@ class ModelRestApi(BaseApi):
450
462
  DO NOT MODIFY UNLESS YOU KNOW WHAT YOU ARE DOING.
451
463
  """
452
464
 
465
+ """
466
+ -------------
467
+ LIST
468
+ -------------
469
+ """
470
+
471
+ list_query_count_enabled = True
472
+ """
473
+ Whether to enable counting the total number of items in the list endpoint. Defaults to `True`.
474
+ """
475
+
453
476
  """
454
477
  -------------
455
478
  TITLES
@@ -542,17 +565,25 @@ class ModelRestApi(BaseApi):
542
565
  The list of columns to display in the edit endpoint. If not provided, all columns will be displayed.
543
566
  """
544
567
  search_columns = lazy(
545
- lambda self: self.datamodel.get_search_column_list(
546
- list_columns=self.list_columns
547
- )
568
+ lambda self: [
569
+ x
570
+ for x in self.datamodel.get_search_column_list(
571
+ list_columns=self.list_columns
572
+ )
573
+ if x not in self.search_exclude_columns
574
+ ]
548
575
  )
549
576
  """
550
577
  The list of columns that are allowed to be filtered in the list endpoint. If not provided, all columns will be allowed.
551
578
  """
552
579
  order_columns = lazy(
553
- lambda self: self.datamodel.get_order_column_list(
554
- list_columns=self.list_columns
555
- )
580
+ lambda self: [
581
+ x
582
+ for x in self.datamodel.get_order_column_list(
583
+ list_columns=self.list_columns
584
+ )
585
+ if x not in self.order_exclude_columns
586
+ ]
556
587
  )
557
588
  """
558
589
  The list of columns that can be ordered in the list endpoint. If not provided, all columns will be allowed.
@@ -584,6 +615,10 @@ class ModelRestApi(BaseApi):
584
615
  """
585
616
  The list of columns to exclude from the search columns.
586
617
  """
618
+ order_exclude_columns = lazy(lambda: list[str]())
619
+ """
620
+ The list of columns to exclude from the order columns.
621
+ """
587
622
 
588
623
  """
589
624
  -------------
@@ -1006,6 +1041,28 @@ class ModelRestApi(BaseApi):
1006
1041
  """
1007
1042
  return current_permissions(self)
1008
1043
 
1044
+ def get_q_and_session_count_factory(self):
1045
+ """
1046
+ Returns a dependency that yields a tuple of (QuerySchema, session or None).
1047
+
1048
+ If `list_query_count_enabled` is False or `q.with_count` is False, the session will be None.
1049
+
1050
+ Returns:
1051
+ Callable[..., Coroutine[Any, Any, Tuple[QuerySchema, AsyncSession | Session | None]]]: The dependency that yields the tuple.
1052
+ """
1053
+
1054
+ async def dependency(
1055
+ q: self.query_schema = Depends(), # type: ignore
1056
+ session=Depends(self.datamodel.get_session_factory()),
1057
+ ):
1058
+ q: QuerySchema = q
1059
+ if not self.list_query_count_enabled or not q.with_count:
1060
+ # TODO: Find a better way to not request session count when not needed
1061
+ return q, None
1062
+ return q, session
1063
+
1064
+ return dependency
1065
+
1009
1066
  """
1010
1067
  -------------
1011
1068
  ROUTES
@@ -1026,12 +1083,15 @@ class ModelRestApi(BaseApi):
1026
1083
  expose(
1027
1084
  "/_image/{filename}",
1028
1085
  methods=["GET"],
1029
- name="Get Image",
1030
- description="Get an image from the application.",
1031
- response_class=FileResponse,
1032
- dependencies=[Depends(has_access_dependency(self, "image"))]
1033
- if "image" in self.protected_routes
1034
- else None,
1086
+ **{
1087
+ "name": "Get Image",
1088
+ "description": f"Get an image associated with an existing {self.datamodel.obj.__name__} item.",
1089
+ "response_class": FileResponse,
1090
+ "dependencies": [Depends(has_access_dependency(self, "image"))]
1091
+ if "image" in self.protected_routes
1092
+ else None,
1093
+ **self.expose_params.get("image", {}),
1094
+ },
1035
1095
  )(self.image_headless)
1036
1096
 
1037
1097
  def file(self):
@@ -1048,12 +1108,15 @@ class ModelRestApi(BaseApi):
1048
1108
  expose(
1049
1109
  "/_file/{filename}",
1050
1110
  methods=["GET"],
1051
- name="Get File",
1052
- description="Get a file from the application.",
1053
- response_class=FileResponse,
1054
- dependencies=[Depends(has_access_dependency(self, "file"))]
1055
- if "file" in self.protected_routes
1056
- else None,
1111
+ **{
1112
+ "name": "Get File",
1113
+ "description": f"Get a file associated with an existing {self.datamodel.obj.__name__} item.",
1114
+ "response_class": FileResponse,
1115
+ "dependencies": [Depends(has_access_dependency(self, "file"))]
1116
+ if "file" in self.protected_routes
1117
+ else None,
1118
+ **self.expose_params.get("file", {}),
1119
+ },
1057
1120
  )(self.file_headless)
1058
1121
 
1059
1122
  def info(self):
@@ -1065,12 +1128,15 @@ class ModelRestApi(BaseApi):
1065
1128
  expose(
1066
1129
  "/_info",
1067
1130
  methods=["GET"],
1068
- name="Get Info",
1069
- description="Get the info for this API's Model.",
1070
- response_model=self.info_return_schema | typing.Any,
1071
- dependencies=[Depends(has_access_dependency(self, "info"))]
1072
- if "info" in self.protected_routes
1073
- else None,
1131
+ **{
1132
+ "name": "Get Info",
1133
+ "description": f"Get metadata information about the model {self.datamodel.obj.__name__}. such as add/edit columns, filter options, titles, and JSONForms schemas.",
1134
+ "response_model": self.info_return_schema | typing.Any,
1135
+ "dependencies": [Depends(has_access_dependency(self, "info"))]
1136
+ if "info" in self.protected_routes
1137
+ else None,
1138
+ **self.expose_params.get("info", {}),
1139
+ },
1074
1140
  )(self.info_headless)
1075
1141
 
1076
1142
  def download(self):
@@ -1082,11 +1148,14 @@ class ModelRestApi(BaseApi):
1082
1148
  expose(
1083
1149
  "/download",
1084
1150
  methods=["GET"],
1085
- name="Download",
1086
- description="Download list of items in CSV format.",
1087
- dependencies=[Depends(has_access_dependency(self, "download"))]
1088
- if "download" in self.protected_routes
1089
- else None,
1151
+ **{
1152
+ "name": "Download",
1153
+ "description": f"Download list of {self.datamodel.obj.__name__} items as a CSV file with support for filtering, ordering, and global search.",
1154
+ "dependencies": [Depends(has_access_dependency(self, "download"))]
1155
+ if "download" in self.protected_routes
1156
+ else None,
1157
+ **self.expose_params.get("download", {}),
1158
+ },
1090
1159
  )(self.download_headless)
1091
1160
 
1092
1161
  #! Disabled until further notice
@@ -1113,11 +1182,14 @@ class ModelRestApi(BaseApi):
1113
1182
  expose(
1114
1183
  "/bulk/{handler}",
1115
1184
  methods=["POST"],
1116
- name="Bulk",
1117
- description="Handle bulk operations.",
1118
- dependencies=[Depends(has_access_dependency(self, "bulk"))]
1119
- if "bulk" in self.protected_routes
1120
- else None,
1185
+ **{
1186
+ "name": "Bulk",
1187
+ "description": f"Handle bulk operations for the model {self.datamodel.obj.__name__} that are set in the API.",
1188
+ "dependencies": [Depends(has_access_dependency(self, "bulk"))]
1189
+ if "bulk" in self.protected_routes
1190
+ else None,
1191
+ **self.expose_params.get("bulk", {}),
1192
+ },
1121
1193
  )(self.bulk_headless)
1122
1194
 
1123
1195
  def get_list(self):
@@ -1129,12 +1201,15 @@ class ModelRestApi(BaseApi):
1129
1201
  expose(
1130
1202
  "/",
1131
1203
  methods=["GET"],
1132
- name="Get items",
1133
- description="Get a list of items.",
1134
- response_model=self.list_return_schema | typing.Any,
1135
- dependencies=[Depends(has_access_dependency(self, "get"))]
1136
- if "get_list" in self.protected_routes
1137
- else None,
1204
+ **{
1205
+ "name": "Get items",
1206
+ "description": f"Get a list of {self.datamodel.obj.__name__} items with support for column selection, filtering, ordering, pagination, and global search.",
1207
+ "response_model": self.list_return_schema | typing.Any,
1208
+ "dependencies": [Depends(has_access_dependency(self, "get"))]
1209
+ if "get_list" in self.protected_routes
1210
+ else None,
1211
+ **self.expose_params.get("get_list", {}),
1212
+ },
1138
1213
  )(self.get_list_headless)
1139
1214
 
1140
1215
  def get(self):
@@ -1146,12 +1221,15 @@ class ModelRestApi(BaseApi):
1146
1221
  expose(
1147
1222
  "/{id}",
1148
1223
  methods=["GET"],
1149
- name="Get item",
1150
- description="Get a single item.",
1151
- response_model=self.show_return_schema | typing.Any,
1152
- dependencies=[Depends(has_access_dependency(self, "get"))]
1153
- if "get" in self.protected_routes
1154
- else None,
1224
+ **{
1225
+ "name": "Get item",
1226
+ "description": f"Get a single {self.datamodel.obj.__name__} item.",
1227
+ "response_model": self.show_return_schema | typing.Any,
1228
+ "dependencies": [Depends(has_access_dependency(self, "get"))]
1229
+ if "get" in self.protected_routes
1230
+ else None,
1231
+ **self.expose_params.get("get", {}),
1232
+ },
1155
1233
  )(self.get_headless)
1156
1234
 
1157
1235
  def post(self):
@@ -1163,12 +1241,15 @@ class ModelRestApi(BaseApi):
1163
1241
  expose(
1164
1242
  "/",
1165
1243
  methods=["POST"],
1166
- name="Add item",
1167
- description="Add a new item.",
1168
- response_model=self.add_return_schema | typing.Any,
1169
- dependencies=[Depends(has_access_dependency(self, "post"))]
1170
- if "post" in self.protected_routes
1171
- else None,
1244
+ **{
1245
+ "name": "Add item",
1246
+ "description": f"Add a new {self.datamodel.obj.__name__} item.",
1247
+ "response_model": self.add_return_schema | typing.Any,
1248
+ "dependencies": [Depends(has_access_dependency(self, "post"))]
1249
+ if "post" in self.protected_routes
1250
+ else None,
1251
+ **self.expose_params.get("post", {}),
1252
+ },
1172
1253
  )(self.post_headless)
1173
1254
 
1174
1255
  def put(self):
@@ -1180,12 +1261,15 @@ class ModelRestApi(BaseApi):
1180
1261
  expose(
1181
1262
  "/{id}",
1182
1263
  methods=["PUT"],
1183
- name="Update item",
1184
- description="Update an item.",
1185
- response_model=self.edit_return_schema | typing.Any,
1186
- dependencies=[Depends(has_access_dependency(self, "put"))]
1187
- if "put" in self.protected_routes
1188
- else None,
1264
+ **{
1265
+ "name": "Update item",
1266
+ "description": f"Update an existing {self.datamodel.obj.__name__} item.",
1267
+ "response_model": self.edit_return_schema | typing.Any,
1268
+ "dependencies": [Depends(has_access_dependency(self, "put"))]
1269
+ if "put" in self.protected_routes
1270
+ else None,
1271
+ **self.expose_params.get("put", {}),
1272
+ },
1189
1273
  )(self.put_headless)
1190
1274
 
1191
1275
  def delete(self):
@@ -1197,12 +1281,15 @@ class ModelRestApi(BaseApi):
1197
1281
  expose(
1198
1282
  "/{id}",
1199
1283
  methods=["DELETE"],
1200
- response_model=GeneralResponse | typing.Any,
1201
- name="Delete item",
1202
- description="Delete an item.",
1203
- dependencies=[Depends(has_access_dependency(self, "delete"))]
1204
- if "delete" in self.protected_routes
1205
- else None,
1284
+ **{
1285
+ "name": "Delete item",
1286
+ "description": f"Delete an existing {self.datamodel.obj.__name__} item.",
1287
+ "response_model": GeneralResponse | typing.Any,
1288
+ "dependencies": [Depends(has_access_dependency(self, "delete"))]
1289
+ if "delete" in self.protected_routes
1290
+ else None,
1291
+ **self.expose_params.get("delete", {}),
1292
+ },
1206
1293
  )(self.delete_headless)
1207
1294
 
1208
1295
  """
@@ -1454,19 +1541,17 @@ class ModelRestApi(BaseApi):
1454
1541
 
1455
1542
  async def get_list_headless(
1456
1543
  self,
1457
- q: QuerySchema = SelfType.with_depends().query_schema,
1458
1544
  session: AsyncSession | Session = SelfDepends().datamodel.get_session_factory,
1459
- session_count: AsyncSession
1460
- | Session = SelfDepends().datamodel.get_session_factory,
1545
+ q_session_count: tuple[
1546
+ QuerySchema, AsyncSession | Session | None
1547
+ ] = SelfDepends().get_q_and_session_count_factory,
1461
1548
  ):
1462
1549
  """
1463
1550
  Retrieves all items in a headless mode.
1464
1551
 
1465
1552
  Args:
1466
- q (QuerySchema): The query schema.
1467
- query (QueryManager): The query manager object.
1468
1553
  session (AsyncSession | Session): A database scoped session.
1469
- session_count (AsyncSession | Session): A database scoped session for counting.
1554
+ q_session_count (tuple[QuerySchema, AsyncSession | Session | None]): A tuple of query schema and a database scoped session for counting.
1470
1555
 
1471
1556
  Returns:
1472
1557
  list_return_schema: The list return schema.
@@ -1475,6 +1560,7 @@ class ModelRestApi(BaseApi):
1475
1560
  If you are overriding this method, make sure to copy all the decorators too.
1476
1561
  """
1477
1562
  async with AsyncTaskRunner():
1563
+ q, session_count = q_session_count
1478
1564
  self._validate_query_parameters(q)
1479
1565
  params = self._handle_query_params(q)
1480
1566
  try:
@@ -1482,9 +1568,14 @@ class ModelRestApi(BaseApi):
1482
1568
  task_items = tg.create_task(
1483
1569
  smart_run(self.datamodel.get_many, session, params)
1484
1570
  )
1485
- task_count = tg.create_task(
1486
- smart_run(self.datamodel.count, session_count, params)
1487
- )
1571
+ if session_count:
1572
+ task_count = tg.create_task(
1573
+ smart_run(self.datamodel.count, session_count, params)
1574
+ )
1575
+ else:
1576
+ task_count = tg.create_task(
1577
+ asyncio.sleep(0, result=None)
1578
+ ) # Dummy task
1488
1579
  items, count = await task_items, await task_count
1489
1580
  pks, data = self.datamodel.convert_to_result(items)
1490
1581
  schema = self.list_return_schema
@@ -2274,6 +2365,7 @@ class ModelRestApi(BaseApi):
2274
2365
  )
2275
2366
 
2276
2367
  if self.datamodel.is_files(key) or self.datamodel.is_images(key):
2368
+ value = [x for x in value if x] # Remove None values
2277
2369
  old_filenames = (
2278
2370
  [x for x in value if isinstance(x, str)] if value else []
2279
2371
  )
@@ -2534,12 +2626,18 @@ class ModelRestApi(BaseApi):
2534
2626
  Returns:
2535
2627
  dict: The generated JSONForms schema.
2536
2628
  """
2537
- jsonforms_schema = schema.model_json_schema()
2629
+ cache_key = f"jsonforms_schema_{schema.__name__}"
2630
+ jsonforms_schema = self.cache.get(cache_key)
2631
+ if not jsonforms_schema:
2632
+ self.cache[cache_key] = jsonforms_schema = schema.model_json_schema()
2538
2633
 
2539
2634
  # Remove unused vars
2540
2635
  jsonforms_schema.pop("$defs", None)
2541
2636
 
2542
- for key, value in jsonforms_schema["properties"].items():
2637
+ result = jsonforms_schema.copy()
2638
+ result["properties"] = jsonforms_schema["properties"].copy()
2639
+ for key, value in result["properties"].items():
2640
+ value = value.copy()
2543
2641
  label = self.label_columns.get(key)
2544
2642
  if label:
2545
2643
  value["title"] = label
@@ -2564,7 +2662,7 @@ class ModelRestApi(BaseApi):
2564
2662
  ]
2565
2663
  value["contentMediaType"] = ", ".join(allowed_extensions)
2566
2664
  if self.datamodel.is_files(key) or self.datamodel.is_images(key):
2567
- current_value = copy.deepcopy(value)
2665
+ current_value = value.copy()
2568
2666
  value["type"] = "array"
2569
2667
  value["items"] = current_value
2570
2668
  elif self.datamodel.is_boolean(key):
@@ -2631,9 +2729,9 @@ class ModelRestApi(BaseApi):
2631
2729
  if key in g.sensitive_data.get(self.datamodel.obj.__name__, []):
2632
2730
  value["format"] = "password"
2633
2731
 
2634
- jsonforms_schema["properties"][key] = value
2732
+ result["properties"][key] = value
2635
2733
 
2636
- return jsonforms_schema
2734
+ return result
2637
2735
 
2638
2736
  async def _export_data(
2639
2737
  self,
@@ -2678,11 +2776,8 @@ class ModelRestApi(BaseApi):
2678
2776
 
2679
2777
  async for chunk in data:
2680
2778
  for item in chunk:
2681
- async with AsyncTaskRunner():
2682
- item_model = schema.model_validate(item, from_attributes=True)
2683
- item_dict = item_model.model_dump(mode="json")
2684
- row = CSVJSONConverter._json_to_csv(
2685
- item_dict,
2779
+ row = await CSVJSONConverter.ajson_to_csv_single(
2780
+ item,
2686
2781
  list_columns=list_columns,
2687
2782
  delimiter=delimiter,
2688
2783
  export_mode=export_mode,
fastapi_rtk/auth/auth.py CHANGED
@@ -142,15 +142,6 @@ class Authenticator(BaseAuthenticator):
142
142
  except HTTPException as e:
143
143
  if not default_to_none:
144
144
  raise e
145
-
146
- # Retrieve list of apis, that user has access to
147
- if user:
148
- user.permissions = []
149
- for role in user.roles:
150
- for permission_api in role.permissions:
151
- user.permissions.append(permission_api.api.name)
152
- user.permissions = list(set(user.permissions))
153
-
154
145
  return user, token
155
146
 
156
147
 
@@ -34,6 +34,7 @@ LOAD_TYPE_MAPPING = {
34
34
 
35
35
  class LoadColumn(typing.TypedDict):
36
36
  statement: Select[tuple[T]] | _AbstractLoad
37
+ statement_type: typing.Literal["select", "joinedload", "selectinload"] | None
37
38
  type: typing.Literal["defer", "some", "all"]
38
39
  columns: list[str]
39
40
  related_columns: collections.defaultdict[str, "LoadColumn"]
@@ -42,6 +43,7 @@ class LoadColumn(typing.TypedDict):
42
43
  def create_load_column(statement: Select[tuple[T]] | _AbstractLoad | None = None):
43
44
  return LoadColumn(
44
45
  statement=statement,
46
+ statement_type=None,
45
47
  type="defer",
46
48
  columns=[],
47
49
  related_columns=collections.defaultdict(create_load_column),
@@ -104,7 +106,7 @@ class SQLAQueryBuilder(AbstractQueryBuilder[Select[tuple[T]]]):
104
106
  statement = statement.options(cache_option)
105
107
  return statement
106
108
 
107
- load_column = self._load_columns_recursively(statement, list_columns)
109
+ load_column = self._load_columns_recursively(statement, "select", list_columns)
108
110
  logger.debug(f"Load Column:\n{prettify_dict(load_column)}")
109
111
  return self._load_columns_from_dictionary(statement, load_column, list_columns)
110
112
 
@@ -365,27 +367,32 @@ class SQLAQueryBuilder(AbstractQueryBuilder[Select[tuple[T]]]):
365
367
  return statement
366
368
 
367
369
  def _load_columns_recursively(
368
- self, statement: Select[tuple[T]] | _AbstractLoad, columns: list[str]
370
+ self,
371
+ statement: Select[tuple[T]] | _AbstractLoad,
372
+ statement_type: typing.Literal["select", "joinedload", "selectinload"],
373
+ columns: list[str],
369
374
  ):
370
375
  """
371
376
  Load specified columns into the given SQLAlchemy statement. This returns a dictionary that can be used with the `load_columns_from_dictionary` method.
372
377
 
373
378
  Args:
374
379
  statement (Select[tuple[T]] | _AbstractLoad): The SQLAlchemy statement to which the columns will be loaded.
380
+ statement_type (typing.Literal["select", "joinedload", "selectinload"]): The type of statement to use for loading.
375
381
  columns (list[str]): A list of column names to be loaded.
376
382
 
377
383
  Returns:
378
384
  dict: A dictionary that can be used with the `load_columns_from_dictionary` method.
379
385
  """
380
386
  load_column = create_load_column(statement)
387
+ load_column["statement_type"] = statement_type
381
388
  for col in columns:
382
389
  sub_col = ""
383
390
  if "." in col:
384
391
  col, sub_col = col.split(".", 1)
385
392
 
386
- # If it is not a relation, load only the column if it is in the user column list, else skip
393
+ # If it is not a relation, load only the column if it is in the column list, else skip
387
394
  if not self.datamodel.is_relation(col):
388
- if col in self.datamodel.get_user_column_list():
395
+ if col in self.datamodel.get_column_list():
389
396
  load_column["columns"].append(col)
390
397
  load_column["type"] = "some"
391
398
  continue
@@ -393,10 +400,18 @@ class SQLAQueryBuilder(AbstractQueryBuilder[Select[tuple[T]]]):
393
400
  if self.datamodel.is_relation_one_to_one(
394
401
  col
395
402
  ) or self.datamodel.is_relation_many_to_one(col):
403
+ load_column["related_columns"][col]["statement_type"] = (
404
+ load_column["related_columns"][col]["statement_type"]
405
+ or "joinedload"
406
+ )
396
407
  load_column["related_columns"][col]["statement"] = load_column[
397
408
  "related_columns"
398
409
  ][col]["statement"] or joinedload(self.datamodel.obj.load_options(col))
399
410
  else:
411
+ load_column["related_columns"][col]["statement_type"] = (
412
+ load_column["related_columns"][col]["statement_type"]
413
+ or "selectinload"
414
+ )
400
415
  load_column["related_columns"][col]["statement"] = load_column[
401
416
  "related_columns"
402
417
  ][col]["statement"] or selectinload(
@@ -410,7 +425,9 @@ class SQLAQueryBuilder(AbstractQueryBuilder[Select[tuple[T]]]):
410
425
  load_column["related_columns"][col] = deep_merge(
411
426
  load_column["related_columns"][col],
412
427
  interface.query._load_columns_recursively(
413
- load_column["related_columns"][col]["statement"], [sub_col]
428
+ load_column["related_columns"][col]["statement"],
429
+ load_column["related_columns"][col]["statement_type"],
430
+ [sub_col],
414
431
  ),
415
432
  rules={
416
433
  "type": lambda x1, x2: LOAD_TYPE_MAPPING[x2]
@@ -427,8 +444,8 @@ class SQLAQueryBuilder(AbstractQueryBuilder[Select[tuple[T]]]):
427
444
  load_column["related_columns"][col]["type"] = "all"
428
445
  continue
429
446
 
430
- # Skip if the sub column is not in the user column list or if it is a relation
431
- if sub_col not in interface.get_user_column_list() or interface.is_relation(
447
+ # Skip if the sub column is not in the column list or if it is a relation
448
+ if sub_col not in interface.get_column_list() or interface.is_relation(
432
449
  sub_col
433
450
  ):
434
451
  continue
@@ -437,3 +454,11 @@ class SQLAQueryBuilder(AbstractQueryBuilder[Select[tuple[T]]]):
437
454
  load_column["related_columns"][col]["type"] = "some"
438
455
 
439
456
  return load_column
457
+
458
+ def _convert_id_into_dict(self, id):
459
+ # Cast the id into the right type based on the pk column type
460
+ pk_dict = super()._convert_id_into_dict(id)
461
+ for pk in pk_dict:
462
+ col_type = self.datamodel.list_columns[pk].type.python_type
463
+ pk_dict[pk] = col_type(pk_dict[pk])
464
+ return pk_dict
@@ -617,6 +617,22 @@ class SQLAFilterConverter:
617
617
  FilterIn,
618
618
  ],
619
619
  ),
620
+ (
621
+ "is_files",
622
+ [
623
+ FilterTextContains,
624
+ # TODO: Make compatible filters
625
+ # FilterEqual,
626
+ # FilterNotEqual,
627
+ # FilterStartsWith,
628
+ # FilterNotStartsWith,
629
+ # FilterEndsWith,
630
+ # FilterNotEndsWith,
631
+ # FilterContains,
632
+ # FilterNotContains,
633
+ # FilterIn,
634
+ ],
635
+ ),
620
636
  (
621
637
  "is_integer",
622
638
  [