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.
Files changed (26) hide show
  1. {graphique-1.7 → graphique-1.8}/PKG-INFO +10 -4
  2. {graphique-1.7 → graphique-1.8}/README.md +5 -2
  3. {graphique-1.7 → graphique-1.8}/graphique/core.py +117 -165
  4. {graphique-1.7 → graphique-1.8}/graphique/interface.py +106 -157
  5. {graphique-1.7 → graphique-1.8}/graphique/middleware.py +7 -21
  6. {graphique-1.7 → graphique-1.8}/graphique/models.py +11 -21
  7. {graphique-1.7 → graphique-1.8}/graphique/scalars.py +17 -37
  8. {graphique-1.7 → graphique-1.8}/graphique/service.py +4 -0
  9. {graphique-1.7 → graphique-1.8}/graphique/shell.py +9 -6
  10. {graphique-1.7 → graphique-1.8}/graphique.egg-info/PKG-INFO +10 -4
  11. {graphique-1.7 → graphique-1.8}/graphique.egg-info/requires.txt +3 -1
  12. {graphique-1.7 → graphique-1.8}/pyproject.toml +3 -3
  13. {graphique-1.7 → graphique-1.8}/requirements.in +3 -1
  14. {graphique-1.7 → graphique-1.8}/tests/test_bench.py +4 -4
  15. {graphique-1.7 → graphique-1.8}/tests/test_core.py +23 -88
  16. {graphique-1.7 → graphique-1.8}/tests/test_dataset.py +13 -15
  17. {graphique-1.7 → graphique-1.8}/tests/test_models.py +13 -11
  18. {graphique-1.7 → graphique-1.8}/tests/test_service.py +1 -1
  19. {graphique-1.7 → graphique-1.8}/LICENSE.txt +0 -0
  20. {graphique-1.7 → graphique-1.8}/graphique/__init__.py +0 -0
  21. {graphique-1.7 → graphique-1.8}/graphique/inputs.py +0 -0
  22. {graphique-1.7 → graphique-1.8}/graphique/py.typed +0 -0
  23. {graphique-1.7 → graphique-1.8}/graphique.egg-info/SOURCES.txt +0 -0
  24. {graphique-1.7 → graphique-1.8}/graphique.egg-info/dependency_links.txt +0 -0
  25. {graphique-1.7 → graphique-1.8}/graphique.egg-info/top_level.txt +0 -0
  26. {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.7
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>=17
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
- app = GraphQL(ds.dataset(...)) # Table is root query type
93
- app = GraphQL.federated({<name>: ds.dataset(...), ...}, keys={...}) # Tables on federated fields
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
- app = GraphQL(ds.dataset(...)) # Table is root query type
44
- app = GraphQL.federated({<name>: ds.dataset(...), ...}, keys={...}) # Tables on federated fields
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, Sequence
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 astuple(self, func: str) -> tuple:
64
- options = self.option_map[func.rpartition('hash_')[-1]](**self.options)
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
- values = [pc.index(value, **options) for value in ListChunk.scalars(self)]
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(scanner: ds.Scanner, func: Callable, *rargs, **kwargs) -> pa.Table:
342
- # TODO(apache/arrow#31612): replace with user defined function for multiple kernels
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.ranked(self, 1, *names, null_placement=null_placement)[:1]
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 ranked(self, k: int, *names: str, null_placement: str = 'at_end') -> Batch:
514
- """Return table filtered by values within dense rank, similar to `select_k_unstable`."""
515
- if k == 1: # optimized for min_max
516
- expr = isinstance(self, pa.Table)
517
- for name, order in map(sort_key, names):
518
- func = Column.min if order == 'ascending' else Column.max
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 get_fragments(self) -> Iterator[ds.Fragment]:
543
- """Support filtered datasets if it only references partition keys."""
544
- expr = self._scan_options.get('filter')
545
- if expr is not None: # raise ValueError if filter references other fields
546
- ds.dataset([], schema=self.partitioning.schema).scanner(filter=expr)
547
- return self._get_fragments(expr)
548
-
549
- def fragment_keys(self) -> list:
550
- """Filtered partitioned datasets may not have fragments."""
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
- Table.get_fragments(self)
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
- return self.partitioning.schema.names
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
- schema = set(Table.fragment_keys(self))
566
- keys = dict(itertools.takewhile(lambda key: key[0] in schema, map(sort_key, names)))
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 = Table.ranked(table, k, *names[:offset])
495
+ table = table.take(pc.select_k_unstable(table, k, keys.items()))
577
496
  else:
578
- table = Table.sort(table, *names[:offset])
579
- index = pc.index(pc.greater_equal(pc.cumulative_sum(table['']), k), True).as_py()
580
- table = table[: index + 1 if index >= 0 else None]
581
- remaining = names[offset:]
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[name].unique()) > 1 for name in keys]
504
+ selectors = [len(table[key].unique()) > 1 for key in keys]
584
505
  remaining = tuple(itertools.compress(names, selectors)) + remaining
585
- fields, prefix = [], [] # type: ignore
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 Declaration(ac.Declaration):
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
- @classmethod
652
- def scan(cls, dataset: ds.Dataset, columns=None) -> Self:
653
- """Return source node from a dataset."""
654
- expr = dataset._scan_options.get('filter')
655
- self = cls('scan', dataset, filter=expr, columns=columns)
656
- if expr is not None:
657
- self = self.filter(expr)
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
- self = self.project(columns.values(), columns)
660
- elif columns is not None:
661
- self = self.project(map(pc.field, columns))
662
- return self
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
- def __getattr__(self, name: str) -> Callable:
668
- return functools.partial(self.apply, name)
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)