Skip to content

Commit 7d6719f

Browse files
Merge pull request #45 from AbdelrhmanBassiouny/match
Match
2 parents 7d054cf + a55ecb7 commit 7d6719f

File tree

5 files changed

+325
-16
lines changed

5 files changed

+325
-16
lines changed

doc/_toc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ parts:
1515
- file: eql/domain_mapping
1616
- file: eql/predicate_and_symbolic_function
1717
- file: eql/cache
18+
- file: eql/match
1819
- file: eql/writing_rule_trees
1920
- file: eql/eql_for_sql_experts
2021
- caption: ORMatic

examples/eql/match.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
jupytext:
3+
text_representation:
4+
extension: .md
5+
format_name: myst
6+
format_version: 0.13
7+
jupytext_version: 1.16.4
8+
kernelspec:
9+
display_name: Python 3
10+
language: python
11+
name: python3
12+
---
13+
14+
# Pattern matching with `match` and `entity_matching`
15+
16+
EQL provides a concise pattern-matching API for building nested structural queries.
17+
Use `match(type_)(...)` to describe a nested pattern on attributes, and wrap the outermost match
18+
with `entity_matching(type_, domain)(...)` when you also need to bind a search domain.
19+
20+
The following example shows how nested patterns translate
21+
into an equivalent manual query built with `entity(...)` and predicates.
22+
23+
```{code-cell} ipython3
24+
from dataclasses import dataclass
25+
from typing_extensions import List
26+
27+
from krrood.entity_query_language.entity import (
28+
let, entity, the,
29+
match, entity_matching, Symbol,
30+
)
31+
from krrood.entity_query_language.predicate import HasType
32+
33+
34+
# --- Model -------------------------------------------------------------
35+
@dataclass
36+
class Body(Symbol):
37+
name: str
38+
39+
40+
@dataclass
41+
class Handle(Body):
42+
...
43+
44+
45+
@dataclass
46+
class Container(Body):
47+
...
48+
49+
50+
@dataclass
51+
class Connection(Symbol):
52+
parent: Body
53+
child: Body
54+
55+
56+
@dataclass
57+
class FixedConnection(Connection):
58+
...
59+
60+
61+
@dataclass
62+
class World:
63+
connections: List[Connection]
64+
65+
66+
# Build a small world with a few connections
67+
c1 = Container("Container1")
68+
h1 = Handle("Handle1")
69+
other_c = Container("ContainerX")
70+
other_h = Handle("HandleY")
71+
72+
world = World(
73+
connections=[
74+
FixedConnection(parent=c1, child=h1),
75+
FixedConnection(parent=other_c, child=h1),
76+
]
77+
)
78+
```
79+
80+
## Matching a nested structure
81+
82+
`entity_matching(FixedConnection, world.connections)` selects from `world.connections` items of type
83+
`FixedConnection`. Inner `match(...)` clauses describe constraints on attributes of that selected item.
84+
85+
```{code-cell} ipython3
86+
fixed_connection_query = the(
87+
entity_matching(FixedConnection, world.connections)(
88+
parent=match(Container)(name="Container1"),
89+
child=match(Handle)(name="Handle1"),
90+
)
91+
)
92+
```
93+
94+
## The equivalent manual query
95+
96+
You can express the same query explicitly using `entity`, `let`, attribute comparisons, and `HasType` for
97+
attribute type constraints:
98+
99+
```{code-cell} ipython3
100+
fc = let(FixedConnection, domain=None)
101+
fixed_connection_query_manual = the(
102+
entity(
103+
fc,
104+
HasType(fc.parent, Container),
105+
HasType(fc.child, Handle),
106+
fc.parent.name == "Container1",
107+
fc.child.name == "Handle1",
108+
)
109+
)
110+
111+
# The two query objects are structurally equivalent
112+
assert fixed_connection_query == fixed_connection_query_manual
113+
```
114+
115+
## Evaluate the query
116+
117+
```{code-cell} ipython3
118+
fixed_connection = fixed_connection_query.evaluate()
119+
print(type(fixed_connection).__name__, fixed_connection.parent.name, fixed_connection.child.name)
120+
```
121+
122+
Notes:
123+
- Use `entity_matching` for the outer pattern when a domain is involved; inner attributes use `match`.
124+
- Nested `match(...)` can be composed arbitrarily deep following your object graph.
125+
- `entity_matching` is a syntactic sugar over the explicit `entity` + predicates form, so both are interchangeable.

src/krrood/entity_query_language/entity.py

