diff --git a/README.md b/README.md index 5e85b97..f39a69f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ where `parse_result` is a [ParseResults](https://pyparsing-docs.readthedocs.io/e - Subqueries (e.g., `SELECT Name, (SELECT LastName FROM Contacts) FROM Account`) - Aggregate queries -- SOQL specific WHERE-clause tokens (e.g., `LAST_N_DAYS:`) +- Some SOQL specific WHERE-clause tokens like Polymorphic Relationship Fields. [Date literals](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_dateformats.htm) _are_ supported (e.g., `LAST_N_DAYS:`). ## Partially supported diff --git a/python_soql_parser/core.py b/python_soql_parser/core.py index 8b014c1..01592c1 100644 --- a/python_soql_parser/core.py +++ b/python_soql_parser/core.py @@ -1,9 +1,11 @@ # stolen from https://github.com/pyparsing/pyparsing/blob/master/examples/simpleSQL.py from typing import Any, TypedDict +from functools import cache from pyparsing import ( CaselessKeyword, + Literal, Forward, Group, Optional, @@ -21,65 +23,79 @@ ) from python_soql_parser.binops import EQ, GT, GTE, LT, LTE, NEQ - -ParserElement.enablePackrat() - -select_statement = Forward() -SELECT, FROM, WHERE, AND, OR, IN, NULL, TRUE, FALSE, LIMIT, OFFSET, ORDER, BY, DESC, ASC = map( - CaselessKeyword, - "select from where and or in null true false limit offset order by desc asc".split(), -) - -identifier = Word(alphas, alphanums + "_" + ".").setName("identifier") -field_name = delimitedList(identifier).setName("field name") -field_name_list = Group(delimitedList(field_name)) -sobject_name = identifier.setName("sobject name") - - -binop = oneOf(f"{EQ} {NEQ} {LT} {LTE} {GT} {GTE}") -real_num = pyparsing_common.real() -int_num = pyparsing_common.signed_integer() -date_time = pyparsing_common.iso8601_datetime() - -field_right_value = date_time | real_num | int_num | quotedString | field_name -where_condition = Group( - (field_name + binop + field_right_value) - | (field_name + IN + Group("(" + delimitedList(field_right_value) + ")")) -) - -where_expression = infixNotation( - where_condition, - [ - (AND, 2, opAssoc.LEFT), - (OR, 2, opAssoc.LEFT), - ], -) - -where_clause = Optional(Suppress(WHERE) + where_expression, None) - -limit_clause = Optional(Suppress(LIMIT) + int_num, None) - -offset_clause = Optional(Suppress(OFFSET) + int_num, None) - -ordering_term = Group(field_name + Optional(ASC | DESC)("direction")) - -order_clause = Optional( - Suppress(ORDER) + Suppress(BY) + Group(delimitedList(ordering_term)) -) - -# define the grammar -select_statement <<= ( - SELECT - + field_name_list("fields") - + FROM - + sobject_name("sobject") - + where_clause("where") - + order_clause("order_by") - + limit_clause("limit") - + offset_clause("offset") -) - -soql = select_statement +from python_soql_parser.date_literals import CONSTANT_DATE_KEYWORDS, PARAMETERIZED_DATE_KEYWORDS + + +@cache +def create_soql_parser() -> ParserElement: + ParserElement.enablePackrat() + + select_statement = Forward() + SELECT, FROM, WHERE, AND, OR, IN, NULL, TRUE, FALSE, LIMIT, OFFSET, ORDER, BY, DESC, ASC = map( + CaselessKeyword, + "select from where and or in null true false limit offset order by desc asc".split(), + ) + + identifier = Word(alphas, alphanums + "_" + ".").setName("identifier") + field_name = delimitedList(identifier).setName("field name") + field_name_list = Group(delimitedList(field_name)) + sobject_name = identifier.setName("sobject name") + + binop = oneOf(f"{EQ} {NEQ} {LT} {LTE} {GT} {GTE}") + real_num = pyparsing_common.real() + int_num = pyparsing_common.signed_integer() + date_time = pyparsing_common.iso8601_datetime() + # See https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_dateformats.htm + constant_date_literal = oneOf(CONSTANT_DATE_KEYWORDS)("constant date literal") + parameterized_date_literal = Group( + oneOf(PARAMETERIZED_DATE_KEYWORDS) + Suppress(Literal(":")) + int_num + )("parameterized date literal") + + field_right_value = ( + date_time | real_num | int_num | quotedString + | constant_date_literal | parameterized_date_literal + | field_name + ) + where_condition = Group( + (field_name + binop + field_right_value) + | (field_name + IN + Group("(" + delimitedList(field_right_value) + ")")) + ) + + where_expression = infixNotation( + where_condition, + [ + (AND, 2, opAssoc.LEFT), + (OR, 2, opAssoc.LEFT), + ], + ) + + where_clause = Optional(Suppress(WHERE) + where_expression, None) + + limit_clause = Optional(Suppress(LIMIT) + int_num, None) + + offset_clause = Optional(Suppress(OFFSET) + int_num, None) + + ordering_term = Group(field_name + Optional(ASC | DESC)("direction")) + + order_clause = Optional( + Suppress(ORDER) + Suppress(BY) + Group(delimitedList(ordering_term)) + ) + + # define the grammar + select_statement <<= ( + SELECT + + field_name_list("fields") + + FROM + + sobject_name("sobject") + + where_clause("where") + + order_clause("order_by") + + limit_clause("limit") + + offset_clause("offset") + ) + + soql: ParserElement = select_statement + + return soql class SoqlQuery(TypedDict): @@ -92,4 +108,4 @@ class SoqlQuery(TypedDict): def parse(soql_query: str) -> SoqlQuery: - return soql.parseString(soql_query) + return create_soql_parser().parseString(soql_query) diff --git a/python_soql_parser/date_literals.py b/python_soql_parser/date_literals.py new file mode 100644 index 0000000..1ed9cab --- /dev/null +++ b/python_soql_parser/date_literals.py @@ -0,0 +1,49 @@ +CONSTANT_DATE_KEYWORDS = [ + "YESTERDAY", + "TODAY", + "TOMORROW", + "LAST_WEEK", + "THIS_WEEK", + "NEXT_WEEK", + "LAST_MONTH", + "THIS_MONTH", + "NEXT_MONTH", + "LAST_90_DAYS", + "NEXT_90_DAYS", + "THIS_QUARTER", + "LAST_QUARTER", + "NEXT_QUARTER", + "THIS_YEAR", + "LAST_YEAR", + "NEXT_YEAR", + "THIS_FISCAL_QUARTER", + "LAST_FISCAL_QUARTER", + "NEXT_FISCAL_QUARTER", + "THIS_FISCAL_YEAR", + "LAST_FISCAL_YEAR", + "NEXT_FISCAL_YEAR", +] + +PARAMETERIZED_DATE_KEYWORDS = [ + "LAST_N_DAYS", + "NEXT_N_DAYS", + "N_DAYS_AGO", + "NEXT_N_WEEKS", + "LAST_N_WEEKS", + "N_WEEKS_AGO", + "NEXT_N_MONTHS", + "LAST_N_MONTHS", + "N_MONTHS_AGO", + "NEXT_N_QUARTERS", + "LAST_N_QUARTERS", + "N_QUARTERS_AGO", + "NEXT_N_YEARS", + "LAST_N_YEARS", + "N_YEARS_AGO", + "NEXT_N_FISCAL_​QUARTERS", + "LAST_N_FISCAL_​QUARTERS", + "N_FISCAL_QUARTERS_AGO", + "NEXT_N_FISCAL_​YEARS", + "LAST_N_FISCAL_​YEARS", + "N_FISCAL_YEARS_AGO", +] diff --git a/python_soql_parser/tokens.py b/python_soql_parser/tokens.py deleted file mode 100644 index e66d2de..0000000 --- a/python_soql_parser/tokens.py +++ /dev/null @@ -1,37 +0,0 @@ -YESTERDAY = "YESTERDAY" -TODAY = "TODAY" -TOMORROW = "TOMORROW" -LAST_WEEK = "LAST_WEEK" -THIS_WEEK = "THIS_WEEK" -NEXT_WEEK = "NEXT_WEEK" -LAST_MONTH = "LAST_MONTH" -THIS_MONTH = "THIS_MONTH" -NEXT_MONTH = "NEXT_MONTH" -LAST_90_DAYS = "LAST_90_DAYS" -NEXT_90_DAYS = "NEXT_90_DAYS" -LAST_N_DAYS = "LAST_N_DAYS" -NEXT_N_DAYS = "NEXT_N_DAYS" -NEXT_N_WEEKS = "NEXT_N_WEEKS" -LAST_N_WEEKS = "LAST_N_WEEKS" -NEXT_N_MONTHS = "NEXT_N_MONTHS" -LAST_N_MONTHS = "LAST_N_MONTHS" -THIS_QUARTER = "THIS_QUARTER" -LAST_QUARTER = "LAST_QUARTER" -NEXT_QUARTER = "NEXT_QUARTER" -NEXT_N_QUARTERS = "NEXT_N_QUARTERS" -LAST_N_QUARTERS = "LAST_N_QUARTERS" -THIS_YEAR = "THIS_YEAR" -LAST_YEAR = "LAST_YEAR" -NEXT_YEAR = "NEXT_YEAR" -NEXT_N_YEARS = "NEXT_N_YEARS" -LAST_N_YEARS = "LAST_N_YEARS" -THIS_FISCAL_QUARTER = "THIS_FISCAL_QUARTER" -LAST_FISCAL_QUARTER = "LAST_FISCAL_QUARTER" -NEXT_FISCAL_QUARTER = "NEXT_FISCAL_QUARTER" -NEXT_N_FISCAL_QUARTERS = "NEXT_N_FISCAL_​QUARTERS" -LAST_N_FISCAL_QUARTERS = "LAST_N_FISCAL_​QUARTERS" -THIS_FISCAL_YEAR = "THIS_FISCAL_YEAR" -LAST_FISCAL_YEAR = "LAST_FISCAL_YEAR" -NEXT_FISCAL_YEAR = "NEXT_FISCAL_YEAR" -NEXT_N_FISCAL_YEARS = "NEXT_N_FISCAL_YEARS" -LAST_N_FISCAL_YEARS = "LAST_N_FISCAL_YEARS"