All Projects → nrktkt → ninny-json

nrktkt / ninny-json

Licence: Unlicense license
JSON typeclasses that know the difference between null and absent fields

Programming Languages

HTML
75241 projects
scala
5932 projects
shell
77523 projects

Projects that are alternatives of or similar to ninny-json

toast
Plugin-driven CLI utility for code generation using Go source as IDL
Stars: ✭ 52 (+173.68%)
Mutual labels:  ast
pgsql-ast-parser
Yet another simple Postgres SQL parser
Stars: ✭ 152 (+700%)
Mutual labels:  ast
about-Vue
📔 Vue 源码的探讨和学习
Stars: ✭ 56 (+194.74%)
Mutual labels:  ast
ts-transform-react-constant-elements
A TypeScript AST Transformer that can speed up reconciliation and reduce garbage collection pressure by hoisting React elements to the highest possible scope.
Stars: ✭ 44 (+131.58%)
Mutual labels:  ast
abstract-syntax-tree
A library for working with abstract syntax trees.
Stars: ✭ 77 (+305.26%)
Mutual labels:  ast
cppcombinator
parser combinator and AST generator in c++17
Stars: ✭ 20 (+5.26%)
Mutual labels:  ast
predeclared
Find definitions and declarations in Go source code that shadow predeclared identifiers
Stars: ✭ 26 (+36.84%)
Mutual labels:  ast
konan
find all require/import calls by walking the AST
Stars: ✭ 48 (+152.63%)
Mutual labels:  ast
py2many
Transpiler of Python to many other languages
Stars: ✭ 420 (+2110.53%)
Mutual labels:  ast
human-parser-generator
A straightforward recursive descent Parser Generator with a focus on "human" code generation and ease of use.
Stars: ✭ 27 (+42.11%)
Mutual labels:  ast
ts-transform-react-jsx-source
TypeScript AST Transformer that adds source file and line number to JSX elements
Stars: ✭ 12 (-36.84%)
Mutual labels:  ast
clickhouse-ast-parser
AST parser and visitor for ClickHouse SQL
Stars: ✭ 60 (+215.79%)
Mutual labels:  ast
ctxexp-parser
In the dynamic execution of JS language environment (wechat applet) to execute JS class calling function.
Stars: ✭ 17 (-10.53%)
Mutual labels:  ast
markright
A customizable markdown parser in Elixir: pure pattern matching.
Stars: ✭ 14 (-26.32%)
Mutual labels:  ast
lectures
Learn Functional Programming in Scala
Stars: ✭ 114 (+500%)
Mutual labels:  typeclasses
AUXify
Introduces macro/meta annotations @ aux, @ self, @ instance, @ apply, @ delegated, @ syntax and String-based type class LabelledGeneric
Stars: ✭ 25 (+31.58%)
Mutual labels:  typeclasses
tree-hugger
A light-weight, extendable, high level, universal code parser built on top of tree-sitter
Stars: ✭ 96 (+405.26%)
Mutual labels:  ast
vscode-blockman
VSCode extension to highlight nested code blocks
Stars: ✭ 233 (+1126.32%)
Mutual labels:  ast
ast-grep
🔍 Like grep, but more powerful than you can possibly imagine
Stars: ✭ 14 (-26.32%)
Mutual labels:  ast
CastXMLSuperbuild
Build CastXML and its dependencies (LLVM/Clang)
Stars: ✭ 32 (+68.42%)
Mutual labels:  ast

None Is Not Null

ninny-json is an experiment to look at what JSON type classes would look like if they made a distinction between absent JSON fields, and fields with null values.
This project does include its own AST, but the point here is really not to introduce a new AST or look at the ergonomics of manipulating the AST directly. Thus, the AST included is kept simple.
Why not use json4s, the project created to provide one unifying AST? Read on.

Why does this matter?

In principle, we want our libraries to be as expressive as possible.
In practice, the limitations of libraries today make it hard or impossible to implement things like JSON merge patch or JSON-RPC. Whether a field will be included in the final JSON is also left up to the configuration of the JSON serializer (whether to include nulls or not) rather than the AST. When the AST doesn't match the JSON output, testability issues can open up.

Jump to proposal

User Guide / Getting Started

What do libraries do today?

Let's look at three popular libraries and see how they deal with converting Option[A] to and from JSON.

json4s

json4s uses the following type classes

trait Reader[T] {
  def read(value: JValue): T
}
trait Writer[-T] {
  def write(obj: T): JValue
}

These are fairly standard and pretty similar to Play JSON, with the difference that they throw exceptions.

Interestingly json4s includes a JNothing in its AST. Technically there is no such thing as "nothing" in JSON, but I can see how it would allow for maximum flexibility with other JSON libraries given that's the goal of json4s.

JNothing would let us distinguish between None and a missing field. However, the default Writer[Option[A]] doesn't leverage JNothing, rather it just writes None as JNull. The default reader for Option on the other hand just aggregates a failure to parse for any reason into None.

Pros

  • It is technically possible to distinguish null from absent both when reading and writing JSON.

Cons

  • Default readers/writers don't distinguish null from absent.
  • JNothing makes for a strange AST. We can imagine bugs where
    myObj.obj.map(_._1).contains("myField") // true
    // and yet
    myObj \ "myField" // JNothing
    Some might suggest "well you should have done myObj \ "myField" != JNothing instead", but ideally that's a mistake that wouldn't compile.

