Coverage for python/lsst/daf/relation/tests.py: 29%
56 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-19 10:06 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-19 10:06 +0000
1# This file is part of daf_relation.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""Utility code to aid in writing unit tests for relations and relation
23algorithms.
24"""
26__all__ = ("ColumnTag", "RelationTestCase", "to_sql_str")
28import dataclasses
29import re
30import unittest
31from typing import Any
33try:
34 from sqlalchemy.dialects.sqlite.pysqlite import dialect as sql_dialect
35except ImportError:
36 # MyPy doesn't like this trick.
37 def sql_dialect() -> Any: # type: ignore
38 """Mock sql dialect."""
39 raise unittest.SkipTest("sqlalchemy SQLite dialect not available")
42from ._operation_relations import BinaryOperationRelation, UnaryOperationRelation
43from ._relation import Relation
46def to_sql_str(sql: Any) -> str:
47 """Convert a SQLAlchemy expression to a string suitable for tests.
49 Parameters
50 ----------
51 sql
52 SQLAlchemy object with a `.compile` method.
54 Returns
55 -------
56 string : `str`
57 SQL string, with all whitespace converted to single spaces.
59 Notes
60 -----
61 This method uses the "pysqlite3" dialect, since that's the most likely one
62 to be available (as it's backed by a module usually shipped with Python
63 itself).
65 Converting SQLAlchemy expressions to strings that are checked against
66 literals is a good way to check that the `.sql` engine works as intended,
67 but it's also fragile, as changes to whitespace or automatic simplification
68 on the SQLAlchemy side will result in a test failure.
70 Raises
71 ------
72 unittest.SkipTest
73 Raised if `sqlalchemy` or its "pysqlite" dialect could not be imported.
74 """
75 return re.sub(
76 r"\s+", " ", str(sql.compile(dialect=sql_dialect(), compile_kwargs={"literal_binds": True}))
77 )
80@dataclasses.dataclass(frozen=True)
81class ColumnTag:
82 """A very simple ColumnTag implementation for use in tests.
84 Notes
85 -----
86 This class's ``__hash__`` implementation intentionally avoids the "salting"
87 randomization included in the `hash` function's behavior on most built-in
88 types, and it should correspond to ASCII order for single-character ASCII
89 `qualified_name` values. This means that sets of these `ColumnTag` objects
90 are always deterministically ordered, and that they are *probably* sorted
91 (this appears to be true, but it's really relying on CPython implementation
92 details). The former can be assumed in tests that check SQL conversions
93 against expected strings (see `to_sql_str`), while the latter just makes it
94 easier to write a good "expected string" on the first try.
95 """
97 qualified_name: str
98 is_key: bool = True
100 def __repr__(self) -> str:
101 return self.qualified_name
103 def __hash__(self) -> int:
104 return int.from_bytes(self.qualified_name.encode(), byteorder="little")
107def diff_relations(a: Relation, b: Relation) -> str | None:
108 """Recursively compare relation trees, returning `str` representation of
109 their first difference.
111 Parameters
112 ----------
113 a : `Relation`
114 Relation to compare.
115 b : `Relation`
116 Other relation to compare.
118 Returns
119 -------
120 diff : `str` or `None`
121 The `str` representations of the operators or relations that first
122 differ when traversing the tree, formatted as '{a} != {b}'. `None`
123 if the relations are equal.
124 """
125 match (a, b):
126 case (
127 UnaryOperationRelation(operation=op_a, target=target_a),
128 UnaryOperationRelation(operation=op_b, target=target_b),
129 ):
130 if op_a == op_b:
131 return diff_relations(target_a, target_b)
132 else:
133 return f"{op_a} != {op_b}"
134 case (
135 BinaryOperationRelation(operation=op_a, lhs=lhs_a, rhs=rhs_a),
136 BinaryOperationRelation(operation=op_b, lhs=lhs_b, rhs=rhs_b),
137 ):
138 if op_a == op_b:
139 diff_lhs = diff_relations(lhs_a, lhs_b)
140 diff_rhs = diff_relations(rhs_a, rhs_b)
141 if diff_lhs is not None:
142 if diff_rhs is not None:
143 return f"{diff_lhs}, {diff_rhs}"
144 else:
145 return diff_lhs
146 else:
147 if diff_rhs is not None:
148 return diff_rhs
149 else:
150 return None
151 else:
152 return f"{op_a} != {op_b}"
153 if a == b:
154 return None
155 else:
156 return f"{a} != {b}"
159class RelationTestCase(unittest.TestCase):
160 """An intermediate TestCase base class for relation tests."""
162 def check_sql_str(self, text: str, *sql: Any) -> None:
163 """Check one or more SQLAlchemy objects against the given SQL string.
165 Parameters
166 ----------
167 text : `str`
168 Expected SQL string in the pysqlite dialect, with all whitespace
169 replaced by single spaces.
171 *sql
172 SQLAlchemy queries or column expressions to check.
173 """
174 for s in sql:
175 self.assertEqual(to_sql_str(s), text)
177 def assert_relations_equal(self, a: Relation, b: Relation) -> None:
178 """Test relations for equality, reporting their difference on failure.
180 Parameters
181 ----------
182 a : `Relation`
183 Relation to compare.
184 b : `Relation`
185 Other relation to compare.
186 """
187 if diff := diff_relations(a, b):
188 assert a != b, "Check that diff_relations is consistent with equality comparison."
189 msg = f"{a} != {b}"
190 if diff != msg:
191 msg = f"{msg}:\n{diff}"
192 raise self.failureException(msg)
193 else:
194 assert a == b, "Check that diff_relations is consistent with equality comparison."