Skip to content

Commit 8161478

Browse files
committed
refactor(appengine): use changesets in merge_device_status
rewrite merge_device_status to use changesets to minimize the number of queries performed. refactor some private functions in their own modules. Signed-off-by: Francesco Noacco <francesco.noacco@secomind.com>
1 parent 0e0452e commit 8161478

File tree

4 files changed

+434
-80
lines changed

4 files changed

+434
-80
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#
2+
# This file is part of Astarte.
3+
#
4+
# Copyright 2025 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
18+
defmodule Astarte.AppEngine.API.Device.Aliases do
19+
alias Astarte.DataAccess.Realms.Device
20+
alias Ecto.Changeset
21+
22+
alias Astarte.AppEngine.API.Device.Queries
23+
24+
require Logger
25+
26+
defstruct to_update: [], to_delete: []
27+
28+
@type input :: %{alias_tag => alias_value} | [alias]
29+
@type alias_tag :: String.t()
30+
@type alias_value :: String.t()
31+
@type alias :: {alias_tag, alias_value}
32+
@type t :: %__MODULE__{
33+
to_update: [alias],
34+
to_delete: [alias_tag]
35+
}
36+
37+
@spec validate(input() | nil, String.t(), Device.t()) :: {:ok, t()} | term()
38+
def validate(nil, _, _), do: {:ok, %__MODULE__{to_delete: [], to_update: []}}
39+
40+
def validate(aliases, realm_name, device) do
41+
with :ok <- validate_format(aliases) do
42+
{to_delete, to_update} = aliases |> Enum.split_with(fn {_key, value} -> is_nil(value) end)
43+
to_delete = to_delete |> Enum.map(fn {tag, nil} -> tag end)
44+
state = %__MODULE__{to_delete: to_delete, to_update: to_update}
45+
46+
with :ok <- validate_device_ownership(state, realm_name, device) do
47+
{:ok, state}
48+
end
49+
end
50+
end
51+
52+
@spec apply(Changeset.t(), t()) :: Changeset.t()
53+
def apply(changeset, aliases) do
54+
%__MODULE__{to_delete: to_delete, to_update: to_update} = aliases
55+
56+
changeset
57+
|> apply_delete(to_delete)
58+
|> apply_update(to_update)
59+
end
60+
61+
@spec validate_format(input()) :: :ok | {:error, :invalid_alias}
62+
defp validate_format(aliases) do
63+
Enum.find_value(aliases, :ok, fn
64+
{_tag, ""} ->
65+
:invalid_value
66+
67+
{"", _value} ->
68+
:invalid_tag
69+
70+
_valid_format_tag ->
71+
false
72+
end)
73+
|> case do
74+
:ok ->
75+
:ok
76+
77+
:invalid_tag ->
78+
Logger.warning("Alias key cannot be an empty string.", tag: :invalid_alias_empty_key)
79+
{:error, :invalid_alias}
80+
81+
:invalid_value ->
82+
Logger.warning("Alias value cannot be an empty string.", tag: :invalid_alias_empty_value)
83+
{:error, :invalid_alias}
84+
end
85+
end
86+
87+
@spec validate_device_ownership(t(), String.t(), Device.t()) :: :ok
88+
defp validate_device_ownership(aliases, realm_name, device) do
89+
%__MODULE__{to_delete: to_delete, to_update: to_update} = aliases
90+
91+
to_delete = device.aliases |> Map.take(to_delete) |> Enum.map(fn {_tag, value} -> value end)
92+
to_update = to_update |> Enum.map(fn {_tag, value} -> value end)
93+
94+
all_aliases = to_delete ++ to_update
95+
96+
invalid_name =
97+
Queries.find_all_aliases(realm_name, all_aliases)
98+
|> Enum.find(fn name -> name.object_uuid != device.device_id end)
99+
100+
if is_nil(invalid_name) do
101+
:ok
102+
else
103+
existing_aliases =
104+
Enum.find(device.aliases, fn {_tag, value} -> value == invalid_name.object_name end)
105+
106+
inconsistent? = !is_nil(existing_aliases)
107+
108+
if inconsistent? do
109+
{invalid_tag, _value} = existing_aliases
110+
111+
Logger.error("Inconsistent alias for #{invalid_tag}.",
112+
device_id: device.device_id,
113+
tag: "inconsistent_alias"
114+
)
115+
116+
{:error, :database_error}
117+
else
118+
{:error, :alias_already_in_use}
119+
end
120+
end
121+
end
122+
123+
@spec apply_delete(Changeset.t(), [alias]) :: Changeset.t()
124+
defp apply_delete(%Changeset{valid?: false} = changeset, _delete_aliases),
125+
do: changeset
126+
127+
defp apply_delete(changeset, delete_aliases) when length(delete_aliases) == 0,
128+
do: changeset
129+
130+
defp apply_delete(changeset, delete_aliases) do
131+
aliases = changeset |> Changeset.fetch_field!(:aliases)
132+
133+
delete_tags = delete_aliases |> MapSet.new()
134+
135+
device_aliases = aliases |> Map.keys() |> MapSet.new()
136+
137+
if MapSet.subset?(delete_tags, device_aliases) do
138+
aliases = aliases |> Map.drop(delete_aliases)
139+
140+
changeset
141+
|> Changeset.put_change(:aliases, aliases)
142+
else
143+
Changeset.add_error(changeset, :aliases, "", reason: :alias_tag_not_found)
144+
end
145+
end
146+
147+
@spec apply_update(Changeset.t(), [alias]) :: Changeset.t()
148+
defp apply_update(%Changeset{valid?: false} = changeset, _update_aliases),
149+
do: changeset
150+
151+
defp apply_update(changeset, update_aliases) when length(update_aliases) == 0,
152+
do: changeset
153+
154+
defp apply_update(changeset, update_aliases) do
155+
aliases =
156+
changeset |> Changeset.fetch_field!(:aliases)
157+
158+
aliases = Map.merge(aliases, Map.new(update_aliases))
159+
160+
Changeset.put_change(changeset, :aliases, aliases)
161+
end
162+
end
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#
2+
# This file is part of Astarte.
3+
#
4+
# Copyright 2025 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
18+
defmodule Astarte.AppEngine.API.Device.Attributes do
19+
alias Ecto.Changeset
20+
21+
require Logger
22+
23+
defstruct to_update: [], to_delete: []
24+
25+
@type input :: %{attribute_tag => attribute_value} | [attribute]
26+
@type attribute_tag :: String.t()
27+
@type attribute_value :: String.t()
28+
@type attribute :: {attribute_tag, attribute_value}
29+
@type t ::
30+
%__MODULE__{
31+
to_update: [attribute],
32+
to_delete: [attribute_tag]
33+
}
34+
35+
@spec validate(input() | nil) :: {:ok, t()} | term()
36+
def validate(attributes) do
37+
attributes =
38+
case attributes do
39+
nil -> []
40+
attributes -> attributes
41+
end
42+
43+
with :ok <- validate_format(attributes) do
44+
{to_delete, to_update} =
45+
attributes
46+
|> Enum.split_with(fn {_key, value} -> is_nil(value) end)
47+
48+
to_delete = to_delete |> Enum.map(fn {key, nil} -> key end)
49+
50+
{:ok, %__MODULE__{to_delete: to_delete, to_update: to_update}}
51+
end
52+
end
53+
54+
@spec apply(Changeset.t(), t()) :: Changeset.t()
55+
def apply(changeset, attributes) do
56+
%__MODULE__{to_delete: to_delete, to_update: to_update} = attributes
57+
58+
changeset
59+
|> apply_delete(to_delete)
60+
|> apply_update(to_update)
61+
end
62+
63+
@spec validate_format(input()) :: :ok | {:error, :invalid_attributes}
64+
defp validate_format(attributes) do
65+
invalid_attribute? =
66+
Enum.any?(attributes, fn {attribute_key, _value} -> attribute_key == "" end)
67+
68+
if invalid_attribute? do
69+
Logger.warning("Attribute key cannot be an empty string.",
70+
tag: :invalid_attribute_empty_key
71+
)
72+
73+
{:error, :invalid_attributes}
74+
else
75+
:ok
76+
end
77+
end
78+
79+
@spec apply_delete(Changeset.t(), [attribute_tag]) :: Changeset.t()
80+
defp apply_delete(%Changeset{valid?: false} = changeset, _delete_attributes), do: changeset
81+
82+
defp apply_delete(changeset, delete_attributes) when length(delete_attributes) == 0,
83+
do: changeset
84+
85+
defp apply_delete(changeset, delete_attributes) do
86+
attributes = changeset |> Changeset.fetch_field!(:attributes)
87+
88+
attributes_to_delete = delete_attributes |> MapSet.new()
89+
90+
device_attributes = attributes |> Map.keys() |> MapSet.new()
91+
92+
if MapSet.subset?(attributes_to_delete, device_attributes) do
93+
attributes = attributes |> Map.drop(delete_attributes)
94+
95+
changeset
96+
|> Changeset.put_change(:attributes, attributes)
97+
else
98+
Changeset.add_error(changeset, :attributes, "", reason: :attribute_key_not_found)
99+
end
100+
end
101+
102+
@spec apply_update(Changeset.t(), [attribute]) :: Changeset.t()
103+
defp apply_update(%Changeset{valid?: false} = changeset, _update_attributes), do: changeset
104+
105+
defp apply_update(changeset, update_attributes) when length(update_attributes) == 0,
106+
do: changeset
107+
108+
defp apply_update(changeset, update_attributes) do
109+
attributes =
110+
changeset |> Changeset.fetch_field!(:attributes)
111+
112+
attributes = Map.merge(attributes, Map.new(update_attributes))
113+
114+
Changeset.put_change(changeset, :attributes, attributes)
115+
end
116+
end

0 commit comments

Comments
 (0)