Skip to content

Commit 6b022f4

Browse files
committed
feat: add limit support with pagination
1 parent 53cb2ee commit 6b022f4

File tree

11 files changed

+344
-35
lines changed

11 files changed

+344
-35
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ end
2525
## Topics
2626

2727
- [Supported Ash Features](documentation/topics/ash-features.md)
28+
- [Runtime Filtering and Pagination](documentation/topics/runtime-filtering.md)
2829
- [Scan Warning](documentation/topics/scan-warning.md)
2930

3031
## Development

documentation/topics/ash-features.md

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This document describes which Ash features are supported by AshDynamo and how they map to DynamoDB operations.
44

5-
## Base Operations
5+
## Operations
66

77
| Capability | Status | Notes |
88
| ---------- | ------ | ------------------------------------------------------------------------ |
@@ -13,6 +13,7 @@ This document describes which Ash features are supported by AshDynamo and how th
1313
| `:select` || ProjectionExpression |
1414
| `:filter` || KeyCondition + FilterExpression + GSI index selection + Runtime fallback |
1515
| `:sort` || ScanIndexForward (SK) + Runtime fallback |
16+
| `:limit` || DynamoDB `Limit` + `LastEvaluatedKey`/`ExclusiveStartKey` pagination |
1617

1718
## Filter Operators
1819

@@ -73,22 +74,18 @@ Used for all other cases:
7374

7475
## Not Implemented
7576

