Skip to content

Commit b36a76c

Browse files
committed
fix: make composite primary key path param behavior opt-in
1 parent 1caf82a commit b36a76c

File tree

9 files changed

+332
-54
lines changed

9 files changed

+332
-54
lines changed

.formatter.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ spark_locals_without_parens = [
3636
patch_relationship: 1,
3737
patch_relationship: 2,
3838
patch_relationship: 3,
39+
path_param_is_composite_key: 1,
3940
post: 1,
4041
post: 2,
4142
post: 3,

documentation/dsls/DSL-AshJsonApi.Domain.md

Lines changed: 46 additions & 0 deletions
Large diffs are not rendered by default.

documentation/dsls/DSL-AshJsonApi.Resource.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ A GET route to retrieve a single record
132132
get :read
133133
```
134134

135+
```
136+
get :read, path_param_is_composite_key: :id
137+
```
138+
135139

136140

137141
### Arguments
@@ -151,6 +155,7 @@ get :read
151155
| [`name`](#json_api-routes-get-name){: #json_api-routes-get-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications |
152156
| [`derive_sort?`](#json_api-routes-get-derive_sort?){: #json_api-routes-get-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
153157
| [`derive_filter?`](#json_api-routes-get-derive_filter?){: #json_api-routes-get-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
158+
| [`path_param_is_composite_key`](#json_api-routes-get-path_param_is_composite_key){: #json_api-routes-get-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. |
154159

155160

156161

@@ -195,6 +200,7 @@ index :read
195200
| [`name`](#json_api-routes-index-name){: #json_api-routes-index-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications |
196201
| [`derive_sort?`](#json_api-routes-index-derive_sort?){: #json_api-routes-index-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
197202
| [`derive_filter?`](#json_api-routes-index-derive_filter?){: #json_api-routes-index-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
203+
| [`path_param_is_composite_key`](#json_api-routes-index-path_param_is_composite_key){: #json_api-routes-index-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. |
198204

199205

200206

@@ -239,6 +245,7 @@ post :create
239245
| [`name`](#json_api-routes-post-name){: #json_api-routes-post-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications |
240246
| [`derive_sort?`](#json_api-routes-post-derive_sort?){: #json_api-routes-post-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
241247
| [`derive_filter?`](#json_api-routes-post-derive_filter?){: #json_api-routes-post-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
248+
| [`path_param_is_composite_key`](#json_api-routes-post-path_param_is_composite_key){: #json_api-routes-post-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. |
242249
| [`relationship_arguments`](#json_api-routes-post-relationship_arguments){: #json_api-routes-post-relationship_arguments } | `list(atom \| {:id, atom})` | `[]` | Arguments to be used to edit relationships. See the [relationships guide](/documentation/topics/relationships.md) for more. |
243250
| [`upsert?`](#json_api-routes-post-upsert?){: #json_api-routes-post-upsert? } | `boolean` | `false` | Whether or not to use the `upsert?: true` option when calling `Ash.create/2`. |
244251
| [`upsert_identity`](#json_api-routes-post-upsert_identity){: #json_api-routes-post-upsert_identity } | `atom` | `false` | Which identity to use for the upsert |
@@ -266,6 +273,10 @@ A PATCH route to update a record
266273
patch :update
267274
```
268275

276+
```
277+
patch :update, path_param_is_composite_key: :id
278+
```
279+
269280

270281

271282
### Arguments
@@ -288,6 +299,7 @@ patch :update
288299
| [`name`](#json_api-routes-patch-name){: #json_api-routes-patch-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications |
289300
| [`derive_sort?`](#json_api-routes-patch-derive_sort?){: #json_api-routes-patch-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
290301
| [`derive_filter?`](#json_api-routes-patch-derive_filter?){: #json_api-routes-patch-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
302+
| [`path_param_is_composite_key`](#json_api-routes-patch-path_param_is_composite_key){: #json_api-routes-patch-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. |
291303

292304

293305

@@ -312,6 +324,10 @@ A DELETE route to destroy a record
312324
delete :destroy
313325
```
314326

327+
```
328+
delete :destroy, path_param_is_composite_key: :id
329+
```
330+
315331

316332

317333
### Arguments
@@ -333,6 +349,7 @@ delete :destroy
333349
| [`name`](#json_api-routes-delete-name){: #json_api-routes-delete-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications |
334350
| [`derive_sort?`](#json_api-routes-delete-derive_sort?){: #json_api-routes-delete-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
335351
| [`derive_filter?`](#json_api-routes-delete-derive_filter?){: #json_api-routes-delete-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
352+
| [`path_param_is_composite_key`](#json_api-routes-delete-path_param_is_composite_key){: #json_api-routes-delete-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. |
336353

337354

338355

@@ -378,6 +395,7 @@ related :comments, :read
378395
| [`name`](#json_api-routes-related-name){: #json_api-routes-related-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications |
379396
| [`derive_sort?`](#json_api-routes-related-derive_sort?){: #json_api-routes-related-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
380397
| [`derive_filter?`](#json_api-routes-related-derive_filter?){: #json_api-routes-related-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
398+
| [`path_param_is_composite_key`](#json_api-routes-related-path_param_is_composite_key){: #json_api-routes-related-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. |
381399

382400

383401

@@ -423,6 +441,7 @@ relationship :comments, :read
423441
| [`name`](#json_api-routes-relationship-name){: #json_api-routes-relationship-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications |
424442
| [`derive_sort?`](#json_api-routes-relationship-derive_sort?){: #json_api-routes-relationship-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
425443
| [`derive_filter?`](#json_api-routes-relationship-derive_filter?){: #json_api-routes-relationship-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
444+
| [`path_param_is_composite_key`](#json_api-routes-relationship-path_param_is_composite_key){: #json_api-routes-relationship-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. |
426445

427446

428447

@@ -467,6 +486,7 @@ post_to_relationship :comments
467486
| [`name`](#json_api-routes-post_to_relationship-name){: #json_api-routes-post_to_relationship-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications |
468487
| [`derive_sort?`](#json_api-routes-post_to_relationship-derive_sort?){: #json_api-routes-post_to_relationship-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
469488
| [`derive_filter?`](#json_api-routes-post_to_relationship-derive_filter?){: #json_api-routes-post_to_relationship-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
489+
| [`path_param_is_composite_key`](#json_api-routes-post_to_relationship-path_param_is_composite_key){: #json_api-routes-post_to_relationship-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. |
470490

471491

472492

@@ -511,6 +531,7 @@ patch_relationship :comments
511531
| [`name`](#json_api-routes-patch_relationship-name){: #json_api-routes-patch_relationship-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications |
512532
| [`derive_sort?`](#json_api-routes-patch_relationship-derive_sort?){: #json_api-routes-patch_relationship-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
513533
| [`derive_filter?`](#json_api-routes-patch_relationship-derive_filter?){: #json_api-routes-patch_relationship-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
534+
| [`path_param_is_composite_key`](#json_api-routes-patch_relationship-path_param_is_composite_key){: #json_api-routes-patch_relationship-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. |
514535

515536

516537

@@ -555,6 +576,7 @@ delete_from_relationship :comments
555576
| [`name`](#json_api-routes-delete_from_relationship-name){: #json_api-routes-delete_from_relationship-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications |
556577
| [`derive_sort?`](#json_api-routes-delete_from_relationship-derive_sort?){: #json_api-routes-delete_from_relationship-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
557578
| [`derive_filter?`](#json_api-routes-delete_from_relationship-derive_filter?){: #json_api-routes-delete_from_relationship-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
579+
| [`path_param_is_composite_key`](#json_api-routes-delete_from_relationship-path_param_is_composite_key){: #json_api-routes-delete_from_relationship-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. |
558580

559581

560582

@@ -601,6 +623,7 @@ route :get, "say_hi/:name", :say_hello
601623
| [`name`](#json_api-routes-route-name){: #json_api-routes-route-name } | `String.t` | | A globally unique name for this route, to be used when generating docs and open api specifications |
602624
| [`derive_sort?`](#json_api-routes-route-derive_sort?){: #json_api-routes-route-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource |
603625
| [`derive_filter?`](#json_api-routes-route-derive_filter?){: #json_api-routes-route-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
626+
| [`path_param_is_composite_key`](#json_api-routes-route-path_param_is_composite_key){: #json_api-routes-route-path_param_is_composite_key } | `atom` | | The path parameter that should be parsed as a composite primary key. When specified (e.g., :id), the parameter will be split using the resource's primary key delimiter and mapped to individual primary key fields. This is required for resources with composite primary keys to work correctly with GET, PATCH, and DELETE operations. See the composite primary keys documentation for more details. |
604627

605628

606629

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Composite Primary Keys
2+
3+
When working with resources that have composite primary keys (multiple fields that together form the unique identifier), AshJsonApi provides special support for encoding and decoding these keys in URLs.
4+
5+
## Defining Composite Primary Keys
6+
7+
First, define your composite primary key in the JSON API configuration:
8+
9+
```elixir
10+
defmodule MyApp.Bio do
11+
use Ash.Resource,
12+
extensions: [AshJsonApi.Resource]
13+
14+
attributes do
15+
attribute :author_id, :uuid, primary_key?: true, public?: true
16+
attribute :category, :string, primary_key?: true, public?: true
17+
attribute :content, :string, public?: true
18+
end
19+
20+
json_api do
21+
type "bio"
22+
23+
primary_key do
24+
keys [:author_id, :category]
25+
delimiter "|" # Use a delimiter that won't conflict with your data
26+
end
27+
end
28+
end
29+
```
30+
31+
### Important Considerations for Delimiters
32+
33+
When choosing a delimiter, ensure it won't appear in your actual data:
34+
35+
- **UUIDs contain dashes (`-`)** - Don't use `-` as a delimiter if any of your composite key fields are UUIDs
36+
- **Safe alternatives**: `|`, `~`, `::`, or other characters unlikely to appear in your data
37+
- **Default delimiter**: If not specified, AshJsonApi uses `-` as the default delimiter
38+
39+
## Enabling Composite Key Parsing in Routes
40+
41+
To enable automatic parsing of composite primary keys in URL paths, you must opt-in by specifying the `path_param_is_composite_key` option on your routes:
42+
43+
```elixir
44+
json_api do
45+
type "bio"
46+
47+
primary_key do
48+
keys [:author_id, :category]
49+
delimiter "|"
50+
end
51+
52+
routes do
53+
base "/bios"
54+
55+
# Enable composite key parsing for the :id parameter
56+
get :read, path_param_is_composite_key: :id
57+
patch :update, path_param_is_composite_key: :id
58+
delete :destroy, path_param_is_composite_key: :id
59+
60+
# Other routes that don't need composite key parsing
61+
index :read
62+
post :create
63+
end
64+
end
65+
```
66+
67+
## How It Works
68+
69+
With the above configuration:
70+
71+
1. **URL Format**: `/bios/550e8400-e29b-41d4-a716-446655440000|sports`
72+
2. **Parsing**: The `:id` parameter `550e8400-e29b-41d4-a716-446655440000|sports` gets split by the `|` delimiter
73+
3. **Mapping**: The parts are mapped to the primary key fields in order:
74+
- `author_id` = `550e8400-e29b-41d4-a716-446655440000`
75+
- `category` = `sports`
76+
4. **Filtering**: The query is filtered to find the record with both `author_id` and `category` matching
77+
78+
## Example Usage
79+
80+
```elixir
81+
# Creating a bio
82+
POST /bios
83+
{
84+
"data": {
85+
"type": "bio",
86+
"attributes": {
87+
"author_id": "550e8400-e29b-41d4-a716-446655440000",
88+
"category": "sports",
89+
"content": "Author bio for sports category"
90+
}
91+
}
92+
}
93+
94+
# Retrieving the bio using composite key
95+
GET /bios/550e8400-e29b-41d4-a716-446655440000|sports
96+
97+
# Updating the bio
98+
PATCH /bios/550e8400-e29b-41d4-a716-446655440000|sports
99+
{
100+
"data": {
101+
"type": "bio",
102+
"attributes": {
103+
"content": "Updated bio content"
104+
}
105+
}
106+
}
107+
108+
# Deleting the bio
109+
DELETE /bios/550e8400-e29b-41d4-a716-446655440000|sports
110+
```
111+
112+
## Without Opt-In Parsing
113+
114+
If you don't specify `path_param_is_composite_key` on a route, the path parameter will be treated as a regular single value, even if your resource has composite primary keys defined. This ensures backward compatibility and prevents unexpected behavior.
115+
116+
## Error Handling
117+
118+
If the composite key format is invalid (wrong number of parts after splitting), AshJsonApi will return a 404 Not Found error with appropriate JSON:API error formatting.

0 commit comments

Comments
 (0)