Skip to content

Commit 2ddb15f

Browse files
author
Matthias Zimmermann
committed
refactor: rename QueryOptions.fields to attributes
1 parent f36d327 commit 2ddb15f

File tree

10 files changed

+253
-33
lines changed

10 files changed

+253
-33
lines changed

docs/FLUENT_QUERY_API_PROPOSAL.md

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# Fluent Query API Proposal
2+
3+
## Overview
4+
5+
A type-safe, fluent query builder API inspired by JOOQ that provides an intuitive, SQL-like interface for constructing Arkiv queries. The API follows the builder pattern and allows for method chaining to construct complex queries.
6+
7+
## Design Goals
8+
9+
1. **Simplicity**: WHERE clauses are plain SQL-like strings - no magic, no operator overloading
10+
2. **SQL Familiarity**: Follow SQL query patterns (SELECT, WHERE, ORDER BY, etc.)
11+
3. **Type Safety**: Use `Attribute()` objects for ORDER BY to specify type and direction
12+
4. **Readability**: Clear, self-documenting code that matches SQL structure
13+
5. **Backward Compatibility**: Coexist with existing string-based query API
14+
6. **Transparency**: Query strings are passed directly to Arkiv node
15+
16+
## Core API Design
17+
18+
Parts and descriptions
19+
- `.select(...)` feeds into "fields" bitmask of QueryOptions of query_entities
20+
- `.where(...)` feeds into "query" parameter of query_entities
21+
- `.order_by(...)` (optional) feeds into "order_by" field of QueryOptions of query_entities
22+
- `.at_block(...)` (optional) feeds into "at_block" field of QueryOptions of query_entities
23+
- `.fetch()` returns the QueryIterator from query_entities
24+
- `.count()` optimized to retrieve only entity keys, count them, and return an int
25+
26+
### Field Selection
27+
28+
Arkiv supports selection of entity field groups (not individual user-defined attributes):
29+
- **Metadata fields**: `KEY`, `OWNER`, `CREATED_AT`, `LAST_MODIFIED_AT`, `EXPIRES_AT`, `TX_INDEX_IN_BLOCK`, `OP_INDEX_IN_TX`
30+
- **Content fields**: `PAYLOAD`, `CONTENT_TYPE`
31+
- **Attributes**: `ATTRIBUTES` (all user-defined attributes - cannot select individual ones)
32+
33+
The `.select()` method accepts a list of these field constants:
34+
35+
### Basic Query Structure
36+
37+
```python
38+
from arkiv import Arkiv
39+
from arkiv.query import IntAttribute, StrAttribute
40+
from arkiv.types import KEY, OWNER, ATTRIBUTES, PAYLOAD, CONTENT_TYPE
41+
42+
client = Arkiv()
43+
44+
# Simple query - WHERE clause is a plain SQL-like string
45+
# Select specific field groups (no brackets needed)
46+
results = client.arkiv \
47+
.select(KEY, ATTRIBUTES) \
48+
.where('type = "user"') \
49+
.fetch()
50+
51+
# Select all fields ( .select() defaults to all fields)
52+
results = client.arkiv \
53+
.select() \
54+
.where('type = "user" AND age >= 18') \
55+
.fetch()
56+
57+
# Select multiple field groups
58+
results = client.arkiv \
59+
.select(KEY, OWNER, ATTRIBUTES, PAYLOAD, CONTENT_TYPE) \
60+
.where('status = "active"') \
61+
.fetch()
62+
63+
# Select single field
64+
results = client.arkiv \
65+
.select(KEY) \
66+
.where('type = "user"') \
67+
.fetch()
68+
69+
# Complex conditions with OR and parentheses
70+
results = client.arkiv \
71+
.where('(type = "user" OR type = "admin") AND status != "banned"') \
72+
.fetch()
73+
74+
# Count matching entities (ignores .select())
75+
count = client.arkiv \
76+
.where('type = "user"') \
77+
.count()
78+
```
79+
80+
**Key Points**:
81+
- `.where()` takes a plain string with SQL-like syntax that is passed directly to the Arkiv node
82+
- `.select()` accepts field group constants as arguments (not individual attribute names)
83+
- Arkiv returns all user-defined attributes or none - cannot select specific attributes like `type` or `age`
84+
- Field groups: `KEY`, `OWNER`, `ATTRIBUTES`, `PAYLOAD`, `CONTENT_TYPE`, etc.
85+
- For sorting: use `IntAttribute` for numeric fields, `StrAttribute` for string fields
86+
87+
### Sorting
88+
89+
Sorting uses type-specific attribute classes (`IntAttribute` for numeric, `StrAttribute` for string):
90+
91+
```python
92+
from arkiv.query import IntAttribute, StrAttribute
93+
from arkiv import ASC, DESC
94+
95+
# Single field sorting
96+
results = client.arkiv \
97+
.select() \
98+
.where('type = "user"') \
99+
.order_by(IntAttribute('age', DESC)) \
100+
.fetch()
101+
102+
# Multiple field sorting - no brackets needed
103+
results = client.arkiv \
104+
.select() \
105+
.where('type = "user"') \
106+
.order_by(
107+
StrAttribute('status'), # String, ascending (default)
108+
IntAttribute('age', DESC) # Numeric, descending
109+
) \
110+
.fetch()
111+
112+
# Ascending is default, so direction can be omitted
113+
results = client.arkiv \
114+
.select() \
115+
.where('status = "active"') \
116+
.order_by(
117+
IntAttribute('priority', DESC), # Descending - explicit
118+
StrAttribute('name') # Ascending - default
119+
) \
120+
.fetch()
121+
122+
# Alternative: Method chaining for direction
123+
results = client.arkiv \
124+
.select() \
125+
.where('type = "user"') \
126+
.order_by(
127+
StrAttribute('status').asc(),
128+
IntAttribute('age').desc()
129+
) \
130+
.fetch()
131+
```
132+
133+
**Why type-specific classes are valuable:**
134+
- **Explicit type**: `IntAttribute` vs `StrAttribute` - immediately clear from class name
135+
- **Required for sorting**: Arkiv needs to know if attribute is string or numeric
136+
- **IDE support**: Type system knows what's available for each class
137+
- **Prevents errors**: Can't accidentally use wrong type
138+
- **Default direction**: ASC is default, only specify DESC when needed
139+
140+
## Implementation Architecture
141+
142+
### Core Classes
143+
144+
145+
## Migration Strategy
146+
147+
The fluent API would coexist with the existing string-based API:
148+
149+
```python
150+
from arkiv.types import KEY, ATTRIBUTES
151+
152+
# Existing API - still supported
153+
results = list(client.arkiv.query_entities('type = "user" AND age >= 18'))
154+
155+
# New fluent API - same query string, cleaner interface
156+
results = client.arkiv \
157+
.where('type = "user" AND age >= 18') \
158+
.fetch()
159+
160+
# With field selection
161+
results = client.arkiv \
162+
.select(KEY, ATTRIBUTES) \
163+
.where('type = "user"') \
164+
.fetch()
165+
166+
# With sorting (new capability made easy)
167+
from arkiv.query import IntAttribute, StrAttribute
168+
169+
results = client.arkiv \
170+
.select(KEY, ATTRIBUTES) \
171+
.where('type = "user" AND age >= 18') \
172+
.order_by(
173+
StrAttribute('status'),
174+
IntAttribute('age', DESC)
175+
) \
176+
.fetch()
177+
178+
# With block pinning
179+
results = client.arkiv \
180+
.select(KEY, ATTRIBUTES) \
181+
.where('status = "active"') \
182+
.at_block(12345) \
183+
.fetch()
184+
185+
# Quick count
186+
count = client.arkiv \
187+
.where('type = "user"') \
188+
.count()
189+
```
190+
191+
**Key differences**: The fluent API provides a cleaner interface for:
192+
- **Field selection**: `.select(KEY, ATTRIBUTES)` instead of bitmask `KEY | ATTRIBUTES`
193+
- **Sorting**: `.order_by()` with type-specific classes `IntAttribute`, `StrAttribute`
194+
- **Block pinning**: `.at_block()` for historical queries
195+
- **Counting**: `.count()` convenience method
196+
- **Iteration**: Returns same iterator, but building the query is more readable
197+
198+
While keeping the WHERE clause simple and familiar (plain SQL-like strings).
199+
200+
**Note**: Both `.select()` and `.order_by()` use Python's `*args` so no brackets needed:
201+
- `.select(KEY, ATTRIBUTES)` not `.select([KEY, ATTRIBUTES])`
202+
- `.order_by(StrAttribute('name'), IntAttribute('age', DESC))` not `.order_by([...])`
203+
204+
## Benefits
205+
206+
1. **Simplicity**: WHERE clauses use familiar SQL syntax - no learning curve
207+
2. **Readability**: Self-documenting code that reads like SQL
208+
3. **Type Safety**: `Attribute()` for ORDER BY provides IDE autocomplete and type checking
209+
4. **Composability**: Build queries programmatically with method chaining
210+
5. **Transparency**: Query strings are passed directly to node - what you see is what you get
211+
6. **Flexibility**: List-based field selection is clearer than bitmask operations
212+
7. **Testability**: Easy to test query building separately from execution
213+
214+
## Open Questions
215+
216+
1. **Error Messages**: How to provide clear error messages when query construction fails?
217+
218+
2. **Performance**: Overhead of building query objects vs. direct string construction?

