Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions lib/fun_with_flags/flag.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,29 @@ defmodule FunWithFlags.Flag do

alias FunWithFlags.Gate

defstruct [name: nil, gates: []]
@type t :: %FunWithFlags.Flag{name: atom, gates: [FunWithFlags.Gate.t]}
@typep options :: Keyword.t
defstruct name: nil, gates: [], last_modified_at: nil

@type t :: %FunWithFlags.Flag{
name: atom,
gates: [FunWithFlags.Gate.t()],
last_modified_at: DateTime.t() | nil
}
@typep options :: Keyword.t()

@doc false
def new(name, gates \\ []) when is_atom(name) do
%__MODULE__{name: name, gates: gates}
last_modified_at =
gates
|> Enum.map(& &1.updated_at)
|> Enum.reject(&is_nil/1)
|> case do
[] -> nil
dates -> Enum.max(dates)
end

%__MODULE__{name: name, gates: gates, last_modified_at: last_modified_at}
end


@doc false
@spec enabled?(t, options) :: boolean
def enabled?(flag, options \\ [])
Expand Down
13 changes: 10 additions & 3 deletions lib/fun_with_flags/gate.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@ defmodule FunWithFlags.Gate do
defexception [:message]
end

defstruct [:type, :for, :enabled, inserted_at: nil, updated_at: nil]

defstruct [:type, :for, :enabled]
@type t :: %FunWithFlags.Gate{type: atom, for: (nil | String.t), enabled: boolean}
@typep options :: Keyword.t
@type t :: %FunWithFlags.Gate{
type: atom,
for: nil | String.t(),
enabled: boolean,
inserted_at: DateTime.t() | nil,
updated_at: DateTime.t() | nil
}

@typep options :: Keyword.t()

@doc false
@spec new(atom, boolean | float) :: t
Expand Down
1 change: 1 addition & 0 deletions lib/fun_with_flags/store/persistent/ecto/record.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ defmodule FunWithFlags.Store.Persistent.Ecto.Record do
field :gate_type, :string
field :target, :string
field :enabled, :boolean
timestamps(type: :utc_datetime)
end

@fields [:flag_name, :gate_type, :target, :enabled]
Expand Down
20 changes: 10 additions & 10 deletions lib/fun_with_flags/store/serializer/ecto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,24 @@ defmodule FunWithFlags.Store.Serializer.Ecto do
def deserialize_gate(_flag_name, _record), do: nil


defp do_deserialize_gate(%Record{gate_type: "boolean", enabled: enabled}) do
%Gate{type: :boolean, for: nil, enabled: enabled}
defp do_deserialize_gate(%Record{gate_type: "boolean", enabled: enabled} = record) do
%Gate{type: :boolean, for: nil, enabled: enabled, inserted_at: record.inserted_at, updated_at: record.updated_at}
end

defp do_deserialize_gate(%Record{gate_type: "actor", enabled: enabled, target: target}) do
%Gate{type: :actor, for: target, enabled: enabled}
defp do_deserialize_gate(%Record{gate_type: "actor", enabled: enabled, target: target} = record) do
%Gate{type: :actor, for: target, enabled: enabled, inserted_at: record.inserted_at, updated_at: record.updated_at}
end

defp do_deserialize_gate(%Record{gate_type: "group", enabled: enabled, target: target}) do
%Gate{type: :group, for: target, enabled: enabled}
defp do_deserialize_gate(%Record{gate_type: "group", enabled: enabled, target: target} = record) do
%Gate{type: :group, for: target, enabled: enabled, inserted_at: record.inserted_at, updated_at: record.updated_at}
end

defp do_deserialize_gate(%Record{gate_type: "percentage", target: "time/" <> ratio_s}) do
%Gate{type: :percentage_of_time, for: parse_float(ratio_s), enabled: true}
defp do_deserialize_gate(%Record{gate_type: "percentage", target: "time/" <> ratio_s} = record) do
%Gate{type: :percentage_of_time, for: parse_float(ratio_s), enabled: true, inserted_at: record.inserted_at, updated_at: record.updated_at}
end

