fquery 0.1__tar.gz → 0.3__tar.gz
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.
- fquery-0.3/PKG-INFO +61 -0
- {fquery-0.1 → fquery-0.3}/README.md +16 -2
- {fquery-0.1 → fquery-0.3}/fquery/aitertools.py +0 -2
- {fquery-0.1 → fquery-0.3}/fquery/async_utils.py +0 -1
- fquery-0.3/fquery/django.py +45 -0
- {fquery-0.1 → fquery-0.3}/fquery/execute.py +9 -9
- fquery-0.3/fquery/malloy_builder.py +86 -0
- {fquery-0.1 → fquery-0.3}/fquery/query.py +21 -19
- {fquery-0.1 → fquery-0.3}/fquery/resolve.py +1 -3
- {fquery-0.1 → fquery-0.3}/fquery/sql_builder.py +3 -2
- fquery-0.3/fquery/sqlmodel.py +280 -0
- {fquery-0.1 → fquery-0.3}/fquery/view_model.py +22 -1
- {fquery-0.1 → fquery-0.3}/fquery/visitor.py +7 -7
- {fquery-0.1 → fquery-0.3}/fquery/walk.py +15 -16
- fquery-0.3/fquery.egg-info/PKG-INFO +61 -0
- {fquery-0.1 → fquery-0.3}/fquery.egg-info/SOURCES.txt +7 -0
- fquery-0.3/fquery.egg-info/requires.txt +10 -0
- {fquery-0.1 → fquery-0.3}/pyproject.toml +2 -0
- {fquery-0.1 → fquery-0.3}/setup.py +12 -2
- {fquery-0.1 → fquery-0.3}/tests/async_test.py +15 -1
- fquery-0.3/tests/benchmark.py +68 -0
- fquery-0.3/tests/django_example_model.py +13 -0
- {fquery-0.1 → fquery-0.3}/tests/graphql_mock_user.py +4 -2
- {fquery-0.1 → fquery-0.3}/tests/mock_user.py +8 -25
- fquery-0.3/tests/test_malloy.py +40 -0
- {fquery-0.1 → fquery-0.3}/tests/test_operators.py +1 -2
- {fquery-0.1 → fquery-0.3}/tests/test_sql.py +2 -1
- fquery-0.3/tests/test_sqlmodel.py +102 -0
- {fquery-0.1 → fquery-0.3}/tests/test_walk.py +2 -1
- fquery-0.1/PKG-INFO +0 -48
- fquery-0.1/fquery.egg-info/PKG-INFO +0 -48
- fquery-0.1/fquery.egg-info/requires.txt +0 -7
- {fquery-0.1 → fquery-0.3}/LICENSE +0 -0
- {fquery-0.1 → fquery-0.3}/fquery/__init__.py +0 -0
- {fquery-0.1 → fquery-0.3}/fquery/fgraphql.py +2 -2
- {fquery-0.1 → fquery-0.3}/fquery.egg-info/dependency_links.txt +0 -0
- {fquery-0.1 → fquery-0.3}/fquery.egg-info/top_level.txt +0 -0
- {fquery-0.1 → fquery-0.3}/setup.cfg +0 -0
- {fquery-0.1 → fquery-0.3}/tests/__init__.py +0 -0
- {fquery-0.1 → fquery-0.3}/tests/test_walk_obj.py +0 -0
fquery-0.3/PKG-INFO
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: fquery
|
|
3
|
+
Version: 0.3
|
|
4
|
+
Summary: A graph query engine
|
|
5
|
+
Home-page: https://github.com/adsharma/fquery
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.6
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Provides-Extra: SQL
|
|
12
|
+
Provides-Extra: graphql
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
|
|
15
|
+
# Overview
|
|
16
|
+
|
|
17
|
+
Projects such as Django and Flask ship with what is known as ORM (object
|
|
18
|
+
relational mappers). These abstractions expose much of the underlying
|
|
19
|
+
relational behavior (both in schema and queries).This project on the
|
|
20
|
+
other hand allows a programmer to stay entirely in the object domain
|
|
21
|
+
(hiding any relational functionality contained within), while still
|
|
22
|
+
allowing transparent mapping to a relational database.
|
|
23
|
+
|
|
24
|
+
Only basic transparent mapping of fqueries to SQL is supported:
|
|
25
|
+
[Demo](https://github.com/adsharma/fquery/blob/main/tests/test_sql.py).
|
|
26
|
+
|
|
27
|
+
Only basic transparent mapping of fqueries to [malloy](https://www.malloydata.dev/) is supported:
|
|
28
|
+
[Demo](https://github.com/adsharma/fquery/blob/main/tests/test_malloy.py).
|
|
29
|
+
|
|
30
|
+
# Installation
|
|
31
|
+
|
|
32
|
+
Requires python3.x
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip3 install fquery
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Running tests:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
alias t=pytest-3
|
|
42
|
+
t
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
You can also run it via tox.
|
|
46
|
+
|
|
47
|
+
# Tutorial
|
|
48
|
+
|
|
49
|
+
[Intro](https://adsharma.github.io/fquery/): What is fquery, sample queries
|
|
50
|
+
and some information on internals.
|
|
51
|
+
|
|
52
|
+
[Blog post](https://adsharma.github.io/django-fquery/) on how to use
|
|
53
|
+
fquery with Django and get easy access to graphql functionality
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# License
|
|
57
|
+
|
|
58
|
+
This project is made available under the Apache License, version 2.0.
|
|
59
|
+
|
|
60
|
+
See [LICENSE.txt](license.txt) for details.
|
|
61
|
+
|
|
@@ -5,8 +5,13 @@ relational mappers). These abstractions expose much of the underlying
|
|
|
5
5
|
relational behavior (both in schema and queries).This project on the
|
|
6
6
|
other hand allows a programmer to stay entirely in the object domain
|
|
7
7
|
(hiding any relational functionality contained within), while still
|
|
8
|
-
allowing transparent mapping to a relational database.
|
|
9
|
-
|
|
8
|
+
allowing transparent mapping to a relational database.
|
|
9
|
+
|
|
10
|
+
Only basic transparent mapping of fqueries to SQL is supported:
|
|
11
|
+
[Demo](https://github.com/adsharma/fquery/blob/main/tests/test_sql.py).
|
|
12
|
+
|
|
13
|
+
Only basic transparent mapping of fqueries to [malloy](https://www.malloydata.dev/) is supported:
|
|
14
|
+
[Demo](https://github.com/adsharma/fquery/blob/main/tests/test_malloy.py).
|
|
10
15
|
|
|
11
16
|
# Installation
|
|
12
17
|
|
|
@@ -25,6 +30,15 @@ t
|
|
|
25
30
|
|
|
26
31
|
You can also run it via tox.
|
|
27
32
|
|
|
33
|
+
# Tutorial
|
|
34
|
+
|
|
35
|
+
[Intro](https://adsharma.github.io/fquery/): What is fquery, sample queries
|
|
36
|
+
and some information on internals.
|
|
37
|
+
|
|
38
|
+
[Blog post](https://adsharma.github.io/django-fquery/) on how to use
|
|
39
|
+
fquery with Django and get easy access to graphql functionality
|
|
40
|
+
|
|
41
|
+
|
|
28
42
|
# License
|
|
29
43
|
|
|
30
44
|
This project is made available under the Apache License, version 2.0.
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
# This source code is licensed under the MIT license found in the
|
|
4
4
|
# LICENSE file in the root directory of this source tree.
|
|
5
5
|
import asyncio
|
|
6
|
-
|
|
7
6
|
from typing import (
|
|
8
7
|
Any,
|
|
9
8
|
AsyncGenerator,
|
|
@@ -17,7 +16,6 @@ from typing import (
|
|
|
17
16
|
Union,
|
|
18
17
|
)
|
|
19
18
|
|
|
20
|
-
|
|
21
19
|
T = TypeVar("T")
|
|
22
20
|
AnyIterable = Union[Iterable[T], AsyncIterable[T]]
|
|
23
21
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from datetime import date, datetime
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from django.db import models
|
|
6
|
+
from django.db.models.fields import (
|
|
7
|
+
BooleanField,
|
|
8
|
+
DateField,
|
|
9
|
+
DateTimeField,
|
|
10
|
+
FloatField,
|
|
11
|
+
IntegerField,
|
|
12
|
+
TextField,
|
|
13
|
+
UUIDField,
|
|
14
|
+
)
|
|
15
|
+
from django.db.models.fields.related import ForeignKey
|
|
16
|
+
|
|
17
|
+
from .view_model import get_edges, get_return_type
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def model(cls):
|
|
21
|
+
def make_django_model(name, bases, **kwattrs):
|
|
22
|
+
return type(name, bases, dict(**kwattrs))
|
|
23
|
+
|
|
24
|
+
def map_type(dataclass_field):
|
|
25
|
+
DATACLASS_TO_DJANGO_FIELD = {
|
|
26
|
+
int: IntegerField,
|
|
27
|
+
float: FloatField,
|
|
28
|
+
bool: BooleanField,
|
|
29
|
+
# TODO: Figure out how to expose CharField
|
|
30
|
+
str: TextField,
|
|
31
|
+
datetime: DateTimeField,
|
|
32
|
+
date: DateField,
|
|
33
|
+
UUID: UUIDField,
|
|
34
|
+
}
|
|
35
|
+
return DATACLASS_TO_DJANGO_FIELD.get(dataclass_field, TextField)()
|
|
36
|
+
|
|
37
|
+
fields = dataclasses.fields(cls)
|
|
38
|
+
django_fields = {djf.name: map_type(djf.type) for djf in fields if djf.name != "id"}
|
|
39
|
+
django_fields["__module__"] = cls.__module__
|
|
40
|
+
django_foreign_key_funcs = get_edges(cls)
|
|
41
|
+
for name, f in django_foreign_key_funcs.items():
|
|
42
|
+
ret_type = get_return_type(f._old)
|
|
43
|
+
name = "_" + name # so it doesn't conflict with the async method name
|
|
44
|
+
django_fields[name] = ForeignKey(ret_type, on_delete=models.CASCADE)
|
|
45
|
+
return make_django_model(cls.__name__, (models.Model,), **django_fields)
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
# This source code is licensed under the MIT license found in the
|
|
4
4
|
# LICENSE file in the root directory of this source tree.
|
|
5
5
|
# Copyright (c) 2016-present, Facebook, Inc. All rights reserved.
|
|
6
|
-
import aioitertools
|
|
7
6
|
import asyncio
|
|
8
7
|
import heapq
|
|
9
8
|
import itertools
|
|
10
9
|
import operator
|
|
11
|
-
|
|
12
10
|
from functools import partial
|
|
13
11
|
from typing import Any, AsyncGenerator, Callable, Iterable, List
|
|
14
12
|
|
|
13
|
+
import aioitertools
|
|
14
|
+
|
|
15
15
|
from .aitertools import tee as aitertools_tee
|
|
16
16
|
from .resolve import VISITED_EDGES_KEY
|
|
17
17
|
from .view_model import ViewModel
|
|
@@ -162,8 +162,8 @@ class AbstractSyntaxTreeVisitor(Visitor):
|
|
|
162
162
|
|
|
163
163
|
async def finish(self):
|
|
164
164
|
"""Setup funcs in reverse order. On entry,
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
p[pkey] may be [orig, a, b, c]
|
|
166
|
+
On exit, it changes to a coroutine returning c(b(a(orig)))."""
|
|
167
167
|
if not self.parent_iter:
|
|
168
168
|
return
|
|
169
169
|
pkey = str(self.parent_key)
|
|
@@ -177,15 +177,15 @@ class AbstractSyntaxTreeVisitor(Visitor):
|
|
|
177
177
|
|
|
178
178
|
def nested(func):
|
|
179
179
|
"""Apply self.map_func to all the values in the map.
|
|
180
|
-
|
|
180
|
+
map_func takes a list of ViewModels as the argument
|
|
181
181
|
"""
|
|
182
182
|
|
|
183
183
|
async def _insert_parent_func(self, func):
|
|
184
|
-
"""
|
|
185
|
-
|
|
184
|
+
"""Given a recursive dict in self.iter backed by generator
|
|
185
|
+
expressions, insert `func' right above the leaves.
|
|
186
186
|
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
self.parent_iter and self.parent_key are used to
|
|
188
|
+
quickly locate the parents of leaf nodes.
|
|
189
189
|
"""
|
|
190
190
|
if not self.parent_iter or not self.parent_key:
|
|
191
191
|
self.iter = aioitertools.map(
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Copyright (c) Facebook, Inc. and its affiliates.
|
|
2
|
+
#
|
|
3
|
+
# This source code is licensed under the MIT license found in the
|
|
4
|
+
# LICENSE file in the root directory of this source tree.
|
|
5
|
+
import ast
|
|
6
|
+
import operator
|
|
7
|
+
|
|
8
|
+
from .visitor import Visitor
|
|
9
|
+
|
|
10
|
+
# inspired from pandas.core.computation.ops
|
|
11
|
+
_cmp_ops_syms = (">", "<", ">=", "<=", "==", "!=")
|
|
12
|
+
_cmp_ops_funcs = (
|
|
13
|
+
operator.gt,
|
|
14
|
+
operator.lt,
|
|
15
|
+
operator.ge,
|
|
16
|
+
operator.le,
|
|
17
|
+
operator.eq,
|
|
18
|
+
operator.ne,
|
|
19
|
+
)
|
|
20
|
+
_cmp_ops_dict = dict(zip(_cmp_ops_syms, _cmp_ops_funcs))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MalloyBuilderVisitor(Visitor):
|
|
24
|
+
INDENT = 2
|
|
25
|
+
|
|
26
|
+
def __init__(self, id1s):
|
|
27
|
+
self.malloy = None
|
|
28
|
+
self.malloy_stack = []
|
|
29
|
+
self.visited = set()
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def table_from_query(query):
|
|
33
|
+
query_name = query.__class__.__name__.lower()
|
|
34
|
+
# UserQuery -> user
|
|
35
|
+
query_name = query_name.split("query")[0]
|
|
36
|
+
return query_name
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _indent():
|
|
40
|
+
return " " * MalloyBuilderVisitor.INDENT
|
|
41
|
+
|
|
42
|
+
async def visit_leaf(self, query):
|
|
43
|
+
table = self.table_from_query(query)
|
|
44
|
+
qstr = f"run: duckdb.table('{table}') -> {{\n"
|
|
45
|
+
while self.malloy_stack:
|
|
46
|
+
func = self.malloy_stack.pop()
|
|
47
|
+
qstr = func(qstr)
|
|
48
|
+
|
|
49
|
+
if query in self.visited:
|
|
50
|
+
# Prevent infinite recursion
|
|
51
|
+
return
|
|
52
|
+
else:
|
|
53
|
+
self.visited.add(query)
|
|
54
|
+
for q in query.edges:
|
|
55
|
+
await self.visit(q)
|
|
56
|
+
qstr += "}"
|
|
57
|
+
self.malloy = qstr
|
|
58
|
+
|
|
59
|
+
async def visit_project(self, query):
|
|
60
|
+
proj = ", ".join([x if x != ":id" else "id" for x in query.projector])
|
|
61
|
+
self.malloy_stack.append(lambda x: x + self._indent() + f"select: {proj}\n")
|
|
62
|
+
await self.visit(query.child)
|
|
63
|
+
|
|
64
|
+
async def visit_take(self, query):
|
|
65
|
+
self.malloy_stack.append(
|
|
66
|
+
lambda x: x + self._indent() + f"limit: {query._count}\n"
|
|
67
|
+
)
|
|
68
|
+
await self.visit(query.child)
|
|
69
|
+
|
|
70
|
+
async def visit_where(self, query):
|
|
71
|
+
# TODO: more general lazy expression evaluator
|
|
72
|
+
left, op, right = query._expr.value.split()
|
|
73
|
+
right = ast.literal_eval(right)
|
|
74
|
+
table, field = left.split(".") if "." in left else (self.malloy, left)
|
|
75
|
+
self.malloy_stack.append(
|
|
76
|
+
lambda x: x + self._indent() + f"where: {table}.{field} {op} {right}\n"
|
|
77
|
+
)
|
|
78
|
+
await self.visit(query.child)
|
|
79
|
+
|
|
80
|
+
async def visit_order_by(self, query):
|
|
81
|
+
key = query._expr.value
|
|
82
|
+
table, field = key.split(".") if "." in key else (self.malloy, key)
|
|
83
|
+
self.malloy_stack.append(
|
|
84
|
+
lambda x: x + self._indent() + f"order_by: {table}.{field}\n"
|
|
85
|
+
)
|
|
86
|
+
await self.visit(query.child)
|
|
@@ -5,17 +5,17 @@
|
|
|
5
5
|
import ast
|
|
6
6
|
import itertools
|
|
7
7
|
import traceback
|
|
8
|
-
|
|
9
8
|
from enum import IntEnum
|
|
10
|
-
from typing import get_type_hints, Dict, ForwardRef, List, Optional, Tuple, Type, Union
|
|
11
9
|
from types import FunctionType
|
|
10
|
+
from typing import Dict, List, Optional, Tuple, Type, Union
|
|
12
11
|
|
|
13
12
|
from .async_utils import wait_for
|
|
14
13
|
from .execute import AbstractSyntaxTreeVisitor
|
|
14
|
+
from .malloy_builder import MalloyBuilderVisitor
|
|
15
15
|
from .sql_builder import SQLBuilderVisitor
|
|
16
|
+
from .view_model import ViewModel, get_edges, get_return_type
|
|
16
17
|
from .walk import (
|
|
17
18
|
EdgeContext,
|
|
18
|
-
ViewModel,
|
|
19
19
|
PrintASTVisitor,
|
|
20
20
|
Tree,
|
|
21
21
|
materialize_walk,
|
|
@@ -86,7 +86,8 @@ class Query:
|
|
|
86
86
|
self._to_json = False
|
|
87
87
|
|
|
88
88
|
def __str__(self) -> str:
|
|
89
|
-
|
|
89
|
+
# black and flake8 don't agree on formatting the next line
|
|
90
|
+
query_name = str(self.OP)[len("QueryableOp.") :] # noqa: E203
|
|
90
91
|
if self.OP == QueryableOp.LEAF:
|
|
91
92
|
return f"{query_name} ({self.__class__.__name__})"
|
|
92
93
|
else:
|
|
@@ -250,6 +251,11 @@ class Query:
|
|
|
250
251
|
wait_for(visitor.visit(self))
|
|
251
252
|
return visitor.sql
|
|
252
253
|
|
|
254
|
+
def to_malloy(self) -> str:
|
|
255
|
+
visitor = MalloyBuilderVisitor([])
|
|
256
|
+
wait_for(visitor.visit(self))
|
|
257
|
+
return visitor.malloy
|
|
258
|
+
|
|
253
259
|
def batch_resolve_objs(self) -> List[Dict[str, List[ViewModel]]]:
|
|
254
260
|
return [{str(None): [o for o in (self.resolve_obj(i) for i in self.ids) if o]}]
|
|
255
261
|
|
|
@@ -277,27 +283,23 @@ class Query:
|
|
|
277
283
|
|
|
278
284
|
|
|
279
285
|
def query(cls):
|
|
280
|
-
def
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if isinstance(ret, type):
|
|
287
|
-
return ret.__name__
|
|
288
|
-
return ret
|
|
286
|
+
def constructor(self, ids=None, items=None):
|
|
287
|
+
Query.__init__(self, None, ids, items)
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
def resolve_obj(_id: int, edge: str = "") -> Optional[ViewModel]:
|
|
291
|
+
return cls.TYPE.get(_id)
|
|
289
292
|
|
|
290
293
|
cls = type(cls.__name__, (Query,), dict(cls.__dict__))
|
|
291
294
|
node_cls = cls.TYPE
|
|
292
|
-
edges =
|
|
293
|
-
(name, func)
|
|
294
|
-
for name, func in node_cls.__dict__.items()
|
|
295
|
-
if hasattr(func, "_edge")
|
|
296
|
-
]
|
|
295
|
+
edges = get_edges(node_cls)
|
|
297
296
|
cls.EDGE_NAME_TO_RETURN_TYPE = {
|
|
298
|
-
name: get_return_type(func._old) for name, func in edges
|
|
297
|
+
name: get_return_type(func._old) for name, func in edges.items()
|
|
299
298
|
}
|
|
300
299
|
Query.ALL_QUERIES.append(cls)
|
|
300
|
+
cls.OP = QueryableOp.LEAF
|
|
301
|
+
cls.__init__ = constructor
|
|
302
|
+
cls.resolve_obj = resolve_obj
|
|
301
303
|
return cls
|
|
302
304
|
|
|
303
305
|
|
|
@@ -3,13 +3,11 @@
|
|
|
3
3
|
# This source code is licensed under the MIT license found in the
|
|
4
4
|
# LICENSE file in the root directory of this source tree.
|
|
5
5
|
import logging
|
|
6
|
-
|
|
6
|
+
from asyncio import iscoroutinefunction
|
|
7
7
|
from collections.abc import AsyncGenerator
|
|
8
8
|
|
|
9
|
-
from asyncio import iscoroutinefunction
|
|
10
9
|
from .async_utils import wait_for
|
|
11
10
|
|
|
12
|
-
|
|
13
11
|
VISITED_EDGES_KEY = "__visited_edges__"
|
|
14
12
|
|
|
15
13
|
logger = logging.getLogger("fquery")
|
|
@@ -6,6 +6,7 @@ import ast
|
|
|
6
6
|
import operator
|
|
7
7
|
|
|
8
8
|
from pypika import Query, Tables
|
|
9
|
+
|
|
9
10
|
from .visitor import Visitor
|
|
10
11
|
|
|
11
12
|
# inspired from pandas.core.computation.ops
|
|
@@ -62,7 +63,7 @@ class SQLBuilderVisitor(Visitor):
|
|
|
62
63
|
left, op, right = query._expr.value.split()
|
|
63
64
|
right = ast.literal_eval(right)
|
|
64
65
|
table, field = left.split(".") if "." in left else (self.sql, left)
|
|
65
|
-
if type(table)
|
|
66
|
+
if type(table) is str:
|
|
66
67
|
table = Tables(table)[0]
|
|
67
68
|
binary_op = _cmp_ops_dict[op]
|
|
68
69
|
self.sql_stack.append(
|
|
@@ -73,7 +74,7 @@ class SQLBuilderVisitor(Visitor):
|
|
|
73
74
|
async def visit_order_by(self, query):
|
|
74
75
|
key = query._expr.value
|
|
75
76
|
table, field = key.split(".") if "." in key else (self.sql, key)
|
|
76
|
-
if type(table)
|
|
77
|
+
if type(table) is str:
|
|
77
78
|
table = Tables(table)[0]
|
|
78
79
|
self.sql_stack.append(lambda x: x.orderby(table.__getattr__(field)))
|
|
79
80
|
await self.visit(query.child)
|