Skip to content

Commit 9f32d7f

Browse files
authored
feature: support multi-select params / array (#170)
1 parent 2b25e9e commit 9f32d7f

File tree

5 files changed

+75
-39
lines changed

5 files changed

+75
-39
lines changed

dune_client/api/base.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@
99
import logging.config
1010
import os
1111
from json import JSONDecodeError
12-
from typing import IO, Any
12+
from typing import IO, TYPE_CHECKING, Any
1313

1414
from deprecated import deprecated
1515
from requests import Response, Session
1616
from requests.adapters import HTTPAdapter, Retry
1717

1818
from dune_client.util import get_package_version
1919

20+
if TYPE_CHECKING:
21+
from dune_client.types import QueryParameters
22+
2023
# Headers used for pagination in CSV results
2124
DUNE_CSV_NEXT_URI_HEADER = "x-dune-next-uri"
2225
DUNE_CSV_NEXT_OFFSET_HEADER = "x-dune-next-offset"
@@ -94,15 +97,15 @@ def default_headers(self) -> dict[str, str]:
9497

9598
def _build_parameters(
9699
self,
97-
params: dict[str, str | int] | None = None,
100+
params: QueryParameters | None = None,
98101
columns: list[str] | None = None,
99102
sample_count: int | None = None,
100103
filters: str | None = None,
101104
sort_by: list[str] | None = None,
102105
limit: int | None = None,
103106
offset: int | None = None,
104107
allow_partial_results: str = "true",
105-
) -> dict[str, str | int]:
108+
) -> QueryParameters:
106109
"""
107110
Utility function that builds a dictionary of parameters to be used
108111
when retrieving advanced results (filters, pagination, sorting, etc.).
@@ -114,24 +117,24 @@ def _build_parameters(
114117
sample_count is None
115118
# We are sampling and don't use filters or pagination
116119
or (limit is None and offset is None and filters is None)
117-
), "sampling cannot be combined with filters or pagination"
120+
), "sapling cannot be combined with filters or pagination"
118121

119-
params = params or {}
120-
params["allow_partial_results"] = allow_partial_results
121-
if columns is not None and len(columns) > 0:
122-
params["columns"] = ",".join(columns)
122+
result: QueryParameters = dict(params) if params else {}
123+
result["allow_partial_results"] = allow_partial_results
124+
if columns:
125+
result["columns"] = ",".join(columns)
123126
if sample_count is not None:
124-
params["sample_count"] = sample_count
127+
result["sample_count"] = sample_count
125128
if filters is not None:
126-
params["filters"] = filters
127-
if sort_by is not None and len(sort_by) > 0:
128-
params["sort_by"] = ",".join(sort_by)
129+
result["filters"] = filters
130+
if sort_by:
131+
result["sort_by"] = ",".join(sort_by)
129132
if limit is not None:
130-
params["limit"] = limit
133+
result["limit"] = limit
131134
if offset is not None:
132-
params["offset"] = offset
135+
result["offset"] = offset
133136

134-
return params
137+
return result
135138

136139

137140
class BaseRouter(BaseDuneClient):

dune_client/query.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,28 @@
44

55
from __future__ import annotations
66

7+
import json
78
import urllib.parse
89
from dataclasses import dataclass
910
from typing import Any
1011

11-
from dune_client.types import QueryParameter
12+
from dune_client.types import QueryParameter, QueryParameters
1213

1314

1415
def parse_query_object_or_id(
1516
query: QueryBase | str | int,
16-
) -> tuple[dict[str, Any] | None, int]:
17+
) -> tuple[QueryParameters | None, int]:
1718
"""
1819
Users are allowed to pass QueryBase or ID into some functions.
1920
This method handles both scenarios, returning a pair of the form (params, query_id)
2021
"""
2122
if isinstance(query, QueryBase):
22-
params = {f"params.{p.key}": p.to_dict()["value"] for p in query.parameters()}
23-
query_id = query.query_id
24-
else:
25-
params = None
26-
query_id = int(query)
27-
return params, query_id
23+
params: QueryParameters = {
24+
f"params.{p.key}": p.to_dict()["value"] for p in query.parameters()
25+
}
26+
return params, query.query_id
27+
28+
return None, int(query)
2829

2930

3031
@dataclass
@@ -46,9 +47,17 @@ def parameters(self) -> list[QueryParameter]:
4647
def url(self) -> str:
4748
"""Returns a parameterized link to the query"""
4849
# Include variable parameters in the URL so they are set
49-
params = "&".join([f"{p.key}={p.value}" for p in self.parameters()])
50-
if params:
51-
return "?".join([self.base_url(), urllib.parse.quote_plus(params, safe="=&?")])
50+
params = []
51+
for parameter in self.parameters():
52+
value = parameter.to_dict()["value"]
53+
if isinstance(value, list):
54+
serialized_value = json.dumps(value, separators=(",", ":"))
55+
else:
56+
serialized_value = value
57+
params.append(f"{parameter.key}={serialized_value}")
58+
param_string = "&".join(params)
59+
if param_string:
60+
return "?".join([self.base_url(), urllib.parse.quote_plus(param_string, safe="=&?")])
5261
return self.base_url()
5362

5463
def __hash__(self) -> int:
@@ -58,7 +67,7 @@ def __hash__(self) -> int:
5867
"""
5968
return self.url().__hash__()
6069

