quite-admin 0.4.1__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 (54) hide show
  1. quite_admin/__init__.py +45 -0
  2. quite_admin/adapters/__init__.py +5 -0
  3. quite_admin/adapters/_gino_relations.py +247 -0
  4. quite_admin/adapters/_peewee_relations.py +228 -0
  5. quite_admin/adapters/_queries.py +113 -0
  6. quite_admin/adapters/_sql_metadata.py +201 -0
  7. quite_admin/adapters/_sql_relations.py +244 -0
  8. quite_admin/adapters/_sqlalchemy_relations.py +305 -0
  9. quite_admin/adapters/_tortoise_relations.py +219 -0
  10. quite_admin/adapters/base.py +28 -0
  11. quite_admin/adapters/delegating.py +50 -0
  12. quite_admin/adapters/gino.py +221 -0
  13. quite_admin/adapters/peewee.py +200 -0
  14. quite_admin/adapters/sql.py +258 -0
  15. quite_admin/adapters/sqlalchemy.py +240 -0
  16. quite_admin/adapters/tortoise.py +155 -0
  17. quite_admin/admin.py +1769 -0
  18. quite_admin/auth.py +95 -0
  19. quite_admin/concurrency.py +135 -0
  20. quite_admin/config.py +226 -0
  21. quite_admin/diagram.py +164 -0
  22. quite_admin/errors.py +21 -0
  23. quite_admin/exports.py +126 -0
  24. quite_admin/filters.py +363 -0
  25. quite_admin/i18n.py +390 -0
  26. quite_admin/logging.py +69 -0
  27. quite_admin/operations.py +44 -0
  28. quite_admin/py.typed +0 -0
  29. quite_admin/relations.py +60 -0
  30. quite_admin/schema.py +358 -0
  31. quite_admin/static/admin.css +551 -0
  32. quite_admin/static/filters.js +247 -0
  33. quite_admin/static/forms.js +249 -0
  34. quite_admin/static/inline.js +107 -0
  35. quite_admin/static/listing.js +37 -0
  36. quite_admin/static/navigation.js +12 -0
  37. quite_admin/static/relations.js +160 -0
  38. quite_admin/static/schema.js +364 -0
  39. quite_admin/templates/quite_admin/_icons.html +3 -0
  40. quite_admin/templates/quite_admin/_schema_content.html +21 -0
  41. quite_admin/templates/quite_admin/base.html +51 -0
  42. quite_admin/templates/quite_admin/dashboard.html +13 -0
  43. quite_admin/templates/quite_admin/delete.html +8 -0
  44. quite_admin/templates/quite_admin/error.html +2 -0
  45. quite_admin/templates/quite_admin/filters.html +27 -0
  46. quite_admin/templates/quite_admin/form.html +72 -0
  47. quite_admin/templates/quite_admin/list.html +62 -0
  48. quite_admin/templates/quite_admin/login.html +15 -0
  49. quite_admin/templates/quite_admin/schema.html +2 -0
  50. quite_admin/theme.py +99 -0
  51. quite_admin-0.4.1.dist-info/METADATA +172 -0
  52. quite_admin-0.4.1.dist-info/RECORD +54 -0
  53. quite_admin-0.4.1.dist-info/WHEEL +4 -0
  54. quite_admin-0.4.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,45 @@
