Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Neo4J Demo

neo4j.livemd

Neo4J Demo

Mix.install([
  {:boltx, "~> 0.0.6"},
  {:sparql, "~> 0.3"},
  {:kino, "~> 0.10.0"},
])

Introduction

To get started, create the following docker compose file:

version: '3'
services:
  neo4j:
    environment:
      - NEO4J_AUTH=neo4j/neo4jtest
    image: neo4j:5.26.1
    ports:
      - '7474:7474'
      - '7473:7473'
      - '7687:7687'
    volumes:
      - /vol/neo4j:/data

Assuming that you have named the file neo4j-docker-compose.yml and have docker compose installed, then run:

docker compose -f neo4j-docker-compose.yml up

Note: If you have an old version installed, then you may have to run docker-compose ... instead of docker compose .... As the interface is in flux, you might also encounter some warnings.

This should make the Neo4j web interface available and allow you to run this livebook.

Convenience

Function for illustrating data as a graph:

result_as_tree = fn connections ->
  contents =
    List.foldl(connections, "graph LR;\n", fn {src, dst}, acc ->
      acc <> "  #{src}-->#{dst};\n"
    end)

  Kino.Mermaid.new(contents)
end

Function for illustrating a Neo4j response as a table:

result_as_table = fn response ->
  fields = response.fields
  results = response.results

  lines =
    results
    |> List.foldl("", fn result, acc ->
      main =
        fields
        |> Enum.map(fn field ->
          case Map.get(result, field) do
            %{id: id} -> "Node id #{id}"
            res -> "#{res}"
          end
        end)
        |> Enum.join(" | ")

      acc <> "| #{main} |\n"
    end)

  Kino.Markdown.new("""
  | #{fields |> Enum.join(" | ")} |
  | #{fields |> Enum.map(fn _ -> "--" end) |> Enum.join(" | ")} |
  #{lines}
  """)
end

Conversion from sensor type to modality:

type2modality = %{
  "TemperatureSensor" => "Temperature",
  "HumiditySensor" => "RelativeHumidity",
  "PIR" => "Occupancy"
}

Setup

Establish connection:

opts = [
  hostname: "127.0.0.1",
  scheme: "bolt",
  port: 7687,
  auth: [username: "neo4j", password: "neo4jtest"],
  pool_size: 15,
]

{:ok, conn} = Boltx.start_link(opts)

Test ping:

Boltx.query!(conn, "RETURN 1 as n") |> Boltx.Response.first()

Model Construction

Establish clean slate:

queries = [
  "MATCH ()-[r]->() DELETE r",
  "MATCH (n) DELETE n"
]

Enum.map(queries, fn query -> Boltx.query!(conn, query) end)

Create Type Tree

typetree = {
  "BaseType",
  [
    {
      "Location",
      [
        {"Building", []},
        {"Floor", []},
        {"Room", []}
      ]
    },
    {
      "Modality",
      [
        {"Temperature", []},
        {"RelativeHumidity", []},
        {"AbsoluteHumidity", []},
        {"Occupancy", []}
      ]
    },
    {
      "Point",
      [
        {
          "Sensor",
          [
            {"TemperatureSensor", []},
            {"HumiditySensor", []},
            {"PIR", []}
          ]
        }
      ]
    },
    {
      "Unit",
      [
        {"Unitless", []},
        {"Kelvin", []},
        {"DegreesCelcius", []},
        {"DegreesFahrenheit", []},
        {"Percentage", []}
      ]
    },
    {
      "Data",
      []
    }
  ]
}
defmodule TypeTree do
  def establish(subtree, conn, parent \\ nil)

  def establish({name, subtypes}, conn, parent) do
    :ok = establish(name, conn, parent)

    success =
      Enum.map(subtypes, fn subtype -> establish(subtype, conn, name) end)
      |> Enum.all?(fn result -> result == :ok end)

    if success do
      :ok
    else
      :error
    end
  end

  def establish(name, conn, parent) when is_binary(name) do
    query =
      case parent do
        nil ->
          "CREATE (:Type {name: '#{name}'})"

        _ ->
          "MATCH (parent:Type {name: '#{parent}'}) MERGE (:Type {name: '#{name}'})-[:subtypeof]->(parent)"
      end

    Boltx.query!(conn, query)
    :ok
  end
