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

56 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-21 09:39 +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 # 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") 

40 

41 

42from ._operation_relations import BinaryOperationRelation, UnaryOperationRelation 

43from ._relation import Relation 

44 

45 

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

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

48 

49 Parameters 

50 ---------- 

51 sql 

52 SQLAlchemy object with a `.compile` method. 

53 

54 Returns 

55 ------- 

56 string : `str` 

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

58 

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

64 

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. 

69 

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 ) 

78 

79 

80@dataclasses.dataclass(frozen=True) 

81class ColumnTag: 

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

83 

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

96 

97 qualified_name: str 

98 is_key: bool = True 

99 

100 def __repr__(self) -> str: 

101 return self.qualified_name 

102 

103 def __hash__(self) -> int: 

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

105 

106 

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

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

109 their first difference. 

110 

111 Parameters 

112 ---------- 

113 a : `Relation` 

114 Relation to compare. 

115 b : `Relation` 

116 Other relation to compare. 

117 

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

157 

158 

159class RelationTestCase(unittest.TestCase): 

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

161 

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

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

164 

165 Parameters 

166 ---------- 

167 text : `str` 

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

169 replaced by single spaces. 

170 

171 *sql 

172 SQLAlchemy queries or column expressions to check. 

173 """ 

174 for s in sql: 

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

176 

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

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

179 

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