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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: graphique
3
- Version: 1.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 <package>:app
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 >=11
142
- * strawberry-graphql[asgi,cli] >=0.149.2
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 deprecaated
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 <package>:app
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 >=11
100
- * strawberry-graphql[asgi,cli] >=0.149.2
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 deprecaated
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
@@ -1,3 +1,3 @@
1
1
  from .middleware import GraphQL # noqa: F401
2
2
 
3
- __version__ = '1.1'
3
+ __version__ = '1.2'
@@ -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
- threader = futures.ThreadPoolExecutor(pa.cpu_count())
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
- return f'hash_{func}', self.option_map[func](**self.options)
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 aggregate(self, **funcs: Optional[pc.FunctionOptions]) -> pa.StructArray:
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
- items = {f'hash_{name}': funcs[name] for name in funcs}.items()
154
- indices = pc.list_parent_indices(self)
155
- groups = pc._group_by([pc.list_flatten(self)] * len(funcs), [indices], items)
156
- if len(groups) == len(self): # no empty or null scalars
157
- return groups
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.concat_arrays([groups.field(-1).cast('uint64'), empties])
161
- counters = [field.name for field in groups.type if 'count' in field.name]
162
- empties = pa.repeat(pa.scalar(dict.fromkeys(counters, 0), groups.type), len(empties))
163
- return pa.concat_arrays([groups, empties]).take(pc.sort_indices(indices))
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).field(0)
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)).field(0)
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).field(0)
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).field(0)
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 combine_dictionaries(self) -> tuple:
230
+ def call_indices(self, func: Callable) -> Array:
218
231
  if not pa.types.is_dictionary(self.type):
219
- return self, None
220
- if not self or len(self) <= max(len(chunk.dictionary) for chunk in self.iterchunks()):
221
- return self.cast(self.type.value_type), None
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) -> pa.ChunkedArray:
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) -> pa.ChunkedArray:
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) -> pa.Array:
254
- dictionary, indices = Column.combine_dictionaries(self)
255
- if indices is None:
256
- return dictionary
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(self, func: Callable, *rargs, **kwargs) -> pa.Table:
322
- return pa.Table.from_batches(
323
- threader.map(lambda batch: func(batch, *rargs, **kwargs), self.to_batches())
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 :]]) # noqa: E203
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
- column = self[names[0]]
403
- args = [(column, 'hash_count', pc.CountOptions(mode='all'))] if counts else []
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
- args.append((Column.indices(column), 'hash_min_max', None))
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
- args.append((Column.indices(column), 'hash_list', None))
410
+ columns[''] = np.arange(len(self))
411
+ args.append(('', 'list'))
410
412
  lists = funcs.pop('list')
411
- for func in funcs:
412
- args += [(self[agg.name], *agg.astuple(func)) for agg in funcs[func]] # type: ignore
413
- values, hashes, options = zip(*args) if args else [()] * 3
414
- keys = map(self.column, names)
415
- if isinstance(self, pa.Table):
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], next(arrays).flatten()):
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 = next(arrays)
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 aggs in funcs.values():
432
- columns.update(zip([agg.alias for agg in aggs], arrays))
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
- func = Agg.getfunc(key)
443
- row.update({agg.alias: func(self[agg.name], **agg.options) for agg in funcs[key]})
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 = {name for name in self.column_names if Column.is_list_type(self[name])}
455
+ lists = Table.list_fields(self)
451
456
  if not lists:
