py10x-universe 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- core_10x/__init__.py +42 -0
- core_10x/backbone/__init__.py +0 -0
- core_10x/backbone/backbone_store.py +59 -0
- core_10x/backbone/backbone_traitable.py +30 -0
- core_10x/backbone/backbone_user.py +66 -0
- core_10x/backbone/bound_data_domain.py +49 -0
- core_10x/backbone/namespace.py +101 -0
- core_10x/backbone/vault.py +38 -0
- core_10x/code_samples/__init__.py +0 -0
- core_10x/code_samples/_package_manifest.py +3 -0
- core_10x/code_samples/directories.py +181 -0
- core_10x/code_samples/person.py +76 -0
- core_10x/concrete_traits.py +356 -0
- core_10x/conftest.py +12 -0
- core_10x/curve.py +321 -0
- core_10x/data_domain.py +48 -0
- core_10x/data_domain_binder.py +45 -0
- core_10x/directory.py +250 -0
- core_10x/entity.py +8 -0
- core_10x/entity_filter.py +5 -0
- core_10x/environment_variables.py +147 -0
- core_10x/exec_control.py +84 -0
- core_10x/experimental/__init__.py +0 -0
- core_10x/experimental/data_protocol_ex.py +34 -0
- core_10x/global_cache.py +121 -0
- core_10x/manual_tests/__init__.py +0 -0
- core_10x/manual_tests/calendar_test.py +35 -0
- core_10x/manual_tests/ctor_update_bug.py +58 -0
- core_10x/manual_tests/debug_graph_on.py +17 -0
- core_10x/manual_tests/debug_graphoff_inside_graph_on.py +28 -0
- core_10x/manual_tests/enum_bits_test.py +17 -0
- core_10x/manual_tests/env_vars_trivial_test.py +12 -0
- core_10x/manual_tests/existing_traitable.py +33 -0
- core_10x/manual_tests/k10x_test1.py +13 -0
- core_10x/manual_tests/named_constant_test.py +121 -0
- core_10x/manual_tests/nucleus_trivial_test.py +42 -0
- core_10x/manual_tests/polars_test.py +14 -0
- core_10x/manual_tests/py_class_test.py +4 -0
- core_10x/manual_tests/rc_test.py +42 -0
- core_10x/manual_tests/rdate_test.py +12 -0
- core_10x/manual_tests/reference_serialization_bug.py +19 -0
- core_10x/manual_tests/resource_trivial_test.py +10 -0
- core_10x/manual_tests/store_uri_test.py +6 -0
- core_10x/manual_tests/trait_definition_test.py +19 -0
- core_10x/manual_tests/trait_filter_test.py +15 -0
- core_10x/manual_tests/trait_flag_modification_test.py +42 -0
- core_10x/manual_tests/trait_modification_bug.py +26 -0
- core_10x/manual_tests/traitable_as_of_test.py +82 -0
- core_10x/manual_tests/traitable_heir_test.py +39 -0
- core_10x/manual_tests/traitable_history_test.py +41 -0
- core_10x/manual_tests/traitable_serialization_test.py +54 -0
- core_10x/manual_tests/traitable_trivial_test.py +71 -0
- core_10x/manual_tests/trivial_graph_test.py +16 -0
- core_10x/manual_tests/ts_class_association_test.py +64 -0
- core_10x/manual_tests/ts_trivial_test.py +35 -0
- core_10x/named_constant.py +425 -0
- core_10x/nucleus.py +81 -0
- core_10x/package_manifest.py +85 -0
- core_10x/package_refactoring.py +153 -0
- core_10x/py_class.py +431 -0
- core_10x/rc.py +155 -0
- core_10x/rdate.py +339 -0
- core_10x/resource.py +189 -0
- core_10x/roman_number.py +67 -0
- core_10x/testlib/__init__.py +0 -0
- core_10x/testlib/test_store.py +240 -0
- core_10x/testlib/traitable_history_tests.py +787 -0
- core_10x/testlib/ts_tests.py +280 -0
- core_10x/trait.py +377 -0
- core_10x/trait_definition.py +176 -0
- core_10x/trait_filter.py +205 -0
- core_10x/trait_method_error.py +36 -0
- core_10x/traitable.py +1082 -0
- core_10x/traitable_cli.py +153 -0
- core_10x/traitable_heir.py +33 -0
- core_10x/traitable_id.py +31 -0
- core_10x/ts_store.py +172 -0
- core_10x/ts_store_type.py +26 -0
- core_10x/ts_union.py +147 -0
- core_10x/ui_hint.py +153 -0
- core_10x/unit_tests/test_concrete_traits.py +156 -0
- core_10x/unit_tests/test_converters.py +51 -0
- core_10x/unit_tests/test_curve.py +157 -0
- core_10x/unit_tests/test_directory.py +54 -0
- core_10x/unit_tests/test_documentation.py +172 -0
- core_10x/unit_tests/test_environment_variables.py +15 -0
- core_10x/unit_tests/test_filters.py +239 -0
- core_10x/unit_tests/test_graph.py +348 -0
- core_10x/unit_tests/test_named_constant.py +98 -0
- core_10x/unit_tests/test_rc.py +11 -0
- core_10x/unit_tests/test_rdate.py +484 -0
- core_10x/unit_tests/test_trait_method_error.py +80 -0
- core_10x/unit_tests/test_trait_modification.py +19 -0
- core_10x/unit_tests/test_traitable.py +959 -0
- core_10x/unit_tests/test_traitable_history.py +1 -0
- core_10x/unit_tests/test_ts_store.py +1 -0
- core_10x/unit_tests/test_ts_union.py +369 -0
- core_10x/unit_tests/test_ui_nodes.py +81 -0
- core_10x/unit_tests/test_xxcalendar.py +471 -0
- core_10x/vault/__init__.py +0 -0
- core_10x/vault/sec_keys.py +133 -0
- core_10x/vault/security_keys_old.py +168 -0
- core_10x/vault/vault.py +56 -0
- core_10x/vault/vault_traitable.py +56 -0
- core_10x/vault/vault_user.py +70 -0
- core_10x/xdate_time.py +136 -0
- core_10x/xnone.py +71 -0
- core_10x/xxcalendar.py +228 -0
- infra_10x/__init__.py +0 -0
- infra_10x/manual_tests/__init__.py +0 -0
- infra_10x/manual_tests/test_misc.py +16 -0
- infra_10x/manual_tests/test_prepare_filter_and_pipeline.py +25 -0
- infra_10x/mongodb_admin.py +111 -0
- infra_10x/mongodb_store.py +346 -0
- infra_10x/mongodb_utils.py +129 -0
- infra_10x/unit_tests/conftest.py +13 -0
- infra_10x/unit_tests/test_mongo_db.py +36 -0
- infra_10x/unit_tests/test_mongo_history.py +1 -0
- py10x_universe-0.1.3.dist-info/METADATA +406 -0
- py10x_universe-0.1.3.dist-info/RECORD +214 -0
- py10x_universe-0.1.3.dist-info/WHEEL +4 -0
- py10x_universe-0.1.3.dist-info/licenses/LICENSE +21 -0
- ui_10x/__init__.py +0 -0
- ui_10x/apps/__init__.py +0 -0
- ui_10x/apps/collection_editor_app.py +100 -0
- ui_10x/choice.py +212 -0
- ui_10x/collection_editor.py +135 -0
- ui_10x/concrete_trait_widgets.py +220 -0
- ui_10x/conftest.py +8 -0
- ui_10x/entity_stocker.py +173 -0
- ui_10x/examples/__init__.py +0 -0
- ui_10x/examples/_guess_word_data.py +14076 -0
- ui_10x/examples/collection_editor.py +17 -0
- ui_10x/examples/date_selector.py +14 -0
- ui_10x/examples/entity_stocker.py +18 -0
- ui_10x/examples/guess_word.py +392 -0
- ui_10x/examples/message_box.py +20 -0
- ui_10x/examples/multi_choice.py +17 -0
- ui_10x/examples/py_data_browser.py +66 -0
- ui_10x/examples/radiobox.py +29 -0
- ui_10x/examples/single_choice.py +31 -0
- ui_10x/examples/style_sheet.py +47 -0
- ui_10x/examples/trivial_entity_editor.py +18 -0
- ui_10x/platform.py +20 -0
- ui_10x/platform_interface.py +517 -0
- ui_10x/py_data_browser.py +249 -0
- ui_10x/qt6/__init__.py +0 -0
- ui_10x/qt6/conftest.py +8 -0
- ui_10x/qt6/manual_tests/__init__.py +0 -0
- ui_10x/qt6/manual_tests/basic_test.py +35 -0
- ui_10x/qt6/platform_implementation.py +275 -0
- ui_10x/qt6/utils.py +665 -0
- ui_10x/rio/__init__.py +0 -0
- ui_10x/rio/apps/examples/examples/__init__.py +22 -0
- ui_10x/rio/apps/examples/examples/components/__init__.py +3 -0
- ui_10x/rio/apps/examples/examples/components/collection_editor.py +15 -0
- ui_10x/rio/apps/examples/examples/pages/collection_editor.py +21 -0
- ui_10x/rio/apps/examples/examples/pages/login_page.py +88 -0
- ui_10x/rio/apps/examples/examples/pages/style_sheet.py +21 -0
- ui_10x/rio/apps/examples/rio.toml +14 -0
- ui_10x/rio/component_builder.py +497 -0
- ui_10x/rio/components/__init__.py +9 -0
- ui_10x/rio/components/group_box.py +31 -0
- ui_10x/rio/components/labeled_checkbox.py +18 -0
- ui_10x/rio/components/line_edit.py +37 -0
- ui_10x/rio/components/radio_button.py +32 -0
- ui_10x/rio/components/separator.py +24 -0
- ui_10x/rio/components/splitter.py +121 -0
- ui_10x/rio/components/tree_view.py +75 -0
- ui_10x/rio/conftest.py +35 -0
- ui_10x/rio/internals/__init__.py +0 -0
- ui_10x/rio/internals/app.py +192 -0
- ui_10x/rio/manual_tests/__init__.py +0 -0
- ui_10x/rio/manual_tests/basic_test.py +24 -0
- ui_10x/rio/manual_tests/splitter.py +27 -0
- ui_10x/rio/platform_implementation.py +91 -0
- ui_10x/rio/style_sheet.py +53 -0
- ui_10x/rio/unit_tests/test_collection_editor.py +68 -0
- ui_10x/rio/unit_tests/test_internals.py +630 -0
- ui_10x/rio/unit_tests/test_style_sheet.py +37 -0
- ui_10x/rio/widgets/__init__.py +46 -0
- ui_10x/rio/widgets/application.py +109 -0
- ui_10x/rio/widgets/button.py +48 -0
- ui_10x/rio/widgets/button_group.py +60 -0
- ui_10x/rio/widgets/calendar.py +23 -0
- ui_10x/rio/widgets/checkbox.py +24 -0
- ui_10x/rio/widgets/dialog.py +137 -0
- ui_10x/rio/widgets/group_box.py +27 -0
- ui_10x/rio/widgets/layout.py +34 -0
- ui_10x/rio/widgets/line_edit.py +37 -0
- ui_10x/rio/widgets/list.py +105 -0
- ui_10x/rio/widgets/message_box.py +70 -0
- ui_10x/rio/widgets/scroll_area.py +31 -0
- ui_10x/rio/widgets/spacer.py +6 -0
- ui_10x/rio/widgets/splitter.py +45 -0
- ui_10x/rio/widgets/text_edit.py +28 -0
- ui_10x/rio/widgets/tree.py +89 -0
- ui_10x/rio/widgets/unit_tests/test_button.py +101 -0
- ui_10x/rio/widgets/unit_tests/test_button_group.py +33 -0
- ui_10x/rio/widgets/unit_tests/test_calendar.py +114 -0
- ui_10x/rio/widgets/unit_tests/test_checkbox.py +109 -0
- ui_10x/rio/widgets/unit_tests/test_group_box.py +158 -0
- ui_10x/rio/widgets/unit_tests/test_label.py +43 -0
- ui_10x/rio/widgets/unit_tests/test_line_edit.py +140 -0
- ui_10x/rio/widgets/unit_tests/test_list.py +146 -0
- ui_10x/table_header_view.py +305 -0
- ui_10x/table_view.py +174 -0
- ui_10x/trait_editor.py +189 -0
- ui_10x/trait_widget.py +131 -0
- ui_10x/traitable_editor.py +200 -0
- ui_10x/traitable_view.py +131 -0
- ui_10x/unit_tests/conftest.py +8 -0
- ui_10x/unit_tests/test_platform.py +9 -0
- ui_10x/utils.py +661 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
"""Tests for TraitableHistory functionality using pytest and real TestStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from py10x_core import BTraitableProcessor
|
|
10
|
+
from typing_extensions import Self
|
|
11
|
+
|
|
12
|
+
from core_10x.exec_control import CACHE_ONLY, GRAPH_OFF, GRAPH_ON, INTERACTIVE
|
|
13
|
+
from core_10x.py_class import PyClass
|
|
14
|
+
from core_10x.rc import RC, RC_TRUE
|
|
15
|
+
from core_10x.traitable import AsOfContext, T, Traitable, TraitableHistory
|
|
16
|
+
from core_10x.traitable_id import ID
|
|
17
|
+
from core_10x.ts_store import TsDuplicateKeyError
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from infra_10x.mongodb_store import MongoStore
|
|
21
|
+
except ImportError:
|
|
22
|
+
MongoStore = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NameValueTraitableBase(Traitable):
|
|
26
|
+
"""Test traitable class for testing."""
|
|
27
|
+
|
|
28
|
+
name: str = T()
|
|
29
|
+
value: int = T()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NameValueTraitableCustomCollection(NameValueTraitableBase):
|
|
33
|
+
s_custom_collection = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PersonTraitableBase(Traitable):
|
|
37
|
+
"""Test person class for testing."""
|
|
38
|
+
|
|
39
|
+
name: str = T()
|
|
40
|
+
age: int = T()
|
|
41
|
+
email: str = T()
|
|
42
|
+
dob: date = T()
|
|
43
|
+
spouse: Self = T()
|
|
44
|
+
|
|
45
|
+
def spouse_set(self, trait, spouse) -> RC:
|
|
46
|
+
self.raw_set_value(trait, spouse)
|
|
47
|
+
if spouse:
|
|
48
|
+
spouse.raw_set_value(trait, self)
|
|
49
|
+
return RC_TRUE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
NameValueTraitable = type(f'PersonTraitable#{uuid.uuid1().hex}', (NameValueTraitableBase,), {'__module__': __name__})
|
|
53
|
+
PersonTraitable = type(f'PersonTraitable#{uuid.uuid1().hex}', (PersonTraitableBase,), {'__module__': __name__})
|
|
54
|
+
|
|
55
|
+
globals()[NameValueTraitable.__name__] = NameValueTraitable
|
|
56
|
+
globals()[PersonTraitable.__name__] = PersonTraitable
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.fixture
|
|
60
|
+
def test_collection(test_store):
|
|
61
|
+
"""Create a test collection for testing."""
|
|
62
|
+
collection_name = f'test_collection_{uuid.uuid1()}'
|
|
63
|
+
yield test_store.collection(collection_name=collection_name)
|
|
64
|
+
test_store.delete_collection(collection_name=collection_name)
|
|
65
|
+
test_store.delete_collection(collection_name=f'{collection_name}#history')
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture
|
|
69
|
+
def test_store(ts_instance):
|
|
70
|
+
store = ts_instance
|
|
71
|
+
store.username = 'test_user'
|
|
72
|
+
store.begin_using()
|
|
73
|
+
yield store
|
|
74
|
+
for cls in [PersonTraitable, NameValueTraitable]:
|
|
75
|
+
store.delete_collection(cls.collection().collection_name())
|
|
76
|
+
store.delete_collection(cls.s_history_class.collection().collection_name())
|
|
77
|
+
store.end_using()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestTraitableHistory:
|
|
81
|
+
"""Test TraitableHistory functionality."""
|
|
82
|
+
|
|
83
|
+
def test_asof_context_enter_exit(self):
|
|
84
|
+
"""Test AsOfContext enter and exit."""
|
|
85
|
+
as_of_time = datetime(2023, 1, 1, 12, 0, 0)
|
|
86
|
+
|
|
87
|
+
with AsOfContext(as_of_time, [NameValueTraitable]) as context:
|
|
88
|
+
assert context.as_of_time == as_of_time
|
|
89
|
+
|
|
90
|
+
def test_asof_context_manager(self):
|
|
91
|
+
"""Test AsOfContext as context manager."""
|
|
92
|
+
as_of_time = datetime(2023, 1, 1, 12, 0, 0)
|
|
93
|
+
|
|
94
|
+
with AsOfContext(as_of_time, [NameValueTraitable]) as context:
|
|
95
|
+
assert context.as_of_time == as_of_time
|
|
96
|
+
|
|
97
|
+
def test_traitable_history_collection(self, test_store):
|
|
98
|
+
"""Test TraitableHistory collection name generation."""
|
|
99
|
+
# Test with default collection name
|
|
100
|
+
coll = NameValueTraitable.collection()
|
|
101
|
+
history_coll = NameValueTraitable.s_history_class.collection()
|
|
102
|
+
assert coll.collection_name() + '#history' == history_coll.collection_name()
|
|
103
|
+
|
|
104
|
+
def test_traitable_history_save(self, test_store, test_collection):
|
|
105
|
+
serialized_data = {'_id': 'test-123', '_rev': 1, 'name': 'Test Item', 'value': 42}
|
|
106
|
+
|
|
107
|
+
class CustomCollectionHistory(TraitableHistory, custom_collection=True):
|
|
108
|
+
s_traitable_class = Traitable
|
|
109
|
+
|
|
110
|
+
CustomCollectionHistory(
|
|
111
|
+
serialized_traitable=serialized_data,
|
|
112
|
+
_traitable_rev=2,
|
|
113
|
+
_collection_name=test_collection.collection_name(),
|
|
114
|
+
).save().throw()
|
|
115
|
+
|
|
116
|
+
# Verify that the history entry was saved
|
|
117
|
+
assert test_collection.count() == 1
|
|
118
|
+
|
|
119
|
+
# Get the saved document to verify its structure
|
|
120
|
+
saved_docs = list(test_collection.find())
|
|
121
|
+
assert len(saved_docs) == 1
|
|
122
|
+
|
|
123
|
+
saved_doc = saved_docs[0]
|
|
124
|
+
assert saved_doc['_traitable_id'] == 'test-123'
|
|
125
|
+
assert saved_doc['_traitable_rev'] == 2
|
|
126
|
+
assert saved_doc['_who'] == 'test_user'
|
|
127
|
+
assert saved_doc['name'] == 'Test Item'
|
|
128
|
+
assert saved_doc['value'] == 42
|
|
129
|
+
assert isinstance(saved_doc['_at'], datetime)
|
|
130
|
+
|
|
131
|
+
def test_traitable_class_history_methods(self, test_store, test_collection):
|
|
132
|
+
"""Test Traitable class history methods."""
|
|
133
|
+
# Create a test traitable and save it with history
|
|
134
|
+
test_item = NameValueTraitableCustomCollection(_collection_name=test_collection.collection_name())
|
|
135
|
+
test_item.name = 'Test Item'
|
|
136
|
+
test_item.value = 42
|
|
137
|
+
|
|
138
|
+
# Save the traitable (this should create history)
|
|
139
|
+
test_item.save()
|
|
140
|
+
|
|
141
|
+
assert test_item._collection_name == test_collection.collection_name()
|
|
142
|
+
|
|
143
|
+
# Test history method
|
|
144
|
+
history = NameValueTraitableCustomCollection.history(_collection_name=test_item._collection_name)
|
|
145
|
+
assert len(history) == 1
|
|
146
|
+
assert history[0]['_traitable_id'] == test_item.id().value
|
|
147
|
+
assert history[0]['_traitable_rev'] == test_item._rev
|
|
148
|
+
assert history[0]['_who'] == 'test_user'
|
|
149
|
+
assert '_at' in history[0] # Should have timestamp
|
|
150
|
+
assert history[0]['name'] == 'Test Item'
|
|
151
|
+
assert history[0]['value'] == 42
|
|
152
|
+
|
|
153
|
+
ts = datetime.utcnow()
|
|
154
|
+
test_item.value = 43
|
|
155
|
+
test_item.save()
|
|
156
|
+
assert test_item._rev == 2
|
|
157
|
+
assert test_item.value == 43
|
|
158
|
+
|
|
159
|
+
assert NameValueTraitableCustomCollection.restore(test_item.id(), ts)
|
|
160
|
+
assert test_item._rev == 1
|
|
161
|
+
assert test_item.value == 42
|
|
162
|
+
|
|
163
|
+
def test_history_entry_creation(self, test_store):
|
|
164
|
+
"""Test that history entries are created when saving traitables."""
|
|
165
|
+
# Create a person
|
|
166
|
+
person = PersonTraitable()
|
|
167
|
+
person.name = 'John Doe'
|
|
168
|
+
person.age = 30
|
|
169
|
+
person.email = 'john@example.com'
|
|
170
|
+
|
|
171
|
+
# Save the person
|
|
172
|
+
person.save()
|
|
173
|
+
|
|
174
|
+
# Check that history was created
|
|
175
|
+
history_collection = test_store.collection(f'{__name__.replace(".", "/")}/{PersonTraitable.__name__}#history')
|
|
176
|
+
assert history_collection.count() == 1
|
|
177
|
+
|
|
178
|
+
# Verify history entry structure
|
|
179
|
+
history_docs = list(history_collection.find())
|
|
180
|
+
history_doc = history_docs[0]
|
|
181
|
+
assert history_doc['_traitable_id'] == person.id().value
|
|
182
|
+
# History entry is created before revision increment, so it should be 0
|
|
183
|
+
assert history_doc['_traitable_rev'] == person._rev
|
|
184
|
+
assert history_doc['_who'] == 'test_user'
|
|
185
|
+
assert '_at' in history_doc
|
|
186
|
+
assert history_doc['name'] == 'John Doe'
|
|
187
|
+
assert history_doc['age'] == 30
|
|
188
|
+
assert history_doc['email'] == 'john@example.com'
|
|
189
|
+
|
|
190
|
+
def test_multiple_history_entries(self, test_store):
|
|
191
|
+
"""Test that multiple history entries are created for updates."""
|
|
192
|
+
|
|
193
|
+
# Create a person
|
|
194
|
+
person = PersonTraitable()
|
|
195
|
+
person.name = 'John Doe'
|
|
196
|
+
person.age = 30
|
|
197
|
+
person.email = 'john@example.com'
|
|
198
|
+
person.save()
|
|
199
|
+
|
|
200
|
+
# Update the person
|
|
201
|
+
person.age = 31
|
|
202
|
+
person.save()
|
|
203
|
+
|
|
204
|
+
# Check that two history entries were created
|
|
205
|
+
history_collection = test_store.collection(f'{__name__.replace(".", "/")}/{PersonTraitable.__name__}#history')
|
|
206
|
+
assert history_collection.count() == 2
|
|
207
|
+
|
|
208
|
+
# Verify both history entries
|
|
209
|
+
history_docs = list(history_collection.find())
|
|
210
|
+
assert len(history_docs) == 2
|
|
211
|
+
|
|
212
|
+
# Both should have the same traitable_id but different revs
|
|
213
|
+
traitable_ids = [doc['_traitable_id'] for doc in history_docs]
|
|
214
|
+
assert all(tid == person.id().value for tid in traitable_ids)
|
|
215
|
+
|
|
216
|
+
revs = {doc['_traitable_rev'] for doc in history_docs}
|
|
217
|
+
assert {1, 2} == revs
|
|
218
|
+
|
|
219
|
+
def test_history_method(self, test_store):
|
|
220
|
+
"""Test the history method."""
|
|
221
|
+
|
|
222
|
+
# Create a person
|
|
223
|
+
person = PersonTraitable()
|
|
224
|
+
person.name = 'John Doe'
|
|
225
|
+
person.age = 30
|
|
226
|
+
person.email = 'john@example.com'
|
|
227
|
+
person.save()
|
|
228
|
+
|
|
229
|
+
# Update the person
|
|
230
|
+
person.age = 31
|
|
231
|
+
person.save()
|
|
232
|
+
|
|
233
|
+
# Get history
|
|
234
|
+
history = PersonTraitable.history()
|
|
235
|
+
|
|
236
|
+
# Should have 2 history entries
|
|
237
|
+
assert len(history) == 2
|
|
238
|
+
|
|
239
|
+
# Both entries should have the correct traitable_id
|
|
240
|
+
for entry in history:
|
|
241
|
+
assert entry['_traitable_id'] == person.id().value
|
|
242
|
+
assert entry['_who'] == 'test_user'
|
|
243
|
+
assert '_at' in entry
|
|
244
|
+
|
|
245
|
+
def test_latest_revision(self, test_store):
|
|
246
|
+
"""Test the latest_revision method."""
|
|
247
|
+
|
|
248
|
+
# Create a person
|
|
249
|
+
person = PersonTraitable()
|
|
250
|
+
person.name = 'John Doe'
|
|
251
|
+
person.age = 30
|
|
252
|
+
person.email = 'john@example.com'
|
|
253
|
+
person.save().throw()
|
|
254
|
+
|
|
255
|
+
# Update the person
|
|
256
|
+
person.age = 31
|
|
257
|
+
person.save().throw()
|
|
258
|
+
|
|
259
|
+
assert person._rev == 2
|
|
260
|
+
|
|
261
|
+
# Get latest revision
|
|
262
|
+
latest = PersonTraitable.latest_revision(person.id())
|
|
263
|
+
|
|
264
|
+
# Should be the latest revision
|
|
265
|
+
assert latest['_traitable_id'] == person.id().value
|
|
266
|
+
assert latest['_traitable_rev'] == person._rev
|
|
267
|
+
assert latest['_who'] == 'test_user'
|
|
268
|
+
assert '_at' in latest
|
|
269
|
+
assert latest['age'] == 31 # Updated age
|
|
270
|
+
|
|
271
|
+
def test_asof_context_manager_ops(self, test_store):
|
|
272
|
+
"""Test AsOfContext with real data."""
|
|
273
|
+
|
|
274
|
+
# Create a person
|
|
275
|
+
person = PersonTraitable()
|
|
276
|
+
person.name = 'John Doe'
|
|
277
|
+
person.age = 30
|
|
278
|
+
person.email = 'john@example.com'
|
|
279
|
+
person.save()
|
|
280
|
+
|
|
281
|
+
# Record the time after creation
|
|
282
|
+
query_time = datetime.utcnow()
|
|
283
|
+
|
|
284
|
+
# Update the person
|
|
285
|
+
person.age = 31
|
|
286
|
+
person.save()
|
|
287
|
+
|
|
288
|
+
# Load the person as of the query time (should be the original version)
|
|
289
|
+
with AsOfContext(query_time):
|
|
290
|
+
historical_person = PersonTraitable.load(person.id())
|
|
291
|
+
assert historical_person.age == 30 # Original age
|
|
292
|
+
assert historical_person.name == 'John Doe'
|
|
293
|
+
|
|
294
|
+
def test_as_of_error_cases(self, test_store):
|
|
295
|
+
with BTraitableProcessor.create_root():
|
|
296
|
+
# Create and save a person
|
|
297
|
+
person = PersonTraitable(first_name='Alyssa', last_name='Lees', dob=date(1985, 7, 5), _replace=True)
|
|
298
|
+
person.spouse = PersonTraitable(first_name='James', last_name='Bond', dob=date(1985, 7, 5), _replace=True)
|
|
299
|
+
person.spouse.save().throw()
|
|
300
|
+
person.save().throw()
|
|
301
|
+
|
|
302
|
+
ts = datetime.utcnow()
|
|
303
|
+
|
|
304
|
+
# Update and save again
|
|
305
|
+
person.dob = date(1985, 7, 6)
|
|
306
|
+
person.spouse.dob = date(1985, 7, 6)
|
|
307
|
+
person.spouse.save().throw()
|
|
308
|
+
person.save().throw()
|
|
309
|
+
|
|
310
|
+
person_id = person.id()
|
|
311
|
+
print(person.serialize_object())
|
|
312
|
+
|
|
313
|
+
person.spouse.id()
|
|
314
|
+
|
|
315
|
+
assert PersonTraitable.existing_instance_by_id(person_id, _throw=False)
|
|
316
|
+
|
|
317
|
+
with CACHE_ONLY():
|
|
318
|
+
assert not PersonTraitable.existing_instance_by_id(person_id, _throw=False)
|
|
319
|
+
|
|
320
|
+
for ctx in (
|
|
321
|
+
INTERACTIVE,
|
|
322
|
+
GRAPH_ON,
|
|
323
|
+
GRAPH_OFF,
|
|
324
|
+
):
|
|
325
|
+
for as_of in (
|
|
326
|
+
ts,
|
|
327
|
+
None,
|
|
328
|
+
):
|
|
329
|
+
with ctx():
|
|
330
|
+
person1 = PersonTraitable(person_id)
|
|
331
|
+
assert person1.dob == date(1985, 7, 6)
|
|
332
|
+
|
|
333
|
+
person_as_of = PersonTraitable.as_of(person_id, as_of_time=ts)
|
|
334
|
+
|
|
335
|
+
with AsOfContext(traitable_classes=[PersonTraitable], as_of_time=as_of):
|
|
336
|
+
with pytest.raises(RuntimeError, match=r'object not usable - origin cache is not reachable'):
|
|
337
|
+
_ = person1.dob
|
|
338
|
+
|
|
339
|
+
with pytest.raises(RuntimeError, match=r'object not usable - origin cache is not reachable'):
|
|
340
|
+
_ = person_as_of.dob
|
|
341
|
+
|
|
342
|
+
person2 = PersonTraitable(person_id)
|
|
343
|
+
assert person2.dob == (date(1985, 7, 5 + (as_of is None)))
|
|
344
|
+
assert person2.spouse.dob == (date(1985, 7, 5 + (as_of is None)))
|
|
345
|
+
|
|
346
|
+
assert person1.spouse.dob == date(1985, 7, 6)
|
|
347
|
+
assert person_as_of.dob == date(1985, 7, 5), person_as_of.dob
|
|
348
|
+
|
|
349
|
+
assert person_as_of.spouse.dob == date(1985, 7, 6) # note - since no AsOfContext was used, nested objects are not loaded "as_of".
|
|
350
|
+
|
|
351
|
+
with pytest.raises(RuntimeError, match=r'object not usable - origin cache is not reachable'):
|
|
352
|
+
_ = person2.dob
|
|
353
|
+
|
|
354
|
+
def test_load_as_of(self, test_store):
|
|
355
|
+
"""Test loading a traitable as of a specific time."""
|
|
356
|
+
|
|
357
|
+
# Create a person
|
|
358
|
+
person = PersonTraitable()
|
|
359
|
+
person.name = 'John Doe'
|
|
360
|
+
person.age = 30
|
|
361
|
+
person.email = 'john@example.com'
|
|
362
|
+
person.save()
|
|
363
|
+
|
|
364
|
+
# Record the time after creation
|
|
365
|
+
initial_time = datetime.utcnow()
|
|
366
|
+
|
|
367
|
+
# Update the person
|
|
368
|
+
person.age = 31
|
|
369
|
+
person.save()
|
|
370
|
+
|
|
371
|
+
# Load the person as of the initial time
|
|
372
|
+
historical_person = PersonTraitable.as_of(person.id(), initial_time)
|
|
373
|
+
assert historical_person.age == 30 # Original age
|
|
374
|
+
assert historical_person.name == 'John Doe'
|
|
375
|
+
assert historical_person == person # person's trait values also get updated due to shared cache
|
|
376
|
+
|
|
377
|
+
def test_load_many_with_as_of(self, test_store):
|
|
378
|
+
"""Test loading multiple traitables as of a specific time."""
|
|
379
|
+
|
|
380
|
+
# Create two people
|
|
381
|
+
person1 = PersonTraitable()
|
|
382
|
+
person1.name = 'John Doe'
|
|
383
|
+
person1.age = 30
|
|
384
|
+
person1.email = 'john@example.com'
|
|
385
|
+
person1.save()
|
|
386
|
+
|
|
387
|
+
person2 = PersonTraitable()
|
|
388
|
+
person2.name = 'Jane Smith'
|
|
389
|
+
person2.age = 25
|
|
390
|
+
person2.email = 'jane@example.com'
|
|
391
|
+
person2.save()
|
|
392
|
+
|
|
393
|
+
# Record the time after creation
|
|
394
|
+
query_time = datetime.utcnow()
|
|
395
|
+
|
|
396
|
+
# Update both people
|
|
397
|
+
person1.age = 31
|
|
398
|
+
person1.save()
|
|
399
|
+
|
|
400
|
+
person2.age = 26
|
|
401
|
+
person2.save()
|
|
402
|
+
|
|
403
|
+
with AsOfContext(query_time, [PersonTraitable]):
|
|
404
|
+
historical_people = PersonTraitable.load_many()
|
|
405
|
+
|
|
406
|
+
# Should have 2 people with original ages
|
|
407
|
+
assert len(historical_people) == 2
|
|
408
|
+
|
|
409
|
+
# Find the specific people
|
|
410
|
+
john = next(p for p in historical_people if p.name == 'John Doe')
|
|
411
|
+
jane = next(p for p in historical_people if p.name == 'Jane Smith')
|
|
412
|
+
|
|
413
|
+
assert john.age == 30 # Original age
|
|
414
|
+
assert jane.age == 25 # Original age
|
|
415
|
+
|
|
416
|
+
assert person1.age == 31 # Current age
|
|
417
|
+
assert person2.age == 26 # Current age
|
|
418
|
+
|
|
419
|
+
def test_history_without_keep_history(self, test_store, monkeypatch):
|
|
420
|
+
"""Test that history is not created when keep_history is False."""
|
|
421
|
+
monkeypatch.setattr('core_10x.package_refactoring.PackageRefactoring.default_class_id', lambda cls, *args, **kwargs: PyClass.name(cls))
|
|
422
|
+
|
|
423
|
+
# Create a class without history tracking
|
|
424
|
+
class NoHistoryTraitable(Traitable, keep_history=False):
|
|
425
|
+
name: str = T()
|
|
426
|
+
|
|
427
|
+
# Create and save an instance
|
|
428
|
+
item = NoHistoryTraitable()
|
|
429
|
+
item.name = 'Test Item'
|
|
430
|
+
item.save()
|
|
431
|
+
|
|
432
|
+
# Check that no history collection was created
|
|
433
|
+
history_collection = test_store.collection(f'{__name__.replace(".", "/")}/{NoHistoryTraitable.__name__}#history')
|
|
434
|
+
assert history_collection.count() == 0
|
|
435
|
+
|
|
436
|
+
def test_asof_with_keep_history_false_is_noop(self, test_store, monkeypatch):
|
|
437
|
+
"""AsOfContext with a class that has keep_history=False is a no-op: current data is used."""
|
|
438
|
+
monkeypatch.setattr('core_10x.package_refactoring.PackageRefactoring.default_class_id', lambda cls, *args, **kwargs: PyClass.name(cls))
|
|
439
|
+
|
|
440
|
+
class NoHistoryTraitable(Traitable, keep_history=False):
|
|
441
|
+
key: str = T(T.ID)
|
|
442
|
+
value: str = T()
|
|
443
|
+
|
|
444
|
+
item = NoHistoryTraitable(key='k1', value='v1', _replace=True)
|
|
445
|
+
item.save()
|
|
446
|
+
original_helper = NoHistoryTraitable.s_storage_helper
|
|
447
|
+
as_of_time = datetime(2020, 1, 1, 12, 0, 0)
|
|
448
|
+
|
|
449
|
+
with pytest.raises(
|
|
450
|
+
ValueError,
|
|
451
|
+
match=r"<class 'core_10x.testlib.traitable_history_tests.TestTraitableHistory.test_asof_with_keep_history_false_is_noop.<locals>.NoHistoryTraitable'> is not storable or does not keep history",
|
|
452
|
+
):
|
|
453
|
+
with AsOfContext(as_of_time, [NoHistoryTraitable]):
|
|
454
|
+
pass
|
|
455
|
+
assert type(NoHistoryTraitable.s_storage_helper) is type(original_helper)
|
|
456
|
+
assert NoHistoryTraitable.existing_instance(key='k1') == item
|
|
457
|
+
|
|
458
|
+
def test_asof_default_applies_to_all_traitable_subclasses(self, test_store):
|
|
459
|
+
"""AsOfContext with default traitable_classes (None) applies to all Traitable subclasses via __mro__."""
|
|
460
|
+
# Create and save a person
|
|
461
|
+
person = PersonTraitable()
|
|
462
|
+
person.name = 'Default AsOf Test'
|
|
463
|
+
person.age = 25
|
|
464
|
+
person.save()
|
|
465
|
+
|
|
466
|
+
query_time = datetime.utcnow()
|
|
467
|
+
|
|
468
|
+
person.age = 26
|
|
469
|
+
person.save()
|
|
470
|
+
|
|
471
|
+
# Default: no traitable_classes => [Traitable]; PersonTraitable is a subclass so gets AsOf
|
|
472
|
+
with AsOfContext(as_of_time=query_time):
|
|
473
|
+
historical = PersonTraitable.load(person.id())
|
|
474
|
+
assert historical is not None
|
|
475
|
+
assert historical.age == 25
|
|
476
|
+
assert historical.name == 'Default AsOf Test'
|
|
477
|
+
|
|
478
|
+
# Outside context: current data
|
|
479
|
+
current = PersonTraitable.load(person.id())
|
|
480
|
+
assert current.age == 26
|
|
481
|
+
|
|
482
|
+
def test_latest_revision_with_timestamp(self, test_store):
|
|
483
|
+
"""Test latest_revision with specific timestamp."""
|
|
484
|
+
# Create a person
|
|
485
|
+
person = PersonTraitable()
|
|
486
|
+
person.name = 'Time Test Person'
|
|
487
|
+
person.age = 30
|
|
488
|
+
person.save()
|
|
489
|
+
|
|
490
|
+
# Record time after first save
|
|
491
|
+
query_time = datetime.utcnow()
|
|
492
|
+
|
|
493
|
+
# Update the person
|
|
494
|
+
person.age = 31
|
|
495
|
+
person.save()
|
|
496
|
+
|
|
497
|
+
# Get latest revision as of the query time
|
|
498
|
+
latest = PersonTraitable.latest_revision(person.id(), timestamp=query_time)
|
|
499
|
+
|
|
500
|
+
# Should be the first revision (age 30)
|
|
501
|
+
assert latest['age'] == 30
|
|
502
|
+
assert latest['_traitable_rev'] == 1
|
|
503
|
+
|
|
504
|
+
def test_history_with_empty_collection(self, test_store):
|
|
505
|
+
"""Test history method with no history entries."""
|
|
506
|
+
# Create a person but don't save it
|
|
507
|
+
person = PersonTraitable()
|
|
508
|
+
person.name = 'Unsaved Person'
|
|
509
|
+
|
|
510
|
+
# Get history - should be empty
|
|
511
|
+
history = PersonTraitable.history()
|
|
512
|
+
assert len(history) == 0
|
|
513
|
+
|
|
514
|
+
def test_latest_revision_with_nonexistent_id(self, test_store):
|
|
515
|
+
"""Test latest_revision with non-existent ID."""
|
|
516
|
+
|
|
517
|
+
# Try to get latest revision for non-existent ID
|
|
518
|
+
latest = PersonTraitable.latest_revision(ID('nonexistent'))
|
|
519
|
+
assert latest is None
|
|
520
|
+
|
|
521
|
+
def test_history_default_at_most_parameter(self, test_store):
|
|
522
|
+
"""Test that history method uses default _at_most=0 (no limit)."""
|
|
523
|
+
# Create a person
|
|
524
|
+
person = PersonTraitable()
|
|
525
|
+
person.name = 'Test Person'
|
|
526
|
+
person.age = 30
|
|
527
|
+
person.save()
|
|
528
|
+
|
|
529
|
+
# Update multiple times
|
|
530
|
+
person.age = 31
|
|
531
|
+
person.save()
|
|
532
|
+
person.age = 32
|
|
533
|
+
person.save()
|
|
534
|
+
|
|
535
|
+
# Get all history entries (should get all 3)
|
|
536
|
+
history = PersonTraitable.history()
|
|
537
|
+
assert len(history) == 3
|
|
538
|
+
|
|
539
|
+
# Test with explicit _at_most=2
|
|
540
|
+
history_limited = PersonTraitable.history(_at_most=2)
|
|
541
|
+
assert len(history_limited) == 2
|
|
542
|
+
|
|
543
|
+
def test_latest_revision_with_timestamp_parameter(self, test_store):
|
|
544
|
+
"""Test that latest_revision accepts timestamp parameter."""
|
|
545
|
+
# Create a person
|
|
546
|
+
person = PersonTraitable()
|
|
547
|
+
person.name = 'Timestamp Test'
|
|
548
|
+
person.age = 25
|
|
549
|
+
person.save()
|
|
550
|
+
|
|
551
|
+
# Record time after first save
|
|
552
|
+
query_time = datetime.utcnow()
|
|
553
|
+
|
|
554
|
+
# Update the person
|
|
555
|
+
person.age = 26
|
|
556
|
+
person.save()
|
|
557
|
+
|
|
558
|
+
# Test latest_revision with timestamp
|
|
559
|
+
latest = PersonTraitable.latest_revision(person.id(), timestamp=query_time)
|
|
560
|
+
assert latest is not None
|
|
561
|
+
assert latest['age'] == 25 # Should be the first revision
|
|
562
|
+
|
|
563
|
+
# Test latest_revision without timestamp (should get latest)
|
|
564
|
+
latest_no_timestamp = PersonTraitable.latest_revision(person.id())
|
|
565
|
+
assert latest_no_timestamp is not None
|
|
566
|
+
assert latest_no_timestamp['age'] == 26 # Should be the latest revision
|
|
567
|
+
|
|
568
|
+
def test_restore(self, test_store):
|
|
569
|
+
"""Test restore method with save parameter."""
|
|
570
|
+
# Create a person
|
|
571
|
+
person = PersonTraitable()
|
|
572
|
+
person.name = 'Restore Test'
|
|
573
|
+
person.age = 30
|
|
574
|
+
person.save()
|
|
575
|
+
|
|
576
|
+
# Record time after first save
|
|
577
|
+
query_time = datetime.utcnow()
|
|
578
|
+
|
|
579
|
+
# Update the person
|
|
580
|
+
person.age = 31
|
|
581
|
+
person.save()
|
|
582
|
+
|
|
583
|
+
# Test restore without save (should not persist)
|
|
584
|
+
result = PersonTraitable.restore(person.id(), timestamp=query_time, save=False)
|
|
585
|
+
assert result is True
|
|
586
|
+
assert person.age == 30
|
|
587
|
+
|
|
588
|
+
# The person should still have the latest age
|
|
589
|
+
person.reload()
|
|
590
|
+
assert person.age == 31
|
|
591
|
+
|
|
592
|
+
# Test restore with save (should persist)
|
|
593
|
+
result = PersonTraitable.restore(person.id(), timestamp=query_time, save=True)
|
|
594
|
+
assert result is True
|
|
595
|
+
assert person.age == 30
|
|
596
|
+
person.reload()
|
|
597
|
+
assert person.age == 30
|
|
598
|
+
|
|
599
|
+
def test_restore_with_nonexistent_timestamp(self, test_store):
|
|
600
|
+
"""Test restore method with non-existent timestamp."""
|
|
601
|
+
# Create a person
|
|
602
|
+
person = PersonTraitable()
|
|
603
|
+
person.name = 'Restore Test'
|
|
604
|
+
person.age = 30
|
|
605
|
+
person.save()
|
|
606
|
+
|
|
607
|
+
# Try to restore to a time before the person existed
|
|
608
|
+
past_time = datetime(2020, 1, 1)
|
|
609
|
+
result = PersonTraitable.restore(person.id(), timestamp=past_time, save=False)
|
|
610
|
+
assert result is False
|
|
611
|
+
|
|
612
|
+
def test_restore_with_nonexistent_id(self, test_store):
|
|
613
|
+
"""Test restore method with non-existent ID."""
|
|
614
|
+
|
|
615
|
+
# Try to restore non-existent ID
|
|
616
|
+
result = PersonTraitable.restore(ID('nonexistent'), save=False)
|
|
617
|
+
assert result is False
|
|
618
|
+
|
|
619
|
+
def test_traitable_history_deserialize(self, test_store):
|
|
620
|
+
"""Test TraitableHistory.deserialize method."""
|
|
621
|
+
# Create a person
|
|
622
|
+
person = PersonTraitable()
|
|
623
|
+
person.name = 'Deserialize Test'
|
|
624
|
+
person.age = 25
|
|
625
|
+
person.save()
|
|
626
|
+
|
|
627
|
+
# Get history entry
|
|
628
|
+
history = PersonTraitable.history(_deserialize=True)
|
|
629
|
+
assert len(history) == 1
|
|
630
|
+
|
|
631
|
+
history_entry = history[0]
|
|
632
|
+
|
|
633
|
+
# Test deserialize
|
|
634
|
+
restored_person = history_entry.traitable
|
|
635
|
+
|
|
636
|
+
assert restored_person.name == 'Deserialize Test'
|
|
637
|
+
assert restored_person.age == 25
|
|
638
|
+
assert restored_person.id().value == person.id().value
|
|
639
|
+
|
|
640
|
+
def test_traitable_history_prepare_to_deserialize(self, test_store):
|
|
641
|
+
"""Test TraitableHistory.prepare_to_deserialize method."""
|
|
642
|
+
# Create a person
|
|
643
|
+
person = PersonTraitable()
|
|
644
|
+
person.name = 'Prepare Test'
|
|
645
|
+
person.age = 28
|
|
646
|
+
person.save()
|
|
647
|
+
|
|
648
|
+
# Get history entry
|
|
649
|
+
history = PersonTraitable.history(_deserialize=True)
|
|
650
|
+
history_entry = history[0]
|
|
651
|
+
|
|
652
|
+
# Test prepare_to_deserialize
|
|
653
|
+
prepared_data = history_entry.serialized_traitable
|
|
654
|
+
|
|
655
|
+
# Should have the traitable data without history-specific fields
|
|
656
|
+
assert prepared_data['name'] == 'Prepare Test'
|
|
657
|
+
assert prepared_data['age'] == 28
|
|
658
|
+
assert prepared_data['_id'] == person.id().value
|
|
659
|
+
assert prepared_data['_rev'] == person._rev
|
|
660
|
+
assert '_traitable_id' not in prepared_data
|
|
661
|
+
assert '_traitable_rev' not in prepared_data
|
|
662
|
+
assert '_who' not in prepared_data
|
|
663
|
+
assert '_at' not in prepared_data
|
|
664
|
+
|
|
665
|
+
def test_save_new_with_overwrite_parameter(self, test_store, test_collection):
|
|
666
|
+
"""Test TestCollection.save_new with overwrite parameter."""
|
|
667
|
+
# Test save_new with MongoDB-style $set operation
|
|
668
|
+
result = test_collection.save_new({'$set': {'_id': 'new-id', 'name': 'New Person', 'age': 25}})
|
|
669
|
+
assert result == 1 # Should succeed for new document
|
|
670
|
+
|
|
671
|
+
# Fails because the document already exists
|
|
672
|
+
with pytest.raises(TsDuplicateKeyError):
|
|
673
|
+
test_collection.save_new({'$set': {'_id': 'new-id'}})
|
|
674
|
+
|
|
675
|
+
test_collection.save_new({'$set': {'_id': 'new-id'}}, overwrite=True)
|
|
676
|
+
|
|
677
|
+
def test_find_without_filter_parameter(self, test_store, test_collection):
|
|
678
|
+
"""Test TestCollection.find without _filter parameter."""
|
|
679
|
+
|
|
680
|
+
# Add documents using the interface method
|
|
681
|
+
test_collection.save_new({'_id': 'doc1', 'name': 'Person 1', 'age': 25})
|
|
682
|
+
test_collection.save_new({'_id': 'doc2', 'name': 'Person 2', 'age': 30})
|
|
683
|
+
|
|
684
|
+
# Test find without _filter parameter
|
|
685
|
+
results = list(test_collection.find())
|
|
686
|
+
assert len(results) == 2 # Should find both persons
|
|
687
|
+
|
|
688
|
+
def test_immutability_of_classes_without_history(self, test_store, test_collection):
|
|
689
|
+
"""Test immutability of storable classes that do not keep history."""
|
|
690
|
+
|
|
691
|
+
class NoHistoryImmutableTraitable(NameValueTraitableCustomCollection, keep_history=False): ...
|
|
692
|
+
|
|
693
|
+
# Classes without history should be marked immutable
|
|
694
|
+
assert NoHistoryImmutableTraitable.s_history_class is None
|
|
695
|
+
assert NoHistoryImmutableTraitable.s_immutable is True
|
|
696
|
+
|
|
697
|
+
# First save should succeed and create a single record
|
|
698
|
+
item = NoHistoryImmutableTraitable(_collection_name=test_collection.collection_name())
|
|
699
|
+
item.name = 'First'
|
|
700
|
+
rc1 = item.save()
|
|
701
|
+
assert rc1
|
|
702
|
+
assert item.name == 'First'
|
|
703
|
+
assert item._rev == 1
|
|
704
|
+
assert test_collection.count() == 1
|
|
705
|
+
|
|
706
|
+
# Second save should fail (cannot update immutable records)
|
|
707
|
+
item.name = 'Second'
|
|
708
|
+
rc2 = item.save()
|
|
709
|
+
assert not rc2
|
|
710
|
+
assert item.name == 'Second'
|
|
711
|
+
assert item._rev == 1
|
|
712
|
+
|
|
713
|
+
# After reload, state should still reflect the original save
|
|
714
|
+
item.reload()
|
|
715
|
+
assert item.name == 'First'
|
|
716
|
+
assert item._rev == 1
|
|
717
|
+
|
|
718
|
+
def test_history_entries_are_immutable(self, test_store):
|
|
719
|
+
"""Test that individual history entries cannot be modified once written."""
|
|
720
|
+
|
|
721
|
+
# Create and save a person twice to generate multiple history entries
|
|
722
|
+
person = PersonTraitable()
|
|
723
|
+
person.name = 'Immutable History'
|
|
724
|
+
person.age = 30
|
|
725
|
+
person.email = 'immutable@example.com'
|
|
726
|
+
person.save()
|
|
727
|
+
|
|
728
|
+
person.age = 31
|
|
729
|
+
person.save()
|
|
730
|
+
|
|
731
|
+
history_collection = test_store.collection(f'{__name__.replace(".", "/")}/{PersonTraitable.__name__}#history')
|
|
732
|
+
assert history_collection.count() == 2
|
|
733
|
+
|
|
734
|
+
# Load a history entry as TraitableHistory instance
|
|
735
|
+
history_entries = PersonTraitable.history(_deserialize=True)
|
|
736
|
+
assert len(history_entries) == 2
|
|
737
|
+
|
|
738
|
+
entry = history_entries[0]
|
|
739
|
+
original_who = entry._who
|
|
740
|
+
|
|
741
|
+
# Attempting to modify and re-save the history entry should fail
|
|
742
|
+
entry._who = 'someone-else'
|
|
743
|
+
rc = entry.save()
|
|
744
|
+
assert not rc
|
|
745
|
+
|
|
746
|
+
# Reload from the store and verify that the stored entry was not changed
|
|
747
|
+
reloaded_docs = list(history_collection.find())
|
|
748
|
+
assert len(reloaded_docs) == 2
|
|
749
|
+
assert any(doc['_who'] == original_who for doc in reloaded_docs)
|
|
750
|
+
assert all(doc['_who'] != 'someone-else' for doc in reloaded_docs)
|
|
751
|
+
|
|
752
|
+
def test_asof_find_returns_one_record_per_id(self, test_store):
|
|
753
|
+
"""Test that the AsOf _find implementation returns at most one record per _id."""
|
|
754
|
+
|
|
755
|
+
# Create two people and multiple history entries for each
|
|
756
|
+
person1 = PersonTraitable()
|
|
757
|
+
person1.name = 'Person One'
|
|
758
|
+
person1.age = 30
|
|
759
|
+
person1.email = 'one@example.com'
|
|
760
|
+
person1.save()
|
|
761
|
+
|
|
762
|
+
person1.age = 31
|
|
763
|
+
person1.save()
|
|
764
|
+
|
|
765
|
+
person2 = PersonTraitable()
|
|
766
|
+
person2.name = 'Person Two'
|
|
767
|
+
person2.age = 25
|
|
768
|
+
person2.email = 'two@example.com'
|
|
769
|
+
person2.save()
|
|
770
|
+
|
|
771
|
+
person2.age = 26
|
|
772
|
+
person2.save()
|
|
773
|
+
|
|
774
|
+
# Sanity check: we indeed have multiple history records per person
|
|
775
|
+
history = PersonTraitable.history()
|
|
776
|
+
assert len(history) >= 4
|
|
777
|
+
|
|
778
|
+
as_of_time = datetime.utcnow()
|
|
779
|
+
|
|
780
|
+
# AsOfContext uses StorableHelperAsOf._find under the hood for load_many
|
|
781
|
+
with AsOfContext(as_of_time, [PersonTraitable]):
|
|
782
|
+
people_as_of = PersonTraitable.load_many()
|
|
783
|
+
|
|
784
|
+
# We should get exactly one record per traitable id
|
|
785
|
+
assert len(people_as_of) == 2
|
|
786
|
+
ids = [p.id().value for p in people_as_of]
|
|
787
|
+
assert len(set(ids)) == len(ids)
|