From 375a8d76a7aa22461f7487759bcc683cfbf7b261 Mon Sep 17 00:00:00 2001 From: Zaid Date: Tue, 7 Jul 2026 14:14:12 +0530 Subject: [PATCH] Add IS NOT NULL operator to query builder Adds Operator.IS_NOT_NULL as the query builder's first unary (postfix) operator: where() takes neither literal nor expression for it, and passing either raises GQLAlchemyExtraKeywordArguments. Includes tests mirroring the IN operator's, a docstring update, and a how-to guide section. Closes #293 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FTdT6AcZMfnUfhiNA92T5e --- docs/how-to-guides/query-builder.md | 38 ++++++++++++ gqlalchemy/query_builders/declarative_base.py | 18 +++++- tests/query_builders/test_query_builders.py | 59 +++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/docs/how-to-guides/query-builder.md b/docs/how-to-guides/query-builder.md index 6dcb7926..6959dcd6 100644 --- a/docs/how-to-guides/query-builder.md +++ b/docs/how-to-guides/query-builder.md @@ -743,6 +743,44 @@ MATCH (p:Person) WHERE p.name IN ["Alice", "Bob"] RETURN *; +### Filter data with the IS NOT NULL operator + +You can use the `IS NOT NULL` operator to **check that a property is set**. It is a unary +operator: pass neither `literal` nor `expression`. + + + + +```python +from gqlalchemy import match +from gqlalchemy.query_builders.memgraph_query_builder import Operator + +results = list( + match() + .node(labels="Person", variable="p") + .where(item="p.name", operator=Operator.IS_NOT_NULL) + .return_() + .execute() +) + +print(results) +``` + + + + +```cypher +MATCH (p:Person) WHERE p.name IS NOT NULL RETURN *; +``` + + + + The `literal` keyword argument is used because you want list values to be properly quoted in the Cypher query. You can also combine `IN` with other boolean operators like `AND`, `OR`, etc. ### Filter data by label diff --git a/gqlalchemy/query_builders/declarative_base.py b/gqlalchemy/query_builders/declarative_base.py index c84fdbd6..39bcbab9 100644 --- a/gqlalchemy/query_builders/declarative_base.py +++ b/gqlalchemy/query_builders/declarative_base.py @@ -88,6 +88,7 @@ class Operator(Enum): GREATER_THAN = ">" IN = "IN" INEQUAL = "<>" + IS_NOT_NULL = "IS NOT NULL" LABEL_FILTER = ":" LESS_THAN = "<" LEQ_THAN = "<=" @@ -167,6 +168,8 @@ def construct_query(self) -> str: class WhereConditionPartialQuery(PartialQuery): _LITERAL = "literal" _EXPRESSION = "expression" + # Postfix operators that take no right-hand side (no literal or expression). + _UNARY_OPERATORS = (Operator.IS_NOT_NULL.value,) def __init__(self, item: str, operator: Operator, keyword: Where = Where.WHERE, is_negated: bool = False, **kwargs): super().__init__(type=keyword.name if not is_negated else f"{keyword.name} {Where.NOT.name}") @@ -186,6 +189,12 @@ def _build_where_query(self, item: str, operator: Operator, **kwargs) -> "Declar if operator_str not in Operator._value2member_map_: raise GQLAlchemyOperatorTypeError(clause=self.type) + if operator_str in WhereConditionPartialQuery._UNARY_OPERATORS: + if literal is not None or value is not None: + raise GQLAlchemyExtraKeywordArguments(clause=self.type) + + return " ".join([item, operator_str]) + if value is None: if literal is None: raise GQLAlchemyLiteralAndExpressionMissing(clause=self.type) @@ -871,8 +880,8 @@ def where(self, item: str, operator: Operator, **kwargs) -> "DeclarativeBase": expression: A node label or property that won't be converted to Cypher value (no additional quotes will be added). Raises: - GQLAlchemyLiteralAndExpressionMissingInWhere: Raises an error when neither literal nor expression keyword arguments were provided. - GQLAlchemyExtraKeywordArgumentsInWhere: Raises an error when both literal and expression keyword arguments were provided. + GQLAlchemyLiteralAndExpressionMissingInWhere: Raises an error when neither literal nor expression keyword arguments were provided, except for unary operators such as `Operator.IS_NOT_NULL`, which take neither. + GQLAlchemyExtraKeywordArgumentsInWhere: Raises an error when both literal and expression keyword arguments were provided, or when either is provided with a unary operator. Returns: self: A partial Cypher query built from the given parameters. @@ -892,6 +901,11 @@ def where(self, item: str, operator: Operator, **kwargs) -> "DeclarativeBase": Python: `match().node(variable='n').where(item='n.age', operator=Operator.GREATER_THAN, literal=18).return_()` Cypher: `MATCH (n) WHERE n.age > 18 RETURN *;` + + Filtering query results by property existence, using a unary operator (no literal or expression). + + Python: `match().node(variable='n').to().node(variable='m').where(item='n.name', operator=Operator.IS_NOT_NULL).return_()` + Cypher: `MATCH (n)-[]->(m) WHERE n.name IS NOT NULL RETURN *;` """ # WHERE item operator (literal | expression) # item: variable | property diff --git a/tests/query_builders/test_query_builders.py b/tests/query_builders/test_query_builders.py index fe38cd93..bcfb7c5c 100644 --- a/tests/query_builders/test_query_builders.py +++ b/tests/query_builders/test_query_builders.py @@ -437,6 +437,65 @@ def test_where_in(self, vendor): mock.assert_called_with(expected_query) + def test_where_is_not_null(self, vendor): + query_builder = ( + vendor[1] + .match() + .node(labels="L1", variable="n") + .to(relationship_type="TO") + .node(labels="L2", variable="m") + .where(item="n.name", operator=Operator.IS_NOT_NULL) + .return_() + ) + expected_query = " MATCH (n:L1)-[:TO]->(m:L2) WHERE n.name IS NOT NULL RETURN * " + + with patch.object(vendor[0], "execute_and_fetch", return_value=None) as mock: + query_builder.execute() + + mock.assert_called_with(expected_query) + + def test_and_where_is_not_null(self, vendor): + query_builder = ( + vendor[1] + .match() + .node(labels="L1", variable="n") + .to(relationship_type="TO") + .node(labels="L2", variable="m") + .where(item="n.name", operator=Operator.IS_NOT_NULL) + .and_where(item="m.name", operator=Operator.IS_NOT_NULL) + .return_() + ) + expected_query = " MATCH (n:L1)-[:TO]->(m:L2) WHERE n.name IS NOT NULL AND m.name IS NOT NULL RETURN * " + + with patch.object(vendor[0], "execute_and_fetch", return_value=None) as mock: + query_builder.execute() + + mock.assert_called_with(expected_query) + + def test_where_is_not_null_with_literal_throws(self, vendor): + with pytest.raises(GQLAlchemyExtraKeywordArguments): + ( + vendor[1] + .match() + .node(labels="L1", variable="n") + .to(relationship_type="TO") + .node(labels="L2", variable="m") + .where(item="n.name", operator=Operator.IS_NOT_NULL, literal="best_name") + .return_() + ) + + def test_where_is_not_null_with_expression_throws(self, vendor): + with pytest.raises(GQLAlchemyExtraKeywordArguments): + ( + vendor[1] + .match() + .node(labels="L1", variable="n") + .to(relationship_type="TO") + .node(labels="L2", variable="m") + .where(item="n.name", operator=Operator.IS_NOT_NULL, expression="m.name") + .return_() + ) + def test_where_not_label(self, vendor): query_builder = ( vendor[1]