From 5dd508807eb3a43548dbc054cf17fa082064319c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?The=20=D0=9A=D0=BE=D0=BD=D1=8C?= Date: Sat, 5 Sep 2020 20:44:36 -0700 Subject: [PATCH] Mutations (#5) --- .github/workflows/dart.yml | 2 +- .gitignore | 5 + CHANGELOG.md | 11 +- README.md | 34 +- example/main.dart | 33 +- lib/json_path.dart | 3 +- lib/src/ast.dart | 37 -- lib/src/ast/ast.dart | 25 ++ lib/src/{ => ast}/node.dart | 4 +- lib/src/ast/tokenize.dart | 17 + lib/src/json_path.dart | 37 +- lib/src/{result.dart => json_path_match.dart} | 4 +- lib/src/parser.dart | 37 -- .../{parser_state.dart => parsing_state.dart} | 38 +- lib/src/selector/combine.dart | 27 -- lib/src/selector/field.dart | 18 - lib/src/selector/filter.dart | 18 +- lib/src/selector/index.dart | 17 - lib/src/selector/joint.dart | 39 +++ lib/src/selector/list_union.dart | 32 +- lib/src/selector/list_wildcard.dart | 16 +- lib/src/selector/object_union.dart | 35 +- lib/src/selector/object_wildcard.dart | 29 +- lib/src/{ => selector}/quote.dart | 0 lib/src/selector/recursive.dart | 52 ++- lib/src/selector/root.dart | 13 +- lib/src/selector/selector.dart | 16 +- lib/src/selector/selector_mixin.dart | 4 +- lib/src/selector/slice.dart | 52 ++- pubspec.yaml | 3 +- test/filter_test.dart | 327 ++++++++++++++++++ test/json_path_test.dart | 302 ---------------- test/parsing_test.dart | 9 + test/set_test.dart | 101 ++++++ 34 files changed, 834 insertions(+), 563 deletions(-) delete mode 100644 lib/src/ast.dart create mode 100644 lib/src/ast/ast.dart rename lib/src/{ => ast}/node.dart (78%) create mode 100644 lib/src/ast/tokenize.dart rename lib/src/{result.dart => json_path_match.dart} (64%) delete mode 100644 lib/src/parser.dart rename lib/src/{parser_state.dart => parsing_state.dart} (72%) delete mode 100644 lib/src/selector/combine.dart delete mode 100644 lib/src/selector/field.dart delete mode 100644 lib/src/selector/index.dart create mode 100644 lib/src/selector/joint.dart rename lib/src/{ => selector}/quote.dart (100%) create mode 100644 test/filter_test.dart delete mode 100644 test/json_path_test.dart create mode 100644 test/parsing_test.dart create mode 100644 test/set_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 9a6cf33..8b975e0 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -23,4 +23,4 @@ jobs: - name: Analyzer run: dartanalyzer --fatal-infos --fatal-warnings lib test example - name: Tests - run: pub run test \ No newline at end of file + run: pub run test_coverage --no-badge --print-test-output --min-coverage 100 diff --git a/.gitignore b/.gitignore index dbef116..9403bdd 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ doc/api/ *.js_ *.js.deps *.js.map + +# test_coverage +test/.test_coverage.dart +coverage +coverage_badge.svg \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c176e5c..5231b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ ## [Unreleased] +## [0.1.0] - 2020-09-05 +### Added +- JsonPath.set() method to alter the JSON object in a non-destructive way + +### Changed +- **BREAKING!** `Result` renamed to `JsonPathMatch` +- **BREAKING!** `JsonPath.filter()` renamed to `read()` + ## [0.0.2] - 2020-09-01 ### Fixed - Last element of array was not selected (regression #1) @@ -41,7 +49,8 @@ ### Added - Basic design draft -[Unreleased]: https://github.com/f3ath/jessie/compare/0.0.2...HEAD +[Unreleased]: https://github.com/f3ath/jessie/compare/0.1.0...HEAD +[0.1.0]: https://github.com/f3ath/jessie/compare/0.0.2...0.1.0 [0.0.2]: https://github.com/f3ath/jessie/compare/0.0.1...0.0.2 [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 diff --git a/README.md b/README.md index b45d85c..c9d808d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ void main() { } '''); + final prices = JsonPath(r'$..price'); + + print('All prices in the store:'); + /// The following code will print: /// /// $['store']['book'][0]['price']: 8.95 @@ -52,10 +56,34 @@ void main() { /// $['store']['book'][2]['price']: 8.99 /// $['store']['book'][3]['price']: 22.99 /// $['store']['bicycle']['price']: 19.95 - JsonPath(r'$..price') - .filter(json) + prices + .read(json) .map((result) => '${result.path}:\t${result.value}') .forEach(print); + + print('\n'); + + final bikeColor = JsonPath(r'$.store.bicycle.color'); + + print('A copy of the store with repainted bike:'); + print(bikeColor.set(json, 'blue')); + + print('\n'); + + print('Note, that the bike in the original store is still red:'); + bikeColor + .read(json) + .map((result) => '${result.path}:\t${result.value}') + .forEach(print); + + print('\n'); + + print('It is also possible to modify json in place:'); + final someBooks = JsonPath(r'$.store.book[::2]'); + someBooks.read(json).forEach((match) { + result.value['title'] = 'Banana'; + }); + print(json); } ``` @@ -89,7 +117,7 @@ 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 + 'discounted': (e) => e['price'] < 20 }); ``` diff --git a/example/main.dart b/example/main.dart index 5625246..61c3694 100644 --- a/example/main.dart +++ b/example/main.dart @@ -42,6 +42,10 @@ void main() { } '''); + final prices = JsonPath(r'$..price'); + + print('All prices in the store:'); + /// The following code will print: /// /// $['store']['book'][0]['price']: 8.95 @@ -49,8 +53,33 @@ void main() { /// $['store']['book'][2]['price']: 8.99 /// $['store']['book'][3]['price']: 22.99 /// $['store']['bicycle']['price']: 19.95 - JsonPath(r'$..price') - .filter(json) + prices + .read(json) + .map((match) => '${match.path}:\t${match.value}') + .forEach(print); + + print('\n'); + + final bikeColor = JsonPath(r'$.store.bicycle.color'); + + print('A copy of the store with repainted bike:'); + print(bikeColor.set(json, 'blue')); + + print('\n'); + + print('Note, that the bike in the original store is still red:'); + bikeColor + .read(json) .map((result) => '${result.path}:\t${result.value}') .forEach(print); + + print('\n'); + + print('It is also possible to modify json in place ' + 'as long as the matching value is an object or a list:'); + final someBooks = JsonPath(r'$.store.book[::2]'); + someBooks.read(json).forEach((match) { + match.value['title'] = 'Banana'; + }); + print(json); } diff --git a/lib/json_path.dart b/lib/json_path.dart index 544ed13..a75bf7b 100644 --- a/lib/json_path.dart +++ b/lib/json_path.dart @@ -2,4 +2,5 @@ library json_path; export 'package:json_path/src/json_path.dart'; -export 'package:json_path/src/result.dart'; +export 'package:json_path/src/json_path_match.dart'; +export 'package:json_path/src/predicate.dart'; diff --git a/lib/src/ast.dart b/lib/src/ast.dart deleted file mode 100644 index 7908d9b..0000000 --- a/lib/src/ast.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:json_path/src/node.dart'; - -/// The Abstract Syntax Tree -class AST { - AST(Iterable tokens) { - tokens.skipWhile((_) => _ == r'$').forEach(_processToken); - } - - /// The children of the root node - Iterable get children => _stack.last.children; - - final _stack = [Node('')]; - - void _processToken(String token) { - if (token == '[') { - _start('[]'); - } else if (token == ']') { - _finish('[]'); - } else { - _stack.last.children.add(Node(token)); - } - } - - void _start(String root) { - _stack.add(Node(root)); - } - - void _finish(String root) { - final children = []; - while (_stack.last.value != root) { - children.add(_stack.removeLast()); - } - final bracketExp = _stack.removeLast(); - bracketExp.children.addAll(children.reversed); - _stack.last.children.add(bracketExp); - } -} diff --git a/lib/src/ast/ast.dart b/lib/src/ast/ast.dart new file mode 100644 index 0000000..fcfaf28 --- /dev/null +++ b/lib/src/ast/ast.dart @@ -0,0 +1,25 @@ +import 'package:json_path/src/ast/node.dart'; + +/// The Abstract Syntax Tree +class AST { + AST(Iterable tokens) { + tokens.skipWhile((_) => _ == r'$').forEach(_processToken); + } + + /// The children of the root node + Iterable get nodes => _stack.last.children; + + final _stack = [Node('')]; + + void _processToken(String token) { + if (token == '[') { + _stack.add(Node(token)); + } else if (token == ']') { + final node = _stack.removeLast(); + if (node.value != '[') throw FormatException('Mismatching brackets'); + _stack.last.children.add(node); + } else { + _stack.last.children.add(Node(token)); + } + } +} diff --git a/lib/src/node.dart b/lib/src/ast/node.dart similarity index 78% rename from lib/src/node.dart rename to lib/src/ast/node.dart index a982bea..22f6d12 100644 --- a/lib/src/node.dart +++ b/lib/src/ast/node.dart @@ -1,7 +1,5 @@ class Node { - Node(this.value, [Iterable children]) { - if (children != null) this.children.addAll(children); - } + Node(this.value); final String value; final children = []; diff --git a/lib/src/ast/tokenize.dart b/lib/src/ast/tokenize.dart new file mode 100644 index 0000000..d819535 --- /dev/null +++ b/lib/src/ast/tokenize.dart @@ -0,0 +1,17 @@ +Iterable tokenize(String expr) => + _tokens.allMatches(expr).map((match) => match.group(0)); + +final _tokens = RegExp([ + r'\$', // root + r'\[', // left bracket + r'\]', // right bracket + r'\.\.', // recursion + r'\.', // child + r'\*', // wildcard + r'\:', // slice + r'\,', // union + r'\?', // filter + r"'(?:[^'\\]|\\.)*'", // quoted string + r'-?\d+', // number + r'([A-Za-z_-][A-Za-z_\d-]*)', // field +].join('|')); diff --git a/lib/src/json_path.dart b/lib/src/json_path.dart index be647b6..da2bc26 100644 --- a/lib/src/json_path.dart +++ b/lib/src/json_path.dart @@ -1,19 +1,40 @@ -import 'package:json_path/src/parser.dart'; +import 'package:json_path/src/ast/ast.dart'; +import 'package:json_path/src/ast/tokenize.dart'; +import 'package:json_path/src/json_path_match.dart'; +import 'package:json_path/src/parsing_state.dart'; import 'package:json_path/src/predicate.dart'; -import 'package:json_path/src/result.dart'; +import 'package:json_path/src/selector/root.dart'; import 'package:json_path/src/selector/selector.dart'; /// A JSONPath expression class JsonPath { - /// Creates an instance from string - JsonPath(String expression, {Map filter}) - : _selector = const Parser().parse(expression, filter ?? {}); + /// Creates an instance from string. The [expression] is parsed once, and + /// the instance may be used many times after that. + /// + /// The [filter] arguments may contain the named filters used + /// in the [expression]. + /// + /// Throws [FormatException] if the [expression] can not be parsed. + factory JsonPath(String expression, {Map filter}) { + if (expression.isEmpty) throw FormatException('Empty expression'); + ParsingState state = Ready(RootSelector()); + AST(tokenize(expression)).nodes.forEach((node) { + state = state.process(node, filter ?? {}); + }); + return JsonPath._(state.selector); + } + + JsonPath._(this._selector); final Selector _selector; - /// Filters the given [json] object. - /// Returns an Iterable of all elements found - Iterable filter(json) => _selector.filter([Result(json, '')]); + /// Reads the given [json] object returning an Iterable of all matches found. + Iterable read(json) => + _selector.read([JsonPathMatch(json, '')]); + + /// Returns a copy of [json] with all matching values replaced with [value]. + dynamic set(dynamic json, dynamic value) => + _selector.replace(json, (_) => value); @override String toString() => _selector.expression(); diff --git a/lib/src/result.dart b/lib/src/json_path_match.dart similarity index 64% rename from lib/src/result.dart rename to lib/src/json_path_match.dart index 488e23b..9d13c4f 100644 --- a/lib/src/result.dart +++ b/lib/src/json_path_match.dart @@ -1,6 +1,6 @@ /// A single matching result -class Result { - Result(this.value, this.path); +class JsonPathMatch { + JsonPathMatch(this.value, this.path); /// The value final T value; diff --git a/lib/src/parser.dart b/lib/src/parser.dart deleted file mode 100644 index 4907b7d..0000000 --- a/lib/src/parser.dart +++ /dev/null @@ -1,37 +0,0 @@ -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'; - -class Parser { - const Parser(); - - /// Builds a selector from the JsonPath [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, filters); - }); - return state.selector; - } - - Iterable _tokenize(String expr) => - _tokens.allMatches(expr).map((match) => match.group(0)); - - static final _tokens = RegExp([ - r'\$', // root - r'\[', // left bracket - r'\]', // right bracket - r'\.\.', // recursion - r'\.', // child - r'\*', // wildcard - r'\:', // slice - r'\,', // union - r'\?', // filter - r"'(?:[^'\\]|\\.)*'", // quoted string - r'-?\d+', // number - r'([A-Za-z_-][A-Za-z_\d-]*)', // field - ].join('|')); -} diff --git a/lib/src/parser_state.dart b/lib/src/parsing_state.dart similarity index 72% rename from lib/src/parser_state.dart rename to lib/src/parsing_state.dart index c6b0438..431a3ba 100644 --- a/lib/src/parser_state.dart +++ b/lib/src/parsing_state.dart @@ -1,8 +1,6 @@ -import 'package:json_path/src/node.dart'; +import 'package:json_path/src/ast/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'; import 'package:json_path/src/selector/object_union.dart'; @@ -12,26 +10,26 @@ import 'package:json_path/src/selector/selector.dart'; import 'package:json_path/src/selector/slice.dart'; /// AST parser state -abstract class ParserState { +abstract class ParsingState { /// Processes the node. Returns the next state - ParserState process(Node node, Map filters); + ParsingState process(Node node, Map filters); /// Selector made from the tree Selector get selector; } /// Ready to process the next node -class Ready implements ParserState { +class Ready implements ParsingState { Ready(this.selector); @override final Selector selector; @override - ParserState process(Node node, Map filters) { + ParsingState process(Node node, Map filters) { switch (node.value) { - case '[]': - return Ready(selector.then(bracketExpression(node.children, filters))); + case '[': + return Ready(selector.then(_brackets(node.children, filters))); case '.': return AwaitingField(selector); case '..': @@ -39,24 +37,24 @@ class Ready implements ParserState { case '*': return Ready(selector.then(ObjectWildcard())); default: - return Ready(selector.then(Field(node.value))); + return Ready(selector.then(ObjectUnion([node.value]))); } } - Selector bracketExpression(List nodes, Map filters) { + Selector _brackets(List nodes, Map filters) { if (nodes.isEmpty) throw FormatException('Empty brackets'); - if (nodes.length == 1) return singleValueBrackets(nodes.single); - return multiValueBrackets(nodes, filters); + if (nodes.length == 1) return _singleValueBrackets(nodes.single); + return _multiValueBrackets(nodes, filters); } - Selector singleValueBrackets(Node node) { + Selector _singleValueBrackets(Node node) { if (node.isWildcard) return ListWildcard(); - if (node.isNumber) return Index(node.intValue); - if (node.isQuoted) return Field(node.unquoted); + if (node.isNumber) return ListUnion([node.intValue]); + if (node.isQuoted) return ObjectUnion([node.unquoted]); throw FormatException('Unexpected bracket expression'); } - Selector multiValueBrackets( + Selector _multiValueBrackets( List nodes, Map filters) { if (_isFilter(nodes)) return _filter(nodes, filters); if (_isSlice(nodes)) return _slice(nodes); @@ -108,17 +106,17 @@ class Ready implements ParserState { } } -class AwaitingField implements ParserState { +class AwaitingField implements ParsingState { AwaitingField(this.selector); @override final Selector selector; @override - ParserState process(Node node, Map filters) { + ParsingState process(Node node, Map filters) { if (node.isWildcard) { return Ready(selector.then(ObjectWildcard())); } - return Ready(selector.then(Field(node.value))); + return Ready(selector.then(ObjectUnion([node.value]))); } } diff --git a/lib/src/selector/combine.dart b/lib/src/selector/combine.dart deleted file mode 100644 index 940ebc7..0000000 --- a/lib/src/selector/combine.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:json_path/src/result.dart'; -import 'package:json_path/src/selector/selector.dart'; -import 'package:json_path/src/selector/selector_mixin.dart'; - -/// A combination of two adjacent selectors -class Combine with SelectorMixin { - Combine(this._left, this._right) - : _realLeft = _left is Combine ? _left._realRight : _left, - _realRight = _right is Combine ? _right._realRight : _right; - - @override - Iterable filter(Iterable results) => - _right.filter(_left.filter(results)); - - @override - String expression([Selector previous]) => - _left.expression() + _right.expression(_realLeft); - - final Selector _left; - final Selector _right; - - /// The rightmost _actual_ selector in the left subtree - final Selector _realLeft; - - /// The rightmost _actual_ selector in the right subtree (and in the entire tree) - final Selector _realRight; -} diff --git a/lib/src/selector/field.dart b/lib/src/selector/field.dart deleted file mode 100644 index 9f93c96..0000000 --- a/lib/src/selector/field.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_path/src/quote.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 Field with SelectorMixin { - Field(this.name); - - final String name; - - @override - Iterable filter(Iterable results) => results - .where((r) => r.value is Map && r.value.containsKey(name)) - .map((r) => Result(r.value[name], r.path + expression())); - - @override - String expression([Selector previous]) => '[${Quote(name)}]'; -} diff --git a/lib/src/selector/filter.dart b/lib/src/selector/filter.dart index 89eef55..ec89f30 100644 --- a/lib/src/selector/filter.dart +++ b/lib/src/selector/filter.dart @@ -1,20 +1,24 @@ import 'package:json_path/json_path.dart'; +import 'package:json_path/src/json_path_match.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); +class Filter with SelectorMixin implements Selector { + Filter(this.name, this.isApplicable); final String name; - final Predicate predicate; + final Predicate isApplicable; @override - Iterable filter(Iterable results) => - results.where((r) => predicate(r.value)); + Iterable read(Iterable matches) => + matches.where((r) => isApplicable(r.value)); @override - String expression([Selector previous]) => '[?$name]'; + String expression() => '[?$name]'; + + @override + dynamic replace(dynamic json, Replacement replacement) => + isApplicable(json) ? replacement(json) : json; } diff --git a/lib/src/selector/index.dart b/lib/src/selector/index.dart deleted file mode 100644 index 0845d5e..0000000 --- a/lib/src/selector/index.dart +++ /dev/null @@ -1,17 +0,0 @@ -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 Index with SelectorMixin { - Index(this.index); - - final int index; - - @override - Iterable filter(Iterable results) => results - .where((r) => r.value is List && r.value.length > index) - .map((r) => Result(r.value[index], r.path + expression())); - - @override - String expression([Selector previous]) => '[$index]'; -} diff --git a/lib/src/selector/joint.dart b/lib/src/selector/joint.dart new file mode 100644 index 0000000..6771e98 --- /dev/null +++ b/lib/src/selector/joint.dart @@ -0,0 +1,39 @@ +import 'package:json_path/src/json_path_match.dart'; +import 'package:json_path/src/selector/object_wildcard.dart'; +import 'package:json_path/src/selector/recursive.dart'; +import 'package:json_path/src/selector/selector.dart'; +import 'package:json_path/src/selector/selector_mixin.dart'; + +/// A combination of two adjacent selectors +class Joint with SelectorMixin implements Selector { + Joint(this.left, this.right); + + /// Rightmost leaf in the tree + static Selector rightmost(Selector s) => s is Joint ? rightmost(s.right) : s; + + /// Leftmost leaf in the tree + static Selector leftmost(Selector s) => s is Joint ? leftmost(s.left) : s; + + static String delimiter(Selector left, Selector right) => + (left is! Recursive && right is ObjectWildcard) ? '.' : ''; + + /// Left subtree + final Selector left; + + /// Right subtree + final Selector right; + + @override + Iterable read(Iterable matches) => + right.read(left.read(matches)); + + @override + String expression() => + left.expression() + + delimiter(rightmost(left), leftmost(right)) + + right.expression(); + + @override + dynamic replace(dynamic json, Replacement replacement) => + left.replace(json, (_) => right.replace(_, replacement)); +} diff --git a/lib/src/selector/list_union.dart b/lib/src/selector/list_union.dart index 1245964..feb9c5f 100644 --- a/lib/src/selector/list_union.dart +++ b/lib/src/selector/list_union.dart @@ -1,21 +1,41 @@ -import 'package:json_path/src/result.dart'; +import 'package:json_path/src/json_path_match.dart'; import 'package:json_path/src/selector/selector.dart'; import 'package:json_path/src/selector/selector_mixin.dart'; -class ListUnion with SelectorMixin { +class ListUnion with SelectorMixin implements Selector { ListUnion(this.keys); final List keys; @override - Iterable filter(Iterable results) => results + Iterable read(Iterable matches) => matches .map((r) => (r.value is List) ? _map(r.value, r.path) : []) .expand((_) => _); @override - String expression([Selector previous]) => '[${keys.join(',')}]'; + String expression() => '[${keys.join(',')}]'; - Iterable _map(List list, String path) => keys + Iterable _map(List list, String path) => keys .where((key) => key < list.length) - .map((key) => Result(list[key], path + '[$key]')); + .map((key) => JsonPathMatch(list[key], path + '[$key]')); + + @override + dynamic replace(dynamic json, Function(dynamic _) replacement) { + if (json is List) { + final applicableKeys = keys.where((key) => json.length > key); + if (applicableKeys.isEmpty) { + return json; + } + return _replaceInList(json, applicableKeys, replacement); + } + return json; + } + + List _replaceInList(List list, Iterable keys, Replacement replacement) { + final copy = [...list]; + keys.forEach((key) { + copy[key] = replacement(copy[key]); + }); + return copy; + } } diff --git a/lib/src/selector/list_wildcard.dart b/lib/src/selector/list_wildcard.dart index a9c6e37..fa39f0d 100644 --- a/lib/src/selector/list_wildcard.dart +++ b/lib/src/selector/list_wildcard.dart @@ -1,20 +1,24 @@ -import 'package:json_path/src/result.dart'; +import 'package:json_path/src/json_path_match.dart'; import 'package:json_path/src/selector/selector.dart'; import 'package:json_path/src/selector/selector_mixin.dart'; -class ListWildcard with SelectorMixin { +class ListWildcard with SelectorMixin implements Selector { @override - Iterable filter(Iterable results) => results + Iterable read(Iterable matches) => matches .where((r) => r.value is List) .map((r) => _wrap(r.value, r.path)) .expand((_) => _); @override - String expression([Selector previous]) => '[*]'; + String expression() => '[*]'; - Iterable _wrap(List val, String path) sync* { + @override + dynamic replace(dynamic json, Replacement replacement) => + (json is List) ? json.map(replacement).toList() : json; + + Iterable _wrap(List val, String path) sync* { for (var i = 0; i < val.length; i++) { - yield Result(val[i], path + '[$i]'); + yield JsonPathMatch(val[i], path + '[$i]'); } } } diff --git a/lib/src/selector/object_union.dart b/lib/src/selector/object_union.dart index 21cbac3..93ad390 100644 --- a/lib/src/selector/object_union.dart +++ b/lib/src/selector/object_union.dart @@ -1,23 +1,40 @@ -import 'package:json_path/src/quote.dart'; -import 'package:json_path/src/result.dart'; +import 'package:json_path/src/json_path_match.dart'; +import 'package:json_path/src/selector/quote.dart'; import 'package:json_path/src/selector/selector.dart'; import 'package:json_path/src/selector/selector_mixin.dart'; -class ObjectUnion with SelectorMixin { +class ObjectUnion with SelectorMixin implements Selector { ObjectUnion(this.keys); final List keys; @override - Iterable filter(Iterable results) => results - .map((r) => (r.value is Map) ? _map(r.value, r.path) : []) + Iterable read(Iterable matches) => matches + .map((r) => + (r.value is Map) ? _readMap(r.value, r.path) : []) .expand((_) => _); @override - String expression([Selector previous]) => - '[${keys.map((k) => Quote(k)).join(',')}]'; + String expression() => '[${keys.map((k) => Quote(k)).join(',')}]'; - Iterable _map(Map map, String path) => keys + @override + dynamic replace(dynamic json, Replacement replacement) { + if (json is Map) { + final patch = _makePatch(json, replacement); + if (patch.isEmpty) { + return json; + } + return {...json, ...patch}; + } + return json; + } + + Iterable _readMap(Map map, String path) => keys .where(map.containsKey) - .map((key) => Result(map[key], path + '[${Quote(key)}]')); + .map((key) => JsonPathMatch(map[key], path + '[${Quote(key)}]')); + + Map _makePatch(Map map, Replacement replacement) => + Map.fromEntries(keys + .where(map.containsKey) + .map((key) => MapEntry(key, replacement(map[key])))); } diff --git a/lib/src/selector/object_wildcard.dart b/lib/src/selector/object_wildcard.dart index b152586..dd77769 100644 --- a/lib/src/selector/object_wildcard.dart +++ b/lib/src/selector/object_wildcard.dart @@ -1,23 +1,30 @@ -import 'package:json_path/src/quote.dart'; -import 'package:json_path/src/result.dart'; -import 'package:json_path/src/selector/recursive.dart'; +import 'package:json_path/src/json_path_match.dart'; +import 'package:json_path/src/selector/quote.dart'; import 'package:json_path/src/selector/selector.dart'; import 'package:json_path/src/selector/selector_mixin.dart'; -class ObjectWildcard with SelectorMixin { +class ObjectWildcard with SelectorMixin implements Selector { @override - Iterable filter(Iterable results) => results.map((r) { + Iterable read(Iterable matches) => + matches.map((r) { if (r.value is Map) return _allProperties(r.value, r.path); if (r.value is List) return _allValues(r.value, r.path); - return []; + return []; }).expand((_) => _); @override - String expression([Selector previous]) => previous is Recursive ? '*' : '.*'; + String expression() => '*'; - Iterable _allProperties(Map map, String path) => - map.entries.map((e) => Result(e.value, path + '[${Quote(e.key)}]')); + Iterable _allProperties(Map map, String path) => map.entries + .map((e) => JsonPathMatch(e.value, path + '[${Quote(e.key)}]')); - Iterable _allValues(List list, String path) => - list.asMap().entries.map((e) => Result(e.value, path + '[${e.key}]')); + Iterable _allValues(List list, String path) => list + .asMap() + .entries + .map((e) => JsonPathMatch(e.value, path + '[${e.key}]')); + + @override + dynamic replace(dynamic json, Replacement replacement) => (json is Map) + ? json.map((key, value) => MapEntry(key, replacement(value))) + : json; } diff --git a/lib/src/quote.dart b/lib/src/selector/quote.dart similarity index 100% rename from lib/src/quote.dart rename to lib/src/selector/quote.dart diff --git a/lib/src/selector/recursive.dart b/lib/src/selector/recursive.dart index ef705a8..0154a8e 100644 --- a/lib/src/selector/recursive.dart +++ b/lib/src/selector/recursive.dart @@ -1,22 +1,48 @@ -import 'package:json_path/src/quote.dart'; -import 'package:json_path/src/result.dart'; +import 'package:json_path/src/json_path_match.dart'; +import 'package:json_path/src/selector/quote.dart'; import 'package:json_path/src/selector/selector.dart'; import 'package:json_path/src/selector/selector_mixin.dart'; -class Recursive with SelectorMixin { +class Recursive with SelectorMixin implements Selector { @override - Iterable filter(Iterable results) => results.map((r) { - if (r.value is Map) return [r].followedBy(_properties(r.value, r.path)); - if (r.value is List) return [r].followedBy(_values(r.value, r.path)); - return []; - }).expand((_) => _); + Iterable read(Iterable matches) => + matches.map(_traverse).expand((_) => _); @override - String expression([Selector previous]) => '..'; + String expression() => '..'; - Iterable _values(List val, String path) => filter( - val.asMap().entries.map((e) => Result(e.value, path + '[${e.key}]'))); + @override + dynamic replace(dynamic json, Replacement replacement) { + if (json is Map) { + return _replaceInMap(json, replacement); + } + if (json is List) { + return _replaceInList(json, replacement); + } + return replacement(json); + } + + Iterable _traverse(JsonPathMatch m) { + if (m.value is Map) { + return [m].followedBy(read(_props(m.value, m.path))); + } + if (m.value is List) { + return [m].followedBy(read(_values(m.value, m.path))); + } + return []; + } + + dynamic _replaceInMap(Map map, Replacement replacement) => replacement( + map.map((key, value) => MapEntry(key, replace(value, replacement)))); + + dynamic _replaceInList(List list, Replacement replacement) => + replacement(list.map((value) => replace(value, replacement)).toList()); + + Iterable _values(List val, String path) => val + .asMap() + .entries + .map((e) => JsonPathMatch(e.value, path + '[${e.key}]')); - Iterable _properties(Map map, String path) => filter( - map.entries.map((e) => Result(e.value, path + '[${Quote(e.key)}]'))); + Iterable _props(Map map, String path) => map.entries + .map((e) => JsonPathMatch(e.value, path + '[${Quote(e.key)}]')); } diff --git a/lib/src/selector/root.dart b/lib/src/selector/root.dart index 6a5e1e3..a4914a2 100644 --- a/lib/src/selector/root.dart +++ b/lib/src/selector/root.dart @@ -1,14 +1,17 @@ -import 'package:json_path/src/result.dart'; +import 'package:json_path/src/json_path_match.dart'; import 'package:json_path/src/selector/selector.dart'; import 'package:json_path/src/selector/selector_mixin.dart'; -class RootSelector with SelectorMixin { +class RootSelector with SelectorMixin implements Selector { const RootSelector(); @override - Iterable filter(Iterable results) => - results.map((m) => Result(m.value, expression())); + Iterable read(Iterable matches) => + matches.map((m) => JsonPathMatch(m.value, expression())); @override - String expression([Selector previous]) => r'$'; + String expression() => r'$'; + + @override + dynamic replace(dynamic json, Replacement replacement) => replacement(json); } diff --git a/lib/src/selector/selector.dart b/lib/src/selector/selector.dart index e5855d8..cbbedfd 100644 --- a/lib/src/selector/selector.dart +++ b/lib/src/selector/selector.dart @@ -1,14 +1,18 @@ -import 'package:json_path/src/result.dart'; +import 'package:json_path/src/json_path_match.dart'; -/// Converts a set of results into a set of results +/// Converts a set of matches into a set of matches abstract class Selector { - /// Applies this filter to the [results] - Iterable filter(Iterable results); + /// Applies this filter to the [matches] + Iterable read(Iterable matches); /// The filter expression as string. - /// The [previous] selector must be provided if being followed by this - String expression([Selector previous]); + String expression(); /// Combines this expression with the [other] Selector then(Selector other); + + /// Returns a copy of [json] with all selected values modified using [replacement] function. + dynamic replace(dynamic json, Replacement replacement); } + +typedef Replacement = dynamic Function(dynamic value); diff --git a/lib/src/selector/selector_mixin.dart b/lib/src/selector/selector_mixin.dart index 1005edf..65256d2 100644 --- a/lib/src/selector/selector_mixin.dart +++ b/lib/src/selector/selector_mixin.dart @@ -1,8 +1,8 @@ -import 'package:json_path/src/selector/combine.dart'; +import 'package:json_path/src/selector/joint.dart'; import 'package:json_path/src/selector/selector.dart'; mixin SelectorMixin implements Selector { /// Combines this expression with the [other] @override - Selector then(Selector other) => Combine(this, other); + Selector then(Selector other) => Joint(this, other); } diff --git a/lib/src/selector/slice.dart b/lib/src/selector/slice.dart index d65950b..d0aff81 100644 --- a/lib/src/selector/slice.dart +++ b/lib/src/selector/slice.dart @@ -1,14 +1,21 @@ import 'dart:math'; -import 'package:json_path/src/result.dart'; +import 'package:json_path/src/json_path_match.dart'; import 'package:json_path/src/selector/selector.dart'; import 'package:json_path/src/selector/selector_mixin.dart'; -class Slice with SelectorMixin { +class Slice with SelectorMixin implements Selector { Slice({int first, this.last, int step}) : first = first ?? 0, step = step ?? 1; + static Iterable _for(int from, int to, int step) sync* { + if (step < 1) return; + for (var i = from; i < to; i += step) { + yield i; + } + } + final int first; final int last; @@ -16,20 +23,35 @@ class Slice with SelectorMixin { final int step; @override - Iterable filter(Iterable results) => results.map((r) { - if (step > 0 && r.value is List) { - return _filterList(r.value, r.path); - } - return const []; - }).expand((_) => _); + Iterable read(Iterable matches) => matches + .map((r) => + (r.value is List) ? _filterList(r.value, r.path) : []) + .expand((_) => _); @override - String expression([Selector previous]) => + String expression() => '[${first == 0 ? '' : first}:${last ?? ''}${step != 1 ? ':$step' : ''}]'; - Iterable _filterList(List list, String path) => - _for(_actualFirst(list.length), _actualLast(list.length), step) - .map((i) => Result(list[i], path + '[$i]')); + @override + dynamic replace(dynamic json, Replacement replacement) { + if (json is List) { + final indices = _indices(json); + if (indices.isNotEmpty) { + final copy = [...json]; + indices.forEach((i) { + copy[i] = replacement(json[i]); + }); + return copy; + } + } + return json; + } + + Iterable _filterList(List list, String path) => + _indices(list).map((i) => JsonPathMatch(list[i], path + '[$i]')); + + Iterable _indices(List list) => + _for(_actualFirst(list.length), _actualLast(list.length), step); int _actualFirst(int len) => max(0, first < 0 ? (len + first) : first); @@ -38,10 +60,4 @@ class Slice with SelectorMixin { if (last < 0) return min(len, len + last); return min(len, last); } - - static Iterable _for(int from, int to, int step) sync* { - for (var i = from; i < to; i += step) { - yield i; - } - } } diff --git a/pubspec.yaml b/pubspec.yaml index c686e9b..b2b5e74 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,12 @@ name: json_path -version: 0.0.2 +version: 0.1.0 description: JSONPath for Dart. JSONPath is XPath for JSON. It is a path in a JSON document. homepage: "https://github.com/f3ath/jessie" dev_dependencies: pedantic: ^1.9.0 test: ^1.9.0 + test_coverage: ^0.4.3 environment: sdk: ">=2.8.0 <3.0.0" diff --git a/test/filter_test.dart b/test/filter_test.dart new file mode 100644 index 0000000..0a88f6f --- /dev/null +++ b/test/filter_test.dart @@ -0,0 +1,327 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:json_path/json_path.dart'; +import 'package:test/test.dart'; + +void main() { + final json = jsonDecode(File('test/store.json').readAsStringSync()); + group('Basic expressions', () { + test('Only root', () { + final root = JsonPath(r'$'); + expect(root.toString(), r'$'); + expect(root.read(json).single.value, json); + expect(root.read(json).single.path, r'$'); + }); + + test('Single field', () { + final store = JsonPath(r'$.store'); + expect(store.toString(), r"$['store']"); + expect(store.read(json).single.value, json['store']); + expect(store.read(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.read(j).single.value, 'foo'); + expect(store.read(j).single.path, r"$['aA_12']"); + }); + + test('Single field in bracket notation', () { + final store = JsonPath(r"$['store']"); + expect(store.toString(), r"$['store']"); + expect(store.read(json).single.value, json['store']); + expect(store.read(json).single.path, r"$['store']"); + }); + + test('Mixed brackets and fields', () { + final price = JsonPath(r"$['store'].bicycle['price']"); + expect(price.toString(), r"$['store']['bicycle']['price']"); + expect(price.read(json).single.value, json['store']['bicycle']['price']); + expect(price.read(json).single.path, r"$['store']['bicycle']['price']"); + }); + }); + + group('Invalid format', () { + test('Empty', () { + expect(() => JsonPath(''), throwsFormatException); + }); + }); + + group('Slices', () { + final abc = 'abcdefg'.split(''); + test('1:3', () { + final slice = JsonPath(r'$[1:3]'); + expect(slice.toString(), r'$[1:3]'); + expect(slice.read(abc).length, 2); + expect(slice.read(abc).first.value, 'b'); + expect(slice.read(abc).first.path, r'$[1]'); + expect(slice.read(abc).last.value, 'c'); + expect(slice.read(abc).last.path, r'$[2]'); + }); + test('1:5:2', () { + final slice = JsonPath(r'$[1:5:2]'); + expect(slice.toString(), r'$[1:5:2]'); + expect(slice.read(abc).length, 2); + expect(slice.read(abc).first.value, 'b'); + expect(slice.read(abc).first.path, r'$[1]'); + expect(slice.read(abc).last.value, 'd'); + expect(slice.read(abc).last.path, r'$[3]'); + }); + test('1:5:-2', () { + final slice = JsonPath(r'$[1:5:-2]'); + expect(slice.toString(), r'$[1:5:-2]'); + expect(slice.read(abc).length, 0); + }); + test(':3', () { + final slice = JsonPath(r'$[:3]'); + expect(slice.toString(), r'$[:3]'); + expect(slice.read(abc).length, 3); + expect(slice.read(abc).first.value, 'a'); + expect(slice.read(abc).first.path, r'$[0]'); + expect(slice.read(abc).last.value, 'c'); + expect(slice.read(abc).last.path, r'$[2]'); + }); + test(':3:2', () { + final slice = JsonPath(r'$[:3:2]'); + expect(slice.toString(), r'$[:3:2]'); + expect(slice.read(abc).length, 2); + expect(slice.read(abc).first.value, 'a'); + expect(slice.read(abc).first.path, r'$[0]'); + expect(slice.read(abc).last.value, 'c'); + expect(slice.read(abc).last.path, r'$[2]'); + }); + test('3::2', () { + final slice = JsonPath(r'$[3::2]'); + expect(slice.toString(), r'$[3::2]'); + expect(slice.read(abc).length, 2); + expect(slice.read(abc).first.value, 'd'); + expect(slice.read(abc).first.path, r'$[3]'); + expect(slice.read(abc).last.value, 'f'); + expect(slice.read(abc).last.path, r'$[5]'); + }); + test('100:', () { + final slice = JsonPath(r'$[100:]'); + expect(slice.toString(), r'$[100:]'); + expect(slice.read(abc).length, 0); + }); + test('3:', () { + final slice = JsonPath(r'$[3:]'); + expect(slice.toString(), r'$[3:]'); + expect(slice.read(abc).length, 4); + expect(slice.read(abc).first.value, 'd'); + expect(slice.read(abc).first.path, r'$[3]'); + expect(slice.read(abc).last.value, 'g'); + expect(slice.read(abc).last.path, r'$[6]'); + }); + test(':-5', () { + final slice = JsonPath(r'$[:-5]'); + expect(slice.toString(), r'$[:-5]'); + expect(slice.read(abc).length, 2); + expect(slice.read(abc).first.value, 'a'); + expect(slice.read(abc).first.path, r'$[0]'); + expect(slice.read(abc).last.value, 'b'); + expect(slice.read(abc).last.path, r'$[1]'); + }); + + test('-5:', () { + final slice = JsonPath(r'$[-5:]'); + expect(slice.toString(), r'$[-5:]'); + expect(slice.read(abc).length, 5); + expect(slice.read(abc).first.value, 'c'); + expect(slice.read(abc).first.path, r'$[2]'); + expect(slice.read(abc).last.value, 'g'); + expect(slice.read(abc).last.path, r'$[6]'); + }); + test('0:6', () { + final slice = JsonPath(r'$[0:6]'); + expect(slice.toString(), r'$[:6]'); + expect(slice.read(abc).length, 6); + expect(slice.read(abc).first.value, 'a'); + expect(slice.read(abc).first.path, r'$[0]'); + expect(slice.read(abc).last.value, 'f'); + expect(slice.read(abc).last.path, r'$[5]'); + }); + test('0:100', () { + final slice = JsonPath(r'$[0:100]'); + expect(slice.toString(), r'$[:100]'); + expect(slice.read(abc).length, 7); + expect(slice.read(abc).first.value, 'a'); + expect(slice.read(abc).first.path, r'$[0]'); + expect(slice.read(abc).last.value, 'g'); + expect(slice.read(abc).last.path, r'$[6]'); + }); + + test('-6:-1', () { + final slice = JsonPath(r'$[-6:-1]'); + expect(slice.toString(), r'$[-6:-1]'); + expect(slice.read(abc).length, 5); + expect(slice.read(abc).first.value, 'b'); + expect(slice.read(abc).first.path, r'$[1]'); + expect(slice.read(abc).last.value, 'f'); + expect(slice.read(abc).last.path, r'$[5]'); + }); + }); + + group('Uncommon brackets', () { + test('Escape single quote', () { + final j = {r"sq'sq s\s qs\'qs": 'value'}; + final path = JsonPath(r"$['sq\'sq s\\s qs\\\'qs']"); + expect(path.toString(), r"$['sq\'sq s\\s qs\\\'qs']"); + final select = path.read(j); + expect(select.single.value, 'value'); + expect(select.single.path, r"$['sq\'sq s\\s qs\\\'qs']"); + }); + }); + + group('Union', () { + test('List', () { + final abc = 'abcdefg'.split(''); + final union = JsonPath(r'$[2,3,100,5]'); + expect(union.toString(), r'$[2,3,100,5]'); + expect(union.read(abc).length, 3); + expect(union.read(abc).first.value, 'c'); + expect(union.read(abc).first.path, r'$[2]'); + expect(union.read(abc).last.value, 'f'); + expect(union.read(abc).last.path, r'$[5]'); + }); + test('Object', () { + final abc = { + 'a': 'A', + 'b': 'B', + 'c': 'C', + }; + final union = JsonPath(r"$['a','x',c]"); + expect(union.toString(), r"$['a','x','c']"); + expect(union.read(abc).length, 2); + expect(union.read(abc).first.value, 'A'); + expect(union.read(abc).first.path, r"$['a']"); + expect(union.read(abc).last.value, 'C'); + expect(union.read(abc).last.path, r"$['c']"); + }); + }); + + group('Wildcards', () { + test('All in root', () { + final allInRoot = JsonPath(r'$.*'); + expect(allInRoot.toString(), r'$.*'); + expect(allInRoot.read(json).length, 1); + expect(allInRoot.read(json).single.value, json['store']); + expect(allInRoot.read(json).single.path, r"$['store']"); + }); + + test('All in store', () { + final allInStore = JsonPath(r'$.store.*'); + expect(allInStore.toString(), r"$['store'].*"); + expect(allInStore.read(json).length, 2); + expect(allInStore.read(json).first.value, json['store']['book']); + expect(allInStore.read(json).first.path, r"$['store']['book']"); + expect(allInStore.read(json).last.value, json['store']['bicycle']); + expect(allInStore.read(json).last.path, r"$['store']['bicycle']"); + }); + test('No effect on scalars', () { + final allInStore = JsonPath(r'$.store.bicycle.color.*'); + expect(allInStore.toString(), r"$['store']['bicycle']['color'].*"); + expect(allInStore.read(json), isEmpty); + }); + }); + + group('Recursion', () { + test('Recursive', () { + final allNode = JsonPath(r'$..'); + expect(allNode.toString(), r'$..'); + expect(allNode.read(json).length, 8); + expect(allNode.read(json).first.value, json); + expect(allNode.read(json).first.path, r'$'); + expect(allNode.read(json).last.value, json['store']['bicycle']); + expect(allNode.read(json).last.path, r"$['store']['bicycle']"); + }); + + test('Recursive with all values', () { + final path = JsonPath(r'$..*'); + expect(path.toString(), r'$..*'); + expect(path.read(json).length, 27); + expect(path.read(json).first.value, json['store']); + expect(path.read(json).first.path, r"$['store']"); + expect(path.read(json).last.value, json['store']['bicycle']['price']); + expect(path.read(json).last.path, r"$['store']['bicycle']['price']"); + }); + + test('Every price tag', () { + final path = JsonPath(r'$..price'); + expect(path.toString(), r"$..['price']"); + expect(path.read(json).length, 5); + expect(path.read(json).first.value, json['store']['book'][0]['price']); + expect(path.read(json).first.path, r"$['store']['book'][0]['price']"); + expect(path.read(json).last.value, json['store']['bicycle']['price']); + expect(path.read(json).last.path, r"$['store']['bicycle']['price']"); + }); + }); + + group('Lists', () { + test('Path with an index', () { + final path = JsonPath(r'$.store.book[0].title'); + expect(path.toString(), r"$['store']['book'][0]['title']"); + expect(path.read(json).single.value, 'Sayings of the Century'); + expect(path.read(json).single.path, r"$['store']['book'][0]['title']"); + }); + + test('Last element of array (regression #1)', () { + final path = JsonPath(r"$['store']['book'][3]['price']"); + expect(path.toString(), r"$['store']['book'][3]['price']"); + expect(path.read(json).single.value, 22.99); + expect(path.read(json).single.path, r"$['store']['book'][3]['price']"); + }); + + test('All in list', () { + final path = JsonPath(r'$.store.book[*]'); + expect(path.toString(), r"$['store']['book'][*]"); + expect(path.read(json).length, 4); + expect(path.read(json).first.value, json['store']['book'][0]); + expect(path.read(json).first.path, r"$['store']['book'][0]"); + expect(path.read(json).last.value, json['store']['book'][3]); + expect(path.read(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.read(json).length, 4); + expect(path.read(json).first.value, json['store']['book'][0]); + expect(path.read(json).first.path, r"$['store']['book'][0]"); + expect(path.read(json).last.value, json['store']['bicycle']); + expect(path.read(json).last.path, r"$['store']['bicycle']"); + }); + + test('Can be applied to scalars', () { + final path = JsonPath(r'$.store..price[?low]', + filter: {'low': (e) => e is num && e < 20}); + expect(path.toString(), r"$['store']..['price'][?low]"); + expect(path.read(json).length, 4); + expect(path.read(json).first.value, json['store']['book'][0]['price']); + expect(path.read(json).first.path, r"$['store']['book'][0]['price']"); + expect(path.read(json).last.value, json['store']['bicycle']['price']); + expect(path.read(json).last.path, r"$['store']['bicycle']['price']"); + }); + + test('Missing filter', () { + expect(() => JsonPath(r'$.store..[?discounted]'), throwsFormatException); + }); + }); + test('Modifying in-place', () { + final someBooks = JsonPath(r'$.store.book[::2]'); + someBooks.read(json).forEach((result) { + result.value['title'] = 'Banana'; + }); + expect(json['store']['book'][0]['title'], 'Banana'); + expect(json['store']['book'][1]['title'], 'Sword of Honour'); + expect(json['store']['book'][2]['title'], 'Banana'); + expect(json['store']['book'][3]['title'], 'The Lord of the Rings'); + }); +} diff --git a/test/json_path_test.dart b/test/json_path_test.dart deleted file mode 100644 index e56db11..0000000 --- a/test/json_path_test.dart +++ /dev/null @@ -1,302 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:json_path/json_path.dart'; -import 'package:test/test.dart'; - -void main() { - final json = jsonDecode(File('test/store.json').readAsStringSync()); - group('Basic expressions', () { - test('Only root', () { - final root = JsonPath(r'$'); - expect(root.toString(), r'$'); - expect(root.filter(json).single.value, json); - expect(root.filter(json).single.path, r'$'); - }); - - test('Single field', () { - final store = JsonPath(r'$.store'); - expect(store.toString(), r"$['store']"); - expect(store.filter(json).single.value, json['store']); - 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']"); - expect(store.filter(json).single.value, json['store']); - expect(store.filter(json).single.path, r"$['store']"); - }); - - test('Mixed brackets and fields', () { - final price = JsonPath(r"$['store'].bicycle['price']"); - expect(price.toString(), r"$['store']['bicycle']['price']"); - expect( - price.filter(json).single.value, json['store']['bicycle']['price']); - expect(price.filter(json).single.path, r"$['store']['bicycle']['price']"); - }); - }); - - group('Invalid format', () { - test('Empty', () { - expect(() => JsonPath(''), throwsFormatException); - }); - }); - - group('Slices', () { - final abc = 'abcdefg'.split(''); - test('1:3', () { - final slice = JsonPath(r'$[1:3]'); - expect(slice.toString(), r'$[1:3]'); - expect(slice.filter(abc).length, 2); - expect(slice.filter(abc).first.value, 'b'); - expect(slice.filter(abc).first.path, r'$[1]'); - expect(slice.filter(abc).last.value, 'c'); - expect(slice.filter(abc).last.path, r'$[2]'); - }); - test('1:5:2', () { - final slice = JsonPath(r'$[1:5:2]'); - expect(slice.toString(), r'$[1:5:2]'); - expect(slice.filter(abc).length, 2); - expect(slice.filter(abc).first.value, 'b'); - expect(slice.filter(abc).first.path, r'$[1]'); - expect(slice.filter(abc).last.value, 'd'); - expect(slice.filter(abc).last.path, r'$[3]'); - }); - test('1:5:-2', () { - final slice = JsonPath(r'$[1:5:-2]'); - expect(slice.toString(), r'$[1:5:-2]'); - expect(slice.filter(abc).length, 0); - }); - test(':3', () { - final slice = JsonPath(r'$[:3]'); - expect(slice.toString(), r'$[:3]'); - expect(slice.filter(abc).length, 3); - expect(slice.filter(abc).first.value, 'a'); - expect(slice.filter(abc).first.path, r'$[0]'); - expect(slice.filter(abc).last.value, 'c'); - expect(slice.filter(abc).last.path, r'$[2]'); - }); - test(':3:2', () { - final slice = JsonPath(r'$[:3:2]'); - expect(slice.toString(), r'$[:3:2]'); - expect(slice.filter(abc).length, 2); - expect(slice.filter(abc).first.value, 'a'); - expect(slice.filter(abc).first.path, r'$[0]'); - expect(slice.filter(abc).last.value, 'c'); - expect(slice.filter(abc).last.path, r'$[2]'); - }); - test('3::2', () { - final slice = JsonPath(r'$[3::2]'); - expect(slice.toString(), r'$[3::2]'); - expect(slice.filter(abc).length, 2); - expect(slice.filter(abc).first.value, 'd'); - expect(slice.filter(abc).first.path, r'$[3]'); - expect(slice.filter(abc).last.value, 'f'); - expect(slice.filter(abc).last.path, r'$[5]'); - }); - test('100:', () { - final slice = JsonPath(r'$[100:]'); - expect(slice.toString(), r'$[100:]'); - expect(slice.filter(abc).length, 0); - }); - test('3:', () { - final slice = JsonPath(r'$[3:]'); - expect(slice.toString(), r'$[3:]'); - expect(slice.filter(abc).length, 4); - expect(slice.filter(abc).first.value, 'd'); - expect(slice.filter(abc).first.path, r'$[3]'); - expect(slice.filter(abc).last.value, 'g'); - expect(slice.filter(abc).last.path, r'$[6]'); - }); - test(':-5', () { - final slice = JsonPath(r'$[:-5]'); - expect(slice.toString(), r'$[:-5]'); - expect(slice.filter(abc).length, 2); - expect(slice.filter(abc).first.value, 'a'); - expect(slice.filter(abc).first.path, r'$[0]'); - expect(slice.filter(abc).last.value, 'b'); - expect(slice.filter(abc).last.path, r'$[1]'); - }); - - test('-5:', () { - final slice = JsonPath(r'$[-5:]'); - expect(slice.toString(), r'$[-5:]'); - expect(slice.filter(abc).length, 5); - expect(slice.filter(abc).first.value, 'c'); - expect(slice.filter(abc).first.path, r'$[2]'); - expect(slice.filter(abc).last.value, 'g'); - expect(slice.filter(abc).last.path, r'$[6]'); - }); - test('0:6', () { - final slice = JsonPath(r'$[0:6]'); - expect(slice.toString(), r'$[:6]'); - expect(slice.filter(abc).length, 6); - expect(slice.filter(abc).first.value, 'a'); - expect(slice.filter(abc).first.path, r'$[0]'); - expect(slice.filter(abc).last.value, 'f'); - expect(slice.filter(abc).last.path, r'$[5]'); - }); - test('0:100', () { - final slice = JsonPath(r'$[0:100]'); - expect(slice.toString(), r'$[:100]'); - expect(slice.filter(abc).length, 7); - expect(slice.filter(abc).first.value, 'a'); - expect(slice.filter(abc).first.path, r'$[0]'); - expect(slice.filter(abc).last.value, 'g'); - expect(slice.filter(abc).last.path, r'$[6]'); - }); - - test('-6:-1', () { - final slice = JsonPath(r'$[-6:-1]'); - expect(slice.toString(), r'$[-6:-1]'); - expect(slice.filter(abc).length, 5); - expect(slice.filter(abc).first.value, 'b'); - expect(slice.filter(abc).first.path, r'$[1]'); - expect(slice.filter(abc).last.value, 'f'); - expect(slice.filter(abc).last.path, r'$[5]'); - }); - }); - - group('Uncommon brackets', () { - test('Escape single quote', () { - final j = {r"sq'sq s\s qs\'qs": 'value'}; - final path = JsonPath(r"$['sq\'sq s\\s qs\\\'qs']"); - expect(path.toString(), r"$['sq\'sq s\\s qs\\\'qs']"); - final select = path.filter(j); - expect(select.single.value, 'value'); - expect(select.single.path, r"$['sq\'sq s\\s qs\\\'qs']"); - }); - }); - - group('Union', () { - test('List', () { - final abc = 'abcdefg'.split(''); - final union = JsonPath(r'$[2,3,100,5]'); - expect(union.toString(), r'$[2,3,100,5]'); - expect(union.filter(abc).length, 3); - expect(union.filter(abc).first.value, 'c'); - expect(union.filter(abc).first.path, r'$[2]'); - expect(union.filter(abc).last.value, 'f'); - expect(union.filter(abc).last.path, r'$[5]'); - }); - test('Object', () { - final abc = { - 'a': 'A', - 'b': 'B', - 'c': 'C', - }; - final union = JsonPath(r"$['a','x',c]"); - expect(union.toString(), r"$['a','x','c']"); - expect(union.filter(abc).length, 2); - expect(union.filter(abc).first.value, 'A'); - expect(union.filter(abc).first.path, r"$['a']"); - expect(union.filter(abc).last.value, 'C'); - expect(union.filter(abc).last.path, r"$['c']"); - }); - }); - - group('Wildcards', () { - 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']"); - }); - - test('All in store', () { - final allInStore = JsonPath(r'$.store.*'); - expect(allInStore.toString(), r"$['store'].*"); - expect(allInStore.filter(json).length, 2); - expect(allInStore.filter(json).first.value, json['store']['book']); - expect(allInStore.filter(json).first.path, r"$['store']['book']"); - expect(allInStore.filter(json).last.value, json['store']['bicycle']); - expect(allInStore.filter(json).last.path, r"$['store']['bicycle']"); - }); - }); - - group('Recursion', () { - test('Recursive', () { - final allNode = JsonPath(r'$..'); - expect(allNode.toString(), r'$..'); - expect(allNode.filter(json).length, 8); - expect(allNode.filter(json).first.value, json); - expect(allNode.filter(json).first.path, r'$'); - expect(allNode.filter(json).last.value, json['store']['bicycle']); - expect(allNode.filter(json).last.path, r"$['store']['bicycle']"); - }); - - test('Recursive with all values', () { - final path = JsonPath(r'$..*'); - expect(path.toString(), r'$..*'); - expect(path.filter(json).length, 27); - expect(path.filter(json).first.value, json['store']); - expect(path.filter(json).first.path, r"$['store']"); - expect(path.filter(json).last.value, json['store']['bicycle']['price']); - expect(path.filter(json).last.path, r"$['store']['bicycle']['price']"); - }); - - test('Every price tag', () { - final path = JsonPath(r'$..price'); - expect(path.toString(), r"$..['price']"); - expect(path.filter(json).length, 5); - expect(path.filter(json).first.value, json['store']['book'][0]['price']); - expect(path.filter(json).first.path, r"$['store']['book'][0]['price']"); - expect(path.filter(json).last.value, json['store']['bicycle']['price']); - expect(path.filter(json).last.path, r"$['store']['bicycle']['price']"); - }); - }); - - group('Lists', () { - test('Path with an index', () { - final path = JsonPath(r'$.store.book[0].title'); - expect(path.toString(), r"$['store']['book'][0]['title']"); - expect(path.filter(json).single.value, 'Sayings of the Century'); - expect(path.filter(json).single.path, r"$['store']['book'][0]['title']"); - }); - - test('Last element of array (regression #1)', () { - final path = JsonPath(r"$['store']['book'][3]['price']"); - expect(path.toString(), r"$['store']['book'][3]['price']"); - expect(path.filter(json).single.value, 22.99); - expect(path.filter(json).single.path, r"$['store']['book'][3]['price']"); - }); - - test('All in list', () { - final path = JsonPath(r'$.store.book[*]'); - expect(path.toString(), r"$['store']['book'][*]"); - 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']['book'][3]); - 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); - }); - }); -} diff --git a/test/parsing_test.dart b/test/parsing_test.dart new file mode 100644 index 0000000..05bfc40 --- /dev/null +++ b/test/parsing_test.dart @@ -0,0 +1,9 @@ +import 'package:json_path/json_path.dart'; +import 'package:test/test.dart'; + +void main() { + test('FormatException is thrown', () { + expect(() => JsonPath(r'$.foo[?]'), throwsFormatException); + expect(() => JsonPath(r'$.foo]'), throwsFormatException); + }); +} diff --git a/test/set_test.dart b/test/set_test.dart new file mode 100644 index 0000000..c10b326 --- /dev/null +++ b/test/set_test.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:json_path/json_path.dart'; +import 'package:test/test.dart'; + +void main() { + final json = jsonDecode(File('test/store.json').readAsStringSync()); + test('Root element', () { + final root = JsonPath(r'$'); + expect(root.set(json, 'foo'), 'foo'); + }); + group('Field', () { + final store = JsonPath(r'$.store'); + test('Replaces existing field', () { + expect(store.set(json, 'foo'), {'store': 'foo'}); + }); + test('Can be nested', () { + final color = JsonPath(r'$.store.bicycle.color'); + final mutated = color.set(json, 'blue') as dynamic; + expect(mutated['store']['bicycle']['color'], 'blue'); + expect(mutated['store']['bicycle']['price'], + json['store']['bicycle']['price']); + expect(mutated['store']['book'], json['store']['book']); + expect(json['store']['bicycle']['color'], 'red', + reason: 'Original bike is still red'); + }); + test('Does not set non-existing field', () { + final foo = JsonPath(r'$.foo'); + final mutated = foo.set(json, 42); + expect(mutated.containsKey('foo'), false); + expect(mutated['store'], json['store']); + }); + test('Does not change non-object (scalars, arrays)', () { + expect(store.set(42, 'foo'), 42); + expect(store.set([true], 'foo'), [true]); + }); + test('Replace all bicycle fields with a banana', () { + final bikeFields = JsonPath(r'$.store.bicycle.*'); + final mutated = bikeFields.set(json, 'banana'); + expect(mutated['store']['bicycle']['color'], 'banana'); + expect(mutated['store']['bicycle']['price'], 'banana'); + }); + }); + + test('Recursive. Set all prices to 0', () { + final prices = JsonPath(r'$..price'); + final mutated = prices.set(json, 0); + expect(mutated['store']['bicycle']['price'], 0); + expect(mutated['store']['book'][0]['price'], 0); + expect(mutated['store']['book'][1]['price'], 0); + expect(mutated['store']['book'][2]['price'], 0); + expect(mutated['store']['book'][3]['price'], 0); + }); + + test('Filter. Hide prices which are too high', () { + final prices = JsonPath(r'$..price[?high]', + filter: {'high': (_) => _ is num && _ > 15}); + final mutated = prices.set(json, 'hidden'); + expect(mutated['store']['bicycle']['price'], 'hidden'); + expect(mutated['store']['book'][0]['price'], 8.95); + expect(mutated['store']['book'][3]['price'], 'hidden'); + }); + + group('Index', () { + test('Index. Hide the prices of the fist book', () { + final price = JsonPath(r'$.store.book[0].price'); + final mutated = price.set(json, 'hidden'); + expect(mutated['store']['bicycle']['price'], isA()); + expect(mutated['store']['book'][0]['price'], 'hidden'); + expect(mutated['store']['book'][1]['price'], isA()); + expect(mutated['store']['book'][2]['price'], isA()); + expect(mutated['store']['book'][3]['price'], isA()); + }); + test('Index. Hide the prices of the second and last books', () { + final price = JsonPath(r'$.store.book[1,3].price'); + final mutated = price.set(json, 'hidden'); + expect(mutated['store']['bicycle']['price'], isA()); + expect(mutated['store']['book'][0]['price'], isA()); + expect(mutated['store']['book'][1]['price'], 'hidden'); + expect(mutated['store']['book'][2]['price'], isA()); + expect(mutated['store']['book'][3]['price'], 'hidden'); + }); + test('Index. Hide the prices of all books', () { + final price = JsonPath(r'$.store.book[*].price'); + final mutated = price.set(json, 'hidden'); + expect(mutated['store']['book'][0]['price'], 'hidden'); + expect(mutated['store']['book'][1]['price'], 'hidden'); + expect(mutated['store']['book'][2]['price'], 'hidden'); + expect(mutated['store']['book'][3]['price'], 'hidden'); + }); + test('Index. Replace every other book with a banana', () { + final price = JsonPath(r'$.store.book[::2]'); + final mutated = price.set(json, 'banana'); + expect(mutated['store']['book'][0], 'banana'); + expect(mutated['store']['book'][1]['price'], isA()); + expect(mutated['store']['book'][2], 'banana'); + expect(mutated['store']['book'][3]['price'], isA()); + }); + }); +}