end
TypeTree.establish(typetree, conn)

Create Floors

floors = [
  "3rd"
]
floors
|> Enum.map(fn floor ->
  query = "MATCH (t:Type {name: 'Floor'}) CREATE (:Floor {name: '#{floor}'})-[:type]->(t)"
  Boltx.query!(conn, query)
end)

Create Rooms

rooms = [
  %{floor: "3rd", area: "17 m²"},
  %{floor: "3rd", area: "23 m²"}
]
rooms
|> Enum.map(fn %{floor: floor, area: area} ->
  query = """
    MATCH (t:Type {name: 'Room'})
    MATCH (f:Floor {name: '#{floor}'})
    MERGE (r:Room {area: '#{area}'})-[:type]->(t)
    MERGE (f)-[:contains]->(r)
  """

  Boltx.query!(conn, query)
end)
Boltx.query!(conn, "MATCH (a:Room), (b:Room) WHERE a<>b CREATE (a)-[:adjacent]->(b)")

Create Sensors

sensors = [
  %{room: "17 m²", type: "TemperatureSensor", unit: "DegreesCelcius", hist: 12, live: "17c5ae15"},
  %{room: "17 m²", type: "HumiditySensor", unit: "Percentage", hist: 13, live: "65d0be73"},
  %{room: "17 m²", type: "PIR", unit: "Unitless", hist: 24, live: "5a3573c3"},
  %{room: "23 m²", type: "TemperatureSensor", unit: "Kelvin", hist: 37, live: "b0bc97af"},
  %{room: "23 m²", type: "HumiditySensor", unit: "Percentage", hist: 22, live: "06ef2490"},
  %{room: "23 m²", type: "PIR", unit: "Unitless", hist: 28, live: "92d17015"}
]
sensors
|> Enum.map(fn sensor ->
  modality = Map.get(type2modality, sensor.type)

  query = """
    MATCH
      (t:Type {name: '#{sensor.type}'}),
      (u:Type {name: '#{sensor.unit}'}),
      (d:Type {name: 'Data'}),
      (m:Type {name: '#{modality}'}),
      (r:Room {area: '#{sensor.room}'})
    CREATE (tmp:#{sensor.type})-[:type]->(t)
    CREATE (tmp)-[:unit]->(u)
    CREATE (tmp)-[:data]->(:Data {hist: '#{sensor.hist}', live: '#{sensor.live}'})-[:type]->(d)
    CREATE (tmp)-[:provides]->(modality:#{modality})-[:type]->(m)
    CREATE (r)-[:modality]->(modality)
  """

  Boltx.query!(conn, query)
end)

Queries

Room sizes:

Boltx.query!(conn, "MATCH (room:Room) RETURN room.area AS area")
|> result_as_table.()

Per-floor combinations of temperature and relative humidity data:

q = """
MATCH
    (r)<-[:contains]-(f),
    (r)-[:type]->(:Type {name: 'Room'}),
    (f)-[:type]->(:Type {name: 'Floor'}),
    (r)-[:modality]->(rhum_modality)<-[:provides]-(hum),
    (rhum_modality)-[:type]->(:Type {name: 'RelativeHumidity'}),
    (r)-[:modality]->(temp_modality)<-[:provides]-(temp),
    (temp_modality)-[:type]->(:Type {name: 'Temperature'})
RETURN
    f.name AS floor, hum, temp
"""

response =
  Boltx.query!(conn, q)
  |> result_as_table.()

Type tree:

response =
  Boltx.query!(conn, "MATCH (t:Type)-[:subtypeof]->(parent:Type) RETURN t.name, parent.name")

response.results
|> Enum.map(fn %{"t.name" => name, "parent.name" => parent} -> {name, parent} end)
|> result_as_tree.()

Final Words

The boltx package, used here, implements several versions of the bolt protocol needed to communicate with Neo4j. Breaking changes were introduced to this protocol in version 5. So, there might still be a few minor issues left.