Skip to content

Commit

Permalink
Add methods for querying JSON values
Browse files Browse the repository at this point in the history
This adds the type std.json.Query, used for querying JSON values without
the usual boilerplate. Take this JSON for example:

    {
      "name": "Alice",
      "address": {
        "street": "Sesame Street"
      }
    }

Using the new Query type, we can query the value like so:

    json_value.query.key('address').key('street').as_string

Querying is done using the Query.key and Query.index methods. Obtaining
the values as a certain type is done using Query.as_string,
Query.as_int, Query.as_float, Query.as_array, and Query.as_object.
There's no Query.as_null method as explicitly checking for NULL is
basically useless, and one should instead test for what value they _did_
expect (e.g. a string).

The query API methods move the Query object they are called on, removing
the need for allocating a new Query object for every step in the query.
This means the only allocations needed are those for the Option values
used as part of the querying process.

This fixes #356.

Changelog: added
  • Loading branch information
yorickpeterse committed Dec 16, 2023
1 parent e287956 commit 7f5bcac
Show file tree
Hide file tree
Showing 2 changed files with 238 additions and 0 deletions.
168 changes: 168 additions & 0 deletions std/src/std/json.inko
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,161 @@ impl ToString for Error {
}
}

# A type used to query/fetch data from a JSON value.
#
# Manually extracting values from JSON objects can be cumbersome. Take the
# following JSON for example:
#
# {
# "name": "Alice",
# "address": {
# "street": "Sesame Street"
# }
# }
#
# If we want to get the value of the `street` key, we'd have to write the
# following:
#
# match json {
# case Object(root) -> match root.opt('address') {
# case Some(Object(addr)) -> match addr.opt('street') {
# case Some(String(v)) -> Option.Some(v)
# case _ -> Option.None
# }
# case _ -> Option.None
# }
# case _ -> Option.None
# }
#
# In contrast, using the `Query` type we can instead write the following:
#
# json.query.key('address').key('street').as_string
#
# Querying is done using the methods `Query.key` to get an object key's value,
# and `Query.index` to get the value of an array index. Methods such as
# `Query.as_string` and `Query.as_int` are used to extract the final value as a
# certain type, if such a value is present.
class pub Query {
let @value: Option[ref Json]

# Returns a `Query` that matches the value assigned to the object key `name`,
# if the current value the query matches against is an object.
#
# # Examples
#
# import std.json.Json
#
# let map = Map.new
#
# map.set('name', 'Alice')
# Json.Object(map).query.key('name').as_string # => Option.Some('alice')
# Json.Int(42).query.key('name').as_string # => Option.None
fn pub move key(name: String) -> Query {
@value = match ref @value {
case Some(Object(v)) -> v.opt(name)
case _ -> Option.None
}

self
}

# Returns a `Query` that matches the value assigned to the array index
# `index`, if the current value the query matches against is an array.
#
# # Examples
#
# import std.json.Json
#
# Json.Array([Json.Int(10)]).query.index(0).as_int # => Option.Some(10)
# Json.Int(42).query.index(0).as_int # => Option.None
fn pub move index(index: Int) -> Query {
@value = match ref @value {
case Some(Array(v)) -> v.opt(index)
case _ -> Option.None
}

self
}

# Returns the value `self` matches against if it's a `String`.
#
# # Examples
#
# import std.json.Json
#
# Json.Int(42).query.as_string # => Option.None
# Json.String('test').query.as_string # => Option.Some('test')
fn pub move as_string -> Option[String] {
match @value {
case Some(String(v)) -> Option.Some(v)
case _ -> Option.None
}
}

# Returns the value `self` matches against if it's an `Int`.
#
# # Examples
#
# import std.json.Json
#
# Json.String('test').query.as_int # => Option.None
# Json.Int(42).query.as_int # => Option.Some(42)
fn pub move as_int -> Option[Int] {
match @value {
case Some(Int(v)) -> Option.Some(v)
case _ -> Option.None
}
}

# Returns the value `self` matches against if it's a `Float`.
#
# # Examples
#
# import std.json.Json
#
# Json.String('test').query.as_float # => Option.None
# Json.Float(42.0).query.as_float # => Option.Some(42.0)
fn pub move as_float -> Option[Float] {
match @value {
case Some(Float(v)) -> Option.Some(v)
case _ -> Option.None
}
}

# Returns the value `self` matches against if it's an `Object`.
#
# # Examples
#
# import std.json.Json
#
# let map = Map.new
#
# map.set('name', 'Alice')
# Json.Object(map).query.as_object # => Option.Some(...)
# Json.Int(42).query.as_object # => Option.None
fn pub move as_object -> Option[ref Map[String, Json]] {
match @value {
case Some(Object(v)) -> Option.Some(v)
case _ -> Option.None
}
}

# Returns the value `self` matches against if it's an `Array`.
#
# # Examples
#
# import std.json.Json
#
# Json.Array([Json.Int(42)]).query.as_array # => Option.Some(...)
# Json.Int(42).query.as_array # => Option.None
fn pub move as_array -> Option[ref Array[Json]] {
match @value {
case Some(Array(v)) -> Option.Some(v)
case _ -> Option.None
}
}
}

