JSON for Scala 3 User Guide

Overview

With this extension module of graph-core, you are able to encode and decode Graphs to and from a JSON text in an efficient way. Codecs are implemented in terms of jsoniter-scala.

Your encoded Graphs will be laid out like

{
  "nodes": [ <a JSON object per node> ],
  "edges": [ <a JSON object per edge> ]
}

While nodes will be encoded without any transformation, for edge codecs you can choose between the following two schemas:

  1. The codec for embedded edge ends encodes edge ends the very same way as it encodes nodes. This codec schema is recommended whenever your node type argument for N is of a primitive-like type including primitive types, String, or any other type with a fairly small JSON representation.
  2. For complex node types, you may want to choose the second, more sophisticated schema where edge ends are transformed to node IDs to save space and increase readability. For this purpose, you need to provide an ID function for your nodes. Actually, there exist two distinct signatures for this schema to cover non-labeled and labeled edges in separate.

Create your Graph codec

To be able to encode your Graph or decode your JSON to a Graph, you need to instantiate a Graph codec. Graph codecs vary with the coding schema and the edge type argument used for your Graph.

Create codecs for embedded edge ends

Using this schema, edge ends are represented by fully encoded nodes. This schema is your friend whenever your node type argument is of a primitive-like type. Let's look at some examples:

Given

type G = Graph[Int, DiEdge[Int]]

you get the codec like

import scalax.collection.io.jsoniter.GraphCodec
given nodeCodec: JsonValueCodec[Int]         = JsonCodecMaker.make
given edgeCodec: JsonValueCodec[DiEdge[Int]] = JsonCodecMaker.make
given graphCodec: JsonValueCodec[G] =
  GraphCodec.withEmbeddedNodes(
    Graph.from(_, _)(_),
    yourGraphConfig,
    yourNullHandling
  )

Given

type People = Graph[Person, Relation]

where Person is a case class and Relation is an ADT of edges with constructors like Relatives, Friends, or Neighbors, you can create the codec in the very same way

import scalax.collection.io.jsoniter.GraphCodec
given nodeCodec: JsonValueCodec[Person]   = JsonCodecMaker.make
given edgeCodec: JsonValueCodec[Relation] = JsonCodecMaker.make
given graphCodec: JsonValueCodec[People]  =
  GraphCodec.withEmbeddedNodes(
    Graph.from(_, _)(_),
    yourGraphConfig,
    yourNullHandling
)

Further, specific handling of non-ADT edge type arguments is provided since this is not covered by jsoniter-scala. Given

type G = Graph[String, AnyEdge[String]]

where your Graph contains the concrete edge classes DiEdge and UnDiEdge, you can create the codec like

import scalax.collection.io.jsoniter.GraphCodec
given nodeCodec: JsonValueCodec[String]             = JsonCodecMaker.make
given anyEdgeCodec: JsonValueCodec[AnyEdge[String]] =
  given unDiEdgeCodec: JsonValueCodec[UnDiEdge[String]] = JsonCodecMaker.make
  given diEdgeCodec: JsonValueCodec[DiEdge[String]]     = JsonCodecMaker.make
  EdgeCodec.makePolymorphicWithEmbeddedNodes[AnyEdge[String], (UnDiEdge[String], DiEdge[String])]()
given graphCodec: JsonValueCodec[G] =
  GraphCodec.withEmbeddedNodes(
    Graph.from(_, _)(_),
    yourGraphConfig,
    yourNullHandling
  )

For complete examples refer to EmbeddedNodesSpec.scala.

Create codecs for edge ends by ID

With this schema, edge ends are encoded to node IDs that are obtained by invoking your ID function. This schema is your friend whenever you are concerned about the size and readability of the encoded edges.

The replacement of edge ends by a corresponding node ID is implemented such that edges get transformed into constructor instances of the nonlabeled.WithNodeReferences respectively labeled.WithNodeReferences ADT. These instances will then be encoded to JSON.

Let's look at an example for a Graph with non-labeled and another example for a Graph with labeled edges:

To start with non-labeled edges, given

case class Airport(code: String, nameByLanguage: Map[String, String])

sealed trait Relation                                    extends AnyHyperEdge[Airport]
case class NonStop(airport1: Airport, airport2: Airport) extends AbstractUnDiEdge(airport1, airport2) with Relation
case class Partnership(partners: Several[Airport])       extends AbstractHyperEdge(partners) with Relation

type Airports = Graph[Airport, Relation]
object Airports extends TypedGraphFactory[Airport, Relation]

here is how to create a codec that encodes edge ends by the airport code which is unique over airports:

import scalax.collection.io.jsoniter.nonlabeled.*
import AnyHyperEdgeWithNodeReferences.compactClassNames

