flask-appbuilder 3.2.1rc1__py3-none-any.whl → 5.0.2rc1__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 (228) hide show
  1. flask_appbuilder/__init__.py +2 -3
  2. flask_appbuilder/_compat.py +0 -1
  3. flask_appbuilder/actions.py +14 -14
  4. flask_appbuilder/api/__init__.py +741 -527
  5. flask_appbuilder/api/convert.py +104 -98
  6. flask_appbuilder/api/manager.py +14 -8
  7. flask_appbuilder/api/schemas.py +12 -1
  8. flask_appbuilder/babel/manager.py +12 -16
  9. flask_appbuilder/base.py +353 -280
  10. flask_appbuilder/basemanager.py +1 -1
  11. flask_appbuilder/baseviews.py +241 -164
  12. flask_appbuilder/charts/jsontools.py +10 -10
  13. flask_appbuilder/charts/views.py +56 -60
  14. flask_appbuilder/cli.py +115 -70
  15. flask_appbuilder/const.py +52 -52
  16. flask_appbuilder/exceptions.py +67 -5
  17. flask_appbuilder/fields.py +32 -23
  18. flask_appbuilder/fieldwidgets.py +34 -27
  19. flask_appbuilder/filemanager.py +33 -45
  20. flask_appbuilder/filters.py +11 -13
  21. flask_appbuilder/forms.py +31 -35
  22. flask_appbuilder/hooks.py +90 -0
  23. flask_appbuilder/menu.py +35 -10
  24. flask_appbuilder/models/base.py +47 -57
  25. flask_appbuilder/models/decorators.py +13 -13
  26. flask_appbuilder/models/filters.py +42 -38
  27. flask_appbuilder/models/generic/__init__.py +29 -29
  28. flask_appbuilder/models/generic/filters.py +11 -3
  29. flask_appbuilder/models/generic/interface.py +1 -3
  30. flask_appbuilder/models/group.py +37 -39
  31. flask_appbuilder/models/mixins.py +22 -18
  32. flask_appbuilder/models/sqla/__init__.py +19 -72
  33. flask_appbuilder/models/sqla/base.py +24 -0
  34. flask_appbuilder/models/sqla/base_legacy.py +132 -0
  35. flask_appbuilder/models/sqla/filters.py +132 -19
  36. flask_appbuilder/models/sqla/interface.py +390 -276
  37. flask_appbuilder/security/api.py +31 -35
  38. flask_appbuilder/security/decorators.py +181 -83
  39. flask_appbuilder/security/forms.py +20 -31
  40. flask_appbuilder/security/manager.py +715 -489
  41. flask_appbuilder/security/registerviews.py +29 -112
  42. flask_appbuilder/security/schemas.py +43 -0
  43. flask_appbuilder/security/sqla/apis/__init__.py +8 -0
  44. flask_appbuilder/security/sqla/apis/group/__init__.py +1 -0
  45. flask_appbuilder/security/sqla/apis/group/api.py +227 -0
  46. flask_appbuilder/security/sqla/apis/group/schema.py +73 -0
  47. flask_appbuilder/security/sqla/apis/permission/__init__.py +1 -0
  48. flask_appbuilder/security/sqla/apis/permission/api.py +19 -0
  49. flask_appbuilder/security/sqla/apis/permission_view_menu/__init__.py +1 -0
  50. flask_appbuilder/security/sqla/apis/permission_view_menu/api.py +16 -0
  51. flask_appbuilder/security/sqla/apis/role/__init__.py +1 -0
  52. flask_appbuilder/security/sqla/apis/role/api.py +306 -0
  53. flask_appbuilder/security/sqla/apis/role/schema.py +27 -0
  54. flask_appbuilder/security/sqla/apis/user/__init__.py +1 -0
  55. flask_appbuilder/security/sqla/apis/user/api.py +292 -0
  56. flask_appbuilder/security/sqla/apis/user/schema.py +97 -0
  57. flask_appbuilder/security/sqla/apis/user/validator.py +27 -0
  58. flask_appbuilder/security/sqla/apis/view_menu/__init__.py +1 -0
  59. flask_appbuilder/security/sqla/apis/view_menu/api.py +18 -0
  60. flask_appbuilder/security/sqla/manager.py +421 -203
  61. flask_appbuilder/security/sqla/models.py +192 -57
  62. flask_appbuilder/security/utils.py +9 -0
  63. flask_appbuilder/security/views.py +232 -229
  64. flask_appbuilder/static/.DS_Store +0 -0
  65. flask_appbuilder/static/appbuilder/css/ab.css +20 -12
  66. flask_appbuilder/static/appbuilder/css/bootstrap-datepicker/bootstrap-datepicker3.min.css +7 -0
  67. flask_appbuilder/static/appbuilder/css/bootstrap.min.css.map +1 -0
  68. flask_appbuilder/static/appbuilder/css/flags/flags16.css +249 -245
  69. flask_appbuilder/static/appbuilder/css/fontawesome/all.min.css +6 -0
  70. flask_appbuilder/static/appbuilder/css/fontawesome/brands.min.css +6 -0
  71. flask_appbuilder/static/appbuilder/css/fontawesome/fontawesome.min.css +6 -0
  72. flask_appbuilder/static/appbuilder/css/fontawesome/regular.min.css +6 -0
  73. flask_appbuilder/static/appbuilder/css/fontawesome/solid.min.css +6 -0
  74. flask_appbuilder/static/appbuilder/css/fontawesome/svg-with-js.min.css +6 -0
  75. flask_appbuilder/static/appbuilder/css/fontawesome/v4-font-face.min.css +6 -0
  76. flask_appbuilder/static/appbuilder/css/fontawesome/v4-shims.min.css +6 -0
  77. flask_appbuilder/static/appbuilder/css/fontawesome/v5-font-face.min.css +6 -0
  78. flask_appbuilder/static/appbuilder/css/images/flags16.png +0 -0
  79. flask_appbuilder/static/appbuilder/css/select2/select2-bootstrap.min.css +7 -0
  80. flask_appbuilder/static/appbuilder/css/select2/select2.min.css +1 -0
  81. flask_appbuilder/static/appbuilder/css/swagger/swagger-ui.css +3 -0
  82. flask_appbuilder/static/appbuilder/css/webfonts/fa-brands-400.ttf +0 -0
  83. flask_appbuilder/static/appbuilder/css/webfonts/fa-brands-400.woff2 +0 -0
  84. flask_appbuilder/static/appbuilder/css/webfonts/fa-regular-400.ttf +0 -0
  85. flask_appbuilder/static/appbuilder/css/webfonts/fa-regular-400.woff2 +0 -0
  86. flask_appbuilder/static/appbuilder/css/webfonts/fa-solid-900.ttf +0 -0
  87. flask_appbuilder/static/appbuilder/css/webfonts/fa-solid-900.woff2 +0 -0
  88. flask_appbuilder/static/appbuilder/css/webfonts/fa-v4compatibility.ttf +0 -0
  89. flask_appbuilder/static/appbuilder/css/webfonts/fa-v4compatibility.woff2 +0 -0
  90. flask_appbuilder/static/appbuilder/js/ab.js +33 -23
  91. flask_appbuilder/static/appbuilder/js/ab_filters.js +91 -84
  92. flask_appbuilder/static/appbuilder/js/bootstrap-datepicker/bootstrap-datepicker.min.js +8 -0
  93. flask_appbuilder/static/appbuilder/js/jquery-latest.js +2 -2
  94. flask_appbuilder/static/appbuilder/js/select2/select2.min.js +2 -0
  95. flask_appbuilder/static/appbuilder/js/swagger-ui-bundle.js +3 -0
  96. flask_appbuilder/templates/appbuilder/baselib.html +9 -3
  97. flask_appbuilder/templates/appbuilder/general/lib.html +60 -34
  98. flask_appbuilder/templates/appbuilder/general/model/edit.html +1 -1
  99. flask_appbuilder/templates/appbuilder/general/model/edit_cascade.html +1 -1
  100. flask_appbuilder/templates/appbuilder/general/model/search.html +3 -2
  101. flask_appbuilder/templates/appbuilder/general/model/show.html +1 -1
  102. flask_appbuilder/templates/appbuilder/general/model/show_cascade.html +1 -1
  103. flask_appbuilder/templates/appbuilder/general/security/login_db.html +7 -7
  104. flask_appbuilder/templates/appbuilder/general/security/login_ldap.html +5 -5
  105. flask_appbuilder/templates/appbuilder/general/security/login_oauth.html +24 -49
  106. flask_appbuilder/templates/appbuilder/general/widgets/base_list.html +2 -1
  107. flask_appbuilder/templates/appbuilder/general/widgets/chart.html +4 -2
  108. flask_appbuilder/templates/appbuilder/general/widgets/direct_chart.html +4 -3
  109. flask_appbuilder/templates/appbuilder/general/widgets/multiple_chart.html +3 -2
  110. flask_appbuilder/templates/appbuilder/general/widgets/search.html +11 -10
  111. flask_appbuilder/templates/appbuilder/init.html +37 -43
  112. flask_appbuilder/templates/appbuilder/navbar_menu.html +1 -1
  113. flask_appbuilder/templates/appbuilder/navbar_right.html +2 -2
  114. flask_appbuilder/templates/appbuilder/swagger/swagger.html +22 -19
  115. flask_appbuilder/translations/de/LC_MESSAGES/messages.mo +0 -0
  116. flask_appbuilder/translations/de/LC_MESSAGES/messages.po +305 -161
  117. flask_appbuilder/translations/fa/LC_MESSAGES/messages.mo +0 -0
  118. flask_appbuilder/translations/fa/LC_MESSAGES/messages.po +802 -0
  119. flask_appbuilder/translations/fr/LC_MESSAGES/messages.po +461 -319
  120. flask_appbuilder/translations/pt_BR/LC_MESSAGES/messages.po +650 -650
  121. flask_appbuilder/translations/ru/LC_MESSAGES/messages.po +1 -1
  122. flask_appbuilder/translations/sl/LC_MESSAGES/messages.mo +0 -0
  123. flask_appbuilder/translations/sl/LC_MESSAGES/messages.po +690 -0
  124. flask_appbuilder/translations/tr/LC_MESSAGES/messages.mo +0 -0
  125. flask_appbuilder/translations/tr/LC_MESSAGES/messages.po +1015 -0
  126. flask_appbuilder/upload.py +20 -22
  127. flask_appbuilder/urltools.py +39 -19
  128. flask_appbuilder/utils/base.py +76 -0
  129. flask_appbuilder/utils/legacy.py +33 -0
  130. flask_appbuilder/utils/limit.py +20 -0
  131. flask_appbuilder/validators.py +73 -14
  132. flask_appbuilder/views.py +75 -424
  133. flask_appbuilder/widgets.py +50 -51
  134. {Flask_AppBuilder-3.2.1rc1.dist-info → flask_appbuilder-5.0.2rc1.dist-info}/METADATA +36 -76
  135. flask_appbuilder-5.0.2rc1.dist-info/RECORD +240 -0
  136. {Flask_AppBuilder-3.2.1rc1.dist-info → flask_appbuilder-5.0.2rc1.dist-info}/WHEEL +1 -1
  137. flask_appbuilder-5.0.2rc1.dist-info/entry_points.txt +2 -0
  138. Flask_AppBuilder-3.2.1rc1.dist-info/RECORD +0 -270
  139. Flask_AppBuilder-3.2.1rc1.dist-info/entry_points.txt +0 -6
  140. flask_appbuilder/console.py +0 -426
  141. flask_appbuilder/models/mongoengine/__init__.py +0 -0
  142. flask_appbuilder/models/mongoengine/fields.py +0 -65
  143. flask_appbuilder/models/mongoengine/filters.py +0 -145
  144. flask_appbuilder/models/mongoengine/interface.py +0 -328
  145. flask_appbuilder/security/mongoengine/__init__.py +0 -0
  146. flask_appbuilder/security/mongoengine/manager.py +0 -402
  147. flask_appbuilder/security/mongoengine/models.py +0 -120
  148. flask_appbuilder/static/appbuilder/css/font-awesome.min.css +0 -4
  149. flask_appbuilder/static/appbuilder/datepicker/bootstrap-datepicker.css +0 -9
  150. flask_appbuilder/static/appbuilder/datepicker/bootstrap-datepicker.js +0 -28
  151. flask_appbuilder/static/appbuilder/fonts/FontAwesome.otf +0 -0
  152. flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.eot +0 -0
  153. flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.svg +0 -2671
  154. flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.ttf +0 -0
  155. flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.woff +0 -0
  156. flask_appbuilder/static/appbuilder/fonts/fontawesome-webfont.woff2 +0 -0
  157. flask_appbuilder/static/appbuilder/img/aol.png +0 -0
  158. flask_appbuilder/static/appbuilder/img/flags/flags16.png +0 -0
  159. flask_appbuilder/static/appbuilder/img/flickr.png +0 -0
  160. flask_appbuilder/static/appbuilder/img/google.png +0 -0
  161. flask_appbuilder/static/appbuilder/img/myopenid.png +0 -0
  162. flask_appbuilder/static/appbuilder/img/yahoo.png +0 -0
  163. flask_appbuilder/static/appbuilder/js/_google_charts.js +0 -39
  164. flask_appbuilder/static/appbuilder/js/html5shiv.js +0 -8
  165. flask_appbuilder/static/appbuilder/js/respond.min.js +0 -6
  166. flask_appbuilder/static/appbuilder/select2/select2-spinner.gif +0 -0
  167. flask_appbuilder/static/appbuilder/select2/select2.css +0 -1205
  168. flask_appbuilder/static/appbuilder/select2/select2.js +0 -23
  169. flask_appbuilder/static/appbuilder/select2/select2.png +0 -0
  170. flask_appbuilder/static/appbuilder/select2/select2x2.png +0 -0
  171. flask_appbuilder/templates/appbuilder/general/security/login_oid.html +0 -129
  172. flask_appbuilder/templates/appbuilder/general/security/resetpassword.html +0 -29
  173. flask_appbuilder/tests/__init__.py +0 -0
  174. flask_appbuilder/tests/__pycache__/__init__.cpython-36.pyc +0 -0
  175. flask_appbuilder/tests/__pycache__/__init__.cpython-37.pyc +0 -0
  176. flask_appbuilder/tests/__pycache__/_test_auth_ldap.cpython-37.pyc +0 -0
  177. flask_appbuilder/tests/__pycache__/_test_auth_oauth.cpython-37.pyc +0 -0
  178. flask_appbuilder/tests/__pycache__/_test_ldapsearch.cpython-36.pyc +0 -0
  179. flask_appbuilder/tests/__pycache__/_test_oauth_registration_role.cpython-36.pyc +0 -0
  180. flask_appbuilder/tests/__pycache__/base.cpython-36.pyc +0 -0
  181. flask_appbuilder/tests/__pycache__/base.cpython-37.pyc +0 -0
  182. flask_appbuilder/tests/__pycache__/config_api.cpython-36.pyc +0 -0
  183. flask_appbuilder/tests/__pycache__/config_api.cpython-37.pyc +0 -0
  184. flask_appbuilder/tests/__pycache__/const.cpython-36.pyc +0 -0
  185. flask_appbuilder/tests/__pycache__/const.cpython-37.pyc +0 -0
  186. flask_appbuilder/tests/__pycache__/test_0_fixture.cpython-36.pyc +0 -0
  187. flask_appbuilder/tests/__pycache__/test_0_fixture.cpython-37.pyc +0 -0
  188. flask_appbuilder/tests/__pycache__/test_api.cpython-36.pyc +0 -0
  189. flask_appbuilder/tests/__pycache__/test_api.cpython-37.pyc +0 -0
  190. flask_appbuilder/tests/__pycache__/test_fab_cli.cpython-36.pyc +0 -0
  191. flask_appbuilder/tests/__pycache__/test_fab_cli.cpython-37.pyc +0 -0
  192. flask_appbuilder/tests/__pycache__/test_menu.cpython-36.pyc +0 -0
  193. flask_appbuilder/tests/__pycache__/test_menu.cpython-37.pyc +0 -0
  194. flask_appbuilder/tests/__pycache__/test_mongoengine.cpython-36.pyc +0 -0
  195. flask_appbuilder/tests/__pycache__/test_mvc.cpython-36.pyc +0 -0
  196. flask_appbuilder/tests/__pycache__/test_mvc.cpython-37.pyc +0 -0
  197. flask_appbuilder/tests/__pycache__/test_sqlalchemy.cpython-36.pyc +0 -0
  198. flask_appbuilder/tests/__pycache__/test_sqlalchemy.cpython-37.pyc +0 -0
  199. flask_appbuilder/tests/_test_auth_ldap.py +0 -1045
  200. flask_appbuilder/tests/_test_auth_oauth.py +0 -419
  201. flask_appbuilder/tests/_test_ldapsearch.py +0 -135
  202. flask_appbuilder/tests/_test_oauth_registration_role.py +0 -59
  203. flask_appbuilder/tests/app.db +0 -0
  204. flask_appbuilder/tests/base.py +0 -90
  205. flask_appbuilder/tests/config_api.py +0 -21
  206. flask_appbuilder/tests/const.py +0 -9
  207. flask_appbuilder/tests/mongoengine/__init__.py +0 -0
  208. flask_appbuilder/tests/mongoengine/__pycache__/__init__.cpython-36.pyc +0 -0
  209. flask_appbuilder/tests/mongoengine/__pycache__/__init__.cpython-37.pyc +0 -0
  210. flask_appbuilder/tests/mongoengine/__pycache__/models.cpython-36.pyc +0 -0
  211. flask_appbuilder/tests/mongoengine/models.py +0 -41
  212. flask_appbuilder/tests/sqla/__init__.py +0 -0
  213. flask_appbuilder/tests/sqla/__pycache__/__init__.cpython-36.pyc +0 -0
  214. flask_appbuilder/tests/sqla/__pycache__/__init__.cpython-37.pyc +0 -0
  215. flask_appbuilder/tests/sqla/__pycache__/models.cpython-36.pyc +0 -0
  216. flask_appbuilder/tests/sqla/__pycache__/models.cpython-37.pyc +0 -0
  217. flask_appbuilder/tests/sqla/models.py +0 -340
  218. flask_appbuilder/tests/test_0_fixture.py +0 -39
  219. flask_appbuilder/tests/test_api.py +0 -2790
  220. flask_appbuilder/tests/test_fab_cli.py +0 -72
  221. flask_appbuilder/tests/test_menu.py +0 -122
  222. flask_appbuilder/tests/test_mongoengine.py +0 -572
  223. flask_appbuilder/tests/test_mvc.py +0 -1710
  224. flask_appbuilder/tests/test_sqlalchemy.py +0 -24
  225. flask_appbuilder/translations/__pycache__/__init__.cpython-36.pyc +0 -0
  226. flask_appbuilder/translations/es/LC_MESSAGES/messages.po~ +0 -582
  227. {Flask_AppBuilder-3.2.1rc1.dist-info → flask_appbuilder-5.0.2rc1.dist-info}/LICENSE +0 -0
  228. {Flask_AppBuilder-3.2.1rc1.dist-info → flask_appbuilder-5.0.2rc1.dist-info}/top_level.txt +0 -0
