graphique 1.6__tar.gz → 1.8__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.
- {graphique-1.6 → graphique-1.8}/PKG-INFO +14 -7
- {graphique-1.6 → graphique-1.8}/README.md +8 -4
- {graphique-1.6 → graphique-1.8}/graphique/__init__.py +0 -2
- {graphique-1.6 → graphique-1.8}/graphique/core.py +124 -167
- {graphique-1.6 → graphique-1.8}/graphique/inputs.py +2 -2
- {graphique-1.6 → graphique-1.8}/graphique/interface.py +108 -159
- {graphique-1.6 → graphique-1.8}/graphique/middleware.py +7 -21
- {graphique-1.6 → graphique-1.8}/graphique/models.py +12 -22
- {graphique-1.6 → graphique-1.8}/graphique/scalars.py +18 -32
- {graphique-1.6 → graphique-1.8}/graphique/service.py +4 -0
- {graphique-1.6 → graphique-1.8}/graphique/shell.py +20 -8
- {graphique-1.6 → graphique-1.8}/graphique.egg-info/PKG-INFO +14 -7
- {graphique-1.6 → graphique-1.8}/graphique.egg-info/SOURCES.txt +1 -0
- graphique-1.8/graphique.egg-info/requires.txt +10 -0
- {graphique-1.6 → graphique-1.8}/pyproject.toml +5 -12
- graphique-1.8/requirements.in +4 -0
- graphique-1.8/tests/test_bench.py +21 -0
- {graphique-1.6 → graphique-1.8}/tests/test_core.py +31 -86
- {graphique-1.6 → graphique-1.8}/tests/test_dataset.py +13 -15
- {graphique-1.6 → graphique-1.8}/tests/test_models.py +13 -13
- {graphique-1.6 → graphique-1.8}/tests/test_service.py +1 -1
- graphique-1.6/graphique.egg-info/requires.txt +0 -8
- graphique-1.6/requirements.in +0 -2
- {graphique-1.6 → graphique-1.8}/LICENSE.txt +0 -0
- {graphique-1.6 → graphique-1.8}/graphique/py.typed +0 -0
- {graphique-1.6 → graphique-1.8}/graphique.egg-info/dependency_links.txt +0 -0
- {graphique-1.6 → graphique-1.8}/graphique.egg-info/top_level.txt +0 -0
- {graphique-1.6 → graphique-1.8}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: graphique
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8
|
|
4
4
|
Summary: GraphQL service for arrow tables and parquet data sets.
|
|
5
5
|
Author-email: Aric Coady <aric.coady@gmail.com>
|
|
6
6
|
License: Copyright 2022 Aric Coady
|
|
@@ -32,6 +32,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
32
32
|
Classifier: Programming Language :: Python :: 3.10
|
|
33
33
|
Classifier: Programming Language :: Python :: 3.11
|
|
34
34
|
Classifier: Programming Language :: Python :: 3.12
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
35
36
|
Classifier: Topic :: Database :: Database Engines/Servers
|
|
36
37
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
37
38
|
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
@@ -40,8 +41,10 @@ Classifier: Typing :: Typed
|
|
|
40
41
|
Requires-Python: >=3.9
|
|
41
42
|
Description-Content-Type: text/markdown
|
|
42
43
|
License-File: LICENSE.txt
|
|
43
|
-
Requires-Dist: pyarrow>=
|
|
44
|
-
Requires-Dist: strawberry-graphql[asgi,cli]>=0.
|
|
44
|
+
Requires-Dist: pyarrow>=18
|
|
45
|
+
Requires-Dist: strawberry-graphql[asgi,cli]>=0.236
|
|
46
|
+
Requires-Dist: numpy
|
|
47
|
+
Requires-Dist: isodate>=0.7
|
|
45
48
|
Provides-Extra: server
|
|
46
49
|
Requires-Dist: uvicorn[standard]; extra == "server"
|
|
47
50
|
Provides-Extra: cli
|
|
@@ -51,9 +54,10 @@ Requires-Dist: tqdm; extra == "cli"
|
|
|
51
54
|

