|
4 | 4 | from odoo.tests import TransactionCase, tagged |
5 | 5 | from odoo.tools.sql import SQL |
6 | 6 |
|
| 7 | +from ..services import cel_parser as P |
| 8 | + |
7 | 9 |
|
8 | 10 | @tagged("post_install", "-at_install") |
9 | 11 | class TestToSqlCase(TransactionCase): |
@@ -135,6 +137,215 @@ def test_boolean_connectives(self): |
135 | 137 | sql_str = self._sql_to_str(result) |
136 | 138 | self.assertIn("AND", sql_str) |
137 | 139 |
|
| 140 | + def test_or_connective(self): |
| 141 | + """Test || in conditions compiles to SQL OR.""" |
| 142 | + expr = 'r.is_group == true || r.active == true ? "either" : "neither"' |
| 143 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 144 | + self.assertIsNotNone(result) |
| 145 | + sql_str = self._sql_to_str(result) |
| 146 | + self.assertIn("OR", sql_str) |
| 147 | + |
| 148 | + def test_not_expression(self): |
| 149 | + """Test ! compiles to SQL NOT.""" |
| 150 | + # '!' is greedy in CEL, so it wraps the whole ternary; the compiled |
| 151 | + # SQL negates the CASE expression. |
| 152 | + expr = '! r.active == true ? "inactive" : "active"' |
| 153 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 154 | + self.assertIsNotNone(result) |
| 155 | + sql_str = self._sql_to_str(result) |
| 156 | + self.assertIn("NOT", sql_str) |
| 157 | + self.assertIn("CASE WHEN", sql_str) |
| 158 | + |
| 159 | + def test_not_unsupported_operand_returns_none(self): |
| 160 | + """Test NOT over an unsupported expression returns None.""" |
| 161 | + expr = "!(r.color + 1 > 2)" |
| 162 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 163 | + self.assertIsNone(result) |
| 164 | + |
| 165 | + def test_and_unsupported_operand_returns_none(self): |
| 166 | + """Test && with an uncompilable operand falls back to None.""" |
| 167 | + expr = 'r.color + 1 > 2 && r.active == true ? "a" : "b"' |
| 168 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 169 | + self.assertIsNone(result) |
| 170 | + |
| 171 | + def test_or_unsupported_operand_returns_none(self): |
| 172 | + """Test || with an uncompilable operand falls back to None.""" |
| 173 | + expr = 'r.color + 1 > 2 || r.active == true ? "a" : "b"' |
| 174 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 175 | + self.assertIsNone(result) |
| 176 | + |
| 177 | + def test_null_value_in_result(self): |
| 178 | + """Test null as a ternary result compiles to SQL NULL.""" |
| 179 | + expr = 'r.active == true ? null : "inactive"' |
| 180 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 181 | + self.assertIsNotNone(result) |
| 182 | + sql_str = self._sql_to_str(result) |
| 183 | + self.assertIn("NULL", sql_str) |
| 184 | + |
| 185 | + def test_numeric_literal_comparison(self): |
| 186 | + """Test comparison against a numeric literal compiles to SQL.""" |
| 187 | + expr = 'r.color > 5 ? "big" : "small"' |
| 188 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 189 | + self.assertIsNotNone(result) |
| 190 | + sql_str = self._sql_to_str(result) |
| 191 | + self.assertIn("color", sql_str) |
| 192 | + |
| 193 | + def test_bare_field_ident(self): |
| 194 | + """Test a bare field name (no r. prefix) resolves to a column.""" |
| 195 | + expr = 'active == true ? "on" : "off"' |
| 196 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 197 | + self.assertIsNotNone(result) |
| 198 | + sql_str = self._sql_to_str(result) |
| 199 | + self.assertIn("active", sql_str) |
| 200 | + |
| 201 | + def test_bare_r_returns_none(self): |
| 202 | + """Test 'r' alone is not a value expression and returns None.""" |
| 203 | + expr = 'r == null ? "a" : "b"' |
| 204 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 205 | + self.assertIsNone(result) |
| 206 | + |
| 207 | + def test_list_literal_returns_none(self): |
| 208 | + """Test a list literal as a ternary result returns None.""" |
| 209 | + expr = 'r.active == true ? [1] : "x"' |
| 210 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 211 | + self.assertIsNone(result) |
| 212 | + |
| 213 | + def test_unsupported_then_returns_none(self): |
| 214 | + """Test an uncompilable THEN branch falls back to None.""" |
| 215 | + expr = 'r.active == true ? r.color + 1 : "x"' |
| 216 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 217 | + self.assertIsNone(result) |
| 218 | + |
| 219 | + def test_unsupported_else_returns_none(self): |
| 220 | + """Test an uncompilable ELSE branch falls back to None.""" |
| 221 | + expr = 'r.active == true ? "x" : r.color + 1' |
| 222 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 223 | + self.assertIsNone(result) |
| 224 | + |
| 225 | + def test_null_on_left_eq(self): |
| 226 | + """Test null == field compiles to IS NULL.""" |
| 227 | + expr = 'null == r.birthdate ? "unknown" : "known"' |
| 228 | + result = self.translator.to_sql_case(expr, "res.partner", "ind") |
| 229 | + self.assertIsNotNone(result) |
| 230 | + sql_str = self._sql_to_str(result) |
| 231 | + self.assertIn("IS NULL", sql_str) |
| 232 | + |
| 233 | + def test_null_on_left_ne(self): |
| 234 | + """Test null != field compiles to IS NOT NULL.""" |
| 235 | + expr = 'null != r.birthdate ? "known" : "unknown"' |
| 236 | + result = self.translator.to_sql_case(expr, "res.partner", "ind") |
| 237 | + self.assertIsNotNone(result) |
| 238 | + sql_str = self._sql_to_str(result) |
| 239 | + self.assertIn("IS NOT NULL", sql_str) |
| 240 | + |
| 241 | + def test_null_ordering_comparison_returns_none(self): |
| 242 | + """Test ordering comparisons against null are not compilable.""" |
| 243 | + for expr in ( |
| 244 | + 'r.birthdate > null ? "a" : "b"', |
| 245 | + 'null < r.birthdate ? "a" : "b"', |
| 246 | + ): |
| 247 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 248 | + self.assertIsNone(result, f"Expected None for {expr}") |
| 249 | + |
| 250 | + def test_null_compared_to_unsupported_operand_returns_none(self): |
| 251 | + """Test null compared to an uncompilable operand returns None.""" |
| 252 | + expr = 'null == r.gender_id.code ? "a" : "b"' |
| 253 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 254 | + self.assertIsNone(result) |
| 255 | + |
| 256 | + def test_nested_attr_returns_none(self): |
| 257 | + """Test nested relational field access (r.a.b) is unsupported.""" |
| 258 | + expr = 'r.gender_id.code == "F" ? "female" : "other"' |
| 259 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 260 | + self.assertIsNone(result) |
| 261 | + |
| 262 | + def test_age_years_less_equal(self): |
| 263 | + """Test age_years(r.birthdate) <= N inverts to >= cutoff.""" |
| 264 | + expr = 'age_years(r.birthdate) <= 17 ? "minor" : "adult"' |
| 265 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 266 | + self.assertIsNotNone(result) |
| 267 | + sql_str = self._sql_to_str(result) |
| 268 | + self.assertIn("17 years", sql_str) |
| 269 | + # age <= 17 => birthdate >= cutoff |
| 270 | + self.assertIn(">=", sql_str) |
| 271 | + |
| 272 | + def test_age_years_greater_than(self): |
| 273 | + """Test age_years(r.birthdate) > N inverts to < cutoff.""" |
| 274 | + expr = 'age_years(r.birthdate) > 59 ? "senior" : "other"' |
| 275 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 276 | + self.assertIsNotNone(result) |
| 277 | + sql_str = self._sql_to_str(result) |
| 278 | + self.assertIn("59 years", sql_str) |
| 279 | + # age > 59 => birthdate < cutoff |
| 280 | + self.assertIn("<", sql_str) |
| 281 | + |
| 282 | + def test_age_years_ne(self): |
| 283 | + """Test age_years(r.birthdate) != N compiles to an OR of two bounds.""" |
| 284 | + expr = 'age_years(r.birthdate) != 30 ? "not_30" : "is_30"' |
| 285 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 286 | + self.assertIsNotNone(result) |
| 287 | + sql_str = self._sql_to_str(result) |
| 288 | + self.assertIn("30 years", sql_str) |
| 289 | + self.assertIn("31 years", sql_str) |
| 290 | + self.assertIn("OR", sql_str) |
| 291 | + |
| 292 | + def test_age_years_non_literal_bound_returns_none(self): |
| 293 | + """Test age_years() compared to a non-literal returns None.""" |
| 294 | + expr = 'age_years(r.birthdate) < r.color ? "a" : "b"' |
| 295 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 296 | + self.assertIsNone(result) |
| 297 | + |
| 298 | + def test_age_years_uncompilable_field_returns_none(self): |
| 299 | + """Test age_years() over a nested field returns None.""" |
| 300 | + expr = 'age_years(r.parent_id.birthdate) < 18 ? "a" : "b"' |
| 301 | + result = self.translator.to_sql_case(expr, "res.partner", "p") |
| 302 | + self.assertIsNone(result) |
| 303 | + |
| 304 | + |
| 305 | +@tagged("post_install", "-at_install") |
| 306 | +class TestAstToSqlExprDefensiveBranches(TransactionCase): |
| 307 | + """Cover defensive AST branches unreachable through the CEL parser. |
| 308 | +
|
| 309 | + The parser lexes true/false to boolean Literals and never emits |
| 310 | + Literal(None) or Compare ops outside the six relational kinds, but |
| 311 | + _ast_to_sql_expr guards those shapes for callers constructing ASTs |
| 312 | + directly. |
| 313 | + """ |
| 314 | + |
| 315 | + def setUp(self): |
| 316 | + super().setUp() |
| 317 | + self.translator = self.env["spp.cel.translator"] |
| 318 | + |
| 319 | + def _expr(self, node): |
| 320 | + return self.translator._ast_to_sql_expr(node, "res.partner", "p", {}, {}) |
| 321 | + |
| 322 | + def test_literal_none(self): |
| 323 | + """Test Literal(None) compiles to SQL NULL.""" |
| 324 | + result = self._expr(P.Literal(None)) |
| 325 | + self.assertIsNotNone(result) |
| 326 | + self.assertIn("NULL", str(result)) |
| 327 | + |
| 328 | + def test_ident_true_false(self): |
| 329 | + """Test Ident('true')/Ident('false') compile to TRUE/FALSE.""" |
| 330 | + self.assertIn("TRUE", str(self._expr(P.Ident("true")))) |
| 331 | + self.assertIn("FALSE", str(self._expr(P.Ident("false")))) |
| 332 | + |
| 333 | + def test_not_none_operand(self): |
| 334 | + """Test Not over an uncompilable node returns None.""" |
| 335 | + node = P.Not(P.BinOp("ADD", P.Attr(P.Ident("r"), "color"), P.Literal(1))) |
| 336 | + self.assertIsNone(self._expr(node)) |
| 337 | + |
| 338 | + def test_compare_unsupported_op_returns_none(self): |
| 339 | + """Test a Compare node with an unknown op returns None.""" |
| 340 | + node = P.Compare("IN", P.Attr(P.Ident("r"), "color"), P.Literal(1)) |
| 341 | + self.assertIsNone(self._expr(node)) |
| 342 | + |
| 343 | + def test_age_years_unsupported_op_returns_none(self): |
| 344 | + """Test age_years comparison with an unknown op returns None.""" |
| 345 | + call = P.Call(P.Ident("age_years"), [P.Attr(P.Ident("r"), "birthdate")]) |
| 346 | + node = P.Compare("XX", call, P.Literal(5)) |
| 347 | + self.assertIsNone(self._expr(node)) |
| 348 | + |
138 | 349 |
|
139 | 350 | @tagged("post_install", "-at_install") |
140 | 351 | class TestSQLBuilderCaseWhen(TransactionCase): |
|
0 commit comments