@@ -1,46 +1,44 @@
1
- # -*- coding: utf-8 -*-
2
- import logging
3
- import sys
4
- from typing import Any, Dict, List, Optional, Tuple, Type, Union
1
+ from __future__ import annotations
5
2
 
6
- import sqlalchemy as sa
3
+ from contextlib import suppress
4
+ import logging
5
+ from typing import Any, Iterable, Optional, Tuple, Type
6
+
7
+ from flask import current_app, Request
8
+ from flask_appbuilder.exceptions import DatabaseException, FABException
9
+ from flask_appbuilder.filemanager import FileManager, ImageManager
10
+ from flask_appbuilder.models.base import BaseInterface
11
+ from flask_appbuilder.models.filters import Filters
12
+ from flask_appbuilder.models.group import GroupByCol, GroupByDateMonth, GroupByDateYear
13
+ from flask_appbuilder.models.mixins import FileColumn, ImageColumn
14
+ from flask_appbuilder.models.sqla import filters, Model
15
+ from flask_appbuilder.utils.base import (
16
+ get_column_leaf,
17
+ get_column_root_relation,
18
+ is_column_dotted,
19
+ )
7
20
  from sqlalchemy import asc, desc
8
- from sqlalchemy.exc import IntegrityError
9
- from sqlalchemy.orm import aliased, ColumnProperty, contains_eager, Load
21
+ from sqlalchemy import types as sa_types
22
+ from sqlalchemy.exc import SQLAlchemyError
23
+ from sqlalchemy.orm import aliased, class_mapper, ColumnProperty, contains_eager, Load
10
24
  from sqlalchemy.orm.descriptor_props import SynonymProperty