61-
def request_format(self) -> dict[str, dict[str, str] | str | None]:
70+
def request_format(self) -> dict[str, str | QueryParameters]:
6271
"""Transforms Query objects to params to pass in API"""
6372
return {"query_parameters": {p.key: p.to_dict()["value"] for p in self.parameters()}}
6473

dune_client/types.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import re
10+
from collections.abc import Sequence
1011
from enum import Enum
1112
from typing import TYPE_CHECKING, Any
1213

@@ -16,6 +17,7 @@
1617
from datetime import datetime
1718

1819
DuneRecord = dict[str, Any]
20+
QueryParameters = dict[str, str | list[str] | int]
1921

2022

2123
class Address:
@@ -125,7 +127,12 @@ def __eq__(self, other: object) -> bool:
125127
)
126128

127129
def __hash__(self) -> int:
128-
return hash((self.key, self.value, self.type.value))
130+
value = (
131+
tuple(self.value)
132+
if isinstance(self.value, Sequence) and not isinstance(self.value, str)
133+
else self.value
134+
)
135+
return hash((self.key, value, self.type.value))
129136

130137
@classmethod
131138
def text_type(cls, name: str, value: str) -> QueryParameter:
@@ -148,25 +155,31 @@ def date_type(cls, name: str, value: datetime | str) -> QueryParameter:
148155
return cls(name, ParameterType.DATE, value)
149156

150157
@classmethod
151-
def enum_type(cls, name: str, value: str) -> QueryParameter:
152-
"""Constructs a Query parameter of type number"""
153-
return cls(name, ParameterType.ENUM, value)
158+
def enum_type(cls, name: str, value: str | Sequence[str]) -> QueryParameter:
159+
"""Constructs a Query parameter of type enum or multi-select"""
160+
if isinstance(value, str):
161+
return cls(name, ParameterType.ENUM, value)
162+
if isinstance(value, Sequence):
163+
return cls(name, ParameterType.ENUM, tuple(value))
164+
raise TypeError(f"Unsupported enum value type for parameter '{name}': {type(value)!r}")
154165

155-
def value_str(self) -> str:
156-
"""Returns string value of parameter"""
166+
def serialized_value(self) -> str | list[str]:
167+
"""Returns JSON-ready value of parameter"""
157168
if self.type in (ParameterType.TEXT, ParameterType.NUMBER, ParameterType.ENUM):
169+
if isinstance(self.value, Sequence) and not isinstance(self.value, str):
170+
return [str(v) for v in self.value]
158171
return str(self.value)
159172
if self.type == ParameterType.DATE:
160173
# This is the postgres string format of timestamptz
161174
return str(self.value.strftime("%Y-%m-%d %H:%M:%S"))
162175
raise TypeError(f"Type {self.type} not recognized!")
163176

164-
def to_dict(self) -> dict[str, str]:
177+
def to_dict(self) -> dict[str, str | list[str]]:
165178
"""Converts QueryParameter into string json format accepted by Dune API"""
166-
results: dict[str, str] = {
179+
results: dict[str, str | list[str]] = {
167180
"key": self.key,
168181
"type": self.type.value,
169-
"value": self.value_str(),
182+
"value": self.serialized_value(),
170183
}
171184
return results
172185

tests/unit/test_query.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import unittest
2+
import urllib.parse
23
from datetime import datetime
34

45
from dune_client.query import QueryBase, parse_query_object_or_id
@@ -13,17 +14,24 @@ def setUp(self) -> None:
1314
QueryParameter.text_type("Text", "plain text"),
1415
QueryParameter.number_type("Number", 12),
1516
QueryParameter.date_type("Date", "2021-01-01 12:34:56"),
17+
QueryParameter.enum_type("Multi", ["a1", "a2"]),
1618
]
1719
self.query = QueryBase(name="", query_id=0, params=self.query_params)
1820

1921
def test_base_url(self):
2022
assert self.query.base_url() == "https://dune.com/queries/0"
2123

2224
def test_url(self):
23-
assert (
24-
self.query.url()
25-
== "https://dune.com/queries/0?Enum=option1&Text=plain+text&Number=12&Date=2021-01-01+12%3A34%3A56"
25+
raw_params = (
26+
'Enum=option1&Text=plain text&Number=12&Date=2021-01-01 12:34:56&Multi=["a1","a2"]'
2627
)
28+
expected_url = "?".join(
29+
[
30+
"https://dune.com/queries/0",
31+
urllib.parse.quote_plus(raw_params, safe="=&?"),
32+
]
33+
)
34+
assert self.query.url() == expected_url
2735
assert QueryBase(0, "", []).url() == "https://dune.com/queries/0"
2836

2937
def test_parameters(self):
@@ -36,6 +44,7 @@ def test_request_format(self):
3644
"Text": "plain text",
3745
"Number": "12",
3846
"Date": "2021-01-01 12:34:56",
47+
"Multi": ["a1", "a2"],
3948
}
4049
}
4150
assert self.query.request_format() == expected_answer
@@ -62,6 +71,7 @@ def test_parse_object_or_id(self):
6271
"params.Enum": "option1",
6372
"params.Number": "12",
6473
"params.Text": "plain text",
74+
"params.Multi": ["a1", "a2"],
6575
}
6676
expected_query_id = self.query.query_id
6777
# Query Object

uv.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)