graphique 1.7__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.7 → graphique-1.8}/PKG-INFO +10 -4
- {graphique-1.7 → graphique-1.8}/README.md +5 -2
- {graphique-1.7 → graphique-1.8}/graphique/core.py +117 -165
- {graphique-1.7 → graphique-1.8}/graphique/interface.py +106 -157
- {graphique-1.7 → graphique-1.8}/graphique/middleware.py +7 -21
- {graphique-1.7 → graphique-1.8}/graphique/models.py +11 -21
- {graphique-1.7 → graphique-1.8}/graphique/scalars.py +17 -37
- {graphique-1.7 → graphique-1.8}/graphique/service.py +4 -0
- {graphique-1.7 → graphique-1.8}/graphique/shell.py +9 -6
- {graphique-1.7 → graphique-1.8}/graphique.egg-info/PKG-INFO +10 -4
- {graphique-1.7 → graphique-1.8}/graphique.egg-info/requires.txt +3 -1
- {graphique-1.7 → graphique-1.8}/pyproject.toml +3 -3
- {graphique-1.7 → graphique-1.8}/requirements.in +3 -1
- {graphique-1.7 → graphique-1.8}/tests/test_bench.py +4 -4
- {graphique-1.7 → graphique-1.8}/tests/test_core.py +23 -88
- {graphique-1.7 → graphique-1.8}/tests/test_dataset.py +13 -15
- {graphique-1.7 → graphique-1.8}/tests/test_models.py +13 -11
- {graphique-1.7 → graphique-1.8}/tests/test_service.py +1 -1
- {graphique-1.7 → graphique-1.8}/LICENSE.txt +0 -0
- {graphique-1.7 → graphique-1.8}/graphique/__init__.py +0 -0
- {graphique-1.7 → graphique-1.8}/graphique/inputs.py +0 -0
- {graphique-1.7 → graphique-1.8}/graphique/py.typed +0 -0
- {graphique-1.7 → graphique-1.8}/graphique.egg-info/SOURCES.txt +0 -0
- {graphique-1.7 → graphique-1.8}/graphique.egg-info/dependency_links.txt +0 -0
- {graphique-1.7 → graphique-1.8}/graphique.egg-info/top_level.txt +0 -0
- {graphique-1.7 → 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: pyarrow>=18
|
|
44
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
|
|
@@ -89,8 +92,9 @@ Supply a mapping of names to datasets for multiple roots, and to enable federati
|
|
|
89
92
|
import pyarrow.dataset as ds
|
|
90
93
|
from graphique import GraphQL
|
|
91
94
|
|
|
92
|
-
|
|
93
|
-
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
|
|
94
98
|
```
|
|
95
99
|
|
|
96
100
|
Start like any ASGI app.
|
|
@@ -147,6 +151,8 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
147
151
|
## Dependencies
|
|
148
152
|
* pyarrow
|
|
149
153
|
* strawberry-graphql[asgi,cli]
|
|
154
|
+
* numpy
|
|
155
|
+
* isodate
|
|
150
156
|
* uvicorn (or other [ASGI server](https://asgi.readthedocs.io/en/latest/implementations.html))
|
|
151
157
|
|
|
152
158
|
## Tests
|
|
@@ -40,8 +40,9 @@ Supply a mapping of names to datasets for multiple roots, and to enable federati
|
|
|
40
40
|
import pyarrow.dataset as ds
|
|
41
41
|
from graphique import GraphQL
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
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
|
|
45
46
|
```
|
|
46
47
|
|
|
47
48
|
Start like any ASGI app.
|
|
@@ -98,6 +99,8 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
98
99
|
## Dependencies
|
|
99
100
|
* pyarrow
|
|
100
101
|
* strawberry-graphql[asgi,cli]
|
|
102
|
+
* numpy
|
|
103
|
+
* isodate
|
|
101
104
|
* uvicorn (or other [ASGI server](https://asgi.readthedocs.io/en/latest/implementations.html))
|
|
102
105
|
|
|
103
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, Mapping
|
|
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,39 +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
|
-
prefix = 'hash_' if names else ''
|
|
423
|
-
aggs = [agg.astuple(prefix + func) for func in funcs for agg in funcs[func]]
|
|
424
|
-
columns = list(names) + [agg[0] for agg in aggs]
|
|
425
|
-
if counts:
|
|
426
|
-
aggs.append(([], 'hash_count_all', None, counts))
|
|
427
|
-
if isinstance(self, pa.Table):
|
|
428
|
-
decl = Declaration('table_source', self.unify_dictionaries())
|
|
429
|
-
else:
|
|
430
|
-
decl = Declaration.scan(self, columns=columns)
|
|
431
|
-
return decl.aggregate(aggs, names).to_table(use_threads=False)
|
|
432
|
-
|
|
433
|
-
def aggregate(self, counts: str = '', **funcs: Sequence[Agg]) -> dict:
|
|
434
|
-
"""Return aggregated scalars as a row of data."""
|
|
435
|
-
row = {counts: len(self)} if counts else {}
|
|
436
|
-
for key in ('one', 'list', 'distinct'): # hash only functions
|
|
437
|
-
func, aggs = getattr(Agg, key), funcs.pop(key, [])
|
|
438
|
-
row |= {agg.alias: func(self[agg.name], **agg.options) for agg in aggs}
|
|
439
|
-
if funcs:
|
|
440
|
-
table = Table.group(self, **funcs) # type: ignore
|
|
441
|
-
row |= {name: table[name][0] for name in table.schema.names}
|
|
442
|
-
for name, value in row.items():
|
|
443
|
-
if isinstance(value, pa.ChunkedArray):
|
|
444
|
-
row[name] = value.combine_chunks()
|
|
445
|
-
return row
|
|
446
|
-
|
|
447
373
|
def list_fields(self) -> set:
|
|
448
374
|
return {field.name for field in self.schema if Column.is_list_type(field)}
|
|
449
375
|
|
|
@@ -490,7 +416,7 @@ class Table(pa.Table):
|
|
|
490
416
|
indices: include original indices in the table
|
|
491
417
|
"""
|
|
492
418
|
if length == 1 and not indices:
|
|
493
|
-
return Table.
|
|
419
|
+
return Table.min_max(self, *names)[:1]
|
|
494
420
|
indices_ = Table.sort_indices(self, *names, length=length, null_placement=null_placement)
|
|
495
421
|
table = self.take(indices_)
|
|
496
422
|
if indices:
|
|
@@ -510,49 +436,47 @@ class Table(pa.Table):
|
|
|
510
436
|
table = pa.concat_tables(table for table in tables if table is not None)
|
|
511
437
|
return Table.union(self, Table.from_counts(table, counts))
|
|
512
438
|
|
|
513
|
-
def
|
|
514
|
-
"""Return table filtered by
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
value = func(self[name], skip_nulls=null_placement == 'at_end')
|
|
520
|
-
if value is None:
|
|
521
|
-
mask = (pc.field(name) if expr else self[name]).is_null()
|
|
522
|
-
else:
|
|
523
|
-
mask = pc.field(name) == value if expr else pc.equal(self[name], value)
|
|
524
|
-
self = self.filter(mask)
|
|
525
|
-
return self
|
|
526
|
-
values = None
|
|
527
|
-
kwargs = dict(tiebreaker='dense', null_placement=null_placement)
|
|
528
|
-
for name, order in map(sort_key, names):
|
|
529
|
-
if len(self) <= k:
|
|
530
|
-
break
|
|
531
|
-
ranks = pc.rank(Column.sort_values(self[name]), order, **kwargs)
|
|
532
|
-
if values is None: # optimized to sort only once on first iteration
|
|
533
|
-
values = ranks
|
|
534
|
-
else:
|
|
535
|
-
values = pc.add_checked(pc.multiply_checked(values, pc.max(ranks)), ranks)
|
|
536
|
-
values = pc.rank(values, 'ascending', tiebreaker='dense')
|
|
537
|
-
mask = pc.less_equal(values, k)
|
|
538
|
-
self = self.filter(mask)
|
|
539
|
-
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)
|
|
540
445
|
return self
|
|
541
446
|
|
|
542
|
-
def
|
|
543
|
-
"""
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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."""
|
|
551
462
|
try:
|
|
552
|
-
|
|
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)
|
|
553
466
|
except (AttributeError, ValueError):
|
|
554
|
-
return
|
|
555
|
-
|
|
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))
|
|
556
480
|
|
|
557
481
|
def rank_keys(self, k: int, *names: str, dense: bool = True) -> tuple:
|
|
558
482
|
"""Return expression and unmatched fields for partitioned dataset which filters by rank.
|
|
@@ -562,39 +486,24 @@ class Table(pa.Table):
|
|
|
562
486
|
*names: columns to rank by
|
|
563
487
|
dense: use dense rank; false indicates sorting
|
|
564
488
|
"""
|
|
565
|
-
|
|
566
|
-
|
|
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}
|
|
567
492
|
if not keys:
|
|
568
493
|
return None, names
|
|
569
|
-
parts = []
|
|
570
|
-
for fragment in Table.get_fragments(self):
|
|
571
|
-
parts.append(ds.get_partition_keys(fragment.partition_expression))
|
|
572
|
-
if not dense:
|
|
573
|
-
parts[-1][''] = fragment.count_rows()
|
|
574
|
-
offset, table = len(keys), pa.Table.from_pylist(parts)
|
|
575
494
|
if dense:
|
|
576
|
-
table =
|
|
495
|
+
table = table.take(pc.select_k_unstable(table, k, keys.items()))
|
|
577
496
|
else:
|
|
578
|
-
table =
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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) :]
|
|
582
503
|
if remaining or not dense: # fields with a single value are no longer needed
|
|
583
|
-
selectors = [len(table[
|
|
504
|
+
selectors = [len(table[key].unique()) > 1 for key in keys]
|
|
584
505
|
remaining = tuple(itertools.compress(names, selectors)) + remaining
|
|
585
|
-
|
|
586
|
-
for index, (name, order) in enumerate(keys.items()):
|
|
587
|
-
expr, field, values = None, ds.field(name), table[name].unique()
|
|
588
|
-
value = pc.max(values) if order == 'ascending' else pc.min(values)
|
|
589
|
-
if index == len(keys) - 1: # last one
|
|
590
|
-
expr = field <= value if order == 'ascending' else field >= value
|
|
591
|
-
elif len(values) > 1:
|
|
592
|
-
expr = field < value if order == 'ascending' else field > value
|
|
593
|
-
if expr is not None:
|
|
594
|
-
fields.append(functools.reduce(operator.and_, prefix + [expr]))
|
|
595
|
-
prefix.append(field == value)
|
|
596
|
-
table = table.filter(prefix[-1])
|
|
597
|
-
return functools.reduce(operator.or_, fields), remaining
|
|
506
|
+
return bit_any(exprs[: len(table)]), remaining
|
|
598
507
|
|
|
599
508
|
def flatten(self, indices: str = '') -> Iterator[pa.RecordBatch]:
|
|
600
509
|
"""Generate batches with list arrays flattened, optionally with parent indices."""
|
|
@@ -632,8 +541,11 @@ class Table(pa.Table):
|
|
|
632
541
|
return f'{size:n} {prefix}B'
|
|
633
542
|
|
|
634
543
|
|
|
635
|
-
class
|
|
636
|
-
"""[Acero](https://arrow.apache.org/docs/python/api/acero.html) engine declaration.
|
|
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
|
+
"""
|
|
637
549
|
|
|
638
550
|
option_map = {
|
|
639
551
|
'table_source': ac.TableSourceNodeOptions,
|
|
@@ -644,25 +556,65 @@ class Declaration(ac.Declaration):
|
|
|
644
556
|
'order_by': ac.OrderByNodeOptions,
|
|
645
557
|
'hashjoin': ac.HashJoinNodeOptions,
|
|
646
558
|
}
|
|
559
|
+
to_batches = ac.Declaration.to_reader # source compatibility
|
|
647
560
|
|
|
648
561
|
def __init__(self, name, *args, inputs=None, **options):
|
|
649
562
|
super().__init__(name, self.option_map[name](*args, **options), inputs)
|
|
650
563
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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))
|
|
658
575
|
if isinstance(columns, Mapping):
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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)
|
|
663
599
|
|
|
664
600
|
def apply(self, name: str, *args, **options) -> Self:
|
|
601
|
+
"""Add a node by name."""
|
|
665
602
|
return type(self)(name, *args, inputs=[self], **options)
|
|
666
603
|
|
|
667
|
-
|
|
668
|
-
|
|
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)
|