Skip to content

Commit 9d20578

Browse files
author
Charles Lariviere
committed
Merge branch 'feature/aggregation' into develop
2 parents cc0d00a + 7fafd8b commit 9d20578

28 files changed

+664
-22
lines changed

README.md

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ pip install metabase-python
1313
```
1414

1515
## Usage
16-
This API is still experimental and may change significantly between minor versions.
1716

17+
### Connection
1818

1919
Start by creating an instance of Metabase with your credentials.
2020
```python
@@ -27,6 +27,7 @@ metabase = Metabase(
2727
)
2828
```
2929

30+
### Interacting with Endpoints
3031
You can then interact with any of the supported endpoints through the classes included in this package. Methods that
3132
instantiate an object from the Metabase API require the `using` parameter which expects an instance of `Metabase` such
3233
as the one we just instantiated above. All changes are reflected in Metabase instantly.
@@ -84,31 +85,105 @@ my_group = PermissionGroup.create(name="My Group", using=metabase)
8485
for user in User.list():
8586
# add all users to my_group
8687
PermissionMembership.create(
87-
using=metabase,
8888
group_id=my_group.id,
89-
user_id=user.id
89+
user_id=user.id,
90+
using=metabase,
9091
)
9192
```
9293

93-
You can also execute queries and get results back as a Pandas DataFrame. Currently, you need to provide
94-
the exact MBQL (i.e. Metabase Query Language) as the `query` argument.
94+
### Querying & MBQL
95+
96+
You can also execute queries and get results back as a Pandas DataFrame. You can provide the exact MBQL, or use
97+
the `Query` object to compile MBQL (i.e. Metabase Query Language) from Python classes included in this package.
98+
9599
```python
96-
from metabase import Dataset
100+
from metabase import Dataset, Query, Count, GroupBy, TemporalOption
97101

98102
dataset = Dataset.create(
99-
using.metabase,
100103
database=1,
101104
type="query",
102105
query={
103106
"source-table": 1,
104107
"aggregation": [["count"]],
105108
"breakout": ["field", 7, {"temporal-unit": "year"},],
106109
},
110+
using=metabase,
111+
)
112+
113+
# compile the MBQL above using the Query object
114+
dataset = Dataset.create(
115+
database=1,
116+
type="query",
117+
query=Query(
118+
table_id=2,
119+
aggregations=[Count()],
120+
group_by=[GroupBy(id=7, option=TemporalOption.YEAR)]
121+
).compile(),
122+
using=metabase
107123
)
108124

109125
df = dataset.to_pandas()
110126
```
111127

128+
As shown above, the `Query` object allows you to easily compile MBQL from Python objects. Here is a
129+
more complete example:
130+
```python
131+
from metabase import Query, Sum, Average, Metric, Greater, GroupBy, BinOption, TemporalOption
132+
133+
query = Query(
134+
table_id=5,
135+
aggregations=[
136+
Sum(id=5), # Provide the ID for the Metabase field
137+
Average(id=5, name="Average of Price"), # Optionally, you can provide a name
138+
Metric.get(5) # You can also provide your Metabase Metrics
139+
],
140+
filters=[
141+
Greater(id=1, value=5.5) # Filter for values of FieldID 1 greater than 5.5
142+
],
143+
group_by=[
144+
GroupBy(id=4), # Group by FieldID 4
145+
GroupBy(id=5, option=BinOption.AUTO), # You can use Metabase's binning feature for numeric fields
146+
GroupBy(id=5, option=TemporalOption.YEAR) # Or it's temporal option for date fields
147+
]
148+
)
149+
150+
print(query.compile())
151+
{
152+
'source-table': 5,
153+
'aggregation': [
154+
['sum', ['field', 5, None]],
155+
['aggregation-options', ['avg', ['field', 5, None]], {'name': 'Average of Price', 'display-name': 'Average of Price'}],
156+
["metric", 5]
157+
],
158+
'breakout': [
159+
['field', 4, None],
160+
['field', 5, {'binning': {'strategy': 'default'}}],
161+
['field', 5, {'temporal-unit': 'year'}]
162+
],
163+
'filter': ['>', ['field', 1, None], 5.5]
164+
}
165+
```
166+
167+
This can also be used to more easily create `Metric` objects.
168+
169+
```python
170+
from metabase import Metric, Query, Count, EndsWith, CaseOption
171+
172+
173+
metric = Metric.create(
174+
name="Gmail Users",
175+
description="Number of users with a @gmail.com email address.",
176+
table_id=2,
177+
definition=Query(
178+
table_id=1,
179+
aggregations=[Count()],
180+
filters=[EndsWith(id=4, value="@gmail.com", option=CaseOption.CASE_INSENSITIVE)]
181+
).compile(),
182+
using=metabase
183+
)
184+
```
185+
186+
112187