25
+ from sqlalchemy.orm.properties import RelationshipProperty
11
26
  from sqlalchemy.orm.query import Query
12
27
  from sqlalchemy.orm.session import Session as SessionBase
13
28
  from sqlalchemy.orm.util import AliasedClass
29
+ from sqlalchemy.sql import ColumnElement, visitors
14
30
  from sqlalchemy.sql.elements import BinaryExpression
31
+ from sqlalchemy.sql.schema import Column
15
32
  from sqlalchemy.sql.sqltypes import TypeEngine
16
33
  from sqlalchemy_utils.types.uuid import UUIDType
17
34
 
18
-
19
- from . import filters, Model
20
- from ..base import BaseInterface
21
- from ..filters import Filters
22
- from ..group import GroupByCol, GroupByDateMonth, GroupByDateYear
23
- from ..mixins import FileColumn, ImageColumn
24
- from ..._compat import as_unicode
25
- from ...const import (
26
- LOGMSG_ERR_DBI_ADD_GENERIC,
27
- LOGMSG_ERR_DBI_DEL_GENERIC,
28
- LOGMSG_ERR_DBI_EDIT_GENERIC,
29
- LOGMSG_WAR_DBI_ADD_INTEGRITY,
30
- LOGMSG_WAR_DBI_DEL_INTEGRITY,
31
- LOGMSG_WAR_DBI_EDIT_INTEGRITY,
32
- )
33
- from ...exceptions import InterfaceQueryWithoutSession
34
- from ...filemanager import FileManager, ImageManager
35
- from ...utils.base import get_column_leaf, get_column_root_relation, is_column_dotted
36
-
37
35
  log = logging.getLogger(__name__)
38
36
 
39
37
 
40
38
  def _is_sqla_type(model: Model, sa_type: Type[TypeEngine]) -> bool:
41
39
  return (
42
40
  isinstance(model, sa_type)
43
- or isinstance(model, sa.types.TypeDecorator)
41
+ or isinstance(model, sa_types.TypeDecorator)
44
42
  and isinstance(model.impl, sa_type)
45
43
  )
46
44
 
@@ -55,33 +53,76 @@ class SQLAInterface(BaseInterface):
55
53
 
56
54
  def __init__(self, obj: Type[Model], session: Optional[SessionBase] = None) -> None:
57
55
  _include_filters(self)
58
- self.list_columns = dict()
59
- self.list_properties = dict()
60
- self.session = session
56
+ self.list_columns = {}
57
+ self.list_properties = {}
58
+ self._session = session
61
59
  # Collect all SQLA columns and properties
62
- for prop in sa.orm.class_mapper(obj).iterate_properties:
60
+ for prop in class_mapper(obj).iterate_properties:
63
61
  if type(prop) != SynonymProperty:
64
62
  self.list_properties[prop.key] = prop
65
63
  for col_name in obj.__mapper__.columns.keys():
66
64
  if col_name in self.list_properties:
67
65
  self.list_columns[col_name] = obj.__mapper__.columns[col_name]
68
- super(SQLAInterface, self).__init__(obj)
66
+ super().__init__(obj)
67
+
68
+ @property
69
+ def session(self) -> SessionBase:
70
+ """
71
+ Returns the SQLAlchemy session
72
+ """
73
+ if not self._session:
74
+ return current_app.appbuilder.session
75
+ return self._session
69
76
 
70
77
  @property
71
- def model_name(self):
78
+ def model_name(self) -> str:
72
79
  """
73
- Returns the models class name
74
- useful for auto title on views
80
+ Returns the models class name
81
+ useful for auto title on views
75
82
  """
76
83
  return self.obj.__name__
77
84
 
78
85
  @staticmethod
79
86
  def is_model_already_joined(query: Query, model: Type[Model]) -> bool:
80
- return model in [mapper.class_ for mapper in query._join_entities]
87
+ if hasattr(query, "_join_entities"): # For SQLAlchemy < 1.3
88
+ return model in [mapper.class_ for mapper in query._join_entities]
89
+ # Solution for SQLAlchemy >= 1.4
90
+ model_table_name = model.__table__.fullname
91
+ for visitor in visitors.iterate(query.statement):
92
+ # Checking for `.join(Parent.child)` clauses
93
+ if visitor.__visit_name__ == "alias":
94
+ _visitor = visitor.element
95
+ else:
96
+ _visitor = visitor
97
+ if _visitor.__visit_name__ == "select":
98
+ continue
99
+ if _visitor.__visit_name__ == "binary":
100
+ for vis in visitors.iterate(_visitor):
101
+ # Visitor might not have table attribute
102
+ with suppress(AttributeError):
103
+ # Verify if already present based on table name
104
+ if model_table_name == vis.table.fullname:
105
+ return True
106
+ # Checking for `.join(Child)` clauses
107
+ if _visitor.__visit_name__ == "table":
108
+ # Visitor might be of ColumnCollection or so,
109
+ # which cannot be compared to model
110
+ if model_table_name == _visitor.fullname:
111
+ return True
112
+ # Checking for `Model.column` clauses
113
+ if _visitor.__visit_name__ == "column":
114
+ with suppress(AttributeError):
115
+ if model_table_name == _visitor.table.fullname:
116
+ return True
117
+ return False
81
118
 
