Current section
Files
Jump to
Current section
Files
lib/data_layer/cast.ex
# SPDX-FileCopyrightText: 2025 ash_neo4j contributors <https://github.com/diffo-dev/ash_neo4j/graphs.contributors>
#
# SPDX-License-Identifier: MIT
defmodule AshNeo4j.DataLayer.Cast do
@moduledoc "Casting for AshNeo4j.DataLayer"
require Logger
alias AshNeo4j.BoltyHelper
@struct_name_regex Regex.compile!("%(.*?){")
@struct_properties_regex Regex.compile!("{(.*?)}$")
@doc """
Casts an Ash.Resource.Attribute
"""
def cast(resource, name, value) do
attribute = Ash.Resource.Info.attribute(resource, name)
if value == nil do
nil
else
if attribute == nil do
Logger.warning(
"AshNeo4j.Cast: cannot cast as name #{name} is not an attribute of resource #{inspect(resource)}"
)
value
else
case attribute.type do
Ash.Type.Atom ->
cast_atom(value)
Ash.Type.String ->
cast_string(value)
Ash.Type.UUID ->
cast_string(value)
Ash.Type.Boolean ->
value
Ash.Type.Integer ->
value
Ash.Type.Float ->
value
Ash.Type.Binary ->
value
Ash.Type.CiString ->
value
Ash.Type.Function ->
cast_function(value)
Ash.Type.Module ->
cast_atom(value)
Ash.Type.Date ->
cast_date(value)
Ash.Type.DateTime ->
cast_datetime(value, [])
Ash.Type.UtcDatetime ->
cast_datetime(value, precision: :microsecond)
Ash.Type.Duration ->
cast_duration(value)
Ash.Type.UtcDatetimeUsec ->
cast_datetime(value, precision: :microsecond)
Ash.Type.NaiveDatetime ->
cast_naivedatetime(value)
Ash.Type.Time ->
cast_time(value, [])
Ash.Type.TimeUsec ->
cast_time(value, precision: :microsecond)
Ash.Type.Map ->
cast_map(value)
Ash.Type.Struct ->
cast_struct(value)
Ash.Type.Keyword ->
cast_list(value)
Ash.Type.Tuple ->
cast_tuple(value)
Ash.Type.Decimal ->
cast_decimal(value)
Ash.Type.Term ->
cast(value)
Ash.Type.Union ->
cast(value)
Ash.Type.UrlEncodedBinary ->
cast_string(value)
{:array, _} ->
cast_list(value)
_name ->
cast(value)
end
end
end
end
defp cast_atom(nil) when is_nil(nil) do
nil
end
defp cast_atom(value) when is_binary(value) do
String.to_atom(String.replace_leading(value, ":", ""))
end
defp cast_function(value) when is_binary(value) do
[module_function | arity] = String.replace_leading(value, "&", "") |> String.split("/")
module_function_splits = String.split(module_function, ".")
function = List.last(module_function_splits)
module = Module.concat(module_function_splits |> Enum.reverse() |> tl() |> Enum.reverse())
Function.capture(module, String.to_atom(function), String.to_integer(hd(arity)))
end
defp cast_struct(nil) when is_nil(nil) do
nil
end
defp cast_struct(value) when is_binary(value) do
cond do
String.starts_with?(value, "Decimal.new(\"") && String.ends_with?(value, "\")") ->
cast_decimal(value)
String.starts_with?(value, "MapSet.new(") && String.ends_with?(value, ")") ->
cast_mapset(value)
String.starts_with?(value, "~r/") ->
cast_regex(value)
true ->
case struct_name(value) do
nil ->
value
name ->
module = Module.concat([name])
properties = cast_struct_properties(value)
struct(module, properties)
|> Map.replace(:__meta__, %Ecto.Schema.Metadata{
state: :loaded
})
end
end
end
defp struct_name(value) when is_binary(value) do
Regex.run(@struct_name_regex, value) |> Enum.at(1)
end
defp cast_struct_properties(value) when is_binary(value) do
Regex.run(@struct_properties_regex, value)
|> Enum.at(1)
|> split_properties()
|> Enum.into([], &cast_property(&1))
end
defp cast_property(property) when is_binary(property) do
trimmed = String.trim(property)
cond do
String.contains?(trimmed, "=>") ->
# "\"aEnd\"" => 1
unquoted = String.replace(trimmed, "\"", "")
splits = String.split(unquoted, "=>")
key = String.trim(hd(splits))
value = String.trim(hd(tl(splits)))
{cast(key), cast(value)}
true ->
splits = String.split(trimmed, ":")
key = hd(splits)
value = Enum.map_join(tl(splits), ":", &String.trim(&1))
{String.to_atom(key), cast(value)}
end
end
defp cast_date(value) do
case Ash.Type.Date.cast_input(value, []) do
{:ok, date} ->
date
_ ->
Logger.warning("AshNeo4j.Cast: value #{value} can't be parsed as Date")
value
end
end
defp cast_datetime(value, opts) when is_struct(value, Bolty.Types.DateTimeWithTZOffset) do
datetime = BoltyHelper.convert_from_datetime_with_tz_offset(value)
cast_datetime(datetime, opts)
end
defp cast_datetime(value, opts) do
case Ash.Type.DateTime.cast_input(value, opts) do
{:ok, datetime} ->
datetime
_ ->
Logger.warning("AshNeo4j.Cast: value #{value} can't be parsed as DateTime")
value
end
end
defp cast_duration(value) do
case Ash.Type.Duration.cast_input(value, precision: :microsecond) do
{:ok, duration} ->
duration
_ ->
Logger.warning("AshNeo4j.Cast: value #{value} can't be parsed as Duration")
value
end
end
defp cast_naivedatetime(value) do
case Ash.Type.NaiveDatetime.cast_input(value, precision: :microsecond) do
{:ok, naivedatetime} ->
naivedatetime
_ ->
Logger.warning("AshNeo4j.Cast: value #{value} can't be parsed as NaiveDateTime")
value
end
end
defp cast_time(value, opts) when is_struct(value, Bolty.Types.TimeWithTZOffset) do
time = BoltyHelper.convert_from_time_with_tz_offset(value)
cast_time(time, opts)
end
defp cast_time(value, opts) do
case Ash.Type.Time.cast_input(value, opts) do
{:ok, time} ->
time
_ ->
Logger.warning("AshNeo4j.Cast: value #{value} can't be parsed as Time")
value
end
end
defp cast(atom) when is_atom(atom) do
atom
end
defp cast(boolean) when is_boolean(boolean) do
boolean
end
defp cast(integer) when is_integer(integer) do
integer
end
defp cast(float) when is_float(float) do
float
end
defp cast(value) when is_binary(value) do
case value do
"nil" ->
nil
"true" ->
true
"false" ->
false
_ ->
cond do
String.starts_with?(value, ":") ->
cast_atom(value)
String.starts_with?(value, "\"") && String.ends_with?(value, "\"") ->
cast_string(value)
String.starts_with?(value, "[") && String.ends_with?(value, "]") ->
cast_list(value)
String.starts_with?(value, "%{") && String.ends_with?(value, "}") ->
cast_map(value)
String.starts_with?(value, "%") && String.contains?(value, "{") && String.ends_with?(value, "}") ->
cast_struct(value)
String.starts_with?(value, "{") && String.ends_with?(value, "}") ->
cast_tuple(value)
String.starts_with?(value, "MapSet.new(") && String.ends_with?(value, ")") ->
cast_mapset(value)
String.starts_with?(value, "Decimal.new(\"") && String.ends_with?(value, "\")") ->
cast_decimal(value)
String.starts_with?(value, "~r/") ->
cast_regex(value)
true ->
case Integer.parse(value) do
{integer, ""} ->
integer
{_integer, _} ->
case Float.parse(value) do
{float, _} ->
float
:error ->
Logger.warning("AshNeo4j.Cast: value #{value} has leading integer but isn't an integer or float")
value
end
:error ->
Logger.warning("AshNeo4j.Cast: no cast for value #{value}")
value
end
end
end
end
defp cast(list) when is_list(list) do
cast_list(list)
end
defp cast(map) when is_map(map) do
cast_map(map)
end
defp cast_string(nil) when is_nil(nil) do
nil
end
defp cast_string(value) when is_bitstring(value) do
value
|> String.replace_leading("\"", "")
|> String.replace_trailing("\"", "")
end
defp cast_list(nil) when is_nil(nil) do
nil
end
defp cast_list(value) when is_list(value) do
value
|> Enum.into([], &cast(&1))
end
defp cast_list(value) when is_bitstring(value) do
value
|> String.replace_leading("[", "")
|> String.replace_trailing("]", "")
|> String.split(",")
|> Enum.into([], &cast(String.trim(&1)))
end
defp cast_map(nil) when is_nil(nil) do
nil
end
defp cast_map(value) when is_map(value) do
value
|> Enum.into(%{}, &cast(&1))
end
defp cast_map(value) when is_bitstring(value) do
value
|> String.replace_leading("%{", "")
|> String.replace_trailing("}", "")
|> String.split(",")
|> Enum.into(%{}, &cast_property(&1))
end
defp cast_tuple(nil) when is_nil(nil) do
nil
end
defp cast_tuple(value) when is_tuple(value) do
value
end
defp cast_tuple(value) when is_bitstring(value) do
value
|> String.replace_leading("{", "")
|> String.replace_trailing("}", "")
|> String.split(",")
|> Enum.into([], &cast(String.trim(&1)))
|> List.to_tuple()
end
defp cast_decimal(nil) when is_nil(nil) do
nil
end
defp cast_decimal(value) when is_bitstring(value) do
string =
value
|> String.replace_leading("Decimal.new(\"", "")
|> String.replace_trailing("\")", "")
case Decimal.parse(string) do
{decimal, _} ->
decimal
:error ->
Logger.warning("AshNeo4j.Cast: value #{value} can't be parsed as a Decimal")
value
end
end
defp cast_mapset(value) when is_bitstring(value) do
value
|> String.replace_leading("MapSet.new(", "")
|> String.replace_trailing(")", "")
|> cast_list()
|> MapSet.new()
end
defp cast_regex(value) when is_bitstring(value) do
splits = String.split(value, "/")
case length(splits) do
2 ->
case Regex.compile(Enum.at(splits, 1)) do
{:ok, regex} ->
regex
{:error, _} ->
Logger.warning("AshNeo4j.Cast: value #{value} can't be parsed as Regex")
value
end
3 ->
case Regex.compile(Enum.at(splits, 1), Enum.at(splits, 2)) do
{:ok, regex} ->
regex
{:error, _} ->
Logger.warning("AshNeo4j.Cast: value #{value} can't be parsed as Regex")
value
end
_ ->
Logger.warning("AshNeo4j.Cast: value #{value} can't be parsed as Regex")
value
end
end
defp split_properties(str) do
{parts, buf, _depths, _in_string} =
String.graphemes(str)
|> Enum.reduce({[], "", %{curly: 0, square: 0}, false}, fn
"\"", {acc, buf, depths, in_string} ->
{acc, buf <> "\"", depths, !in_string}
"{", {acc, buf, depths, false} ->
{acc, buf <> "{", %{depths | curly: depths.curly + 1}, false}
"}", {acc, buf, depths, false} ->
{acc, buf <> "}", %{depths | curly: depths.curly - 1}, false}
"[", {acc, buf, depths, false} ->
{acc, buf <> "[", %{depths | square: depths.square + 1}, false}
"]", {acc, buf, depths, false} ->
{acc, buf <> "]", %{depths | square: depths.square - 1}, false}
",", {acc, buf, %{curly: 0, square: 0}, false} ->
{acc ++ [String.trim(buf)], "", %{curly: 0, square: 0}, false}
ch, {acc, buf, depths, in_string} ->
{acc, buf <> ch, depths, in_string}
end)
parts ++ [String.trim(buf)]
end
end