1
+ from .adapters.base import ADAPTER_API_VERSION
2
+ from .adapters.delegating import DelegatingAdapter
3
+ from .admin import TEMPLATE_API_VERSION, Admin, Page
4
+ from .auth import AuthBackend, Identity, PasswordAuth, hash_password, verify_password
5
+ from .concurrency import CheckedAdapter, WriteGuard
6
+ from .config import FieldOptions, ModelOptions, RecordAction
7
+ from .logging import LoggerProtocol
8
+ from .operations import Operation, OperationHooks, current_operation
9
+ from .relations import Junction, Relation, RelationAdapter
10
+ from .schema import Field, Filter, ModelSchema, Query, RecordPage, RelatedFilter
11
+ from .theme import Theme
12
+
13
+ __version__ = "0.4.1"
14
+
15
+ __all__ = [
16
+ "ADAPTER_API_VERSION",
17
+ "TEMPLATE_API_VERSION",
18
+ "Admin",
19
+ "CheckedAdapter",
20
+ "WriteGuard",
21
+ "AuthBackend",
22
+ "DelegatingAdapter",
23
+ "Field",
24
+ "FieldOptions",
25
+ "Filter",
26
+ "Identity",
27
+ "LoggerProtocol",
28
+ "ModelOptions",
29
+ "ModelSchema",
30
+ "Operation",
31
+ "OperationHooks",
32
+ "Page",
33
+ "PasswordAuth",
34
+ "Query",
35
+ "RecordPage",
36
+ "RecordAction",
37
+ "RelatedFilter",
38
+ "Junction",
39
+ "Relation",
40
+ "RelationAdapter",
41
+ "Theme",
42
+ "hash_password",
43
+ "verify_password",
44
+ "current_operation",
45
+ ]
@@ -0,0 +1,5 @@
1
+ """Adapters are imported explicitly, so unused ORMs are never required."""
2
+
3
+ from .base import Adapter
4
+
5
+ __all__ = ["Adapter"]
@@ -0,0 +1,247 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+
5
+ from sqlalchemy import and_, exists, false, select
6
+
7
+ from quite_admin.concurrency import check_record, check_relation, guarded
8
+ from quite_admin.errors import Conflict, NotFound
9
+ from quite_admin.schema import RelatedFilter
10
+
11
+ from ._sql_metadata import SQLRelationMetadata, related_predicates
12
+
13
+
14
+ class GinoRelations:
15
+ def _links(self, model):
16
+ models = {self._table(item): item for item in self.discover()}
17
+ links = SQLRelationMetadata()._links(self._table(model))
18
+ result = {}
19
+ for name, link in links.items():
20
+ target = models.get(link.relation.target)
21
+ if target is None:
22
+ continue
23
+ local = tuple(self._attribute(model, column) for column in link.local)
24
+ remote = tuple(self._attribute(target, column) for column in link.remote)
25
+ relation = replace(
26
+ link.relation,
27
+ target=target,
28
+ source_fields=local if link.relation.source_fields else (),
29
+ target_fields=remote if link.relation.target_fields else (),
30
+ source_key_fields=local,
31
+ target_key_fields=remote,
32
+ )
33
+ if relation.junction is not None:
34
+ junction_model = models.get(link.through)
35
+ if junction_model is not None:
36
+ junction = replace(
37
+ relation.junction,
38
+ model=junction_model,
39
+ fields=self.describe(junction_model).fields,
40
+ source_fields=tuple(
41
+ self._attribute(junction_model, name)
42
+ for name in relation.junction.source_fields
43
+ ),
44
+ target_fields=tuple(
45
+ self._attribute(junction_model, name)
46
+ for name in relation.junction.target_fields
47
+ ),
48
+ )
49
+ relation = replace(relation, junction=junction)
50
+ result[name] = replace(link, relation=relation, local=local, remote=remote)
51
+ return result
52
+
53
+ def _filter_conditions(self, model, query):
54
+ if not query.related:
55
+ return ()
56
+ links = self._links(model)
57
+ physical = replace(
58
+ query,
59
+ related=tuple(
60
+ RelatedFilter(
61
+ item.relation,
62
+ {
63
+ getattr(links[item.relation].relation.target, name).name: value
64
+ for name, value in item.key.items()
65
+ },
66
+ )
67
+ for item in query.related
68
+ ),
69
+ )
70
+ return related_predicates(
71
+ self._table(model), physical, SQLRelationMetadata()._links(self._table(model))
72
+ )
73
+
74
+ def relations(self, model):
75
+ return tuple(link.relation for link in self._links(model).values())
76
+
77
+ def _condition(self, link, parent):
78
+ target = link.relation.target
79
+ if not link.reverse and link.through is None:
80
+ if any(parent[name] is None for name in link.local):
81
+ return false()
82
+ if link.through is not None:
83
+ # Correlated EXISTS preserves cardinality, including composite/self junctions.
84
+ return self._composite_through(link, parent, link.through.alias())
85
+ return and_(
86
+ *(
87
+ getattr(target, remote) == parent[local]
88
+ for local, remote in zip(link.local, link.remote, strict=True)
89
+ )
90
+ )
91
+
92
+ def _composite_through(self, link, parent, through):
93
+ return exists(
94
+ select([1])
95
+ .select_from(through)
96
+ .where(
97
+ and_(
98
+ *(
99
+ through.c[column] == parent[local]
100
+ for column, local in zip(link.through_local, link.local, strict=True)
101
+ ),
102
+ *(
103
+ through.c[column] == getattr(link.relation.target, remote)
104
+ for column, remote in zip(link.through_remote, link.remote, strict=True)
105
+ ),
106
+ )
107
+ )
108
+ .correlate(self._table(link.relation.target))
109
+ )
110
+
111
+ async def related(self, model, name, key, query):
112
+ if query.related:
113
+ raise ValueError("Relationship filters are supported only in list().")
114
+ links = self._links(model)
115
+ if name not in links:
116
+ raise Conflict("Unknown relationship.")
117
+ parent = await self.get(model, key)
118
+ if parent is None:
119
+ raise NotFound("Record not found.")
120
+ link = links[name]
121
+ return await self._page(
122
+ link.relation.target, query, condition=self._condition(link, parent)
123
+ )
124
+
125
+ async def save_relations(self, model, values, selections, *, key=None):
126
+ schema = self.describe(model)
127
+ schema.validate_values(values, creating=key is None)
128
+ if key is not None:
129
+ schema.validate_key(key)
130
+ links = self._links(model)
131
+ if set(selections) - links.keys():
132
+ raise Conflict("Unknown relationship.")
133
+ for name, keys in selections.items():
134
+ link = links[name]
135
+ if link.relation.read_only:
136
+ raise Conflict("This relationship is read only.")
137
+ if link.relation.kind in {"one_to_one", "many_to_one"} and len(keys) > 1:
138
+ raise Conflict("Choose a single related record.")
139
+ for target_key in keys:
140
+ self.describe(link.relation.target).validate_key(target_key)
141
+
142
+ async with self._transaction() as connection:
143
+ if key is not None and guarded():
144
+ original = await connection.first(
145
+ model.query.where(self._where(model, key)).with_for_update()
146
+ )
147
+ if original is None:
148
+ raise NotFound("Record not found.")
149
+ check_record(model, self._record(model, original))
150
+ for relation_name in selections:
151
+ link = links[relation_name]
152
+ target = link.relation.target
153
+ previous = await connection.all(
154
+ target.query.where(
155
+ self._condition(link, self._record(model, original))
156
+ ).with_for_update()
157
+ )
158
+ check_relation(
159
+ model,
160
+ relation_name,
161
+ self.describe(target),
162
+ [self._record(target, row) for row in previous],
163
+ )
164
+ changes = dict(values)
165
+ targets = {}
166
+ for name, keys in selections.items():
167
+ link = links[name]
168
+ target = link.relation.target
169
+ # Deduplicate selections without relying on driver-specific keys.
170
+ unique = {tuple(sorted(target_key.items())): target_key for target_key in keys}
171
+ rows = [
172
+ await connection.first(
173
+ target.query.where(self._where(target, target_key)).with_for_update()
174
+ )
175
+ for target_key in unique.values()
176
+ ]
177
+ if any(row is None for row in rows):
178
+ raise NotFound("Related record not found.")
179
+ targets[name] = [self._record(target, row) for row in rows]
180
+ if not link.reverse and link.through is None:
181
+ if not rows and not link.relation.can_detach:
182
+ raise Conflict("A related record is required.")
183
+ changes.update(
184
+ {
185
+ local: getattr(rows[0], remote) if rows else None
186
+ for local, remote in zip(link.local, link.remote, strict=True)
187
+ }
188
+ )
189
+ parent = await self._persist(connection, model, changes, key=key)
190
+ for name, rows in targets.items():
191
+ link = links[name]
192
+ if not link.reverse and link.through is None:
193
+ continue
194
+ target = link.relation.target
195
+ keys = tuple(field.name for field in self.describe(target).primary_keys)
196
+ previous = await connection.all(
197
+ target.query.where(self._condition(link, parent)).with_for_update()
198
+ )
199
+ selected = {tuple(row[k] for k in keys) for row in rows}
200
+ removed = [
201
+ row for row in previous if tuple(getattr(row, k) for k in keys) not in selected
202
+ ]
203
+ if removed and not link.relation.can_detach:
204
+ raise Conflict("This relationship cannot be detached.")
205
+ if link.through is not None:
206
+ await connection.status(
207
+ link.through.delete().where(
208
+ and_(
209
+ *(
210
+ link.through.c[column] == parent[local]
211
+ for column, local in zip(
212
+ link.through_local, link.local, strict=True
213
+ )
214
+ )
215
+ )
216
+ )
217
+ )
218
+ for row in rows:
219
+ data = {
220
+ column: parent[local]
221
+ for column, local in zip(link.through_local, link.local, strict=True)
222
+ }
223
+ data.update(
224
+ {
225
+ column: row[remote]
226
+ for column, remote in zip(
227
+ link.through_remote, link.remote, strict=True
228
+ )
229
+ }
230
+ )
231
+ await connection.status(link.through.insert().values(**data))
232
+ else:
233
+ # Release a unique 1:1 FK before assigning its replacement.
234
+ for row in removed:
235
+ await row.update(**dict.fromkeys(link.remote)).apply(bind=connection)
236
+ for row in rows:
237
+ await self._persist(
238
+ connection,
239
+ target,
240
+ {
241
+ remote: parent[local]
242
+ for local, remote in zip(link.local, link.remote, strict=True)
243
+ },
244
+ key={field: row[field] for field in keys},
245
+ )
246
+ identity = {field.name: parent[field.name] for field in schema.primary_keys}
247
+ return await self._persist(connection, model, {}, key=identity)
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import peewee as pw
4
+
5
+ from quite_admin.concurrency import check_record, check_relation, guarded
6
+ from quite_admin.errors import Conflict, NotFound
7
+ from quite_admin.relations import Junction, Relation
8
+ from quite_admin.schema import RecordPage
9
+
10
+ from ._queries import ordering as order_expressions
11
+ from ._queries import predicates, search_predicate
12
+
13
+
14
+ class PeeweeRelations:
15
+ def _relation_fields(self, model):
16
+ result = {field.name: ("forward", field) for field in model._meta.refs}
17
+ result.update(
18
+ {
19
+ field.backref: ("reverse", field)
20
+ for field in model._meta.backrefs
21
+ if field.backref != "!"
22
+ }
23
+ )
24
+ result.update({name: ("many", field) for name, field in model._meta.manytomany.items()})
25
+ return result
26
+
27
+ def relations(self, model):
28
+ result = []
29
+ for name, (direction, field) in self._relation_fields(model).items():
30
+ if direction == "many":
31
+ source_fk = field.through_model._meta.model_refs[model][0]
32
+ target_fk = field.through_model._meta.model_refs[field.rel_model][0]
33
+ result.append(
34
+ Relation(
35
+ name,
36
+ field.rel_model,
37
+ "many_to_many",
38
+ source_key_fields=(source_fk.rel_field.name,),
39
+ target_key_fields=(target_fk.rel_field.name,),
40
+ through=field.through_model._meta.table_name,
41
+ junction=Junction(
42
+ field.through_model._meta.table_name,
43
+ self.describe(field.through_model).fields,
44
+ (source_fk.name,),
45
+ (target_fk.name,),
46
+ field.through_model,
47
+ ),
48
+ )
49
+ )
50
+ continue
51
+ reverse = direction == "reverse"
52
+ target = field.model if reverse else field.rel_model
53
+ result.append(
54
+ Relation(
55
+ name,
56
+ target,
57
+ "one_to_one" if field.unique else "one_to_many" if reverse else "many_to_one",
58
+ source_fields=() if reverse else (field.name,),
59
+ source_key_fields=(field.rel_field.name,) if reverse else (field.name,),
60
+ target_key_fields=(field.name,) if reverse else (field.rel_field.name,),
61
+ target_fields=(field.name,) if reverse else (),
62
+ can_detach=field.null,
63
+ mutates_target=reverse,
64
+ read_only=field.primary_key,
65
+ )
66
+ )
67
+ return tuple(result)
68
+
69
+ def _filter_conditions(self, model, query):
70
+ fields = self._relation_fields(model) if query.related else {}
71
+ for item in query.related:
72
+ direction, field = fields[item.relation]
73
+ target_model = field.model if direction == "reverse" else field.rel_model
74
+ target = target_model.alias()
75
+ statement = target.select(pw.SQL("1"))
76
+ conditions = [getattr(target, name) == value for name, value in item.key.items()]
77
+ if direction == "many":
78
+ source_fk = field.through_model._meta.model_refs[model][0]
79
+ target_fk = field.through_model._meta.model_refs[target_model][0]
80
+ through = field.through_model.alias()
81
+ statement = statement.join(
82
+ through,
83
+ on=(
84
+ getattr(through, target_fk.name)
85
+ == getattr(target, target_fk.rel_field.name)
86
+ ),
87
+ )
88
+ conditions.append(
89
+ getattr(through, source_fk.name) == getattr(model, source_fk.rel_field.name)
90
+ )
91
+ elif direction == "reverse":
92
+ conditions.append(
93
+ getattr(target, field.name) == getattr(model, field.rel_field.name)
94
+ )
95
+ else:
96
+ conditions.append(
97
+ getattr(target, field.rel_field.name) == getattr(model, field.name)
98
+ )
99
+ yield pw.fn.EXISTS(statement.where(*conditions))
100
+
101
+ def _related_query(self, parent, name):
102
+ direction, field = self._relation_fields(type(parent))[name]
103
+ if direction == "many":
104
+ return getattr(parent, name)
105
+ if direction == "reverse":
106
+ return field.model.select().where(field == parent.__data__[field.rel_field.name])
107
+ value = parent.__data__.get(field.name)
108
+ return field.rel_model.select().where(
109
+ field.rel_field == value if value is not None else False
110
+ )
111
+
112
+ async def related(self, model, name, key, query):
113
+ relation = next(item for item in self.relations(model) if item.name == name)
114
+ self.describe(model).validate_key(key)
115
+ query.validate(self.describe(relation.target))
116
+
117
+ def operation():
118
+ parent = model.get_or_none(self._where(model, key))
119
+ if parent is None:
120
+ raise NotFound("Record not found.")
121
+ statement = self._related_query(parent, name)
122
+ if query.filters:
123
+ statement = statement.where(
124
+ *predicates(query, lambda field: getattr(relation.target, field), peewee=True)
125
+ )
126
+ if query.search and query.search_fields:
127
+ statement = statement.where(
128
+ search_predicate(
129
+ query, lambda field: getattr(relation.target, field), peewee=True
130
+ )
131
+ )
132
+ total = statement.count()
133
+ statement = statement.order_by(
134
+ *order_expressions(
135
+ query,
136
+ self.describe(relation.target),
137
+ lambda field: getattr(relation.target, field),
138
+ )
139
+ )
140
+ return RecordPage(
141
+ [
142
+ query.project(self._record(relation.target, row))
143
+ for row in statement.offset(query.offset).limit(query.limit)
144
+ ],
145
+ total,
146
+ )
147
+
148
+ return await self._run(model, operation)
149
+
150
+ async def save_relations(self, model, values, selections, *, key=None):
151
+ self.describe(model).validate_values(values, creating=key is None)
152
+ if key is not None:
153
+ self.describe(model).validate_key(key)
154
+ available_targets = {item.name: item.target for item in self.relations(model)}
155
+ for relation_name, selected_keys in selections.items():
156
+ if relation_name in available_targets:
157
+ target_schema = self.describe(available_targets[relation_name])
158
+ for selected_key in selected_keys:
159
+ target_schema.validate_key(selected_key)
160
+ available = {item.name: item for item in self.relations(model)}
161
+ fields = self._relation_fields(model)
162
+ if set(selections) - available.keys():
163
+ raise Conflict("Unknown relationship.")
164
+
165
+ def operation():
166
+ parent = self._locked_get(model, key) if key is not None else model()
167
+ if parent is None:
168
+ raise NotFound("Record not found.")
169
+ if key is not None and guarded():
170
+ check_record(model, self._record(model, parent))
171
+ for relation_name in selections:
172
+ target = available[relation_name].target
173
+ query = self._related_query(parent, relation_name)
174
+ if guarded() and not isinstance(model._meta.database, pw.SqliteDatabase):
175
+ query = query.for_update()
176
+ check_relation(
177
+ model,
178
+ relation_name,
179
+ self.describe(target),
180
+ [self._record(target, row) for row in query],
181
+ )
182
+ for name, value in values.items():
183
+ setattr(parent, name, value)
184
+ selected = {}
185
+ for name, keys in selections.items():
186
+ relation = available[name]
187
+ if relation.read_only:
188
+ raise Conflict("This relationship is read only.")
189
+ if relation.kind in {"one_to_one", "many_to_one"} and len(keys) > 1:
190
+ raise Conflict("Choose a single related record.")
191
+ targets = [
192
+ relation.target.get_or_none(self._where(relation.target, target_key))
193
+ for target_key in keys
194
+ ]
195
+ if any(target is None for target in targets):
196
+ raise NotFound("Related record not found.")
197
+ selected[name] = targets
198
+ direction, field = fields[name]
199
+ if direction == "forward":
200
+ if not targets and not relation.can_detach:
201
+ raise Conflict("A related record is required.")
202
+ setattr(parent, name, targets[0] if targets else None)
203
+ parent.save(force_insert=key is None)
204
+ for name, targets in selected.items():
205
+ direction, field = fields[name]
206
+ if direction == "forward":
207
+ continue
208
+ previous = list(self._related_query(parent, name))
209
+ removed = [item for item in previous if item not in targets]
210
+ if removed and not available[name].can_detach:
211
+ raise Conflict("This relationship cannot be detached.")
212
+ if direction == "many":
213
+ manager = getattr(parent, name)
214
+ if removed:
215
+ manager.remove(removed)
216
+ added = [item for item in targets if item not in previous]
217
+ if added:
218
+ manager.add(added)
219
+ else:
220
+ for item in removed:
221
+ setattr(item, field.name, None)
222
+ item.save(only=[field])
223
+ for item in targets:
224
+ setattr(item, field.name, parent)
225
+ item.save(only=[field])
226
+ return self._record(model, parent)
227
+
228
+ return await self._run(model, operation, write=True)
@@ -0,0 +1,113 @@
1
+ """Native query expressions, called only after Query.validate()."""
2
+
3
+ import operator
4
+
5
+ from quite_admin.schema import TEXT_FILTER_OPERATORS
6
+
7
+
8
+ def predicates(query, column, *, peewee=False, text_column=None):
9
+ for item in query.filters:
10
+ field = column(item.field)
11
+ if item.operator == "is_null":
12
+ if peewee:
13
+ yield field.is_null(item.value)
14
+ else:
15
+ yield field.is_(None) if item.value else field.isnot(None)
16
+ elif item.operator in TEXT_FILTER_OPERATORS:
17
+ field = (text_column or column)(item.field)
18
+ method = {
19
+ "icontains": "contains",
20
+ "not_icontains": "contains",
21
+ "istartswith": "startswith",
22
+ "iendswith": "endswith",
23
+ }[item.operator]
24
+ if peewee:
25
+ expression = getattr(field, method)(item.value)
26
+ else:
27
+ literal = item.value.replace("!", "!!").replace("%", "!%").replace("_", "!_")
28
+ pattern = ("%" if method != "startswith" else "") + literal
29
+ pattern += "%" if method != "endswith" else ""
30
+ expression = field.ilike(pattern, escape="!")
31
+ yield ~expression if item.operator == "not_icontains" else expression
32
+ elif item.operator == "in":
33
+ yield field.in_(item.value)
34
+ else:
35
+ operation = {
36
+ "eq": operator.eq,
37
+ "ne": operator.ne,
38
+ "lt": operator.lt,
39
+ "lte": operator.le,
40
+ "gt": operator.gt,
41
+ "gte": operator.ge,
42
+ }[item.operator]
43
+ yield operation(field, item.value)
44
+
45
+
46
+ def search_predicate(query, column, *, peewee=False, text_column=None):
47
+ expressions = []
48
+ if query.search:
49
+ for name in query.search_fields:
50
+ field = (text_column or column)(name)
51
+ if peewee:
52
+ expressions.append(field.contains(query.search))
53
+ else:
54
+ literal = query.search.replace("!", "!!").replace("%", "!%").replace("_", "!_")
55
+ expressions.append(field.ilike("%" + literal + "%", escape="!"))
56
+ if query.search_key is not None:
57
+ key_parts = [column(name) == value for name, value in query.search_key.items()]
58
+ key_match = key_parts[0]
59
+ for part in key_parts[1:]:
60
+ key_match = key_match & part
61
+ expressions.append(key_match)
62
+ if not expressions:
63
+ return None
64
+ search = expressions[0]
65
+ for expression in expressions[1:]:
66
+ search = search | expression
67
+ return search
68
+
69
+
70
+ def ordering(query, schema, column):
71
+ order = query.order_by or schema.primary_keys[0].name
72
+ yield column(order).desc() if query.descending else column(order).asc()
73
+ for field in schema.primary_keys:
74
+ if field.name != order:
75
+ yield column(field.name).asc()
76
+
77
+
78
+ def tortoise_filters(statement, query):
79
+ for item in query.filters:
80
+ if item.operator == "not_icontains":
81
+ statement = statement.exclude(**{item.field + "__icontains": item.value}).filter(
82
+ **{item.field + "__isnull": False}
83
+ )
84
+ continue
85
+ suffix = {
86
+ "eq": "",
87
+ "ne": "__not",
88
+ "icontains": "__icontains",
89
+ "istartswith": "__istartswith",
90
+ "iendswith": "__iendswith",
91
+ "lt": "__lt",
92
+ "lte": "__lte",
93
+ "gt": "__gt",
94
+ "gte": "__gte",
95
+ "in": "__in",
96
+ "is_null": "__isnull",
97
+ }[item.operator]
98
+ statement = statement.filter(**{item.field + suffix: item.value})
99
+ if item.operator == "ne":
100
+ statement = statement.filter(**{item.field + "__isnull": False})
101
+ return statement
102
+
103
+
104
+ def validate_query(adapter, model, query):
105
+ schemas = {}
106
+ if query.related:
107
+ names = {item.relation for item in query.related if hasattr(item, "relation")}
108
+ schemas = {
109
+ relation.name: adapter.describe(relation.target)
110
+ for relation in adapter.relations(model)
111
+ if relation.name in names
112
+ }
113
+ query.validate(adapter.describe(model), related_schemas=schemas)