82
119
  def _get_base_query(
83
- self, query=None, filters=None, order_column="", order_direction=""
84
- ):
120
+ self,
121
+ query: Query,
122
+ filters: Filters | None = None,
123
+ order_column: str = "",
124
+ order_direction: str = "",
125
+ ) -> str:
85
126
  if filters:
86
127
  query = filters.apply_all(query)
87
128
  return self.apply_order_by(query, order_column, order_direction)
@@ -90,7 +131,7 @@ class SQLAInterface(BaseInterface):
90
131
  self,
91
132
  query: Query,
92
133
  root_relation: str,
93
- aliases_mapping: Dict[str, AliasedClass] = None,
134
+ aliases_mapping: dict[str, AliasedClass] | None = None,
94
135
  ) -> Query:
95
136
  """
96
137
  Helper function that applies necessary joins for dotted columns on a
@@ -134,7 +175,7 @@ class SQLAInterface(BaseInterface):
134
175
  page
135
176
  and page_size
136
177
  and not order_column
137
- and self.session.bind.dialect.name == "mssql"
178
+ and self.session.get_bind().name == "mssql"
138
179
  ):
139
180
  pk_name = self.get_pk_name()
140
181
  return query.order_by(pk_name)
@@ -145,7 +186,9 @@ class SQLAInterface(BaseInterface):
145
186
  query: Query,
146
187
  order_column: str,
147
188
  order_direction: str,
148
- aliases_mapping: Dict[str, AliasedClass] = None,
189
+ aliases_mapping: dict[str, AliasedClass] | None = None,
190
+ bypass_many_to_many: bool = False,
191
+ add_pk: bool = False,
149
192
  ) -> Query:
150
193
  if order_column != "":
151
194
  # if Model has custom decorator **renders('<COL_NAME>')**
@@ -157,6 +200,12 @@ class SQLAInterface(BaseInterface):
157
200
 
158
201
  if is_column_dotted(order_column):
159
202
  root_relation = get_column_root_relation(order_column)
203
+ if (
204
+ self.is_relation_many_to_many(root_relation)
205
+ or self.is_relation_one_to_many(root_relation)
206
+ and bypass_many_to_many
207
+ ):
208
+ return query
160
209
  # On MVC we still allow for joins to happen here
161
210
  if not self.is_model_already_joined(
162
211
  query, self.get_related_model(root_relation)
@@ -167,10 +216,15 @@ class SQLAInterface(BaseInterface):
167
216
  column_leaf = get_column_leaf(order_column)
168
217
  _alias = self.get_alias_mapping(root_relation, aliases_mapping)
169
218
  _order_column = getattr(_alias, column_leaf)
170
- if order_direction == "asc":
171
- query = query.order_by(asc(_order_column))
172
- else:
173
- query = query.order_by(desc(_order_column))
219
+ # get the primary key so we can add a tie breaker of the order
220
+ # when the order column is not unique it can cause issues with pagination
221
+ direction = asc if order_direction == "asc" else desc
222
+ order_by_columns = [direction(_order_column)]
223
+ pk = self.get_pk()
224
+ if add_pk and pk and pk != _order_column:
225
+ order_by_columns.append(direction(pk))
226
+ query = query.order_by(*order_by_columns)
227
+
174
228
  return query
175
229
 
176
230
  def apply_pagination(
@@ -189,23 +243,27 @@ class SQLAInterface(BaseInterface):
189
243
 
190
244
  def _apply_normal_col_select_option(self, query: Query, column: str) -> Query:
191
245
  if not self.is_relation(column) and not self.is_property_or_function(column):
192
- return query.options(Load(self.obj).load_only(column))
246
+ return query.options(Load(self.obj).load_only(getattr(self.obj, column)))
193
247
  return query
194
248
 
195
- def _apply_relation_fks_select_options(self, query: Query, relation_name) -> Query:
249
+ def _apply_relation_fks_select_options(
250
+ self, query: Query, relation_name: str
251
+ ) -> Query:
196
252
  relation = getattr(self.obj, relation_name)
197
253
  if hasattr(relation, "property"):
198
254
  local_cols = getattr(self.obj, relation_name).property.local_columns
199
255
  for local_fk in local_cols:
200
- query = query.options(Load(self.obj).load_only(local_fk.name))
256
+ query = query.options(
257
+ Load(self.obj).load_only(getattr(self.obj, local_fk.name))
258
+ )
201
259
  return query
202
260
  return query
203
261
 
204
262
  def apply_inner_select_joins(
205
263
  self,
206
264
  query: Query,
207
- select_columns: List[str] = None,
208
- aliases_mapping: Dict[str, AliasedClass] = None,
265
+ select_columns: list[str] | None = None,
266
+ aliases_mapping: dict[str, AliasedClass] | None = None,
209
267
  ) -> Query:
210
268
  """
211
269
  Add select load options to query. The goal
