Skip to content

Commit

Permalink
[AAP-7562] Added support for searching an array of objects in conditi…
Browse files Browse the repository at this point in the history
…ons (#325)

https://issues.redhat.com/browse/AAP-7562
Using the guidelines from selectattr and select in Ansible and Jinja we
can write conditions that would check if the select/selectattr yields
one or more objects to pass the condition. The result of the selectattr
and select is a boolean value. It can only answer if in the array of
objects there is an object that satisfies the condition, the data can be
further processed in the playbook if need be.
Needs a new version of drools_jpy 0.2.0

This also conforms to the ansible playbook guidelines of tests having
either a **is** or **is not**

e.g.
levels is an array of integers
```
    event.levels is select('>', 25)
```

addresses is an array of strings
```
    event.addresses is select('regex', 'Main St')
```

addresses is an array of strings
```
   event.addresses is not select('regex', 'Wall St')
```

event.people is an array of person objects, where the attribute we are
checking is person.age and checking if the age is > 30.
```
   event.people is selectattr('person.age', '>', 30)
```

event.people is an array of person objects, where the attribute we are
checking is person.name and checking if the name is either Barney or
Fred
```
   event.people is selectattr('person.name', 'regex', 'Barney|Fred')
```
  • Loading branch information
mkanoor authored Feb 6, 2023
2 parents 2a52e71 + b9144a8 commit a1d2523
Show file tree
Hide file tree
Showing 13 changed files with 556 additions and 6 deletions.
80 changes: 79 additions & 1 deletion ansible_rulebook/condition_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
pyparsing_common,
)

from ansible_rulebook.exception import (
SelectattrOperatorException,
SelectOperatorException,
)

ParserElement.enable_packrat()

from ansible_rulebook.condition_types import ( # noqa: E402
Expand All @@ -44,9 +49,38 @@
NegateExpression,
OperatorExpression,
SearchType,
SelectattrType,
SelectType,
String,
)

VALID_SELECT_ATTR_OPERATORS = [
"==",
"!=",
">",
">=",
"<",
"<=",
"regex",
"search",
"match",
"in",
"not in",
"contains",
"not contains",
]

VALID_SELECT_OPERATORS = [
"==",
"!=",
">",
">=",
"<",
"<=",
"regex",
"search",
"match",
]
SUPPORTED_SEARCH_KINDS = ("match", "regex", "search")

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -95,13 +129,43 @@
)
list_values = Suppress("[") + delim_value + Suppress("]")

selectattr_t = (
Literal("selectattr")
+ Suppress("(")
+ Group(delimitedList(ident | allowed_values | list_values))
+ Suppress(")")
)

select_t = (
Literal("select")
+ Suppress("(")
+ Group(delimitedList(ident | allowed_values | list_values))
+ Suppress(")")
)


def as_list(var):
if hasattr(var.__class__, "as_list"):
return var.as_list()
return var


def SelectattrTypeFactory(tokens):
if tokens[1].value not in VALID_SELECT_ATTR_OPERATORS:
raise SelectattrOperatorException(
f"Operator {tokens[1]} is not supported"
)

return SelectattrType(tokens[0], tokens[1], as_list(tokens[2]))


def SelectTypeFactory(tokens):
if tokens[0].value not in VALID_SELECT_OPERATORS:
raise SelectOperatorException(f"Operator {tokens[0]} is not supported")

return SelectType(tokens[0], as_list(tokens[1]))


