Skip to content

Commit

Permalink
Merge pull request #39 from Accenture/develop
Browse files Browse the repository at this point in the history
Add CASE WHEN clauses
  • Loading branch information
Roei-Levi authored Nov 6, 2024
2 parents 9e23b63 + ba21284 commit a23cf06
Show file tree
Hide file tree
Showing 14 changed files with 205 additions and 51 deletions.
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
-r requirements-test.txt
pip-tools==6.6.0
setuptools==65.5.1
setuptools==70.0.0
wheel==0.38.1
pylint==2.13.5
pycodestyle==2.8.0
Expand Down
61 changes: 51 additions & 10 deletions src/cymple/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# pylint: disable=R0901
# pylint: disable=R0903
# pylint: disable=W0102
from typing import List, Union
from typing import List, Union, Dict, Any
from .typedefs import Mapping, Properties


Expand Down Expand Up @@ -52,6 +52,41 @@ def call(self):
return CallAvailable(self.query + ' CALL')


class Case(Query):
"""A class for representing a "CASE" clause."""

def case(self, when_then_mapping: Dict[str, Union[List[str], str]], default_result: str, results_ref: str = None, test_expression: str = None):
"""Concatenate a CASE clause to the query, to compare a single expression against multiple values or to express multiple conditional statements.
:param when_then_mapping: A dictionary such that a value represents a literal expression (or a list of
expressions) whose result will be compared to test_expression if given, else is evaluated to a BOOLEAN, and it's key represents the literal expression returned as output if the value matches test_expression if giver, or if the expression evaluates to TRUE.
:type when_then_mapping: Dict[str, Union[List[str], str]]
:param default_result: The expression to return if no value matches the test expression if given, or if all
expressions evaluated as FALSE.
:type default_result: str
:param results_ref: The reference name of the resulted returned values, plus any other desired reference names
to return., defaults to None
:type results_ref: str
:param test_expression: An expression to test the cases against. For example, 'n.name'. Optional., defaults to
None
:type test_expression: str
:return: A Query object with a query that contains the new clause.
:rtype: CaseAvailable
"""
ret = " CASE"
if test_expression is not None:
ret += f" {test_expression}"
for then, when in when_then_mapping.items():
if type(when) is not list:
when = [when]
ret += f" WHEN {', '.join(when)} THEN {then}"
ret += f" ELSE {default_result} END"
if results_ref is not None:
ret += f" AS {results_ref}"
return CaseAvailable(self.query + ret)


class CaseWhen(Query):
"""A class for representing a "CASE WHEN" clause."""