defp do_deserialize_gate(%Record{gate_type: "percentage", target: "actors/" <> ratio_s}) do
%Gate{type: :percentage_of_actors, for: parse_float(ratio_s), enabled: true}
defp do_deserialize_gate(%Record{gate_type: "percentage", target: "actors/" <> ratio_s} = record) do
%Gate{type: :percentage_of_actors, for: parse_float(ratio_s), enabled: true, inserted_at: record.inserted_at, updated_at: record.updated_at}
end

def to_atom(atm) when is_atom(atm), do: atm
Expand Down
14 changes: 14 additions & 0 deletions priv/ecto_repo/migrations/00000000000001_add_timestamps.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule FunWithFlags.Dev.EctoRepo.Migrations.CreateFeatureFlagsTable do
use Ecto.Migration

# This migration assumes the default table name of "fun_with_flags_toggles"
# is being used. If you have overridden that via configuration, you should
# change this migration accordingly.

def change do
alter table(:fun_with_flags_toggles) do
add :inserted_at, :utc_datetime, null: false, default: fragment("CURRENT_TIMESTAMP")
add :updated_at, :utc_datetime, null: false, default: fragment("CURRENT_TIMESTAMP")
end
end
end
28 changes: 21 additions & 7 deletions test/fun_with_flags/notifications/phoenix_pubsub_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -169,17 +169,21 @@ defmodule FunWithFlags.Notifications.PhoenixPubSubTest do
setup do
name = unique_atom()
gate = %Gate{type: :boolean, enabled: true}
stored_flag = %Flag{name: name, gates: [gate]}
expected_stored_flag = %Flag{name: name, gates: [gate]}

gate2 = %Gate{type: :boolean, enabled: false}
cached_flag = %Flag{name: name, gates: [gate2]}

{:ok, ^stored_flag} = Config.persistence_adapter.put(name, gate)
{:ok, stored_flag} = Config.persistence_adapter.put(name, gate)
assert drop_timestamps(stored_flag) == expected_stored_flag

assert_with_retries(fn ->
{:ok, ^cached_flag} = Cache.put(cached_flag)
{:ok, put} = Cache.put(cached_flag)
assert drop_timestamps(put) == drop_timestamps(cached_flag)
end)

assert {:ok, ^stored_flag} = Config.persistence_adapter.get(name)
{:ok, persisted} = Config.persistence_adapter.get(name)
assert drop_timestamps(persisted) == expected_stored_flag
assert {:ok, ^cached_flag} = Cache.get(name)

wait_until_pubsub_is_ready!()
Expand All @@ -190,7 +194,7 @@ defmodule FunWithFlags.Notifications.PhoenixPubSubTest do
# This should be in `setup` but in there it produces a compiler warning because
# the two variables will never match (duh).
test "verify test setup", %{cached_flag: cached_flag, stored_flag: stored_flag} do
refute match? ^stored_flag, cached_flag
refute match? ^stored_flag, drop_timestamps(cached_flag)
end


Expand All @@ -203,7 +207,8 @@ defmodule FunWithFlags.Notifications.PhoenixPubSubTest do
Phoenix.PubSub.broadcast!(client, channel, message)

assert_with_retries(fn ->
assert {:ok, ^cached_flag} = Cache.get(name)
assert {:ok, put} = Cache.get(name)
assert drop_timestamps(put) == drop_timestamps(cached_flag)
end)
end

Expand All @@ -220,8 +225,17 @@ defmodule FunWithFlags.Notifications.PhoenixPubSubTest do
Phoenix.PubSub.broadcast!(client, channel, message)

assert_with_retries(fn ->
assert {:ok, ^stored_flag} = Cache.get(name)
assert {:ok, result} = Cache.get(name)
assert drop_timestamps(result) == drop_timestamps(stored_flag)
end)
end
end

