Skip to content

Commit

Permalink
Merge branch 'jsonpatch'
Browse files Browse the repository at this point in the history
  • Loading branch information
mtmorgan committed Feb 24, 2024
2 parents a60bd23 + 6909112 commit 40b77c7
Show file tree
Hide file tree
Showing 12 changed files with 657 additions and 57 deletions.
8 changes: 5 additions & 3 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: rjsoncons
Title: 'C++' Header-Only 'jsoncons' Library for 'JSON' Queries
Version: 1.2.0.9401
Version: 1.2.0.9500
Authors@R: c(
person(
"Martin", "Morgan", role = c("aut", "cre"),
Expand All @@ -24,8 +24,10 @@ Description: The 'jsoncons'
(filter or transform) and pivot (convert from array-of-objects to
object-of-arrays, for easy import into 'R') 'JSON' or 'NDJSON'
strings or files using 'JSONpointer', 'JSONpath' or 'JMESpath'
expression. The 'jsoncons' library is also be easily linked to
other packages for direct access to 'C++' functionality.
expressions. The package also supports 'JSON' patch for
conveniently editing JSON documents. The 'jsoncons' library is
easily linked to other packages for direct access to 'C++'
functionality.
Imports: cli
Suggests: jsonlite, tibble, tinytest, BiocStyle, knitr, rmarkdown
LinkingTo: cpp11, cli
Expand Down
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

export(as_r)
export(j_data_type)
export(j_patch_apply)
export(j_patch_from)
export(j_path_type)
export(j_pivot)
export(j_query)
Expand Down
52 changes: 27 additions & 25 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,53 +1,55 @@
# rjsoncons 1.3.0

- (1.2.0.9401) internal C++ code cleanup and refactoring
- (1.2.0.9300) add 'Examples' web-only vignette
- (1.2.0.9201) restore progress bar on NDJSON parsing
- (1.2.0.9500) add JSON patch support with `j_patch_apply()`,
`j_patch_from()`.
- (1.2.0.9401) internal C++ code cleanup and refactoring.
- (1.2.0.9300) add 'Examples' web-only vignette.
- (1.2.0.9201) restore progress bar on NDJSON parsing.
- (1.2.0.9100) `as_r()` supports file and url connections; improved
connection implementation using C++ stream buffer.
- (1.2.0.9000) bug fix: support JSON `j_pivot()` file / url connections
- (1.2.0.9000) bug fix: support JSON `j_pivot()` file / url connections.

# rjsoncons 1.2.0

- (1.2.0) CRAN release
- (1.2.0) CRAN release.
- (1.1.0.9500) update documentation, include NDJSON-specific, web-only
vignette
- (1.1.0.9400) support NDJSON and file / url connections
- (1.1.0.9300) implement `j_query()` (query without requiring path
vignette.
- (1.1.0.9400) support NDJSON and file / url connections.
- (1.1.0.9300) implement `j_query()` (query without requiring path.
specification), `j_pivot()`, and `j_path_type()`. Remove
`jsonpivot()`.
- (1.1.0.9200) implement `jsonpointer()` for querying JSON documents.
- (1.1.0.9100) update jsoncons library to 173.2, relaxing compiler
requirements to c++11.
- (1.1.0.9000) implement `jsonpivot()` to transform JSON
- (1.1.0.9000) implement `jsonpivot()` to transform JSON.
array-of-objects to object-of-arrays, a common step before
representation as a data.frame.

# rjsoncons 1.1.0

- (1.1.0) CRAN release
- (1.0.1.9100) using jsonlite (e.g., 'toJSON()' for parsing R objects)
- (1.1.0) CRAN release.
- (1.0.1.9100) using jsonlite (e.g., 'toJSON()' for parsing R objects).
requires separate installation of jsonlite.
- (1.0.1.9000) update jsoncons library to 0.172.1; addresses segfault
on 'fedora' CRAN builder
on 'fedora' CRAN builder.

# rjsoncons 1.0.1

- (1.0.1) CRAN release
- (1.0.0.9200) use pkgdown
- (1.0.0.9100) parse JSON to R with `as = "R"` argument and `as_r()`
- (1.0.1) CRAN release.
- (1.0.0.9200) use pkgdown.
- (1.0.0.9100) parse JSON to R with `as = "R"` argument and `as_r()`.

# rjsoncons 1.0.0

- (1.0.0) initial CRAN release
- (0.0.99) pre-release version
- (0.0.3) support object names ordering 'asis' or 'sort'
- (1.0.0) initial CRAN release.
- (0.0.99) pre-release version.
- (0.0.3) support object names ordering 'asis' or 'sort'.
- (0.0.3) DESCRIPTION file updates: correct 'Title:' capitalization;
avoid warnings about most misspellings
avoid warnings about most misspellings.
- (0.0.3) Add GitHub action to rebuild README.md from
vignettes/rjsoncons.Rmd
- (0.0.2) jsoncons library update
- (0.0.2) support for R object query in addition to JSON string
- (0.0.2) add unit tests
- (0.0.2) R and minor C++ code refactoring
- (0.0.1) initial C++ / R implementation of `jmespath()` / `jsonpath()`
vignettes/rjsoncons.Rmd.
- (0.0.2) jsoncons library update.
- (0.0.2) support for R object query in addition to JSON string.
- (0.0.2) add unit tests.
- (0.0.2) R and minor C++ code refactoring.
- (0.0.1) initial C++ / R implementation of `jmespath()` / `jsonpath()`.
8 changes: 8 additions & 0 deletions R/cpp11.R
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Generated by cpp11: do not edit by hand

cpp_j_patch_apply <- function(data, data_type, patch, as) {
.Call(`_rjsoncons_cpp_j_patch_apply`, data, data_type, patch, as)
}

cpp_j_patch_from <- function(data_x, data_type_x, data_y, data_type_y, as) {
.Call(`_rjsoncons_cpp_j_patch_from`, data_x, data_type_x, data_y, data_type_y, as)
}

cpp_version <- function() {
.Call(`_rjsoncons_cpp_version`)
}
Expand Down
210 changes: 210 additions & 0 deletions R/patch.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
J_PATCH_OP <- c("add", "remove", "replace", "copy", "move", "test")

.is_j_patch_type <-
function(x)
{
identical(x[[1]], "json") || identical(x[[1]], "R")
}
.j_patch_data_from_connection <-
function(data, data_type)
{
con <- .as_unopened_connection(data, data_type)
open(con, "rb")
on.exit(close(con))
lines <- readLines(con, warn = FALSE)
paste0(trimws(lines), collapse = "\n")
}

.j_patch_patch_validate <-
function(patch)
{
## FIXME: use j_schema_validate() when available
bad_op <- character()
if (!j_query(patch, "type(@)") %in% "array") {
stop("'patch' must be a JSON array")
}
op <- j_query(patch, "[].op", as = "R")
bad_op <- setdiff(op, J_PATCH_OP)
if (length(bad_op)) {
stop(
"'patch' malformed:\n",
" op: ", toString(bad_op), "\n",
" not in: ", toString(J_PATCH_OP), "\n",
call. = FALSE
)
}

}

#' @rdname patch
#'
#' @title Patch or compute the difference between two JSON documents
#'
#' @description `j_patch_apply()` uses JSON Patch
#' <https://jsonpatch.com> to transform JSON 'data' according the
#' rules in JSON 'patch'.
#'
#' @param data JSON character vector, file, URL, or an *R* object to
#' be converted to JSON using `jsonline::fromJSON(data, ...)`.
#'
#' @param patch JSON 'patch' as character vector, file, URL, or *R*
#' object.
#'
#' @param as character(1) return type; `"string"` returns a JSON
#' string, `"R"` returns an *R* object using the rules in
#' `as_r()`.
#'
#' @param ... passed to `jsonlite::toJSON` when `data`, `patch`,
#' `data_x`, and / or `data_y` is an _R_ object. Usually, it is
#' appropriate to add the `jsonlite::toJSON()` argument
#' `auto_unbox = TRUE` when `patch` is an *R* object (because the
#' elements of the patch objects are scalar-valued, not arrays of
#' length 1).
#'
#' @return `j_patch_apply()` returns a JSON string or *R* object
#' representing 'data' patched according to 'patch'.
#'
#' @details
#'
#' For `j_patch_apply()`, 'patch' is a JSON array of objects. Each
#' object describes how the patch is to be applied. Simple examples
#' are available at <https://jsonpatch.com>, with verbs 'add',
#' 'remove', 'replace', 'copy' and 'test'. The 'path' element of each
#' operation is a JSON pointer; remember that JSON arrays are 0-based.
#'
#'
#' - `add` -- add elements to an existing document.
#' ```
#' {"op": "add", "path": "/biscuits/1", "value": {"name": "Ginger Nut"}}
#' ```
#' - `remove` -- remove elements from a document.
#' ```
#' {"op": "remove", "path": "/biscuits/0"}
#' ```
#' - `replace` -- replace one element with another
#' ```
#' {
#' "op": "replace", "path": "/biscuits/0/name",
#' "value": "Chocolate Digestive"
#' }
#' ```
#' - `copy` -- copy a path to another location.
#' ```
#' {"op": "copy", "from": "/biscuits/0", "path": "/best_biscuit"}
#' ```
#' - `move` -- move a path to another location.
#' ```
#' {"op": "move", "from": "/biscuits", "path": "/cookies"}
#' ```
#' - `test` -- test for the existence of a path; if the path does not
#' exist, do not apply any of the patch.
#' ```
#' {"op": "test", "path": "/best_biscuit/name", "value": "Choco Leibniz"}
#' ```
#'
#' The examples below illustrate a patch with one (a JSON array with a
#' single object) or several (a JSON array with several arguments)
#' operations. `j_patch_apply()` fits naturally into a pipeline
#' composed with `|>` to transform JSON between representations.
#'
#' @examples
#' data_file <- system.file(package = "rjsoncons", "extdata", "patch_data.json")
#'
#' ## add a biscuit
#' patch <- '[
#' {"op": "add", "path": "/biscuits/1", "value": {"name": "Ginger Nut"}}
#' ]'
#' j_patch_apply(data_file, patch, as = "R") |> str()
#'
#' ## add a biscuit and choose a favorite
#'patch <- '[
#' {"op": "add", "path": "/biscuits/1", "value": {"name": "Ginger Nut"}},
#' {"op": "copy", "from": "/biscuits/2", "path": "/best_biscuit"}
#' ]'
#' biscuits <- j_patch_apply(data_file, patch)
#' as_r(biscuits) |> str()
#'
#' @export
j_patch_apply <-
function(data, patch, as = "string", ...)
{
data_type <- j_data_type(data)
patch_type <- j_data_type(patch)
stopifnot(
## FIXME: support NDJSON
.is_j_patch_type(data_type),
.is_j_patch_type(patch_type),
as %in% c("string", "R")
)

if (.is_j_data_type_connection(data_type)) {
data <- .j_patch_data_from_connection(data, data_type)
data_type <- data_type[[1]]
}
if (.is_j_data_type_connection(patch_type)) {
data <- .j_patch_data_from_connection(patch, patch_type)
data_type <- data_type[[1]]
}

data <- .as_json_string(data, data_type, ...)
patch <- .as_json_string(patch, patch_type, ...)
.j_patch_patch_validate(patch)

result <- do_cpp(
cpp_j_patch_apply, NULL,
data, data_type, patch, as,
n_records = Inf, verbose = FALSE
)

result
}

#' @rdname patch
#'
#' @description `j_patch_from()` computes a JSON patch describing the
#' difference between to JSON documents.
#'
#' @param data_x As for `data`.
#'
#' @param data_y As for `data`.
#'
#' @return `j_patch_from()` returns a JSON string or *R* object
#' representing the difference between 'data_x' and 'data_y'.
#'
#' @examples
#' j_patch_from(biscuits, data_file, as = "R") |> str()
#'
#' @export
j_patch_from <-
function(data_x, data_y, as = "string", ...)
{
data_type_x <- j_data_type(data_x)
data_type_y <- j_data_type(data_y)
stopifnot(
## FIXME: support NDJSON
.is_j_patch_type(data_type_x),
.is_j_patch_type(data_type_y),
as %in% c("string", "R")
)

if (.is_j_data_type_connection(data_type_x)) {
data_x <- .j_patch_data_from_connection(data_x, data_type_x)
data_type_x <- data_type_x[[1]]
} else {
data_x <- .as_json_string(data_x, data_type_x, ...)
}
if (.is_j_data_type_connection(data_type_y)) {
data_y <- .j_patch_data_from_connection(data_y, data_type_y)
data_type_y <- data_type_y[[1]]
} else {
data_y <- .as_json_string(data_y, data_type_y, ...)
}

result <- do_cpp(
cpp_j_patch_from, NULL,
data_x, data_type_x, data_y, data_type_y,
as, n_records = Inf, verbose = FALSE
)

result
}
6 changes: 6 additions & 0 deletions inst/extdata/patch_data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"biscuits":[
{"name":"Digestive"},
{"name":"Choco Leibniz"}
]
}
55 changes: 55 additions & 0 deletions inst/tinytest/test_patch.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
data <- system.file(package = "rjsoncons", "extdata", "patch_data.json")
json <- paste0(trimws(readLines(data, warn = FALSE)), collapse = "")

## j_patch_apply()

expect_identical(j_patch_apply(json, '[]'), json)
expect_identical(j_patch_apply(json, '[]', as = "R"), as_r(json))

patch <- '[{"op": "remove", "path": "/biscuits"}]'
expect_identical(j_patch_apply(json, patch), "{}")

json_r = as_r(json) # 'data' is an R object
expect_identical(j_patch_apply(json_r, patch, auto_unbox = TRUE), "{}")

patch_r = as_r(patch) # 'patch' is an R object
expect_identical(j_patch_apply(json, patch_r, auto_unbox = TRUE), "{}")
expect_identical(j_patch_apply(json_r, patch_r, auto_unbox = TRUE), "{}")

patch <- '{"op": "remove", "path": "/biscuits"}' # not an array
expect_error(j_patch_apply(json, patch))

patch <- '[{"op": "remover", "path": "/biscuits"}]' # unknown op
expect_error(j_patch_apply(json, patch))

patch <- '[{"op": "remove", "path": "/biscuits10"}]' # unknown path
expect_error(j_patch_apply(json, patch))

## j_patch_from

expect_identical(j_patch_from(j_patch_apply(json, '[]'), json), '[]')
expect_identical(
j_patch_from(j_patch_apply(json, '[]'), json, as = "R"),
list()
)

patch <- '[{"op": "remove", "path": "/biscuits/1"}]'
expect_identical(
j_patch_from(j_patch_apply(json, patch), json),
'[{"op":"add","path":"/biscuits/1","value":{"name":"Choco Leibniz"}}]'
)
expect_identical(
j_patch_from(j_patch_apply(json, patch), json, as = "R"),
list(list(
op = "add", path = "/biscuits/1",
value = list(name = "Choco Leibniz")
))
)
expect_identical(
j_patch_from(j_patch_apply(json, patch, as = "R"), json, auto_unbox = TRUE),
'[{"op":"add","path":"/biscuits/1","value":{"name":"Choco Leibniz"}}]'
)
expect_identical(
j_patch_from(j_patch_apply(json, patch), json_r, auto_unbox = TRUE),
'[{"op":"add","path":"/biscuits/1","value":{"name":"Choco Leibniz"}}]'
)
Loading

0 comments on commit 40b77c7

Please sign in to comment.