defmodule Bolty.Types do @moduledoc """ Basic support for representing nodes, relationships and paths belonging to a Neo4j graph database. Four supported types of entities: - Node - Relationship - UnboundRelationship - Path More details, about the Bolt protocol, here: https://github.com/boltprotocol/boltprotocol/blob/master/README.md Additionally, since bolt V2, new types appears: spatial and temporal Those are not documented in bolt protocol, but neo4j documentation can be found here: https://neo4j.com/docs/cypher-manual/current/syntax/temporal/ https://neo4j.com/docs/cypher-manual/current/syntax/spatial/ To work with temporal types, the following Elixir structs are available: - Time, DateTime, NaiveDateTime, Duration - Calendar.DateTime to work with timezone (as string) - TimeWithTZOffset, DateTimeWithTZOffset to work with (date)time and timezone offset(as integer) For spatial types, you only need Point struct as it covers: - 2D point (cartesian or geographic) - 3D point (cartesian or geographic) """ alias Bolty.TypesHelper defmodule Entity do @moduledoc """ base structure for Node and Relationship """ @base_fields [id: nil, properties: nil] defmacro __using__(fields) do fields = @base_fields ++ fields quote do defstruct unquote(fields) end end end defmodule Node do @moduledoc """ Self-contained graph node. A Node represents a node from a Neo4j graph and consists of a unique identifier (within the scope of its origin graph), a list of labels and a map of properties. https://github.com/boltprotocol/boltprotocol/blob/master/v1/_serialization.asciidoc#node """ use Entity, labels: nil, element_id: nil @type t :: %__MODULE__{ id: integer, labels: [String.t()], properties: map, element_id: String.t() } end defmodule Relationship do @moduledoc """ Self-contained graph relationship. A Relationship represents a relationship from a Neo4j graph and consists of a unique identifier (within the scope of its origin graph), identifiers for the start and end nodes of that relationship, a type and a map of properties. https://github.com/boltprotocol/boltprotocol/blob/master/v1/_serialization.asciidoc#relationship """ use Entity, start: nil, end: nil, type: nil, element_id: nil, start_node_element_id: nil, end_node_element_id: nil @type t :: %__MODULE__{ id: integer, properties: map, element_id: String.t(), start_node_element_id: String.t(), end_node_element_id: String.t() } end defmodule UnboundRelationship do @moduledoc """ Self-contained graph relationship without endpoints. An UnboundRelationship represents a relationship relative to a separately known start point and end point. https://github.com/boltprotocol/boltprotocol/blob/master/v1/_serialization.asciidoc#unboundrelationship """ use Entity, type: nil, element_id: nil @type t :: %__MODULE__{ id: integer, properties: map, element_id: String.t() } end defmodule Path do @moduledoc """ Self-contained graph path. A Path is a sequence of alternating nodes and relationships corresponding to a walk in the graph. The path always begins and ends with a node. Its representation consists of a list of distinct nodes, a list of distinct relationships and a sequence of integers describing the path traversal https://github.com/boltprotocol/boltprotocol/blob/master/v1/_serialization.asciidoc#path """ @type t :: %__MODULE__{ nodes: list() | nil, relationships: list() | nil, sequence: list() | nil } defstruct nodes: nil, relationships: nil, sequence: nil @doc """ represents a traversal or walk through a graph and maintains a direction separate from that of any relationships traversed """ @spec graph(Path.t()) :: list() | nil def graph(path) do entities = [List.first(path.nodes)] draw_path( path.nodes, path.relationships, path.sequence, 0, Enum.take_every(path.sequence, 2), entities, # last node List.first(path.nodes), # next node nil ) end # @lint false defp draw_path(_n, _r, _s, _i, [], acc, _ln, _nn), do: acc defp draw_path(n, r, s, i, [h | t] = _rel_index, acc, ln, _nn) do next_node = Enum.at(n, Enum.at(s, 2 * i + 1)) urel = if h > 0 && h < 255 do # rel: rels[rel_index - 1], start/end: (ln.id, next_node.id) rel = Enum.at(r, h - 1) unbound_relationship = [:id, :type, :properties, :start, :end] |> Enum.zip([rel.id, rel.type, rel.properties, ln.id, next_node.id]) struct(UnboundRelationship, unbound_relationship) else # rel: rels[-rel_index - 1], start/end: (next_node.id, ln.id) # Neo4j sends: -1, and Bolty.Internals. returns 255 instead? Investigating, # meanwhile ugly path: # oh dear ... haha = if h == 255, do: -1, else: h rel = Enum.at(r, -haha - 1) unbound_relationship = [:id, :type, :properties, :start, :end] |> Enum.zip([rel.id, rel.type, rel.properties, next_node.id, ln.id]) struct(UnboundRelationship, unbound_relationship) end draw_path(n, r, s, i + 1, t, (acc ++ [urel]) ++ [next_node], next_node, ln) end end defmodule TimeWithTZOffset do @moduledoc """ Manage a Time and its time zone offset. This temporal types hs been added in bolt v2 """ defstruct [:time, :timezone_offset] @type t :: %__MODULE__{ time: Calendar.time(), timezone_offset: integer() } @doc """ Create a valid TimeWithTZOffset from a Time and offset in seconds """ @spec create(Calendar.time(), integer()) :: TimeWithTZOffset.t() def create(%Time{} = time, offset) when is_integer(offset) do %TimeWithTZOffset{ time: time, timezone_offset: offset } end @doc """ Convert TimeWithTZOffset struct in a cypher-compliant string """ @spec format_param(TimeWithTZOffset.t()) :: {:ok, String.t()} | {:error, any()} def format_param(%TimeWithTZOffset{time: time, timezone_offset: offset}) when is_integer(offset) do param = Time.to_iso8601(time) <> TypesHelper.formated_time_offset(offset) {:ok, param} end def format_param(param) do {:error, param} end end defmodule DateTimeWithTZOffset do @moduledoc """ Manage a Time and its time zone offset. This temporal types hs been added in bolt v2 """ defstruct [:naive_datetime, :timezone_offset] @type t :: %__MODULE__{ naive_datetime: Calendar.naive_datetime(), timezone_offset: integer() } @doc """ Create a valid DateTimeWithTZOffset from a NaiveDateTime and offset in seconds """ @spec create(Calendar.naive_datetime(), integer()) :: DateTimeWithTZOffset.t() def create(%NaiveDateTime{} = naive_datetime, offset) when is_integer(offset) do %DateTimeWithTZOffset{ naive_datetime: naive_datetime, timezone_offset: offset } end @doc """ Convert DateTimeWithTZOffset struct in a cypher-compliant string """ @spec format_param(DateTimeWithTZOffset.t()) :: {:ok, String.t()} | {:error, any()} def format_param(%DateTimeWithTZOffset{naive_datetime: ndt, timezone_offset: offset}) when is_integer(offset) do formated = NaiveDateTime.to_iso8601(ndt) <> TypesHelper.formated_time_offset(offset) {:ok, formated} end def format_param(param) do {:error, param} end end defmodule Point do @moduledoc """ Manage spatial data introduced in Bolt V2 Point can be: - Cartesian 2D - Geographic 2D - Cartesian 3D - Geographic 3D """ @srid_cartesian 7203 @srid_cartesian_3d 9157 @srid_wgs_84 4326 @srid_wgs_84_3d 4979 defstruct [:crs, :srid, :x, :y, :z, :longitude, :latitude, :height] @type t :: %__MODULE__{ crs: String.t(), srid: integer(), x: number() | nil, y: number() | nil, z: number() | nil, longitude: number() | nil, latitude: number() | nil, height: number() | nil } defguardp is_crs(crs) when crs in ["cartesian", "cartesian-3d", "wgs-84", "wgs-84-3d"] defguardp is_srid(srid) when srid in [@srid_cartesian, @srid_cartesian_3d, @srid_wgs_84, @srid_wgs_84_3d] defguardp are_coords(lt, lg, h, x, y, z) when (is_number(lt) or is_nil(lt)) and (is_number(lg) or is_nil(lg)) and (is_number(h) or is_nil(h)) and (is_number(x) or is_nil(x)) and (is_number(y) or is_nil(y)) and (is_number(z) or is_nil(z)) defguardp is_valid_coords(x, y) when is_number(x) and is_number(y) defguardp is_valid_coords(x, y, z) when is_number(x) and is_number(y) and is_number(z) @doc """ A 2D point either needs: - 2 coordinates and a atom (:cartesian or :wgs_84) to define its type - 2 coordinates and a srid (4326 or 7203) to define its type ## Examples: iex> Point.create(:cartesian, 10, 20.0) %Bolty.Types.Point{ crs: "cartesian", height: nil, latitude: nil, longitude: nil, srid: 7203, x: 10.0, y: 20.0, z: nil } iex> Point.create(4326, 10, 20.0) %Bolty.Types.Point{ crs: "wgs-84", height: nil, latitude: 20.0, longitude: 10.0, srid: 4326, x: 10.0, y: 20.0, z: 30.0 } """ @spec create(:cartesian | :wgs_84 | 4326 | 7203, number(), number()) :: Point.t() def create(:cartesian, x, y) do create(@srid_cartesian, x, y) end def create(:wgs_84, longitude, latitude) do create(@srid_wgs_84, longitude, latitude) end def create(@srid_cartesian, x, y) when is_valid_coords(x, y) do %Point{ crs: crs(@srid_cartesian), srid: @srid_cartesian, x: format_coord(x), y: format_coord(y) } end def create(@srid_wgs_84, longitude, latitude) when is_valid_coords(longitude, latitude) do %Point{ crs: crs(@srid_wgs_84), srid: @srid_wgs_84, x: format_coord(longitude), y: format_coord(latitude), longitude: format_coord(longitude), latitude: format_coord(latitude) } end @doc """ Create a 3D point A 3D point either needs: - 3 coordinates and a atom (:cartesian or :wgs_84) to define its type - 3 coordinates and a srid (4979 or 9147) to define its type ## Examples: iex> Point.create(:cartesian, 10, 20.0, 30) %Bolty.Types.Point{ crs: "cartesian-3d", height: nil, latitude: nil, longitude: nil, srid: 9157, x: 10.0, y: 20.0, z: 30.0 } iex> Point.create(4979, 10, 20.0, 30) %Bolty.Types.Point{ crs: "wgs-84-3d", height: 30.0, latitude: 20.0, longitude: 10.0, srid: 4979, x: 10.0, y: 20.0, z: 30.0 } """ @spec create(:cartesian | :wgs_84 | 4979 | 9157, number(), number(), number()) :: Point.t() def create(:cartesian, x, y, z) do create(@srid_cartesian_3d, x, y, z) end def create(:wgs_84, longitude, latitude, height) do create(@srid_wgs_84_3d, longitude, latitude, height) end def create(@srid_cartesian_3d, x, y, z) when is_valid_coords(x, y, z) do %Point{ crs: crs(@srid_cartesian_3d), srid: @srid_cartesian_3d, x: format_coord(x), y: format_coord(y), z: format_coord(z) } end def create(@srid_wgs_84_3d, longitude, latitude, height) when is_valid_coords(longitude, latitude, height) do %Point{ crs: crs(@srid_wgs_84_3d), srid: @srid_wgs_84_3d, x: format_coord(longitude), y: format_coord(latitude), z: format_coord(height), longitude: format_coord(longitude), latitude: format_coord(latitude), height: format_coord(height) } end @spec crs(4326 | 4979 | 7203 | 9157) :: String.t() defp crs(@srid_cartesian), do: "cartesian" defp crs(@srid_cartesian_3d), do: "cartesian-3d" defp crs(@srid_wgs_84), do: "wgs-84" defp crs(@srid_wgs_84_3d), do: "wgs-84-3d" defp format_coord(coord) when is_integer(coord), do: coord / 1 defp format_coord(coord), do: coord @doc """ Convert a Point struct into a cypher-compliant map ## Example iex(8)> Point.create(4326, 10, 20.0) |> Point.format_to_param %{crs: "wgs-84", latitude: 20.0, longitude: 10.0, x: 10.0, y: 20.0} """ @spec format_param(Point.t()) :: {:ok, map()} | {:error, any()} def format_param( %Point{crs: crs, srid: srid, latitude: lt, longitude: lg, height: h, x: x, y: y, z: z} = point ) when is_crs(crs) and is_srid(srid) and are_coords(lt, lg, h, x, y, z) do param = point |> Map.from_struct() |> Enum.filter(fn {_, val} -> not is_nil(val) end) |> Map.new() |> Map.drop([:srid]) {:ok, param} end def format_param(param) do {:error, param} end end end