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.
Files changed (28) hide show
  1. {graphique-1.6 → graphique-1.8}/PKG-INFO +14 -7
  2. {graphique-1.6 → graphique-1.8}/README.md +8 -4
  3. {graphique-1.6 → graphique-1.8}/graphique/__init__.py +0 -2
  4. {graphique-1.6 → graphique-1.8}/graphique/core.py +124 -167
  5. {graphique-1.6 → graphique-1.8}/graphique/inputs.py +2 -2
  6. {graphique-1.6 → graphique-1.8}/graphique/interface.py +108 -159
  7. {graphique-1.6 → graphique-1.8}/graphique/middleware.py +7 -21
  8. {graphique-1.6 → graphique-1.8}/graphique/models.py +12 -22
  9. {graphique-1.6 → graphique-1.8}/graphique/scalars.py +18 -32
  10. {graphique-1.6 → graphique-1.8}/graphique/service.py +4 -0
  11. {graphique-1.6 → graphique-1.8}/graphique/shell.py +20 -8
  12. {graphique-1.6 → graphique-1.8}/graphique.egg-info/PKG-INFO +14 -7
  13. {graphique-1.6 → graphique-1.8}/graphique.egg-info/SOURCES.txt +1 -0
  14. graphique-1.8/graphique.egg-info/requires.txt +10 -0
  15. {graphique-1.6 → graphique-1.8}/pyproject.toml +5 -12
  16. graphique-1.8/requirements.in +4 -0
  17. graphique-1.8/tests/test_bench.py +21 -0
  18. {graphique-1.6 → graphique-1.8}/tests/test_core.py +31 -86
  19. {graphique-1.6 → graphique-1.8}/tests/test_dataset.py +13 -15
  20. {graphique-1.6 → graphique-1.8}/tests/test_models.py +13 -13
  21. {graphique-1.6 → graphique-1.8}/tests/test_service.py +1 -1
  22. graphique-1.6/graphique.egg-info/requires.txt +0 -8
  23. graphique-1.6/requirements.in +0 -2
  24. {graphique-1.6 → graphique-1.8}/LICENSE.txt +0 -0
  25. {graphique-1.6 → graphique-1.8}/graphique/py.typed +0 -0
  26. {graphique-1.6 → graphique-1.8}/graphique.egg-info/dependency_links.txt +0 -0
  27. {graphique-1.6 → graphique-1.8}/graphique.egg-info/top_level.txt +0 -0
  28. {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.6
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>=16
44
- Requires-Dist: strawberry-graphql[asgi,cli]>=0.221
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
  ![image](https://img.shields.io/pypi/pyversions/graphique.svg)
52
55
  [![image](https://pepy.tech/badge/graphique)](https://pepy.tech/project/graphique)
53
56
  ![image](https://img.shields.io/pypi/status/graphique.svg)
54
- [![image](https://github.com/coady/graphique/workflows/build/badge.svg)](https://github.com/coady/graphique/actions)
57
+ [![build](https://github.com/coady/graphique/actions/workflows/build.yml/badge.svg)](https://github.com/coady/graphique/actions/workflows/build.yml)
55
58
  [![image](https://codecov.io/gh/coady/graphique/branch/main/graph/badge.svg)](https://codecov.io/gh/coady/graphique/)
56
- [![image](https://github.com/coady/graphique/workflows/codeql/badge.svg)](https://github.com/coady/graphique/security/code-scanning)
59
+ [![CodeQL](https://github.com/coady/graphique/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/coady/graphique/actions/workflows/github-code-scanning/codeql)
60
+ [![CodSpeed Badge](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/coady/graphique)
57
61
  [![image](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
58
62
  [![image](https://mypy-lang.org/static/mypy_badge.svg)](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
- app = GraphQL(ds.dataset(...)) # Table is root query type
92
- 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
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
  ![image](https://img.shields.io/pypi/pyversions/graphique.svg)
3
3
  [![image](https://pepy.tech/badge/graphique)](https://pepy.tech/project/graphique)
4
4
  ![image](https://img.shields.io/pypi/status/graphique.svg)
5
- [![image](https://github.com/coady/graphique/workflows/build/badge.svg)](https://github.com/coady/graphique/actions)
5
+ [![build](https://github.com/coady/graphique/actions/workflows/build.yml/badge.svg)](https://github.com/coady/graphique/actions/workflows/build.yml)
6
6
  [![image](https://codecov.io/gh/coady/graphique/branch/main/graph/badge.svg)](https://codecov.io/gh/coady/graphique/)
7
- [![image](https://github.com/coady/graphique/workflows/codeql/badge.svg)](https://github.com/coady/graphique/security/code-scanning)
7
+ [![CodeQL](https://github.com/coady/graphique/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/coady/graphique/actions/workflows/github-code-scanning/codeql)
8
+ [![CodSpeed Badge](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/coady/graphique)
8
9
  [![image](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
9
10
  [![image](https://mypy-lang.org/static/mypy_badge.svg)](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
- app = GraphQL(ds.dataset(...)) # Table is root query type
43
- 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
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
@@ -1,3 +1 @@
1
1
  from .middleware import GraphQL # noqa: F401
2
-
3
- __version__ = '1.6'
@@ -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, 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,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.ranked(self, 1, *names, null_placement=null_placement)[:1]
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 ranked(self, k: int, *names: str, null_placement: str = 'at_end') -> Batch:
512
- """Return table filtered by values within dense rank, similar to `select_k_unstable`."""
513
- if k == 1: # optimized for min_max
514
- expr = isinstance(self, pa.Table)
515
- for name, order in map(sort_key, names):
516
- func = Column.min if order == 'ascending' else Column.max
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 get_fragments(self) -> Iterator[ds.Fragment]:
541
- """Support filtered datasets if it only references partition keys."""
542
- expr = self._scan_options.get('filter')
543
- if expr is not None: # raise ValueError if filter references other fields
544
- ds.dataset([], schema=self.partitioning.schema).scanner(filter=expr)
545
- return self._get_fragments(expr)
546
-
547
- def fragment_keys(self) -> list:
548
- """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."""
549
462
  try:
550
- 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)
551
466
  except (AttributeError, ValueError):
552
- return []
553
- 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))
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
- schema = set(Table.fragment_keys(self))
564
- 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}
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 = Table.ranked(table, k, *names[:offset])
495
+ table = table.take(pc.select_k_unstable(table, k, keys.items()))
575
496
  else:
576
- table = Table.sort(table, *names[:offset])
577
- index = pc.index(pc.greater_equal(pc.cumulative_sum(table['']), k), True).as_py()
578
- table = table[: index + 1 if index >= 0 else None]
579
- 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) :]
580
503
  if remaining or not dense: # fields with a single value are no longer needed
581
- selectors = [len(table[name].unique()) > 1 for name in keys]
504
+ selectors = [len(table[key].unique()) > 1 for key in keys]
582
505
  remaining = tuple(itertools.compress(names, selectors)) + remaining
583
- fields, prefix = [], [] # type: ignore
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 Declarations(list):
634
- """[Acero](https://arrow.apache.org/docs/python/api/acero.html) engine declarations."""
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, source: Union[pa.Table, ds.Dataset], columns=None):
647
- if isinstance(source, ds.Dataset):
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 __getattr__(self, name: str) -> Callable:
660
- return functools.partial(self.apply, name)
661
-
662
- def to_table(self, use_threads: bool = False) -> pa.Table:
663
- return ac.Declaration.from_sequence(self).to_table(use_threads)
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