113188
## Endpoints
114189

src/metabase/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
1+
from metabase.mbql.aggregations import (
2+
Average,
3+
Count,
4+
CumulativeCount,
5+
CumulativeSum,
6+
Distinct,
7+
Max,
8+
Min,
9+
StandardDeviation,
10+
Sum,
11+
)
12+
from metabase.mbql.filter import (
13+
Between,
14+
CaseOption,
15+
EndsWith,
16+
Equal,
17+
Greater,
18+
GreaterEqual,
19+
IsNotNull,
20+
IsNull,
21+
Less,
22+
LessEqual,
23+
NotEqual,
24+
StartsWith,
25+
)
26+
from metabase.mbql.groupby import BinOption, GroupBy, TemporalOption
27+
from metabase.mbql.query import Query
128
from metabase.metabase import Metabase
229
from metabase.resources.card import Card
330
from metabase.resources.database import Database

src/metabase/mbql/__init__.py

Whitespace-only changes.

src/metabase/mbql/aggregations.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from typing import List
2+
3+
from metabase.mbql.base import Mbql, Option
4+
5+
6+
class Aggregation(Mbql):
7+
function: str
8+
9+
def __init__(self, id: int, name: str = None, option: Option = None):
10+
self.name = name
11+
super(Aggregation, self).__init__(id=id, option=option)
12+
13+
def compile(self) -> List:
14+
compiled = [self.function, super(Aggregation, self).compile()]
15+
16+
if self.name is not None:
17+
compiled = self.compile_name(compiled, self.name)
18+
19+
return compiled
20+
21+
@staticmethod
22+
def compile_name(compiled, name: str) -> str:
23+
return (
24+
["aggregation-options"]
25+
+ [compiled]
26+
+ [{"name": name, "display-name": name}]
27+
)
28+
29+
30+
class Count(Aggregation):
31+
function = "count"
32+
33+
def __init__(self, id: int = None, name: str = None, option: Option = None):
34+
self.id = id
35+
self.name = name
36+
37+
def compile(self) -> List:
38+
compiled = [self.function]
39+
40+
if self.name is not None:
41+
compiled = self.compile_name(compiled, self.name)
42+
43+
return compiled
44+
45+
46+
class Sum(Aggregation):
47+
function = "sum"
48+
49+
50+
class Average(Aggregation):
51+
function = "avg"
52+
53+
54+
class Distinct(Aggregation):
55+
function = "distinct"
56+
57+
58+
class CumulativeSum(Aggregation):
59+
function = "cum-sum"
60+
61+
62+
class CumulativeCount(Aggregation):
63+
function = "cum-count"
64+
65+
66+
class StandardDeviation(Aggregation):
67+
function = "stddev"
68+
69+
70+
class Min(Aggregation):
71+
function = "min"
72+
73+
74+
class Max(Aggregation):
75+
function = "max"