452
- raise ValueError(f"no list columns available: {self.column_names}")
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(self, *names: str, length: Optional[int] = None) -> pa.Table:
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(pa.table(columns), sort_keys=keys.items())
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 = [name for name in self.column_names if Column.is_list_type(self[name])]
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(self, *names: str, length: Optional[int] = None) -> pa.Table:
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(self, *names: str, length: Optional[int] = None, indices: str = '') -> pa.Table:
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) -> 'Table':
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).field(0), 0)
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 matched(self, func: Callable, *names: str) -> pa.Table:
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) -> 'Expression':
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
- """Provisional: return whether there are at least `length` rows."""
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
- if isinstance(self.table, pa.Table) or lists or not set(aggs) <= Field.associatives:
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 in parallel when possible
217
- table = T.map_batch(self.scanner(info), T.group, *by, counts=counts, **aggs)
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
- return type(self)(T.group(table, *by, counts=counts, list=list(map(Field, lists)), **aggs))
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
- # TODO(apache/arrow#33825): `get_partition_keys` will be public
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
- for name, tp in zip(schema.names, schema.types):
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) :]: # noqa: E203
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(self, info: Info, by: List[str], length: Optional[Long] = None) -> Self:
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
- Sorting on list columns will sort within scalars, all of which must have the same lengths.
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
- table = self.select(info)
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(table[key.lstrip('-')]) else scalars).append(key)
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], func: Callable) -> Self:
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 getattr(table, 'partitioning', None) and name in table.partitioning.schema.names:
359
+ elif name in self.schema().partitioning:
332
360
  values = pa.array(
333
- ds._get_partition_keys(fragment.partition_expression)[name]
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
- # TODO(ARROW-16212): replace with user defined function for multiple kernels
342
- batches = self.scanner(info).to_batches()
343
- table = pa.Table.from_batches(
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
- """Return table with minimum values per column."""
351
- return self.min_max(info, by, C.min)
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
- """Return table with maximum values per column."""
356
- return self.min_max(info, by, C.max)
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(deprecation_reason=list_deprecation)
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 function load arrays into memory as needed. See `scan` for scalar functions,
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
- table = self.select(info)
397
- lists = {name for name in table.column_names if C.is_list_type(table[name])}
398
- scalars = set(table.column_names) - lists
399
- for index, count in enumerate(T.list_value_length(table).to_pylist()):
400
- row = {name: pa.repeat(table[name][index], count) for name in scalars}
401
- row.update({name: table[name][index].values for name in lists})
402
- yield type(self)(pa.table(row))
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
- arrays = ListChunk.aggregate(table[name], **funcs).flatten()
442
- columns.update(zip([agg.alias for agg in aggs.values()], arrays))
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 = list_deprecation
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
- oneshot = len(selections(*info.selected_fields)) > 1 and isinstance(self.table, ds.Scanner)
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="""the kind of join: “left semi”, “right semi”, “left anti”, “right anti”, “inner”, “left outer”, “right outer”, “full outer”""",
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
- """Provisional: select rows from indices."""
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, Union
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
- class TimingExtension(strawberry.extensions.Extension):
22
- def on_request_start(self):
23
- self.start = datetime.now()
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 - self.start}")
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] * bool(debug))
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) -> set:
37
- """Return set of field name selections from strawberry `SelectedField`."""
38
- return set(itertools.chain(*map(_selections, fields)))
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
- """Provisional: select indices from a table on the root Query type."""
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 = 0
19
- days: int = 0
20
- nanoseconds: 'Long' = 0 # type: ignore
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.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 <package>:app
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 >=11
142
- * strawberry-graphql[asgi,cli] >=0.149.2
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 deprecaated
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
@@ -10,7 +10,6 @@ graphique/models.py
10
10
  graphique/py.typed
11
11
  graphique/scalars.py
12
12
  graphique/service.py
13
- graphique/settings.py
14
13
  graphique.egg-info/PKG-INFO
15
14
  graphique.egg-info/SOURCES.txt
16
15
  graphique.egg-info/dependency_links.txt
@@ -0,0 +1,5 @@
1
+ pyarrow>=12
2
+ strawberry-graphql[asgi,cli]>=0.160
3
+
4
+ [server]
5
+ uvicorn[standard]
@@ -28,7 +28,7 @@ classifiers = [
28
28
  "Topic :: Software Development :: Libraries :: Python Modules",
29
29
  "Typing :: Typed",
30
30
  ]
31
- dependencies = ["pyarrow>=11", "strawberry-graphql[asgi,cli]>=0.149.2"]
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)
@@ -1,5 +0,0 @@
1
- pyarrow>=11
2
- strawberry-graphql[asgi,cli]>=0.149.2
3
-
4
- [server]
5
- uvicorn[standard]
File without changes
File without changes
File without changes