@@ -219,63 +277,115 @@ class SQLAInterface(BaseInterface):
219
277
  """
220
278
  if not select_columns:
221
279
  return query
222
- joined_models = list()
280
+
281
+ joined_models = []
223
282
  for column in select_columns:
224
- if is_column_dotted(column):
225
- root_relation = get_column_root_relation(column)
226
- leaf_column = get_column_leaf(column)
227
- if self.is_relation_many_to_one(
228
- root_relation
229
- ) or self.is_relation_one_to_one(root_relation):
230
- if root_relation not in joined_models:
231
- query = self._query_join_relation(
232
- query, root_relation, aliases_mapping=aliases_mapping
233
- )
234
- query = query.add_entity(
235
- self.get_alias_mapping(root_relation, aliases_mapping)
236
- )
237
- # Add relation FK to avoid N+1 performance issue
238
- query = self._apply_relation_fks_select_options(
239
- query, root_relation
240
- )
241
- joined_models.append(root_relation)
242
-
243
- related_model_ = self.get_alias_mapping(
244
- root_relation, aliases_mapping
283
+ if not is_column_dotted(column):
284
+ query = self._apply_normal_col_select_option(query, column)
285
+ continue
286
+
287
+ # Dotted column
288
+ root_relation = get_column_root_relation(column)
289
+ leaf_column = get_column_leaf(column)
290
+ related_model = self.get_alias_mapping(root_relation, aliases_mapping)
291
+ relation = getattr(self.obj, root_relation)
292
+
293
+ if self.is_relation_many_to_one(
294
+ root_relation
295
+ ) or self.is_relation_many_to_many_special(root_relation):
296
+ if root_relation not in joined_models:
297
+ query = self._query_join_relation(
298
+ query, root_relation, aliases_mapping=aliases_mapping
245
299
  )
246
- relation = getattr(self.obj, root_relation)
247
- # The Zen of eager loading :(
248
- # https://docs.sqlalchemy.org/en/13/orm/loading_relationships.html
249
- query = query.options(
250
- contains_eager(relation.of_type(related_model_)).load_only(
251
- leaf_column
252
- )
300
+ query = query.add_entity(
301
+ self.get_alias_mapping(root_relation, aliases_mapping)
253
302
  )
254
- query = query.options(Load(related_model_).load_only(leaf_column))
255
- else:
256
- query = self._apply_normal_col_select_option(query, column)
303
+ # Add relation FK to avoid N+1 performance issue
304
+ query = self._apply_relation_fks_select_options(
305
+ query, root_relation
306
+ )
307
+ joined_models.append(root_relation)
308
+
309
+ related_model = self.get_alias_mapping(root_relation, aliases_mapping)
310
+ relation = getattr(self.obj, root_relation)
311
+ # The Zen of eager loading :(
312
+ # https://docs.sqlalchemy.org/en/13/orm/loading_relationships.html
313
+ query = query.options(
314
+ contains_eager(relation.of_type(related_model)).load_only(
315
+ getattr(related_model, leaf_column)
316
+ )
317
+ )
318
+ query = query.options(
319
+ Load(related_model).load_only(getattr(related_model, leaf_column))
320
+ )
257
321
  return query
258
322
 
323
+ def get_outer_query_from_inner_query(
324
+ self, query: Query, inner_query: Query
325
+ ) -> Query:
326
+ pk = self.get_pk()
327
+ pk_name = self.get_pk_name()
328
+
329
+ if isinstance(pk_name, str):
330
+ # Only select the primary key in the inner query
331
+ inner_query = inner_query.with_entities(pk)
332
+
333
+ subquery = inner_query.subquery()
334
+ subquery_pk: ColumnElement = getattr(subquery.c, pk_name)
335
+ return query.join(subquery, pk == subquery_pk)
336
+
337
+ if isinstance(pk_name, Iterable):
338
+ raise FABException("Composite primary key not supported")
339
+
340
+ raise FABException("No primary key found")
341
+
259
342
  def apply_outer_select_joins(
260
- self, query: Query, select_columns: List[str] = None
343
+ self,
344
+ query: Query,
345
+ select_columns: list[str] | None = None,
346
+ outer_default_load: bool = False,
347
+ aliases_mapping: dict[str, AliasedClass] | None = None,
261
348
  ) -> Query:
262
349
  if not select_columns:
263
350
  return query
351
+
352
+ aliases_mapping = aliases_mapping or {}
353
+
264
354
  for column in select_columns:
265
- if is_column_dotted(column):
266
- root_relation = get_column_root_relation(column)
267
- leaf_column = get_column_leaf(column)
268
- if self.is_relation_many_to_many(
269
- root_relation
270
- ) or self.is_relation_one_to_many(root_relation):
271
- query = query.options(
272
- Load(self.obj).joinedload(root_relation).load_only(leaf_column)
355
+ if not is_column_dotted(column):
356
+ query = self._apply_normal_col_select_option(query, column)
357
+ continue
358
+
359
+ root_relation = get_column_root_relation(column)
360
+ related_model = self.get_related_model(root_relation)
361
+
362
+ # Use meaningful alias if not already defined
363
+ if root_relation not in aliases_mapping:
364
+ alias = aliased(related_model, name=root_relation)
365
+ aliases_mapping[root_relation] = alias
366
+ else:
367
+ alias = aliases_mapping[root_relation]
368
+
369
+ attr = getattr(self.obj, root_relation)
370
+ leaf_column = getattr(alias, get_column_leaf(column))
371
+ if self.is_relation_many_to_many(
372
+ root_relation
373
+ ) or self.is_relation_one_to_many(root_relation):
374
+ if outer_default_load:
375
+ load = (
376
+ Load(self.obj)
377
+ .defaultload(attr)
378
+ .load_only(getattr(related_model, get_column_leaf(column)))
273
379
  )
274
380
  else:
275
- related_model = self.get_related_model(root_relation)
276
- query = query.options(Load(related_model).load_only(leaf_column))
381
+ query = query.join(alias, attr, isouter=True)
382
+ load = contains_eager(attr.of_type(alias)).load_only(leaf_column)
277
383
  else:
278
- query = self._apply_normal_col_select_option(query, column)
384
+ query = query.join(alias, attr, isouter=True)
385
+ load = contains_eager(attr.of_type(alias)).load_only(leaf_column)
386
+
387
+ query = query.options(load)
388
+
279
389
  return query
280
390
 
281
391
  def get_inner_filters(self, filters: Optional[Filters]) -> Filters:
@@ -293,25 +403,28 @@ class SQLAInterface(BaseInterface):
293
403
  if not is_column_dotted(flt.column_name):
294
404
  _filters.append((flt.column_name, flt.__class__, value))
295
405
  elif self.is_relation_many_to_one(
296
- flt.column_name
297
- ) or self.is_relation_one_to_one(flt.column_name):
406
+ get_column_root_relation(flt.column_name)
407
+ ) or self.is_relation_one_to_one(
408
+ get_column_root_relation(flt.column_name)
409
+ ):
298
410
  _filters.append((flt.column_name, flt.__class__, value))
299
411
  inner_filters.add_filter_list(_filters)
300
412
  return inner_filters
301
413
 
302
- def exists_col_to_many(self, select_columns: List[str]) -> bool:
414
+ def exists_col_to_many(self, select_columns: list[str]) -> bool:
303
415
  for column in select_columns:
304
- if is_column_dotted(column):
305
- root_relation = get_column_root_relation(column)
306
- if self.is_relation_many_to_many(
307
- root_relation
308
- ) or self.is_relation_one_to_many(root_relation):
309
- return True
416
+ if not is_column_dotted(column):
417
+ continue
418
+ root_relation = get_column_root_relation(column)
419
+ if self.is_relation_many_to_many(
420
+ root_relation
421
+ ) or self.is_relation_one_to_many(root_relation):
422
+ return True
310
423
  return False
311
424
 
312
425
  def get_alias_mapping(
313
- self, model_name: str, aliases_mapping: Dict[str, AliasedClass]
314
- ) -> Union[AliasedClass, Type[Model]]:
426
+ self, model_name: str, aliases_mapping: dict[str, AliasedClass] | None
427
+ ) -> AliasedClass | Type[Model]:
315
428
  if aliases_mapping is None:
316
429
  return self.get_related_model(model_name)
317
430
  return aliases_mapping.get(model_name, self.get_related_model(model_name))
@@ -319,20 +432,25 @@ class SQLAInterface(BaseInterface):
319
432
  def _apply_inner_all(
320
433
  self,
321
434
  query: Query,
322
- filters: Optional[Filters] = None,
435
+ filters: Filters | None = None,
323
436
  order_column: str = "",
324
437
  order_direction: str = "",
325
- page: Optional[int] = None,
326
- page_size: Optional[int] = None,
327
- select_columns: Optional[List[str]] = None,
328
- aliases_mapping: Dict[str, AliasedClass] = None,
438
+ page: int | None = None,
439
+ page_size: int | None = None,
440
+ select_columns: list[str] | None = None,
441
+ aliases_mapping: dict[str, AliasedClass] | None = None,
329
442
  ) -> Query:
330
443
  inner_filters = self.get_inner_filters(filters)
331
444
  query = self.apply_inner_select_joins(query, select_columns, aliases_mapping)
332
445
  query = self.apply_filters(query, inner_filters)
333
446
  query = self.apply_engine_specific_hack(query, page, page_size, order_column)
334
447
  query = self.apply_order_by(
335
- query, order_column, order_direction, aliases_mapping=aliases_mapping
448
+ query,
449
+ order_column,
450
+ order_direction,
451
+ aliases_mapping=aliases_mapping,
452
+ bypass_many_to_many=True,
453
+ add_pk=True,
336
454
  )
337
455
  query = self.apply_pagination(query, page, page_size)
338
456
  return query
@@ -341,7 +459,7 @@ class SQLAInterface(BaseInterface):
341
459
  self,
342
460
  query: Query,
343
461
  filters: Optional[Filters] = None,
344
- select_columns: Optional[List[str]] = None,
462
+ select_columns: Optional[list[str]] = None,
345
463
  ) -> int:
346
464
  return self._apply_inner_all(
347
465
  query, filters, select_columns=select_columns, aliases_mapping={}
@@ -355,7 +473,8 @@ class SQLAInterface(BaseInterface):
355
473
  order_direction: str = "",
356
474
  page: Optional[int] = None,
357
475
  page_size: Optional[int] = None,
358
- select_columns: Optional[List[str]] = None,
476
+ select_columns: Optional[list[str]] = None,
477
+ outer_default_load: bool = False,
359
478
  ) -> Query:
360
479
  """
361
480
  Accepts a SQLAlchemy Query and applies all filtering logic, order by and
@@ -374,9 +493,14 @@ class SQLAInterface(BaseInterface):
374
493
  the current page size
375
494
  :param select_columns:
376
495
  A List of columns to be specifically selected on the query
496
+ :param outer_default_load: If True, the default load for outer joins will be
497
+ applied. This is useful for when you want to control
498
+ the load of the many-to-many relationships at the model level.
499
+ we will apply:
500
+ https://docs.sqlalchemy.org/en/14/orm/loading_relationships.html#sqlalchemy.orm.Load.defaultload
377
501
  :return: A SQLAlchemy Query with all the applied logic