|
|
52
55
|
[](https://pepy.tech/project/graphique)
|
|
53
56
|

|
|
54
|
-
[](https://github.com/coady/graphique/actions/workflows/build.yml)
|
|
55
58
|
[](https://codecov.io/gh/coady/graphique/)
|
|
56
|
-
[](https://github.com/coady/graphique/actions/workflows/github-code-scanning/codeql)
|
|
60
|
+
[](https://codspeed.io/coady/graphique)
|
|
57
61
|
[](https://github.com/astral-sh/ruff)
|
|
58
62
|
[](https://mypy-lang.org/)
|
|
59
63
|
|
|
@@ -88,8 +92,9 @@ Supply a mapping of names to datasets for multiple roots, and to enable federati
|
|
|
88
92
|
import pyarrow.dataset as ds
|
|
89
93
|
from graphique import GraphQL
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
app = GraphQL
|
|
95
|
+
source = ds.dataset(...)
|
|
96
|
+
app = GraphQL(source) # Table is root query type
|
|
97
|
+
app = GraphQL.federated({<name>: source, ...}, keys={<name>: [], ...}) # Tables on federated fields
|
|
93
98
|
```
|
|
94
99
|
|
|
95
100
|
Start like any ASGI app.
|
|
@@ -146,6 +151,8 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
146
151
|
## Dependencies
|
|
147
152
|
* pyarrow
|
|
148
153
|
* strawberry-graphql[asgi,cli]
|
|
154
|
+
* numpy
|
|
155
|
+
* isodate
|
|
149
156
|
* uvicorn (or other [ASGI server](https://asgi.readthedocs.io/en/latest/implementations.html))
|
|
150
157
|
|
|
151
158
|
## Tests
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|

|
|
3
3
|
[](https://pepy.tech/project/graphique)
|
|
4
4
|

|
|
5
|
-
[](https://github.com/coady/graphique/actions/workflows/build.yml)
|
|
6
6
|
[](https://codecov.io/gh/coady/graphique/)
|
|
7
|
-
[](https://github.com/coady/graphique/actions/workflows/github-code-scanning/codeql)
|
|
8
|
+
[](https://codspeed.io/coady/graphique)
|
|
8
9
|
[](https://github.com/astral-sh/ruff)
|
|
9
10
|
[](https://mypy-lang.org/)
|
|
10
11
|
|
|
@@ -39,8 +40,9 @@ Supply a mapping of names to datasets for multiple roots, and to enable federati
|
|
|
39
40
|
import pyarrow.dataset as ds
|
|
40
41
|
from graphique import GraphQL
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
app = GraphQL
|
|
43
|
+
source = ds.dataset(...)
|
|
44
|
+
app = GraphQL(source) # Table is root query type
|
|
45
|
+
app = GraphQL.federated({<name>: source, ...}, keys={<name>: [], ...}) # Tables on federated fields
|
|
44
46
|
```
|
|
45
47
|
|
|
46
48
|
Start like any ASGI app.
|
|
@@ -97,6 +99,8 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
97
99
|
## Dependencies
|
|
98
100
|
* pyarrow
|
|
99
101
|
* strawberry-graphql[asgi,cli]
|
|
102
|
+
* numpy
|
|
103
|
+
* isodate
|
|
100
104
|
* uvicorn (or other [ASGI server](https://asgi.readthedocs.io/en/latest/implementations.html))
|
|
101
105
|
|
|
102
106
|
## Tests
|
|
@@ -12,7 +12,7 @@ import inspect
|
|
|
12
12
|
import itertools
|
|
13
13
|
import operator
|
|
14
14
|
import json
|
|
15
|
-
from collections.abc import Callable, Iterable, Iterator,
|
|
15
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
16
16
|
from dataclasses import dataclass
|
|
17
17
|
from typing import Optional, Union, get_type_hints
|
|
18
18
|
import numpy as np
|
|
@@ -24,6 +24,8 @@ from typing_extensions import Self
|
|
|
24
24
|
|
|
25
25
|
Array = Union[pa.Array, pa.ChunkedArray]
|
|
26
26
|
Batch = Union[pa.RecordBatch, pa.Table]
|
|
27
|
+
bit_any = functools.partial(functools.reduce, operator.or_)
|
|
28
|
+
bit_all = functools.partial(functools.reduce, operator.and_)
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
class Agg:
|
|
@@ -51,28 +53,15 @@ class Agg:
|
|
|
51
53
|
'tdigest': pc.TDigestOptions,
|
|
52
54
|
'variance': pc.VarianceOptions,
|
|
53
55
|
}
|
|
54
|
-
|
|
55
|
-
associatives = {'all', 'any', 'first', 'last', 'max', 'min', 'one', 'product', 'sum'}
|
|
56
|
-
associatives |= {'count'} # transformed to be associative
|
|
56
|
+
ordered = {'first', 'last'}
|
|
57
57
|
|
|
58
58
|
def __init__(self, name: str, alias: str = '', **options):
|
|
59
59
|
self.name = name
|
|
60
60
|
self.alias = alias or name
|
|
61
61
|
self.options = options
|
|
62
62
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
return self.name, func, options, self.alias
|
|
66
|
-
|
|
67
|
-
one = operator.itemgetter(0)
|
|
68
|
-
list = staticmethod(lambda array: array)
|
|
69
|
-
|
|
70
|
-
@staticmethod
|
|
71
|
-
def distinct(array: Array, mode: str = 'only_valid') -> pa.Array:
|
|
72
|
-
values = pc.unique(array)
|
|
73
|
-
if not values.null_count or mode == 'all':
|
|
74
|
-
return values
|
|
75
|
-
return values.drop_null() if mode == 'only_valid' else pa.array([None], array.type)
|
|
63
|
+
def func_options(self, func: str) -> pc.FunctionOptions:
|
|
64
|
+
return self.option_map[func.removeprefix('hash_')](**self.options)
|
|
76
65
|
|
|
77
66
|
|
|
78
67
|
@dataclass(frozen=True)
|
|
@@ -210,8 +199,7 @@ class ListChunk(pa.lib.BaseListArray):
|
|
|
210
199
|
|
|
211
200
|
def index(self, **options) -> pa.Array:
|
|
212
201
|
"""index for first occurrence of each list scalar"""
|
|
213
|
-
|
|
214
|
-
return Column.from_scalars(values)
|
|
202
|
+
return pa.array(pc.index(value, **options) for value in ListChunk.scalars(self))
|
|
215
203
|
|
|
216
204
|
@register
|
|
217
205
|
def list_all(ctx, self: pa.list_(pa.bool_())) -> pa.bool_(): # type: ignore
|
|
@@ -227,13 +215,6 @@ class ListChunk(pa.lib.BaseListArray):
|
|
|
227
215
|
class Column(pa.ChunkedArray):
|
|
228
216
|
"""Chunked array interface as a namespace of functions."""
|
|
229
217
|
|
|
230
|
-
def from_scalars(values: Sequence) -> pa.Array:
|
|
231
|
-
"""Return array from arrow scalars."""
|
|
232
|
-
return pa.array((value.as_py() for value in values), values[0].type)
|
|
233
|
-
|
|
234
|
-
def scalar_type(self):
|
|
235
|
-
return self.type.value_type if pa.types.is_dictionary(self.type) else self.type
|
|
236
|
-
|
|
237
218
|
def is_list_type(self):
|
|
238
219
|
funcs = pa.types.is_list, pa.types.is_large_list, pa.types.is_fixed_size_list
|
|
239
220
|
return any(func(self.type) for func in funcs)
|
|
@@ -284,26 +265,6 @@ class Column(pa.ChunkedArray):
|
|
|
284
265
|
mask = predicate(Column.diff(self), *args) if args else Column.diff(self, predicate)
|
|
285
266
|
return pc.indices_nonzero(pa.chunked_array(ends + mask.chunks + ends))
|
|
286
267
|
|
|
287
|
-
def first_last(self, **options):
|
|
288
|
-
if not pa.types.is_dictionary(self.type):
|
|
289
|
-
return pc.first_last(self, **options).as_py()
|
|
290
|
-
self = self.combine_chunks()
|
|
291
|
-
indices = pc.first_last(self.indices, **options).as_py().items()
|
|
292
|
-
return {key: None if index is None else self.dictionary[index] for key, index in indices}
|
|
293
|
-
|
|
294
|
-
def min_max(self, **options):
|
|
295
|
-
if pa.types.is_dictionary(self.type):
|
|
296
|
-
self = self.unique().dictionary_decode()
|
|
297
|
-
return pc.min_max(self, **options).as_py()
|
|
298
|
-
|
|
299
|
-
def min(self, **options):
|
|
300
|
-
"""Return min of the values."""
|
|
301
|
-
return Column.min_max(self, **options)['min']
|
|
302
|
-
|
|
303
|
-
def max(self, **options):
|
|
304
|
-
"""Return max of the values."""
|
|
305
|
-
return Column.min_max(self, **options)['max']
|
|
306
|
-
|
|
307
268
|
def index(self, value, start=0, end=None) -> int:
|
|
308
269
|
"""Return the first index of a value."""
|
|
309
270
|
with contextlib.suppress(NotImplementedError):
|
|
@@ -338,10 +299,8 @@ class Column(pa.ChunkedArray):
|
|
|
338
299
|
class Table(pa.Table):
|
|
339
300
|
"""Table interface as a namespace of functions."""
|
|
340
301
|
|
|
341
|
-
def map_batch(
|
|
342
|
-
|
|
343
|
-
batches = [func(batch, *rargs, **kwargs) for batch in scanner.to_batches() if batch]
|
|
344
|
-
return pa.Table.from_batches(batches, None if batches else scanner.projected_schema)
|
|
302
|
+
def map_batch(self, func: Callable, *args, **kwargs) -> pa.Table:
|
|
303
|
+
return pa.Table.from_batches(func(batch, *args, **kwargs) for batch in self.to_batches())
|
|
345
304
|
|
|
346
305
|
def columns(self) -> dict:
|
|
347
306
|
"""Return columns as a dictionary."""
|
|
@@ -411,37 +370,6 @@ class Table(pa.Table):
|
|
|
411
370
|
table = Table.union(scalars, Table.from_offsets(lists, offsets))
|
|
412
371
|
return table, Column.diff(offsets)
|
|
413
372
|
|
|
414
|
-
def group(self, *names: str, counts: str = '', **funcs: Sequence[Agg]) -> pa.Table:
|
|
415
|
-
"""Group by and aggregate.
|
|
416
|
-
|
|
417
|
-
Args:
|
|
418
|
-
*names: columns to group by
|
|
419
|
-
counts: alias for optional row counts
|
|
420
|
-
**funcs: aggregate funcs with columns options
|
|
421
|
-
"""
|
|
422
|
-
if isinstance(self, pa.Table):
|
|
423
|
-
self = self.unify_dictionaries()
|
|
424
|
-
prefix = 'hash_' if names else ''
|
|
425
|
-
aggs = [agg.astuple(prefix + func) for func in funcs for agg in funcs[func]]
|
|
426
|
-
columns = list(names) + [agg[0] for agg in aggs]
|
|
427
|
-
if counts:
|
|
428
|
-
aggs.append(([], 'hash_count_all', None, counts))
|
|
429
|
-
return Declarations(self, columns).aggregate(aggs, names).to_table()
|
|
430
|
-
|
|
431
|
-
def aggregate(self, counts: str = '', **funcs: Sequence[Agg]) -> dict:
|
|
432
|
-
"""Return aggregated scalars as a row of data."""
|
|
433
|
-
row = {counts: len(self)} if counts else {}
|
|
434
|
-
for key in ('one', 'list', 'distinct'): # hash only functions
|
|
435
|
-
func, aggs = getattr(Agg, key), funcs.pop(key, [])
|
|
436
|
-
row |= {agg.alias: func(self[agg.name], **agg.options) for agg in aggs}
|
|
437
|
-
if funcs:
|
|
438
|
-
table = Table.group(self, **funcs) # type: ignore
|
|
439
|
-
row |= {name: table[name][0] for name in table.schema.names}
|
|
440
|
-
for name, value in row.items():
|
|
441
|
-
if isinstance(value, pa.ChunkedArray):
|
|
442
|
-
row[name] = value.combine_chunks()
|
|
443
|
-
return row
|
|
444
|
-
|
|
445
373
|
def list_fields(self) -> set:
|
|
446
374
|
return {field.name for field in self.schema if Column.is_list_type(field)}
|
|
447
375
|
|
|
@@ -488,7 +416,7 @@ class Table(pa.Table):
|
|
|
488
416
|
indices: include original indices in the table
|
|
489
417
|
"""
|
|
490
418
|
if length == 1 and not indices:
|
|
491
|
-
return Table.
|
|
419
|
+
return Table.min_max(self, *names)[:1]
|
|
492
420
|
indices_ = Table.sort_indices(self, *names, length=length, null_placement=null_placement)
|
|
493
421
|
table = self.take(indices_)
|
|
494
422
|
if indices:
|
|
@@ -508,49 +436,47 @@ class Table(pa.Table):
|
|
|
508
436
|
table = pa.concat_tables(table for table in tables if table is not None)
|
|
509
437
|
return Table.union(self, Table.from_counts(table, counts))
|
|
510
438
|
|
|
511
|
-
def
|
|
512
|
-
"""Return table filtered by
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
value = func(self[name], skip_nulls=null_placement == 'at_end')
|
|
518
|
-
if value is None:
|
|
519
|
-
mask = (pc.field(name) if expr else self[name]).is_null()
|
|
520
|
-
else:
|
|
521
|
-
mask = pc.field(name) == value if expr else pc.equal(self[name], value)
|
|
522
|
-
self = self.filter(mask)
|
|
523
|
-
return self
|
|
524
|
-
values = None
|
|
525
|
-
kwargs = dict(tiebreaker='dense', null_placement=null_placement)
|
|
526
|
-
for name, order in map(sort_key, names):
|
|
527
|
-
if len(self) <= k:
|
|
528
|
-
break
|
|
529
|
-
ranks = pc.rank(Column.sort_values(self[name]), order, **kwargs)
|
|
530
|
-
if values is None: # optimized to sort only once on first iteration
|
|
531
|
-
values = ranks
|
|
532
|
-
else:
|
|
533
|
-
values = pc.add_checked(pc.multiply_checked(values, pc.max(ranks)), ranks)
|
|
534
|
-
values = pc.rank(values, 'ascending', tiebreaker='dense')
|
|
535
|
-
mask = pc.less_equal(values, k)
|
|
536
|
-
self = self.filter(mask)
|
|
537
|
-
values = values.filter(mask)
|
|
439
|
+
def min_max(self, *names: str) -> Self:
|
|
440
|
+
"""Return table filtered by minimum or maximum values."""
|
|
441
|
+
for key, order in map(sort_key, names):
|
|
442
|
+
field, asc = pc.field(key), (order == 'ascending')
|
|
443
|
+
((value,),) = Nodes.group(self, _=(key, ('min' if asc else 'max'), None)).to_table()
|
|
444
|
+
self = self.filter(field <= value if asc else field >= value)
|
|
538
445
|
return self
|
|
539
446
|
|
|
540
|
-
def
|
|
541
|
-
"""
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
447
|
+
def rank(self, k: int, *names: str) -> Self:
|
|
448
|
+
"""Return table filtered by values within dense rank, similar to `select_k_unstable`."""
|
|
449
|
+
if k == 1:
|
|
450
|
+
return Table.min_max(self, *names)
|
|
451
|
+
keys = dict(map(sort_key, names))
|
|
452
|
+
table = Nodes.group(self, *keys).to_table()
|
|
453
|
+
table = table.take(pc.select_k_unstable(table, k, keys.items()))
|
|
454
|
+
exprs = []
|
|
455
|
+
for key, order in keys.items():
|
|
456
|
+
field, asc = pc.field(key), (order == 'ascending')
|
|
457
|
+
exprs.append(field <= pc.max(table[key]) if asc else field >= pc.min(table[key]))
|
|
458
|
+
return self.filter(bit_all(exprs))
|
|
459
|
+
|
|
460
|
+
def fragments(self, *names, counts: str = '') -> pa.Table:
|
|
461
|
+
"""Return selected fragment keys in a table."""
|
|
549
462
|
try:
|
|
550
|
-
|
|
463
|
+
expr = self._scan_options.get('filter')
|
|
464
|
+
if expr is not None: # raise ValueError if filter references other fields
|
|
465
|
+
ds.dataset([], schema=self.partitioning.schema).scanner(filter=expr)
|
|
551
466
|
except (AttributeError, ValueError):
|
|
552
|
-
return
|
|
553
|
-
|
|
467
|
+
return pa.table({})
|
|
468
|
+
fragments = self._get_fragments(expr)
|
|
469
|
+
parts = [ds.get_partition_keys(frag.partition_expression) for frag in fragments]
|
|
470
|
+
names, table = set(names), pa.Table.from_pylist(parts) # type: ignore
|
|
471
|
+
keys = [name for name in table.schema.names if name in names]
|
|
472
|
+
table = table.group_by(keys, use_threads=False).aggregate([])
|
|
473
|
+
if not counts:
|
|
474
|
+
return table
|
|
475
|
+
if not table.schema:
|
|
476
|
+
return table.append_column(counts, pa.array([self.count_rows()]))
|
|
477
|
+
exprs = [bit_all(pc.field(key) == row[key] for key in row) for row in table.to_pylist()]
|
|
478
|
+
column = [self.filter(expr).count_rows() for expr in exprs]
|
|
479
|
+
return table.append_column(counts, pa.array(column))
|
|
554
480
|
|
|
555
481
|
def rank_keys(self, k: int, *names: str, dense: bool = True) -> tuple:
|
|
556
482
|
"""Return expression and unmatched fields for partitioned dataset which filters by rank.
|
|
@@ -560,39 +486,24 @@ class Table(pa.Table):
|
|
|
560
486
|
*names: columns to rank by
|
|
561
487
|
dense: use dense rank; false indicates sorting
|
|
562
488
|
"""
|
|
563
|
-
|
|
564
|
-
|
|
489
|
+
keys = dict(map(sort_key, names))
|
|
490
|
+
table = Table.fragments(self, *keys, counts='' if dense else '_')
|
|
491
|
+
keys = {name: keys[name] for name in table.schema.names if name in keys}
|
|
565
492
|
if not keys:
|
|
566
493
|
return None, names
|
|
567
|
-
parts = []
|
|
568
|
-
for fragment in Table.get_fragments(self):
|
|
569
|
-
parts.append(ds.get_partition_keys(fragment.partition_expression))
|
|
570
|
-
if not dense:
|
|
571
|
-
parts[-1][''] = fragment.count_rows()
|
|
572
|
-
offset, table = len(keys), pa.Table.from_pylist(parts)
|
|
573
494
|
if dense:
|
|
574
|
-
table =
|
|
495
|
+
table = table.take(pc.select_k_unstable(table, k, keys.items()))
|
|
575
496
|
else:
|
|
576
|
-
table =
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
497
|
+
table = table.sort_by(keys.items())
|
|
498
|
+
totals = itertools.accumulate(table['_'].to_pylist())
|
|
499
|
+
counts = (count for count, total in enumerate(totals, 1) if total >= k)
|
|
500
|
+
table = table[: next(counts, None)].remove_column(len(table) - 1)
|
|
501
|
+
exprs = [bit_all(pc.field(key) == row[key] for key in row) for row in table.to_pylist()]
|
|
502
|
+
remaining = names[len(keys) :]
|
|
580
503
|
if remaining or not dense: # fields with a single value are no longer needed
|
|
581
|
-
selectors = [len(table[
|
|
504
|
+
selectors = [len(table[key].unique()) > 1 for key in keys]
|
|
582
505
|
remaining = tuple(itertools.compress(names, selectors)) + remaining
|
|
583
|
-
|
|
584
|
-
for index, (name, order) in enumerate(keys.items()):
|
|
585
|
-
expr, field, values = None, ds.field(name), table[name].unique()
|
|
586
|
-
value = pc.max(values) if order == 'ascending' else pc.min(values)
|
|
587
|
-
if index == len(keys) - 1: # last one
|
|
588
|
-
expr = field <= value if order == 'ascending' else field >= value
|
|
589
|
-
elif len(values) > 1:
|
|
590
|
-
expr = field < value if order == 'ascending' else field > value
|
|
591
|
-
if expr is not None:
|
|
592
|
-
fields.append(functools.reduce(operator.and_, prefix + [expr]))
|
|
593
|
-
prefix.append(field == value)
|
|
594
|
-
table = table.filter(prefix[-1])
|
|
595
|
-
return functools.reduce(operator.or_, fields), remaining
|
|
506
|
+
return bit_any(exprs[: len(table)]), remaining
|
|
596
507
|
|
|
597
508
|
def flatten(self, indices: str = '') -> Iterator[pa.RecordBatch]:
|
|
598
509
|
"""Generate batches with list arrays flattened, optionally with parent indices."""
|
|
@@ -630,8 +541,11 @@ class Table(pa.Table):
|
|
|
630
541
|
return f'{size:n} {prefix}B'
|
|
631
542
|
|
|
632
543
|
|
|
633
|
-
class
|
|
634
|
-
"""[Acero](https://arrow.apache.org/docs/python/api/acero.html) engine
|
|
544
|
+
class Nodes(ac.Declaration):
|
|
545
|
+
"""[Acero](https://arrow.apache.org/docs/python/api/acero.html) engine declaration.
|
|
546
|
+
|
|
547
|
+
Provides a `Scanner` interface with no "oneshot" limitation.
|
|
548
|
+
"""
|
|
635
549
|
|
|
636
550
|
option_map = {
|
|
637
551
|
'table_source': ac.TableSourceNodeOptions,
|
|
@@ -642,22 +556,65 @@ class Declarations(list):
|
|
|
642
556
|
'order_by': ac.OrderByNodeOptions,
|
|
643
557
|
'hashjoin': ac.HashJoinNodeOptions,
|
|
644
558
|
}
|
|
559
|
+
to_batches = ac.Declaration.to_reader # source compatibility
|
|
645
560
|
|
|
646
|
-
def __init__(self,
|
|
647
|
-
|
|
648
|
-
expr = source._scan_options.get('filter')
|
|
649
|
-
self.scan(source, filter=expr, columns=columns)
|
|
650
|
-
if expr is not None:
|
|
651
|
-
self.filter(expr)
|
|
652
|
-
else:
|
|
653
|
-
self.table_source(source)
|
|
654
|
-
|
|
655
|
-
def apply(self, name: str, *args, **kwargs) -> Self:
|
|
656
|
-
self.append(ac.Declaration(name, self.option_map[name](*args, **kwargs)))
|
|
657
|
-
return self
|
|
561
|
+
def __init__(self, name, *args, inputs=None, **options):
|
|
562
|
+
super().__init__(name, self.option_map[name](*args, **options), inputs)
|
|
658
563
|
|
|
659
|
-
def
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
564
|
+
def scan(self, columns: Iterable[str]) -> Self:
|
|
565
|
+
"""Return projected source node, supporting datasets and tables."""
|
|
566
|
+
if isinstance(self, ds.Dataset):
|
|
567
|
+
expr = self._scan_options.get('filter')
|
|
568
|
+
self = Nodes('scan', self, columns=columns)
|
|
569
|
+
if expr is not None:
|
|
570
|
+
self = self.apply('filter', expr)
|
|
571
|
+
elif isinstance(self, pa.Table):
|
|
572
|
+
self = Nodes('table_source', self)
|
|
573
|
+
elif isinstance(self, pa.RecordBatch):
|
|
574
|
+
self = Nodes('table_source', pa.table(self))
|
|
575
|
+
if isinstance(columns, Mapping):
|
|
576
|
+
return self.apply('project', columns.values(), columns)
|
|
577
|
+
return self.apply('project', map(pc.field, columns))
|
|
578
|
+
|
|
579
|
+
@property
|
|
580
|
+
def schema(self) -> pa.Schema:
|
|
581
|
+
"""projected schema"""
|
|
582
|
+
with self.to_reader() as reader:
|
|
583
|
+
return reader.schema
|
|
584
|
+
|
|
585
|
+
def scanner(self, **options) -> ds.Scanner:
|
|
586
|
+
return ds.Scanner.from_batches(self.to_reader(**options))
|
|
587
|
+
|
|
588
|
+
def count_rows(self) -> int:
|
|
589
|
+
"""Count matching rows."""
|
|
590
|
+
return self.scanner().count_rows()
|
|
591
|
+
|
|
592
|
+
def head(self, num_rows: int, **options) -> pa.Table:
|
|
593
|
+
"""Load the first N rows."""
|
|
594
|
+
return self.scanner(**options).head(num_rows)
|
|
595
|
+
|
|
596
|
+
def take(self, indices: Iterable[int], **options) -> pa.Table:
|
|
597
|
+
"""Select rows by index."""
|
|
598
|
+
return self.scanner(**options).take(indices)
|
|
599
|
+
|
|
600
|
+
def apply(self, name: str, *args, **options) -> Self:
|
|
601
|
+
"""Add a node by name."""
|
|
602
|
+
return type(self)(name, *args, inputs=[self], **options)
|
|
603
|
+
|
|
604
|
+
filter = functools.partialmethod(apply, 'filter')
|
|
605
|
+
|
|
606
|
+
def group(self, *names, **aggs: tuple) -> Self:
|
|
607
|
+
"""Add `aggregate` node with dictionary support.
|
|
608
|
+
|
|
609
|
+
Also supports datasets because aggregation determines the projection.
|
|
610
|
+
"""
|
|
611
|
+
aggregates, targets = [], set(names)
|
|
612
|
+
for name, (target, _, _) in aggs.items():
|
|
613
|
+
aggregates.append(aggs[name] + (name,))
|
|
614
|
+
targets.update([target] if isinstance(target, str) else target)
|
|
615
|
+
columns = {name: pc.field(name) for name in targets}
|
|
616
|
+
for name in columns:
|
|
617
|
+
field = self.schema.field(name)
|
|
618
|
+
if pa.types.is_dictionary(field.type):
|
|
619
|
+
columns[name] = columns[name].cast(field.type.value_type)
|
|
620
|
+
return Nodes.scan(self, columns).apply('aggregate', aggregates, names)
|
|
@@ -15,9 +15,9 @@ import pyarrow.dataset as ds
|
|
|
15
15
|
import strawberry
|
|
16
16
|
from strawberry import UNSET
|
|
17
17
|
from strawberry.annotation import StrawberryAnnotation
|
|
18
|
-
from strawberry.arguments import StrawberryArgument
|
|
18
|
+
from strawberry.types.arguments import StrawberryArgument
|
|
19
19
|
from strawberry.schema_directive import Location
|
|
20
|
-
from strawberry.field import StrawberryField
|
|
20
|
+
from strawberry.types.field import StrawberryField
|
|
21
21
|
from strawberry.scalars import JSON
|
|
22
22
|
from typing_extensions import Self
|
|
23
23
|
from .core import Agg
|