Coverage for python/lsst/daf/relation/tests.py: 29%
56 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-22 02:59 -0800
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-22 02:59 -0800
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 raise unittest.SkipTest("sqlalchemy SQLite dialect not available")
41from ._operation_relations import BinaryOperationRelation, UnaryOperationRelation
42from ._relation import Relation
45def to_sql_str(sql: Any) -> str:
46 """Convert a SQLAlchemy expression to a string suitable for tests.
48 Parameters
49 ----------
50 sql
51 SQLAlchemy object with a `.compile` method.
53 Returns
54 -------
55 string : `str`
56 SQL string, with all whitespace converted to single spaces.
58 Notes
59 -----
60 This method uses the "pysqlite3" dialect, since that's the most likely one
61 to be available (as it's backed by a module usually shipped with Python
62 itself).
64 Converting SQLAlchemy expressions to strings that are checked against
65 literals is a good way to check that the `.sql` engine works as intended,
66 but it's also fragile, as changes to whitespace or automatic simplification
67 on the SQLAlchemy side will result in a test failure.
69 Raises
70 ------
71 unittest.SkipTest
72 Raised if `sqlalchemy` or its "pysqlite" dialect could not be imported.
73 """
74 return re.sub(
75 r"\s+", " ", str(sql.compile(dialect=sql_dialect(), compile_kwargs={"literal_binds": True}))
76 )
79@dataclasses.dataclass(frozen=True)
80class ColumnTag:
81 """A very simple ColumnTag implementation for use in tests.
83 Notes
84 -----
85 This class's ``__hash__`` implementation intentionally avoids the "salting"
86 randomization included in the `hash` function's behavior on most built-in
87 types, and it should correspond to ASCII order for single-character ASCII
88 `qualified_name` values. This means that sets of these `ColumnTag` objects
89 are always deterministically ordered, and that they are *probably* sorted
90 (this appears to be true, but it's really relying on CPython implementation
91 details). The former can be assumed in tests that check SQL conversions
92 against expected strings (see `to_sql_str`), while the latter just makes it
93 easier to write a good "expected string" on the first try.
94 """
96 qualified_name: str
97 is_key: bool = True
99 def __repr__(self) -> str:
100 return self.qualified_name
102 def __hash__(self) -> int:
103 return int.from_bytes(self.qualified_name.encode(), byteorder="little")
106def diff_relations(a: Relation, b: Relation) -> str | None:
107 """Recursively compare relation trees, returning `str` representation of
108 their first difference.
110 Parameters
111 ----------
112 a : `Relation`
113 Relation to compare.
114 b : `Relation`
115 Other relation to compare.
117 Returns
118 -------
119 diff : `str` or `None`
120 The `str` representations of the operators or relations that first
121 differ when traversing the tree, formatted as '{a} != {b}'. `None`
122 if the relations are equal.
123 """
124 match (a, b):
125 case (
126 UnaryOperationRelation(operation=op_a, target=target_a),
127 UnaryOperationRelation(operation=op_b, target=target_b),
128 ):
129 if op_a == op_b:
130 return diff_relations(target_a, target_b)
131 else:
132 return f"{op_a} != {op_b}"
133 case (
134 BinaryOperationRelation(operation=op_a, lhs=lhs_a, rhs=rhs_a),
135 BinaryOperationRelation(operation=op_b, lhs=lhs_b, rhs=rhs_b),
136 ):
137 if op_a == op_b:
138 diff_lhs = diff_relations(lhs_a, lhs_b)
139 diff_rhs = diff_relations(rhs_a, rhs_b)
140 if diff_lhs is not None:
141 if diff_rhs is not None:
142 return f"{diff_lhs}, {diff_rhs}"
143 else:
144 return diff_lhs
145 else:
146 if diff_rhs is not None:
147 return diff_rhs
148 else:
149 return None
150 else:
151 return f"{op_a} != {op_b}"
152 if a == b:
153 return None
154 else:
155 return f"{a} != {b}"
158class RelationTestCase(unittest.TestCase):
159 """An intermediate TestCase base class for relation tests."""
161 def check_sql_str(self, text: str, *sql: Any) -> None:
162 """Check one or more SQLAlchemy objects against the given SQL string.
164 Parameters
165 ----------
166 text : `str`
167 Expected SQL string in the pysqlite dialect, with all whitespace
168 replaced by single spaces.
170 *sql
171 SQLAlchemy queries or column expressions to check.
172 """
173 for s in sql:
174 self.assertEqual(to_sql_str(s), text)
176 def assert_relations_equal(self, a: Relation, b: Relation) -> None:
177 """Test relations for equality, reporting their difference on failure.
179 Parameters
180 ----------
181 a : `Relation`
182 Relation to compare.
183 b : `Relation`
184 Other relation to compare.
185 """
186 if diff := diff_relations(a, b):
187 assert a != b, "Check that diff_relations is consistent with equality comparison."
188 msg = f"{a} != {b}"
189 if diff != msg:
190 msg = f"{msg}:\n{diff}"
191 raise self.failureException(msg)
192 else:
193 assert a == b, "Check that diff_relations is consistent with equality comparison."