src/arkiv/module.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ def transfer_eth(
209209
def entity_exists(self, entity_key: EntityKey, at_block: int | None = None) -> bool:
210210
# Docstring inherited from ArkivModuleBase.entity_exists
211211
try:
212-
options = QueryOptions(fields=NONE, at_block=at_block)
212+
options = QueryOptions(attributes=NONE, at_block=at_block)
213213
query_result: QueryPage = self.query_entities_page(
214214
f"$key = {entity_key}", options=options
215215
)
@@ -221,7 +221,7 @@ def get_entity(
221221
self, entity_key: EntityKey, fields: int = ALL, at_block: int | None = None
222222
) -> Entity:
223223
# Docstring inherited from ArkivModuleBase.get_entity
224-
options = QueryOptions(fields=fields, at_block=at_block)
224+
options = QueryOptions(attributes=fields, at_block=at_block)
225225
query_result: QueryPage = self.query_entities_page(
226226
f"$key = {entity_key}", options=options
227227
)
@@ -243,7 +243,7 @@ def query_entities_page(
243243
rpc_options = to_rpc_query_options(options)
244244
raw_results = self.client.eth.query(query, rpc_options)
245245

246-
return to_query_result(options.fields, raw_results)
246+
return to_query_result(options.attributes, raw_results)
247247

248248
def query_entities(
249249
self, query: str, options: QueryOptions = QUERY_OPTIONS_DEFAULT

src/arkiv/module_async.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ async def entity_exists( # type: ignore[override]
170170
) -> bool:
171171
# Docstring inherited from ArkivModuleBase.entity_exists
172172
try:
173-
options = QueryOptions(fields=NONE, at_block=at_block)
173+
options = QueryOptions(attributes=NONE, at_block=at_block)
174174
query_result: QueryPage = await self.query_entities_page(
175175
f"$key = {entity_key}", options=options
176176
)
@@ -182,7 +182,7 @@ async def get_entity( # type: ignore[override]
182182
self, entity_key: EntityKey, fields: int = ALL, at_block: int | None = None
183183
) -> Entity:
184184
# Docstring inherited from ArkivModuleBase.get_entity
185-
options = QueryOptions(fields=fields, at_block=at_block)
185+
options = QueryOptions(attributes=fields, at_block=at_block)
186186
query_result: QueryPage = await self.query_entities_page(
187187
f"$key = {entity_key}", options=options
188188
)
@@ -206,7 +206,7 @@ async def query_entities_page( # type: ignore[override]
206206
rpc_options = to_rpc_query_options(options)
207207
raw_results = await self.client.eth.query(query, rpc_options)
208208

209-
return to_query_result(options.fields, raw_results)
209+
return to_query_result(options.attributes, raw_results)
210210

211211
def query_entities(
212212
self, query: str, options: QueryOptions = QUERY_OPTIONS_DEFAULT

src/arkiv/types.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class OrderByAttribute:
6262
class QueryOptions:
6363
"""Options for querying entities."""
6464

65-
fields: int = ALL # Bitmask of fields to populate
65+
attributes: int = ALL # Bitmask of fields to populate
6666
order_by: Sequence[OrderByAttribute] | None = None # Fields to order results by
6767
at_block: int | None = (
6868
None # Block number to pin query to specific block, or None to use latest block available
@@ -74,12 +74,14 @@ class QueryOptions:
7474

7575
def validate(self, query: str | None) -> None:
7676
# Validates fields
77-
if self.fields is not None:
78-
if self.fields < 0:
79-
raise ValueError(f"Fields cannot be negative: {self.fields}")
80-
81-
if self.fields > ALL:
82-
raise ValueError(f"Fields contains unknown field flags: {self.fields}")
77+
if self.attributes is not None:
78+
if self.attributes < 0:
79+
raise ValueError(f"Fields cannot be negative: {self.attributes}")
80+
81+
if self.attributes > ALL:
82+
raise ValueError(
83+
f"Fields contains unknown field flags: {self.attributes}"
84+
)
8385

8486
# Validate that at least one of query or cursor is provided
8587
if query is None:

src/arkiv/utils.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ def to_query_options(
251251
raise ValueError(f"at_block cannot be negative: {at_block}")
252252

253253
return QueryOptions(
254-
fields=fields,
254+
attributes=fields,
255255
max_results_per_page=max_results_per_page,
256256
at_block=at_block,
257257
cursor=cursor,
@@ -276,16 +276,16 @@ def to_rpc_query_options(
276276
# see https://github.com/Golem-Base/golembase-op-geth/blob/main/eth/api_arkiv.go
277277
rpc_query_options: dict[str, Any] = {
278278
"includeData": {
279-
"key": options.fields & KEY != 0,
280-
"attributes": options.fields & ATTRIBUTES != 0,
281-
"payload": options.fields & PAYLOAD != 0,
282-
"contentType": options.fields & CONTENT_TYPE != 0,
283-
"expiration": options.fields & EXPIRATION != 0,
284-
"owner": options.fields & OWNER != 0,
285-
"createdAtBlock": options.fields & CREATED_AT != 0,
286-
"lastModifiedAtBlock": options.fields & LAST_MODIFIED_AT != 0,
287-
"transactionIndexInBlock": options.fields & TX_INDEX_IN_BLOCK != 0,
288-
"operationIndexInTransaction": options.fields & OP_INDEX_IN_TX != 0,
279+
"key": options.attributes & KEY != 0,
280+
"attributes": options.attributes & ATTRIBUTES != 0,
281+
"payload": options.attributes & PAYLOAD != 0,
282+
"contentType": options.attributes & CONTENT_TYPE != 0,
283+
"expiration": options.attributes & EXPIRATION != 0,
284+
"owner": options.attributes & OWNER != 0,
285+
"createdAtBlock": options.attributes & CREATED_AT != 0,
286+
"lastModifiedAtBlock": options.attributes & LAST_MODIFIED_AT != 0,
287+
"transactionIndexInBlock": options.attributes & TX_INDEX_IN_BLOCK != 0,
288+
"operationIndexInTransaction": options.attributes & OP_INDEX_IN_TX != 0,
289289
}
290290
}
291291

tests/test_async_query_iterator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async def test_async_iterate_entities_basic(
6868

6969
# Define query and options
7070
query = f'batch_id = "{batch_id}"'
71-
options = QueryOptions(fields=KEY | ATTRIBUTES, max_results_per_page=4)
71+
options = QueryOptions(attributes=KEY | ATTRIBUTES, max_results_per_page=4)
7272

7373
# Collect all entities using async iterator
7474
# Iterate with page size of 4 (should auto-fetch 3 pages: 4, 4, 2)

tests/test_query_iterator.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def test_iterate_entities_basic(self, arkiv_client_http: Arkiv) -> None:
6161

6262
# Define query and options
6363
query = f'batch_id = "{batch_id}"'
64-
options = QueryOptions(fields=KEY | ATTRIBUTES, max_results_per_page=4)
64+
options = QueryOptions(attributes=KEY | ATTRIBUTES, max_results_per_page=4)
6565

6666
# Classical for loop
6767
# Should get all 10 entities
@@ -91,7 +91,7 @@ def test_iterate_entities_with_list(self, arkiv_client_http: Arkiv) -> None:
9191

9292
# Define query and options
9393
query = f'batch_id = "{batch_id}"'
94-
options = QueryOptions(fields=KEY | ATTRIBUTES, max_results_per_page=4)
94+
options = QueryOptions(attributes=KEY | ATTRIBUTES, max_results_per_page=4)
9595

9696
# Collect all entities using iterator
9797
# Iterate with page size of 4 (should auto-fetch 3 pages: 4, 4, 2)
@@ -121,7 +121,7 @@ def test_iterate_entities_empty(self, arkiv_client_http: Arkiv) -> None:
121121

122122
# Define query and options
123123
query = 'batch_id = "does not exist"'
124-
options = QueryOptions(fields=KEY | ATTRIBUTES, max_results_per_page=4)
124+
options = QueryOptions(attributes=KEY | ATTRIBUTES, max_results_per_page=4)
125125

126126
# Classical for loop
127127
# Should get all 10 entities
@@ -143,7 +143,7 @@ def test_iterate_entities_less_than_page(self, arkiv_client_http: Arkiv) -> None
143143
# Define query and options
144144
query = f'batch_id = "{batch_id}"'
145145
options = QueryOptions(
146-
fields=KEY | ATTRIBUTES, max_results_per_page=2 * num_entities
146+
attributes=KEY | ATTRIBUTES, max_results_per_page=2 * num_entities
147147
)
148148

149149
# Classical for loop
@@ -176,7 +176,7 @@ def test_iterate_entities_exactly_page(self, arkiv_client_http: Arkiv) -> None:
176176
# Define query and options
177177
query = f'batch_id = "{batch_id}"'
178178
options = QueryOptions(
179-
fields=KEY | ATTRIBUTES, max_results_per_page=num_entities
179+
attributes=KEY | ATTRIBUTES, max_results_per_page=num_entities
180180
)
181181

182182
# Classical for loop

0 commit comments

Comments
 (0)