Lines changed: 138 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
from __future__ import annotations
22

3+
from dataclasses import dataclass, field
4+
from functools import cached_property
5+
6+
from black.strings import Match
7+
8+
from .hashed_data import T
39
from .symbol_graph import SymbolGraph
410
from .utils import is_iterable
511

@@ -13,7 +19,8 @@
1319
Optional,
1420
Union,
1521
Iterable,
16-
TypeVar,
22+
Dict,
23+
Generic,
1724
Type,
1825
Tuple,
1926
List,
@@ -37,22 +44,23 @@
3744
ForAll,
3845
Exists,
3946
Literal,
47+
ResultQuantifier,
4048
)
4149
from .result_quantification_constraint import ResultQuantificationConstraint
4250

4351
from .predicate import (
4452
Predicate,
4553
# type: ignore
4654
Symbol, # type: ignore
55+
HasType,
4756
)
4857

49-
T = TypeVar("T") # Define type variable "T"
5058

5159
ConditionType = Union[SymbolicExpression, bool, Predicate]
5260
"""
5361
The possible types for conditions.
5462
"""
55-
EntityType = Union[SetOf[T], Entity[T], T, Iterable[T], Type[T]]
63+
EntityType = Union[SetOf[T], Entity[T], T, Iterable[T], Type[T], Match[T]]
5664
"""
5765
The possible types for entities.
5866
"""
@@ -61,7 +69,7 @@
6169
def an(
6270
entity_: EntityType,
6371
quantification: Optional[ResultQuantificationConstraint] = None,
64-
) -> Union[An[T], T, SymbolicExpression[T]]:
72+
) -> Union[An[T], T]:
6573
"""
6674
Select a single element satisfying the given entity description.
6775
@@ -70,7 +78,7 @@ def an(
7078
:return: A quantifier representing "an" element.
7179
:rtype: An[T]
7280
"""
73-
return An(entity_, _quantification_constraint_=quantification)
81+
return _quantify_entity(An, entity_, _quantification_constraint_=quantification)
7482

7583