378
502
  """
379
- aliases_mapping = {}
503
+ aliases_mapping: dict[str, AliasedClass] = {}
380
504
  inner_query = self._apply_inner_all(
381
505
  query,
382
506
  filters,
@@ -391,9 +515,19 @@ class SQLAInterface(BaseInterface):
391
515
  if select_columns and self.exists_col_to_many(select_columns):
392
516
  if select_columns and order_column:
393
517
  select_columns = select_columns + [order_column]
394
- outer_query = inner_query.from_self()
395
- outer_query = self.apply_outer_select_joins(outer_query, select_columns)
396
- return self.apply_order_by(outer_query, order_column, order_direction)
518
+ outer_query = self.get_outer_query_from_inner_query(query, inner_query)
519
+ outer_query = self.apply_outer_select_joins(
520
+ outer_query,
521
+ select_columns,
522
+ outer_default_load=outer_default_load,
523
+ aliases_mapping=aliases_mapping,
524
+ )
525
+ return self.apply_order_by(
526
+ outer_query,
527
+ order_column,
528
+ order_direction,
529
+ aliases_mapping=aliases_mapping,
530
+ )
397
531
  else:
398
532
  return inner_query
399
533
 
@@ -404,8 +538,9 @@ class SQLAInterface(BaseInterface):
404
538
  order_direction: str = "",
405
539
  page: Optional[int] = None,
406
540
  page_size: Optional[int] = None,
407
- select_columns: Optional[List[str]] = None,
408
- ) -> Tuple[int, List[Model]]:
541
+ select_columns: Optional[list[str]] = None,
542
+ outer_default_load: bool = False,
543
+ ) -> Tuple[int, list[Model]]:
409
544
  """
410
545
  Returns the results for a model query, applies filters, sorting and pagination
411
546
 
@@ -416,10 +551,13 @@ class SQLAInterface(BaseInterface):
416
551
  :param page_size: the current page size
417
552
  :param select_columns: A List of columns to be specifically selected
418
553
  on the query. Supports dotted notation.
554
+ :param outer_default_load: If True, the default load for outer joins will be
555
+ applied. This is useful for when you want to control
556
+ the load of the many-to-many relationships at the model level.
557
+ we will apply:
558
+ https://docs.sqlalchemy.org/en/14/orm/loading_relationships.html#sqlalchemy.orm.Load.defaultload
419
559
  :return: A tuple with the query count (non paginated) and the results
420
560
  """
421
- if not self.session:
422
- raise InterfaceQueryWithoutSession()
423
561
  query = self.session.query(self.obj)
424
562
 
425
563
  count = self.query_count(query, filters, select_columns)
@@ -434,7 +572,7 @@ class SQLAInterface(BaseInterface):
434
572
  )
435
573
  query_results = query.all()
436
574
 
437
- result = list()
575
+ result = []
438
576
  for item in query_results:
439
577
  if hasattr(item, self.obj.__name__):
440
578
  result.append(getattr(item, self.obj.__name__))
@@ -443,22 +581,26 @@ class SQLAInterface(BaseInterface):
443
581
  return count, result
444
582
 
445
583
  def query_simple_group(
446
- self, group_by="", aggregate_func=None, aggregate_col=None, filters=None
447
- ):
584
+ self, group_by: str | None = None, filters: Filters | None = None
585
+ ) -> list[list[Any]]:
448
586
  query = self.session.query(self.obj)
449
587
  query = self._get_base_query(query=query, filters=filters)
450
588
  query_result = query.all()
451
589
  group = GroupByCol(group_by, "Group by")
452
590
  return group.apply(query_result)
453
591
 
454
- def query_month_group(self, group_by="", filters=None):
592
+ def query_month_group(
593
+ self, group_by: str | None = None, filters: Filters | None = None
594
+ ) -> list[list[Any]]:
455
595
  query = self.session.query(self.obj)
456
596
  query = self._get_base_query(query=query, filters=filters)
457
597
  query_result = query.all()
458
598
  group = GroupByDateMonth(group_by, "Group by Month")
459
599
  return group.apply(query_result)
460
600
 
461
- def query_year_group(self, group_by="", filters=None):
601
+ def query_year_group(
602
+ self, group_by: str | None = None, filters: Filters | None = None
603
+ ) -> list[list[Any]]:
462
604
  query = self.session.query(self.obj)
463
605
  query = self._get_base_query(query=query, filters=filters)
464
606
  query_result = query.all()
@@ -486,7 +628,7 @@ class SQLAInterface(BaseInterface):
486
628
  def is_string(self, col_name: str) -> bool:
487
629
  try:
488
630
  return (
489
- _is_sqla_type(self.list_columns[col_name].type, sa.types.String)
631
+ _is_sqla_type(self.list_columns[col_name].type, sa_types.String)
490
632
  or self.list_columns[col_name].type.__class__ == UUIDType
491
633
  )
492
634
  except KeyError:
@@ -494,63 +636,67 @@ class SQLAInterface(BaseInterface):
494
636
 
495
637
  def is_text(self, col_name: str) -> bool:
496
638
  try:
497
- return _is_sqla_type(self.list_columns[col_name].type, sa.types.Text)
639
+ return _is_sqla_type(self.list_columns[col_name].type, sa_types.Text)
498
640
  except KeyError:
499
641
  return False
500
642
 
501
643
  def is_binary(self, col_name: str) -> bool:
502
644
  try:
503
- return _is_sqla_type(self.list_columns[col_name].type, sa.types.LargeBinary)
645
+ return _is_sqla_type(self.list_columns[col_name].type, sa_types.LargeBinary)
504
646
  except KeyError:
505
647
  return False
506
648
 
507
649
  def is_integer(self, col_name: str) -> bool:
508
650
  try:
509
- return _is_sqla_type(self.list_columns[col_name].type, sa.types.Integer)
651
+ return _is_sqla_type(self.list_columns[col_name].type, sa_types.Integer)
510
652
  except KeyError:
511
653
  return False
512
654
 
513
655
  def is_numeric(self, col_name: str) -> bool:
514
656
  try:
515
- return _is_sqla_type(self.list_columns[col_name].type, sa.types.Numeric)
657
+ return _is_sqla_type(self.list_columns[col_name].type, sa_types.Numeric)
516
658
  except KeyError:
517
659
  return False
518
660
 
519
661
  def is_float(self, col_name: str) -> bool:
520
662
  try:
521
- return _is_sqla_type(self.list_columns[col_name].type, sa.types.Float)
663
+ return _is_sqla_type(self.list_columns[col_name].type, sa_types.Float)
522
664
  except KeyError:
523
665
  return False
524
666
 
525
667
  def is_boolean(self, col_name: str) -> bool:
526
668
  try:
527
- return _is_sqla_type(self.list_columns[col_name].type, sa.types.Boolean)
669
+ return _is_sqla_type(self.list_columns[col_name].type, sa_types.Boolean)
528
670
  except KeyError:
529
671
  return False
530
672
 
531
673
  def is_date(self, col_name: str) -> bool:
532
674
  try:
533
- return _is_sqla_type(self.list_columns[col_name].type, sa.types.Date)
675
+ return _is_sqla_type(self.list_columns[col_name].type, sa_types.Date)
534
676
  except KeyError:
535
677
  return False
536
678
 
537
679
  def is_datetime(self, col_name: str) -> bool:
538
680
  try:
539
- return _is_sqla_type(self.list_columns[col_name].type, sa.types.DateTime)
681
+ return _is_sqla_type(self.list_columns[col_name].type, sa_types.DateTime)
540
682
  except KeyError:
541
683
  return False
542
684
 
543
685
  def is_enum(self, col_name: str) -> bool:
544
686
  try:
545
- return _is_sqla_type(self.list_columns[col_name].type, sa.types.Enum)
687
+ return _is_sqla_type(self.list_columns[col_name].type, sa_types.Enum)
688
+ except KeyError:
689
+ return False
690
+
691
+ def is_json(self, col_name: str) -> bool:
692
+ try:
693
+ return _is_sqla_type(self.list_columns[col_name].type, sa_types.JSON)
546
694
  except KeyError:
547
695
  return False
548
696
 
549
697
  def is_relation(self, col_name: str) -> bool:
550
698
  try:
551
- return isinstance(
552
- self.list_properties[col_name], sa.orm.properties.RelationshipProperty
553
- )
699
+ return isinstance(self.list_properties[col_name], RelationshipProperty)
554
700
  except KeyError:
555
701
  return False
556
702
 
@@ -565,7 +711,17 @@ class SQLAInterface(BaseInterface):
565
711
  def is_relation_many_to_many(self, col_name: str) -> bool:
566
712
  try:
567
713
  if self.is_relation(col_name):
568
- return self.list_properties[col_name].direction.name == "MANYTOMANY"
714
+ relation = self.list_properties[col_name]
715
+ return relation.direction.name == "MANYTOMANY"
716
+ return False
717
+ except KeyError:
718
+ return False
719
+
720
+ def is_relation_many_to_many_special(self, col_name: str) -> bool:
721
+ try:
722
+ if self.is_relation(col_name):
723
+ relation = self.list_properties[col_name]
724
+ return relation.direction.name == "ONETOONE" and relation.uselist
569
725
  return False
570
726
  except KeyError:
571
727
  return False
@@ -573,7 +729,10 @@ class SQLAInterface(BaseInterface):
573
729
  def is_relation_one_to_one(self, col_name: str) -> bool:
574
730
  try:
575
731
  if self.is_relation(col_name):
576
- return self.list_properties[col_name].direction.name == "ONETOONE"
732
+ relation = self.list_properties[col_name]
733
+ return self.list_properties[col_name].direction.name == "ONETOONE" or (
734
+ relation.direction.name == "ONETOMANY" and relation.uselist is False
735
+ )
577
736
  return False
578
737
  except KeyError:
579
738
  return False
@@ -581,7 +740,8 @@ class SQLAInterface(BaseInterface):
581
740
  def is_relation_one_to_many(self, col_name: str) -> bool:
582
741
  try:
583
742
  if self.is_relation(col_name):
584
- return self.list_properties[col_name].direction.name == "ONETOMANY"
743
+ relation = self.list_properties[col_name]
744
+ return relation.direction.name == "ONETOMANY" and relation.uselist
585
745
  return False
586
746
  except KeyError:
587
747
  return False
@@ -643,100 +803,47 @@ class SQLAInterface(BaseInterface):
643
803
  -------------------------------
644
804
  """
