graphique 2.0.2__tar.gz → 2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {graphique-2.0.2 → graphique-2.1}/PKG-INFO +28 -17
  2. {graphique-2.0.2 → graphique-2.1}/README.md +23 -13
  3. graphique-2.1/graphique/__init__.py +2 -0
  4. graphique-2.1/graphique/core.py +100 -0
  5. {graphique-2.0.2 → graphique-2.1}/graphique/inputs.py +138 -34
  6. {graphique-2.0.2 → graphique-2.1}/graphique/interface.py +248 -86
  7. {graphique-2.0.2 → graphique-2.1}/graphique/middleware.py +83 -41
  8. {graphique-2.0.2 → graphique-2.1}/graphique/models.py +29 -34
  9. graphique-2.0.2/graphique/shell.py → graphique-2.1/graphique/partition.py +2 -4
  10. {graphique-2.0.2 → graphique-2.1}/graphique/scalars.py +30 -4
  11. graphique-2.1/graphique/service.py +35 -0
  12. {graphique-2.0.2 → graphique-2.1}/graphique.egg-info/PKG-INFO +28 -17
  13. {graphique-2.0.2 → graphique-2.1}/graphique.egg-info/SOURCES.txt +1 -1
  14. {graphique-2.0.2 → graphique-2.1}/graphique.egg-info/requires.txt +2 -1
  15. {graphique-2.0.2 → graphique-2.1}/pyproject.toml +12 -13
  16. {graphique-2.0.2 → graphique-2.1}/tests/test_core.py +36 -4
  17. {graphique-2.0.2 → graphique-2.1}/tests/test_dataset.py +93 -42
  18. {graphique-2.0.2 → graphique-2.1}/tests/test_models.py +24 -4
  19. {graphique-2.0.2 → graphique-2.1}/tests/test_service.py +57 -3
  20. graphique-2.0.2/graphique/__init__.py +0 -3
  21. graphique-2.0.2/graphique/core.py +0 -83
  22. graphique-2.0.2/graphique/service.py +0 -44
  23. {graphique-2.0.2 → graphique-2.1}/LICENSE.txt +0 -0
  24. {graphique-2.0.2 → graphique-2.1}/graphique/py.typed +0 -0
  25. {graphique-2.0.2 → graphique-2.1}/graphique.egg-info/dependency_links.txt +0 -0
  26. {graphique-2.0.2 → graphique-2.1}/graphique.egg-info/top_level.txt +0 -0
  27. {graphique-2.0.2 → graphique-2.1}/setup.cfg +0 -0
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphique
3
- Version: 2.0.2
3
+ Version: 2.1
4
4
  Summary: GraphQL service for python dataframes and parquet datasets.
5
- Author-email: Aric Coady <aric.coady@gmail.com>
5
+ Author: A. Coady
6
6
  License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/coady/graphique
8
8
  Project-URL: Documentation, https://coady.github.io/graphique
9
9
  Project-URL: Changelog, https://github.com/coady/graphique/blob/main/CHANGELOG.md
10
10
  Project-URL: Issues, https://github.com/coady/graphique/issues
11
- Keywords: graphql,arrow,parquet
11
+ Keywords: graphql,ibis,dataframes,arrow,parquet
12
12
  Classifier: Development Status :: 5 - Production/Stable
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Intended Audience :: Science/Research
@@ -26,7 +26,7 @@ Classifier: Typing :: Typed
26
26
  Requires-Python: >=3.11
27
27
  Description-Content-Type: text/markdown
28
28
  License-File: LICENSE.txt
29
- Requires-Dist: pyarrow>=20
29
+ Requires-Dist: pyarrow>=23
30
30
  Requires-Dist: strawberry-graphql[asgi,cli]>=0.288
31
31
  Requires-Dist: ibis-framework>=10.7
32
32
  Requires-Dist: pyarrow-hotfix
@@ -36,6 +36,7 @@ Requires-Dist: uvicorn[standard]; extra == "server"
36
36
  Requires-Dist: duckdb; extra == "server"
37
37
  Provides-Extra: cli
38
38
  Requires-Dist: tqdm; extra == "cli"
39
+ Requires-Dist: typer; extra == "cli"
39
40
  Dynamic: license-file
40
41
 
