Skip to content

Commit

Permalink
feat: map & where opts
Browse files Browse the repository at this point in the history
  • Loading branch information
grantwest committed Oct 5, 2023
1 parent 8020eb3 commit 4fec486
Show file tree
Hide file tree
Showing 6 changed files with 195 additions and 17 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ To clone a post with it's comments and tags:
{:ok, cloned_post_id} = EctoGraf.clone(%Post{id: 5}, Repo, %{title: "new title"}, [Comment, PostTag])
```

See [clone docs](https://hexdocs.pm/ecto_graf/EctoGraf.html#clone/4) for more information and examples.

### Todo

- [ ] error when intermediate schemas are missing
- [ ] allow setting inserted_at, updated_at
- [ ] filter with where clause
- [ ] support mysql
- [ ] support sqlite
- [ ] clone tables in parallel
Expand Down
92 changes: 82 additions & 10 deletions lib/ecto_graf.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,39 +15,104 @@ defmodule EctoGraf do
@doc """
Deep clones a target record.
`target` is the record being cloned.
`target` is the record to be cloned.
`repo` is the ecto repo to operate on, the current implementation supports only a single repo.
`new_attrs` is a map that will override values on the clone of the target.
`related_schemas_with_options` is the list of schemas to clone in relation to the target.
The order of schemas does not matter, EctoGraf determines clone order by associations.
See examples for setting options per schema. Suppored options include:
`map` - a function that takes the source row fields as a map and returns the cloned row fields
`where` - a function that appends a where clause to the sql query and thus filters which rows should be cloned
## Examples
To clone a post with it's comments:
post = Repo.insert!(%Post{title: "hello"})
Repo.insert!(%Comment{body: "p", post_id: post.id})
{:ok, clone_id} = EctoGraf.clone(post, Repo, %{title: "New Title"}, [Comment])
To clone a post with it's tags, comments, comment edits:
EctoGraf.clone(post, Repo, %{}, [
PostTag,
Comment,
CommentEdit
])
Options on schemas allow chaning values of cloned records:
EctoGraf.clone(post, Repo, %{}, [
PostTag,
[Comment, map: fn comment -> Map.put(comment, :likes, 0) end],
CommentEdit
])
The where option allows selective cloning at the schema level:
EctoGraf.clone(post, Repo, %{}, [
PostTag,
[Comment, where: fn query -> where(query, [comment], comment.likes >= 0) end],
CommentEdit
])
If you use the ecto timestamps and want to set inserted_at on all of your cloned records, you can use the map option globally:
now = your_get_timestamp_function()
EctoGraf.clone(
post,
Repo,
%{},
[PostTag, Comment, CommentEdit],
map: fn r -> Map.put(r, :inserted_at, now) end
)
## Association Requirements
Each related schema being cloned must have either a belongs_to or has_one through association to the target.
A Comment can be cloned along with a Post because it has `belongs_to :post, Post`
A CommentEdit can be cloned with a Post because it has `belongs_to :comment, Comment` and `has_one :post, through: [:comment, :post]`
It is possible to have multiple possible relations to the target. A schema can have multiple has_one through:
schema "comment_pair" do
belongs_to :comment_a, Comment
belongs_to :comment_b, Comment
has_one :post_a, through: [:comment_a, :post]
has_one :post_b, through: [:comment_b, :post]
end
Or a schema can have both a belongs_to AND has_one through:
schema "moderation_flag" do
belongs_to :post, Post
belongs_to :comment, Comment
has_one :comment_post, through: [:comment, :post]
end
These examples, while contrived, show that a record can have multple potential paths to being related to the target.
Every possible path is exhausted when determing if a record is related to the clone target.
"""
@spec clone(target(), Ecto.Repo.t(), map(), [related_schema_with_options()]) ::
{:ok, any()} | {:error, binary()}
def clone(target, repo, new_attrs, related_schemas_with_options) do
def clone(target, repo, new_attrs, related_schemas_with_options, global_opts \\ []) do
%target_schema{} = target

schema_options =
Enum.map(related_schemas_with_options, fn
schema when is_atom(schema) -> {schema, []}
[schema | opts] when is_atom(schema) -> {schema, opts}
schema when is_atom(schema) -> {schema, global_opts}
[schema | schema_opts] when is_atom(schema) -> {schema, global_opts ++ schema_opts}
other -> raise "invalid schema #{inspect(other)}"
end)
|> Map.new()

target_opts = [
map: fn t -> Map.merge(t, new_attrs) end
]
target_opts =
[
map: fn t -> Map.merge(t, new_attrs) end
] ++ global_opts

schema_options = Map.put(schema_options, target_schema, target_opts)
schemas = Map.keys(schema_options)
Expand Down Expand Up @@ -175,11 +240,18 @@ defmodule EctoGraf do
defp stream_rows(schema, target, state) do
from(row in schema, select: row)
|> order_by(^schema.__schema__(:primary_key))
|> apply_where_opts(schema, state)
|> join_to_target(schema, target.__struct__, target)
|> state.repo.stream()
end

defp join_to_target(query, schema, schema, target) do
defp apply_where_opts(query, schema, state) do
state.schema_opts[schema]
|> Keyword.get_values(:where)
|> Enum.reduce(query, fn where, query -> where.(query) end)
end

defp join_to_target(query, target_schema, target_schema, target) do
where(query, id: ^target.id)
end

Expand Down Expand Up @@ -207,7 +279,7 @@ defmodule EctoGraf do
|> Enum.filter(&match?(%BelongsTo{}, &1))
end

def self_associations(schema) do
defp self_associations(schema) do
belongs_to_associations(schema)
|> Enum.filter(&(&1.related == schema))
end
Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule EctoGraf.MixProject do
use Mix.Project

@version "0.1.0"
@version "0.2.0"
@description "Leverage Ecto associations to deep clone db records & do other helpful stuff"
@source_url "https://github.com/grantwest/ecto_graf"

Expand Down Expand Up @@ -69,7 +69,7 @@ defmodule EctoGraf.MixProject do
source_ref: "v#{@version}",
source_url: @source_url,
extras: ["README.md"],
main: "readme",
main: "readme"
# main: "readme",
]
end
Expand Down
110 changes: 107 additions & 3 deletions test/clone_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,114 @@ defmodule EctoGraf.CloneTest do
assert count == comment_count
end

test "map option on individual schema" do
post = Repo.insert!(%Post{title: "hello"})
Repo.insert!(%Comment{body: "first", post_id: post.id})
before = all_entires()

{:ok, clone_id} =
EctoGraf.clone(post, Repo, %{}, [[Comment, map: fn c -> Map.put(c, :body, "new") end]])

assert %{
posts: [%Post{id: ^clone_id, title: "hello"}],
comments: [%Comment{body: "new", post_id: ^clone_id}],
edits: [],
tags: [],
post_tags: [],
comment_pairs: []
} = diff(before, all_entires())
end

test "global map option" do
post = Repo.insert!(%Post{title: "hello"})
Repo.insert!(%Comment{body: "first", post_id: post.id})
before = all_entires()

{:ok, clone_id} =
EctoGraf.clone(post, Repo, %{}, [Comment], map: fn c -> Map.put(c, :body, "new") end)

assert %{
posts: [%Post{id: ^clone_id, title: "hello"}],
comments: [%Comment{body: "new", post_id: ^clone_id}],
edits: [],
tags: [],
post_tags: [],
comment_pairs: []
} = diff(before, all_entires())
end

test "combined schema map option and global map option" do
post = Repo.insert!(%Post{title: "hello"})
Repo.insert!(%Comment{body: "first", likes: 1, post_id: post.id})
before = all_entires()

{:ok, clone_id} =
EctoGraf.clone(
post,
Repo,
%{},
[[Comment, map: fn c -> Map.put(c, :likes, -1) end]],
map: fn c -> Map.put(c, :body, "new") end
)

assert %{
posts: [%Post{id: ^clone_id, title: "hello"}],
comments: [%Comment{body: "new", likes: -1, post_id: ^clone_id}],
edits: [],
tags: [],
post_tags: [],
comment_pairs: []
} = diff(before, all_entires())
end

test "schema map option takes precedence over global map option" do
post = Repo.insert!(%Post{title: "hello"})
Repo.insert!(%Comment{body: "first", likes: 1, post_id: post.id})
before = all_entires()

{:ok, clone_id} =
EctoGraf.clone(
post,
Repo,
%{},
[[Comment, map: fn c -> Map.put(c, :body, "schema") end]],
map: fn c -> Map.put(c, :body, "global") end
)

assert %{
posts: [%Post{id: ^clone_id, title: "hello"}],
comments: [%Comment{body: "schema", post_id: ^clone_id}],
edits: [],
tags: [],
post_tags: [],
comment_pairs: []
} = diff(before, all_entires())
end

test "where option" do
post = Repo.insert!(%Post{title: "hello"})
Repo.insert!(%Comment{body: "1", post_id: post.id})
Repo.insert!(%Comment{body: "2", post_id: post.id})
before = all_entires()

{:ok, clone_id} =
EctoGraf.clone(post, Repo, %{}, [[Comment, where: &where(&1, [c], c.body == "1")]])

assert %{
posts: [%Post{id: ^clone_id, title: "hello"}],
comments: [%Comment{body: "1", post_id: ^clone_id}],
edits: [],
tags: [],
post_tags: [],
comment_pairs: []
} = diff(before, all_entires())
end

test "target not found error" do
post = %Post{id: -1}
assert EctoGraf.clone(post, Repo, %{}, []) == {:error, "target not found"}
end

test "circular associations error" do
end

test "no association to target error" do
user = Repo.insert!(%User{name: "alice"})
post = Repo.insert!(%Post{title: "hello", author_id: user.id})
Expand All @@ -212,6 +312,10 @@ defmodule EctoGraf.CloneTest do
end
end

@tag :skip
test "circular associations error" do
end

defp all_entires() do
%{
posts: Repo.all(from(x in Post, order_by: x.id)),
Expand Down
1 change: 1 addition & 0 deletions test/support/migrations/20230830015426_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ defmodule EctoGraf.Repo.Migrations.Test do

create table("comment") do
add(:body, :string)
add(:likes, :integer)
add(:post_id, references(:post))
add(:parent_id, references(:comment))
end
Expand Down
1 change: 1 addition & 0 deletions test/support/schemas.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ defmodule EctoGraf.Schemas.Comment do

schema "comment" do
field :body, :string
field :likes, :integer
belongs_to :post, Post
belongs_to :parent, __MODULE__
belongs_to :circular, Circular
Expand Down

0 comments on commit 4fec486

Please sign in to comment.