diff --git a/README.rst b/README.rst index 822e186..00360a0 100644 --- a/README.rst +++ b/README.rst @@ -127,8 +127,10 @@ parenthesis. DjangoQL is case-sensitive. - model fields: exactly as they are defined in Python code. Access nested properties via ``.``, for example ``author.last_name``; -- strings must be double-quoted. Single quotes are not supported. - To escape a double quote use ``\"``; +- strings can be enclosed in either double quotes or single quotes. + To escape a quote, use ``\"`` for double quotes or ``\'`` for single quotes. + You can also use single quotes to enclose strings containing double quotes, + and vice versa; - boolean and null values: ``True``, ``False``, ``None``. Please note that they can be combined only with equality operators, so you can write ``published = False or date_published = None``, but diff --git a/djangoql/lexer.py b/djangoql/lexer.py index 580dab9..3affd26 100644 --- a/djangoql/lexer.py +++ b/djangoql/lexer.py @@ -53,9 +53,10 @@ def find_column(self, t): re_line_terminators = r'\n\r\u2028\u2029' - re_escaped_char = r'\\[\"\\/bfnrt]' + re_escaped_char = r'\\[\"\'/\\bfnrt]' re_escaped_unicode = r'\\u[0-9A-Fa-f]{4}' - re_string_char = r'[^\"\\' + re_line_terminators + u']' + re_string_char_double = r'[^\"\\' + re_line_terminators + u']' + re_string_char_single = r'[^\'\\' + re_line_terminators + u']' re_int_value = r'(-?0|-?[1-9][0-9]*)' re_fraction_part = r'\.[0-9]+' @@ -106,7 +107,10 @@ def find_column(self, t): @TOKEN(r'\"(' + re_escaped_char + '|' + re_escaped_unicode + - '|' + re_string_char + r')*\"') + '|' + re_string_char_double + r')*\"|' + + r'\'(' + re_escaped_char + + '|' + re_escaped_unicode + + '|' + re_string_char_single + r')*\'') def t_STRING_VALUE(self, t): t.value = t.value[1:-1] # cut leading and trailing quotes "" return t diff --git a/test_project/core/tests/test_lexer.py b/test_project/core/tests/test_lexer.py index 30e1fc3..3085d0e 100644 --- a/test_project/core/tests/test_lexer.py +++ b/test_project/core/tests/test_lexer.py @@ -76,6 +76,33 @@ def test_string(self): [('STRING_VALUE', s.strip('"'))], ) + def test_string_single_quotes(self): + for s in ("''", u"''", "'42'", r"'\t\n\u0042 ^'"): + self.assert_output( + self.lexer.input(s), + [('STRING_VALUE', s.strip("'"))], + ) + + def test_string_mixed_quotes(self): + self.assert_output( + self.lexer.input("""'He said "hello"'"""), + [('STRING_VALUE', 'He said "hello"')], + ) + self.assert_output( + self.lexer.input('''"It's working"'''), + [('STRING_VALUE', "It's working")], + ) + + def test_string_escaped_quotes(self): + self.assert_output( + self.lexer.input(r"'It\'s working'"), + [('STRING_VALUE', r"It\'s working")], + ) + self.assert_output( + self.lexer.input(r'"He said \"hello\""'), + [('STRING_VALUE', r'He said \"hello\"')], + ) + def test_illegal_chars(self): for s in ('"', '^'): try: diff --git a/test_project/core/tests/test_parser.py b/test_project/core/tests/test_parser.py index 3cf604c..e03d317 100644 --- a/test_project/core/tests/test_parser.py +++ b/test_project/core/tests/test_parser.py @@ -79,6 +79,31 @@ def test_escaped_chars(self): self.parser.parse(u'options = "\\u041f \\u0438 \\u0429"'), ) + def test_single_quoted_strings(self): + self.assertEqual( + Expression(Name('gender'), Comparison('='), Const('female')), + self.parser.parse("gender = 'female'"), + ) + self.assertEqual( + Expression(Name('name'), Comparison('~'), Const('He said "hello"')), + self.parser.parse("""name ~ 'He said "hello"'"""), + ) + self.assertEqual( + Expression(Name('name'), Comparison('~'), Const("It's working")), + self.parser.parse('''name ~ "It's working"'''), + ) + + def test_escaped_chars_single_quotes(self): + self.assertEqual( + Expression(Name('name'), Comparison('~'), + Const(u"It's working")), + self.parser.parse(u"name ~ 'It\\'s working'"), + ) + self.assertEqual( + Expression(Name('options'), Comparison('='), Const(u'П и Щ')), + self.parser.parse(u"options = '\\u041f \\u0438 \\u0429'"), + ) + def test_numbers(self): self.assertEqual( Expression(Name('pk'), Comparison('>'), Const(5)),