defp drop_timestamps(%FunWithFlags.Gate{} = gate) do
%{gate | inserted_at: nil, updated_at: nil}
end

defp drop_timestamps(%FunWithFlags.Flag{} = flag) do
%{flag | last_modified_at: nil, gates: Enum.map(flag.gates, &drop_timestamps/1)}
end
end
76 changes: 59 additions & 17 deletions test/fun_with_flags/simple_store_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,22 @@ defmodule FunWithFlags.SimpleStoreTest do
assert {:ok, %Flag{name: ^name, gates: []}} = SimpleStore.lookup(name)

SimpleStore.put(name, gate)
assert {:ok, %Flag{name: ^name, gates: [^gate]}} = SimpleStore.lookup(name)
{:ok, result} = SimpleStore.lookup(name)
assert %Flag{name: ^name} = result
assert [persisted_gate] = result.gates
assert drop_timestamps(persisted_gate) == gate

gate2 = %Gate{gate | enabled: false}
SimpleStore.put(name, gate2)
assert {:ok, %Flag{name: ^name, gates: [^gate2]}} = SimpleStore.lookup(name)
refute match? {:ok, %Flag{name: ^name, gates: [^gate]}}, SimpleStore.lookup(name)
{:ok, result2} = SimpleStore.lookup(name)
assert %Flag{name: ^name} = result2
assert [persisted_gate2] = result2.gates
assert drop_timestamps(persisted_gate2) == gate2
end

test "put() returns the tuple {:ok, %Flag{}}", %{name: name, gate: gate, flag: flag} do
assert {:ok, ^flag} = SimpleStore.put(name, gate)
{:ok, result} = SimpleStore.put(name, gate)
assert drop_timestamps(result) == flag
end

@tag :telemetry
Expand Down Expand Up @@ -91,27 +97,45 @@ defmodule FunWithFlags.SimpleStoreTest do
SimpleStore.put(name, bool_gate)
SimpleStore.put(name, group_gate)
{:ok, flag} = SimpleStore.lookup(name)
assert %Flag{name: ^name, gates: [^bool_gate, ^group_gate]} = flag
assert %Flag{name: ^name} = flag
assert [persisted_bool, persisted_group] = flag.gates
assert drop_timestamps(persisted_bool) == bool_gate
assert drop_timestamps(persisted_group) == group_gate

{:ok, name: name, bool_gate: bool_gate, group_gate: group_gate}
{:ok, name: name, bool_gate: persisted_bool, group_gate: persisted_group}
end

test "delete(flag_name, gate) can change the value of a flag", %{name: name, bool_gate: bool_gate, group_gate: group_gate} do
assert {:ok, %Flag{name: ^name, gates: [^bool_gate, ^group_gate]}} = SimpleStore.lookup(name)
{:ok, flag} = SimpleStore.lookup(name)
assert %Flag{name: ^name} = flag
assert length(flag.gates) == 2

SimpleStore.delete(name, bool_gate)
assert {:ok, %Flag{name: ^name, gates: [^group_gate]}} = SimpleStore.lookup(name)
{:ok, flag2} = SimpleStore.lookup(name)
assert %Flag{name: ^name} = flag2
assert [remaining_gate] = flag2.gates
assert drop_timestamps(remaining_gate) == drop_timestamps(group_gate)

SimpleStore.delete(name, group_gate)
assert {:ok, %Flag{name: ^name, gates: []}} = SimpleStore.lookup(name)
end

test "delete(flag_name, gate) returns the tuple {:ok, %Flag{}}", %{name: name, bool_gate: bool_gate, group_gate: group_gate} do
assert {:ok, %Flag{name: ^name, gates: [^group_gate]}} = SimpleStore.delete(name, bool_gate)
{:ok, result} = SimpleStore.delete(name, bool_gate)
assert %Flag{name: ^name} = result
assert [remaining_gate] = result.gates
assert drop_timestamps(remaining_gate) == drop_timestamps(group_gate)
end

