Skip to content

Commit 136736a

Browse files
authored
Merge pull request #270 from nullpilot/feature/dynamic-primary-keys
Dynamic Primary Keys
2 parents 862a6b7 + bbbdad7 commit 136736a

File tree

11 files changed

+265
-29
lines changed

11 files changed

+265
-29
lines changed

lib/kaffy/resource_admin.ex

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ defmodule Kaffy.ResourceAdmin do
22
alias Kaffy.ResourceSchema
33
alias Kaffy.Utils
44

5+
@default_id_separator ":"
6+
57
@moduledoc """
68
ResourceAdmin modules should be created for every schema you want to customize/configure in Kaffy.
79
@@ -148,7 +150,7 @@ defmodule Kaffy.ResourceAdmin do
148150
@doc """
149151
`ordering/1` takes a schema and returns how the entries should be ordered.
150152
151-
If `ordering/1` is not defined, Kaffy will return `[desc: :id]`.
153+
If `ordering/1` is not defined, Kaffy will return `[desc: primary_key]`, or the first field of the primary key, if it's a composite.
152154
153155
## Examples
154156
@@ -159,7 +161,10 @@ defmodule Kaffy.ResourceAdmin do
159161
```
160162
"""
161163
def ordering(resource) do
162-
Utils.get_assigned_value_or_default(resource, :ordering, desc: :id)
164+
schema = resource[:schema]
165+
[order_key | _] = ResourceSchema.primary_keys(schema)
166+
167+
Utils.get_assigned_value_or_default(resource, :ordering, desc: order_key)
163168
end
164169

165170
@doc """
@@ -331,6 +336,70 @@ defmodule Kaffy.ResourceAdmin do
331336
Utils.get_assigned_value_or_default(resource, :plural_name, default)
332337
end
333338

339+
@doc """
340+
`serialize_id/2` takes a schema and record and must return a string to be used in the URL and form values.
341+
342+
If `serialize_id/2` is not defined, Kaffy will concatenate multiple primary keys with `":"` as a separator.
343+
344+
Examples:
345+
346+
```elixir
347+
# Default method with fixed keys
348+
def serialize_id(_schema, record) do
349+
Enum.join([record.post_id, record.tag_id], ":")
350+
end
351+
352+
# ETF
353+
def serialize_id(_schema, record) do
354+
{record.post_id, record.tag_id}
355+
|> :erlang.term_to_binary()
356+
|> Base.url_encode64()
357+
end
358+
```
359+
"""
360+
def serialize_id(resource, entry) do
361+
schema = resource[:schema]
362+
default = schema
363+
|> ResourceSchema.primary_keys()
364+
|> Enum.map_join(@default_id_separator, &Map.get(entry, &1))
365+
366+
Utils.get_assigned_value_or_default(resource, :serialize_id, default, [entry])
367+
end
368+
369+
@doc """
370+
`deserialize_id/2` takes a schema and serialized id and must return a complete
371+
keyword list in the form of [{:primary_key, value}, ...].
372+
373+
If `deserialize_id/2` is not defined, Kaffy will split multiple primary keys with `":"` as a separator.
374+
375+
Examples:
376+
377+
```elixir
378+
# Default method with fixed keys
379+
def deserialize_id(_schema, serialized_id) do
380+
Enum.zip([:post_id, :tag_id], String.split(serialized_id, ":"))
381+
end
382+
383+
# Deserialize from ETF
384+
def deserialize_id(_schema, serialized_id) do
385+
{product_id, tag_id} = serialized_id
386+
|> Base.url_decode64!()
387+
|> :erlang.binary_to_term()
388+
389+
[product_id: product_id, tag_id: tag_id]
390+
end
391+
```
392+
"""
393+
def deserialize_id(resource, id) do
394+
schema = resource[:schema]
395+
id_list = String.split(id, @default_id_separator)
396+
default = schema
397+
|> ResourceSchema.primary_keys()
398+
|> Enum.zip(id_list)
399+
400+
Utils.get_assigned_value_or_default(resource, :deserialize_id, default, [id])
401+
end
402+
334403
def resource_actions(resource, conn) do
335404
Utils.get_assigned_value_or_default(resource, :resource_actions, nil, [conn], false)
336405
end

lib/kaffy/resource_form.ex

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,14 @@ defmodule Kaffy.ResourceForm do
4747
opts
4848
end
4949

50+
# Check if any primary key fields are nil
51+
is_create_event = changeset.data.__struct__
52+
|> Kaffy.ResourceSchema.primary_keys()
53+
|> Enum.map(&Map.get(changeset.data, &1))
54+
|> Enum.any?(&is_nil/1)
55+
5056
permission =
51-
case is_nil(changeset.data.id) do
57+
case is_create_event do
5258
true -> Map.get(options, :create, :editable)
5359
false -> Map.get(options, :update, :editable)
5460
end
@@ -115,13 +121,13 @@ defmodule Kaffy.ResourceForm do
115121
textarea(form, field, [value: value, rows: 4, placeholder: "JSON Content"] ++ opts)
116122

117123
:id ->
118-
case Kaffy.ResourceSchema.primary_key(schema) == [field] do
124+
case field in Kaffy.ResourceSchema.primary_keys(schema) do
119125
true -> text_input(form, field, opts)
120126
false -> text_or_assoc(conn, schema, form, field, opts)
121127
end
122128

123129
:binary_id ->
124-
case Kaffy.ResourceSchema.primary_key(schema) == [field] do
130+
case field in Kaffy.ResourceSchema.primary_keys(schema) do
125131
true -> text_input(form, field, opts)
126132
false -> text_or_assoc(conn, schema, form, field, opts)
127133
end

lib/kaffy/resource_query.ex

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ defmodule Kaffy.ResourceQuery do
5252

5353
def fetch_resource(conn, resource, id) do
5454
schema = resource[:schema]
55-
query = from(s in schema, where: s.id == ^id)
55+
56+
id_filter = Kaffy.ResourceAdmin.deserialize_id(resource, id)
57+
query = from(s in schema, where: ^id_filter)
5658

5759
case Kaffy.ResourceAdmin.custom_show_query(conn, resource, query) do
5860
{custom_query, opts} -> Kaffy.Utils.repo().one(custom_query, opts)
@@ -65,8 +67,13 @@ defmodule Kaffy.ResourceQuery do
6567
def fetch_list(resource, ids) do
6668
schema = resource[:schema]
6769

68-
from(s in schema, where: s.id in ^ids)
69-
|> Kaffy.Utils.repo().all()
70+
primary_keys = Kaffy.ResourceSchema.primary_keys(schema)
71+
ids = Enum.map(ids, &Kaffy.ResourceAdmin.deserialize_id(resource, &1))
72+
73+
case build_list_query(schema, primary_keys, ids) do
74+
{:error, error_msg} -> {:error, error_msg}
75+
query -> Kaffy.Utils.repo().all(query)
76+
end
7077
end
7178

7279
def total_count(schema, do_cache, query, opts \\ [])
@@ -149,6 +156,21 @@ defmodule Kaffy.ResourceQuery do
149156
{query, limited_query}
150157
end
151158

159+
defp build_list_query(_schema, [], _key_pairs) do
160+
{:error, "No private keys. List action not supported."}
161+
end
162+
163+
defp build_list_query(schema, [primary_key], ids) do
164+
ids = Enum.map(ids, fn [{_key, id}] -> id end)
165+
from(s in schema, where: field(s, ^primary_key) in ^ids)
166+
end
167+
168+
defp build_list_query(schema, _composite_key, key_pairs) do
169+
Enum.reduce(key_pairs, schema, fn pair, query_acc ->
170+
from query_acc, or_where: ^pair
171+
end)
172+
end
173+
152174
defp build_filtered_fields_query(query, []), do: query
153175

154176
defp build_filtered_fields_query(query, [filter | rest]) do

lib/kaffy/resource_schema.ex

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule Kaffy.ResourceSchema do
22
@moduledoc false
33

4-
def primary_key(schema) do
4+
def primary_keys(schema) do
55
schema.__schema__(:primary_key)
66
end
77

@@ -26,7 +26,10 @@ defmodule Kaffy.ResourceSchema do
2626
end
2727

2828
def form_fields(schema) do
29-
to_be_removed = fields_to_be_removed(schema) ++ [:id, :inserted_at, :updated_at]
29+
to_be_removed =
30+
fields_to_be_removed(schema) ++
31+
primary_keys(schema) ++
32+
[:inserted_at, :updated_at]
3033
Keyword.drop(fields(schema), to_be_removed)
3134
end
3235

@@ -35,7 +38,9 @@ defmodule Kaffy.ResourceSchema do
3538
fields_to_be_removed(schema) ++
3639
get_has_many_associations(schema) ++
3740
get_has_one_assocations(schema) ++
38-
get_many_to_many_associations(schema) ++ [:id, :inserted_at, :updated_at]
41+
get_many_to_many_associations(schema) ++
42+
primary_keys(schema) ++
43+
[:inserted_at, :updated_at]
3944

4045
Keyword.drop(fields(schema), to_be_removed)
4146
end

lib/kaffy_web/controllers/resource_controller.ex

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -349,11 +349,14 @@ defmodule KaffyWeb.ResourceController do
349349
action_record = get_action_record(actions, action_key)
350350
kaffy_inputs = Map.get(params, "kaffy-input", %{})
351351

352-
result =
353-
case Map.get(action_record, :inputs, []) do
354-
[] -> action_record.action.(conn, entries)
355-
_ -> action_record.action.(conn, entries, kaffy_inputs)
356-
end
352+
result = case entries do
353+
{:error, error_msg} -> {:error, error_msg}
354+
entries ->
355+
case Map.get(action_record, :inputs, []) do
356+
[] -> action_record.action.(conn, entries)
357+
_ -> action_record.action.(conn, entries, kaffy_inputs)
358+
end
359+
end
357360

358361
case result do
359362
:ok ->
@@ -391,14 +394,17 @@ defmodule KaffyWeb.ResourceController do
391394
end
392395

393396
defp redirect_to_resource(conn, context, resource, entry) do
397+
my_resource = Kaffy.Utils.get_resource(conn, context, resource)
398+
id = Kaffy.ResourceAdmin.serialize_id(my_resource, entry)
399+
394400
redirect(conn,
395401
to:
396402
Kaffy.Utils.router().kaffy_resource_path(
397403
conn,
398404
:show,
399405
context,
400406
resource,
401-
entry.id
407+
id
402408
)
403409
)
404410
end

lib/kaffy_web/templates/resource/_table.html.eex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44
</thead>
55

66
<tbody>
7-
<%= for entry <- @entries do %>
7+
<%= for {entry, index} <- Enum.with_index(@entries) do %>
88
<tr>
99
<td>
1010
<div class="custom-control custom-checkbox">
11-
<input type="checkbox" class="custom-control-input select-item kaffy-resource-checkbox" id="kaffy-select-<%= entry.id %>" name="resource" value="<%= entry.id %>"/>
12-
<label class="custom-control-label" for="kaffy-select-<%= entry.id %>"></label>
11+
<input type="checkbox" class="custom-control-input select-item kaffy-resource-checkbox" id="kaffy-select-<%= index %>" name="resource" value="<%= Kaffy.ResourceAdmin.serialize_id(@my_resource, entry) %>"/>
12+
<label class="custom-control-label" for="kaffy-select-<%= index %>"></label>
1313
</div>
1414
</td>
1515
<%= for {field, index} <- Enum.with_index(@fields) do %>
1616
<%= if index == 0 do %>
17-
<td><%= link Kaffy.ResourceSchema.kaffy_field_value(@conn, entry, field), to: Kaffy.Utils.router().kaffy_resource_path(@conn, :show, @context, @resource, entry.id) %></td>
17+
<td><%= link Kaffy.ResourceSchema.kaffy_field_value(@conn, entry, field), to: Kaffy.Utils.router().kaffy_resource_path(@conn, :show, @context, @resource, Kaffy.ResourceAdmin.serialize_id(@my_resource, entry)) %></td>
1818
<% else %>
1919
<td><%= Kaffy.ResourceSchema.kaffy_field_value(@conn, entry, field) %></td>
2020
<% end %>

lib/kaffy_web/templates/resource/show.html.eex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<div class="card-header">
1010
<div class="row justify-content-between">
1111
<div class="col-auto mr-auto">
12-
<h1><%= @resource_name %> #<%= @changeset.data.id %></h1>
12+
<h1><%= @resource_name %> #<%= Kaffy.ResourceAdmin.serialize_id(@my_resource, @changeset.data) %></h1>
1313
</div>
1414
<div class="col-auto">
1515
<%= if Kaffy.ResourceAdmin.resource_actions(@my_resource, @conn) do %>
@@ -20,7 +20,7 @@
2020
</button>
2121
<div class="dropdown-menu">
2222
<%= for {action_key, options} <- Kaffy.ResourceAdmin.resource_actions(@my_resource, @conn) do %>
23-
<%= form_tag(Kaffy.Utils.router().kaffy_resource_path(@conn, :single_action, @context, @resource, @changeset.data.id, to_string(action_key)), method: :post) %>
23+
<%= form_tag(Kaffy.Utils.router().kaffy_resource_path(@conn, :single_action, @context, @resource, Kaffy.ResourceAdmin.serialize_id(@my_resource, @changeset.data), to_string(action_key)), method: :post) %>
2424
<input type="submit" name="submit" value="<%= options.name %>" class="dropdown-item" />
2525
</form>
2626
<% end %>
@@ -32,7 +32,7 @@
3232
</div>
3333
</div>
3434
<div class="card-body">
35-
<%= form_for @changeset, Kaffy.Utils.router().kaffy_resource_path(@conn, :update, @context, @resource, @changeset.data.id), [method: :put, multipart: true], fn f -> %>
35+
<%= form_for @changeset, Kaffy.Utils.router().kaffy_resource_path(@conn, :update, @context, @resource, Kaffy.ResourceAdmin.serialize_id(@my_resource, @changeset.data)), [method: :put, multipart: true], fn f -> %>
3636
<%= for {field, options} <- Kaffy.ResourceAdmin.form_fields(@my_resource) do %>
3737
<%= if options.update != :hidden do %>
3838
<%= Kaffy.ResourceForm.kaffy_input @conn, @changeset, f, field, options %>
@@ -53,7 +53,7 @@
5353
</div>
5454

5555
<!-- Modal -->
56-
<%= form_for @changeset, Kaffy.Utils.router().kaffy_resource_path(@conn, :delete, @context, @resource, @changeset.data.id), [method: :delete], fn _f -> %>
56+
<%= form_for @changeset, Kaffy.Utils.router().kaffy_resource_path(@conn, :delete, @context, @resource, Kaffy.ResourceAdmin.serialize_id(@my_resource, @changeset.data)), [method: :delete], fn _f -> %>
5757
<div class="modal fade" id="delete-modal" tabindex="-1" role="dialog" aria-labelledby="delete-modal-label" aria-hidden="true">
5858
<div class="modal-dialog" role="document">
5959
<div class="modal-content">

test/actions_test.exs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,35 @@ defmodule ActionsTest do
6767
end
6868
end
6969

70+
defmodule ActionsCompositeKeyAdmin do
71+
@string_action "test_action"
72+
@response %{product_id: 1, tag_id: 1}
73+
74+
def index(_), do: []
75+
76+
def resource_actions(_conn) do
77+
%{
78+
@string_action => %{
79+
name: "Test Action",
80+
action: fn _, _ ->
81+
{:ok, @response}
82+
end
83+
}
84+
}
85+
end
86+
87+
def list_actions(_conn) do
88+
%{
89+
@string_action => %{
90+
name: "Test Action",
91+
action: fn _, _ ->
92+
:ok
93+
end
94+
}
95+
}
96+
end
97+
end
98+
7099
defmodule FakeSchema do
71100
use Ecto.Schema
72101

@@ -99,6 +128,12 @@ defmodule ActionsTest do
99128
resources: [
100129
test: [schema: FakeSchema, admin: ActionsMapAdmin]
101130
]
131+
],
132+
composite: [
133+
name: "composite",
134+
resources: [
135+
test: [schema: KaffyTest.Schemas.Owner, admin: ActionsCompositeKeyAdmin]
136+
]
102137
]
103138
]
104139
end)
@@ -140,4 +175,32 @@ defmodule ActionsTest do
140175
assert %{"success" => _} = get_flash(result_conn)
141176
end
142177
end
178+
179+
test "single action handles composite primary keys", %{conn: conn} do
180+
with_mock Kaffy.ResourceQuery, fetch_resource: fn _, _, _ -> %{product_id: 1, tag_id: 1} end do
181+
result_conn =
182+
ResourceController.single_action(conn, %{
183+
"context" => "composite",
184+
"resource" => "test",
185+
"action_key" => "test_action",
186+
"id" => "1:1"
187+
})
188+
189+
assert %{"success" => _} = get_flash(result_conn)
190+
end
191+
end
192+
193+
test "list action handles composite primary keys", %{conn: conn} do
194+
with_mock Kaffy.ResourceQuery, fetch_list: fn _, _ -> [%{product_id: 1, tag_id: 1}, %{product_id: 1, tag_id: 2}] end do
195+
result_conn =
196+
ResourceController.list_action(conn, %{
197+
"context" => "composite",
198+
"resource" => "test",
199+
"action_key" => "test_action",
200+
"id" => "1:1,1:2"
201+
})
202+
203+
assert %{"success" => _} = get_flash(result_conn)
204+
end
205+
end
143206
end

0 commit comments

Comments
 (0)