zf

zenflows testing
git clone https://s.sonu.ch/~srfsh/zf.git
Log | Files | Refs | Submodules | README | LICENSE

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