graphique 1.1__tar.gz → 1.2__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.1 → graphique-1.2}/PKG-INFO +13 -6
- {graphique-1.1 → graphique-1.2}/README.md +12 -5
- {graphique-1.1 → graphique-1.2}/graphique/__init__.py +1 -1
- {graphique-1.1 → graphique-1.2}/graphique/core.py +107 -89
- {graphique-1.1 → graphique-1.2}/graphique/inputs.py +14 -4
- {graphique-1.1 → graphique-1.2}/graphique/interface.py +79 -51
- {graphique-1.1 → graphique-1.2}/graphique/middleware.py +10 -13
- {graphique-1.1 → graphique-1.2}/graphique/models.py +5 -4
- {graphique-1.1 → graphique-1.2}/graphique/scalars.py +3 -3
- graphique-1.2/graphique/service.py +35 -0
- {graphique-1.1 → graphique-1.2}/graphique.egg-info/PKG-INFO +13 -6
- {graphique-1.1 → graphique-1.2}/graphique.egg-info/SOURCES.txt +0 -1
- graphique-1.2/graphique.egg-info/requires.txt +5 -0
- {graphique-1.1 → graphique-1.2}/pyproject.toml +4 -1
- graphique-1.1/graphique/service.py +0 -22
- graphique-1.1/graphique/settings.py +0 -10
- graphique-1.1/graphique.egg-info/requires.txt +0 -5
- {graphique-1.1 → graphique-1.2}/LICENSE.txt +0 -0
- {graphique-1.1 → graphique-1.2}/graphique/py.typed +0 -0
- {graphique-1.1 → graphique-1.2}/graphique.egg-info/dependency_links.txt +0 -0
- {graphique-1.1 → graphique-1.2}/graphique.egg-info/top_level.txt +0 -0
- {graphique-1.1 → graphique-1.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: graphique
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2
|
|
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
|
|
@@ -88,7 +88,7 @@ app = GraphQL.federated({<name>: ds.dataset(...), ...}, keys={...}) # Tables on
|
|
|
88
88
|
Start like any ASGI app.
|
|
89
89
|
|
|
90
90
|
```console
|
|
91
|
-
uvicorn <
|
|
91
|
+
uvicorn <module>:app
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
Configuration options exist to provide a convenient no-code solution, but are subject to change in the future. Using a custom app is recommended for production usage.
|
|
@@ -138,8 +138,8 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
## Dependencies
|
|
141
|
-
* pyarrow >=
|
|
142
|
-
* strawberry-graphql[asgi,cli]
|
|
141
|
+
* pyarrow >=12
|
|
142
|
+
* strawberry-graphql[asgi,cli]
|
|
143
143
|
* uvicorn (or other [ASGI server](https://asgi.readthedocs.io/en/latest/implementations.html))
|
|
144
144
|
|
|
145
145
|
## Tests
|
|
@@ -150,12 +150,19 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
150
150
|
```
|
|
151
151
|
|
|
152
152
|
## Changes
|
|
153
|
+
1.2
|
|
154
|
+
|
|
155
|
+
* Pyarrow >=12 required
|
|
156
|
+
* Grouping fragments optimized
|
|
157
|
+
* Group by empty columns
|
|
158
|
+
* Batch sorting and grouping into lists
|
|
159
|
+
|
|
153
160
|
1.1
|
|
154
161
|
|
|
155
162
|
* Pyarrow >=11 required
|
|
156
163
|
* Python >=3.8 required
|
|
157
164
|
* Scannable functions added
|
|
158
|
-
* List aggregations
|
|
165
|
+
* List aggregations deprecated
|
|
159
166
|
* Group by fragments
|
|
160
167
|
* Month day nano interval array
|
|
161
168
|
* `min` and `max` fields memory optimized
|
|
@@ -226,9 +233,9 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
226
233
|
|
|
227
234
|
0.2
|
|
228
235
|
|
|
236
|
+
* Pyarrow >= 2 required
|
|
229
237
|
* `ListColumn` and `StructColumn` types
|
|
230
238
|
* `Groups` type with `aggregate` field
|
|
231
239
|
* `group` and `unique` optimized
|
|
232
|
-
* pyarrow >= 2 required
|
|
233
240
|
* Statistical fields: `mode`, `stddev`, `variance`
|
|
234
241
|
* `is_in`, `min`, and `max` optimized
|
|
@@ -46,7 +46,7 @@ app = GraphQL.federated({<name>: ds.dataset(...), ...}, keys={...}) # Tables on
|
|
|
46
46
|
Start like any ASGI app.
|
|
47
47
|
|
|
48
48
|
```console
|
|
49
|
-
uvicorn <
|
|
49
|
+
uvicorn <module>:app
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
Configuration options exist to provide a convenient no-code solution, but are subject to change in the future. Using a custom app is recommended for production usage.
|
|
@@ -96,8 +96,8 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
## Dependencies
|
|
99
|
-
* pyarrow >=
|
|
100
|
-
* strawberry-graphql[asgi,cli]
|
|
99
|
+
* pyarrow >=12
|
|
100
|
+
* strawberry-graphql[asgi,cli]
|
|
101
101
|
* uvicorn (or other [ASGI server](https://asgi.readthedocs.io/en/latest/implementations.html))
|
|
102
102
|
|
|
103
103
|
## Tests
|
|
@@ -108,12 +108,19 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
## Changes
|
|
111
|
+
1.2
|
|
112
|
+
|
|
113
|
+
* Pyarrow >=12 required
|
|
114
|
+
* Grouping fragments optimized
|
|
115
|
+
* Group by empty columns
|
|
116
|
+
* Batch sorting and grouping into lists
|
|
117
|
+
|
|
111
118
|
1.1
|
|
112
119
|
|
|
113
120
|
* Pyarrow >=11 required
|
|
114
121
|
* Python >=3.8 required
|
|
115
122
|
* Scannable functions added
|
|
116
|
-
* List aggregations
|
|
123
|
+
* List aggregations deprecated
|
|
117
124
|
* Group by fragments
|
|
118
125
|
* Month day nano interval array
|
|
119
126
|
* `min` and `max` fields memory optimized
|
|
@@ -184,9 +191,9 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
184
191
|
|
|
185
192
|
0.2
|
|
186
193
|
|
|
194
|
+
* Pyarrow >= 2 required
|
|
187
195
|
* `ListColumn` and `StructColumn` types
|
|
188
196
|
* `Groups` type with `aggregate` field
|
|
189
197
|
* `group` and `unique` optimized
|
|
190
|
-
* pyarrow >= 2 required
|
|
191
198
|
* Statistical fields: `mode`, `stddev`, `variance`
|
|
192
199
|
* `is_in`, `min`, and `max` optimized
|
|
@@ -9,8 +9,8 @@ import contextlib
|
|
|
9
9
|
import functools
|
|
10
10
|
import inspect
|
|
11
11
|
import itertools
|
|
12
|
+
import operator
|
|
12
13
|
import json
|
|
13
|
-
from concurrent import futures
|
|
14
14
|
from dataclasses import dataclass
|
|
15
15
|
from typing import Callable, Iterable, Iterator, Optional, Sequence, Union, get_type_hints
|
|
16
16
|
import numpy as np # type: ignore
|
|
@@ -18,7 +18,7 @@ import pyarrow as pa
|
|
|
18
18
|
import pyarrow.compute as pc
|
|
19
19
|
import pyarrow.dataset as ds
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
Array = Union[pa.Array, pa.ChunkedArray]
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class Agg:
|
|
@@ -52,11 +52,14 @@ class Agg:
|
|
|
52
52
|
self.options = options
|
|
53
53
|
|
|
54
54
|
def astuple(self, func: str) -> tuple:
|
|
55
|
-
|
|
55
|
+
options = self.option_map[func](**self.options)
|
|
56
|
+
return self.name, func, options
|
|
56
57
|
|
|
57
58
|
@classmethod
|
|
58
59
|
def getfunc(cls, name: str) -> Callable:
|
|
59
60
|
"""Return callable with named parameters for keyword arguments."""
|
|
61
|
+
if name in ('first', 'last', 'one'):
|
|
62
|
+
return operator.itemgetter(-1 if name == 'last' else 0)
|
|
60
63
|
if name == 'element':
|
|
61
64
|
return lambda array, index: array[index]
|
|
62
65
|
if name == 'slice':
|
|
@@ -144,29 +147,39 @@ class ListChunk(pa.lib.BaseListArray):
|
|
|
144
147
|
values = [func(value, **kwargs) for value in ListChunk.scalars(self)]
|
|
145
148
|
return ListChunk.from_scalars(values)
|
|
146
149
|
|
|
147
|
-
def
|
|
150
|
+
def inner_flatten(self) -> pa.lib.BaseListArray:
|
|
151
|
+
"""Return flattened inner lists from a nested list array."""
|
|
152
|
+
offsets = self.values.offsets.take(self.offsets)
|
|
153
|
+
return type(self).from_arrays(offsets, self.values.values)
|
|
154
|
+
|
|
155
|
+
def aggregate(self, **funcs: Optional[pc.FunctionOptions]) -> pa.RecordBatch:
|
|
148
156
|
"""Return aggregated scalars by grouping each hash function on the parent indices.
|
|
149
157
|
|
|
150
158
|
If there are empty or null scalars, then the result must be padded with null defaults and
|
|
151
159
|
reordered. If the function is a `count`, then the default is 0.
|
|
152
160
|
"""
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
columns = {'key': pc.list_parent_indices(self), '': pc.list_flatten(self)}
|
|
162
|
+
items = [('', name, funcs[name]) for name in funcs]
|
|
163
|
+
table = pa.table(columns).group_by(['key']).aggregate(items)
|
|
164
|
+
indices, table = table['key'], table.remove_column(table.schema.get_field_index('key'))
|
|
165
|
+
(batch,) = table.to_batches()
|
|
166
|
+
if len(batch) == len(self): # no empty or null scalars
|
|
167
|
+
return batch
|
|
158
168
|
mask = pc.equal(pc.list_value_length(self), 0)
|
|
159
169
|
empties = pc.indices_nonzero(Column.fill_null(mask, True))
|
|
160
|
-
indices = pa.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
170
|
+
indices = pa.chunked_array(indices.chunks + [empties.cast(indices.type)])
|
|
171
|
+
columns = {}
|
|
172
|
+
for field in batch.schema:
|
|
173
|
+
scalar = pa.scalar(0 if 'count' in field.name else None, field.type)
|
|
174
|
+
columns[field.name] = pa.repeat(scalar, len(empties))
|
|
175
|
+
table = pa.concat_tables([table, pa.table(columns)]).combine_chunks()
|
|
176
|
+
return table.to_batches()[0].take(pc.sort_indices(indices))
|
|
164
177
|
|
|
165
178
|
def min_max(self, **options) -> pa.Array:
|
|
166
179
|
if pa.types.is_dictionary(self.type.value_type):
|
|
167
|
-
self = ListChunk.aggregate(self, distinct=None)
|
|
180
|
+
(self,) = ListChunk.aggregate(self, distinct=None)
|
|
168
181
|
self = type(self).from_arrays(self.offsets, self.values.dictionary_decode())
|
|
169
|
-
return ListChunk.aggregate(self, min_max=pc.ScalarAggregateOptions(**options))
|
|
182
|
+
return ListChunk.aggregate(self, min_max=pc.ScalarAggregateOptions(**options))[0]
|
|
170
183
|
|
|
171
184
|
def min(self, **options) -> pa.Array:
|
|
172
185
|
"""min value of each list scalar"""
|
|
@@ -192,12 +205,12 @@ class ListChunk(pa.lib.BaseListArray):
|
|
|
192
205
|
@register
|
|
193
206
|
def list_all(ctx, self: pa.list_(pa.bool_())) -> pa.bool_(): # type: ignore
|
|
194
207
|
"""Test whether all elements in a boolean array evaluate to true."""
|
|
195
|
-
return ListChunk.aggregate(self, all=None)
|
|
208
|
+
return ListChunk.aggregate(self, all=None)[0]
|
|
196
209
|
|
|
197
210
|
@register
|
|
198
211
|
def list_any(ctx, self: pa.list_(pa.bool_())) -> pa.bool_(): # type: ignore
|
|
199
212
|
"""Test whether any element in a boolean array evaluates to true."""
|
|
200
|
-
return ListChunk.aggregate(self, any=None)
|
|
213
|
+
return ListChunk.aggregate(self, any=None)[0]
|
|
201
214
|
|
|
202
215
|
|
|
203
216
|
class Column(pa.ChunkedArray):
|
|
@@ -214,35 +227,17 @@ class Column(pa.ChunkedArray):
|
|
|
214
227
|
funcs = pa.types.is_list, pa.types.is_large_list, pa.types.is_fixed_size_list
|
|
215
228
|
return any(func(self.type) for func in funcs)
|
|
216
229
|
|
|
217
|
-
def
|
|
230
|
+
def call_indices(self, func: Callable) -> Array:
|
|
218
231
|
if not pa.types.is_dictionary(self.type):
|
|
219
|
-
return self
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
self = self.unify_dictionaries()
|
|
223
|
-
indices = pa.chunked_array(chunk.indices for chunk in self.iterchunks())
|
|
224
|
-
return self.chunk(0).dictionary, indices
|
|
225
|
-
|
|
226
|
-
def indices(self) -> Union[pa.Array, pa.ChunkedArray]:
|
|
227
|
-
"""Return chunked indices suitable for aggregation."""
|
|
228
|
-
if isinstance(self, pa.Array):
|
|
229
|
-
return pa.array(np.arange(len(self)))
|
|
230
|
-
offsets = list(itertools.accumulate(map(len, self.iterchunks())))
|
|
231
|
-
return pa.chunked_array(map(np.arange, [0] + offsets, offsets))
|
|
232
|
-
|
|
233
|
-
def call_indices(self, func: Callable) -> pa.ChunkedArray:
|
|
234
|
-
dictionary, indices = Column.combine_dictionaries(self)
|
|
235
|
-
if indices is None:
|
|
236
|
-
return func(dictionary)
|
|
237
|
-
return pa.chunked_array(
|
|
238
|
-
pa.DictionaryArray.from_arrays(chunk, dictionary) for chunk in func(indices).chunks
|
|
239
|
-
)
|
|
232
|
+
return func(self)
|
|
233
|
+
array = self.combine_chunks()
|
|
234
|
+
return pa.DictionaryArray.from_arrays(func(array.indices), array.dictionary)
|
|
240
235
|
|
|
241
|
-
def fill_null_backward(self) ->
|
|
236
|
+
def fill_null_backward(self) -> Array:
|
|
242
237
|
"""`fill_null_backward` with dictionary support."""
|
|
243
238
|
return Column.call_indices(self, pc.fill_null_backward)
|
|
244
239
|
|
|
245
|
-
def fill_null_forward(self) ->
|
|
240
|
+
def fill_null_forward(self) -> Array:
|
|
246
241
|
"""`fill_null_forward` with dictionary support."""
|
|
247
242
|
return Column.call_indices(self, pc.fill_null_forward)
|
|
248
243
|
|
|
@@ -250,11 +245,11 @@ class Column(pa.ChunkedArray):
|
|
|
250
245
|
"""Optimized `fill_null` to check `null_count`."""
|
|
251
246
|
return self.fill_null(value) if self.null_count else self
|
|
252
247
|
|
|
253
|
-
def sort_values(self) ->
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return pc.sort_indices(pc.sort_indices(dictionary)).take(indices)
|
|
248
|
+
def sort_values(self) -> Array:
|
|
249
|
+
if not pa.types.is_dictionary(self.type):
|
|
250
|
+
return self
|
|
251
|
+
array = self if isinstance(self, pa.Array) else self.combine_chunks()
|
|
252
|
+
return pc.sort_indices(pc.sort_indices(array.dictionary)).take(array.indices)
|
|
258
253
|
|
|
259
254
|
def diff(self, func: Callable = pc.subtract) -> pa.ChunkedArray:
|
|
260
255
|
"""Return discrete differences between adjacent values."""
|
|
@@ -318,10 +313,10 @@ class Column(pa.ChunkedArray):
|
|
|
318
313
|
class Table(pa.Table):
|
|
319
314
|
"""Table interface as a namespace of functions."""
|
|
320
315
|
|
|
321
|
-
def map_batch(
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
)
|
|
316
|
+
def map_batch(scanner: ds.Scanner, func: Callable, *rargs, **kwargs) -> pa.Table:
|
|
317
|
+
# TODO(apache/arrow#31612): replace with user defined function for multiple kernels
|
|
318
|
+
batches = [func(batch, *rargs, **kwargs) for batch in scanner.to_batches() if batch]
|
|
319
|
+
return pa.Table.from_batches(batches, None if batches else scanner.projected_schema)
|
|
325
320
|
|
|
326
321
|
def union(*tables: pa.Table) -> pa.Table:
|
|
327
322
|
"""Return table with union of columns."""
|
|
@@ -351,7 +346,7 @@ class Table(pa.Table):
|
|
|
351
346
|
Assumes the table is sorted by the column name, i.e., indexed.
|
|
352
347
|
"""
|
|
353
348
|
(slc,) = Column.find(self[name], value)
|
|
354
|
-
return pa.concat_tables([self[: slc.start], self[slc.stop :]])
|
|
349
|
+
return pa.concat_tables([self[: slc.start], self[slc.stop :]])
|
|
355
350
|
|
|
356
351
|
def from_offsets(self, offsets: pa.IntegerArray) -> pa.Table:
|
|
357
352
|
"""Return table with columns converted into list columns."""
|
|
@@ -399,38 +394,37 @@ class Table(pa.Table):
|
|
|
399
394
|
last: columns to take last value
|
|
400
395
|
**funcs: aggregate funcs with columns options
|
|
401
396
|
"""
|
|
402
|
-
|
|
403
|
-
|
|
397
|
+
if isinstance(self, pa.Table):
|
|
398
|
+
self = self.unify_dictionaries()
|
|
399
|
+
columns = {name: self[name] for name in self.schema.names}
|
|
400
|
+
aliases, args = {}, [] # type: ignore
|
|
401
|
+
if counts:
|
|
402
|
+
aliases['count_all'] = counts
|
|
403
|
+
args.append(([], 'count_all'))
|
|
404
404
|
if first or last:
|
|
405
|
-
|
|
405
|
+
columns[''] = np.arange(len(self))
|
|
406
|
+
args.append(('', 'min_max'))
|
|
406
407
|
lists: Sequence[Agg] = []
|
|
407
408
|
list_cols = [self[agg.name] for agg in funcs.get('list', [])]
|
|
408
409
|
if any(pa.types.is_dictionary(col.type) or Column.is_list_type(col) for col in list_cols):
|
|
409
|
-
|
|
410
|
+
columns[''] = np.arange(len(self))
|
|
411
|
+
args.append(('', 'list'))
|
|
410
412
|
lists = funcs.pop('list')
|
|
411
|
-
for func in funcs:
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
keys = (key.unify_dictionaries() for key in keys) # type: ignore
|
|
417
|
-
values = (
|
|
418
|
-
value.unify_dictionaries() if 'distinct' in func else value
|
|
419
|
-
for value, func in zip(values, hashes)
|
|
420
|
-
)
|
|
421
|
-
arrays = iter(pc._group_by(values, keys, zip(hashes, options)).flatten())
|
|
422
|
-
columns = {counts: next(arrays)} if counts else {}
|
|
413
|
+
for func, aggs in funcs.items():
|
|
414
|
+
aliases.update({f'{agg.name}_{func}': agg.alias for agg in aggs})
|
|
415
|
+
args += (agg.astuple(func) for agg in aggs) # type: ignore
|
|
416
|
+
table = pa.table(columns).group_by(list(names)).aggregate(args)
|
|
417
|
+
columns = {name: table[name].combine_chunks() for name in table.schema.names}
|
|
423
418
|
if first or last:
|
|
424
|
-
for aggs, indices in zip([first, last],
|
|
419
|
+
for aggs, indices in zip([first, last], columns.pop('_min_max').flatten()):
|
|
425
420
|
columns.update({agg.alias: self[agg.name].take(indices) for agg in aggs})
|
|
426
421
|
if lists:
|
|
427
|
-
indices =
|
|
422
|
+
indices = columns.pop('_list')
|
|
428
423
|
table = self.select({agg.name for agg in lists}).take(indices.values)
|
|
429
424
|
table = Table.from_offsets(table, indices.offsets)
|
|
430
|
-
columns.update({agg.alias: table[agg.name] for agg in lists})
|
|
431
|
-
for
|
|
432
|
-
|
|
433
|
-
columns.update(zip(names, map(decode, arrays)))
|
|
425
|
+
columns.update({agg.alias: table[agg.name].combine_chunks() for agg in lists})
|
|
426
|
+
columns.update({name: decode(columns[name]) for name in names})
|
|
427
|
+
columns = {aliases.get(name, name): columns[name] for name in columns}
|
|
434
428
|
return type(self).from_pydict(columns)
|
|
435
429
|
|
|
436
430
|
def aggregate(self, counts: str = '', **funcs: Sequence[Agg]) -> dict:
|
|
@@ -438,30 +432,45 @@ class Table(pa.Table):
|
|
|
438
432
|
row = {name: self[name] for name in self.column_names}
|
|
439
433
|
if counts:
|
|
440
434
|
row[counts] = len(self)
|
|
435
|
+
aliases, args = {}, [] # type: ignore
|
|
441
436
|
for key in funcs:
|
|
442
|
-
|
|
443
|
-
|
|
437
|
+
if key in Agg.option_map:
|
|
438
|
+
aliases.update({f'{agg.name}_{key}': agg.alias for agg in funcs[key]})
|
|
439
|
+
args += (agg.astuple(key) for agg in funcs[key])
|
|
440
|
+
else:
|
|
441
|
+
func = Agg.getfunc(key)
|
|
442
|
+
row.update({agg.alias: func(self[agg.name], **agg.options) for agg in funcs[key]})
|
|
443
|
+
if args:
|
|
444
|
+
table = self.group_by([]).aggregate(args)
|
|
445
|
+
row.update({aliases[name]: table[name][0] for name in table.column_names})
|
|
444
446
|
for name, value in row.items():
|
|
445
447
|
if isinstance(value, pa.ChunkedArray):
|
|
446
448
|
row[name] = value.combine_chunks()
|
|
447
449
|
return row
|
|
448
450
|
|
|
451
|
+
def list_fields(self) -> set:
|
|
452
|
+
return {field.name for field in self.schema if Column.is_list_type(field)}
|
|
453
|
+
|
|
449
454
|
def list_value_length(self) -> pa.Array:
|
|
450
|
-
lists =
|
|
455
|
+
lists = Table.list_fields(self)
|
|
451
456
|
if not lists:
|
|
452
|
-
raise ValueError(f"no list columns available: {self.
|
|
457
|
+
raise ValueError(f"no list columns available: {self.schema.names}")
|
|
453
458
|
counts, *others = (pc.list_value_length(self[name]) for name in lists)
|
|
454
459
|
if any(counts != other for other in others):
|
|
455
460
|
raise ValueError(f"list columns have different value lengths: {lists}")
|
|
456
|
-
return counts.chunk(0)
|
|
461
|
+
return counts if isinstance(counts, pa.Array) else counts.chunk(0)
|
|
457
462
|
|
|
458
|
-
def sort_list(
|
|
463
|
+
def sort_list(
|
|
464
|
+
self, *names: str, length: Optional[int] = None, null_placement: str = 'at_end'
|
|
465
|
+
) -> pa.Table:
|
|
459
466
|
"""Return table with list columns sorted within scalars."""
|
|
460
467
|
keys = dict(map(sort_key, names)) # type: ignore
|
|
461
468
|
columns = {name: Column.sort_values(pc.list_flatten(self[name])) for name in keys}
|
|
462
469
|
columns[''] = pc.list_parent_indices(self[next(iter(keys))])
|
|
463
470
|
keys = dict({'': 'ascending'}, **keys)
|
|
464
|
-
indices = pc.sort_indices(
|
|
471
|
+
indices = pc.sort_indices(
|
|
472
|
+
pa.table(columns), sort_keys=keys.items(), null_placement=null_placement
|
|
473
|
+
)
|
|
465
474
|
counts = Table.list_value_length(self)
|
|
466
475
|
if length is not None:
|
|
467
476
|
indices = pa.concat_arrays(
|
|
@@ -473,19 +482,27 @@ class Table(pa.Table):
|
|
|
473
482
|
|
|
474
483
|
def select_list(self, apply: Callable = lambda c: c) -> pa.Table:
|
|
475
484
|
"""Return table with only the list columns."""
|
|
476
|
-
names =
|
|
485
|
+
names = list(Table.list_fields(self))
|
|
477
486
|
return pa.table(list(map(apply, self.select(names))), names)
|
|
478
487
|
|
|
479
|
-
def sort_indices(
|
|
488
|
+
def sort_indices(
|
|
489
|
+
self, *names: str, length: Optional[int] = None, null_placement: str = 'at_end'
|
|
490
|
+
) -> pa.Array:
|
|
480
491
|
"""Return indices which would sort the table by columns, optimized for fixed length."""
|
|
481
|
-
func = pc.sort_indices
|
|
492
|
+
func = functools.partial(pc.sort_indices, null_placement=null_placement)
|
|
482
493
|
if length is not None:
|
|
483
494
|
func = functools.partial(pc.select_k_unstable, k=length)
|
|
484
495
|
keys = dict(map(sort_key, names)) # type: ignore
|
|
485
496
|
table = pa.table({name: Column.sort_values(self[name]) for name in keys})
|
|
486
497
|
return func(table, sort_keys=keys.items())
|
|
487
498
|
|
|
488
|
-
def sort(
|
|
499
|
+
def sort(
|
|
500
|
+
self,
|
|
501
|
+
*names: str,
|
|
502
|
+
length: Optional[int] = None,
|
|
503
|
+
indices: str = '',
|
|
504
|
+
null_placement: str = 'at_end',
|
|
505
|
+
) -> pa.Table:
|
|
489
506
|
"""Return table sorted by columns, optimized for fixed length.
|
|
490
507
|
|
|
491
508
|
Args:
|
|
@@ -493,7 +510,7 @@ class Table(pa.Table):
|
|
|
493
510
|
length: maximum number of rows to return
|
|
494
511
|
indices: include original indices in the table
|
|
495
512
|
"""
|
|
496
|
-
indices_ = Table.sort_indices(self, *names, length=length)
|
|
513
|
+
indices_ = Table.sort_indices(self, *names, length=length, null_placement=null_placement)
|
|
497
514
|
table = self.take(indices_)
|
|
498
515
|
if indices:
|
|
499
516
|
table = table.append_column(indices, indices_)
|
|
@@ -501,7 +518,7 @@ class Table(pa.Table):
|
|
|
501
518
|
metadata = {'index_columns': list(itertools.takewhile(func, names))}
|
|
502
519
|
return table.replace_schema_metadata({'pandas': json.dumps(metadata)})
|
|
503
520
|
|
|
504
|
-
def filter_list(self, expr: ds.Expression) ->
|
|
521
|
+
def filter_list(self, expr: ds.Expression) -> pa.Table:
|
|
505
522
|
"""Return table with list columns filtered within scalars."""
|
|
506
523
|
table = Table.select_list(self)
|
|
507
524
|
counts = Table.list_value_length(table)
|
|
@@ -514,12 +531,13 @@ class Table(pa.Table):
|
|
|
514
531
|
flattened = pa.table(columns, self.column_names)
|
|
515
532
|
mask = ds.dataset(flattened).to_table(columns={'mask': expr})['mask'].combine_chunks()
|
|
516
533
|
masks = type(first).from_arrays(first.offsets, mask)
|
|
517
|
-
counts = Column.fill_null(ListChunk.aggregate(masks, sum=None)
|
|
534
|
+
counts = Column.fill_null(ListChunk.aggregate(masks, sum=None)[0], 0)
|
|
518
535
|
flattened = flattened.select(table.column_names).filter(mask)
|
|
519
536
|
return Table.union(self, Table.from_counts(flattened, counts))
|
|
520
537
|
|
|
521
|
-
def
|
|
522
|
-
for name in names:
|
|
538
|
+
def min_max(self, *names: str) -> pa.Table:
|
|
539
|
+
for name, order in map(sort_key, names):
|
|
540
|
+
func = Column.min if order == 'ascending' else Column.max
|
|
523
541
|
if Column.is_list_type(self[name]):
|
|
524
542
|
self = self.append_column('', getattr(ListChunk, func.__name__)(self[name]))
|
|
525
543
|
self = Table.filter_list(self, pc.field(name) == pc.field('')).drop([''])
|
|
@@ -18,6 +18,7 @@ from strawberry.schema_directive import Location
|
|
|
18
18
|
from strawberry.field import StrawberryField
|
|
19
19
|
from strawberry.scalars import JSON
|
|
20
20
|
from strawberry.types.fields.resolver import StrawberryResolver
|
|
21
|
+
from typing_extensions import Self
|
|
21
22
|
from .core import Agg, ListChunk
|
|
22
23
|
from .scalars import Long
|
|
23
24
|
|
|
@@ -161,6 +162,13 @@ class Quantile(Field):
|
|
|
161
162
|
min_count: int = 1
|
|
162
163
|
|
|
163
164
|
|
|
165
|
+
@strawberry.input
|
|
166
|
+
class Rank(Field):
|
|
167
|
+
sort_keys: str = 'ascending'
|
|
168
|
+
null_placement: str = 'at_end'
|
|
169
|
+
tiebreaker: str = 'first'
|
|
170
|
+
|
|
171
|
+
|
|
164
172
|
@strawberry.input
|
|
165
173
|
class Sort:
|
|
166
174
|
by: List[str]
|
|
@@ -169,10 +177,12 @@ class Sort:
|
|
|
169
177
|
|
|
170
178
|
@strawberry.input(description=f"[functions]({links.compute}#structural-transforms) for lists")
|
|
171
179
|
class ListFunction(Input):
|
|
180
|
+
deprecation = "List scalar functions will be moved to `scan(...: {list: ...})`"
|
|
181
|
+
|
|
172
182
|
filter: 'Expression' = default_field({}, description="filter within list scalars")
|
|
173
|
-
index: Optional[Index] = default_field(func=pc.index)
|
|
174
|
-
mode: Optional[Mode] = default_field(func=pc.mode)
|
|
175
|
-
quantile: Optional[Quantile] = default_field(func=pc.quantile)
|
|
183
|
+
index: Optional[Index] = default_field(func=pc.index, deprecation_reason=deprecation)
|
|
184
|
+
mode: Optional[Mode] = default_field(func=pc.mode, deprecation_reason=deprecation)
|
|
185
|
+
quantile: Optional[Quantile] = default_field(func=pc.quantile, deprecation_reason=deprecation)
|
|
176
186
|
|
|
177
187
|
|
|
178
188
|
@strawberry.input(description=f"options for count [aggregation]({links.compute}#aggregations)")
|
|
@@ -399,7 +409,7 @@ class Expression:
|
|
|
399
409
|
|
|
400
410
|
@classmethod
|
|
401
411
|
@no_type_check
|
|
402
|
-
def from_query(cls, **queries: Filter) ->
|
|
412
|
+
def from_query(cls, **queries: Filter) -> Self:
|
|
403
413
|
"""Transform query syntax into an Expression input."""
|
|
404
414
|
exprs = []
|
|
405
415
|
for name, query in queries.items():
|
|
@@ -17,13 +17,12 @@ from strawberry.types import Info
|
|
|
17
17
|
from typing_extensions import Annotated, Self
|
|
18
18
|
from .core import Column as C, ListChunk, Table as T, sort_key
|
|
19
19
|
from .inputs import CountAggregate, Cumulative, Diff, Expression, Field, Filter
|
|
20
|
-
from .inputs import HashAggregates, ListFunction, Projection, ScalarAggregate, Sort
|
|
20
|
+
from .inputs import HashAggregates, ListFunction, Projection, Rank, ScalarAggregate, Sort
|
|
21
21
|
from .inputs import TDigestAggregate, VarianceAggregate, VectorAggregates, links
|
|
22
22
|
from .models import Column, doc_field, selections
|
|
23
23
|
from .scalars import Long
|
|
24
24
|
|
|
25
25
|
Root = Union[ds.Dataset, ds.Scanner, pa.Table]
|
|
26
|
-
list_deprecation = "List functions will be moved to `scan(...: {list: ...})`"
|
|
27
26
|
|
|
28
27
|
|
|
29
28
|
def references(field) -> Iterator:
|
|
@@ -163,7 +162,10 @@ class Dataset:
|
|
|
163
162
|
|
|
164
163
|
@doc_field
|
|
165
164
|
def any(self, info: Info, length: Long = 1) -> bool:
|
|
166
|
-
"""
|
|
165
|
+
"""Return whether there are at least `length` rows.
|
|
166
|
+
|
|
167
|
+
May be significantly faster than `length` for out-of-core data.
|
|
168
|
+
"""
|
|
167
169
|
table = self.select(info, length)
|
|
168
170
|
return len(table) >= length
|
|
169
171
|
|
|
@@ -200,7 +202,7 @@ class Dataset:
|
|
|
200
202
|
aggregate="grouped aggregation functions",
|
|
201
203
|
)
|
|
202
204
|
def group(
|
|
203
|
-
self, info: Info, by: List[str], counts: str = '', aggregate: HashAggregates = {} # type: ignore
|
|
205
|
+
self, info: Info, by: List[str] = [], counts: str = '', aggregate: HashAggregates = {} # type: ignore
|
|
204
206
|
) -> Self:
|
|
205
207
|
"""Return table grouped by columns, with stable ordering.
|
|
206
208
|
|
|
@@ -211,14 +213,23 @@ class Dataset:
|
|
|
211
213
|
for values in aggs.values():
|
|
212
214
|
scalars.update(agg.alias for agg in values)
|
|
213
215
|
lists = self.references(info, level=1) - scalars - {counts}
|
|
214
|
-
|
|
216
|
+
flat = isinstance(self.table, pa.Table) or not set(aggs) <= Field.associatives
|
|
217
|
+
aggs['list'] = list(map(Field, lists))
|
|
218
|
+
if flat:
|
|
215
219
|
table = self.select(info)
|
|
216
|
-
else: # scan
|
|
217
|
-
|
|
220
|
+
else: # scan fragments or batches when possible
|
|
221
|
+
if set(by) <= set(self.schema().partitioning) > set():
|
|
222
|
+
table = self.fragments(info, Expression(), counts, aggregate, Expression()).table
|
|
223
|
+
else:
|
|
224
|
+
table = T.map_batch(self.scanner(info), T.group, *by, counts=counts, **aggs)
|
|
218
225
|
if counts:
|
|
219
226
|
aggs.setdefault('sum', []).append(Field(counts))
|
|
220
227
|
counts = ''
|
|
221
|
-
|
|
228
|
+
table = T.group(table, *by, counts=counts, **aggs)
|
|
229
|
+
columns = dict(zip(table.column_names, table))
|
|
230
|
+
if not flat:
|
|
231
|
+
columns.update({name: ListChunk.inner_flatten(*columns[name].chunks) for name in lists})
|
|
232
|
+
return type(self)(pa.table(columns))
|
|
222
233
|
|
|
223
234
|
@doc_field(
|
|
224
235
|
keys="selected fragments",
|
|
@@ -253,8 +264,7 @@ class Dataset:
|
|
|
253
264
|
projection.pop(name, None)
|
|
254
265
|
columns = collections.defaultdict(list)
|
|
255
266
|
for fragment in self.table.get_fragments(filter=keys.to_arrow()):
|
|
256
|
-
|
|
257
|
-
row = ds._get_partition_keys(fragment.partition_expression)
|
|
267
|
+
row = ds.get_partition_keys(fragment.partition_expression)
|
|
258
268
|
if projection:
|
|
259
269
|
table = fragment.to_table(filter=filter, columns=projection)
|
|
260
270
|
row.update(T.aggregate(table, counts=counts, **aggs))
|
|
@@ -271,8 +281,7 @@ class Dataset:
|
|
|
271
281
|
columns[name] = C.from_scalars(values)
|
|
272
282
|
elif isinstance(values[0], pa.Array):
|
|
273
283
|
columns[name] = ListChunk.from_scalars(values)
|
|
274
|
-
|
|
275
|
-
columns[name] = pa.array(columns[name], tp)
|
|
284
|
+
columns.update({field.name: pa.array(columns[field.name], field.type) for field in schema})
|
|
276
285
|
return type(self)(pa.table(columns))
|
|
277
286
|
|
|
278
287
|
@doc_field(
|
|
@@ -294,7 +303,7 @@ class Dataset:
|
|
|
294
303
|
funcs = {diff.pop('name'): diff for diff in map(dict, diffs)}
|
|
295
304
|
names = list(itertools.takewhile(lambda name: name not in funcs, by))
|
|
296
305
|
predicates = {}
|
|
297
|
-
for name in by[len(names) :]:
|
|
306
|
+
for name in by[len(names) :]:
|
|
298
307
|
((func, value),) = funcs.pop(name, {'not_equal': None}).items()
|
|
299
308
|
predicates[name] = (getattr(pc, func),)
|
|
300
309
|
if value is not None:
|
|
@@ -307,30 +316,49 @@ class Dataset:
|
|
|
307
316
|
@doc_field(
|
|
308
317
|
by="column names; prefix with `-` for descending order",
|
|
309
318
|
length="maximum number of rows to return; may be significantly faster but is unstable",
|
|
319
|
+
null_placement="where nulls in input should be sorted; incompatible with `length`",
|
|
310
320
|
)
|
|
311
|
-
def sort(
|
|
321
|
+
def sort(
|
|
322
|
+
self,
|
|
323
|
+
info: Info,
|
|
324
|
+
by: List[str],
|
|
325
|
+
length: Optional[Long] = None,
|
|
326
|
+
null_placement: str = 'at_end',
|
|
327
|
+
) -> Self:
|
|
312
328
|
"""Return table slice sorted by specified columns.
|
|
313
329
|
|
|
314
|
-
|
|
330
|
+
Optimized for length == 1; matches min or max values.
|
|
331
|
+
Pending deprecation: sorting on list columns will sort within scalars, all of which must have the same lengths.
|
|
315
332
|
"""
|
|
316
|
-
|
|
333
|
+
if length == 1:
|
|
334
|
+
self = self.min_max(info, by).slice(info, length=length)
|
|
335
|
+
table = self.table
|
|
336
|
+
schema = table.projected_schema if isinstance(table, ds.Scanner) else table.schema
|
|
317
337
|
scalars, lists = [], [] # type: ignore
|
|
318
338
|
for key in by:
|
|
319
|
-
(lists if C.is_list_type(
|
|
339
|
+
(lists if C.is_list_type(schema.field(key.lstrip('-'))) else scalars).append(key)
|
|
340
|
+
if not scalars or isinstance(table, pa.Table) or length is None:
|
|
341
|
+
table = self.select(info)
|
|
342
|
+
else:
|
|
343
|
+
kwargs = dict(length=length, null_placement=null_placement)
|
|
344
|
+
table = T.map_batch(
|
|
345
|
+
self.scanner(info), lambda b: b.take(T.sort_indices(b, *scalars, **kwargs))
|
|
346
|
+
)
|
|
320
347
|
if scalars:
|
|
321
|
-
table = T.sort(table, *scalars, length=length)
|
|
348
|
+
table = T.sort(table, *scalars, length=length, null_placement=null_placement)
|
|
322
349
|
if lists:
|
|
323
|
-
table = T.sort_list(table, *lists, length=length)
|
|
350
|
+
table = T.sort_list(table, *lists, length=length, null_placement=null_placement)
|
|
324
351
|
return type(self)(table)
|
|
325
352
|
|
|
326
|
-
def min_max(self, info: Info, by: List[str]
|
|
327
|
-
table, name = self.table, by[0]
|
|
353
|
+
def min_max(self, info: Info, by: List[str]) -> Self:
|
|
354
|
+
table, (name, order) = self.table, sort_key(by[0])
|
|
355
|
+
func = C.min if order == 'ascending' else C.max
|
|
328
356
|
schema = table.projected_schema if isinstance(table, ds.Scanner) else table.schema
|
|
329
357
|
if isinstance(table, pa.Table) or C.is_list_type(schema.field(name)):
|
|
330
358
|
table = self.select(info)
|
|
331
|
-
elif
|
|
359
|
+
elif name in self.schema().partitioning:
|
|
332
360
|
values = pa.array(
|
|
333
|
-
ds.
|
|
361
|
+
ds.get_partition_keys(fragment.partition_expression)[name]
|
|
334
362
|
for fragment in table.get_fragments()
|
|
335
363
|
)
|
|
336
364
|
scanner = self.scanner(info, filter=ds.field(name) == func(values))
|
|
@@ -338,22 +366,19 @@ class Dataset:
|
|
|
338
366
|
return type(self)(scanner)
|
|
339
367
|
table, by = scanner.to_table(), by[1:]
|
|
340
368
|
else:
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
batch.filter(pc.equal(batch[name], func(batch[name]))) for batch in batches
|
|
345
|
-
)
|
|
346
|
-
return type(self)(T.matched(table, func, *by))
|
|
369
|
+
scanner = self.scanner(info)
|
|
370
|
+
table = T.map_batch(scanner, lambda b: b.filter(pc.equal(b[name], func(b[name]))))
|
|
371
|
+
return type(self)(T.min_max(table, *by))
|
|
347
372
|
|
|
348
373
|
@doc_field(by="column names")
|
|
349
374
|
def min(self, info: Info, by: List[str]) -> Self:
|
|
350
|
-
"""
|
|
351
|
-
return self.min_max(info, by
|
|
375
|
+
"""Pending deprecation: return table with minimum values per column."""
|
|
376
|
+
return self.min_max(info, by)
|
|
352
377
|
|
|
353
378
|
@doc_field(by="column names")
|
|
354
379
|
def max(self, info: Info, by: List[str]) -> Self:
|
|
355
|
-
"""
|
|
356
|
-
return self.min_max(info, by
|
|
380
|
+
"""Pending deprecation: return table with maximum values per column."""
|
|
381
|
+
return self.min_max(info, ['-' + name for name in by])
|
|
357
382
|
|
|
358
383
|
@doc_field
|
|
359
384
|
@no_type_check
|
|
@@ -363,13 +388,14 @@ class Dataset:
|
|
|
363
388
|
cumulative_sum: doc_argument(List[Cumulative], func=pc.cumulative_sum) = [],
|
|
364
389
|
fill_null_backward: doc_argument(List[Field], func=pc.fill_null_backward) = [],
|
|
365
390
|
fill_null_forward: doc_argument(List[Field], func=pc.fill_null_forward) = [],
|
|
391
|
+
rank: doc_argument(List[Rank], func=pc.rank) = [],
|
|
366
392
|
list: Annotated[
|
|
367
|
-
List[ListFunction], strawberry.argument(
|
|
393
|
+
List[ListFunction], strawberry.argument(description="Functions for list arrays.")
|
|
368
394
|
] = [],
|
|
369
395
|
) -> Self:
|
|
370
396
|
"""Return view of table with vector functions applied across columns.
|
|
371
397
|
|
|
372
|
-
Applied
|
|
398
|
+
Applied functions load arrays into memory as needed. See `scan` for scalar functions,
|
|
373
399
|
which do not require loading.
|
|
374
400
|
"""
|
|
375
401
|
table = self.select(info)
|
|
@@ -380,8 +406,8 @@ class Dataset:
|
|
|
380
406
|
table = T.filter_list(table, expr)
|
|
381
407
|
for func, field in value.items():
|
|
382
408
|
columns[field.alias] = getattr(ListChunk, func)(table[field.name], **field.options)
|
|
383
|
-
args = cumulative_sum, fill_null_backward, fill_null_forward
|
|
384
|
-
funcs = pc.cumulative_sum, C.fill_null_backward, C.fill_null_forward
|
|
409
|
+
args = cumulative_sum, fill_null_backward, fill_null_forward, rank
|
|
410
|
+
funcs = pc.cumulative_sum, C.fill_null_backward, C.fill_null_forward, pc.rank
|
|
385
411
|
for fields, func in zip(args, funcs):
|
|
386
412
|
for field in fields:
|
|
387
413
|
columns[field.alias] = func(table[field.name], **field.options)
|
|
@@ -393,13 +419,13 @@ class Dataset:
|
|
|
393
419
|
|
|
394
420
|
At least one list column must be referenced, and all list columns must have the same lengths.
|
|
395
421
|
"""
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
422
|
+
for batch in self.scanner(info).to_batches():
|
|
423
|
+
lists = T.list_fields(batch)
|
|
424
|
+
scalars = set(batch.schema.names) - lists
|
|
425
|
+
for index, count in enumerate(T.list_value_length(batch).to_pylist()):
|
|
426
|
+
row = {name: pa.repeat(batch[name][index], count) for name in scalars}
|
|
427
|
+
row.update({name: batch[name][index].values for name in lists})
|
|
428
|
+
yield type(self)(pa.table(row))
|
|
403
429
|
|
|
404
430
|
@doc_field
|
|
405
431
|
def aggregate(
|
|
@@ -437,12 +463,12 @@ class Dataset:
|
|
|
437
463
|
else:
|
|
438
464
|
columns[agg.alias] = func(table[agg.name], **agg.options)
|
|
439
465
|
for name, aggs in agg_fields.items():
|
|
440
|
-
funcs = {key: agg.astuple(key)[1] for key, agg in aggs.items()}
|
|
441
|
-
|
|
442
|
-
columns.update(zip([agg.alias for agg in aggs.values()],
|
|
466
|
+
funcs = {key: agg.astuple(key)[-1] for key, agg in aggs.items()}
|
|
467
|
+
batch = ListChunk.aggregate(table[name], **funcs)
|
|
468
|
+
columns.update(zip([agg.alias for agg in aggs.values()], batch))
|
|
443
469
|
return type(self)(pa.table(columns))
|
|
444
470
|
|
|
445
|
-
aggregate.deprecation_reason =
|
|
471
|
+
aggregate.deprecation_reason = ListFunction.deprecation
|
|
446
472
|
|
|
447
473
|
def project(self, info: Info, columns: List[Projection]) -> dict:
|
|
448
474
|
"""Return projected columns, including all references from below fields."""
|
|
@@ -458,14 +484,16 @@ class Dataset:
|
|
|
458
484
|
) -> Self:
|
|
459
485
|
"""Select rows and project columns without memory usage."""
|
|
460
486
|
scanner = self.scanner(info, filter=filter.to_arrow(), columns=self.project(info, columns))
|
|
461
|
-
|
|
487
|
+
selected = selections(*info.selected_fields)
|
|
488
|
+
selected['type'] = selected['schema'] = 0
|
|
489
|
+
oneshot = sum(selected.values()) > 1 and isinstance(self.table, ds.Scanner)
|
|
462
490
|
return type(self)(scanner.to_table() if oneshot else scanner)
|
|
463
491
|
|
|
464
492
|
@doc_field(
|
|
465
493
|
right="name of right table; must be on root Query type",
|
|
466
494
|
keys="column names used as keys on the left side",
|
|
467
495
|
right_keys="column names used as keys on the right side; defaults to left side.",
|
|
468
|
-
join_type="
|
|
496
|
+
join_type="the kind of join: 'left semi', 'right semi', 'left anti', 'right anti', 'inner', 'left outer', 'right outer', 'full outer'",
|
|
469
497
|
left_suffix="add suffix to left column names; for preventing collisions",
|
|
470
498
|
right_suffix="add suffix to right column names; for preventing collisions.",
|
|
471
499
|
coalesce_keys="omit duplicate keys",
|
|
@@ -499,5 +527,5 @@ class Dataset:
|
|
|
499
527
|
|
|
500
528
|
@doc_field
|
|
501
529
|
def take(self, info: Info, indices: List[Long]) -> Self:
|
|
502
|
-
"""
|
|
530
|
+
"""Select rows from indices."""
|
|
503
531
|
return type(self)(self.scanner(info).take(indices))
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
ASGI GraphQL utilities.
|
|
3
3
|
"""
|
|
4
4
|
from datetime import datetime
|
|
5
|
-
from typing import Iterable, Mapping, Optional
|
|
6
|
-
import pyarrow as pa
|
|
5
|
+
from typing import Iterable, Mapping, Optional
|
|
7
6
|
import pyarrow.dataset as ds
|
|
8
7
|
import strawberry.asgi
|
|
9
8
|
from strawberry import UNSET
|
|
@@ -11,20 +10,17 @@ from strawberry.utils.str_converters import to_camel_case
|
|
|
11
10
|
from strawberry.types import Info
|
|
12
11
|
from .core import Column as C
|
|
13
12
|
from .inputs import Filter
|
|
14
|
-
from .interface import Dataset
|
|
13
|
+
from .interface import Dataset, Root
|
|
15
14
|
from .models import Column, doc_field
|
|
16
15
|
from .scalars import Long, scalar_map, type_map
|
|
17
16
|
|
|
18
|
-
Root = Union[ds.Dataset, ds.Scanner, pa.Table]
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def on_request_end(self):
|
|
18
|
+
class TimingExtension(strawberry.extensions.SchemaExtension):
|
|
19
|
+
def on_operation(self):
|
|
20
|
+
start = datetime.now()
|
|
21
|
+
yield
|
|
26
22
|
end = datetime.now()
|
|
27
|
-
print(f"[{end.replace(microsecond=0)}]: {end -
|
|
23
|
+
print(f"[{end.replace(microsecond=0)}]: {end - start}")
|
|
28
24
|
|
|
29
25
|
|
|
30
26
|
class GraphQL(strawberry.asgi.GraphQL):
|
|
@@ -33,13 +29,14 @@ class GraphQL(strawberry.asgi.GraphQL):
|
|
|
33
29
|
Args:
|
|
34
30
|
root: root dataset to attach as the Query type
|
|
35
31
|
debug: enable timing extension
|
|
32
|
+
extensions: schema extensions
|
|
36
33
|
**kwargs: additional `asgi.GraphQL` options
|
|
37
34
|
"""
|
|
38
35
|
|
|
39
36
|
options = dict(types=Column.registry.values(), scalar_overrides=scalar_map)
|
|
40
37
|
|
|
41
|
-
def __init__(self, root: Root, debug: bool = False, **kwargs):
|
|
42
|
-
options = dict(self.options, extensions=[TimingExtension]
|
|
38
|
+
def __init__(self, root: Root, debug: bool = False, extensions: list = [], **kwargs):
|
|
39
|
+
options = dict(self.options, extensions=extensions + [TimingExtension][: bool(debug)])
|
|
43
40
|
if type(root).__name__ == 'Query':
|
|
44
41
|
self.root_value = root
|
|
45
42
|
options['enable_federation_2'] = True # type: ignore
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
GraphQL output types and resolvers.
|
|
3
3
|
"""
|
|
4
|
+
import collections
|
|
4
5
|
import functools
|
|
5
6
|
import inspect
|
|
6
7
|
import itertools
|
|
@@ -33,9 +34,9 @@ def _selections(field):
|
|
|
33
34
|
yield from _selections(selection)
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
def selections(*fields) ->
|
|
37
|
-
"""Return
|
|
38
|
-
return
|
|
37
|
+
def selections(*fields) -> dict:
|
|
38
|
+
"""Return counts of field name selections from strawberry `SelectedField`."""
|
|
39
|
+
return collections.Counter(itertools.chain(*map(_selections, fields)))
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
def doc_field(func: Optional[Callable] = None, **kwargs: str) -> StrawberryField:
|
|
@@ -245,7 +246,7 @@ class IntColumn(RatioColumn[T]):
|
|
|
245
246
|
def take_from(
|
|
246
247
|
self, info: Info, field: str
|
|
247
248
|
) -> Annotated['Dataset', strawberry.lazy('.interface')]:
|
|
248
|
-
"""
|
|
249
|
+
"""Select indices from a table on the root Query type."""
|
|
249
250
|
root = getattr(info.root_value, field)
|
|
250
251
|
return type(root)(root.scanner(info).take(self.array.combine_chunks()))
|
|
251
252
|
|
|
@@ -15,9 +15,9 @@ def parse_long(value) -> int:
|
|
|
15
15
|
|
|
16
16
|
@strawberry.type(description="month day nano interval")
|
|
17
17
|
class Interval:
|
|
18
|
-
months: int
|
|
19
|
-
days: int
|
|
20
|
-
nanoseconds: 'Long'
|
|
18
|
+
months: int
|
|
19
|
+
days: int
|
|
20
|
+
nanoseconds: 'Long' # type: ignore
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
Long = strawberry.scalar(int, name='Long', description="64-bit int", parse_value=parse_long)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Default GraphQL service.
|
|
3
|
+
|
|
4
|
+
Copy and customize as needed. Demonstrates:
|
|
5
|
+
* federation versus root type
|
|
6
|
+
* datasets, scanners, and tables
|
|
7
|
+
* filtering and projection
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import pyarrow.dataset as ds
|
|
12
|
+
from starlette.config import Config
|
|
13
|
+
from graphique.inputs import Expression
|
|
14
|
+
from graphique import GraphQL
|
|
15
|
+
|
|
16
|
+
config = Config('.env')
|
|
17
|
+
PARQUET_PATH = Path(config('PARQUET_PATH')).resolve()
|
|
18
|
+
FEDERATED = config('FEDERATED', default='')
|
|
19
|
+
DEBUG = config('DEBUG', cast=bool, default=False)
|
|
20
|
+
COLUMNS = config('COLUMNS', cast=json.loads, default=None)
|
|
21
|
+
FILTERS = config('FILTERS', cast=json.loads, default=None)
|
|
22
|
+
|
|
23
|
+
root = ds.dataset(PARQUET_PATH, partitioning='hive' if PARQUET_PATH.is_dir() else None)
|
|
24
|
+
|
|
25
|
+
if isinstance(COLUMNS, dict):
|
|
26
|
+
COLUMNS = {alias: ds.field(name) for alias, name in COLUMNS.items()}
|
|
27
|
+
if FILTERS is not None:
|
|
28
|
+
root = root.to_table(columns=COLUMNS, filter=Expression.from_query(**FILTERS).to_arrow())
|
|
29
|
+
elif COLUMNS:
|
|
30
|
+
root = root.scanner(columns=COLUMNS)
|
|
31
|
+
|
|
32
|
+
if FEDERATED:
|
|
33
|
+
app = GraphQL.federated({FEDERATED: root}, debug=DEBUG)
|
|
34
|
+
else:
|
|
35
|
+
app = GraphQL(root, debug=DEBUG)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: graphique
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2
|
|
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
|
|
@@ -88,7 +88,7 @@ app = GraphQL.federated({<name>: ds.dataset(...), ...}, keys={...}) # Tables on
|
|
|
88
88
|
Start like any ASGI app.
|
|
89
89
|
|
|
90
90
|
```console
|
|
91
|
-
uvicorn <
|
|
91
|
+
uvicorn <module>:app
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
Configuration options exist to provide a convenient no-code solution, but are subject to change in the future. Using a custom app is recommended for production usage.
|
|
@@ -138,8 +138,8 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
## Dependencies
|
|
141
|
-
* pyarrow >=
|
|
142
|
-
* strawberry-graphql[asgi,cli]
|
|
141
|
+
* pyarrow >=12
|
|
142
|
+
* strawberry-graphql[asgi,cli]
|
|
143
143
|
* uvicorn (or other [ASGI server](https://asgi.readthedocs.io/en/latest/implementations.html))
|
|
144
144
|
|
|
145
145
|
## Tests
|
|
@@ -150,12 +150,19 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
150
150
|
```
|
|
151
151
|
|
|
152
152
|
## Changes
|
|
153
|
+
1.2
|
|
154
|
+
|
|
155
|
+
* Pyarrow >=12 required
|
|
156
|
+
* Grouping fragments optimized
|
|
157
|
+
* Group by empty columns
|
|
158
|
+
* Batch sorting and grouping into lists
|
|
159
|
+
|
|
153
160
|
1.1
|
|
154
161
|
|
|
155
162
|
* Pyarrow >=11 required
|
|
156
163
|
* Python >=3.8 required
|
|
157
164
|
* Scannable functions added
|
|
158
|
-
* List aggregations
|
|
165
|
+
* List aggregations deprecated
|
|
159
166
|
* Group by fragments
|
|
160
167
|
* Month day nano interval array
|
|
161
168
|
* `min` and `max` fields memory optimized
|
|
@@ -226,9 +233,9 @@ If index columns are detected in the schema metadata, then an initial `filter` w
|
|
|
226
233
|
|
|
227
234
|
0.2
|
|
228
235
|
|
|
236
|
+
* Pyarrow >= 2 required
|
|
229
237
|
* `ListColumn` and `StructColumn` types
|
|
230
238
|
* `Groups` type with `aggregate` field
|
|
231
239
|
* `group` and `unique` optimized
|
|
232
|
-
* pyarrow >= 2 required
|
|
233
240
|
* Statistical fields: `mode`, `stddev`, `variance`
|
|
234
241
|
* `is_in`, `min`, and `max` optimized
|
|
@@ -28,7 +28,7 @@ classifiers = [
|
|
|
28
28
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
29
|
"Typing :: Typed",
|
|
30
30
|
]
|
|
31
|
-
dependencies = ["pyarrow>=
|
|
31
|
+
dependencies = ["pyarrow>=12", "strawberry-graphql[asgi,cli]>=0.160"]
|
|
32
32
|
|
|
33
33
|
[project.urls]
|
|
34
34
|
Homepage = "https://github.com/coady/graphique"
|
|
@@ -50,6 +50,9 @@ graphique = ["py.typed"]
|
|
|
50
50
|
line-length = 100
|
|
51
51
|
skip-string-normalization = true
|
|
52
52
|
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
ignore = ["E501"]
|
|
55
|
+
|
|
53
56
|
[[tool.mypy.overrides]]
|
|
54
57
|
module = ["pyarrow.*", "strawberry.*", "starlette.*"]
|
|
55
58
|
ignore_missing_imports = true
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Default GraphQL service.
|
|
3
|
-
"""
|
|
4
|
-
import pyarrow.compute as pc
|
|
5
|
-
import pyarrow.dataset as ds
|
|
6
|
-
from .inputs import Expression
|
|
7
|
-
from .middleware import GraphQL
|
|
8
|
-
from .settings import COLUMNS, DEBUG, FEDERATED, FILTERS, PARQUET_PATH
|
|
9
|
-
|
|
10
|
-
root = dataset = ds.dataset(PARQUET_PATH, partitioning='hive' if PARQUET_PATH.is_dir() else None)
|
|
11
|
-
|
|
12
|
-
if isinstance(COLUMNS, dict):
|
|
13
|
-
COLUMNS = {alias: pc.field(name) for alias, name in COLUMNS.items()}
|
|
14
|
-
if FILTERS is not None:
|
|
15
|
-
root = dataset.to_table(columns=COLUMNS, filter=Expression.from_query(**FILTERS).to_arrow())
|
|
16
|
-
elif COLUMNS:
|
|
17
|
-
root = dataset.scanner(columns=COLUMNS)
|
|
18
|
-
|
|
19
|
-
if FEDERATED:
|
|
20
|
-
app = GraphQL.federated({FEDERATED: root}, debug=DEBUG)
|
|
21
|
-
else:
|
|
22
|
-
app = GraphQL(root, debug=DEBUG)
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
from starlette.config import Config
|
|
4
|
-
|
|
5
|
-
config = Config('.env')
|
|
6
|
-
PARQUET_PATH = Path(config('PARQUET_PATH')).resolve()
|
|
7
|
-
FEDERATED = config('FEDERATED', default='')
|
|
8
|
-
DEBUG = config('DEBUG', cast=bool, default=False)
|
|
9
|
-
COLUMNS = config('COLUMNS', cast=json.loads, default=None)
|
|
10
|
-
FILTERS = config('FILTERS', cast=json.loads, default=None)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|