Skip to content

Commit

Permalink
Adds the notion of unassignable tags.
Browse files Browse the repository at this point in the history
  • Loading branch information
hickscorp committed Jul 2, 2020
1 parent 5a324c2 commit 2a5fc3d
Show file tree
Hide file tree
Showing 8 changed files with 154 additions and 84 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,19 @@ You can override this behaviour by doing the following in your `config` files:
config :dymo, create_missing_tags_by_default: true
```

## Unassignable Labels

Sometimes, tags might be present in a database but it should be forbidden for some of them to become
assigned to a taggable. For example, if some of the tags are stored for the sole purpose of structure, if
they represent a parent tag that should never be attached itself, etc.

Dymo handles these cases by exposing an attribute on the `Tag` model named `assignable`. Tags can be attached
to taggables as long as their `attachable`attribute is set to true - otherwise they will just be dropped from
tagging operations.

Note that operations such as `set_labels` or `add_labels` won't raise or error when unassignable tags are
given. Instead, they will just ignore these tags completely.

### Querying Labels

To get the labels associated with a given post, you have several options.
Expand Down
30 changes: 15 additions & 15 deletions lib/dymo/tag.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@ defmodule Dymo.Tag do
It essentially aims at maintaining singleton labels in a `tags` table
and exposes helper functions to ease their creation.
"""

use Ecto.Schema
import Ecto.Query

alias Dymo.Tag.Ns
alias Ecto.Changeset
import Ecto.{Query, Changeset}

@me __MODULE__

Expand All @@ -25,15 +23,17 @@ defmodule Dymo.Tag do
@type label_or_labels :: label | [label]

@typedoc "Defines attributes for building this model's changeset"
@type attrs :: %{
@type creation_attrs :: %{
optional(:ns) => Ns.t(),
optional(:assignable) => boolean,
required(:label) => String.t()
}

schema "tags" do
# Regular fields.
field :label, :string
field :ns, Ns, default: Ns.root_namespace()
field :label, :string
field :assignable, :boolean, default: true
timestamps()
end

Expand Down Expand Up @@ -71,7 +71,7 @@ defmodule Dymo.Tag do
...> |> Map.take([:valid?])
%{valid?: false}
"""
@spec changeset(label | attrs()) :: Ecto.Changeset.t()
@spec changeset(label | creation_attrs()) :: Changeset.t()
# Called for a given string label.
def changeset(label) when is_binary(label),
do: changeset(%{label: label})
Expand All @@ -88,9 +88,9 @@ defmodule Dymo.Tag do
sanitized_params = attrs |> Map.put(:ns, ns)

%@me{}
|> Changeset.cast(sanitized_params, [:ns, :label])
|> Changeset.validate_required([:ns, :label])
|> Changeset.unique_constraint(:label, name: :tags_unicity)
|> cast(sanitized_params, [:ns, :label, :assignable])
|> validate_required([:ns, :label])
|> unique_constraint(:label, name: :tags_unicity)
end

# Called with just a %{label: label}.
Expand All @@ -100,15 +100,15 @@ defmodule Dymo.Tag do
@doc """
Casts attributes into a `Tag` struct.
"""
@spec cast(label_or_labels | t() | Changeset.t()) :: t
def cast(%@me{} = struct),
@spec to_struct(label_or_labels | t() | Changeset.t()) :: t
def to_struct(%@me{} = struct),
do: struct

def cast(stuff) do
def to_struct(stuff) do
stuff
|> changeset()
|> case do
%{valid?: true} = cs -> Changeset.apply_changes(cs)
%{valid?: true} = cs -> cs |> apply_changes()
_ -> raise "Parametters cannot be cast into a tag: #{inspect(stuff)}"
end
end
Expand Down Expand Up @@ -152,7 +152,7 @@ defmodule Dymo.Tag do
|> Dymo.repo().insert!(
on_conflict: {:replace, [:updated_at]},
conflict_target: [:ns, :label],
returning: false
returning: [:ns, :label, :assignable]
)

@doc """
Expand Down Expand Up @@ -188,6 +188,6 @@ defmodule Dymo.Tag do
def find_existing({ns, label}),
do:
@me
|> where([t], t.ns == ^ns and t.label == ^label)
|> where([t], t.assignable and t.ns == ^ns and t.label == ^label)
|> Dymo.repo().one()
end
83 changes: 42 additions & 41 deletions lib/dymo/tagger_impl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,30 +42,34 @@ defmodule Dymo.TaggerImpl do
they are discarded if they are not part of the list of passed new
labels.
Note that only tags with the `:assignable` boolean set to `true` can be
set. If a tag which `:assignable` flag is false is provided, it won't be
assigned to the target object **but no error will be returned**.
## Examples
iex> post = %Dymo.Post{title: "Hey"}
...> |> Dymo.repo().insert!
iex> post =
...> %Dymo.Post{title: "Hey"}
...> |> Dymo.repo().insert!
iex> %{ns: :special, label: "nope", assignable: false}
...> |> Dymo.Tag.changeset()
...> |> Dymo.repo().insert!
iex> post
...> |> TaggerImpl.set_labels([{:rank, "one"}, {:rank, "two"}], create_missing: true)
...> |> Map.get(:tags)
...> |> Enum.map(& {&1.ns, &1.label})
...> |> TaggerImpl.set_labels([{:rank, "one"}, {:rank, "two"}, {:special, "nope"}], create_missing: true)
...> |> Map.get(:tags)
...> |> Enum.map(& {&1.ns, &1.label})
[{:rank, "one"}, {:rank, "two"}]
iex> post
...> |> TaggerImpl.set_labels({:rank, "officer"}, create_missing: true)
...> |> Map.get(:tags)
...> |> Enum.map(& {&1.ns, &1.label})
...> |> TaggerImpl.set_labels({:rank, "officer"}, create_missing: true)
...> |> Map.get(:tags)
...> |> Enum.map(& {&1.ns, &1.label})
[{:rank, "officer"}]
"""
@impl Tagger
@spec set_labels(Taggable.t(), Tag.label_or_labels(), keyword) :: Schema.t()
def set_labels(struct, label_or_labels, opts \\ []) do
%{tags: tags} = full_struct = struct |> Dymo.repo().preload(:tags)

default_ns =
opts
|> Keyword.get(:ns)
|> Ns.cast!()
default_ns = opts |> Keyword.get(:ns) |> Ns.cast!()

label_or_labels
# Make the labels a list if not one.
Expand All @@ -90,12 +94,12 @@ defmodule Dymo.TaggerImpl do
## Examples
iex> %Dymo.Post{title: "Hey"}
...> |> Dymo.repo().insert!
...> |> TaggerImpl.set_labels([{:number, "three"}, {:number, "four"}], create_missing: true)
...> |> TaggerImpl.add_labels({:number, "five"}, create_missing: true)
...> |> Map.get(:tags)
...> |> Enum.map(& &1.label)
...> |> Enum.sort()
...> |> Dymo.repo().insert!
...> |> TaggerImpl.set_labels([{:number, "three"}, {:number, "four"}], create_missing: true)
...> |> TaggerImpl.add_labels({:number, "five"}, create_missing: true)
...> |> Map.get(:tags)
...> |> Enum.map(& &1.label)
...> |> Enum.sort()
~w(five four three)
"""
@impl Tagger
Expand Down Expand Up @@ -131,9 +135,9 @@ defmodule Dymo.TaggerImpl do
## Examples
iex> %{tags: tags} = %Dymo.Post{title: "Hey"}
...> |> Dymo.repo().insert!
...> |> TaggerImpl.set_labels([{:number, "six"}, {:number, "seven"}], create_missing: true)
...> |> TaggerImpl.remove_labels({:number, "six"})
...> |> Dymo.repo().insert!
...> |> TaggerImpl.set_labels([{:number, "six"}, {:number, "seven"}], create_missing: true)
...> |> TaggerImpl.remove_labels({:number, "six"})
iex> Enum.map(tags, & &1.label)
["seven"]
"""
Expand Down Expand Up @@ -167,11 +171,11 @@ defmodule Dymo.TaggerImpl do
## Examples
iex> %Dymo.Post{title: "Hey"}
...> |> Dymo.repo().insert!
...> |> TaggerImpl.set_labels([{:number, "eight"}, {:number, "nine"}], create_missing: true)
...> |> Dymo.repo().insert!
...> |> TaggerImpl.set_labels([{:number, "eight"}, {:number, "nine"}], create_missing: true)
iex> "taggings"
...> |> TaggerImpl.all_labels(:post_id, ns: :number)
...> |> Dymo.repo().all()
...> |> TaggerImpl.all_labels(:post_id, ns: :number)
...> |> Dymo.repo().all()
["eight", "nine"]
"""
@impl Tagger
Expand All @@ -196,17 +200,17 @@ defmodule Dymo.TaggerImpl do
## Examples
iex> %{id: id} = %Dymo.Post{title: "Hey"}
...> |> Dymo.repo().insert!
...> |> TaggerImpl.set_labels([{:number, "ten"}, {:number, "eleven"}], create_missing: true)
...> |> Dymo.repo().insert!
...> |> TaggerImpl.set_labels([{:number, "ten"}, {:number, "eleven"}], create_missing: true)
iex> id == Dymo.Post
...> |> TaggerImpl.labeled_with({:number, "ten"}, "taggings", :post_id)
...> |> Dymo.repo().all()
...> |> hd
...> |> Map.get(:id)
...> |> TaggerImpl.labeled_with({:number, "ten"}, "taggings", :post_id)
...> |> Dymo.repo().all()
...> |> hd
...> |> Map.get(:id)
true
iex> Dymo.Post
...> |> TaggerImpl.labeled_with({:unknown, "nothing"}, "taggings", :post_id)
...> |> Dymo.repo().all()
...> |> TaggerImpl.labeled_with({:unknown, "nothing"}, "taggings", :post_id)
...> |> Dymo.repo().all()
[]
"""
@impl Tagger
Expand Down Expand Up @@ -298,17 +302,14 @@ defmodule Dymo.TaggerImpl do
finder_or_creator =
opts
|> Keyword.get(:create_missing, Dymo.create_missing_tags_by_default())
|> if do
&Tag.find_or_create!/1
else
&Tag.find_existing/1
end
|> if(do: &Tag.find_or_create!/1, else: &Tag.find_existing/1)

# Find the specified tags (create them if options allow that).
safe_tags =
tags
|> finder_or_creator.()
|> Enum.filter(&(&1 != nil))
|> Enum.filter(& &1.assignable)

# Update assocs.
struct
Expand All @@ -325,15 +326,15 @@ defmodule Dymo.TaggerImpl do
tags
|> Enum.reduce([], fn {ns, lbls}, acc ->
lbls
|> Enum.map(&Tag.cast({ns, &1}))
|> Enum.map(&Tag.to_struct({ns, &1}))
|> Enum.concat(acc)
end)

defp join_taggings(q, [pk_type], %{id: id}, jt, jk) when pk_type in ~w(id binary_id)a,
do:
q
|> join(:inner, [t], tg in ^jt,
on: t.id == tg.tag_id and field(tg, ^jk) == type(^id, ^pk_type)
on: t.assignable and t.id == tg.tag_id and field(tg, ^jk) == type(^id, ^pk_type)
)

defp join_taggings(_, pk_types, _, _, _),
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Dymo.MixProject do
def project(),
do: [
app: :dymo,
version: "1.0.4",
version: "2.0.0",
elixir: "~> 1.5",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
Expand Down
4 changes: 4 additions & 0 deletions priv/repo/migrations/20180822000000_create_tags.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ defmodule Repo.Migrations.CreateTags do
create table(:tags) do
add :ns, :string, null: false
add :label, :string, null: false
add :assignable, :boolean, null: false, default: true

timestamps()
end

create index(:tags, [:label])
create index(:tags, [:ns])
create index(:tags, [:assignable])

create index(:tags, [:label, :ns], unique: true, name: :tags_unicity)
end
end
44 changes: 31 additions & 13 deletions test/dymo/end_to_end_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ defmodule Dymo.EndToEndTest do
@moduledoc false

use Dymo.DataCase, async: false
alias Dymo.{Taggable, Repo, Post, UUPost}
alias Dymo.{Tag, Taggable, Repo, Post, UUPost}
import Ecto.Query

setup :create_unassignable_tags

describe "performs end-to-end" do
test "set_labels/{2,3} works from scratch" do
[p1, p2, p3] =
Expand Down Expand Up @@ -341,21 +343,37 @@ defmodule Dymo.EndToEndTest do
end
end

## Private.

def create_unassignable_tags(_) do
[
%{label: "nr1", assignable: false},
%{ns: :a, label: "na1", assignable: false},
%{ns: :b, label: "nb1", assignable: false},
%{ns: :b, label: "nb2", assignable: false}
]
|> Enum.each(&(&1 |> Tag.changeset() |> Repo.insert!()))

:ok
end

# Prepares fixtures given a list of lists of tags.
defp prepare(fixtures),
do:
fixtures
|> Enum.map(fn data ->
post =
%Post{}
|> Post.changeset(%{title: "Hey!", body: "Bodybuilder..."})
|> Repo.insert!()
defp prepare(fixtures) do
unassignable = ["nr1", {:a, "na1"}, {:b, "nb1"}, {:b, "nb2"}]

post
|> Taggable.set_labels(data, create_missing: true)
fixtures
|> Enum.map(fn data ->
post =
%Post{}
|> Post.changeset(%{title: "Hey!", body: "Bodybuilder..."})
|> Repo.insert!()

post
end)
post
|> Taggable.set_labels(data ++ unassignable, create_missing: true)

post
end)
end

# Gets all labels for the `Post` model.
defp all_labels(taggable_module, opts \\ []),
Expand Down
4 changes: 2 additions & 2 deletions test/dymo/tag_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@ defmodule Dymo.TagTest do

describe ".cast/1" do
test "can cast into a valid changeset", %{ns: ns, label: label} do
tag = {ns, label} |> Tag.cast()
tag = {ns, label} |> Tag.to_struct()
assert match?(%Tag{ns: ^ns, label: ^label}, tag)
end

test "raises when the cast cannot be performed", %{label: label} do
ret = catch_error({"bad namespace", label} |> Tag.cast())
ret = catch_error({"bad namespace", label} |> Tag.to_struct())
assert match?(%RuntimeError{}, ret)
end
end
Expand Down
Loading

0 comments on commit 2a5fc3d

Please sign in to comment.