Expand All @@ -78,7 +113,7 @@ def case_when(self, filters: dict, on_true: str, on_false: str, ref_name: str, c
:rtype: CaseWhenAvailable
"""
filt = ' CASE WHEN ' + Properties(filters).to_str(comparison_operator, boolean_operator, **kwargs)
filt += f' THEN {on_true} ELSE {on_false} END as {ref_name}'
filt += f' THEN {on_true} ELSE {on_false} END AS {ref_name}'
return CaseWhenAvailable(self.query + filt)


Expand Down Expand Up @@ -614,17 +649,19 @@ def remove(self, properties: Union[str, List[str]]):
class Return(Query):
"""A class for representing a "RETURN" clause."""

def return_literal(self, literal: str):
def return_literal(self, literal: str = None):
"""Concatenate a literal RETURN statement.
:param literal: A Cypher string describing the objects to be returned, referencing name/names which were
defined earlier in the query
defined earlier in the query, defaults to None
:type literal: str
:return: A Query object with a query that contains the new clause.
:rtype: ReturnAvailable
"""
ret = f' RETURN {literal}'
ret = f' RETURN'
if literal is not None:
ret += f' {literal}'

return ReturnAvailable(self.query + ret)

Expand All @@ -642,7 +679,7 @@ def return_mapping(self, mappings: List[Mapping]):

ret = ' RETURN ' + \
', '.join(
f'{mapping[0]} as {mapping[1]}' if mapping[1] else mapping[0].replace(".", "_")
f'{mapping[0]} AS {mapping[1]}' if mapping[1] else mapping[0].replace(".", "_")
for mapping in mappings)

return ReturnAvailable(self.query + ret)
Expand Down Expand Up @@ -840,7 +877,7 @@ def yield_(self, mappings: List[Mapping]):
mappings = [mappings]

query = ' YIELD ' + \
', '.join(f'{mapping[0]} as '
', '.join(f'{mapping[0]} AS '
f'{mapping[1] if mapping[1] else mapping[0].replace(".", "_")}'
for mapping in mappings)
return YieldAvailable(self.query + query)
Expand All @@ -854,6 +891,10 @@ class CallAvailable(Procedure):
"""A class decorator declares a Call is available in the current query."""


class CaseAvailable(QueryStartAvailable, Unwind, Where, Set, Remove, CaseWhen, Return, Limit, Skip, OrderBy, Union):
"""A class decorator declares a Case is available in the current query."""


class CaseWhenAvailable(QueryStartAvailable, Unwind, Where, CaseWhen, Return, Set):
"""A class decorator declares a CaseWhen is available in the current query."""

Expand Down Expand Up @@ -922,7 +963,7 @@ class RemoveAvailable(Set, Return, Union):
"""A class decorator declares a Remove is available in the current query."""


class ReturnAvailable(QueryStartAvailable, Unwind, Return, Limit, Skip, OrderBy, Union):
class ReturnAvailable(QueryStartAvailable, Unwind, Return, Limit, Skip, OrderBy, Union, CaseWhen, Case):
"""A class decorator declares a Return is available in the current query."""


Expand Down Expand Up @@ -950,15 +991,15 @@ class WhereAvailable(Return, Delete, Where, Set, Remove, OperatorStart, QuerySta
"""A class decorator declares a Where is available in the current query."""


class WithAvailable(QueryStartAvailable, Unwind, Where, Set, Remove, CaseWhen, Return, Limit, Skip, OrderBy):
class WithAvailable(QueryStartAvailable, Unwind, Where, Set, Remove, CaseWhen, Return, Limit, Skip, OrderBy, Case):
"""A class decorator declares a With is available in the current query."""


class YieldAvailable(QueryStartAvailable, Node, Where, Return):
"""A class decorator declares a Yield is available in the current query."""


class AnyAvailable(Call, CaseWhen, Create, Delete, Limit, Match, Merge, Node, NodeAfterMerge, OnCreate, OnMatch, OperatorEnd, OperatorStart, OrderBy, Procedure, QueryStart, Relation, RelationAfterMerge, Remove, Return, Set, SetAfterMerge, Skip, Union, Unwind, Where, With, Yield):
class AnyAvailable(Call, Case, CaseWhen, Create, Delete, Limit, Match, Merge, Node, NodeAfterMerge, OnCreate, OnMatch, OperatorEnd, OperatorStart, OrderBy, Procedure, QueryStart, Relation, RelationAfterMerge, Remove, Return, Set, SetAfterMerge, Skip, Union, Unwind, Where, With, Yield):
"""A class decorator declares anything is available in the current query."""


Expand Down
42 changes: 42 additions & 0 deletions src/cymple/internal/declarations/case.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"clause_name": "CASE",
"methods": [
{
"name": "case",
"docstring_summary": "Concatenate a CASE clause to the query, to compare a single expression against multiple values or to express multiple conditional statements.",
"args": {
"when_then_mapping": {
"type": "Dict[str, Union[List[str], str]]",
"description": "A dictionary such that a value represents a literal expression (or a list of expressions) whose result will be compared to test_expression if given, else is evaluated to a BOOLEAN, and it's key represents the literal expression returned as output if the value matches test_expression if giver, or if the expression evaluates to TRUE."
},
"default_result": {
"type": "str",
"description": "The expression to return if no value matches the test expression if given, or if all expressions evaluated as FALSE."
},
"results_ref": {
"type": "str",
"description": "The reference name of the resulted returned values, plus any other desired reference names to return.",
"default": "None"
},
"test_expression": {
"type": "str",
"description": "An expression to test the cases against. For example, 'n.name'. Optional.",
"default": "None"
}
}
}
],
"successors": [
"QueryStartAvailable",
"Unwind",
"Where",
"Set",
"Remove",
"CaseWhen",
"Return",
"Limit",
"Skip",
"OrderBy",
"Union"
]
}
7 changes: 5 additions & 2 deletions src/cymple/internal/declarations/return.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"args": {
"literal": {
"type": "str",
"description": "A Cypher string describing the objects to be returned, referencing name/names which were defined earlier in the query"
"description": "A Cypher string describing the objects to be returned, referencing name/names which were defined earlier in the query",
"default": "None"
}
}
},
Expand All @@ -29,6 +30,8 @@
"Limit",
"Skip",
"OrderBy",
"Union"
"Union",
"CaseWhen",
"Case"
]
}
3 changes: 2 additions & 1 deletion src/cymple/internal/declarations/with.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"Return",
"Limit",
"Skip",
"OrderBy"
"OrderBy",
"Case"
]
}
2 changes: 1 addition & 1 deletion src/cymple/internal/internal_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ def render_builder_code():
clauses_output += '# pylint: disable=R0901\n'
clauses_output += '# pylint: disable=R0903\n'
clauses_output += '# pylint: disable=W0102\n'
clauses_output += 'from typing import List, Union\n'
clauses_output += 'from typing import List, Union, Dict, Any\n'
clauses_output += 'from .typedefs import Mapping, Properties\n\n'
clauses_output += inspect.getsource(query_class) + '\n\n'

Expand Down
21 changes: 15 additions & 6 deletions src/cymple/internal/overloads/case.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@


def case_when(self, filters: dict, on_true: str, on_false: str, ref_name: str, comparison_operator: str = '"', boolean_operator: str = 'AND', **kwargs):
filt = ' CASE WHEN ' + Properties(filters).to_str(comparison_operator, boolean_operator, **kwargs)
filt += f' THEN {on_true} ELSE {on_false} END as {ref_name}'
return CaseWhenAvailable(self.query + filt)
from typing import Dict, List, Union


def case(when_then_mapping: Dict[str, Union[List[str], str]], default_result: str, results_ref: str, test_expression: str = None):
ret = " CASE"
if test_expression is not None:
ret += f" {test_expression}"
for then, when in when_then_mapping.items():
if type(when) is not list:
when = [when]
ret += f" WHEN {', '.join(when)} THEN {then}"
ret += f" ELSE {default_result} END"
if results_ref is not None:
ret += f" AS {results_ref}"
return CaseAvailable(self.query + ret)
6 changes: 6 additions & 0 deletions src/cymple/internal/overloads/case_when.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@


def case_when(self, filters: dict, on_true: str, on_false: str, ref_name: str, comparison_operator: str = '"', boolean_operator: str = 'AND', **kwargs):
filt = ' CASE WHEN ' + Properties(filters).to_str(comparison_operator, boolean_operator, **kwargs)
filt += f' THEN {on_true} ELSE {on_false} END AS {ref_name}'
return CaseWhenAvailable(self.query + filt)
8 changes: 5 additions & 3 deletions src/cymple/internal/overloads/returns.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@

def return_literal(self, literal: str):
ret = f' RETURN {literal}'
def return_literal(self, literal):
ret = f' RETURN'
if literal is not None:
ret += f' {literal}'

return ReturnAvailable(self.query + ret)

Expand All @@ -11,7 +13,7 @@ def return_mapping(self, mappings):

ret = ' RETURN ' + \
', '.join(
f'{mapping[0]} as {mapping[1]}' if mapping[1] else mapping[0].replace(".", "_")
f'{mapping[0]} AS {mapping[1]}' if mapping[1] else mapping[0].replace(".", "_")
for mapping in mappings)

return ReturnAvailable(self.query + ret)
2 changes: 1 addition & 1 deletion src/cymple/internal/overloads/yield_.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ def yield_(self, mappings):
mappings = [mappings]

query = ' YIELD ' + \
', '.join(f'{mapping[0]} as '
', '.join(f'{mapping[0]} AS '
f'{mapping[1] if mapping[1] else mapping[0].replace(".", "_")}'
for mapping in mappings)
return YieldAvailable(self.query + query)
2 changes: 1 addition & 1 deletion src/cymple/version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Version information for Cymple"""

__version__: str = "0.11.0"
__version__: str = "0.12.0"
33 changes: 25 additions & 8 deletions tests/unit/test_clauses.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,18 @@
'UNION': qb.match().node(ref_name='n', labels="Actor").return_mapping([('n.name', 'name')]).union().match().node(
ref_name='n', labels="Movie").return_mapping([('n.title', 'name')]),
'UNION (all)': qb.match().node(ref_name='n', labels="Actor").return_mapping(
[('n.name', 'name')]).union_all().match().node(ref_name='n', labels="Movie").return_mapping([('n.title', 'name')])
[('n.name', 'name')]).union_all().match().node(ref_name='n', labels="Movie").return_mapping([('n.title', 'name')]),
'CASE (simple)': qb.match().node(ref_name="n", labels="Person").return_literal().case(when_then_mapping={"1": "'blue'", "2": ["'brown'", "'hazel'"]}, default_result="3", results_ref="result, n.eyes", test_expression="n.eyes"),
'CASE (simple extended)': qb.match().node(ref_name="n", labels="Person").return_literal("n.name,").case(when_then_mapping={"'Unknown'": ["IS NULL", "IS NOT TYPED INTEGER | FLOAT"], "'Baby'": ["= 0", "= 1", "= 2"], "'Child'": "<= 13", "'Teenager'": "< 20", "'Young Adult'": "< 30", "'Immortal'": "> 1000"}, default_result="'Adult'", test_expression="n.age", results_ref="result"),
'CASE (generic)': qb.match().node(ref_name="n", labels="Person").return_literal().case(when_then_mapping={"1": "n.eyes = 'blue'", "2": "n.age < 40"}, default_result="3", results_ref="result, n.eyes, n.age"),
'CASE (null values)': qb.match().node(ref_name="n", labels="Person").return_literal("n.name,").case(when_then_mapping={"-1": "n.age IS NULL"}, default_result="n.age - 10", results_ref="age_10_years_ago"),
'CASE (with set)': qb.match().node(ref_name="n", labels="Person").with_("n,").case(when_then_mapping={"1": "'blue'", "2": "'brown'"}, default_result="3", results_ref="colorCode", test_expression="n.eyes").set({"n.colorCode": "colorCode"}, escape_values=False).return_literal("n.name, n.colorCode")
}

