tsJson Package
Author: thosti.
The tsJson contributed package reads, builds and writes JSON, finds values in a document with JSON Pointer and JSONPath, changes documents with JSON Patch and JSON Merge Patch, validates them against a JSON Schema, and converts ISO 8601 dates. The package describes itself as “Parse, query, patch, validate - one JSON model.” This section documents version 1.0.15.0, published under the MIT licence.
All of it works on one model: a JSON object is a JsonObject, a JSON array is a JsonArray, and the other JSON values are plain Variant values. The Json module is the entry point, with a function for each task. JsonOptions holds the options of a call, and JsonError reports what went wrong.
Dim doc As Variant = Json.Parse("{""store"":{""book"":[{""title"":""A"",""price"":8.95},{""title"":""B"",""price"":12.99}]}}")
If JsonError.HasError Then
Debug.Print JsonError.ToString()
Else
Debug.Print Json.QueryOne(doc, "$.store.book[?@.price < 10].title") ' A
Debug.Print Json.PointerGet(doc, "/store/book/1/title") ' B
Dim cheaper As Variant = Json.PointerSet(doc, "/store/book/1/price", 9.5)
Debug.Print Json.Serialize(Json.Diff(doc, cheaper), JsonOptions.Minified) ' [{"op":"replace","path":"/store/book/1/price","value":9.5}]
Dim report As JsonValidationResult = Json.Validate(doc, Json.Parse("{""type"":""array""}"))
Debug.Print report.ToString() ' type / - Type is not 'array'.
End If
The JSON model
A value of JSON has these counterparts in twinBASIC. A function that reads JSON creates them, and a function that writes JSON accepts them.
| JSON | In twinBASIC | Notes |
|---|---|---|
| object | JsonObject | Members in the order they were added. Keys are compared by character code, so a and A are different keys. |
| array | JsonArray | Elements numbered from 0. |
| string | String | |
| number | Decimal when read; any numeric type when written | A Decimal keeps every digit. The package rejects a number outside the range of Decimal (see Parse). |
true, false | Boolean | |
null | Null | Not Empty and not Nothing. |
A Date is not a JSON value. Json.Serialize writes one as an ISO 8601 string in local time, and Json.Parse never creates one: a string that holds a date stays a string until Json.ParseIso8601 converts it. Empty, Nothing, and any object that is not a JsonObject or a JsonArray, are not JSON values either, and Json.Serialize refuses them with the error UnsupportedType. Json.TypeOfValue classifies a value.
A value that is read from a document is the object or array inside it, not a copy. Changing it changes the document. Json.Clone makes a deep copy, and the functions that return a changed document (PointerSet, PointerRemove, Patch, MergePatch) work on a copy and leave their argument as it was.
Where a function returns a list, the array is unbound when the list is empty, and UBound raises run-time error 9 for it. This applies to Json.Query, Json.QueryHits, JsonObject.Keys, JsonObject.Items, JsonArray.ToVariantArray and JsonValidationResult.Errors. Json.Serialize writes such an array as [].
Errors
The package does not raise run-time errors for bad data or bad arguments. A function that fails returns an empty result (Empty, Nothing, an empty string or False) and sets the last error, which the predeclared object JsonError reports: HasError is True, Code is a short name such as InvalidSyntax, and Message, Origin, the position in the text and the JSON Pointer of the place say more. A function that can fail clears the last error when it starts, so the last error belongs to the latest call. A search that finds nothing, and an invalid regular expression given to a pattern function, are not errors. JsonError lists the codes.
Dim v As Variant = Json.Parse("[1, 2,]")
Debug.Print IsEmpty(v) ' True
Debug.Print JsonError.Code ' InvalidSyntax
Debug.Print Json.TryParse("[1, 2,]", v, JsonOptions.Jsonc) ' True
Debug.Print Json.Serialize(v, JsonOptions.Minified) ' [1,2]
The package itself raises a run-time error on purpose in one case: an assignment to a property of the predeclared JsonOptions object, which is a factory (error 5). The other run-time errors that a program meets are those of the language, such as error 91 for a call on the Nothing that a failed function returned, error 424 for the assignment of the Empty that a failed Parse returned to a variable of a class type, and error 9 for UBound of an unbound array. One more comes from Serialize, which raises error -2147467259 for a two-dimensional array, and leaves the last error stuck (see Known problems).
The standards
| Standard | What the package provides |
|---|---|
| RFC 8259, JSON | Parse and Serialize, with limits on size and depth, and the JSONC dialect (comments and a trailing comma) as an option. |
| NDJSON, JSON Lines | One value on each line: ParseLines and SerializeLines, and the same for files. |
| RFC 6901, JSON Pointer | PointerGet, PointerSet and the other pointer functions. |
| RFC 9535, JSONPath | Query and the other query functions, and CompilePath for a path that is used again. The syntax and its departures from the RFC are on the page of JsonPathQuery. |
| RFC 9485, I-Regexp | The regular expressions of JSONPath and of schema patterns, and IsMatchPattern and its two partners. |
| RFC 6902, JSON Patch | Patch, and Diff to make a patch. |
| RFC 7396, JSON Merge Patch | MergePatch. |
| JSON Schema, draft 2020-12 | Validate and CompileSchema, with a local catalogue of documents. JsonSchema lists the keywords that are checked. |
| ISO 8601 | ParseIso8601 and FormatIso8601. |
The package reads and writes text, byte arrays (UTF-8, UTF-16 little endian and big endian, with a byte order mark detected) and files, and always writes UTF-8. It has no streaming interface and no mapping of JSON onto user-defined types.
Where the package departs from the standards
Each departure is described where it applies; this is the list.
- RFC 8259. The reading is strict. A number that a Decimal cannot hold is an error, which the RFC allows an implementation to do (Parse). The package accepts a byte order mark in bytes but not at the start of a String.
- RFC 6901. The form of a pointer as a URI fragment,
#/a/b, is not accepted. An empty token after another token names the wrong place (a defect, in Known problems). - RFC 6902. A patch can be a single operation object, which the RFC does not allow. The operations behave as the RFC says in every case that was tested: the add, remove, replace, move, copy and test operations, the index rules for arrays and the handling of the root.
- RFC 7396. None found.
- RFC 9535. A comparison with a missing value is false,
<=and>=are false for equal values that are not numbers or strings, a call of an unknown function is not an error, a number literal in a filter must be an integer,lengthcounts UTF-16 code units, and the results are values and pointers, not normalized paths. The first three are defects (JsonPathQuery). - RFC 9485. The matcher accepts
\d,\sand\w, which the RFC leaves out, and does not accept\n,\r,\t,\-,\^or a hyphen after a single character at the end of a set, which the RFC has. It does not try the other alternatives of a choice once one has matched (Patterns). - JSON Schema, draft 2020-12.
patternuses I-Regexp, not ECMA-262 regular expressions; lengths are counted in UTF-16 code units;formatis checked loosely, andipv4wrongly; some keywords are not evaluated (JsonSchema). - ISO 8601. Only the extended forms with a date, or with a date and a time with seconds, are read (ParseIso8601).
Compared with the other JSON sections of this reference: VBA_JSON and the cJson class of TBMANLIB (which uses the VBA-JSON code) return Dictionary and Collection objects, read numbers as Double or, when long, as String, and raise run-time error 10001 for text they cannot read, while VBA-JSON does not check for commas between items and ignores text after the value. tsJson has its own classes, reads numbers as Decimal, accepts only valid JSON unless JSONC is asked for, and reports errors through JsonError.
Classes and modules
- Json – the module that builds, parses and serializes values and applies pointers, queries, patches, schemas and dates
- Map, Arr, TypeOfValue, IsObject, IsArray, Clone, Equals – build, classify, copy and compare values
- Parse, TryParse, Serialize, SerializeToUtf8 – text and bytes
- ParseFile, TryParseFile, WriteFile – files
- ParseLines, TryParseLines, SerializeLines, ParseLinesFile, TryParseLinesFile, WriteLinesFile – NDJSON
- PointerGet, PointerExists, PointerTryGet, PointerSet, PointerRemove, PointerEscape, PointerUnescape – JSON Pointer
- CompilePath, Query, QueryOne, TryQueryOne, QueryHits – JSONPath
- IsValidPattern, IsMatchPattern, IsSearchPattern – I-Regexp
- Patch, MergePatch, Diff – JSON Patch and Merge Patch
- Validate, CompileSchema – JSON Schema
- ParseIso8601, FormatIso8601 – dates
- eJsonType – the JSON types
- JsonObject – a JSON object
- JsonArray – a JSON array
- JsonOptions – the options of a call; its predeclared object creates the presets
- Rfc, Jsonc, Minified, Pretty, Clone – the presets and the copy
- the 15 properties: DuplicateKeys, Encoding, EnsuresAscii, EscapesSolidus, IncludesBom, Indent, IsFormatAssertion, MaxArrayItems, MaxBytes, MaxDepth, MaxObjectMembers, MaxStringLength, Newline, SortsKeys, Syntax
- eJsonDuplicateKeys, eJsonEncodingKind, eJsonSyntax – the enumerations
- JsonError – the predeclared object that reports the last error; it also lists the error codes
- JsonPathQuery – a compiled JSONPath, and the JSONPath syntax
- Source, Query, QueryHits, QueryOne, TryQueryOne – its members
- JsonHit – one result of a JSONPath query, with its pointer
- JsonSchema – a compiled schema with a local catalogue, and the keywords that are checked
- AddDocument, Validate – its members
- JsonValidationResult – the report of a validation
- ErrorCount, Errors, IsValid, ToString – its members
- JsonValidationError – one failure of a validation, and the table of messages
- InstancePointer, Keyword, Message, SchemaPointer, ToString – its members
Test code
The package also contains its own test code, which has no pages: the modules Tests, TestsCore, TestsParse, TestsSerialize, TestsIo, TestsDates, TestsPointer, TestsPath, TestsPatch, TestsSchema, TestsRegexp, TestsExtNst, TestsExtNstData, TestsSmoke and TestsTour. Tests.RunAll runs twelve areas, Core, Parse, Serialize, Io, Dates, Pointer, Path, Patch, Schema, Regexp, ExtNst and Smoke, and prints the result of each check with Debug.Print, so the lines appear in the DEBUG CONSOLE of the IDE and nowhere else. Each check prints OK, FAIL or SKIP, a failed check does not stop the run, and the last lines give the counts of each area and in total and say that the tests passed or failed. The Io area and the demonstration write small files to the folder named by %TEMP% and delete them. The ExtNst area (TestsExtNst, with its data in TestsExtNstData) runs the accept and reject cases of the public JSONTestSuite against Parse, and skips the cases that the package decides differently: documents with a repeated key, which are an error by default, and numbers outside the range of Decimal. Tests.RunDemo runs TestsTour, which demonstrates the package on one order document and prints each result as pretty-printed JSON, with the calls that made it as comment lines in front. In version 1.0.15.0 a run has 714 checks that pass, none that fail and 6 that are skipped, and it ends with the line === tsJson tests passed followed by = characters. The areas have 96 (Core), 66 (Parse), 22 (Serialize), 23 (Io), 21 (Dates), 29 (Pointer), 38 (Path), 34 (Patch), 62 (Schema), 38 (Regexp), 278 (ExtNst) and 7 (Smoke) checks that pass; the six skipped checks are in ExtNst. The tests pass although the problems below exist, because no check covers them.
Internal modules
Nineteen more modules and classes are part of the package’s implementation and have no pages. The package describes all of them except JsonLimits as not part of its public API: JsonBuf (the string builder), JsonEncoding (the UTF-8 and UTF-16 codecs), JsonErrorStore (the storage behind JsonError), JsonIo (the file access), JsonIso8601 (the date conversion behind ParseIso8601), JsonLexer, JsonParser and JsonText (the JSON reader), JsonSerializer (the writer), JsonValues (helpers shared by JsonObject and JsonArray), JsonPointer, JsonPatch, JsonPath with its node class JsonPathNode, and JsonSchemaEngine, which implement the functions of Json that are named after them, JsonReNode (a node of a compiled regular expression), JsonUnicodeCats (the table of Unicode categories for \p{...}) and JsonIRegexp, the I-Regexp matcher that IsMatchPattern and its partners call. JsonLimits holds the constants of the default limits, which JsonOptions lists with their values. None of them is declared Private, so a project that references the package can name them.
Debug.Print JsonLimits.DEFAULT_MAX_DEPTH ' 1000
Debug.Print JsonLimits.DEFAULT_MAX_BYTES ' 16777216
The test modules are public in the same way, so their names, such as Tests, are visible to the whole project. A module of the project with the same name takes precedence, with no error: in a project with its own module Tests, a call of Tests.Run reaches the project’s module, and the package’s Tests can no longer be named.
Module Tests
Public Function Run() As String
Return "mine"
End Function
End Module
Debug.Print Tests.Run() ' mine
Known problems in version 1.0.15.0
Warning
In version 1.0.15.0 these problems give a wrong result with no error.
- A choice in a regular expression is not tried again. When one alternative matches and the rest of the pattern then fails, the matcher does not try the other alternatives, so
a|abdoes not matchab, and(Mr|Mrs) Smithdoes not matchMrs Smith, although(Mrs|Mr) Smithmatches both. RFC 9485 defines the match of the whole pattern. A choice whose alternatives start the same way works when the longer alternative comes first. The matcher serves IsMatchPattern, IsSearchPattern, the JSONPath functionsmatchandsearch, and the schema keywordspatternandpatternProperties. \n,\rand\tare not understood. RFC 9485 has these escapes. Outside a character set the pattern is invalid, and inside a set[\n]means the lettern. Write the control character itself in the pattern string, for exampleChrW(9), which is an ordinary character in the dialect. The escapes\-and\^, which the RFC also has, are invalid outside a set, and[a-]is invalid, although the RFC allows a last hyphen; put the hyphen first, as in[-a].- A schema
patterncannot be anchored. The dialect has no^and$: they are ordinary characters, so^[a-z]+$needs a^and a$in the string. The JSON Schema draft names regular expressions in which they are anchors, so a string such asabcfails apatternthat was written for them. - A JSONPath filter compares a missing value differently from RFC 9535. A comparison is false whenever one side selects no value, whatever the operator is, so
[?@.a != 1]leaves out the elements that have noa, and[?@.a == @.b]leaves out an element that has neither, where the RFC selects both.<=and>=are false for two equal booleans, nulls or arrays. A call of a function that does not exist is not an error: the filter is false for every element. JsonPathQuery lists these and the other departures. - A JSON Pointer with an empty token after another token names the wrong place. The token is read as a copy of the token before it, so
/a/is read as/a/a: PointerGet returns the wrong value, and PointerSet, PointerRemove and Patch change the wrong member. The pointers that QueryHits returns for a member with an empty key are affected. The code declares a variable inside a loop and relies on it being empty in each pass; twinBASIC, like VBA, keeps its value from the pass before. - The
ipv4format rejects almost every valid address when IsFormatAssertion is True, for the same reason: the number of the previous part is not set back to 0. - A run-time error that escapes from a function stops the last error from being cleared. Json.Serialize raises error -2147467259 for a two-dimensional array. The function has by then marked the call as nested, and the mark stays: from then on a function neither clears the last error when it starts nor sets its origin, so JsonError.HasError stays True after a call that worked, and the origin of an error is
Json.Serialize. The callJsonErrorStore.LeaveNested, of an internal module, ends the mark.
Debug.Print Json.IsMatchPattern("ab", "a|ab") ' False
Debug.Print Json.IsMatchPattern("Mrs Smith", "(Mr|Mrs) Smith") ' False
Debug.Print Json.IsMatchPattern("Mrs Smith", "(Mrs|Mr) Smith") ' True
Debug.Print Json.IsValidPattern("a\tb") ' False
Debug.Print Json.IsMatchPattern("n", "[\n]") ' True
Debug.Print Json.IsMatchPattern(vbLf, "[\n]") ' False
Debug.Print Json.IsValidPattern("a\-b") ' False
Debug.Print Json.IsValidPattern("[a-]") ' False
Debug.Print Json.Validate("abc", Json.Parse("{""pattern"":""^[a-z]+$""}")).IsValid ' False
Dim doc As Variant = Json.Parse("[{""a"":1},{""a"":2},{""b"":3}]")
Debug.Print Json.Serialize(Json.Query(doc, "$[?@.a != 1]"), JsonOptions.Minified) ' [{"a":2}]
Debug.Print Json.Serialize(Json.Query(Json.Parse("[{""c"":1}]"), "$[?@.a == @.b]"), JsonOptions.Minified) ' []
Debug.Print Json.Serialize(Json.Query(doc, "$[?unknown(@.a)]"), JsonOptions.Minified) ' []
Debug.Print JsonError.HasError ' False
Dim doc As Variant = Json.Parse("{""a"":{""a"":1,"""":2}}")
Debug.Print Json.PointerGet(doc, "/a/")
Debug.Print Json.Serialize(Json.PointerSet(doc, "/a/", 9), JsonOptions.Minified)
Debug.Print Json.Serialize(Json.PointerRemove(doc, "/a/"), JsonOptions.Minified)
Dim hit As JsonHit = Json.QueryHits(doc, "$.a['']")(0)
Debug.Print hit.Pointer & " " & hit.Value & " " & Json.PointerGet(doc, hit.Pointer)
Dim i As Long
For i = 1 To 3
Dim n As Long
n += 1
Debug.Print n
Next
' Output:
' 1
' {"a":{"a":9,"":2}}
' {"a":{"":2}}
' /a/ 2 1
' 1
' 2
' 3
Dim o As JsonOptions = JsonOptions.Rfc
o.IsFormatAssertion = True
Dim ip As Variant = Json.Parse("{""format"":""ipv4""}")
Debug.Print Json.Validate("0.0.0.0", ip, o).IsValid ' True
Debug.Print Json.Validate("1.2.3.4", ip, o).IsValid ' False
Debug.Print Json.Validate("192.168.1.1", ip, o).IsValid ' False
Dim grid(0 To 1, 0 To 1) As Variant
JsonError.Clear
Dim text As String
On Error Resume Next
text = Json.Serialize(grid, JsonOptions.Minified)
Debug.Print Err.Number & " " & Err.Description ' -2147467259 Unspecified error
Err.Clear
On Error GoTo 0
Dim v As Variant = Json.Parse("{")
v = Json.Parse("[1]")
Debug.Print JsonError.HasError & " " & JsonError.Code & " " & JsonError.Origin ' True UnexpectedEnd Json.Serialize
JsonErrorStore.LeaveNested
v = Json.Parse("[1]")
Debug.Print JsonError.HasError ' False
Note
The package has three more problems, each of which is an error that the reader cannot miss.
- Numbers must fit a Decimal. Parse rejects a valid JSON number with the error
InvalidNumberwhen its digits make a whole number above 79228162514264337593543950335, or its scale is outside -28 to 28, so1e29,1e-29, a number of 36 digits such as pi, and the largest Double,1.7976931348623157e308, are errors. Serialize fails the same way for a Double above that range. RFC 8259 allows an implementation to limit the range of numbers. - A JSONPath filter takes integer literals only. RFC 9535 allows a fraction and an exponent.
[?@.price < 10.5]is the errorInvalidPath. Compare with a whole number, or compare strings. Tests.SetContinueOnFailhas no effect. A failed check never stops Tests.RunAll, whatever the setting.
Debug.Print IsEmpty(Json.Parse("1e29")) ' True
Debug.Print JsonError.Code ' InvalidNumber
Debug.Print IsEmpty(Json.Parse("1.7976931348623157e308")) ' True
Debug.Print IsEmpty(Json.Parse("1e28")) ' False
Debug.Print Json.CompilePath("$[?@.price < 10.5]") Is Nothing ' True
Debug.Print JsonError.Code ' InvalidPath
The first check of this sample fails on purpose. The second check still runs after SetContinueOnFail False:
Tests.BeginRun
Tests.SetContinueOnFail False
Tests.CheckTrue "first", False
Tests.CheckTrue "second", True
Debug.Print Tests.FailCount()
' Output:
' === tsJson tests ===========================================
' FAIL first
' expected: True
' actual: False
' OK second
' 1
See Also
- Importing a package from TWINSERV – adding a published package to a project
- VBA_JSON package – JSON parsing and writing with Dictionary and Collection objects
- cJson class – the JSON class of TBMANLIB