41
42
  [![image](https://img.shields.io/pypi/v/graphique.svg)](https://pypi.org/project/graphique/)
@@ -74,25 +75,27 @@ outputs the graphql schema.
74
75
  The example app uses [Starlette's config](https://www.starlette.io/config/): in environment variables or a `.env` file.
75
76
 
76
77
  * PARQUET_PATH: path to the parquet directory or file
77
- * FEDERATED = '': field name to extend type `Query` with a federated `Table`
78
- * METRICS = False: include timings from apollo tracing extension
78
+ * NAME = '': GraphQL field on `Query`; defaults to root type
79
79
  * COLUMNS = None: list of names, or mapping of aliases, of columns to select
80
- * FILTERS = None: json `filter` query for which rows to read at startup
81
80
 
82
81
  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.
83
82
 
84
83
  ### App
85
84
  For more options create a custom [ASGI](https://asgi.readthedocs.io/en/latest/index.html) app. Call graphique's `GraphQL` on an ibis [Table](https://ibis-project.org/reference/expression-tables) or arrow [Dataset](https://arrow.apache.org/docs/python/api/dataset.html).
86
- Supply a mapping of names to datasets for multiple roots, and to enable federation.
85
+ Use a `Query` type with dataset attributes for multiple roots, and to enable federation.
87
86
 
88
87
  ```python
89
88
  import ibis
90
- from graphique import GraphQL
89
+ from graphique import GraphQL, typed
91
90
 
92
- source = ibis.read_*(...) # or ibis.connect(...).table(...) or pyarrow.dataset.dataset(...)
91
+ source = ibis.read_*(...) # or `ibis.connect(...).table(...)` or `pyarrow.dataset.dataset(...)`
93
92
  # apply initial projections or filters to `source`
94
93
  app = GraphQL(source) # Table is root query type
95
- app = GraphQL.federated({<name>: source, ...}, keys={<name>: [], ...}) # Tables on federated fields
94
+
95
+ # multiple named fields, with optional federation keys
96
+ class Query:
97
+ name = source # or `typed(source, name, keys=...)`
98
+ app = GraphQL(Query)
96
99
  ```
97
100
 
98
101
  Start like any ASGI app.
@@ -111,7 +114,8 @@ uvicorn <module>:app
111
114
  #### selection
112
115
  * `slice`: contiguous selection of rows
113
116
  * `filter`: select rows by predicates
114
- * `join`: join tables by key columns
117
+ * `join`, `asofJoin`, `crossJoin`: join tables by key columns
118
+ * `difference`, `intersect`, `union`: set operations on tables
115
119
  * `take`: rows by index
116
120
  * `dropNull`: remove rows with nulls
117
121
 
@@ -121,6 +125,7 @@ uvicorn <module>:app
121
125
  * `column`: access a column of any type by name
122
126
  * `row`: provides a field for each scalar of a single row
123
127
  * `cast`: cast column types
128
+ * `unpack`: project struct fields
124
129
  * `fillNull`: fill null values
125
130
 
126
131
  #### aggregation
@@ -128,20 +133,26 @@ uvicorn <module>:app
128
133
  * `distinct`: group with all columns
129
134
  * `runs`: provisionally group by adjacency
130
135
  * `unnest`: unnest an array column
131
- * `count`: number of rows
136
+ * `count`, `any`: number of rows
132
137
 
133
138
  #### ordering
134
139
  * `order`: sort table by given columns
135
- * options `limit` and `dense`: select rows with smallest or largest values
140
+ * `first`: provisionally sort and filter by rank
141
+
142
+ #### reflection
143
+ * `type`: type of data source
144
+ * `schema`: field names and types
145
+ * `optional`: nullable for errors
146
+ * `toSql`: compiles SQL query
136
147
 
137
148
  ### Performance
138
- Performance is dependent on the [ibis backend](https://ibis-project.org/backends/duckdb), which defaults to [duckdb](https://duckdb.org/). There are no internal Python loops. Scalars do not become Python types until serialized.
149
+ Performance is dependent on the [ibis backend](https://ibis-project.org/backends/duckdb), which defaults to [duckdb](https://duckdb.org/). There are no internal Python loops. Scalars do not become Python types until serialized. Table fields are lazily evaluated up until scalars are reached, and automatically cached as needed for multiple fields.
139
150
 
140
- [PyArrow](https://arrow.apache.org/docs/python/) is also used for partitioned dataset optimizations, and for any feature which ibis does not support. Table fields are lazily evaluated up until scalars are reached, and automatically cached as needed for multiple fields.
151
+ [PyArrow](https://arrow.apache.org/docs/python/) is also used for partitioned dataset optimizations. `python -m graphique.partition` is a command-line script provided in `graphique[cli]`, for out-of-core partitioning.
141
152
 
142
153
  ## Installation
143
154
  ```console
144
- pip install graphique[server]
155
+ pip install graphique[server,cli]
145
156
  ```
146
157
 
147
158
  ## Dependencies
@@ -34,25 +34,27 @@ outputs the graphql schema.
34
34
  The example app uses [Starlette's config](https://www.starlette.io/config/): in environment variables or a `.env` file.
35
35
 
36
36
  * PARQUET_PATH: path to the parquet directory or file
37
- * FEDERATED = '': field name to extend type `Query` with a federated `Table`
38
- * METRICS = False: include timings from apollo tracing extension
37
+ * NAME = '': GraphQL field on `Query`; defaults to root type
39
38
  * COLUMNS = None: list of names, or mapping of aliases, of columns to select
40
- * FILTERS = None: json `filter` query for which rows to read at startup
41
39
 
42
40
  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.
43
41
 
44
42
  ### App
45
43
  For more options create a custom [ASGI](https://asgi.readthedocs.io/en/latest/index.html) app. Call graphique's `GraphQL` on an ibis [Table](https://ibis-project.org/reference/expression-tables) or arrow [Dataset](https://arrow.apache.org/docs/python/api/dataset.html).
46
- Supply a mapping of names to datasets for multiple roots, and to enable federation.
44
+ Use a `Query` type with dataset attributes for multiple roots, and to enable federation.
47
45
 
48
46
  ```python
49
47
  import ibis
50
- from graphique import GraphQL
48
+ from graphique import GraphQL, typed
51
49
 
52
- source = ibis.read_*(...) # or ibis.connect(...).table(...) or pyarrow.dataset.dataset(...)
50
+ source = ibis.read_*(...) # or `ibis.connect(...).table(...)` or `pyarrow.dataset.dataset(...)`
53
51
  # apply initial projections or filters to `source`
54
52
  app = GraphQL(source) # Table is root query type
55
- app = GraphQL.federated({<name>: source, ...}, keys={<name>: [], ...}) # Tables on federated fields
53
+
54
+ # multiple named fields, with optional federation keys
55
+ class Query:
56
+ name = source # or `typed(source, name, keys=...)`
57
+ app = GraphQL(Query)
56
58
  ```
57
59
 
58
60
  Start like any ASGI app.
@@ -71,7 +73,8 @@ uvicorn <module>:app
71
73
  #### selection
72
74
  * `slice`: contiguous selection of rows
73
75
  * `filter`: select rows by predicates
74
- * `join`: join tables by key columns
76
+ * `join`, `asofJoin`, `crossJoin`: join tables by key columns
77
+ * `difference`, `intersect`, `union`: set operations on tables
75
78
  * `take`: rows by index
76
79
  * `dropNull`: remove rows with nulls
77
80
 
@@ -81,6 +84,7 @@ uvicorn <module>:app
81
84
  * `column`: access a column of any type by name
82
85
  * `row`: provides a field for each scalar of a single row
83
86
  * `cast`: cast column types
87
+ * `unpack`: project struct fields
84
88
  * `fillNull`: fill null values
85
89
 
86
90
  #### aggregation
@@ -88,20 +92,26 @@ uvicorn <module>:app
88
92
  * `distinct`: group with all columns
89
93
  * `runs`: provisionally group by adjacency
90
94
  * `unnest`: unnest an array column
91
- * `count`: number of rows
95
+ * `count`, `any`: number of rows
92
96
 
93
97
  #### ordering
94
98
  * `order`: sort table by given columns
95
- * options `limit` and `dense`: select rows with smallest or largest values
99
+ * `first`: provisionally sort and filter by rank
100
+
101
+ #### reflection
102
+ * `type`: type of data source
103
+ * `schema`: field names and types
104
+ * `optional`: nullable for errors
105
+ * `toSql`: compiles SQL query
96
106
 
97
107
  ### Performance
98
- Performance is dependent on the [ibis backend](https://ibis-project.org/backends/duckdb), which defaults to [duckdb](https://duckdb.org/). There are no internal Python loops. Scalars do not become Python types until serialized.
108
+ Performance is dependent on the [ibis backend](https://ibis-project.org/backends/duckdb), which defaults to [duckdb](https://duckdb.org/). There are no internal Python loops. Scalars do not become Python types until serialized. Table fields are lazily evaluated up until scalars are reached, and automatically cached as needed for multiple fields.
99
109
 
100
- [PyArrow](https://arrow.apache.org/docs/python/) is also used for partitioned dataset optimizations, and for any feature which ibis does not support. Table fields are lazily evaluated up until scalars are reached, and automatically cached as needed for multiple fields.
110
+ [PyArrow](https://arrow.apache.org/docs/python/) is also used for partitioned dataset optimizations. `python -m graphique.partition` is a command-line script provided in `graphique[cli]`, for out-of-core partitioning.
101
111
 
102
112
  ## Installation
103
113
  ```console
104
- pip install graphique[server]
114
+ pip install graphique[server,cli]
105
115
  ```
106
116
 
107
117
  ## Dependencies
@@ -0,0 +1,2 @@
1
+ from .interface import Dataset
2
+ from .middleware import GraphQL, MetricsExtension, implement, typed
@@ -0,0 +1,100 @@
1
+ """
2
+ Core arrow utilities.
3
+ """
4
+
5
+ import bisect
6
+ import itertools
7
+
8
+ import ibis
9
+ import pyarrow as pa
10
+ import pyarrow.dataset as ds
11
+
12
+
13
+ class links:
14
+ ref = "https://ibis-project.org/reference"
15
+ types = f"[data type]({ref}/datatypes)"
16
+ schema = f"[table schema]({ref}/schemas#ibis.expr.schema.Schema)"
17
+
18
+
19
+ def getitems(obj, *keys):
20
+ """Nested `getitem`."""
21
+ for key in keys:
22
+ obj = obj[key]
23
+ return obj
24
+
25
+
26
+ def order_key(name: str):
27
+ """Parse sort order."""
28
+ return (ibis.desc if name.startswith("-") else ibis.asc)(ibis._[name.lstrip("-")])
29
+
30
+
31
+ def rank_over(
32
+ table: ibis.Table,
33
+ by: list[str],
34
+ over: list[str],
35
+ index: ibis.Column,
36
+ rank: int = 1,
37
+ ) -> ibis.Table:
38
+ """Filter rows by rank within each grouping window."""
39
+ order_by = list(map(order_key, by))
40
+ table = table.mutate(_=index.over(group_by=over, order_by=order_by))
41
+ return table.filter(table["_"] < rank).drop("_").order_by(*order_by)
42
+
43
+
44
+ class Parquet(ds.Dataset):
45
+ """Partitioned parquet dataset."""
46
+
47
+ def schema(self) -> pa.Schema:
48
+ """partition schema"""
49
+ return self.partitioning.schema if hasattr(self, "partitioning") else pa.schema([])
50
+
51
+ def keys(self, *names) -> list:
52
+ """Return prefix of matching partition keys."""
53
+ keys = set(Parquet.schema(self).names)
54
+ return list(itertools.takewhile(lambda name: name.lstrip("-") in keys, names))
55
+
56
+ def fragments(self, counts: str = "", path: str = "__path__") -> ibis.Table:
57
+ """Return partition fragments as a table."""
58
+ parts = []
59
+ for frag in self.get_fragments():
60
+ parts.append(ds.get_partition_keys(frag.partition_expression))
61
+ parts[-1][path] = frag.path
62
+ if counts:
63
+ parts[-1][counts] = frag.count_rows()
64
+ return ibis.memtable(pa.Table.from_pylist(parts))
65
+
66
+ def filter(self, expr: ds.Expression | None) -> ds.Dataset | None:
67
+ """Attempt to apply filter to partition keys."""
68
+ try: # raises ValueError if filter references non-partition keys
69
+ ds.dataset([], schema=self.partitioning.schema).scanner(filter=expr)
70
+ except (AttributeError, ValueError):
71
+ return None
72
+ if expr is None:
73
+ return self
74
+ paths = [frag.path for frag in self.get_fragments(expr)]
75
+ return ds.dataset(paths, partitioning=self.partitioning)
76
+
77
+ def to_table(self, name: str | None = None) -> ibis.Table:
78
+ """Return ibis `Table` from filtered dataset."""
79
+ paths = [frag.path for frag in self.get_fragments()]
80
+ return ibis.read_parquet(paths, table_name=name)
81
+
82
+ def order(self, *names: str, limit: int | None = None) -> ds.Dataset:
83
+ """Return ordered partitions of the dataset."""
84
+ table = Parquet.fragments(self, counts="" if limit is None else "_")
85
+ table = table.order_by(*map(order_key, names)).cache()
86
+ if limit is not None:
87
+ limit = bisect.bisect_left(table["_"].cumsum().to_list(), limit) + 1
88
+ return ds.dataset(table[:limit]["__path__"].to_list(), partitioning=self.partitioning)
89
+
90
+ def first(self, *names: str, rank: int = 1, dense: bool = False) -> ds.Dataset:
91
+ """Return ordered partitions up to max rank (dense or sparse)."""
92
+ keys = {name.strip("-"): order_key(name) for name in names}
93
+ if dense or rank == 1:
94
+ table = Parquet.fragments(self).aggregate(_=ibis._["__path__"].collect(), by=list(keys))
95
+ paths = table.order_by(*keys.values())[:rank]["_"].unnest()
96
+ else:
97
+ table = Parquet.fragments(self, counts="_").order_by(*keys.values()).cache()
98
+ limit = bisect.bisect_left(table["_"].cumsum().to_list(), rank) + 1
99
+ paths = table.semi_join(table[:limit].select(*keys).distinct(), list(keys))["__path__"]
100
+ return ds.dataset(paths.to_list(), partitioning=self.partitioning)
@@ -12,7 +12,7 @@ from datetime import date, datetime, time
12
12
  from decimal import Decimal
13
13
  from typing import Generic, TypeVar
14
14
 
15
- import ibis
15
+ import ibis.expr.types
16
16
  import pyarrow as pa
17
17
  import pyarrow.compute as pc
18
18
  import pyarrow.dataset as ds
@@ -22,7 +22,6 @@ from strawberry.annotation import StrawberryAnnotation
22
22
  from strawberry.scalars import JSON
23
23
  from strawberry.schema_directive import Location
24
24
  from strawberry.types.arguments import StrawberryArgument
25
- from strawberry.types.field import StrawberryField
26
25
 
27
26
  from .core import getitems, links, order_key
28
27
  from .scalars import Duration
@@ -44,14 +43,15 @@ class optional:
44
43
  """
45
44
 
46
45
 
47
- @use_doc(strawberry.schema_directive, locations=[Location.OBJECT, Location.FIELD_DEFINITION])
46
+ @use_doc(
47
+ strawberry.schema_directive,
48
+ locations=[Location.ARGUMENT_DEFINITION, Location.FIELD_DEFINITION, Location.OBJECT],
49
+ )
48
50
  class provisional:
49
51
  """Provisional feature; subject to change in the future."""
50
52
 
51
53
 
52
- def default_field(
53
- default=UNSET, func: Callable | None = None, nullable: bool = False, **kwargs
54
- ) -> StrawberryField:
54
+ def default_field(default=UNSET, func: Callable | None = None, nullable: bool = False, **kwargs):
55
55
  """Use dataclass `default_factory` for `UNSET` or mutables."""
56
56
  if func is not None:
57
57
  kwargs["description"] = inspect.getdoc(func).splitlines()[0]
@@ -83,10 +83,12 @@ class Filter(Generic[T]):
83
83
  @classmethod
84
84
  def resolve_args(cls, types: dict) -> Iterable[StrawberryArgument]:
85
85
  """Generate dynamically resolved arguments for filter field."""
86
- for name in types:
87
- annotation = StrawberryAnnotation(cls[types[name]]) # type: ignore
88
- if types[name] not in (list, dict):
89
- yield StrawberryArgument(name, name, annotation, default={})
86
+ for name, scalar in types.items():
87
+ if getattr(scalar, "__origin__", None) is list:
88
+ annotation = ArrayFilter[scalar.__args__[0]]
89
+ else:
90
+ annotation = cls[scalar] # type: ignore
91
+ yield StrawberryArgument(name, name, StrawberryAnnotation(annotation), default={})
90
92
  annotation = StrawberryAnnotation(Expression | None)
91
93
  yield StrawberryArgument("where", None, annotation, default=None)
92
94
 
@@ -98,9 +100,11 @@ class Filter(Generic[T]):
98
100
  for op, value in query:
99
101
  match value, op:
100
102
  case list(), "eq":
101
- yield field.isin(value)
103
+ yield field == value[0] if len(value) == 1 else field.isin(value)
102
104
  case list(), "ne":
103
- yield field.notin(value)
105
+ yield field != value[0] if len(value) == 1 else field.notin(value)
106
+ case _, "contains":
107
+ yield field.contains(value)
104
108
  case _:
105
109
  yield getattr(operator, op)(field, value)
106
110
 
@@ -114,28 +118,37 @@ class Filter(Generic[T]):
114
118
  if isinstance(value, list):
115
119
  expr = pc.is_in(field, pa.array(value))
116
120
  exprs.append(pc.invert(expr) if op == "ne" else expr)
121
+ elif op == "contains": # placeholder, will not be in partition schema
122
+ exprs.append(field == value)
117
123
  else:
118
124
  exprs.append(getattr(operator, op)(field, value))
119
125
  return functools.reduce(operator.and_, exprs or [None])
120
126
 
121
127
 
128
+ @strawberry.input(description="predicates for arrays")
129
+ class ArrayFilter(Generic[T]):
130
+ contains: T | None = default_field(description="array contains element")
131
+
132
+ __iter__ = Filter.__iter__
133
+
134
+
122
135
  @strawberry.input(description="name and optional alias for aggregation")
123
136
  class Aggregate:
124
137
  name: str = strawberry.field(description="column name")
125
138
  alias: str = strawberry.field(default="", description="output column name")
126
139
  where: Expression | None = None
127
140
 
128
- def to_ibis(self, func: str, **options) -> tuple:
141
+ def to_ibis(self, func: str, *args, **options) -> tuple:
129
142
  options["where"] = self.where and self.where.to_ibis()
130
- return (self.alias or self.name), getattr(ibis._[self.name], func)(**options)
143
+ return (self.alias or self.name), getattr(ibis._[self.name], func)(*args, **options)
131
144
 
132
145
 
133
146
  @strawberry.input
134
147
  class UniqueAggregate(Aggregate):
135
148
  approx: bool = False
136
149
 
137
- def to_ibis(self, func: str, **options) -> tuple:
138
- return super().to_ibis("approx_" + func if self.approx else func, **options)
150
+ def to_ibis(self, func: str, *args) -> tuple:
151
+ return super().to_ibis("approx_" + func if self.approx else func, *args)
139
152
 
140
153
 
141
154
  @strawberry.input
@@ -155,7 +168,7 @@ class OrderAggregate(Aggregate):
155
168
  class VarAggregate(Aggregate):
156
169
  how: str = "sample"
157
170
 
158
- def to_ibis(self, func: str, **_) -> tuple:
171
+ def to_ibis(self, func: str) -> tuple:
159
172
  return super().to_ibis(func, how=self.how)
160
173
 
161
174
 
@@ -163,8 +176,8 @@ class VarAggregate(Aggregate):
163
176
  class QuantileAggregate(UniqueAggregate):
164
177
  q: float = 0.5
165
178
 
166
- def to_ibis(self, func: str, **_) -> tuple:
167
- return super().to_ibis(func, quantile=self.q)
179
+ def to_ibis(self, func: str, *_) -> tuple:
180
+ return super().to_ibis(func, self.q)
168
181
 
169
182
 
170
183
  @strawberry.input
@@ -175,17 +188,40 @@ class CollectAggregate(OrderAggregate):
175
188
  return super().to_ibis(func, distinct=self.distinct)
176
189
 
177
190
 
191
+ @strawberry.input
192
+ class ConcatAggregate(Aggregate):
193
+ sep: str = ","
194
+ order_by: list[str] = default_field([])
195
+
196
+ def to_ibis(self, func: str) -> tuple:
197
+ order_by = list(map(order_key, self.order_by)) or None
198
+ return super().to_ibis("group_" + func, sep=self.sep, order_by=order_by)
199
+
200
+
201
+ @strawberry.input
202
+ class ArgAggregate(Aggregate):
203
+ key: str
204
+
205
+ def to_ibis(self, func: str) -> tuple:
206
+ return super().to_ibis(func, key=self.key)
207
+
208
+
178
209
  @strawberry.input(description=f"aggregation [expressions]({links.ref}/expression-generic)")
179
210
  class Aggregates:
180
211
  all: list[Aggregate] = default_field([], func=ibis.expr.types.BooleanColumn.all)
181
212
  any: list[Aggregate] = default_field([], func=ibis.expr.types.BooleanColumn.any)
213
+ argmax: list[ArgAggregate] = default_field([], func=ibis.Column.argmax)
214
+ argmin: list[ArgAggregate] = default_field([], func=ibis.Column.argmin)
182
215
  collect: list[CollectAggregate] = default_field([], func=ibis.Column.collect)
216
+ concat: list[ConcatAggregate] = default_field([], func=ibis.Column.group_concat)
183
217
  count: list[Aggregate] = default_field([], func=ibis.Column.count)
184
218
  first: list[OrderAggregate] = default_field([], func=ibis.Column.first)
219
+ kurtosis: list[VarAggregate] = default_field([], func=ibis.expr.types.NumericColumn.kurtosis)
185
220
  last: list[OrderAggregate] = default_field([], func=ibis.Column.last)
186
221
  max: list[Aggregate] = default_field([], func=ibis.Column.max)
187
222
  mean: list[Aggregate] = default_field([], func=ibis.expr.types.NumericColumn.mean)
188
223
  min: list[Aggregate] = default_field([], func=ibis.Column.min)
224
+ mode: list[Aggregate] = default_field([], func=ibis.Column.mode)
189
225
  nunique: list[UniqueAggregate] = default_field([], func=ibis.Column.nunique)
190
226
  quantile: list[QuantileAggregate] = default_field([], func=ibis.Column.quantile)
191
227
  std: list[VarAggregate] = default_field([], func=ibis.expr.types.NumericColumn.std)
@@ -198,7 +234,7 @@ class Aggregates:
198
234
  yield agg.to_ibis(name)
199
235
 
200
236
 
201
- @strawberry.input(description="typed scalars")
237
+ @strawberry.input(description="typed scalars", one_of=True)
202
238
  class Scalars:
203
239
  base64: strawberry.scalars.Base64 | None = default_field(description="binary scalar")
204
240
  date: date | None = default_field(description="date scalar")
@@ -218,7 +254,9 @@ class Expression:
218
254
  name: list[str] = default_field([], description="column name(s)")
219
255
  value: JSON | None = default_field(description="JSON scalar", nullable=True)
220
256
  scalar: Scalars | None = default_field(description="typed scalar")
221
- row_number: None = default_field(func=ibis.row_number)
257
+ row_number: None = default_field(
258
+ func=ibis.row_number, deprecation_reason="use `window.rowNumber`"
259
+ )
222
260
 
223
261
  eq: list[Expression] = default_field([], description="==")
224
262
  ne: list[Expression] = default_field([], description="!=")
@@ -240,13 +278,21 @@ class Expression:
240
278
  truediv: list[Expression] = default_field([], name="div", description="/")
241
279
 
242
280
  coalesce: list[Expression] = default_field([], func=ibis.Column.coalesce)
243
- cume_dist: Expression | None = default_field(func=ibis.Column.cume_dist)
281
+ cume_dist: Expression | None = default_field(
282
+ func=ibis.Column.cume_dist, deprecation_reason="use `window.cumeDist`"
283
+ )
244
284
  cummax: Expression | None = default_field(func=ibis.Column.cummax)
245
285
  cummin: Expression | None = default_field(func=ibis.Column.cummin)
246
- dense_rank: Expression | None = default_field(func=ibis.Column.dense_rank)
286
+ dense_rank: Expression | None = default_field(
287
+ func=ibis.Column.dense_rank, deprecation_reason="use `window.denseRank`"
288
+ )
247
289
  ifelse: list[Expression] = default_field([], func=ibis.expr.types.BooleanColumn.ifelse)
248
- percent_rank: Expression | None = default_field(func=ibis.Column.percent_rank)
249
- rank: Expression | None = default_field(func=ibis.Column.rank)
290
+ percent_rank: Expression | None = default_field(
291
+ func=ibis.Column.percent_rank, deprecation_reason="use `window.percentRank`"
292
+ )
293
+ rank: Expression | None = default_field(
294
+ func=ibis.Column.rank, deprecation_reason="use `window.rank`"
295
+ )
250
296
 
251
297
  array: Arrays | None = default_field(description="array value functions")
252
298
  numeric: Numeric | None = default_field(description="numeric functions")
@@ -283,10 +329,10 @@ class Expression:
283
329
  yield from field
284
330
 
285
331
  def to_ibis(self) -> ibis.Deferred | None:
286
- fields = list(self) or [None]
287
- if len(fields) == 1:
288
- return fields[0]
289
- raise ValueError(f"conflicting inputs: {', '.join(map(str, fields))}")
332
+ fields = list(self)
333
+ if len(fields) > 1:
334
+ raise ValueError("conflicting inputs: " + ", ".join(map(str, fields)))
335
+ return next(iter(fields), None) # type: ignore
290
336
 
291
337
 
292
338
  @strawberry.input(description="an `Expression` with an optional alias")
@@ -304,6 +350,7 @@ class Projection(Expression):
304
350
  class Arrays:
305
351
  alls: Expression | None = default_field(func=ibis.expr.types.ArrayValue.alls)
306
352
  anys: Expression | None = default_field(func=ibis.expr.types.ArrayValue.anys)
353
+ flatten: Expression | None = default_field(func=ibis.expr.types.ArrayValue.flatten)
307
354
  length: Expression | None = default_field(func=ibis.expr.types.ArrayValue.length)
308
355
  maxs: Expression | None = default_field(func=ibis.expr.types.ArrayValue.maxs)
309
356
  means: Expression | None = default_field(func=ibis.expr.types.ArrayValue.means)
@@ -313,13 +360,25 @@ class Arrays:
313
360
  sums: Expression | None = default_field(func=ibis.expr.types.ArrayValue.sums)
314
361
  unique: Expression | None = default_field(func=ibis.expr.types.ArrayValue.unique)
315
362
 
363
+ concat: list[Expression] = default_field([], func=ibis.expr.types.ArrayValue.concat)
364
+ contains: list[Expression] = default_field([], func=ibis.expr.types.ArrayValue.contains)
316
365
  index: list[Expression] = default_field([], func=ibis.expr.types.ArrayValue.index)
366
+ intersect: list[Expression] = default_field([], func=ibis.expr.types.ArrayValue.intersect)
367
+ remove: list[Expression] = default_field([], func=ibis.expr.types.ArrayValue.remove)
368
+ union: list[Expression] = default_field([], func=ibis.expr.types.ArrayValue.union)
369
+ zip: list[Expression] = default_field([], func=ibis.expr.types.ArrayValue.zip)
317
370
 
318
371
  slice: Expression | None = default_field(description="array slice")
319
372
  value: Expression | None = default_field(description="value at offset")
320
373
  offset: int = 0
321
374
  limit: int | None = None
322
375
 
376
+ join: Expression | None = default_field(func=ibis.expr.types.ArrayValue.join)
377
+ sep: str = ""
378
+
379
+ repeat: Expression | None = default_field(func=ibis.expr.types.ArrayValue.repeat)
380
+ n: int = 1
381
+
323
382
  def __iter__(self) -> Iterator[ibis.Deferred]:
324
383
  for name, (expr, *args) in Expression.items(self):
325
384
  match name:
@@ -327,6 +386,10 @@ class Arrays:
327
386
  yield expr[self.offset :][: self.limit]
328
387
  case "value":
329
388
  yield expr[self.offset]
389
+ case "join":
390
+ yield expr.join(self.sep)
391
+ case "repeat":
392
+ yield expr.repeat(self.n)
330
393
  case _:
331
394
  yield getattr(expr, name)(*args)
332
395
 
@@ -448,6 +511,9 @@ class Temporal:
448
511
  description=f"window [expressions]({links.ref}/expression-tables.html#ibis.window)"
449
512
  )
450
513
  class Window:
514
+ over: list[str] = default_field([], description="partition column names")
515
+ by: list[str] = default_field([], description="order column names within each partition")
516
+
451
517
  lag: Expression | None = default_field(func=ibis.Column.lag)
452
518
  lead: Expression | None = default_field(func=ibis.Column.lead)
453
519
 
@@ -459,15 +525,53 @@ class Window:
459
525
  ge: Expression | None = default_field(description=r"pairwise \>=")
460
526
  sub: Expression | None = default_field(description="pairwise -")
461
527
 
462
- offset: int = 1
463
- default: JSON | None = default_field(None, description="default JSON scalar")
528
+ count: Expression | None = default_field(func=ibis.Column.count)
529
+ sum: Expression | None = default_field(func=ibis.expr.types.NumericColumn.sum)
530
+ mean: Expression | None = default_field(func=ibis.expr.types.NumericColumn.mean)
531
+ min: Expression | None = default_field(func=ibis.Column.min)
532
+ max: Expression | None = default_field(func=ibis.Column.max)
533
+
534
+ row_number: None = default_field(func=ibis.row_number)
535
+ rank: None = default_field(func=ibis.rank)
536
+ dense_rank: None = default_field(func=ibis.dense_rank)
537
+ percent_rank: None = default_field(func=ibis.percent_rank)
538
+ cume_dist: None = default_field(func=ibis.cume_dist)
539
+ ntile: int = default_field(0, func=ibis.ntile)
540
+
541
+ preceding: int | None = strawberry.field(
542
+ default=None, description="number of preceding rows in the window"
543
+ )
544
+ following: int | None = strawberry.field(
545
+ default=None, description="number of following rows in the window"
546
+ )
547
+ range: bool = strawberry.field(
548
+ default=False, description="aggregate rows based upon differences in the order by values"
549
+ )
550
+
551
+ offset: int = strawberry.field(default=1, description="offset for lag and lead")
552
+ default: JSON | None = strawberry.field(default=None, description="default JSON scalar")
464
553
  scalar: Scalars | None = default_field(description="default typed scalar")
465
554
 
466
- def __iter__(self) -> Iterator[ibis.Deferred]:
555
+ def __iter__(self) -> Iterator[ibis.Expr]:
467
556
  (default,) = self.scalar or [self.default]
557
+ orderings = list(map(order_key, self.by))
558
+ win = cumwin = None
559
+ if self.over or self.by:
560
+ frame = ibis.range_window if self.range else ibis.window
561
+ win = frame(self.preceding, self.following, group_by=self.over, order_by=orderings)
562
+ if self.by and self.preceding is self.following is None:
563
+ cumwin = ibis.cumulative_window(group_by=self.over, order_by=orderings)
564
+ for name in ("row_number", "rank", "dense_rank", "percent_rank", "cume_dist"):
565
+ if getattr(self, name) is not UNSET:
566
+ yield getattr(ibis, name)().over(win)
567
+ if self.ntile:
568
+ yield ibis.ntile(self.ntile).over(win).cast("int32")
468
569
  for name, (expr,) in Expression.items(self):
469
570
  match name:
571
+ case "count" | "sum" | "mean" | "min" | "max":
572
+ yield getattr(expr, name)().over(cumwin or win)
470
573
  case "lag" | "lead":
471
- yield getattr(expr, name)(self.offset, default)
574
+ yield getattr(expr, name)(self.offset, default).over(win)
472
575
  case _:
473
- yield getattr(operator, name)(expr, expr.lag(self.offset)).fill_null(default)
576
+ lag = expr.lag(self.offset).over(win)
577
+ yield getattr(operator, name)(expr, lag).fill_null(default)