Skip to content

Commit a039cf8

Browse files
committed
Support json query list sorting
Related bug: kennknowles#8
1 parent eb23b1e commit a039cf8

File tree

5 files changed

+101
-8
lines changed

5 files changed

+101
-8
lines changed

jsonpath_rw/jsonpath.py

+51
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import six
44
from six.moves import xrange
55
from itertools import *
6+
import functools
67

78
logger = logging.getLogger(__name__)
89

@@ -508,3 +509,53 @@ def __repr__(self):
508509

509510
def __eq__(self, other):
510511
return isinstance(other, Slice) and other.start == self.start and self.end == other.end and other.step == self.step
512+
513+
514+
class SortedThis(This):
515+
"""The JSONPath referring to the sorted version of the current object.
516+
517+
Concrete syntax is [\\field,/field].
518+
"""
519+
def __init__(self, expressions=None):
520+
self.expressions = expressions
521+
522+
def _compare(self, left, right):
523+
left = DatumInContext.wrap(left)
524+
right = DatumInContext.wrap(right)
525+
526+
for expr in self.expressions:
527+
field, reverse = expr
528+
l_datum = field.find(left)
529+
r_datum = field.find(right)
530+
if (not l_datum or not r_datum or
531+
len(l_datum) > 1 or len(r_datum) > 1 or
532+
l_datum[0].value == r_datum[0].value):
533+
# NOTE(sileht): should we do something if the expression
534+
# match multiple fields, for now ignore them
535+
continue
536+
elif l_datum[0].value < r_datum[0].value:
537+
return 1 if reverse else -1
538+
else:
539+
return -1 if reverse else 1
540+
return 0
541+
542+
def find(self, datum):
543+
"""Return sorted value of This if list or dict."""
544+
if isinstance(datum.value, dict) and self.expressions:
545+
return datum
546+
547+
if isinstance(datum.value, dict) or isinstance(datum.value, list):
548+
key = (functools.cmp_to_key(self._compare)
549+
if self.expressions else None)
550+
return [DatumInContext.wrap(value)
551+
for value in sorted(datum.value, key=key)]
552+
return datum
553+
554+
def __eq__(self, other):
555+
return isinstance(other, Len)
556+
557+
def __repr__(self):
558+
return '%s(%r)' % (self.__class__.__name__, self.expressions)
559+
560+
def __str__(self):
561+
return '[?%s]' % self.expressions

jsonpath_rw/lexer.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def tokenize(self, string):
5050

5151
reserved_words = { 'where': 'WHERE' }
5252

53-
tokens = ['DOUBLEDOT', 'NUMBER', 'ID', 'NAMED_OPERATOR'] + list(reserved_words.values())
53+
tokens = ['DOUBLEDOT', 'NUMBER', 'ID', 'NAMED_OPERATOR', 'SORT_DIRECTION'] + list(reserved_words.values())
5454

