Coverage for python/lsst/daf/relation/tests.py: 29%

56 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-01-10 10:30 +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/>. 

21 

22"""Utility code to aid in writing unit tests for relations and relation 

23algorithms. 

24""" 

25 

26__all__ = ("ColumnTag", "RelationTestCase", "to_sql_str") 

27 

28import dataclasses 

29import re 

30import unittest 

31from typing import Any 

32 

33try: 

34 from sqlalchemy.dialects.sqlite.pysqlite import dialect as sql_dialect 

35except ImportError: 

36 

37 def sql_dialect() -> Any: 

38 raise unittest.SkipTest("sqlalchemy SQLite dialect not available") 

39 

40 

41from ._operation_relations import BinaryOperationRelation, UnaryOperationRelation 

42from ._relation import Relation 

43 

44 

45def to_sql_str(sql: Any) -> str: 

46 """Convert a SQLAlchemy expression to a string suitable for tests. 

47 

48 Parameters 

49 ---------- 

50 sql 

51 SQLAlchemy object with a `.compile` method. 

52 

53 Returns 

54 ------- 

55 string : `str` 

56 SQL string, with all whitespace converted to single spaces. 

57 

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). 

63 

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. 

68 

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 ) 

77 

78 

79@dataclasses.dataclass(frozen=True) 

80class ColumnTag: 

81 """A very simple ColumnTag implementation for use in tests. 

82 

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 """ 

95 

96 qualified_name: str 

97 is_key: bool = True 

98 

99 def __repr__(self) -> str: 

100 return self.qualified_name 

101 

102 def __hash__(self) -> int: 

103 return int.from_bytes(self.qualified_name.encode(), byteorder="little") 

104 

105 

106def diff_relations(a: Relation, b: Relation) -> str | None: 

107 """Recursively compare relation trees, returning `str` representation of 

108 their first difference. 

109 

110 Parameters 

111 ---------- 

112 a : `Relation` 

113 Relation to compare. 

114 b : `Relation` 

115 Other relation to compare. 

116 

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}" 

156 

157 

158class RelationTestCase(unittest.TestCase): 

159 """An intermediate TestCase base class for relation tests.""" 

160 

161 def check_sql_str(self, text: str, *sql: Any) -> None: 

162 """Check one or more SQLAlchemy objects against the given SQL string. 

163 

164 Parameters 

165 ---------- 

166 text : `str` 

167 Expected SQL string in the pysqlite dialect, with all whitespace 

168 replaced by single spaces. 

169 

170 *sql 

171 SQLAlchemy queries or column expressions to check. 

172 """ 

173 for s in sql: 

174 self.assertEqual(to_sql_str(s), text) 

175 

176 def assert_relations_equal(self, a: Relation, b: Relation) -> None: 

177 """Test relations for equality, reporting their difference on failure. 

178 

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."