src/metabase/mbql/base.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from typing import List
2+
3+
4+
class Option:
5+
pass
6+
7+
8+
class Mbql:
9+
def __init__(self, id: int, option: Option = None):
10+
self.id = id
11+
self.option = option
12+
13+
def compile(self) -> List:
14+
return ["field", self.id, self.option]
15+
16+
def __repr__(self):
17+
return str(self.compile())

src/metabase/mbql/filter.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from typing import Any, List
2+
3+
from metabase.mbql.base import Mbql, Option
4+
5+
6+
class CaseOption(Option):
7+
CASE_SENSITIVE = {"case-sensitive": True}
8+
CASE_INSENSITIVE = {"case-sensitive": False}
9+
10+
11+
class TimeGrainOption(Option):
12+
MINUTE = "minute"
13+
HOUR = "hour"
14+
DAY = "day"
15+
WEEK = "week"
16+
MONTH = "month"
17+
QUARTER = "quarter"
18+
YEAR = "year"
19+
20+
21+
class Filter(Mbql):
22+
function: str
23+
24+
def __init__(self, id: int, option: Option = None):
25+
self.id = id
26+
self.option = None
27+
self.filter_option = option
28+
29+
def compile(self) -> List:
30+
compiled = [self.function, super(Filter, self).compile()]
31+
32+
if self.filter_option is not None:
33+
compiled = compiled + [self.filter_option]
34+
35+
return compiled
36+
37+
38+
class ValueFilter(Filter):
39+
def __init__(self, id: int, value: Any, option: Option = None):
40+
self.id = id
41+
self.value = value
42+
self.option = None
43+
self.filter_option = option
44+
45+
def compile(self) -> List:
46+
compiled = [self.function, super(Filter, self).compile(), self.value]
47+
48+
if self.filter_option is not None:
49+
compiled = compiled + [self.filter_option]
50+
51+
return compiled
52+
53+
54+
class Equal(ValueFilter):
55+
function = "="
56+
57+
58+
class NotEqual(ValueFilter):
59+
function = "!="
60+
61+
62+
class Greater(ValueFilter):
63+
function = ">"
64+
65+
66+
class Less(ValueFilter):
67+
function = "<"
68+
69+
70+
class Between(Filter):
71+
function = "between"
72+
73+
def __init__(
74+
self, id: int, lower_bound: float, upper_bound: float, option: Option = None
75+
):
76+
self.id = id
77+
self.option = None
78+
self.filter_option = option
79+
self.lower_bound = lower_bound
80+
self.upper_bound = upper_bound
81+
82+
def compile(self) -> List:
83+
return super(Between, self).compile() + [self.lower_bound, self.upper_bound]
84+
85+
86+
class GreaterEqual(ValueFilter):
87+
function = ">="
88+
89+
90+
class LessEqual(ValueFilter):
91+
function = "<="
92+
93+
94+
class IsNull(Filter):
95+
function = "is-null"
96+
97+
98+
class IsNotNull(Filter):
99+
function = "not-null"
100+
101+
102+
class Contains(ValueFilter):
103+
function = "contains"
104+
105+
106+
class StartsWith(ValueFilter):
107+
function = "starts-with"
108+
109+
110+
class EndsWith(ValueFilter):
111+
function = "ends-with"
112+
113+
114+
class TimeInterval(Filter):
115+
function = "time-interval"
116+
117+
def __init__(
118+
self,
119+
id: int,
120+
value: Any,
121+
time_grain: TimeGrainOption,
122+
include_current: bool = True,
123+
):
124+
self.id = id
125+
self.value = value
126+
self.option = None
127+
self.time_grain = time_grain
128+
self.include_current = include_current
129+
130+
def compile(self) -> List:
131+
compiled = [
132+
self.function,
133+
super(Filter, self).compile(),
134+
self.value,
135+
self.time_grain,
136+
]
137+
138+
if self.include_current:
139+
compiled = compiled + [{"include-current": True}]
140+
141+
return compiled

0 commit comments

Comments
 (0)