Play JSON

Play JSON uses the type classes

trait Writes[A] { 
  def writes(o: A): JsValue
}
trait Reads[A] {
  def reads(json: JsValue): JsResult[A]
}

with a more standard AST.

It does provide a Writes[Option[A]], which writes None as null. However, there is no Reads[Option[A]] since the type class has no way to know if the field was missing.

The nice thing about Play JSON is the macro based type class derivation, so you can just write implicit val format = Json.format[MyModel]. Now you might think "well, that's not very useful if there is no Reads[Option]" and MyModel can't have any optional fields. However, that's not the case, and the macro generated code will read an Option using some internal logic. This works for the common use case, but if we want to distinguish between an absent field and some null, then we can't use the automatic format because we need access to the fields on the outer object.

Reads(root => JsSuccess(MyClass((root \ "myField") match {
  case JsDefined(JsNull) => Null
  case JsDefined(value) => Defined(value)
  case JsUndefined() => Absent
})))

Pros

  • Automatic format derivation (although circe will call it semi-automatic)

Cons

  • Inconsistent handling of Option between Reads and Writes.
  • If we want to take direct control, we lose the composability of type classes.

circe

circe uses the type classes

trait Encoder[A] { 
  def apply(a: A): Json
}
trait Decoder[A] {
  def apply(c: HCursor): Decoder.Result[A]
  def tryDecode(c: ACursor): Decoder.Result[A] = c match {
    case hc: HCursor => apply(hc)
    case _ =>
      Left(
        DecodingFailure("Attempt to decode value on failed cursor", c.history)
      )
  }
}

The Encoder here is the same as we've seen in the others (and it also encodes None as null), but the Decoder is interesting. Since circe uses cursors to move around the JSON, there is an ACursor which has the ability to tell us that the cursor was unable to focus on the field we're trying to decode (the field wasn't there). circe can and does use this to decode missing fields into None, and we can use it to distinguish null from absent fields.

new Decoder[FieldPresence[A]] {
  def tryDecode(c: ACursor) = c match {
    case c: HCursor =>
      if (c.value.isNull) Right(Null)
      else
        d(c) match {
          case Right(a) => Right(Defined(a))
          case Left(df) => Left(df)
        }
    case c: FailedCursor =>
      if (!c.incorrectFocus) Right(Absent) 
      else Left(DecodingFailure("[A]Option[A]", c.history))
  }
}

Because this is a Decoder for the value rather than the object containing the value, we can still use circe's awesome fully automatic type class generation which doesn't even require us to invoke a macro method like we do in Play.

Sadly there is nothing we can do with the Encoder to indicate that we don't want our field included in the output.

Pros

  • Decoder can distinguish between null and absent fields.

Cons

  • Encoder can't output an indication that the field should be absent.
  • Cursors might be intimidating to the uninitiated.

What are we proposing?

Now that we have the lay of the land, what are we proposing to shore up the cons without losing the pros?

Two simple type classes (the signatures are what matter, not the names)

trait ToJson[A] {
  // return None if the field should not be included in the JSON
  def to(a: A): Option[JsonValue]
}
trait FromJson[A] {
  // None if the field was not present in the JSON
  def from(maybeJson: Option[JsonValue]): Try[A]
}

note: Try and Option aren't strictly required, anything that conceptually conveys the possibility of failure and absence will work.

ToJson[Option[A]] is implemented predictably

new ToJson[Option[A]] {
  def to(a: Option[A]) = a.flatMap(ToJson[A].to(_))
}

FromJson[Option[A]] is pretty straightforward as well

new FromJson[Option[A]] {
  def from(maybeJson: Option[JsonValue]) = maybeJson match {
    case Some(JsonNull) => Success(None)
    case Some(json)     => FromJson[A].from(json).map(Some(_))
    case None           => Success(None)
  }
}

If we want to distinguish between a null and absent field

new FromJson[FieldPresence[A]] {
  def from(maybeJson: Option[JsonValue]) = Success(maybeJson match {
    case Some(JsonNull) => Null
    case Some(json)     => Defined(json)
    case None           => Absent
  })
}

How are we doing with our pros and cons?

  • Able to distinguish null from absent fields when reading and writing JSON from inside the type class.
  • AST is predictable and closely models JSON.
  • We can automatically (or semi-automatically) derive type classes using shapeless.
  • Option is handled in the same way when reading and writing.

Ergonomic improvements

Always dealing with Option could get annoying, so some simple additions can alleviate that

FromJson

Addition of a method that takes the AST directly saves us from having to constantly invoke Some().

def from(json: JsonValue): Try[A]

ToJson

Some types (like String) will always result in a JSON output. Instances for those types can be implemented with ToSomeJson to remove the Option from created AST.

trait ToSomeJson[A] extends ToJson[A] {
  def toSome(a: A): JsonValue
  override def to(a: A) = Some(toSome(a))
}

Example

An example of updating a user profile which clears one field, sets the value of another, and leaves a third unchanged without overwriting it with the existing value.

Note that the project description data, including the texts, logos, images, and/or trademarks, for each open source project belongs to its rightful owner. If you wish to add or remove any projects, please contact us at [email protected].