# A JSON value, such as `true` or an array.
class pub enum Json {
case Int(Int)
Expand Down Expand Up @@ -237,6 +392,19 @@ class pub enum Json {
fn pub to_pretty_string -> String {
Generator.new(DEFAULT_PRETTY_INDENT).generate(self)
}

# Returns a new `Query` that starts at `self`.
#
# See the documentation of the `Query` type for more information.
#
# # Examples
#
# import std.json.Json
#
# Json.Int(42).query.as_int # => Option.Some(42)
fn pub query -> Query {
Query { @value = Option.Some(self) }
}
}

impl ToString for Json {
Expand Down
70 changes: 70 additions & 0 deletions std/test/std/test_json.inko
Original file line number Diff line number Diff line change
Expand Up @@ -412,4 +412,74 @@ fn pub tests(t: mut Tests) {
t.true(Json.parse("\u{FFFE}10").error?)
t.true(Json.parse("\u{EF}\u{BB}\u{BF}10").error?)
}

t.test('Json.query') fn (t) {
t.equal(Json.Int(42).query.as_int, Option.Some(42))
t.equal(Json.String('test').query.as_int, Option.None)
}

t.test('Query.key') fn (t) {
let map = Map.new

map.set('name', Json.String('Alice'))

let obj = Json.Object(map)

t.equal(obj.query.key('name').as_string, Option.Some('Alice'))
t.equal(obj.query.key('city').as_string, Option.None)
t.equal(Json.Int(42).query.key('name').as_string, Option.None)
}

t.test('Query.index') fn (t) {
t.equal(Json.Array([Json.Int(42)]).query.index(0).as_int, Option.Some(42))
t.equal(Json.Array([Json.Int(42)]).query.index(1).as_int, Option.None)
t.equal(Json.Int(42).query.index(0).as_int, Option.None)
}

t.test('Query.as_int') fn (t) {
t.equal(Json.String('test').query.as_int, Option.None)
t.equal(Json.Float(1.2).query.as_int, Option.None)
t.equal(Json.Null.query.as_int, Option.None)
t.equal(Json.Array([]).query.as_int, Option.None)
t.equal(Json.Object(Map.new).query.as_int, Option.None)
t.equal(Json.Int(42).query.as_int, Option.Some(42))
}

t.test('Query.as_float') fn (t) {
t.equal(Json.String('test').query.as_float, Option.None)
t.equal(Json.Null.query.as_float, Option.None)
t.equal(Json.Int(42).query.as_float, Option.None)
t.equal(Json.Array([]).query.as_float, Option.None)
t.equal(Json.Object(Map.new).query.as_float, Option.None)
t.equal(Json.Float(1.2).query.as_float, Option.Some(1.2))
}

t.test('Query.as_string') fn (t) {
t.equal(Json.Null.query.as_string, Option.None)
t.equal(Json.Int(42).query.as_string, Option.None)
t.equal(Json.Float(1.2).query.as_string, Option.None)
t.equal(Json.Array([]).query.as_string, Option.None)
t.equal(Json.Object(Map.new).query.as_string, Option.None)
t.equal(Json.String('test').query.as_string, Option.Some('test'))
}

t.test('Query.as_array') fn (t) {
t.equal(Json.Null.query.as_array, Option.None)
t.equal(Json.Int(42).query.as_array, Option.None)
t.equal(Json.Float(1.2).query.as_array, Option.None)
t.equal(Json.Object(Map.new).query.as_array, Option.None)
t.equal(Json.String('test').query.as_array, Option.None)
t.equal(
Json.Array([Json.Int(42)]).query.as_array, Option.Some(ref [Json.Int(42)])
)
}

t.test('Query.as_object') fn (t) {
t.equal(Json.Null.query.as_object, Option.None)
t.equal(Json.Int(42).query.as_object, Option.None)
t.equal(Json.Float(1.2).query.as_object, Option.None)
t.equal(Json.String('test').query.as_object, Option.None)
t.equal(Json.Array([Json.Int(42)]).query.as_object, Option.None)
t.equal(Json.Object(Map.new).query.as_object, Option.Some(ref Map.new))
}
}

0 comments on commit 7f5bcac

Please sign in to comment.