645
805
 
646
- def add(self, item: Model, raise_exception: bool = False) -> bool:
806
+ def add(self, item: Model, commit: bool = True) -> None:
647
807
  try:
648
808
  self.session.add(item)
649
- self.session.commit()
650
- self.message = (as_unicode(self.add_row_message), "success")
651
- return True
652
- except IntegrityError as e:
653
- self.message = (as_unicode(self.add_integrity_error_message), "warning")
654
- log.warning(LOGMSG_WAR_DBI_ADD_INTEGRITY.format(str(e)))
655
- self.session.rollback()
656
- if raise_exception:
657
- raise e
658
- return False
659
- except Exception as e:
660
- self.message = (
661
- as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])),
662
- "danger",
663
- )
664
- log.exception(LOGMSG_ERR_DBI_ADD_GENERIC.format(str(e)))
809
+ if commit:
810
+ self.session.commit()
811
+ except SQLAlchemyError as ex:
812
+ log.exception("Add item database error")
665
813
  self.session.rollback()
666
- if raise_exception:
667
- raise e
668
- return False
814
+ raise ex
669
815
 
670
- def edit(self, item: Model, raise_exception: bool = False) -> bool:
816
+ def edit(self, item: Model, commit: bool = True) -> None:
671
817
  try:
672
818
  self.session.merge(item)
673
- self.session.commit()
674
- self.message = (as_unicode(self.edit_row_message), "success")
675
- return True
676
- except IntegrityError as e:
677
- self.message = (as_unicode(self.edit_integrity_error_message), "warning")
678
- log.warning(LOGMSG_WAR_DBI_EDIT_INTEGRITY.format(str(e)))
679
- self.session.rollback()
680
- if raise_exception:
681
- raise e
682
- return False
683
- except Exception as e:
684
- self.message = (
685
- as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])),
686
- "danger",
687
- )
688
- log.exception(LOGMSG_ERR_DBI_EDIT_GENERIC.format(str(e)))
819
+ if commit:
820
+ self.session.commit()
821
+ except SQLAlchemyError as ex:
822
+ log.exception("Edit item database error")
689
823
  self.session.rollback()
690
- if raise_exception:
691
- raise e
692
- return False
824
+ raise DatabaseException from ex
693
825
 
694
- def delete(self, item: Model, raise_exception: bool = False) -> bool:
826
+ def delete(self, item: Model, commit: bool = True) -> None:
695
827
  try:
696
828
  self._delete_files(item)
697
829
  self.session.delete(item)
698
- self.session.commit()
699
- self.message = (as_unicode(self.delete_row_message), "success")
700
- return True
701
- except IntegrityError as e:
702
- self.message = (as_unicode(self.delete_integrity_error_message), "warning")
703
- log.warning(LOGMSG_WAR_DBI_DEL_INTEGRITY.format(str(e)))
704
- self.session.rollback()
705
- if raise_exception:
706
- raise e
707
- return False
708
- except Exception as e:
709
- self.message = (
710
- as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])),
711
- "danger",
712
- )
713
- log.exception(LOGMSG_ERR_DBI_DEL_GENERIC.format(str(e)))
830
+ if commit:
831
+ self.session.commit()
832
+ except SQLAlchemyError as ex:
833
+ log.exception("Delete item database error")
714
834
  self.session.rollback()
715
- if raise_exception:
716
- raise e
717
- return False
835
+ raise DatabaseException from ex
718
836
 
719
- def delete_all(self, items: List[Model]) -> bool:
837
+ def delete_all(self, items: list[Model]) -> None:
720
838
  try:
721
839
  for item in items:
722
840
  self._delete_files(item)
723
841
  self.session.delete(item)
724
842
  self.session.commit()
725
- self.message = (as_unicode(self.delete_row_message), "success")
726
- return True
727
- except IntegrityError as e:
728
- self.message = (as_unicode(self.delete_integrity_error_message), "warning")
729
- log.warning(LOGMSG_WAR_DBI_DEL_INTEGRITY.format(str(e)))
730
- self.session.rollback()
731
- return False
732
- except Exception as e:
733
- self.message = (
734
- as_unicode(self.general_error_message + " " + str(sys.exc_info()[0])),
735
- "danger",
736
- )
737
- log.exception(LOGMSG_ERR_DBI_DEL_GENERIC.format(str(e)))
843
+ except SQLAlchemyError as ex:
844
+ log.exception("Delete items database error")
738
845
  self.session.rollback()
739
- return False
846
+ raise DatabaseException from ex
740
847
 
741
848
  """
742
849
  -----------------------
@@ -744,27 +851,24 @@ class SQLAInterface(BaseInterface):
744
851
  -----------------------
745
852
  """
746
853
 
747
- def _add_files(self, this_request, item: Model):
854
+ def _add_files(self, this_request: Request, item: Model) -> None:
748
855
  fm = FileManager()
749
856
  im = ImageManager()
750
857
  for file_col in this_request.files:
751
858
  if self.is_file(file_col):
752
859
  fm.save_file(this_request.files[file_col], getattr(item, file_col))
753
- for file_col in this_request.files:
754
- if self.is_image(file_col):
860
+ elif self.is_image(file_col):
755
861
  im.save_file(this_request.files[file_col], getattr(item, file_col))
756
862
 
757
- def _delete_files(self, item: Model):
863
+ def _delete_files(self, item: Model) -> None:
758
864
  for file_col in self.get_file_column_list():
759
- if self.is_file(file_col):
760
- if getattr(item, file_col):
761
- fm = FileManager()
762
- fm.delete_file(getattr(item, file_col))
865
+ if self.is_file(file_col) and getattr(item, file_col):
866
+ fm = FileManager()
867
+ fm.delete_file(getattr(item, file_col))
763
868
  for file_col in self.get_image_column_list():
764
- if self.is_image(file_col):
765
- if getattr(item, file_col):
766
- im = ImageManager()
767
- im.delete_file(getattr(item, file_col))
869
+ if self.is_image(file_col) and getattr(item, file_col):
870
+ im = ImageManager()
871
+ im.delete_file(getattr(item, file_col))
768
872
 