expected = {
'_RESET_': '',
'CALL': 'CALL db.labels()',
'CASE WHEN': 'MATCH (n) WITH n CASE WHEN n.name = "Bob" THEN true ELSE false END as my_boolean',
'CASE WHEN': 'MATCH (n) WITH n CASE WHEN n.name = "Bob" THEN true ELSE false END AS my_boolean',
'DELETE': 'MATCH (n) DELETE n',
'DETACH DELETE': 'MATCH (n) DETACH DELETE n',
'WHERE (single)': 'MATCH (n) WHERE n.name = "value"',
Expand All @@ -85,8 +90,8 @@
'RELATION (variable length, empty)': 'MATCH ()-[*]-()',
'RELATION (variable length, with label)': 'MATCH ()-[rel: Relation*1..2]-()',
'RETURN (literal)': 'MATCH (n) RETURN n',
'RETURN (mapping)': 'MATCH (n) RETURN n.name as name',
'RETURN (mapping, list)': 'MATCH (n) RETURN n.name as name, n.age as age',
'RETURN (mapping)': 'MATCH (n) RETURN n.name AS name',
'RETURN (mapping, list)': 'MATCH (n) RETURN n.name AS name, n.age AS age',
'SET': 'MERGE (n) SET n.name = "Alice"',
'SET (not escaping)': 'MERGE (n) SET n.name = n.name + "!"',
'SET (string)': 'MERGE (n) SET n.name = n.name + "!"',
Expand All @@ -97,8 +102,8 @@
'UNWIND': 'MATCH (n) WITH n UNWIND n',
'WITH': 'MATCH (a) WITH a,b',
'WITH (start)': 'WITH a MATCH (a) WITH a,b',
'YIELD': 'CALL db.labels() YIELD label as label WHERE label CONTAINS "User" RETURN count(label) AS numLabels',
'YIELD (list)': 'CALL db.labels() YIELD length(labels) as len, count(label) as cnt',
'YIELD': 'CALL db.labels() YIELD label AS label WHERE label CONTAINS "User" RETURN count(label) AS numLabels',
'YIELD (list)': 'CALL db.labels() YIELD length(labels) AS len, count(label) AS cnt',
'LIMIT': 'MATCH (n) RETURN n LIMIT 1',
'LIMIT (expression)': 'MATCH (n) RETURN n LIMIT 1 + toInteger(3 * rand())',
'LIMIT (with)': 'MATCH (n) WITH n LIMIT 1',
Expand All @@ -114,8 +119,20 @@
'CREATE': 'CREATE (n) RETURN n',
'REMOVE': 'MATCH (n) REMOVE n.name RETURN n.age, n.name',
'REMOVE (list)': 'MATCH (n) REMOVE n.age, n.name RETURN n.age, n.name',
'UNION': 'MATCH (n: Actor) RETURN n.name as name UNION MATCH (n: Movie) RETURN n.title as name',
'UNION (all)': 'MATCH (n: Actor) RETURN n.name as name UNION ALL MATCH (n: Movie) RETURN n.title as name',
'UNION': 'MATCH (n: Actor) RETURN n.name AS name UNION MATCH (n: Movie) RETURN n.title AS name',
'UNION (all)': 'MATCH (n: Actor) RETURN n.name AS name UNION ALL MATCH (n: Movie) RETURN n.title AS name',
'CASE (simple)': "MATCH (n: Person) RETURN CASE n.eyes WHEN 'blue' THEN 1 WHEN 'brown', 'hazel' THEN 2 ELSE 3 END"
" AS result, n.eyes",
'CASE (simple extended)': "MATCH (n: Person) RETURN n.name, CASE n.age WHEN IS NULL, IS NOT TYPED INTEGER | FLOAT"
" THEN 'Unknown' WHEN = 0, = 1, = 2 THEN 'Baby' WHEN <= 13 THEN 'Child' WHEN < 20 THEN"
" 'Teenager' WHEN < 30 THEN 'Young Adult' WHEN > 1000 THEN 'Immortal' ELSE 'Adult' END"
" AS result",
'CASE (generic)': "MATCH (n: Person) RETURN CASE WHEN n.eyes = 'blue' THEN 1 WHEN n.age < 40 THEN 2 ELSE 3 END"
" AS result, n.eyes, n.age",
'CASE (null values)': "MATCH (n: Person) RETURN n.name, CASE WHEN n.age IS NULL THEN -1 ELSE n.age - 10 END"
" AS age_10_years_ago",
'CASE (with set)': "MATCH (n: Person) WITH n, CASE n.eyes WHEN 'blue' THEN 1 WHEN 'brown' THEN 2 ELSE 3 END "
"AS colorCode SET n.colorCode = colorCode RETURN n.name, n.colorCode"
}


Expand Down
Loading

0 comments on commit a23cf06

Please sign in to comment.