5555
states = [ ('singlequote', 'exclusive'),
5656
('doublequote', 'exclusive'),
@@ -70,6 +70,10 @@ def t_NUMBER(self, t):
7070
t.value = int(t.value)
7171
return t
7272

73+
def t_SORT_DIRECTION(self, t):
74+
r',?\s*(/|\\)'
75+
t.value = t.value[-1]
76+
return t
7377

7478
# Single-quoted strings
7579
t_singlequote_ignore = ''

jsonpath_rw/parser.py

+25-7
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class JsonPathParser(object):
1717
'''
1818
An LALR-parser for JsonPath
1919
'''
20-
20+
2121
tokens = JsonPathLexer.tokens
2222

2323
def __init__(self, debug=False, lexer_class=None):
@@ -40,7 +40,7 @@ def parse_token_stream(self, token_iterator, start_symbol='jsonpath'):
4040
module_name = os.path.splitext(os.path.split(__file__)[1])[0]
4141
except:
4242
module_name = __name__
43-
43+
4444
parsing_table_module = '_'.join([module_name, start_symbol, 'parsetab'])
4545

4646
# And we regenerate the parse table every time; it doesn't actually take that long!
@@ -55,7 +55,7 @@ def parse_token_stream(self, token_iterator, start_symbol='jsonpath'):
5555
return new_parser.parse(lexer = IteratorToTokenStream(token_iterator))
5656

5757
# ===================== PLY Parser specification =====================
58-
58+
5959
precedence = [
6060
('left', ','),
6161
('left', 'DOUBLEDOT'),
@@ -66,10 +66,10 @@ def parse_token_stream(self, token_iterator, start_symbol='jsonpath'):
6666
]
6767

6868
def p_error(self, t):
69-
raise Exception('Parse error at %s:%s near token %s (%s)' % (t.lineno, t.col, t.value, t.type))
69+
raise Exception('Parse error at %s:%s near token %s (%s)' % (t.lineno, t.col, t.value, t.type))
7070

7171
def p_jsonpath_binop(self, p):
72-
"""jsonpath : jsonpath '.' jsonpath
72+
"""jsonpath : jsonpath '.' jsonpath
7373
| jsonpath DOUBLEDOT jsonpath
7474
| jsonpath WHERE jsonpath
7575
| jsonpath '|' jsonpath
@@ -134,7 +134,7 @@ def p_jsonpath_parens(self, p):
134134

135135
# Because fields in brackets cannot be '*' - that is reserved for array indices
136136
def p_fields_or_any(self, p):
137-
"""fields_or_any : fields
137+
"""fields_or_any : fields
138138
| '*' """
139139
if p[1] == '*':
140140
p[0] = ['*']
@@ -165,11 +165,29 @@ def p_maybe_int(self, p):
165165
"""maybe_int : NUMBER
166166
| empty"""
167167
p[0] = p[1]
168-
168+
169169
def p_empty(self, p):
170170
'empty :'
171171
p[0] = None
172172

173+
def p_sort(self, p):
174+
"sort : SORT_DIRECTION jsonpath"
175+
p[0] = (p[2], p[1] != "/")
176+
177+
def p_sorts_sort(self, p):
178+
"sorts : sort"
179+
p[0] = [p[1]]
180+
181+
def p_sorts_comma(self, p):
182+
"sorts : sorts sorts"
183+
p[0] = p[1] + p[2]
184+
185+
def p_jsonpath_sort(self, p):
186+
"jsonpath : jsonpath '[' sorts ']'"
187+
sort = SortedThis(p[3])
188+
p[0] = Child(p[1], sort)
189+
190+
173191
class IteratorToTokenStream(object):
174192
def __init__(self, iterator):
175193
self.iterator = iterator

tests/test_jsonpath.py

+18
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,24 @@ def test_fields_value(self):
110110
jsonpath.auto_id_field = 'id'
111111
self.check_cases([ ('*', {'foo': 1, 'baz': 2}, set([1, 2, '`this`'])) ])
112112

113+
def test_sort_value(self):
114+
jsonpath.auto_id_field = None
115+
self.check_cases([
116+
('objects[/cow]', {'objects': [{'cat': 1, 'cow': 2}, {'cat': 2, 'cow': 1}, {'cat': 3, 'cow': 3}]},
117+
[{'cat': 2, 'cow': 1}, {'cat': 1, 'cow': 2}, {'cat': 3, 'cow': 3}]),
118+
('objects[\cat]', {'objects': [{'cat': 2}, {'cat': 1}, {'cat': 3}]},
119+
[{'cat': 3}, {'cat': 2}, {'cat': 1}]),
120+
('objects[/cow,\cat]', {'objects': [{'cat': 1, 'cow': 2}, {'cat': 2, 'cow': 1}, {'cat': 3, 'cow': 1}, {'cat': 3, 'cow': 3}]},
121+
[{'cat': 3, 'cow': 1}, {'cat': 2, 'cow': 1}, {'cat': 1, 'cow': 2}, {'cat': 3, 'cow': 3}]),
122+
('objects[\cow , /cat]', {'objects': [{'cat': 1, 'cow': 2}, {'cat': 2, 'cow': 1}, {'cat': 3, 'cow': 1}, {'cat': 3, 'cow': 3}]},
123+
[{'cat': 3, 'cow': 3}, {'cat': 1, 'cow': 2}, {'cat': 2, 'cow': 1}, {'cat': 3, 'cow': 1}]),
124+
('objects[/cat.cow]', {'objects': [{'cat': {'dog': 1, 'cow': 2}}, {'cat': {'dog': 2, 'cow': 1}}, {'cat': {'dog': 3, 'cow': 3}}]},
125+
[{'cat': {'dog': 2, 'cow': 1}}, {'cat': {'dog': 1, 'cow': 2}}, {'cat': {'dog': 3, 'cow': 3}}]),
126+
('objects[/cat.(cow,bow)]', {'objects': [{'cat': {'dog': 1, 'bow': 3}}, {'cat': {'dog': 2, 'cow': 1}}, {'cat': {'dog': 2, 'bow': 2}}, {'cat': {'dog': 3, 'cow': 2}}]},
127+
[{'cat': {'dog': 2, 'cow': 1}}, {'cat': {'dog': 2, 'bow': 2}}, {'cat': {'dog': 3, 'cow': 2}}, {'cat': {'dog': 1, 'bow': 3}}]),
128+
])
129+
130+
113131
def test_root_value(self):
114132
jsonpath.auto_id_field = None
115133
self.check_cases([

tests/test_lexer.py

+2
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ def test_simple_inputs(self):
5353
self.assert_lex_equiv('`this`', [self.token('this', 'NAMED_OPERATOR')])
5454
self.assert_lex_equiv('|', [self.token('|', '|')])
5555
self.assert_lex_equiv('where', [self.token('where', 'WHERE')])
56+
self.assert_lex_equiv('/', [self.token('/', 'SORT_DIRECTION')])
57+
self.assert_lex_equiv('\\', [self.token('\\', 'SORT_DIRECTION')])
5658

5759
def test_basic_errors(self):
5860
def tokenize(s):

0 commit comments

Comments
 (0)