7684
a = an
@@ -89,7 +97,23 @@ def the(
8997
:return: A quantifier representing "an" element.
9098
:rtype: The[T]
9199
"""
92-
return The(entity_)
100+
return _quantify_entity(The, entity_)
101+
102+
103+
def _quantify_entity(
104+
quantifier: Type[ResultQuantifier], entity_: EntityType, **quantifier_kwargs
105+
) -> Union[ResultQuantifier[T], T]:
106+
"""
107+
Apply the given quantifier to the given entity.
108+
109+
:param quantifier: The quantifier to apply.
110+
:param entity_: The entity to quantify.
111+
:param quantifier_kwargs: Keyword arguments to pass to the quantifier.
112+
:return: The quantified entity.
113+
"""
114+
if isinstance(entity_, Match):
115+
entity_ = entity_.expression
116+
return quantifier(entity_, **quantifier_kwargs)
93117

94118

95119
def entity(
@@ -319,3 +343,111 @@ def inference(
319343
return lambda **kwargs: Variable(
320344
_type_=type_, _name__=type_.__name__, _kwargs_=kwargs, _is_inferred_=True
321345
)
346+
347+
348+
@dataclass
349+
class Match(Generic[T]):
350+
"""
351+
Construct a query that looks for the pattern provided by the type and the keyword arguments.
352+
"""
353+
354+
type_: Type[T]
355+
"""
356+
The type of the variable.
357+
"""
358+
kwargs: Dict[str, Any]
359+
"""
360+
The keyword arguments to match against.
361+
"""
362+
variable: CanBehaveLikeAVariable[T] = field(init=False)
363+
"""
364+
The created variable from the type and kwargs.
365+
"""
366+
conditions: List[ConditionType] = field(init=False, default_factory=list)
367+
"""
368+
The conditions that define the match.
369+
"""
370+
371+
def _resolve(self, variable: Optional[CanBehaveLikeAVariable] = None):
372+
"""
373+
Resolve the match by creating the variable and conditions expressions.
374+
375+
:param variable: An optional pre-existing variable to use for the match; if not provided, a new variable will be created.
376+
:return:
377+
"""
378+
self.variable = variable if variable else self._create_variable()
379+
for k, v in self.kwargs.items():
380+
attr = getattr(self.variable, k)
381+
if isinstance(v, Match):
382+
v._resolve(attr)
383+
self.conditions.append(HasType(attr, v.type_))
384+
self.conditions.extend(v.conditions)
385+
else:
386+
self.conditions.append(attr == v)
387+
388+
def _create_variable(self) -> Variable[T]:
389+
"""
390+
Create a variable with the given type.
391+
"""
392+
return let(self.type_, None)
393+
394+
@cached_property
395+
def expression(self) -> Entity[T]:
396+
"""
397+
Return the entity expression corresponding to the match query.
398+
"""
399+
self._resolve()
400+
return entity(self.variable, *self.conditions)
401+
402+
403+
@dataclass
404+
class MatchEntity(Match[T]):
405+
"""
406+
A match that can also take a domain and should be used as the outermost match in a nested match statement.
407+
This is because the inner match statements derive their domain from the outer match as they are basically attributes
408+
of the outer match variable.
409+
"""
410+
411+
domain: DomainType
412+
"""
413+
The domain to use for the variable created by the match.
414+
"""
415+
416+
def _create_variable(self) -> Variable[T]:
417+
"""
418+
Create a variable with the given type and domain.
419+
"""
420+
return let(self.type_, self.domain)
421+
422+
423+
def match(type_: Type[T]) -> Union[Type[T], Callable[..., Match[T]]]:
424+
"""
425+
This returns a factory function that creates a Match instance that looks for the pattern provided by the type and the
426+
keyword arguments.
427+
428+
:param type_: The type of the variable (i.e., The class you want to instantiate).
429+
:return: The factory function for creating the match query.
430+
"""
431+
432+
def match_factory(**kwargs) -> Match[T]:
433+
return Match(type_, kwargs)
434+
435+
return match_factory
436+
437+
438+
def entity_matching(
439+
type_: Type[T], domain: DomainType
440+
) -> Union[Type[T], Callable[..., MatchEntity[T]]]:
441+
"""
442+
Same as :py:func:`krrood.entity_query_language.entity.match` but with a domain to use for the variable created
443+
by the match.
444+
445+
:param type_: The type of the variable (i.e., The class you want to instantiate).
446+
:param domain: The domain used for the variable created by the match.
447+
:return: The factory function for creating the match query.
448+
"""
449+
450+
def match_factory(**kwargs) -> MatchEntity[T]:
451+
return MatchEntity(type_, kwargs, domain)
452+
453+
return match_factory

src/krrood/entity_query_language/symbolic.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
GreaterThanExpectedNumberOfSolutions,
4444
LessThanExpectedNumberOfSolutions,
4545
InvalidEntityType,
46-
UnsupportedOperation,
4746
UnSupportedOperand,
4847
)
4948
from .hashed_data import HashedValue, HashedIterable, T
@@ -1091,20 +1090,39 @@ def _evaluate__(
10911090
sources: Optional[Dict[int, HashedValue]] = None,
10921091
parent: Optional[SymbolicExpression] = None,
10931092
) -> Iterable[OperationResult]:
1093+
10941094
sources = sources or {}
1095+
10951096
self._eval_parent_ = parent
1097+
10961098
if self._id_ in sources:
10971099
yield OperationResult(sources, self._is_false_, self)
10981100
return
1099-
child_val = self._child_._evaluate__(sources, parent=self)
1100-
for child_v in child_val:
1101-
for v in self._apply_mapping_(child_v[self._child_._id_]):
1102-
self._is_false_ = not bool(v)
1103-
yield OperationResult(
1104-
{**child_v.bindings, self._id_: v},
1105-
self._is_false_,
1106-
self,
1107-
)
1101+
1102+
yield from (
1103+
self._build_operation_result_and_update_truth_value_(
1104+
child_result, mapped_value
1105+
)
1106+
for child_result in self._child_._evaluate__(sources, parent=self)
1107+
for mapped_value in self._apply_mapping_(child_result[self._child_._id_])
1108+
)
1109+
1110+
def _build_operation_result_and_update_truth_value_(
1111+
self, child_result: OperationResult, current_value: Any
1112+
) -> OperationResult:
1113+
"""
1114+
Set the current truth value of the operation result, and build the operation result to be yielded.
1115+
1116+
:param child_result: The current result from the child operation.
1117+
:param current_value: The current value of this operation that is derived from the child result.
1118+
:return: The operation result.
1119+
"""
1120+
self._is_false_ = not bool(current_value)
1121+
return OperationResult(
1122+
{**child_result.bindings, self._id_: current_value},
1123+
self._is_false_,
1124+
self,
1125+
)
11081126

11091127
@abstractmethod
11101128
def _apply_mapping_(self, value: HashedValue) -> Iterable[HashedValue]:

0 commit comments

Comments
 (0)