Skip to content

Commit

Permalink
Initial release
Browse files Browse the repository at this point in the history
  • Loading branch information
f3ath committed Aug 3, 2020
1 parent 458c9af commit 14524b3
Show file tree
Hide file tree
Showing 9 changed files with 178 additions and 23 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
103 changes: 93 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/
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
5 changes: 4 additions & 1 deletion lib/src/json_path.dart
Original file line number Diff line number Diff line change
@@ -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<String, Predicate> filter})
: _selector = const Parser().parse(expression, filter ?? {});

final Selector _selector;

Expand All @@ -16,3 +18,4 @@ class JsonPath {
@override
String toString() => _selector.expression();
}

8 changes: 5 additions & 3 deletions lib/src/parser.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
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) {
Selector parse(String expression, Map<String, Predicate> 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;
}
Expand All @@ -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('|'));
}
28 changes: 21 additions & 7 deletions lib/src/parser_state.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<String, Predicate> filters);

/// Selector made from the tree
Selector get selector;
Expand All @@ -26,10 +28,10 @@ class Ready implements ParserState {
final Selector selector;

@override
ParserState process(Node node) {
ParserState process(Node node, Map<String, Predicate> 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 '..':
Expand All @@ -41,10 +43,10 @@ class Ready implements ParserState {
}
}

Selector bracketExpression(List<Node> nodes) {
Selector bracketExpression(List<Node> nodes, Map<String, Predicate> 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) {
Expand All @@ -54,11 +56,23 @@ class Ready implements ParserState {
throw FormatException('Unexpected bracket expression');
}

Selector multiValueBrackets(List<Node> nodes) {
Selector multiValueBrackets(
List<Node> nodes, Map<String, Predicate> filters) {
if (_isFilter(nodes)) return _filter(nodes, filters);
if (_isSlice(nodes)) return _slice(nodes);
return _union(nodes);
}

Filter _filter(List<Node> nodes, Map<String, Predicate> filters) {
final name = nodes[1].value;
if (!filters.containsKey(name)) {
throw FormatException('Filter not found: "${name}"');
}
return Filter(name, filters[name]);
}

bool _isFilter(List<Node> nodes) => nodes.first.value == '?';

bool _isSlice(List<Node> nodes) => nodes.any((node) => node.value == ':');

Selector _union(List<Node> nodes) {
Expand Down Expand Up @@ -101,7 +115,7 @@ class AwaitingField implements ParserState {
final Selector selector;

@override
ParserState process(Node node) {
ParserState process(Node node, Map<String, Predicate> filters) {
if (node.isWildcard) {
return Ready(selector.then(ObjectWildcard()));
}
Expand Down
1 change: 1 addition & 0 deletions lib/src/predicate.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
typedef Predicate = bool Function(dynamic element);
20 changes: 20 additions & 0 deletions lib/src/selector/filter.dart
Original file line number Diff line number Diff line change
@@ -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<Result> filter(Iterable<Result> results) =>
results.where((r) => predicate(r.value));

@override
String expression([Selector previous]) => '[?$name]';
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
27 changes: 27 additions & 0 deletions test/json_path_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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']");
Expand Down Expand Up @@ -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']");
});
Expand Down Expand Up @@ -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);
});
});
}

0 comments on commit 14524b3

Please sign in to comment.