def SearchTypeFactory(kind, tokens):
options = []
if len(tokens) > 1:
Expand All @@ -123,6 +187,18 @@ def OperatorExpressionFactory(tokens):
tokens[0], tokens[1], search_type
)
tokens = tokens[4:]
elif tokens[2] == "selectattr":
select_attr_type = SelectattrTypeFactory(tokens[3])
return_value = OperatorExpression(
tokens[0], tokens[1], select_attr_type
)
tokens = tokens[4:]
elif tokens[2] == "select":
select_type = SelectTypeFactory(tokens[3])
return_value = OperatorExpression(
tokens[0], tokens[1], select_type
)
tokens = tokens[4:]
else:
return_value = OperatorExpression(
as_list(tokens[0]), tokens[1], as_list(tokens[2])
Expand All @@ -137,7 +213,9 @@ def OperatorExpressionFactory(tokens):


all_terms = (
string_search_t
selectattr_t
| select_t
| string_search_t
| list_values
| float_t
| integer
Expand Down
17 changes: 16 additions & 1 deletion ansible_rulebook/condition_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,21 @@ class SearchType(NamedTuple):
options: List[KeywordValue] = None


class SelectattrType(NamedTuple):
key: String
operator: String
value: Union[Float, Integer, String, Boolean, List]


class SelectType(NamedTuple):
operator: String
value: Union[Float, Integer, String, Boolean, List]


class OperatorExpression(NamedTuple):
left: Union[Float, Integer, String, List]
operator: str
right: Union[Float, Integer, String, List, SearchType]
right: Union[Float, Integer, String, List, SearchType, SelectType]


class NegateExpression(NamedTuple):
Expand All @@ -67,6 +78,8 @@ class Condition(NamedTuple):
NegateExpression,
KeywordValue,
SearchType,
SelectattrType,
SelectType,
]


Expand All @@ -81,4 +94,6 @@ class Condition(NamedTuple):
NegateExpression,
KeywordValue,
SearchType,
SelectType,
SelectattrType,
]
10 changes: 10 additions & 0 deletions ansible_rulebook/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,13 @@ class VarsKeyMissingException(Exception):
class InvalidAssignmentException(Exception):

pass


class SelectattrOperatorException(Exception):

pass


class SelectOperatorException(Exception):