76-
| Feature | Notes |
77-
| -------------------- | ----------------------------------------------------- |
78-
| `:or` | Via filter expression |
79-
| `:upsert` | Explicit upsert mode |
80-
| `:limit` / `:offset` | Pagination via `LastEvaluatedKey`/`ExclusiveStartKey` |
81-
| `:aggregate` | Via `Select: COUNT` |
82-
| Bulk operations | Bulk insert/update/delete |
83-
| LSI index selection | Local Secondary Index support |
84-
| Transactions | Via `TransactWriteItems` |
85-
86-
> #### Warning {: .warning}
87-
>
88-
> Since pagination is not implemented, queries on large datasets will return only the first 1MB of results (DynamoDB's per-request limit).
77+
| Feature | Notes |
78+
| ------------------- | ----------------------------- |
79+
| `:or` | Via filter expression |
80+
| `:upsert` | Explicit upsert mode |
81+
| `:aggregate` | Via `Select: COUNT` |
82+
| Bulk operations | Bulk insert/update/delete |
83+
| LSI index selection | Local Secondary Index support |
84+
| Transactions | Via `TransactWriteItems` |
8985

9086
## Not Supported
9187

92-
| Feature | Notes |
93-
| ------------- | ---------------------------- |
94-
| Relationships | DynamoDB has no native joins |
88+
| Feature | Notes |
89+
| ------------- | --------------------------------------- |
90+
| `:offset` | DynamoDB has no native offset mechanism |
91+
| Relationships | DynamoDB has no native joins |
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Runtime Filtering and Pagination
2+
3+
DynamoDB is not a relational database. It supports a limited set of filter operations natively, so AshDynamo uses a layered filtering strategy where DynamoDB handles what it can and Ash handles the rest at runtime.
4+
5+
## How Filtering Works
6+
7+
AshDynamo partitions Ash filter predicates into three tiers:
8+
9+
1. **KeyConditionExpression** -- Partition key (equality) and sort key (comparison operators). These define _which_ items DynamoDB reads from the index.
10+
11+
2. **FilterExpression** -- Non-key attribute comparisons (`==`, `!=`, `<`, `>`, `<=`, `>=`) and `contains`. Applied server-side _after_ items are read but _before_ they are returned. Reduces data transfer but still consumes read capacity for all scanned items.
12+
13+
3. **Runtime filter** -- Predicates that DynamoDB cannot express (OR conditions, `is_nil`, `in`, etc.). Applied in-memory via `Ash.Filter.Runtime.filter_matches` after DynamoDB returns results.
14+
15+
The runtime filter evaluates the **full original Ash filter**, not just the unsupported predicates. This means DynamoDB-handled predicates are re-evaluated redundantly, which is harmless since those items already satisfy them.
16+
17+
## Interaction with Limit and Pagination
18+
19+
When `Ash.Query.limit(N)` is used, AshDynamo passes `Limit=N` to DynamoDB and follows `LastEvaluatedKey`/`ExclusiveStartKey` pagination to accumulate results across pages.
20+
21+
DynamoDB's `Limit` parameter counts items **evaluated**, not items **returned** after `FilterExpression`. This means a request with `Limit=10` and a `FilterExpression` may return fewer than 10 items. The `AshDynamo.DataLayer.Query.Paginator` handles this by continuing to fetch pages until enough post-`FilterExpression` items are accumulated.
22+
23+
### Edge Case: Limit + Runtime Filters
24+
25+
When unsupported predicates (OR, `is_nil`) are present alongside a limit, the following can occur:
26+
27+
1. The `AshDynamo.DataLayer.Query.Paginator` accumulates N items that passed DynamoDB's partial filter
28+
2. The runtime filter then re-evaluates these items against the full Ash filter
29+
3. An item that passed DynamoDB's `FilterExpression` may fail an unsupported predicate evaluated at runtime
30+
4. The final result may contain fewer than N items
31+
32+
This is not a limitation in AshDynamo's implementation. It is a consequence of DynamoDB's restricted query language -- certain predicates simply cannot be translated into DynamoDB expressions. The runtime filter exists to bridge this gap, and it may reduce the result set beyond what DynamoDB returned.
33+
34+
This mirrors how sort works: DynamoDB natively sorts via `ScanIndexForward` only when sorting by the sort key in Query mode. All other sort scenarios fall back to `Ash.Actions.Sort.runtime_sort` after results are fetched.
35+
36+
### When This Edge Case Does Not Apply
37+
38+
In the common case -- no unsupported predicates in the filter -- every item that passes DynamoDB's filter also passes the runtime filter. The `AshDynamo.DataLayer.Query.Paginator` returns exactly N items and no runtime reduction occurs.

documentation/topics/scan-warning.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ config :ash_dynamo, warn_on_scan?: true
2929

3030
When enabled, AshDynamo logs a warning whenever a Scan is used:
3131

32-
```
32+
```shell
3333
[warning] AshDynamo: Scan operation on table "posts" for resource AshDynamo.Test.Post. Scans read every item in the table and consume significant read capacity. Add a partition key filter or define a GSI to use a Query instead. To disable this warning set: "config :ash_dynamo, warn_on_scan?: false"
3434
```
3535

lib/ash_dynamo.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
defmodule AshDynamo do
22
@moduledoc """
3-
Documentation for `AshDynamo`.
3+
DynamoDB data layer for `Ash` resources.
44
"""
55
end

lib/ash_dynamo/data_layer.ex

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
defmodule AshDynamo.DataLayer do
22
@moduledoc """
3-
DynamoDB data layer scaffold for Ash.
3+
DynamoDB data layer for `Ash`.
44
55
This wires in a `dynamodb` DSL section on resources so you can declare how a
66
resource maps to a table. Introspection helpers live in
@@ -12,6 +12,7 @@ defmodule AshDynamo.DataLayer do
1212
require Logger
1313

1414
alias AshDynamo.DataLayer.Info
15+
alias AshDynamo.DataLayer.Query.Paginator
1516

1617
@global_secondary_index %Spark.Dsl.Entity{
1718
name: :global_secondary_index,
@@ -82,7 +83,8 @@ defmodule AshDynamo.DataLayer do
8283
:domain,
8384
:select,
8485
:filter,
85-
:sort
86+
:sort,
87+
:limit
8688
]
8789
end
8890

@@ -99,6 +101,7 @@ defmodule AshDynamo.DataLayer do
99101
def can?(_, :boolean_filter), do: true
100102
def can?(_, :sort), do: true
101103
def can?(_, {:sort, _}), do: true
104+
def can?(_, :limit), do: true
102105
def can?(_, _), do: false
103106

104107
# --- Query shaping ------------------------------------------------------
@@ -122,6 +125,10 @@ defmodule AshDynamo.DataLayer do
122125
def sort(query, nil, _resource), do: {:ok, query}
123126
def sort(query, sort, _resource), do: {:ok, %{query | sort: sort}}
124127

128+
@impl true
129+
def limit(query, nil, _resource), do: {:ok, query}
130+
def limit(query, limit, _resource), do: {:ok, %{query | limit: limit}}
131+
125132
# --- Execution ----------------------------------------------------------
126133
@impl true
127134
def run_query(%Query{} = query, resource) do
@@ -132,18 +139,12 @@ defmodule AshDynamo.DataLayer do
132139

133140
maybe_warn_on_scan(mode, resource)
134141

135-
opts = merge_projection_opts(opts, select_fields)
136-
opts = merge_sort_opts(opts, query.sort, mode, effective_sk)
137-
138-
result =
139-
mode
140-
|> case do
141-
:query -> ExAws.Dynamo.query(table, opts)
142-
:scan -> ExAws.Dynamo.scan(table, opts)
143-
end
144-
|> ExAws.request()
142+
opts =
143+
opts
144+
|> merge_projection_opts(select_fields)
145+
|> merge_sort_opts(query.sort, mode, effective_sk)
145146

146-
with {:ok, resp} <- result,
147+
with {:ok, resp} <- Paginator.fetch(table, mode, opts, query.limit),
147148
{:ok, items} <- decode_items(resp, resource),
148149
{:ok, filtered} <- apply_runtime_filter(items, query) do
149150
apply_runtime_sort(filtered, query, mode, effective_sk)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
defmodule AshDynamo.DataLayer.Query.Paginator do
2+
@moduledoc """
3+
Handles DynamoDB pagination via `LastEvaluatedKey` and `ExclusiveStartKey`.
4+
5+
DynamoDB returns at most 1MB of data per request. When more items are available,
6+
the response includes a `LastEvaluatedKey` that must be passed as `ExclusiveStartKey`
7+
in the next request. This module encapsulates that loop, accumulating pages until
8+
the requested limit is reached or no more data is available.
9+
"""
10+
11+
@doc """
12+
Fetches items from DynamoDB, handling pagination via `LastEvaluatedKey`/`ExclusiveStartKey`.
13+
14+
Returns a merged response map with the same shape as a single ExAws response:
15+
`%{"Items" => [...], "Count" => N, "ScannedCount" => N}`.
16+
"""
17+
def fetch(table, mode, base_opts, limit \\ nil) do
18+
do_fetch(table, mode, base_opts, limit, _acc = nil, _start_key = nil)
19+
end
20+
21+
# Accumulator is `nil` for the first request and `{pages, count, scanned}`
22+
# for subsequent ones. Each page's items list is prepended as a whole (O(1)),
23+
# then reversed and concatenated in `to_response`.
24+
defp do_fetch(table, mode, base_opts, limit, acc, start_key) do
25+
# We always delegate limiting to DynamoDB by passing the remaining count
26+
# as the Limit parameter. On the first request this equals the original limit.
27+
# On subsequent requests it is reduced by the number of items already accumulated.
28+
# This avoids over-fetching when the 1MB page boundary causes DynamoDB to return
29+
# fewer items than requested, requiring additional pages.
30+
remaining = remaining_limit(limit, acc)
31+
32+
page_opts =
33+
base_opts
34+
|> maybe_put(:limit, remaining)
35+
|> maybe_put(:exclusive_start_key, start_key)
36+
37+
result =
38+
mode
39+
|> case do
40+
:query -> ExAws.Dynamo.query(table, page_opts)
41+
:scan -> ExAws.Dynamo.scan(table, page_opts)
42+
end
43+
|> ExAws.request()
44+
45+
with {:ok, resp} <- result do
46+
merged = accumulate(acc, resp)
47+
48+
cond do
49+
limit != nil and item_count(merged) >= limit ->
50+
{:ok, to_response(merged)}
51+
52+
Map.has_key?(resp, "LastEvaluatedKey") ->
53+
do_fetch(table, mode, base_opts, limit, merged, resp["LastEvaluatedKey"])
54+
55+
true ->
56+
{:ok, to_response(merged)}
57+
end
58+
end
59+
end
60+
61+
defp remaining_limit(nil, _acc), do: nil
62+
defp remaining_limit(limit, nil), do: limit
63+
defp remaining_limit(limit, {_pages, count, _scanned}), do: limit - count
64+
65+
defp accumulate(nil, resp) do
66+
items = resp["Items"] || []
67+
{[items], resp["Count"] || 0, resp["ScannedCount"] || 0}
68+
end
69+
70+
defp accumulate({pages, count, scanned}, resp) do
71+
items = resp["Items"] || []
72+
73+
{
74+
[items | pages],
75+
count + (resp["Count"] || 0),
76+
scanned + (resp["ScannedCount"] || 0)
77+
}
78+
end
79+
80+
# Pages are prepended during accumulation (O(1) per page), so we
81+
# reverse the page order and concatenate into a flat item list.
82+
defp to_response({pages, count, scanned}) do
83+
items = pages |> Enum.reverse() |> Enum.concat()
84+
%{"Items" => items, "Count" => count, "ScannedCount" => scanned}
85+
end
86+
87+
defp item_count({_pages, count, _scanned}), do: count
88+
89+
defp maybe_put(opts, _key, nil), do: opts
90+
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
91+
end

mix.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule AshDynamo.MixProject do
33

44
@version "0.4.1"
55

6-
@moduledoc "DynamoDB data layer for Ash resources."
6+
@moduledoc "DynamoDB data layer for `Ash` resources."
77

88
def project do
99
[
@@ -33,6 +33,7 @@ defmodule AshDynamo.MixProject do
3333
"CHANGELOG.md",
3434
"documentation/tutorials/getting-started-with-ash-dynamo.md",
3535
"documentation/topics/ash-features.md",
36+
"documentation/topics/runtime-filtering.md",
3637
"documentation/topics/scan-warning.md",
3738
"documentation/development/testing.md",
3839
"documentation/dsls/DSL-AshDynamo.DataLayer.md"

0 commit comments

Comments
 (0)