given nodeCodec: JsonValueCodec[Airport]                                = JsonCodecMaker.make
given idCodec: JsonValueCodec[String]                                   = JsonCodecMaker.make
given edgeCodec: JsonValueCodec[AnyHyperEdgeWithNodeReferences[String]] = JsonCodecMaker.make(compactClassNames)
given graphCodec: JsonValueCodec[Airports]                              =
  GraphCodec.withNodeReferences(
    _.code,
    HyperEdgeWithNodeReferences.apply,
    Graph.from(_, _)(_),
    yourGraphConfig,
    yourNullHandling,
    edgeFactory = Some { case ("NonStop", a1, a2) =>
      NonStop(a1, a2)
    },
    hyperEdgeFactory = Some { case ("Partnership", ends) =>
      Partnership(ends)
    }
  )

As to

  1. Imported the nonlabeled package because edges are not labeled.
  2. Support for compact class names so "type": "UnDiEdgeWithNodeReferences" gets "type": "UnDiR".
  3. Codec for String reflecting the type of code returned by the ID function.
  4. Codec for AnyHyperEdgeWithNodeReferences[String] because the Graph contains both undirected edges and hyperedges.
  5. The ID function.
  6. Factory for constructors of AnyHyperEdgeWithNodeReferences that consumes any edge type having the superclass HyperEdge.
  7. Used for decoding. If the encoded edge contains "edgeT": "NonStop", we create an edge of type NonStop. Note that at invocation, the codec has transformed the ID back to Airport by looking up the airport in the decoded nodes by code.
  8. Used for decoding. If the encoded edge contains "edgeT": "Partnership", we create a hyperedge of type Partnership. Importantly, you need to cover all participating edge types by the passed edge factories. Otherwise, decoding throws an exception.

Second, given a related use case extended by edge labels

case class Airport(code: String, nameByLanguage: Map[String, String])

sealed trait Relation extends AnyDiEdge[Airport]

case class NonStop(from: Airport, to: Airport, airline: String)
    extends AbstractDiEdge(from, to)
    with MultiEdge
    with Relation:
  override def extendKeyBy: OneOrMore[String] = OneOrMore(airline)

case class Connecting(from: Airport, to: Airport, via: String, airline1: String, airline2: String)
    extends AbstractDiEdge(from, to)
    with MultiEdge
    with Relation:
  override def extendKeyBy: OneOrMore[(String, String, String)] = OneOrMore((via, airline1, airline2))

type Airports = Graph[Airport, Relation]
object Airports extends TypedGraphFactory[Airport, Relation]

you can get the Graph codec including labels like

import scalax.collection.io.jsoniter.labeled.*
import AnyEdgeWithNodeReferences.compactClassNames

given nodeCodec: JsonValueCodec[Airport] = JsonCodecMaker.make
given idCodec: JsonValueCodec[String]    = JsonCodecMaker.make

sealed trait Label
case class NonStopLabel(airline: String)                                    extends Label
case class ConnectingLabel(via: String, airline1: String, airline2: String) extends Label

given labelCodec: JsonValueCodec[Label]                                  = JsonCodecMaker.make
given edgeCodec: JsonValueCodec[DiEdgeWithNodeReferences[String, Label]] = JsonCodecMaker.make(compactClassNames)
given graphCodec: JsonValueCodec[Airports]                               =
  GraphCodec.withNodeReferences(
    _.code,
    {
      case NonStop(_, _, airline)        => NonStopLabel(airline)
      case Connecting(_, _, via, a1, a2) => ConnectingLabel(via, a1, a2)
    },
    DiEdgeWithNodeReferences.apply,
    factory = Graph.from(_, _)(_),
    yourGraphConfig,
    yourNullHandling,
    edgeFactory = Some {
      case ("NonStop", from, to, NonStopLabel(airline))           => NonStop(from, to, airline)
      case ("Connecting", from, to, ConnectingLabel(via, a1, a2)) => Connecting(from, to, via, a1, a2)
    }
  )

Note the differences to the non-labeled use case

  1. Imported the labeled package to be able to deal with edge labels.
  2. ADT for labels necessary because of different label types of the participating edge types.
  3. Labels will also be encoded into DiEdgeWithNodeReferences.
  4. This toLabel argument defines what to encode as the Label part of edges.
  5. Used for decoding including labels.

For more and complete examples refer to

In the above Library example, by the way, nodes are modeled as an ADT, so the ID function needs to deal with every case.

Encode your Graph

With your Graph codec in the implicit scope, you are ready to encode your Graph by using syntactic sugar like

import scalax.collection.io.jsoniter.toJson
val json: String = graph.toJson

Decode your JSON

With your Graph codec in the implicit scope, you can decode your JSON to a Graph by simply

import scalax.collection.io.jsoniter.toGraph
val graph: G = json.toGraph[G]

where G stands for your Graph type.