pass
29 changes: 29 additions & 0 deletions ansible_rulebook/json_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
NegateExpression,
OperatorExpression,
SearchType,
SelectattrType,
SelectType,
String,
)
from ansible_rulebook.exception import (
Expand Down Expand Up @@ -116,6 +118,17 @@ def visit_condition(parsed_condition: ConditionTypes, variables: Dict):
visit_condition(v, variables) for v in parsed_condition.options
]
return {"SearchType": data}
elif isinstance(parsed_condition, SelectattrType):
return dict(
key=visit_condition(parsed_condition.key, variables),
operator=visit_condition(parsed_condition.operator, variables),
value=visit_condition(parsed_condition.value, variables),
)
elif isinstance(parsed_condition, SelectType):
return dict(
operator=visit_condition(parsed_condition.operator, variables),
value=visit_condition(parsed_condition.value, variables),
)
elif isinstance(parsed_condition, KeywordValue):
return dict(
name=visit_condition(parsed_condition.name, variables),
Expand Down Expand Up @@ -143,6 +156,14 @@ def visit_condition(parsed_condition: ConditionTypes, variables: Dict):
return create_binary_node(
"SearchMatchesExpression", parsed_condition, variables
)
elif isinstance(parsed_condition.right, SelectattrType):
return create_binary_node(
"SelectAttrExpression", parsed_condition, variables
)
elif isinstance(parsed_condition.right, SelectType):
return create_binary_node(
"SelectExpression", parsed_condition, variables
)
elif parsed_condition.operator == "is not":
if isinstance(parsed_condition.right, Identifier):
if parsed_condition.right.value == "defined":
Expand All @@ -155,6 +176,14 @@ def visit_condition(parsed_condition: ConditionTypes, variables: Dict):
return create_binary_node(
"SearchNotMatchesExpression", parsed_condition, variables
)
elif isinstance(parsed_condition.right, SelectattrType):
return create_binary_node(
"SelectAttrNotExpression", parsed_condition, variables
)
elif isinstance(parsed_condition.right, SelectType):
return create_binary_node(
"SelectNotExpression", parsed_condition, variables
)
else:
raise Exception(f"Unhandled token {parsed_condition}")
elif isinstance(parsed_condition, NegateExpression):
Expand Down
89 changes: 87 additions & 2 deletions docs/conditions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ A condition can contain
* Multiple conditions where any one of them has to match
* Multiple conditions where not all one of them have to match

Supported datatypes
*******************
Supported data types
********************
The data type is of great importance for the rules engine. The following types are supported

* integers
Expand Down Expand Up @@ -96,6 +96,14 @@ Conditions support the following operators:
- To check if the regular expression pattern exists in the string
* - is not regex(pattern,ignorecase=true)
- To check if the regular expression pattern does not exist in the string
* - is select(operator, value)
- To check if an item exists in the list, that satisfies the test defined by operator and value
* - is not select(operator, value)
- To check if an item does not exist in the list, that does not satisfy the test defined by operator and value
* - is selectattr(key, operator, value)
- To check if an object exists in the list, that satisfies the test defined by key, operator and value
* - is not selectattr(key, operator, value)
- To check if an object does not exist in the list, that does not satisfy the test defined by key, operator and value
* - `<<`
- Assignment operator, to save the matching events or facts with events or facts prefix
* - not
Expand Down Expand Up @@ -582,6 +590,83 @@ String regular expression
| In the above example we check if the event.url does not have "example.com" in its value
| And the option controls that this is a case insensitive search.
Check if an item exists in a list based on a test
-------------------------------------------------

.. code-block:: yaml
name: check if an item exist in list
condition: event.levels is select('>=', 10)
action:
echo:
message: The list has an item with the value greater than or equal to 10
| In the above example "levels" is a list of integers e.g. [1,2,3,20], the test says
| check if any item exists in the list with a value >= 10. This test passes because
| of the presence of 20 in the list. If the value of "levels" is [1,2,3] then the
| test would yield False.
Check if an item does not exist in a list based on a test
---------------------------------------------------------

.. code-block:: yaml
name: check if an item does not exist in list
condition: event.levels is not select('>=', 10)
action:
echo:
message: The list does not have item with the value greater than or equal to 10
| In the above example "levels" is a list of integers e.g. [1,2,3], the test says
| check if *no* item exists with a value >= 10. This test passes because none of the items
| in the list is greater than or equal to 10. If the value of "levels" is [1,2,3,20] then
| the test would yield False because of the presence of 20 in the list.
| The result of the *select* condition is either True or False. It doesn't return the item or items.
| The select takes 2 arguments which are comma delimited, **operator** and **value**.
| The different operators we support are >,>=,<,<=,==,!=,match,search,regex
| The value is based on the operator used, if the operator is regex then the value is a pattern.
| If the operator is one of >,>=,<,<= then the value is either an integer or a float
Checking if an object exists in a list based on a test
------------------------------------------------------

.. code-block:: yaml
name: check if an object exist in list
condition: event.objects is selectattr('age', '>=', 20)
action:
echo:
message: An object with age greater than 20 found
| In the above example "objects" is a list of object's, with multiple properties. One of the
| properties is age, the test says check if any object exists in the list with an age >= 20.
Checking if an object does not exist in a list based on a test
---------------------------------------------------------------

.. code-block:: yaml
name: check if an object does not exist in list
condition: event.objects is not selectattr('age', '>=', 20)
action:
echo:
message: No object with age greater than 20 found
| In the above example "objects" is a list of object's, with multiple properties. One of the
| properties is age, the test says check if *no* object exists in the list with an age >= 20.
| The result of the *selectattr* condition is either True or False. It doesn't return the
| matching object or objects.
| The *selectattr* takes 3 arguments which are comma delimited, **key**, **operator** and **value**.
| The key is a valid key name in the object.
| The different operators we support are >, >=, <, <=, ==, !=, match, search, regex, in, not in,
| contains, not contains.
| The value is based on the operator used, if the operator is regex then the value is a pattern.
| If the operator is one of >, >=, <, <= then the value is either an integer or a float.
| If the operator is in or not in then the value is list of integer, float or string.

FAQ
***

Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ install_requires =
janus
ansible-runner
websockets
drools_jpy == 0.1.9
drools_jpy == 0.2.0

[options.packages.find]
include =
Expand Down
30 changes: 30 additions & 0 deletions tests/examples/61_select_1.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
- name: 61 select 1
hosts: all
sources:
- generic:
payload:
- name: Fred
age: 54
levels:
- 10
- 20
- 30
- name: Barney
age: 53
levels:
- 11
- 15
- 16
- name: Wilma
age: 53
levels:
- 1
- 5
- 6
rules:
- name: r1
condition: event.levels is select('>', 25)
action:
echo:
message: Found a player with level greater than 25
Loading

0 comments on commit a1d2523

Please sign in to comment.