diff --git a/README.md b/README.md index 846837e..e3fb5a5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ This library borrows liberally from [realtime](https://github.com/supabase/realtime) from Supabase, which in turn draws heavily on [cainophile](https://github.com/cainophile/cainophile). +## Alternatives + +If you don't need to subscribe to WAL events directly in Elixir or are not an Elixir user, take a look at [Sequin](https://github.com/sequinstream/sequin), which allows you to stream Postgres changes to a variety of destinations, including Kafka, S3, and more. + ## Installation If [available in Hex](https://hex.pm/docs/publish), the package can be installed diff --git a/docker-compose.dbs.yml b/docker-compose.dbs.yml index 5a0ddd5..2f82a61 100644 --- a/docker-compose.dbs.yml +++ b/docker-compose.dbs.yml @@ -1,5 +1,3 @@ -version: '3' - services: db: image: supabase/postgres:14.1.0 diff --git a/lib/mix/tasks/walex.setup.ex b/lib/mix/tasks/walex.setup.ex index 3b0b2f0..0e21b04 100644 --- a/lib/mix/tasks/walex.setup.ex +++ b/lib/mix/tasks/walex.setup.ex @@ -102,6 +102,9 @@ defmodule Mix.Tasks.Walex.Setup do email citext UNIQUE NOT NULL, name VARCHAR NOT NULL, age INTEGER DEFAULT 0, + books VARCHAR[] DEFAULT '{}'::VARCHAR[], + favorite_numbers INTEGER[] DEFAULT '{}'::INTEGER[], + meta JSONB DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); diff --git a/lib/walex/casting/array_parser.ex b/lib/walex/casting/array_parser.ex new file mode 100644 index 0000000..8b5ab10 --- /dev/null +++ b/lib/walex/casting/array_parser.ex @@ -0,0 +1,166 @@ +defmodule WalEx.Casting.ArrayParser do + @moduledoc """ + Parser for PostgreSQL array literals + + Implementation inspired by Supabase Realtime and Sequin + """ + + @doc """ + Parses a PostgreSQL array literal string into an Elixir list. + + Returns all elements as strings - the caller is responsible for any + type conversion. NULL values are returned as `nil`. + + ## Parameters + - `array_string` - PostgreSQL array literal (e.g., `"{1,2,3}"`) + + ## Returns + - `{:ok, list}` - Successfully parsed array as nested list + - `{:error, reason}` - Parsing failed with reason + + ## Examples + + # Simple arrays + iex> WalEx.ArrayParser.parse("{1,2,3}") + {:ok, ["1", "2", "3"]} + + # Empty arrays + iex> WalEx.ArrayParser.parse("{}") + {:ok, []} + + # Arrays with quoted strings + iex> WalEx.ArrayParser.parse("{\"hello, world\",\"foo\"}") + {:ok, ["hello, world", "foo"]} + + # Nested arrays + iex> WalEx.ArrayParser.parse("{{1,2},{3,4}}") + {:ok, [["1", "2"], ["3", "4"]]} + + # NULL values + iex> WalEx.ArrayParser.parse("{1,NULL,3}") + {:ok, ["1", nil, "3"]} + """ + def parse(array_string) when is_binary(array_string) do + case array_string do + "{}" -> {:ok, []} + <<"{", rest::binary>> -> parse_array_contents(rest, [], "") + _ -> {:error, "Invalid array format - must start with {"} + end + end + + # Main parsing loop - handles end of input + defp parse_array_contents(<<>>, _acc, _current) do + {:error, "Unexpected end of array - missing closing }"} + end + + # Handle closing brace - empty current element + defp parse_array_contents(<<"}", _rest::binary>>, acc, "") do + {:ok, Enum.reverse(acc)} + end + + defp parse_array_contents(<<"}", _rest::binary>>, acc, current) do + {:ok, Enum.reverse([current | acc])} + end + + # Handle NULL values - must be followed by comma or closing brace + defp parse_array_contents(<<"NULL", rest::binary>>, acc, "") do + case rest do + <<",", rest::binary>> -> parse_array_contents(rest, [nil | acc], "") + <<"}", _::binary>> -> parse_array_contents(rest, [nil | acc], "") + _ -> {:error, "Invalid character after NULL"} + end + end + + # Handle nested arrays - track depth and recursively parse + defp parse_array_contents(<<"{", rest::binary>>, acc, "") do + case parse_nested_array(rest, 1, "{") do + {:ok, nested_content, remaining} -> + case parse(nested_content) do + {:ok, nested_array} -> + case remaining do + <<",", rest::binary>> -> parse_array_contents(rest, [nested_array | acc], "") + <<"}", _::binary>> -> parse_array_contents(remaining, [nested_array | acc], "") + <<>> -> {:error, "Unexpected end of array - missing closing }"} + _ -> {:error, "Invalid character after nested array"} + end + + {:error, _} = error -> + error + end + + {:error, _} = error -> + error + end + end + + # Handle quoted strings - delegate to specialized parser + defp parse_array_contents(<<"\"", rest::binary>>, acc, "") do + parse_quoted_string(rest, acc, "") + end + + # Handle comma separator - empty current means consecutive commas + defp parse_array_contents(<<",", rest::binary>>, acc, "") do + parse_array_contents(rest, acc, "") + end + + defp parse_array_contents(<<",", rest::binary>>, acc, current) do + parse_array_contents(rest, [current | acc], "") + end + + # Handle regular characters - accumulate into current element + defp parse_array_contents(<>, acc, current) do + parse_array_contents(rest, acc, current <> <>) + end + + # Parse quoted string - handle unterminated string error + defp parse_quoted_string(<<>>, _acc, _buffer) do + {:error, "Unexpected end of array - unterminated quoted string"} + end + + # Handle escape sequences within quoted strings + defp parse_quoted_string(<<"\\", escaped, rest::binary>>, acc, buffer) do + case escaped do + ?\\ -> parse_quoted_string(rest, acc, buffer <> "\\") + ?\" -> parse_quoted_string(rest, acc, buffer <> "\"") + _ -> parse_quoted_string(rest, acc, buffer <> "\\" <> <>) + end + end + + # End of quoted string - must be followed by comma or closing brace + defp parse_quoted_string(<<"\"", rest::binary>>, acc, buffer) do + case rest do + <<",", rest::binary>> -> parse_array_contents(rest, [buffer | acc], "") + <<"}", _::binary>> -> parse_array_contents(rest, [buffer | acc], "") + _ -> {:error, "Invalid character after quoted string"} + end + end + + defp parse_quoted_string(<>, acc, buffer) do + parse_quoted_string(rest, acc, buffer <> <>) + end + + # Parse nested array - track brace depth to find matching closing brace + defp parse_nested_array(<<>>, _depth, _buffer) do + {:error, "Unexpected end of array - unclosed nested array"} + end + + # Increment depth when encountering opening brace + defp parse_nested_array(<<"{", rest::binary>>, depth, buffer) do + parse_nested_array(rest, depth + 1, buffer <> "{") + end + + # Decrement depth when encountering closing brace (not the final one) + defp parse_nested_array(<<"}", rest::binary>>, depth, buffer) when depth > 1 do + parse_nested_array(rest, depth - 1, buffer <> "}") + end + + # Found matching closing brace - return nested array content + defp parse_nested_array(<<"}", rest::binary>>, 1, buffer) do + {:ok, buffer <> "}", rest} + end + + # Regular character in nested array - just accumulate + defp parse_nested_array(<>, depth, buffer) do + parse_nested_array(rest, depth, buffer <> <>) + end +end diff --git a/lib/walex/casting/types.ex b/lib/walex/casting/types.ex new file mode 100644 index 0000000..ed0ce1a --- /dev/null +++ b/lib/walex/casting/types.ex @@ -0,0 +1,448 @@ +defmodule WalEx.Casting.Types do + @moduledoc """ + Cast from Postgres to Elixir types + + Implementation inspired by Cainophile, Supabase Realtime and Sequin + """ + + @doc """ + Casts a PostgreSQL string value to its appropriate Elixir type. + + ## Examples + + iex> cast_record("t", "bool") + true + + iex> cast_record("123", "int4") + 123 + + iex> cast_record("123.45", "numeric") + #Decimal<123.45> + + iex> cast_record("{1,2,3}", "_int4") + [1, 2, 3] + + iex> cast_record("2024-01-15T10:30:00Z", "timestamptz") + #DateTime<2024-01-15 10:30:00Z> + + Special values like NaN and Infinity are handled: + + iex> cast_record("NaN", "float8") + :nan + + Returns the original value if casting fails. + """ + def cast_record("t", "bool"), do: true + def cast_record("f", "bool"), do: false + + # Handle interval type before general integer pattern + def cast_record(record, "interval") when is_binary(record), do: record + + # Handle special numeric values before general numeric patterns + def cast_record("NaN", type) when type in ["float4", "float8", "numeric"], do: :nan + def cast_record("Infinity", type) when type in ["float4", "float8", "numeric"], do: :infinity + + def cast_record("-Infinity", type) when type in ["float4", "float8", "numeric"], + do: :neg_infinity + + def cast_record(record, <<"int", _::binary>>) when is_binary(record) do + case Integer.parse(record) do + {int, _} -> + int + + :error -> + record + end + end + + def cast_record(record, <<"float", _::binary>>) when is_binary(record) do + case Float.parse(record) do + {float, _} -> + float + + :error -> + record + end + end + + def cast_record(record, "numeric") when is_binary(record), do: Decimal.new(record) + def cast_record(record, "decimal"), do: cast_record(record, "numeric") + + def cast_record(record, "timestamp") when is_binary(record) do + with {:ok, %NaiveDateTime{} = naive_date_time} <- Timex.parse(record, "{RFC3339}"), + %DateTime{} = date_time <- Timex.to_datetime(naive_date_time) do + date_time + else + _ -> record + end + end + + def cast_record(record, "timestamptz") when is_binary(record) do + case Timex.parse(record, "{RFC3339}") do + {:ok, %DateTime{} = date_time} -> + date_time + + _ -> + record + end + end + + def cast_record(record, "jsonb") when is_binary(record) do + case Jason.decode(record) do + {:ok, json} -> + json + + _ -> + record + end + end + + def cast_record(record, "json"), do: cast_record(record, "jsonb") + + def cast_record(record, "uuid") when is_binary(record), do: record + + def cast_record(record, "date") when is_binary(record) do + case Date.from_iso8601(record) do + {:ok, date} -> date + _ -> record + end + end + + def cast_record(record, "time") when is_binary(record) do + case Time.from_iso8601(record) do + {:ok, time} -> time + _ -> record + end + end + + def cast_record(record, "timetz") when is_binary(record) do + # PostgreSQL timetz format includes timezone offset + # For now, just parse as regular time + case Time.from_iso8601(String.slice(record, 0..7)) do + {:ok, time} -> time + _ -> record + end + end + + def cast_record(record, "money") when is_binary(record) do + # Remove currency symbol and convert to decimal + record + |> String.replace(~r/[^\d.-]/, "") + |> Decimal.new() + end + + def cast_record(record, "bytea") when is_binary(record) do + # PostgreSQL bytea hex format starts with \x + if String.starts_with?(record, "\\x") do + record + |> String.slice(2..-1) + |> Base.decode16!(case: :mixed) + else + record + end + end + + def cast_record(record, "inet") when is_binary(record), do: record + def cast_record(record, "cidr") when is_binary(record), do: record + def cast_record(record, "macaddr") when is_binary(record), do: record + def cast_record(record, "macaddr8") when is_binary(record), do: record + + def cast_record(record, "xml") when is_binary(record), do: record + + # Geometric types - return as strings for now + def cast_record(record, "point") when is_binary(record), do: record + def cast_record(record, "line") when is_binary(record), do: record + def cast_record(record, "lseg") when is_binary(record), do: record + def cast_record(record, "box") when is_binary(record), do: record + def cast_record(record, "path") when is_binary(record), do: record + def cast_record(record, "polygon") when is_binary(record), do: record + def cast_record(record, "circle") when is_binary(record), do: record + + # Range types - return as strings for now + def cast_record(record, "int4range") when is_binary(record), do: record + def cast_record(record, "int8range") when is_binary(record), do: record + def cast_record(record, "numrange") when is_binary(record), do: record + def cast_record(record, "tsrange") when is_binary(record), do: record + def cast_record(record, "tstzrange") when is_binary(record), do: record + def cast_record(record, "daterange") when is_binary(record), do: record + + # Text search types + def cast_record(record, "tsvector") when is_binary(record), do: record + def cast_record(record, "tsquery") when is_binary(record), do: record + + # Other specialized types + def cast_record(record, "bit") when is_binary(record), do: record + def cast_record(record, "varbit") when is_binary(record), do: record + def cast_record(record, "oid") when is_binary(record), do: record + def cast_record(record, "regclass") when is_binary(record), do: record + def cast_record(record, "regproc") when is_binary(record), do: record + def cast_record(record, "regtype") when is_binary(record), do: record + def cast_record(record, "regrole") when is_binary(record), do: record + def cast_record(record, "regnamespace") when is_binary(record), do: record + + # PostgreSQL internal types + def cast_record(record, "name") when is_binary(record), do: record + def cast_record(record, "pg_lsn") when is_binary(record), do: record + def cast_record(record, "pg_snapshot") when is_binary(record), do: record + def cast_record(record, "txid_snapshot") when is_binary(record), do: record + + # Array type casting - integer arrays with support for multidimensional arrays + def cast_record(array_string, <<"_int", _::binary>>) when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> + cast_array_elements(elements, &String.to_integer/1) + + {:error, _} -> + array_string + end + end + + # Array type casting - float arrays + def cast_record(array_string, <<"_float", _::binary>>) when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> + cast_array_elements(elements, &String.to_float/1) + + {:error, _} -> + array_string + end + end + + # Array type casting - text/varchar arrays + def cast_record(array_string, column_type) + when is_binary(array_string) and column_type in ["_text", "_varchar"] do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> elements + {:error, _} -> array_string + end + end + + # Array type casting - boolean arrays + def cast_record(array_string, "_bool") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> + Enum.map(elements, fn + nil -> nil + "t" -> true + "f" -> false + other -> other + end) + + {:error, _} -> + array_string + end + end + + # Array type casting - numeric/decimal arrays + def cast_record(array_string, "_numeric") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> + Enum.map(elements, fn + nil -> nil + elem -> Decimal.new(elem) + end) + + {:error, _} -> + array_string + end + end + + def cast_record(array_string, "_decimal"), do: cast_record(array_string, "_numeric") + + # Array type casting - timestamptz arrays + def cast_record(array_string, "_timestamptz") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> + Enum.map(elements, fn + nil -> + nil + + elem -> + case Timex.parse(elem, "{RFC3339}") do + {:ok, %DateTime{} = dt} -> dt + _ -> elem + end + end) + + {:error, _} -> + array_string + end + end + + # Array type casting - timestamp arrays + def cast_record(array_string, "_timestamp") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> + Enum.map(elements, fn + nil -> + nil + + elem -> + with {:ok, %NaiveDateTime{} = naive} <- Timex.parse(elem, "{RFC3339}"), + %DateTime{} = dt <- Timex.to_datetime(naive) do + dt + else + _ -> elem + end + end) + + {:error, _} -> + array_string + end + end + + # Array type casting - UUID arrays + def cast_record(array_string, "_uuid") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> elements + {:error, _} -> array_string + end + end + + # Array type casting - JSONB arrays + def cast_record(array_string, "_jsonb") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> + Enum.map(elements, fn + nil -> + nil + + elem -> + case Jason.decode(elem) do + {:ok, json} -> json + _ -> elem + end + end) + + {:error, _} -> + array_string + end + end + + def cast_record(array_string, "_json"), do: cast_record(array_string, "_jsonb") + + # Array type casting - date arrays + def cast_record(array_string, "_date") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> + Enum.map(elements, fn + nil -> + nil + + elem -> + case Date.from_iso8601(elem) do + {:ok, date} -> date + _ -> elem + end + end) + + {:error, _} -> + array_string + end + end + + # Array type casting - time arrays + def cast_record(array_string, "_time") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> + Enum.map(elements, fn + nil -> + nil + + elem -> + case Time.from_iso8601(elem) do + {:ok, time} -> time + _ -> elem + end + end) + + {:error, _} -> + array_string + end + end + + # Array type casting - network address arrays (inet, cidr, macaddr) + def cast_record(array_string, "_inet") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> elements + {:error, _} -> array_string + end + end + + def cast_record(array_string, "_cidr") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> elements + {:error, _} -> array_string + end + end + + def cast_record(array_string, "_macaddr") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> elements + {:error, _} -> array_string + end + end + + # Array type casting - money arrays + def cast_record(array_string, "_money") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> + Enum.map(elements, fn + nil -> + nil + + elem -> + elem + |> String.replace(~r/[^\d.-]/, "") + |> Decimal.new() + end) + + {:error, _} -> + array_string + end + end + + # Array type casting - bytea arrays + def cast_record(array_string, "_bytea") when is_binary(array_string) do + case WalEx.Casting.ArrayParser.parse(array_string) do + {:ok, elements} -> + Enum.map(elements, fn + nil -> + nil + + elem -> + if String.starts_with?(elem, "\\x") do + elem + |> String.slice(2..-1) + |> Base.decode16!(case: :mixed) + else + elem + end + end) + + {:error, _} -> + array_string + end + end + + # Fallback - return record unchanged if no specific casting is defined + def cast_record(record, _column_type) do + record + end + + @doc false + # Helper function to recursively cast array elements, supporting nested arrays + defp cast_array_elements(elements, cast_fn) do + Enum.map(elements, fn + nil -> + nil + + elem when is_list(elem) -> + # Handle nested arrays recursively + cast_array_elements(elem, cast_fn) + + elem -> + cast_fn.(elem) + end) + end +end diff --git a/lib/walex/decoder/oid_database.ex b/lib/walex/decoder/oid_database.ex index da94a95..e305bc9 100755 --- a/lib/walex/decoder/oid_database.ex +++ b/lib/walex/decoder/oid_database.ex @@ -1,23 +1,8 @@ -# CREDITS -# This file steals liberally from https://github.com/supabase/realtime, -# which in turn draws on https://github.com/cainophile/cainophile - -# Lifted from epgsql (src/epgsql_binary.erl), this module licensed under -# 3-clause BSD found here: https://raw.githubusercontent.com/epgsql/epgsql/devel/LICENSE - -# https://github.com/brianc/node-pg-types/blob/master/lib/builtins.js -# MIT License (MIT) - -# Following query was used to generate this file: -# SELECT json_object_agg(UPPER(PT.typname), PT.oid::int4 ORDER BY pt.oid) -# FROM pg_type PT -# WHERE typnamespace = (SELECT pgn.oid FROM pg_namespace pgn WHERE nspname = 'pg_catalog') -- Take only built-in Postgres types with stable OID (extension types are not guaranteed to be stable) -# AND typtype = 'b' -- Only basic types -# AND typisdefined -- Ignore undefined types - defmodule WalEx.OidDatabase do @moduledoc """ Maps a numeric PostgreSQL type ID to a descriptive string. + + Implementation borrowed from Supabase Realtime, Cainophile, and epgsql. """ @doc """ Maps a numeric PostgreSQL type ID to a descriptive string. @@ -167,6 +152,7 @@ defmodule WalEx.OidDatabase do 3907 -> "_numrange" 3909 -> "_tsrange" 3911 -> "_tstzrange" + 3912 -> "daterange" 3913 -> "_daterange" 3927 -> "_int8range" 4089 -> "regnamespace" diff --git a/lib/walex/replication/publisher.ex b/lib/walex/replication/publisher.ex index b128777..1460a9e 100644 --- a/lib/walex/replication/publisher.ex +++ b/lib/walex/replication/publisher.ex @@ -4,7 +4,7 @@ defmodule WalEx.Replication.Publisher do """ use GenServer - alias WalEx.{Changes, Config, Events, Types} + alias WalEx.{Changes, Config, Events} alias WalEx.Decoder.Messages defmodule(State, @@ -265,7 +265,7 @@ defmodule WalEx.Replication.Publisher do defp validate_tuple_and_handle_response(tuple_data, index, acc, column_name, column_type) do case validate_tuple(tuple_data, index) do {:ok, record} -> - {:cont, Map.put(acc, column_name, Types.cast_record(record, column_type))} + {:cont, Map.put(acc, column_name, WalEx.Casting.Types.cast_record(record, column_type))} :error -> {:halt, acc} diff --git a/lib/walex/types.ex b/lib/walex/types.ex deleted file mode 100644 index 1df72b2..0000000 --- a/lib/walex/types.ex +++ /dev/null @@ -1,113 +0,0 @@ -defmodule WalEx.Types do - @moduledoc """ - Cast from Postgres to Elixir types - """ - def cast_record("t", "bool"), do: true - def cast_record("f", "bool"), do: false - - def cast_record(record, <<"int", _::binary>>) when is_binary(record) do - case Integer.parse(record) do - {int, _} -> - int - - :error -> - record - end - end - - def cast_record(record, <<"float", _::binary>>) when is_binary(record) do - case Float.parse(record) do - {float, _} -> - float - - :error -> - record - end - end - - def cast_record(record, "numeric") when is_binary(record), do: Decimal.new(record) - def cast_record(record, "decimal"), do: cast_record(record, "numeric") - - def cast_record(record, "timestamp") when is_binary(record) do - with {:ok, %NaiveDateTime{} = naive_date_time} <- Timex.parse(record, "{RFC3339}"), - %DateTime{} = date_time <- Timex.to_datetime(naive_date_time) do - date_time - else - _ -> record - end - end - - def cast_record(record, "timestamptz") when is_binary(record) do - case Timex.parse(record, "{RFC3339}") do - {:ok, %DateTime{} = date_time} -> - date_time - - _ -> - record - end - end - - def cast_record(record, "jsonb") when is_binary(record) do - case Jason.decode(record) do - {:ok, json} -> - json - - _ -> - record - end - end - - # TODO: Add additional type castings and ability to load external types - def cast_record(record, _column_type) do - record - end - - # defp cast_record(record, "int2") when is_binary(record), do: String.to_integer(record) - # defp cast_record(record, "int4") when is_binary(record), do: String.to_integer(record) - # defp cast_record(record, "int8") when is_binary(record), do: String.to_integer(record) - - # defp cast_record(record, "numeric") when is_binary(record) do - # if String.contains?(record, ".") do - # String.to_float(record) - # else - # String.to_integer(record) - # end - # end - - # defp cast_record(record, "json") when is_binary(record) do - # case Jason.decode(record) do - # {:ok, json} -> - # Jason.decode!(json) - - # _ -> - # record - # end - # end - - # # Integer Array - this assumes a single non-nested array - # # This is brittle, I imagine there's a safer way to handle arrays.. - # defp cast_record(<<123>> <> record, "_int4") when is_binary(record) do - # record - # |> String.replace(["{", "}"], "") - # |> String.split(",") - # |> Enum.map(&String.to_integer/1) - # end - - # # Text Array - this assumes a single non-nested array - # defp cast_record(<<123>> <> record, "_text") when is_binary(record) do - # record - # |> String.replace(["{", "}"], "") - # |> String.split(",") - # end - - # # TODO: Create a dynamic function that can take custom decoders - # defp cast_record(record, "geography") when is_binary(record) do - # case Geo.WKB.decode(record) do - # {:ok, geo} -> - # geo - - # _ -> - # record - # end - # end -end diff --git a/mix.exs b/mix.exs index d5a9485..54b54d4 100644 --- a/mix.exs +++ b/mix.exs @@ -33,12 +33,12 @@ defmodule WalEx.MixProject do {:postgrex, "~> 0.20.0"}, {:decimal, "~> 2.3.0"}, {:jason, "~> 1.4"}, - {:timex, "~> 3.7"}, + {:timex, "~> 3.7.13"}, # Dev & Test - {:ex_doc, "~> 0.37.0", only: :dev, runtime: false}, - {:sobelow, "~> 0.12", only: [:dev, :test], runtime: false}, - {:credo, "~> 1.7.11", only: [:dev, :test], runtime: false}, + {:ex_doc, "~> 0.38.2", only: :dev, runtime: false}, + {:sobelow, "~> 0.14.0", only: [:dev, :test], runtime: false}, + {:credo, "~> 1.7.12", only: [:dev, :test], runtime: false}, {:excoveralls, "~> 0.18.5", only: [:dev, :test], runtime: false}, {:rambo, "~> 0.3.4", only: [:dev, :test], runtime: false} ] diff --git a/mix.lock b/mix.lock index ae3f87d..34ac0f0 100644 --- a/mix.lock +++ b/mix.lock @@ -1,32 +1,32 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, - "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, + "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, - "ex_doc": {:hex, :ex_doc, "0.37.0", "970f92b39e62c460aa8a367508e938f5e4da6e2ff3eaed3f8530b25870f45471", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "b0ee7f17373948e0cf471e59c3a0ee42f3bd1171c67d91eb3626456ef9c6202c"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, - "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, - "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, + "hackney": {:hex, :hackney, "1.24.1", "f5205a125bba6ed4587f9db3cc7c729d11316fa8f215d3e57ed1c067a9703fa9", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "f4a7392a0b53d8bbc3eb855bdcc919cd677358e65b2afd3840b5b3690c4c8a39"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "rambo": {:hex, :rambo, "0.3.4", "8962ac3bd1a633ee9d0e8b44373c7913e3ce3d875b4151dcd060886092d2dce7", [:mix], [], "hexpm", "0cc54ed089fbbc84b65f4b8a774224ebfe60e5c80186fafc7910b3e379ad58f1"}, - "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, + "sobelow": {:hex, :sobelow, "0.14.0", "dd82aae8f72503f924fe9dd97ffe4ca694d2f17ec463dcfd365987c9752af6ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7ecf91e298acfd9b24f5d761f19e8f6e6ac585b9387fb6301023f1f2cd5eed5f"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, - "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, - "tzdata": {:hex, :tzdata, "1.1.2", "45e5f1fcf8729525ec27c65e163be5b3d247ab1702581a94674e008413eef50b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cec7b286e608371602318c414f344941d5eb0375e14cfdab605cca2fe66cba8b"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, + "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, } diff --git a/test/support/test_helpers.ex b/test/support/test_helpers.ex index 45995af..bcb30f9 100644 --- a/test/support/test_helpers.ex +++ b/test/support/test_helpers.ex @@ -78,7 +78,7 @@ defmodule WalEx.Support.TestHelpers do def update_user(database_pid) do update_user = """ - UPDATE \"user\" SET age = 30 WHERE id = 1 + UPDATE \"user\" SET age = 30, books = ARRAY['book1, 2 and 3', 'book4'], favorite_numbers = ARRAY[1, 2, 3], meta = '{"key": {"foo": "bar"}, "list": [1, 2, 3]}'::JSONB WHERE id = 1 """ Postgrex.query!(database_pid, update_user, []) diff --git a/test/walex/casting/array_parser_test.exs b/test/walex/casting/array_parser_test.exs new file mode 100644 index 0000000..c9a47ac --- /dev/null +++ b/test/walex/casting/array_parser_test.exs @@ -0,0 +1,95 @@ +defmodule WalEx.Casting.ArrayParserTest do + use ExUnit.Case, async: true + alias WalEx.Casting.ArrayParser + + describe "parse/1" do + test "parses empty arrays" do + assert ArrayParser.parse("{}") == {:ok, []} + end + + test "parses simple integer arrays" do + assert ArrayParser.parse("{1,2,3}") == {:ok, ["1", "2", "3"]} + end + + test "parses arrays with spaces" do + assert ArrayParser.parse("{1, 2, 3}") == {:ok, ["1", " 2", " 3"]} + end + + test "parses arrays with NULL values" do + assert ArrayParser.parse("{1,NULL,3}") == {:ok, ["1", nil, "3"]} + assert ArrayParser.parse("{NULL,NULL}") == {:ok, [nil, nil]} + end + + test "parses quoted strings" do + assert ArrayParser.parse(~s({"hello","world"})) == {:ok, ["hello", "world"]} + end + + test "parses quoted strings with commas" do + assert ArrayParser.parse(~s({"hello, world","foo"})) == {:ok, ["hello, world", "foo"]} + end + + test "parses quoted strings with escaped quotes" do + assert ArrayParser.parse(~s({"say \\"hello\\"","world"})) == + {:ok, ["say \"hello\"", "world"]} + end + + test "parses arrays with backslashes" do + assert ArrayParser.parse(~s({"a\\\\b","c\\\\d"})) == {:ok, ["a\\b", "c\\d"]} + end + + test "parses arrays with empty strings" do + assert ArrayParser.parse(~s({"","x",""})) == {:ok, ["", "x", ""]} + assert ArrayParser.parse(~s({"","",""})) == {:ok, ["", "", ""]} + end + + test "parses arrays with whitespace" do + assert ArrayParser.parse("{ \"a\", \"b\" , \"c\" }") == + {:ok, [" \"a\"", " \"b\" ", " \"c\" "]} + end + + test "parses arrays with JSON-like strings" do + assert ArrayParser.parse(~s({"{\\\"key\\\": \\\"value\\\"}","[1,2,3]"})) == + {:ok, ["{\"key\": \"value\"}", "[1,2,3]"]} + end + + test "parses arrays with quoted braces" do + assert ArrayParser.parse(~s({"{nested}","not{nested"})) == {:ok, ["{nested}", "not{nested"]} + end + + test "parses nested arrays" do + assert ArrayParser.parse("{{1,2},{3,4}}") == {:ok, [["1", "2"], ["3", "4"]]} + end + + test "parses deeply nested arrays" do + assert ArrayParser.parse("{{{1,2}},{{3,4}}}") == {:ok, [[["1", "2"]], [["3", "4"]]]} + end + + test "parses mixed nested arrays" do + assert ArrayParser.parse("{1,{2,3},4}") == {:ok, ["1", ["2", "3"], "4"]} + end + + test "parses arrays with quoted nested structures" do + assert ArrayParser.parse(~s({"{1,2}","normal"})) == {:ok, ["{1,2}", "normal"]} + end + + test "handles invalid array format" do + assert ArrayParser.parse("not_an_array") == + {:error, "Invalid array format - must start with {"} + end + + test "handles unclosed arrays" do + assert ArrayParser.parse("{1,2,3") == + {:error, "Unexpected end of array - missing closing }"} + end + + test "handles unterminated quoted strings" do + assert ArrayParser.parse(~s({"hello)) == + {:error, "Unexpected end of array - unterminated quoted string"} + end + + test "handles unclosed nested arrays" do + assert ArrayParser.parse("{{1,2}") == + {:error, "Unexpected end of array - missing closing }"} + end + end +end diff --git a/test/walex/casting/types_test.exs b/test/walex/casting/types_test.exs new file mode 100644 index 0000000..4282d2c --- /dev/null +++ b/test/walex/casting/types_test.exs @@ -0,0 +1,358 @@ +defmodule WalEx.Casting.TypesTest do + use ExUnit.Case, async: true + alias WalEx.Casting.Types + + describe "boolean casting" do + test "casts 't' to true" do + assert Types.cast_record("t", "bool") == true + end + + test "casts 'f' to false" do + assert Types.cast_record("f", "bool") == false + end + end + + describe "integer casting" do + test "casts int2" do + assert Types.cast_record("123", "int2") == 123 + assert Types.cast_record("-456", "int2") == -456 + end + + test "casts int4" do + assert Types.cast_record("123456", "int4") == 123_456 + assert Types.cast_record("-789012", "int4") == -789_012 + end + + test "casts int8" do + assert Types.cast_record("9223372036854775807", "int8") == 9_223_372_036_854_775_807 + end + + test "returns original on invalid integer" do + assert Types.cast_record("not_a_number", "int4") == "not_a_number" + end + end + + describe "float casting" do + test "casts float4" do + assert Types.cast_record("123.45", "float4") == 123.45 + assert Types.cast_record("-67.89", "float4") == -67.89 + end + + test "casts float8" do + assert Types.cast_record("123.456789", "float8") == 123.456789 + end + + test "returns original on invalid float" do + assert Types.cast_record("not_a_float", "float8") == "not_a_float" + end + end + + describe "numeric/decimal casting" do + test "casts numeric" do + result = Types.cast_record("123.456", "numeric") + assert %Decimal{} = result + assert Decimal.to_string(result) == "123.456" + end + + test "casts decimal" do + result = Types.cast_record("789.012", "decimal") + assert %Decimal{} = result + assert Decimal.to_string(result) == "789.012" + end + end + + describe "timestamp casting" do + test "casts timestamp" do + result = Types.cast_record("2024-01-15T10:30:00", "timestamp") + assert %DateTime{} = result + assert result.year == 2024 + assert result.month == 1 + assert result.day == 15 + end + + test "casts timestamptz" do + result = Types.cast_record("2024-01-15T10:30:00Z", "timestamptz") + assert %DateTime{} = result + assert result.year == 2024 + assert result.time_zone == "Etc/UTC" + end + + test "returns original on invalid timestamp" do + assert Types.cast_record("not_a_timestamp", "timestamp") == "not_a_timestamp" + end + end + + describe "date and time casting" do + test "casts date" do + result = Types.cast_record("2024-01-15", "date") + assert %Date{} = result + assert result.year == 2024 + assert result.month == 1 + assert result.day == 15 + end + + test "casts time" do + result = Types.cast_record("10:30:45", "time") + assert %Time{} = result + assert result.hour == 10 + assert result.minute == 30 + assert result.second == 45 + end + + test "casts timetz" do + result = Types.cast_record("10:30:45+02", "timetz") + assert %Time{} = result + assert result.hour == 10 + assert result.minute == 30 + end + end + + describe "json/jsonb casting" do + test "casts jsonb" do + result = Types.cast_record(~s({"key": "value", "number": 123}), "jsonb") + assert result == %{"key" => "value", "number" => 123} + end + + test "casts json" do + result = Types.cast_record(~s({"key": "value"}), "json") + assert result == %{"key" => "value"} + end + + test "returns original on invalid json" do + assert Types.cast_record("not_json", "jsonb") == "not_json" + end + end + + describe "UUID casting" do + test "returns UUID as-is" do + uuid = "550e8400-e29b-41d4-a716-446655440000" + assert Types.cast_record(uuid, "uuid") == uuid + end + end + + describe "money casting" do + test "casts money values" do + result = Types.cast_record("$123.45", "money") + assert %Decimal{} = result + assert Decimal.to_string(result) == "123.45" + end + + test "handles negative money" do + result = Types.cast_record("-$67.89", "money") + assert %Decimal{} = result + assert Decimal.to_string(result) == "-67.89" + end + end + + describe "bytea casting" do + test "decodes hex bytea" do + # "Hello" in hex + assert Types.cast_record("\\x48656c6c6f", "bytea") == "Hello" + end + + test "returns original for non-hex bytea" do + assert Types.cast_record("not_hex", "bytea") == "not_hex" + end + end + + describe "network types" do + test "returns inet as-is" do + assert Types.cast_record("192.168.1.1", "inet") == "192.168.1.1" + end + + test "returns cidr as-is" do + assert Types.cast_record("192.168.0.0/24", "cidr") == "192.168.0.0/24" + end + + test "returns macaddr as-is" do + assert Types.cast_record("08:00:2b:01:02:03", "macaddr") == "08:00:2b:01:02:03" + end + end + + describe "integer array casting" do + test "casts integer arrays" do + assert Types.cast_record("{1,2,3}", "_int4") == [1, 2, 3] + assert Types.cast_record("{-1,0,100}", "_int8") == [-1, 0, 100] + end + + test "handles empty integer arrays" do + assert Types.cast_record("{}", "_int4") == [] + end + end + + describe "float array casting" do + test "casts float arrays" do + assert Types.cast_record("{1.5,2.7,3.9}", "_float4") == [1.5, 2.7, 3.9] + assert Types.cast_record("{-1.1,0.0,100.99}", "_float8") == [-1.1, 0.0, 100.99] + end + + test "handles empty float arrays" do + assert Types.cast_record("{}", "_float8") == [] + end + end + + describe "text array casting" do + test "casts text arrays" do + assert Types.cast_record("{hello,world}", "_text") == ["hello", "world"] + assert Types.cast_record("{one,two,three}", "_varchar") == ["one", "two", "three"] + end + + test "handles quoted strings with commas" do + result = Types.cast_record(~s({\"hello, world\",\"foo, bar\"}), "_text") + assert result == ["hello, world", "foo, bar"] + end + + test "handles varchar arrays with commas in quoted strings" do + # Test case: book titles that contain commas and numbers + result = Types.cast_record(~s({"book1, 2 and 3","book4"}), "_varchar") + assert result == ["book1, 2 and 3", "book4"] + end + + test "handles empty text arrays" do + assert Types.cast_record("{}", "_text") == [] + end + end + + describe "boolean array casting" do + test "casts boolean arrays" do + assert Types.cast_record("{t,f,t}", "_bool") == [true, false, true] + end + + test "handles empty boolean arrays" do + assert Types.cast_record("{}", "_bool") == [] + end + end + + describe "numeric array casting" do + test "casts numeric arrays" do + result = Types.cast_record("{123.45,67.89}", "_numeric") + assert [d1, d2] = result + assert Decimal.to_string(d1) == "123.45" + assert Decimal.to_string(d2) == "67.89" + end + + test "casts decimal arrays" do + result = Types.cast_record("{1.1,2.2}", "_decimal") + assert length(result) == 2 + end + end + + describe "timestamp array casting" do + test "casts timestamptz arrays" do + result = + Types.cast_record(~s({\"2024-01-15T10:30:00Z\",\"2024-01-16T11:45:00Z\"}), "_timestamptz") + + assert [dt1, dt2] = result + assert %DateTime{} = dt1 + assert dt1.year == 2024 + assert %DateTime{} = dt2 + assert dt2.day == 16 + end + + test "handles empty timestamp arrays" do + assert Types.cast_record("{}", "_timestamptz") == [] + end + end + + describe "UUID array casting" do + test "casts UUID arrays" do + result = + Types.cast_record( + "{550e8400-e29b-41d4-a716-446655440000,550e8400-e29b-41d4-a716-446655440001}", + "_uuid" + ) + + assert length(result) == 2 + assert Enum.at(result, 0) == "550e8400-e29b-41d4-a716-446655440000" + end + end + + describe "complex array scenarios" do + test "handles arrays with NULL values" do + assert Types.cast_record("{1,NULL,3}", "_int4") == [1, nil, 3] + assert Types.cast_record("{NULL,NULL}", "_int4") == [nil, nil] + end + + test "handles nested integer arrays" do + result = Types.cast_record("{{1,2},{3,4}}", "_int4") + assert result == [[1, 2], [3, 4]] + end + + test "handles deeply nested arrays" do + result = Types.cast_record("{{{1,2}}}", "_int4") + assert result == [[[1, 2]]] + end + + test "handles text arrays with special characters" do + result = Types.cast_record(~s({"hello, world","foo\\\\bar"}), "_text") + assert result == ["hello, world", "foo\\bar"] + end + + test "handles mixed content in JSONB arrays" do + result = Types.cast_record(~s({"{\\\"a\\\": 1}","[1,2,3]","null"}), "_jsonb") + assert result == [%{"a" => 1}, [1, 2, 3], nil] + end + end + + describe "edge cases for specific types" do + test "handles very large integers" do + large_int = "9223372036854775807" + assert Types.cast_record(large_int, "int8") == 9_223_372_036_854_775_807 + end + + test "handles high precision decimals" do + result = Types.cast_record("123.4567890123456789", "numeric") + assert Decimal.to_string(result) == "123.4567890123456789" + end + + test "handles special numeric values" do + assert Types.cast_record("NaN", "float4") == :nan + assert Types.cast_record("NaN", "float8") == :nan + assert Types.cast_record("NaN", "numeric") == :nan + + assert Types.cast_record("Infinity", "float4") == :infinity + assert Types.cast_record("Infinity", "float8") == :infinity + assert Types.cast_record("Infinity", "numeric") == :infinity + + assert Types.cast_record("-Infinity", "float4") == :neg_infinity + assert Types.cast_record("-Infinity", "float8") == :neg_infinity + assert Types.cast_record("-Infinity", "numeric") == :neg_infinity + end + + test "handles interval type" do + # Intervals are kept as strings for now + assert Types.cast_record("1 year 2 months 3 days", "interval") == "1 year 2 months 3 days" + end + + test "handles network addresses" do + assert Types.cast_record("192.168.1.1/24", "cidr") == "192.168.1.1/24" + assert Types.cast_record("08:00:2b:01:02:03", "macaddr") == "08:00:2b:01:02:03" + assert Types.cast_record("08:00:2b:01:02:03:04:05", "macaddr8") == "08:00:2b:01:02:03:04:05" + end + + test "handles range types" do + assert Types.cast_record("[2023-01-01,2023-12-31]", "daterange") == + "[2023-01-01,2023-12-31]" + + assert Types.cast_record("[1,10)", "int4range") == "[1,10)" + assert Types.cast_record("[1.5,2.5]", "numrange") == "[1.5,2.5]" + end + + test "handles geometric types as strings" do + assert Types.cast_record("(1,2)", "point") == "(1,2)" + assert Types.cast_record("((1,1),(2,2))", "box") == "((1,1),(2,2))" + assert Types.cast_record("<(1,2),3>", "circle") == "<(1,2),3>" + end + + test "handles XML data" do + xml = "value" + assert Types.cast_record(xml, "xml") == xml + end + end + + describe "fallback casting" do + test "returns original for unknown types" do + assert Types.cast_record("some_value", "unknown_type") == "some_value" + end + end +end diff --git a/test/walex/event/event_test.exs b/test/walex/event/event_test.exs index 5c8ff07..7acb2ae 100644 --- a/test/walex/event/event_test.exs +++ b/test/walex/event/event_test.exs @@ -70,6 +70,12 @@ defmodule WalEx.EventTest do age: 30, created_at: _created_at, email: "john.doe@example.com", + books: ["book1, 2 and 3", "book4"], + favorite_numbers: [1, 2, 3], + meta: %{ + "key" => %{"foo" => "bar"}, + "list" => [1, 2, 3] + }, updated_at: _updated_at }, schema: "public",