documentation/topics/paginated-relationships.md

<!--
SPDX-FileCopyrightText: 2020 Zach Daniel

SPDX-License-Identifier: MIT
-->

# Paginated Relationships

AshJsonApi supports pagination for included relationships, allowing you to limit the number of related resources returned when using the `include` query parameter.

## Overview

By default, when you include relationships in a JSON:API request, all related resources are returned. For relationships with many records (e.g., a post with hundreds of comments), this can result in large response payloads and performance issues.

Paginated relationships allow clients to request a specific page of related resources using the `included_page` query parameter, similar to how top-level resources can be paginated with the `page` parameter.

## Configuration

To enable pagination for specific relationships, add them to the `paginated_includes` list in your resource's `json_api` block:

```elixir
defmodule MyApp.Post do
  use Ash.Resource,
    extensions: [AshJsonApi.Resource]

  json_api do
    type "post"

    # Allow comments to be included
    includes [:comments, :author]

    # Configure which relationships can be paginated
    paginated_includes [:comments]
  end

  relationships do
    has_many :comments, MyApp.Comment
    belongs_to :author, MyApp.Author
  end
end
```

### Nested Relationship Paths

You can also configure pagination for nested relationship paths:

```elixir
defmodule MyApp.Author do
  use Ash.Resource,
    extensions: [AshJsonApi.Resource]

  json_api do
    type "author"

    includes posts: [:comments]

    # Allow pagination for both posts and nested comments
    paginated_includes [
      :posts,
      [:posts, :comments]
    ]
  end
end
```

## Query Parameters

### Basic Pagination

Use the `included_page` query parameter to paginate included relationships:

```
GET /posts/1?include=comments&included_page[comments][limit]=10
```

This will include only the first 10 comments.

### Offset Pagination

Offset pagination uses `limit` and `offset` parameters:

```
GET /posts/1?include=comments&included_page[comments][limit]=10&included_page[comments][offset]=20
```

This returns 10 comments starting from the 21st comment.

### Keyset Pagination

Keyset (cursor-based) pagination uses `limit`, `after`, and `before` parameters:

```
GET /posts/1?include=comments&included_page[comments][limit]=10&included_page[comments][after]=<cursor>
```

### Count Parameter

To include the total count of related resources:

```
GET /posts/1?include=comments&included_page[comments][limit]=10&included_page[comments][count]=true
```

### Nested Relationships

For nested relationship paths, use dot notation:

```
GET /authors/1?include=posts.comments&included_page[posts.comments][limit]=5
```

This paginates the comments included for each post.

## Response Format

When a relationship is paginated, the response includes:
1. Pagination metadata in the relationship's `meta` object
2. Pagination links in the relationship's `links` object

```json
{
  "data": {
    "id": "1",
    "type": "post",
    "attributes": {
      "title": "My Post"
    },
    "relationships": {
      "comments": {
        "data": [
          {"id": "1", "type": "comment"},
          {"id": "2", "type": "comment"}
        ],
        "links": {
          "self": "/posts/1/relationships/comments",
          "related": "/posts/1/comments",
          "first": "/posts/1?include=comments&included_page[comments][limit]=10",
          "next": "/posts/1?include=comments&included_page[comments][limit]=10&included_page[comments][offset]=10",
          "prev": null,
          "last": "/posts/1?include=comments&included_page[comments][limit]=10&included_page[comments][offset]=40"
        },
        "meta": {
          "limit": 10,
          "offset": 0,
          "count": 50
        }
      }
    }
  },
  "included": [
    {
      "id": "1",
      "type": "comment",
      "attributes": {
        "body": "First comment"
      }
    },
    {
      "id": "2",
      "type": "comment",
      "attributes": {
        "body": "Second comment"
      }
    }
  ]
}
```

### Pagination Links

The `links` object in a paginated relationship includes:

- `first`: Link to the first page of the relationship
- `next`: Link to the next page (null if on the last page)
- `prev`: Link to the previous page (null if on the first page)
- `last`: Link to the last page (only for offset pagination with count)
- `self`: Link to the relationship endpoint (if configured)
- `related`: Link to the related resources endpoint (if configured)

These links allow clients to navigate through paginated relationships without manually constructing URLs.

### Metadata Fields

For **offset pagination**:
- `limit`: The number of resources requested
- `offset`: The starting position
- `count`: The total count (if requested)

For **keyset pagination**:
- `limit`: The number of resources requested
- `more?`: Whether there are more resources available
- `count`: The total count (if requested)

## Error Handling

If you attempt to paginate a relationship that is not configured in `paginated_includes`, you will receive a 400 Bad Request error:

```json
{
  "errors": [
    {
      "status": "400",
      "code": "invalid_pagination",
      "title": "InvalidPagination",
      "detail": "Invalid pagination: Relationship path author is not configured for pagination. Add it to paginated_includes in the resource.",
      "source": {
        "parameter": "page"
      }
    }
  ]
}
```

## Best Practices

1. **Performance**: Consider adding default limits at the action level for relationships that are commonly included:

   ```elixir
   read :read do
     primary? true
     pagination offset?: true, default_limit: 20
   end
   ```

2. **Client Implementation**:
   - Use the pagination `links` in the relationship object to navigate pages instead of manually constructing URLs
   - Check the `meta` object to understand pagination state (limit, offset, count, etc.)
   - Check if `next` link is `null` to determine if you're on the last page
   - Check if `prev` link is `null` to determine if you're on the first page

3. **Nested Pagination**: Be cautious with nested pagination - paginating both `posts` and `posts.comments` can result in complex queries. Consider whether you really need both levels paginated.

4. **Backwards Compatibility**: Non-paginated includes continue to work as before, so adding `paginated_includes` configuration is backwards compatible. Clients that don't use `included_page` parameters will receive all related resources as usual.

5. **JSON:API Compliance**: The pagination links in relationships follow the JSON:API specification, which states that "A relationship object that represents a to-many relationship MAY also contain pagination links under the links member."

## Example: Complete Flow

### 1. Configure the resource

```elixir
defmodule MyApp.Post do
  use Ash.Resource,
    extensions: [AshJsonApi.Resource]

  json_api do
    type "post"
    includes [:comments, :author]
    paginated_includes [:comments]

    routes do
      base "/posts"
      get :read
      index :read
    end
  end

  actions do
    defaults [:read]
  end

  relationships do
    has_many :comments, MyApp.Comment
    belongs_to :author, MyApp.Author
  end
end
```

### 2. Make the API request

```bash
curl "http://localhost:4000/posts/1?include=comments&included_page[comments][limit]=5&included_page[comments][count]=true"
```

### 3. Process the response

The response will include:
- The post data in `data`
- Up to 5 comments in `included`
- Pagination metadata in `data.relationships.comments.meta`
- Pagination links in `data.relationships.comments.links`
- The linkage (comment IDs) in `data.relationships.comments.data`

### 4. Navigate to the next page

```bash
curl "http://localhost:4000/posts/1?include=comments&included_page[comments][limit]=5&included_page[comments][offset]=5"
```

## Combining with Other Features

Paginated relationships can be combined with:

- **Sparse fieldsets**: `fields[comment]=body,created_at`
- **Filtering included**: `filter_included[comments][status]=published`
- **Sorting included**: `sort_included[comments]=-created_at`
- **Field inputs**: `field_inputs[comment][calculated_field][arg]=value`

Example combining multiple features:

```
GET /posts/1?
  include=comments&
  included_page[comments][limit]=10&
  filter_included[comments][status]=published&
  sort_included[comments]=-created_at&
  fields[comment]=body,author_name
```