|
7 | 7 | https://github.com/odoo/odoo/blob/19.0/addons/base_sparse_field/tests/test_sparse_fields.py |
8 | 8 | """ |
9 | 9 |
|
| 10 | +import json |
| 11 | + |
10 | 12 | from odoo import fields, models |
11 | 13 | from odoo.orm.model_classes import add_to_registry |
12 | 14 | from odoo.tests import TransactionCase |
13 | 15 |
|
| 16 | +from ..hooks import drop_gin_indexes_on_text_columns, post_init_hook, pre_init_hook |
| 17 | +from ..models.fields import SerializedJsonb |
| 18 | + |
14 | 19 |
|
15 | 20 | class SparseFieldsTestModel(models.Model): |
16 | 21 | """Test model for sparse fields with JSONB storage.""" |
@@ -216,3 +221,356 @@ def test_selection_sparse_field(self): |
216 | 221 | # Clear selection |
217 | 222 | record.write({"selection": False}) |
218 | 223 | self.assertFalse(record.selection) |
| 224 | + |
| 225 | + def test_convert_to_cache_with_dict(self): |
| 226 | + """Test convert_to_cache with dict value returns JSON string.""" |
| 227 | + field = SerializedJsonb() |
| 228 | + record = self.env["sparse_fields_jsonb.test"].create({}) |
| 229 | + |
| 230 | + # Dict should be converted to JSON string |
| 231 | + result = field.convert_to_cache({"key": "value"}, record) |
| 232 | + self.assertEqual(result, '{"key": "value"}') |
| 233 | + |
| 234 | + # Nested dict |
| 235 | + nested = {"level1": {"level2": {"level3": "deep"}}} |
| 236 | + result = field.convert_to_cache(nested, record) |
| 237 | + self.assertEqual(json.loads(result), nested) |
| 238 | + |
| 239 | + def test_convert_to_cache_with_non_dict(self): |
| 240 | + """Test convert_to_cache with non-dict values.""" |
| 241 | + field = SerializedJsonb() |
| 242 | + record = self.env["sparse_fields_jsonb.test"].create({}) |
| 243 | + |
| 244 | + # None should return None |
| 245 | + result = field.convert_to_cache(None, record) |
| 246 | + self.assertIsNone(result) |
| 247 | + |
| 248 | + # Empty string should return None |
| 249 | + result = field.convert_to_cache("", record) |
| 250 | + self.assertIsNone(result) |
| 251 | + |
| 252 | + # False should return None |
| 253 | + result = field.convert_to_cache(False, record) |
| 254 | + self.assertIsNone(result) |
| 255 | + |
| 256 | + # JSON string should pass through |
| 257 | + json_str = '{"existing": "json"}' |
| 258 | + result = field.convert_to_cache(json_str, record) |
| 259 | + self.assertEqual(result, json_str) |
| 260 | + |
| 261 | + def test_convert_to_record_with_dict(self): |
| 262 | + """Test convert_to_record with dict value (from JSONB).""" |
| 263 | + field = SerializedJsonb() |
| 264 | + record = self.env["sparse_fields_jsonb.test"].create({}) |
| 265 | + |
| 266 | + # Dict should pass through unchanged |
| 267 | + data = {"key": "value", "number": 42} |
| 268 | + result = field.convert_to_record(data, record) |
| 269 | + self.assertEqual(result, data) |
| 270 | + |
| 271 | + def test_convert_to_record_with_none(self): |
| 272 | + """Test convert_to_record with None returns empty dict.""" |
| 273 | + field = SerializedJsonb() |
| 274 | + record = self.env["sparse_fields_jsonb.test"].create({}) |
| 275 | + |
| 276 | + result = field.convert_to_record(None, record) |
| 277 | + self.assertEqual(result, {}) |
| 278 | + |
| 279 | + def test_convert_to_record_with_string(self): |
| 280 | + """Test convert_to_record with string value (fallback for TEXT).""" |
| 281 | + field = SerializedJsonb() |
| 282 | + record = self.env["sparse_fields_jsonb.test"].create({}) |
| 283 | + |
| 284 | + # JSON string should be parsed |
| 285 | + json_str = '{"from": "string"}' |
| 286 | + result = field.convert_to_record(json_str, record) |
| 287 | + self.assertEqual(result, {"from": "string"}) |
| 288 | + |
| 289 | + # Empty string should return empty dict |
| 290 | + result = field.convert_to_record("", record) |
| 291 | + self.assertEqual(result, {}) |
| 292 | + |
| 293 | + def test_convert_to_column_insert_with_none(self): |
| 294 | + """Test convert_to_column_insert with None value.""" |
| 295 | + field = SerializedJsonb() |
| 296 | + record = self.env["sparse_fields_jsonb.test"].create({}) |
| 297 | + |
| 298 | + result = field.convert_to_column_insert(None, record) |
| 299 | + self.assertIsNone(result) |
| 300 | + |
| 301 | + def test_convert_to_column_update_with_none(self): |
| 302 | + """Test convert_to_column_update with None value.""" |
| 303 | + field = SerializedJsonb() |
| 304 | + record = self.env["sparse_fields_jsonb.test"].create({}) |
| 305 | + |
| 306 | + result = field.convert_to_column_update(None, record) |
| 307 | + self.assertIsNone(result) |
| 308 | + |
| 309 | + def test_postgresql_json_containment_operator(self): |
| 310 | + """Test PostgreSQL @> containment operator on JSONB.""" |
| 311 | + record = self.env["sparse_fields_jsonb.test"].create( |
| 312 | + { |
| 313 | + "boolean": True, |
| 314 | + "integer": 42, |
| 315 | + "char": "test", |
| 316 | + } |
| 317 | + ) |
| 318 | + record.flush_recordset() |
| 319 | + |
| 320 | + # Test @> containment operator |
| 321 | + self.env.cr.execute( |
| 322 | + """ |
| 323 | + SELECT id FROM sparse_fields_jsonb_test |
| 324 | + WHERE data @> %s::jsonb |
| 325 | + """, |
| 326 | + ('{"integer": 42}',), |
| 327 | + ) |
| 328 | + result = self.env.cr.fetchone() |
| 329 | + self.assertIsNotNone(result) |
| 330 | + self.assertEqual(result[0], record.id) |
| 331 | + |
| 332 | + def test_postgresql_json_key_exists_operator(self): |
| 333 | + """Test PostgreSQL ? key exists operator on JSONB.""" |
| 334 | + record = self.env["sparse_fields_jsonb.test"].create( |
| 335 | + { |
| 336 | + "char": "has_char", |
| 337 | + } |
| 338 | + ) |
| 339 | + record.flush_recordset() |
| 340 | + |
| 341 | + # Test ? key exists operator |
| 342 | + self.env.cr.execute( |
| 343 | + """ |
| 344 | + SELECT id FROM sparse_fields_jsonb_test |
| 345 | + WHERE data ? 'char' |
| 346 | + """ |
| 347 | + ) |
| 348 | + results = self.env.cr.fetchall() |
| 349 | + record_ids = [r[0] for r in results] |
| 350 | + self.assertIn(record.id, record_ids) |
| 351 | + |
| 352 | + def test_postgresql_json_path_operator(self): |
| 353 | + """Test PostgreSQL -> and ->> path operators on JSONB.""" |
| 354 | + record = self.env["sparse_fields_jsonb.test"].create( |
| 355 | + { |
| 356 | + "integer": 999, |
| 357 | + "char": "path_test", |
| 358 | + } |
| 359 | + ) |
| 360 | + record.flush_recordset() |
| 361 | + |
| 362 | + # Test -> operator (returns jsonb) |
| 363 | + self.env.cr.execute( |
| 364 | + """ |
| 365 | + SELECT data->'integer' FROM sparse_fields_jsonb_test |
| 366 | + WHERE id = %s |
| 367 | + """, |
| 368 | + (record.id,), |
| 369 | + ) |
| 370 | + result = self.env.cr.fetchone() |
| 371 | + self.assertEqual(result[0], 999) |
| 372 | + |
| 373 | + # Test ->> operator (returns text) |
| 374 | + self.env.cr.execute( |
| 375 | + """ |
| 376 | + SELECT data->>'char' FROM sparse_fields_jsonb_test |
| 377 | + WHERE id = %s |
| 378 | + """, |
| 379 | + (record.id,), |
| 380 | + ) |
| 381 | + result = self.env.cr.fetchone() |
| 382 | + self.assertEqual(result[0], "path_test") |
| 383 | + |
| 384 | + def test_batch_create_multiple_records(self): |
| 385 | + """Test creating multiple records with sparse fields.""" |
| 386 | + records = self.env["sparse_fields_jsonb.test"].create( |
| 387 | + [ |
| 388 | + {"integer": 1, "char": "first"}, |
| 389 | + {"integer": 2, "char": "second"}, |
| 390 | + {"integer": 3, "char": "third"}, |
| 391 | + ] |
| 392 | + ) |
| 393 | + |
| 394 | + self.assertEqual(len(records), 3) |
| 395 | + self.assertEqual(records[0].integer, 1) |
| 396 | + self.assertEqual(records[1].integer, 2) |
| 397 | + self.assertEqual(records[2].integer, 3) |
| 398 | + |
| 399 | + def test_batch_write_multiple_records(self): |
| 400 | + """Test writing to multiple records with sparse fields.""" |
| 401 | + records = self.env["sparse_fields_jsonb.test"].create( |
| 402 | + [ |
| 403 | + {"integer": 1}, |
| 404 | + {"integer": 2}, |
| 405 | + {"integer": 3}, |
| 406 | + ] |
| 407 | + ) |
| 408 | + |
| 409 | + # Update all records at once |
| 410 | + records.write({"char": "batch_updated"}) |
| 411 | + |
| 412 | + for record in records: |
| 413 | + self.assertEqual(record.char, "batch_updated") |
| 414 | + |
| 415 | + def test_search_with_sparse_fields(self): |
| 416 | + """Test that records can be searched after sparse field operations.""" |
| 417 | + record = self.env["sparse_fields_jsonb.test"].create({"char": "searchable"}) |
| 418 | + |
| 419 | + # Search should work |
| 420 | + found = self.env["sparse_fields_jsonb.test"].search([("id", "=", record.id)]) |
| 421 | + self.assertEqual(len(found), 1) |
| 422 | + self.assertEqual(found.char, "searchable") |
| 423 | + |
| 424 | + def test_copy_record_with_sparse_fields(self): |
| 425 | + """Test copying a record preserves sparse field values.""" |
| 426 | + original = self.env["sparse_fields_jsonb.test"].create( |
| 427 | + { |
| 428 | + "boolean": True, |
| 429 | + "integer": 100, |
| 430 | + "char": "original", |
| 431 | + } |
| 432 | + ) |
| 433 | + |
| 434 | + copy = original.copy() |
| 435 | + |
| 436 | + self.assertEqual(copy.boolean, True) |
| 437 | + self.assertEqual(copy.integer, 100) |
| 438 | + self.assertEqual(copy.char, "original") |
| 439 | + self.assertNotEqual(copy.id, original.id) |
| 440 | + |
| 441 | + def test_unlink_record_with_sparse_fields(self): |
| 442 | + """Test deleting a record with sparse fields.""" |
| 443 | + record = self.env["sparse_fields_jsonb.test"].create({"integer": 42}) |
| 444 | + record_id = record.id |
| 445 | + |
| 446 | + record.unlink() |
| 447 | + |
| 448 | + # Should not exist anymore |
| 449 | + self.env.cr.execute( |
| 450 | + """ |
| 451 | + SELECT id FROM sparse_fields_jsonb_test WHERE id = %s |
| 452 | + """, |
| 453 | + (record_id,), |
| 454 | + ) |
| 455 | + self.assertIsNone(self.env.cr.fetchone()) |
| 456 | + |
| 457 | + def test_float_precision_in_sparse_field(self): |
| 458 | + """Test float precision is preserved in JSONB storage.""" |
| 459 | + record = self.env["sparse_fields_jsonb.test"].create( |
| 460 | + {"float_field": 3.141592653589793} |
| 461 | + ) |
| 462 | + |
| 463 | + record.flush_recordset() |
| 464 | + record.invalidate_recordset() |
| 465 | + |
| 466 | + # Re-read from database |
| 467 | + record = self.env["sparse_fields_jsonb.test"].browse(record.id) |
| 468 | + self.assertAlmostEqual(record.float_field, 3.141592653589793, places=10) |
| 469 | + |
| 470 | + |
| 471 | +class TestHooks(TransactionCase): |
| 472 | + """Test installation hooks functionality.""" |
| 473 | + |
| 474 | + def test_drop_gin_indexes_on_text_columns_no_indexes(self): |
| 475 | + """Test drop_gin_indexes_on_text_columns when no indexes exist.""" |
| 476 | + # Should return 0 when no GIN indexes on TEXT columns exist |
| 477 | + count = drop_gin_indexes_on_text_columns(self.env.cr) |
| 478 | + self.assertEqual(count, 0) |
| 479 | + |
| 480 | + def test_pre_init_hook_runs_without_error(self): |
| 481 | + """Test pre_init_hook executes without errors.""" |
| 482 | + # pre_init_hook should run without raising exceptions |
| 483 | + # even when there are no GIN indexes to drop |
| 484 | + pre_init_hook(self.env) |
| 485 | + |
| 486 | + def test_post_init_hook_runs_without_error(self): |
| 487 | + """Test post_init_hook executes without errors.""" |
| 488 | + # post_init_hook should run without raising exceptions |
| 489 | + # even when there are no columns to migrate |
| 490 | + post_init_hook(self.env) |
| 491 | + |
| 492 | + def test_post_init_hook_with_jsonb_column(self): |
| 493 | + """Test post_init_hook handles existing JSONB columns.""" |
| 494 | + # Create a test table with a JSONB column matching the pattern |
| 495 | + self.env.cr.execute( |
| 496 | + """ |
| 497 | + CREATE TABLE IF NOT EXISTS test_hook_table ( |
| 498 | + id SERIAL PRIMARY KEY, |
| 499 | + x_custom_json_test JSONB |
| 500 | + ) |
| 501 | + """ |
| 502 | + ) |
| 503 | + |
| 504 | + # Run post_init_hook - should not fail |
| 505 | + post_init_hook(self.env) |
| 506 | + |
| 507 | + # Check if GIN index was created |
| 508 | + self.env.cr.execute( |
| 509 | + """ |
| 510 | + SELECT indexname FROM pg_indexes |
| 511 | + WHERE tablename = 'test_hook_table' |
| 512 | + AND indexname LIKE '%gin%' |
| 513 | + """ |
| 514 | + ) |
| 515 | + result = self.env.cr.fetchone() |
| 516 | + self.assertIsNotNone(result) |
| 517 | + |
| 518 | + # Cleanup |
| 519 | + self.env.cr.execute("DROP TABLE IF EXISTS test_hook_table") |
| 520 | + |
| 521 | + def test_gin_index_creation_on_jsonb(self): |
| 522 | + """Test that GIN indexes can be created on JSONB columns.""" |
| 523 | + # Create a test table |
| 524 | + self.env.cr.execute( |
| 525 | + """ |
| 526 | + CREATE TABLE IF NOT EXISTS test_gin_jsonb ( |
| 527 | + id SERIAL PRIMARY KEY, |
| 528 | + data JSONB |
| 529 | + ) |
| 530 | + """ |
| 531 | + ) |
| 532 | + |
| 533 | + # Create GIN index |
| 534 | + self.env.cr.execute( |
| 535 | + """ |
| 536 | + CREATE INDEX IF NOT EXISTS idx_test_gin_jsonb_data |
| 537 | + ON test_gin_jsonb USING GIN (data) |
| 538 | + """ |
| 539 | + ) |
| 540 | + |
| 541 | + # Verify index exists |
| 542 | + self.env.cr.execute( |
| 543 | + """ |
| 544 | + SELECT indexname FROM pg_indexes |
| 545 | + WHERE tablename = 'test_gin_jsonb' |
| 546 | + AND indexname = 'idx_test_gin_jsonb_data' |
| 547 | + """ |
| 548 | + ) |
| 549 | + result = self.env.cr.fetchone() |
| 550 | + self.assertIsNotNone(result) |
| 551 | + |
| 552 | + # Cleanup |
| 553 | + self.env.cr.execute("DROP TABLE IF EXISTS test_gin_jsonb") |
| 554 | + |
| 555 | + |
| 556 | +class TestSerializedJsonbField(TransactionCase): |
| 557 | + """Test SerializedJsonb field class properties.""" |
| 558 | + |
| 559 | + def test_field_type(self): |
| 560 | + """Test that SerializedJsonb has correct type.""" |
| 561 | + field = SerializedJsonb() |
| 562 | + self.assertEqual(field.type, "serialized") |
| 563 | + |
| 564 | + def test_field_column_type(self): |
| 565 | + """Test that SerializedJsonb uses JSONB column type.""" |
| 566 | + field = SerializedJsonb() |
| 567 | + self.assertEqual(field.column_type, ("jsonb", "jsonb")) |
| 568 | + |
| 569 | + def test_field_prefetch(self): |
| 570 | + """Test that SerializedJsonb is not prefetched by default.""" |
| 571 | + field = SerializedJsonb() |
| 572 | + self.assertFalse(field.prefetch) |
| 573 | + |
| 574 | + def test_serialized_class_is_replaced(self): |
| 575 | + """Test that fields.Serialized is now SerializedJsonb.""" |
| 576 | + self.assertIs(fields.Serialized, SerializedJsonb) |
0 commit comments