769
873
  """
770
874
  ------------------------------
@@ -774,22 +878,27 @@ class SQLAInterface(BaseInterface):
774
878
 
775
879
  def get_col_default(self, col_name: str) -> Any:
776
880
  default = getattr(self.list_columns[col_name], "default", None)
777
- if default is not None:
778
- value = getattr(default, "arg", None)
779
- if value is not None:
780
- if getattr(default, "is_callable", False):
781
- return lambda: default.arg(None)
782
- else:
783
- if not getattr(default, "is_scalar", True):
784
- return None
785
- return value
881
+ if default is None:
882
+ return None
883
+
884
+ value = getattr(default, "arg", None)
885
+ if value is None:
886
+ return None
887
+
888
+ if getattr(default, "is_callable", False):
889
+ return lambda: default.arg(None)
890
+
891
+ if not getattr(default, "is_scalar", True):
892
+ return None
893
+
894
+ return value
786
895
 
787
896
  def get_related_model(self, col_name: str) -> Type[Model]:
788
897
  return self.list_properties[col_name].mapper.class_
789
898
 
790
899
  def get_related_model_and_join(
791
900
  self, col_name: str
792
- ) -> List[Tuple[Type[Model], object]]:
901
+ ) -> list[Tuple[Type[Model], BinaryExpression]]:
793
902
  relation = self.list_properties[col_name]
794
903
  if relation.direction.name == "MANYTOMANY":
795
904
  return [
@@ -798,16 +907,14 @@ class SQLAInterface(BaseInterface):
798
907
  ]
799
908
  return [(relation.mapper.class_, relation.primaryjoin)]
800
909
 
801
- def get_related_interface(self, col_name: str):
802
- return self.__class__(self.get_related_model(col_name), self.session)
910
+ def get_related_interface(self, col_name: str) -> BaseInterface:
911
+ return self.__class__(self.get_related_model(col_name))
803
912
 
804
913
  def get_related_obj(self, col_name: str, value: Any) -> Optional[Type[Model]]:
805
914
  rel_model = self.get_related_model(col_name)
806
- if self.session:
807
- return self.session.query(rel_model).get(value)
808
- return None
915
+ return self.session.query(rel_model).get(value)
809
916
 
810
- def get_related_fks(self, related_views) -> List[str]:
917
+ def get_related_fks(self, related_views: Any) -> list[str]:
811
918
  return [view.datamodel.get_related_fk(self.obj) for view in related_views]
812
919
 
813
920
  def get_related_fk(self, model: Type[Model]) -> Optional[str]:
@@ -817,7 +924,7 @@ class SQLAInterface(BaseInterface):
817
924
  return col_name
818
925
  return None
819
926
 
820
- def get_info(self, col_name: str):
927
+ def get_info(self, col_name: str) -> dict[str, Any]:
821
928
  if col_name in self.list_properties:
822
929
  return self.list_properties[col_name].info
823
930
  return {}
@@ -828,25 +935,25 @@ class SQLAInterface(BaseInterface):
828
935
  -------------
829
936
  """
830
937
 
831
- def get_columns_list(self) -> List[str]:
938
+ def get_columns_list(self) -> list[str]:
832
939
  """
833
940
  Returns all model's columns on SQLA properties
834
941
  """
835
942
  return list(self.list_properties.keys())
836
943
 
837
- def get_user_columns_list(self) -> List[str]:
944
+ def get_user_columns_list(self) -> list[str]:
838
945
  """
839
946
  Returns all model's columns except pk or fk
840
947
  """
841
- ret_lst = list()
842
- for col_name in self.get_columns_list():
843
- if (not self.is_pk(col_name)) and (not self.is_fk(col_name)):
844
- ret_lst.append(col_name)
845
- return ret_lst
948
+ return [
949
+ col_name
950
+ for col_name in self.get_columns_list()
951
+ if (not self.is_pk(col_name)) and (not self.is_fk(col_name))
952
+ ]
846
953
 
847
954
  # TODO get different solution, more integrated with filters
848
- def get_search_columns_list(self) -> List[str]:
849
- ret_lst = list()
955
+ def get_search_columns_list(self) -> list[str]:
956
+ ret_lst = []
850
957
  for col_name in self.get_columns_list():
851
958
  if not self.is_relation(col_name):
852
959
  tmp_prop = self.get_property_first_col(col_name).name
@@ -861,34 +968,37 @@ class SQLAInterface(BaseInterface):
861
968
  ret_lst.append(col_name)
862
969
  return ret_lst
863
970
 
864
- def get_order_columns_list(self, list_columns: List[str] = None) -> List[str]:
971
+ def get_order_columns_list(self, list_columns: list[str] = None) -> list[str]:
865
972
  """
866
- Returns the columns that can be ordered
973
+ Returns the columns that can be ordered.
867
974
 
868
975
  :param list_columns: optional list of columns name, if provided will
869
976
  use this list only.
870
977
  """
871
- ret_lst = list()
978
+ ret_lst = []
872
979
  list_columns = list_columns or self.get_columns_list()
980
+
873
981
  for col_name in list_columns:
874
- if not self.is_relation(col_name):
875
- if hasattr(self.obj, col_name):
876
- if not hasattr(getattr(self.obj, col_name), "__call__") or hasattr(
877
- getattr(self.obj, col_name), "_col_name"
878
- ):
879
- ret_lst.append(col_name)
880
- else:
982
+ if self.is_relation(col_name):
983
+ continue
984
+
985
+ if hasattr(self.obj, col_name):
986
+ attribute = getattr(self.obj, col_name)
987
+ if not callable(attribute) or hasattr(attribute, "_col_name"):
881
988
  ret_lst.append(col_name)
989
+ else:
990
+ ret_lst.append(col_name)
991
+
882
992
  return ret_lst
883
993
 
884
- def get_file_column_list(self) -> List[str]:
994
+ def get_file_column_list(self) -> list[str]:
885
995
  return [
886
996
  i.name
887
997
  for i in self.obj.__mapper__.columns
888
998
  if isinstance(i.type, FileColumn)
889
999
  ]
890
1000
 
891
- def get_image_column_list(self) -> List[str]:
1001
+ def get_image_column_list(self) -> list[str]:
892
1002
  return [
893
1003
  i.name
894
1004
  for i in self.obj.__mapper__.columns
@@ -899,15 +1009,16 @@ class SQLAInterface(BaseInterface):
899
1009
  # support for only one col for pk and fk
900
1010
  return self.list_properties[col_name].columns[0]
901
1011
 
902
- def get_relation_fk(self, col_name: str) -> str:
1012
+ def get_relation_fk(self, col_name: str) -> Column:
903
1013
  # support for only one col for pk and fk
904
1014
  return list(self.list_properties[col_name].local_columns)[0]
905
1015
 
906
1016
  def get(
907
1017
  self,
908
- id,
1018
+ id: Any,
909
1019
  filters: Optional[Filters] = None,
910
- select_columns: Optional[List[str]] = None,
1020
+ select_columns: Optional[list[str]] = None,
1021
+ outer_default_load: bool = False,
911
1022
  ) -> Optional[Model]:
912
1023
  """
913
1024
  Returns the result for a model get, applies filters and supports dotted
@@ -925,38 +1036,41 @@ class SQLAInterface(BaseInterface):
925
1036
  else:
926
1037
  _filters = Filters(self.filter_converter_class, self)
927
1038
 
928
- if self.is_pk_composite():
1039
+ if self.is_pk_composite() and isinstance(pk, Iterable):
929
1040
  for _pk, _id in zip(pk, id):
930
1041
  _filters.add_filter(_pk, self.FilterEqual, _id)
931
1042
  else:
932
1043
  _filters.add_filter(pk, self.FilterEqual, id)
933
1044
  query = self.session.query(self.obj)
934
1045
  item = self.apply_all(
935
- query, _filters, select_columns=select_columns
1046
+ query,
1047
+ _filters,
1048
+ select_columns=select_columns,
1049
+ outer_default_load=outer_default_load,
936
1050
  ).one_or_none()
937
1051
  if item:
938
1052
  if hasattr(item, self.obj.__name__):
939
1053
  return getattr(item, self.obj.__name__)
940
1054
  return item
941
1055
 
942
- def get_pk_name(self) -> Optional[Union[List[str], str]]:
1056
+ def get_pk_name(self) -> Optional[list[str] | str]:
943
1057
  """
944
1058
  Get the model primary key column name.
945
1059
  """
946
1060
  return self._get_pk_name(self.obj)
947
1061
 
948
- def get_pk(self, model: Optional[Type[Model]] = None):
1062
+ def get_pk(self, model: Optional[Type[Model]] = None) -> Model | None:
949
1063
  """
950
1064
  Get the model primary key SQLAlchemy column.
951
1065
  Will not support composite keys
952
1066
  """
953
- model_ = model or self.obj
1067
+ model_ = self.obj if model is None else model
954
1068
  pk_name = self._get_pk_name(model_)
955
1069
  if pk_name and isinstance(pk_name, str):
956
1070
  return getattr(model_, pk_name)
957
1071
  return None
958
1072
 
959
- def _get_pk_name(self, model: Type[Model]) -> Optional[Union[List[str], str]]:
1073
+ def _get_pk_name(self, model: Type[Model]) -> Optional[list[str] | str]:
960
1074
  pk = [pk.name for pk in model.__mapper__.primary_key]
961
1075
  if pk:
962
1076
  return pk if self.is_pk_composite() else pk[0]