test "deleting is safe and idempotent", %{name: name, bool_gate: bool_gate, group_gate: group_gate} do
assert {:ok, %Flag{name: ^name, gates: [^group_gate]}} = SimpleStore.delete(name, bool_gate)
assert {:ok, %Flag{name: ^name, gates: [^group_gate]}} = SimpleStore.delete(name, bool_gate)
{:ok, result1} = SimpleStore.delete(name, bool_gate)
assert [g1] = result1.gates
assert drop_timestamps(g1) == drop_timestamps(group_gate)

{:ok, result2} = SimpleStore.delete(name, bool_gate)
assert [g2] = result2.gates
assert drop_timestamps(g2) == drop_timestamps(group_gate)

assert {:ok, %Flag{name: ^name, gates: []}} = SimpleStore.delete(name, group_gate)
assert {:ok, %Flag{name: ^name, gates: []}} = SimpleStore.delete(name, group_gate)
end
Expand Down Expand Up @@ -169,13 +193,18 @@ defmodule FunWithFlags.SimpleStoreTest do
SimpleStore.put(name, bool_gate)
SimpleStore.put(name, group_gate)
{:ok, flag} = SimpleStore.lookup(name)
assert %Flag{name: ^name, gates: [^bool_gate, ^group_gate]} = flag
assert %Flag{name: ^name} = flag
assert [persisted_bool, persisted_group] = flag.gates
assert drop_timestamps(persisted_bool) == bool_gate
assert drop_timestamps(persisted_group) == group_gate

{:ok, name: name, bool_gate: bool_gate, group_gate: group_gate}
{:ok, name: name, bool_gate: persisted_bool, group_gate: persisted_group}
end

test "delete(flag_name) will reset all the flag gates", %{name: name, bool_gate: bool_gate, group_gate: group_gate} do
assert {:ok, %Flag{name: ^name, gates: [^bool_gate, ^group_gate]}} = SimpleStore.lookup(name)
test "delete(flag_name) will reset all the flag gates", %{name: name} do
{:ok, flag} = SimpleStore.lookup(name)
assert %Flag{name: ^name} = flag
assert length(flag.gates) == 2

SimpleStore.delete(name)
assert {:ok, %Flag{name: ^name, gates: []}} = SimpleStore.lookup(name)
Expand Down Expand Up @@ -246,7 +275,10 @@ defmodule FunWithFlags.SimpleStoreTest do

assert {:ok, %Flag{name: ^name, gates: []}} = SimpleStore.lookup(name)
SimpleStore.put(name, gate)
assert {:ok, %Flag{name: ^name, gates: [^gate]}} = SimpleStore.lookup(name)
{:ok, result} = SimpleStore.lookup(name)
assert %Flag{name: ^name} = result
assert [persisted_gate] = result.gates
assert drop_timestamps(persisted_gate) == gate
end

@tag :telemetry
Expand Down Expand Up @@ -327,12 +359,14 @@ defmodule FunWithFlags.SimpleStoreTest do
{:ok, result} = SimpleStore.all_flags()
assert 3 = length(result)

result_without_timestamps = Enum.map(result, &drop_timestamps/1)

for flag <- [
%Flag{name: name1, gates: [g_1a, g_1b, g_1c]},
%Flag{name: name2, gates: [g_2a, g_2b]},
%Flag{name: name3, gates: [g_3a]}
] do
assert flag in result
assert flag in result_without_timestamps
end
end

Expand Down Expand Up @@ -531,4 +565,12 @@ defmodule FunWithFlags.SimpleStoreTest do
end
end
end

defp drop_timestamps(%Gate{} = gate) do
%{gate | inserted_at: nil, updated_at: nil}
end

defp drop_timestamps(%Flag{} = flag) do
%{flag | last_modified_at: nil, gates: Enum.map(flag.gates, &drop_timestamps/1)}
end
end
Loading