From 14524b31048527f67e4e782fd0dc5857c0020a80 Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 3 Aug 2020 01:27:35 -0700 Subject: [PATCH] Initial release --- CHANGELOG.md | 7 ++- README.md | 103 +++++++++++++++++++++++++++++++---- lib/src/json_path.dart | 5 +- lib/src/parser.dart | 8 ++- lib/src/parser_state.dart | 28 +++++++--- lib/src/predicate.dart | 1 + lib/src/selector/filter.dart | 20 +++++++ pubspec.yaml | 2 +- test/json_path_test.dart | 27 +++++++++ 9 files changed, 178 insertions(+), 23 deletions(-) create mode 100644 lib/src/predicate.dart create mode 100644 lib/src/selector/filter.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 25dde3a..73c8610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ ## [Unreleased] +## [0.0.1] - 2020-08-03 +### Added +- Filters + ## [0.0.0+dev.7] - 2020-08-02 ### Changed - Tokenized and AST refactoring @@ -33,7 +37,8 @@ ### Added - Basic design draft -[Unreleased]: https://github.com/f3ath/jessie/compare/0.0.0+dev.7...HEAD +[Unreleased]: https://github.com/f3ath/jessie/compare/0.0.1...HEAD +[0.0.1]: https://github.com/f3ath/jessie/compare/0.0.0+dev.7...0.0.1 [0.0.0+dev.7]: https://github.com/f3ath/jessie/compare/0.0.0+dev.6...0.0.0+dev.7 [0.0.0+dev.6]: https://github.com/f3ath/jessie/compare/0.0.0+dev.5...0.0.0+dev.6 [0.0.0+dev.5]: https://github.com/f3ath/jessie/compare/0.0.0+dev.4...0.0.0+dev.5 diff --git a/README.md b/README.md index 01856a1..b45d85c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,97 @@ # [JSONPath] for Dart -**Warning!** This is a work-in-progress. Expect the API to change often. Also, feel free to join. -## Roadmap -- [x] Basic selectors: fields, indices -- [x] Recursive descent (`$..`) -- [x] Wildcard (`$.store.*`) -- [x] Square-bracket field notation (`['foo']`, `$['"some" \'special\' [chars]']`) -- [x] Slice (`articles[1:10:2]`) -- [x] Union (`book[0, 1]`, `book[author, title, price]`) -- [ ] Filtering +```dart +import 'dart:convert'; +import 'package:json_path/json_path.dart'; -[JSONPath]: https://goessner.net/articles/JsonPath/ \ No newline at end of file +void main() { + final json = jsonDecode(''' +{ + "store": { + "book": [ + { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + }, + { + "category": "fiction", + "author": "Herman Melville", + "title": "Moby Dick", + "isbn": "0-553-21311-3", + "price": 8.99 + }, + { + "category": "fiction", + "author": "J. R. R. Tolkien", + "title": "The Lord of the Rings", + "isbn": "0-395-19395-8", + "price": 22.99 + } + ], + "bicycle": { + "color": "red", + "price": 19.95 + } + } +} + '''); + + /// The following code will print: + /// + /// $['store']['book'][0]['price']: 8.95 + /// $['store']['book'][1]['price']: 12.99 + /// $['store']['book'][2]['price']: 8.99 + /// $['store']['book'][3]['price']: 22.99 + /// $['store']['bicycle']['price']: 19.95 + JsonPath(r'$..price') + .filter(json) + .map((result) => '${result.path}:\t${result.value}') + .forEach(print); +} + +``` + +## Features and limitations +Generally, this library tries to mimic the [reference implementations], except for the filtering. +Evaluated expressions are not supported, use named filters instead (see below). +### Fields and indices +Both dot-notation (`$.store.book[0].title`) and bracket-notation (`$['store']['book'][2]['title']`) are supported. + +- The dot-notation only recognizes alphanumeric fields starting with a letter. Use bracket-notation for general cases. +- The bracket-notation supports only single quotes. + +### Wildcards +Wildcards (`*`) can be used for objects (`$.store.*`) and arrays (`$.store.book[*]`); + +### Recursion +Use `..` to iterate all elements recursively. E.g. `$.store..price` matches all prices in the store. + +### Array slice +Use `[start:end:step]` to filter arrays. Any index can be omitted E.g. `$.store.book[3::2]` selects all even books +starting from the 4th. Negative `start` and `end` are also supported. + +### Unions +Array (`book[0,1]`) and object (`book[author,title,price]`) unions are supported. +Object unions support the bracket-notation. + +### Filtering +Due to the nature of Dart language, filtering expressions like `$..book[?(@.price<10)]` are NOT supported. +Instead, use the callback-kind of filters. +```dart +/// Select all elements with price under 20 +JsonPath(r'$.store..[?discounted]', filter: { + 'discounted': (e) => e is Map && e['price'] is num && e['price'] < 20 +}); +``` + +[JSONPath]: https://goessner.net/articles/JsonPath/ +[reference implementations]: https://goessner.net/articles/JsonPath/index.html#e4 \ No newline at end of file diff --git a/lib/src/json_path.dart b/lib/src/json_path.dart index 9d324be..07b6eae 100644 --- a/lib/src/json_path.dart +++ b/lib/src/json_path.dart @@ -1,11 +1,13 @@ import 'package:json_path/src/parser.dart'; +import 'package:json_path/src/predicate.dart'; import 'package:json_path/src/result.dart'; import 'package:json_path/src/selector/selector.dart'; /// A JSONPath expression class JsonPath { /// Creates an instance from string - JsonPath(String expression) : _selector = const Parser().parse(expression); + JsonPath(String expression, {Map filter}) + : _selector = const Parser().parse(expression, filter ?? {}); final Selector _selector; @@ -16,3 +18,4 @@ class JsonPath { @override String toString() => _selector.expression(); } + diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 5102518..4907b7d 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -1,5 +1,6 @@ import 'package:json_path/src/ast.dart'; import 'package:json_path/src/parser_state.dart'; +import 'package:json_path/src/predicate.dart'; import 'package:json_path/src/selector/root.dart'; import 'package:json_path/src/selector/selector.dart'; @@ -7,11 +8,11 @@ class Parser { const Parser(); /// Builds a selector from the JsonPath [expression] - Selector parse(String expression) { + Selector parse(String expression, Map filters) { if (expression.isEmpty) throw FormatException('Empty expression'); ParserState state = Ready(RootSelector()); AST(_tokenize(expression)).children.forEach((node) { - state = state.process(node); + state = state.process(node, filters); }); return state.selector; } @@ -28,8 +29,9 @@ class Parser { r'\*', // wildcard r'\:', // slice r'\,', // union + r'\?', // filter r"'(?:[^'\\]|\\.)*'", // quoted string r'-?\d+', // number - r'[A-Za-z_-]+', // field + r'([A-Za-z_-][A-Za-z_\d-]*)', // field ].join('|')); } diff --git a/lib/src/parser_state.dart b/lib/src/parser_state.dart index 5d1d91c..c6b0438 100644 --- a/lib/src/parser_state.dart +++ b/lib/src/parser_state.dart @@ -1,5 +1,7 @@ import 'package:json_path/src/node.dart'; +import 'package:json_path/src/predicate.dart'; import 'package:json_path/src/selector/field.dart'; +import 'package:json_path/src/selector/filter.dart'; import 'package:json_path/src/selector/index.dart'; import 'package:json_path/src/selector/list_union.dart'; import 'package:json_path/src/selector/list_wildcard.dart'; @@ -12,7 +14,7 @@ import 'package:json_path/src/selector/slice.dart'; /// AST parser state abstract class ParserState { /// Processes the node. Returns the next state - ParserState process(Node node); + ParserState process(Node node, Map filters); /// Selector made from the tree Selector get selector; @@ -26,10 +28,10 @@ class Ready implements ParserState { final Selector selector; @override - ParserState process(Node node) { + ParserState process(Node node, Map filters) { switch (node.value) { case '[]': - return Ready(selector.then(bracketExpression(node.children))); + return Ready(selector.then(bracketExpression(node.children, filters))); case '.': return AwaitingField(selector); case '..': @@ -41,10 +43,10 @@ class Ready implements ParserState { } } - Selector bracketExpression(List nodes) { + Selector bracketExpression(List nodes, Map filters) { if (nodes.isEmpty) throw FormatException('Empty brackets'); if (nodes.length == 1) return singleValueBrackets(nodes.single); - return multiValueBrackets(nodes); + return multiValueBrackets(nodes, filters); } Selector singleValueBrackets(Node node) { @@ -54,11 +56,23 @@ class Ready implements ParserState { throw FormatException('Unexpected bracket expression'); } - Selector multiValueBrackets(List nodes) { + Selector multiValueBrackets( + List nodes, Map filters) { + if (_isFilter(nodes)) return _filter(nodes, filters); if (_isSlice(nodes)) return _slice(nodes); return _union(nodes); } + Filter _filter(List nodes, Map filters) { + final name = nodes[1].value; + if (!filters.containsKey(name)) { + throw FormatException('Filter not found: "${name}"'); + } + return Filter(name, filters[name]); + } + + bool _isFilter(List nodes) => nodes.first.value == '?'; + bool _isSlice(List nodes) => nodes.any((node) => node.value == ':'); Selector _union(List nodes) { @@ -101,7 +115,7 @@ class AwaitingField implements ParserState { final Selector selector; @override - ParserState process(Node node) { + ParserState process(Node node, Map filters) { if (node.isWildcard) { return Ready(selector.then(ObjectWildcard())); } diff --git a/lib/src/predicate.dart b/lib/src/predicate.dart new file mode 100644 index 0000000..552da30 --- /dev/null +++ b/lib/src/predicate.dart @@ -0,0 +1 @@ +typedef Predicate = bool Function(dynamic element); diff --git a/lib/src/selector/filter.dart b/lib/src/selector/filter.dart new file mode 100644 index 0000000..89eef55 --- /dev/null +++ b/lib/src/selector/filter.dart @@ -0,0 +1,20 @@ +import 'package:json_path/json_path.dart'; +import 'package:json_path/src/predicate.dart'; +import 'package:json_path/src/result.dart'; +import 'package:json_path/src/selector/selector.dart'; +import 'package:json_path/src/selector/selector_mixin.dart'; + +class Filter with SelectorMixin { + Filter(this.name, this.predicate); + + final String name; + + final Predicate predicate; + + @override + Iterable filter(Iterable results) => + results.where((r) => predicate(r.value)); + + @override + String expression([Selector previous]) => '[?$name]'; +} diff --git a/pubspec.yaml b/pubspec.yaml index ac9c754..0c3c142 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_path -version: 0.0.0+dev.7 +version: 0.0.1 description: JSONPath for Dart. JSONPath is XPath for JSON. It is a path in a JSON document. homepage: "https://github.com/f3ath/jessie" diff --git a/test/json_path_test.dart b/test/json_path_test.dart index f8a0b5d..caaec1c 100644 --- a/test/json_path_test.dart +++ b/test/json_path_test.dart @@ -21,6 +21,14 @@ void main() { expect(store.filter(json).single.path, r"$['store']"); }); + test('Single field with digits', () { + final j = {'aA_12': 'foo'}; + final store = JsonPath(r'$aA_12'); + expect(store.toString(), r"$['aA_12']"); + expect(store.filter(j).single.value, 'foo'); + expect(store.filter(j).single.path, r"$['aA_12']"); + }); + test('Single field in bracket notation', () { final store = JsonPath(r"$['store']"); expect(store.toString(), r"$['store']"); @@ -200,6 +208,7 @@ void main() { test('All in root', () { final allInRoot = JsonPath(r'$.*'); expect(allInRoot.toString(), r'$.*'); + expect(allInRoot.filter(json).length, 1); expect(allInRoot.filter(json).single.value, json['store']); expect(allInRoot.filter(json).single.path, r"$['store']"); }); @@ -265,4 +274,22 @@ void main() { expect(path.filter(json).last.path, r"$['store']['book'][3]"); }); }); + + group('Filtering', () { + test('Simple', () { + final path = JsonPath(r'$.store..[?discounted]', filter: { + 'discounted': (e) => e is Map && e['price'] is num && e['price'] < 20 + }); + expect(path.toString(), r"$['store']..[?discounted]"); + expect(path.filter(json).length, 4); + expect(path.filter(json).first.value, json['store']['book'][0]); + expect(path.filter(json).first.path, r"$['store']['book'][0]"); + expect(path.filter(json).last.value, json['store']['bicycle']); + expect(path.filter(json).last.path, r"$['store']['bicycle']"); + }); + + test('Missing filter', () { + expect(() => JsonPath(r'$.store..[?discounted]'), throwsFormatException); + }); + }); }