validate.ex (10607B)
1 # Zenflows is designed to implement the Valueflows vocabulary, 2 # written and maintained by srfsh <info@dyne.org>. 3 # Copyright (C) 2021-2023 Dyne.org foundation <foundation@dyne.org>. 4 # 5 # This program is free software: you can redistribute it and/or modify 6 # it under the terms of the GNU Affero General Public License as published by 7 # the Free Software Foundation, either version 3 of the License, or 8 # (at your option) any later version. 9 # 10 # This program is distributed in the hope that it will be useful, 11 # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 # GNU Affero General Public License for more details. 14 # 15 # You should have received a copy of the GNU Affero General Public License 16 # along with this program. If not, see <https://www.gnu.org/licenses/>. 17 18 defmodule Zenflows.DB.Validate do 19 @moduledoc "Ecto.Changeset validation helpers." 20 21 alias Ecto.Changeset 22 23 require Logger 24 25 @typedoc """ 26 Used to specify whether to fetch the field's value from the `:changes`, 27 `:data`, or `:both` of an `Ecto.Changeset`, respectively. 28 29 Basically, uses `Ecto.Changeset.fetch_field/2` behind the scenes, 30 but `nil` values will mean empty/not-provided. 31 """ 32 @type fetch_method() :: :change | :data | :both 33 34 @doc """ 35 Escape possible characters that could represent expressions in SQL's 36 `LIKE` keyword of a field. The value is changed if it is present. 37 """ 38 @spec escape_like(Changeset.t(), atom()) :: Changeset.t() 39 def escape_like(cset, field) do 40 case Changeset.fetch_change(cset, field) do 41 {:ok, v} -> 42 v = Regex.replace(~r/\\|%|_/, v, &"\\#{&1}") 43 Changeset.put_change(cset, field, v) 44 :error -> cset 45 end 46 end 47 48 @doc """ 49 Use the OR logic on the existance of `fields`, and error if the 50 result is false. 51 52 Note: `fields` must contain at least 2 items, and make sure to read 53 `t:fetch_method()`'s doc. 54 55 Here's a truth table as a quick reference: 56 57 | p | q | = | 58 |---|---|---| 59 | 0 | 0 | 0 | 60 | 1 | 0 | 1 | 61 | 0 | 1 | 1 | 62 | 1 | 1 | 1 | 63 """ 64 @spec exist_or(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t() 65 def exist_or(cset, fields, opts \\ []) do 66 meth = Keyword.get(opts, :method, :change) 67 if Enum.any?(fields, &field_exists?(meth, cset, &1)) do 68 cset 69 else 70 Enum.reduce(fields, cset, 71 &Changeset.add_error(&2, &1, "at least one of them must be provided")) 72 end 73 end 74 75 @doc """ 76 Use the XOR logic on the existance of `fields`, and error if the 77 result is false. 78 79 Note: `fields` must contain at least 2 items, and make sure to read 80 `t:fetch_method()`'s doc. 81 82 Here's a truth table as a quick reference: 83 84 | p | q | = | 85 |---|---|---| 86 | 0 | 0 | 0 | 87 | 1 | 0 | 1 | 88 | 0 | 1 | 1 | 89 | 1 | 1 | 0 | 90 """ 91 @spec exist_xor(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t() 92 def exist_xor(cset, [h | t] = fields, opts \\ []) do 93 meth = Keyword.get(opts, :method, :change) 94 h_exists? = field_exists?(meth, cset, h) 95 96 if Enum.all?(t, &(h_exists? != field_exists?(meth, cset, &1))) do 97 cset 98 else 99 Enum.reduce(fields, cset, 100 &Changeset.add_error(&2, &1, "exactly one of them must be provided")) 101 end 102 end 103 104 @doc """ 105 Use the NAND logic on the existance of `fields`, and error if the 106 result is false. 107 108 Note: `fields` must contain at least 2 items, and make sure to 109 read `t:fetch_method()`'s doc. 110 111 Here's a truth table as a quick reference: 112 113 | p | q | = | 114 |---|---|---| 115 | 0 | 0 | 1 | 116 | 1 | 0 | 1 | 117 | 0 | 1 | 1 | 118 | 1 | 1 | 0 | 119 """ 120 @spec exist_nand(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t() 121 def exist_nand(cset, fields, opts \\ []) do 122 meth = Keyword.get(opts, :method, :change) 123 if Enum.all?(fields, &field_exists?(meth, cset, &1)) do 124 Enum.reduce(fields, cset, 125 &Changeset.add_error(&2, &1, "one or none of them must be provided")) 126 else 127 cset 128 end 129 end 130 131 @doc """ 132 Compare the values of `fields`, and error if they aren't equal to 133 each other. 134 135 Note: `fields` must contain at least 2 items, and make sure to read 136 `t:fetch_method()`'s doc. Also, empty fields will be skipped to 137 allow more flexibility. Use `Ecto.Changeset.validate_required/3` 138 to achieve otherwise. 139 140 Here's a truth table as a quick reference: 141 142 | p | q | = | 143 |---|---|---| 144 | 0 | 0 | 1 | 145 | 1 | 0 | 0 | 146 | 0 | 1 | 0 | 147 | 1 | 1 | 1 | 148 """ 149 @spec value_eq(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t() 150 def value_eq(cset, [a, b], opts \\ []) do 151 meth = Keyword.get(opts, :method, :change) 152 with {:ok, x} <- field_fetch(meth, cset, a), 153 {:ok, y} <- field_fetch(meth, cset, b) do 154 if x == y do 155 cset 156 else 157 msg = "all of them must be the same" 158 cset 159 |> Changeset.add_error(a, msg) 160 |> Changeset.add_error(b, msg) 161 end 162 else _ -> 163 cset 164 end 165 end 166 167 @doc """ 168 Compare the values of `fields`, and error if they aren't all 169 different. 170 171 Note: `fields` must contain at least 2 items, and make sure to read 172 `t:fetch_method()`'s doc. Also, empty fields will be skipped to 173 allow more flexibility. Use `Ecto.Changeset.validate_required/3` 174 to achieve otherwise. 175 176 Here's a truth table as a quick reference: 177 178 | p | q | = | 179 |---|---|---| 180 | 0 | 0 | 1 | 181 | 1 | 0 | 1 | 182 | 0 | 1 | 1 | 183 | 1 | 1 | 0 | 184 """ 185 @spec value_ne(Changeset.t(), [atom(), ...], Keyword.t()) :: Changeset.t() 186 def value_ne(cset, [a, b], opts \\ []) do 187 meth = Keyword.get(opts, :method, :change) 188 with {:ok, x} <- field_fetch(meth, cset, a), 189 {:ok, y} <- field_fetch(meth, cset, b) do 190 if x != y do 191 cset 192 else 193 msg = "all of them must be different" 194 cset 195 |> Changeset.add_error(a, msg) 196 |> Changeset.add_error(b, msg) 197 end 198 else _ -> 199 cset 200 end 201 end 202 203 @doc "Validate that given `field` is a valid email address." 204 @spec email(Changeset.t(), atom()) :: Changeset.t() 205 def email(cset, field) do 206 # works good enough for now 207 Changeset.validate_format(cset, field, ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/) 208 end 209 210 @doc """ 211 Validate that given `field` is [1, 256] bytes long. 212 213 The name "name" is a reference to short texts, as in email subject, 214 usernames, full names, and so on. 215 """ 216 @spec name(Changeset.t(), atom()) :: Changeset.t() 217 def name(cset, field), do: byte_range(cset, field, 1, 256) 218 219 @doc """ 220 Validate that given `field` is [1, 2048] bytes long. 221 222 The name "note" is a reference to long texts, as in email bodies, 223 descriptions, notes, and so on. 224 """ 225 @spec note(Changeset.t(), atom()) :: Changeset.t() 226 def note(cset, field), do: byte_range(cset, field, 1, 2048) 227 228 @doc """ 229 Validate that given `field` is [16, 2048] bytes long. 230 231 The name "key" is a reference to encoded cryptographic keys (so the 232 size is not the size of the binary key). 233 """ 234 @spec key(Changeset.t(), atom()) :: Changeset.t() 235 def key(cset, field), do: byte_range(cset, field, 16, 2048) 236 237 @doc """ 238 Validate that given `field` is [1, 512] bytes long. 239 240 The name "uri" is a reference to not-literal URIs, but a size used 241 for mostly URIs. 242 """ 243 @spec uri(Changeset.t(), atom()) :: Changeset.t() 244 def uri(cset, field), do: byte_range(cset, field, 1, 512) 245 246 @doc """ 247 Validate that given `field` is [1B, 25MiB] long, and log a warning 248 if it is longer than 4MiB. 249 250 The name "img" is a reference to Base64-encoded image binary data. 251 """ 252 @spec img(Changeset.t(), atom()) :: Changeset.t() 253 def img(cset, field) do 254 Changeset.validate_change(cset, field, __MODULE__, fn 255 _, str when byte_size(str) < 1 -> 256 [{field, "should be at least 1B long"}] 257 _, str when byte_size(str) > 25 * 1024 * 1024 -> 258 [{field, "should be at most 25MiB long"}] 259 _, str when byte_size(str) > 4 * 1024 * 1024 -> 260 Logger.warning("file exceeds 4MiB") 261 [] 262 _, _ -> 263 [] 264 end) 265 end 266 267 @doc """ 268 Validate that given `field` is a list of binaries, which are [1, 269 512] bytes long in size, and the list itself contains [1, 128] 270 items. 271 272 The name "class" is a reference to list of strings used for tagging, 273 categorization and so on. 274 """ 275 @spec class(Changeset.t(), atom()) :: Changeset.t() 276 def class(cset, field) do 277 Changeset.validate_change(cset, field, __MODULE__, fn 278 _, [] -> 279 [{field, "must contain at least 1 item"}] 280 _, list -> 281 case do_class(list, 0, 128) do 282 {:exceeds, _ind} -> 283 [{field, "must contain at most 128 items"}] 284 {:short, ind} -> 285 [{field, "the item at #{ind + 1} cannot be shorter than 1 byte"}] 286 {:long, ind} -> 287 [{field, "the item at #{ind + 1} cannot be longer than 512 bytes"}] 288 {:valid, _ind} -> 289 [] 290 end 291 end) 292 end 293 294 # The rationale of this function is to loop over the list while 295 # decreasing `rem` (reminder) and increasing `ind` (index) until 296 # either one of these happen (in that order): 297 # 298 # 1. `rem` is equal to 0 299 # 2. one of the items in the list is shorter than 1 byte long 300 # 3. one of the items in the list is longer than 512 bytes long 301 # 302 @spec do_class([String.t()], non_neg_integer(), non_neg_integer()) 303 :: {:exceeds | :short | :long | :valid, non_neg_integer()} 304 defp do_class([], ind, _), do: {:valid, ind - 1} 305 defp do_class([h | t], ind, rem) do 306 cond do 307 rem == 0 -> {:exceeds, ind} 308 byte_size(h) < 1 -> {:short, ind} 309 byte_size(h) > 512 -> {:long, ind} 310 true -> do_class(t, ind + 1, rem - 1) 311 end 312 end 313 314 @doc """ 315 Validate that the given binary (in Elixir terms) is in this inclusive 316 octects/bytes range. 317 """ 318 @spec byte_range(Changeset.t(), atom(), non_neg_integer(), non_neg_integer()) 319 :: Changeset.t() 320 def byte_range(cset, field, min, max) do 321 Changeset.validate_change(cset, field, __MODULE__, fn 322 _, bin when byte_size(bin) < min -> 323 [{field, "should be at least #{min} byte(s) long"}] 324 _, bin when byte_size(bin) > max -> 325 [{field, "should be at most #{max} byte(s) long"}] 326 _, _ -> 327 [] 328 end) 329 end 330 331 @spec field_exists?(fetch_method(), Changeset.t(), atom()) :: boolean() 332 defp field_exists?(:change, cset, field) do 333 case Changeset.fetch_field(cset, field) do 334 {:changes, x} when not is_nil(x) -> true 335 _ -> false 336 end 337 end 338 defp field_exists?(:data, cset, field) do 339 case Changeset.fetch_field(cset, field) do 340 {:data, x} when not is_nil(x) -> true 341 _ -> false 342 end 343 end 344 defp field_exists?(:both, cset, field) do 345 case Changeset.fetch_field(cset, field) do 346 {:changes, x} when not is_nil(x) -> true 347 {:data, x} when not is_nil(x) -> true 348 _ -> false 349 end 350 end 351 352 @spec field_fetch(fetch_method(), Changeset.t(), atom()) :: {:ok, term()} | :error 353 defp field_fetch(:change, cset, field) do 354 case Changeset.fetch_field(cset, field) do 355 {:changes, v} -> {:ok, v} 356 _ -> :error 357 end 358 end 359 defp field_fetch(:data, cset, field) do 360 case Changeset.fetch_field(cset, field) do 361 {:data, v} -> {:ok, v} 362 _ -> :error 363 end 364 end 365 defp field_fetch(:both, cset, field) do 366 case Changeset.fetch_field(cset, field) do 367 :error -> :error 368 {_, v} -> {:ok, v} 369 end 370 end 371 end