Browse Source

Merge "Validate bucket diffing works with revision rollback"

changes/22/610722/3
Zuul 8 months ago
parent
commit
464d2c0ea5

+ 5
- 6
deckhand/engine/revision_diff.py View File

@@ -102,8 +102,8 @@ def revision_diff(revision_id, comparison_revision_id, deepdiff=False):
102 102
         bucket_a: created
103 103
     """
104 104
     if deepdiff:
105
-        docs = (_rendered_doc(revision_id) if revision_id != 0 else [])
106
-        comparison_docs = (_rendered_doc(comparison_revision_id)
105
+        docs = (_render_documents(revision_id) if revision_id != 0 else [])
106
+        comparison_docs = (_render_documents(comparison_revision_id)
107 107
                            if comparison_revision_id != 0 else [])
108 108
     else:
109 109
         # Retrieve document history for each revision. Since `revision_id` of 0
@@ -143,7 +143,7 @@ def revision_diff(revision_id, comparison_revision_id, deepdiff=False):
143 143
     shared_buckets = set(buckets.keys()).intersection(
144 144
         comparison_buckets.keys())
145 145
     # `unshared_buckets` references buckets not shared by both `revision_id`
146
-    # and `comparison_revision_id` -- i.e. their non-intersection.
146
+    # and `comparison_revision_id` -- i.e. their union.
147 147
     unshared_buckets = set(buckets.keys()).union(
148 148
         comparison_buckets.keys()) - shared_buckets
149 149
 
@@ -163,9 +163,8 @@ def revision_diff(revision_id, comparison_revision_id, deepdiff=False):
163 163
             result[bucket_name] = 'unmodified'
164 164
         else:
165 165
             result[bucket_name] = 'modified'
166
-            # If deepdiff enabled
166
+            # If deepdiff is enabled, find out diff between buckets
167 167
             if deepdiff:
168
-                # find out diff between buckets
169 168
                 bucket_diff = _diff_buckets(buckets[bucket_name],
170 169
                                             comparison_buckets[bucket_name])
171 170
                 result[bucket_name + ' diff'] = bucket_diff
@@ -289,7 +288,7 @@ def _format_diff_result(dr):
289 288
     return dr
290 289
 
291 290
 
292
-def _rendered_doc(revision_id):
291
+def _render_documents(revision_id):
293 292
     """Provides rendered document by given revision id."""
294 293
     filters = {'deleted': False}
295 294
     rendered_documents, _ = common.get_rendered_docs(revision_id, **filters)

+ 116
- 0
deckhand/tests/unit/base.py View File

@@ -25,12 +25,19 @@ import testtools
25 25
 from deckhand.conf import config  # noqa: Calls register_opts(CONF)
26 26
 from deckhand.db.sqlalchemy import api as db_api
27 27
 from deckhand.engine import cache
28
+from deckhand.tests import test_utils
28 29
 from deckhand.tests.unit import fixtures as dh_fixtures
29 30
 
30 31
 CONF = cfg.CONF
31 32
 logging.register_options(CONF)
32 33
 logging.setup(CONF, 'deckhand')
33 34
 
35
+BASE_EXPECTED_FIELDS = ("created_at", "updated_at", "deleted_at", "deleted")
36
+DOCUMENT_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + (
37
+    "id", "schema", "name", "layer", "metadata", "data", "data_hash",
38
+    "metadata_hash", "revision_id", "bucket_id")
39
+REVISION_EXPECTED_FIELDS = ("id", "documents", "tags")
40
+
34 41
 
35 42
 class DeckhandTestCase(testtools.TestCase):
36 43
 
@@ -122,3 +129,112 @@ class DeckhandWithDBTestCase(DeckhandTestCase):
122 129
             group='database')
123 130
         db_api.setup_db(CONF.database.connection, create_tables=True)
124 131
         self.addCleanup(db_api.drop_db)
132
+
133
+    def create_documents(self, bucket_name, documents,
134
+                         validation_policies=None):
135
+        if not validation_policies:
136
+            validation_policies = []
137
+
138
+        if not isinstance(documents, list):
139
+            documents = [documents]
140
+        if not isinstance(validation_policies, list):
141
+            validation_policies = [validation_policies]
142
+
143
+        docs = db_api.documents_create(
144
+            bucket_name, documents, validation_policies)
145
+
146
+        return docs
147
+
148
+    def show_document(self, **fields):
149
+        doc = db_api.document_get(**fields)
150
+
151
+        self.validate_document(actual=doc)
152
+
153
+        return doc
154
+
155
+    def create_revision(self):
156
+        # Implicitly creates a revision and returns it.
157
+        documents = [DocumentFixture.get_minimal_fixture()]
158
+        bucket_name = test_utils.rand_name('bucket')
159
+        revision_id = self.create_documents(bucket_name, documents)[0][
160
+            'revision_id']
161
+        return revision_id
162
+
163
+    def show_revision(self, revision_id):
164
+        revision = db_api.revision_get(revision_id)
165
+        self.validate_revision(revision)
166
+        return revision
167
+
168
+    def delete_revisions(self):
169
+        return db_api.revision_delete_all()
170
+
171
+    def list_revision_documents(self, revision_id, **filters):
172
+        documents = db_api.revision_documents_get(revision_id, **filters)
173
+        for document in documents:
174
+            self.validate_document(document)
175
+        return documents
176
+
177
+    def list_revisions(self):
178
+        return db_api.revision_get_all()
179
+
180
+    def rollback_revision(self, revision_id):
181
+        latest_revision = db_api.revision_get_latest()
182
+        return db_api.revision_rollback(revision_id, latest_revision)
183
+
184
+    def create_validation(self, revision_id, val_name, val_data):
185
+        return db_api.validation_create(revision_id, val_name, val_data)
186
+
187
+    def _validate_object(self, obj):
188
+        for attr in BASE_EXPECTED_FIELDS:
189
+            if attr.endswith('_at'):
190
+                self.assertThat(obj[attr], testtools.matchers.MatchesAny(
191
+                    testtools.matchers.Is(None),
192
+                    testtools.matchers.IsInstance(str)))
193
+            else:
194
+                self.assertIsInstance(obj[attr], bool)
195
+
196
+    def validate_document(self, actual, expected=None, is_deleted=False):
197
+        self._validate_object(actual)
198
+
199
+        # Validate that the document has all expected fields and is a dict.
200
+        expected_fields = list(DOCUMENT_EXPECTED_FIELDS)
201
+        if not is_deleted:
202
+            expected_fields.remove('deleted_at')
203
+
204
+        self.assertIsInstance(actual, dict)
205
+        for field in expected_fields:
206
+            self.assertIn(field, actual)
207
+
208
+    def validate_revision(self, revision):
209
+        self._validate_object(revision)
210
+
211
+        for attr in REVISION_EXPECTED_FIELDS:
212
+            self.assertIn(attr, revision)
213
+
214
+
215
+# TODO(felipemonteiro): Move this into a separate module called `fixtures`.
216
+class DocumentFixture(object):
217
+
218
+    @staticmethod
219
+    def get_minimal_fixture(**kwargs):
220
+        fixture = {
221
+            'data': {
222
+                test_utils.rand_name('key'): test_utils.rand_name('value')
223
+            },
224
+            'metadata': {
225
+                'name': test_utils.rand_name('metadata_data'),
226
+                'label': test_utils.rand_name('metadata_label'),
227
+                'layeringDefinition': {
228
+                    'abstract': test_utils.rand_bool(),
229
+                    'layer': test_utils.rand_name('layer')
230
+                },
231
+                'storagePolicy': test_utils.rand_name('storage_policy')
232
+            },
233
+            'schema': test_utils.rand_name('schema')}
234
+        fixture.update(kwargs)
235
+        return fixture
236
+
237
+    @staticmethod
238
+    def get_minimal_multi_fixture(count=2, **kwargs):
239
+        return [DocumentFixture.get_minimal_fixture(**kwargs)
240
+                for _ in range(count)]

+ 0
- 136
deckhand/tests/unit/db/base.py View File

@@ -1,136 +0,0 @@
1
-# Copyright 2017 AT&T Intellectual Property.  All other rights reserved.
2
-#
3
-# Licensed under the Apache License, Version 2.0 (the "License");
4
-# you may not use this file except in compliance with the License.
5
-# You may obtain a copy of the License at
6
-#
7
-#     http://www.apache.org/licenses/LICENSE-2.0
8
-#
9
-# Unless required by applicable law or agreed to in writing, software
10
-# distributed under the License is distributed on an "AS IS" BASIS,
11
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
-# See the License for the specific language governing permissions and
13
-# limitations under the License.
14
-
15
-from testtools import matchers
16
-
17
-from deckhand.db.sqlalchemy import api as db_api
18
-from deckhand.tests import test_utils
19
-from deckhand.tests.unit import base
20
-
21
-BASE_EXPECTED_FIELDS = ("created_at", "updated_at", "deleted_at", "deleted")
22
-DOCUMENT_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + (
23
-    "id", "schema", "name", "layer", "metadata", "data", "data_hash",
24
-    "metadata_hash", "revision_id", "bucket_id")
25
-REVISION_EXPECTED_FIELDS = ("id", "documents", "tags")
26
-
27
-
28
-# TODO(felipemonteiro): Move this into a separate module called `fixtures`.
29
-class DocumentFixture(object):
30
-
31
-    @staticmethod
32
-    def get_minimal_fixture(**kwargs):
33
-        fixture = {
34
-            'data': {
35
-                test_utils.rand_name('key'): test_utils.rand_name('value')
36
-            },
37
-            'metadata': {
38
-                'name': test_utils.rand_name('metadata_data'),
39
-                'label': test_utils.rand_name('metadata_label'),
40
-                'layeringDefinition': {
41
-                    'abstract': test_utils.rand_bool(),
42
-                    'layer': test_utils.rand_name('layer')
43
-                },
44
-                'storagePolicy': test_utils.rand_name('storage_policy')
45
-            },
46
-            'schema': test_utils.rand_name('schema')}
47
-        fixture.update(kwargs)
48
-        return fixture
49
-
50
-    @staticmethod
51
-    def get_minimal_multi_fixture(count=2, **kwargs):
52
-        return [DocumentFixture.get_minimal_fixture(**kwargs)
53
-                for _ in range(count)]
54
-
55
-
56
-class TestDbBase(base.DeckhandWithDBTestCase):
57
-
58
-    def create_documents(self, bucket_name, documents,
59
-                         validation_policies=None):
60
-        if not validation_policies:
61
-            validation_policies = []
62
-
63
-        if not isinstance(documents, list):
64
-            documents = [documents]
65
-        if not isinstance(validation_policies, list):
66
-            validation_policies = [validation_policies]
67
-
68
-        docs = db_api.documents_create(
69
-            bucket_name, documents, validation_policies)
70
-
71
-        return docs
72
-
73
-    def show_document(self, **fields):
74
-        doc = db_api.document_get(**fields)
75
-
76
-        self.validate_document(actual=doc)
77
-
78
-        return doc
79
-
80
-    def create_revision(self):
81
-        # Implicitly creates a revision and returns it.
82
-        documents = [DocumentFixture.get_minimal_fixture()]
83
-        bucket_name = test_utils.rand_name('bucket')
84
-        revision_id = self.create_documents(bucket_name, documents)[0][
85
-            'revision_id']
86
-        return revision_id
87
-
88
-    def show_revision(self, revision_id):
89
-        revision = db_api.revision_get(revision_id)
90
-        self.validate_revision(revision)
91
-        return revision
92
-
93
-    def delete_revisions(self):
94
-        return db_api.revision_delete_all()
95
-
96
-    def list_revision_documents(self, revision_id, **filters):
97
-        documents = db_api.revision_documents_get(revision_id, **filters)
98
-        for document in documents:
99
-            self.validate_document(document)
100
-        return documents
101
-
102
-    def list_revisions(self):
103
-        return db_api.revision_get_all()
104
-
105
-    def rollback_revision(self, revision_id):
106
-        latest_revision = db_api.revision_get_latest()
107
-        return db_api.revision_rollback(revision_id, latest_revision)
108
-
109
-    def create_validation(self, revision_id, val_name, val_data):
110
-        return db_api.validation_create(revision_id, val_name, val_data)
111
-
112
-    def _validate_object(self, obj):
113
-        for attr in BASE_EXPECTED_FIELDS:
114
-            if attr.endswith('_at'):
115
-                self.assertThat(obj[attr], matchers.MatchesAny(
116
-                    matchers.Is(None), matchers.IsInstance(str)))
117
-            else:
118
-                self.assertIsInstance(obj[attr], bool)
119
-
120
-    def validate_document(self, actual, expected=None, is_deleted=False):
121
-        self._validate_object(actual)
122
-
123
-        # Validate that the document has all expected fields and is a dict.
124
-        expected_fields = list(DOCUMENT_EXPECTED_FIELDS)
125
-        if not is_deleted:
126
-            expected_fields.remove('deleted_at')
127
-
128
-        self.assertIsInstance(actual, dict)
129
-        for field in expected_fields:
130
-            self.assertIn(field, actual)
131
-
132
-    def validate_revision(self, revision):
133
-        self._validate_object(revision)
134
-
135
-        for attr in REVISION_EXPECTED_FIELDS:
136
-            self.assertIn(attr, revision)

+ 2
- 2
deckhand/tests/unit/db/test_documents.py View File

@@ -19,10 +19,10 @@ from deckhand.db.sqlalchemy import api as db_api
19 19
 from deckhand import errors
20 20
 from deckhand import factories
21 21
 from deckhand.tests import test_utils
22
-from deckhand.tests.unit.db import base
22
+from deckhand.tests.unit import base
23 23
 
24 24
 
25
-class TestDocuments(base.TestDbBase):
25
+class TestDocuments(base.DeckhandWithDBTestCase):
26 26
 
27 27
     def setUp(self):
28 28
         super(TestDocuments, self).setUp()

+ 2
- 2
deckhand/tests/unit/db/test_documents_negative.py View File

@@ -14,10 +14,10 @@
14 14
 
15 15
 from deckhand import errors
16 16
 from deckhand.tests import test_utils
17
-from deckhand.tests.unit.db import base
17
+from deckhand.tests.unit import base
18 18
 
19 19
 
20
-class TestDocumentsNegative(base.TestDbBase):
20
+class TestDocumentsNegative(base.DeckhandWithDBTestCase):
21 21
 
22 22
     def test_get_documents_by_revision_id_and_wrong_filters(self):
23 23
         payload = base.DocumentFixture.get_minimal_fixture()

+ 2
- 2
deckhand/tests/unit/db/test_layering_policies.py View File

@@ -15,10 +15,10 @@
15 15
 from deckhand import errors
16 16
 from deckhand import factories
17 17
 from deckhand.tests import test_utils
18
-from deckhand.tests.unit.db import base
18
+from deckhand.tests.unit import base
19 19
 
20 20
 
21
-class LayeringPoliciesBaseTest(base.TestDbBase):
21
+class LayeringPoliciesBaseTest(base.DeckhandWithDBTestCase):
22 22
 
23 23
     def setUp(self):
24 24
         super(LayeringPoliciesBaseTest, self).setUp()

+ 2
- 2
deckhand/tests/unit/db/test_revision_documents.py View File

@@ -13,10 +13,10 @@
13 13
 # limitations under the License.
14 14
 
15 15
 from deckhand.tests import test_utils
16
-from deckhand.tests.unit.db import base
16
+from deckhand.tests.unit import base
17 17
 
18 18
 
19
-class TestRevisionDocumentsFiltering(base.TestDbBase):
19
+class TestRevisionDocumentsFiltering(base.DeckhandWithDBTestCase):
20 20
 
21 21
     def test_document_filtering_by_bucket_name(self):
22 22
         document = base.DocumentFixture.get_minimal_fixture()

+ 3
- 3
deckhand/tests/unit/db/test_revision_rollback.py View File

@@ -14,10 +14,10 @@
14 14
 
15 15
 from deckhand import errors
16 16
 from deckhand.tests import test_utils
17
-from deckhand.tests.unit.db import base
17
+from deckhand.tests.unit import base
18 18
 
19 19
 
20
-class TestRevisionRollback(base.TestDbBase):
20
+class TestRevisionRollback(base.DeckhandWithDBTestCase):
21 21
 
22 22
     def test_create_update_rollback(self):
23 23
         # Revision 1: Create 4 documents.
@@ -124,7 +124,7 @@ class TestRevisionRollback(base.TestDbBase):
124 124
         self.assertEmpty(rollback_documents)
125 125
 
126 126
 
127
-class TestRevisionRollbackNegative(base.TestDbBase):
127
+class TestRevisionRollbackNegative(base.DeckhandWithDBTestCase):
128 128
 
129 129
     def test_rollback_to_missing_revision_raises_exc(self):
130 130
         # revision_id=1 doesn't exist yet since we start from an empty DB.

+ 2
- 2
deckhand/tests/unit/db/test_revision_tags.py View File

@@ -15,10 +15,10 @@
15 15
 from deckhand.db.sqlalchemy import api as db_api
16 16
 from deckhand import errors
17 17
 from deckhand.tests import test_utils
18
-from deckhand.tests.unit.db import base
18
+from deckhand.tests.unit import base
19 19
 
20 20
 
21
-class TestRevisionTags(base.TestDbBase):
21
+class TestRevisionTags(base.DeckhandWithDBTestCase):
22 22
 
23 23
     def setUp(self):
24 24
         super(TestRevisionTags, self).setUp()

+ 2
- 2
deckhand/tests/unit/db/test_revision_tags_negative.py View File

@@ -14,10 +14,10 @@
14 14
 
15 15
 from deckhand.db.sqlalchemy import api as db_api
16 16
 from deckhand import errors
17
-from deckhand.tests.unit.db import base
17
+from deckhand.tests.unit import base
18 18
 
19 19
 
20
-class TestRevisionTagsNegative(base.TestDbBase):
20
+class TestRevisionTagsNegative(base.DeckhandWithDBTestCase):
21 21
 
22 22
     def test_create_tag_revision_not_found(self):
23 23
         self.assertRaises(

+ 2
- 2
deckhand/tests/unit/db/test_revisions.py View File

@@ -14,10 +14,10 @@
14 14
 
15 15
 from deckhand import errors
16 16
 from deckhand.tests import test_utils
17
-from deckhand.tests.unit.db import base
17
+from deckhand.tests.unit import base
18 18
 
19 19
 
20
-class TestRevisions(base.TestDbBase):
20
+class TestRevisions(base.DeckhandWithDBTestCase):
21 21
 
22 22
     def test_list(self):
23 23
         documents = [base.DocumentFixture.get_minimal_fixture()

+ 2
- 2
deckhand/tests/unit/engine/test_revision_deepdiffing.py View File

@@ -16,10 +16,10 @@ import copy
16 16
 
17 17
 from deckhand.engine import revision_diff
18 18
 from deckhand import factories
19
-from deckhand.tests.unit.db import base
19
+from deckhand.tests.unit import base
20 20
 
21 21
 
22
-class TestRevisionDeepDiffing(base.TestDbBase):
22
+class TestRevisionDeepDiffing(base.DeckhandWithDBTestCase):
23 23
 
24 24
     def _test_data(self):
25 25
         return {

+ 29
- 2
deckhand/tests/unit/engine/test_revision_diffing.py View File

@@ -16,10 +16,10 @@ import copy
16 16
 
17 17
 from deckhand.engine.revision_diff import revision_diff
18 18
 from deckhand.tests import test_utils
19
-from deckhand.tests.unit.db import base
19
+from deckhand.tests.unit import base
20 20
 
21 21
 
22
-class TestRevisionDiffing(base.TestDbBase):
22
+class TestRevisionDiffing(base.DeckhandWithDBTestCase):
23 23
 
24 24
     def _verify_buckets_status(self, revision_id, comparison_revision_id,
25 25
                                expected):
@@ -307,3 +307,30 @@ class TestRevisionDiffing(base.TestDbBase):
307 307
         self._verify_buckets_status(
308 308
             revision_id_1, revision_id_4,
309 309
             {bucket_name: 'unmodified', alt_bucket_name_2: 'created'})
310
+
311
+    def test_revision_diff_delete_then_rollback(self):
312
+        """Validate that rolling back a revision works with bucket diff."""
313
+        payload = base.DocumentFixture.get_minimal_fixture()
314
+        bucket_name = test_utils.rand_name('bucket')
315
+        created_documents = self.create_documents(bucket_name, payload)
316
+        revision_id = created_documents[0]['revision_id']
317
+
318
+        # Delete all previously created documents.
319
+        deleted_documents = self.create_documents(bucket_name, [])
320
+        comparison_revision_id = deleted_documents[0]['revision_id']
321
+
322
+        # Validate that the empty bucket is deleted.
323
+        self._verify_buckets_status(
324
+            revision_id, comparison_revision_id, {bucket_name: 'deleted'})
325
+
326
+        # Rollback to first non-empty revision.
327
+        rollback_revision_id = self.rollback_revision(revision_id)['id']
328
+        # Validate that diffing rolled-back revision against 1 is unmodified.
329
+        self._verify_buckets_status(
330
+            revision_id, rollback_revision_id, {bucket_name: 'unmodified'})
331
+
332
+        # Validate that diffing rolled-back revision against 2 is created
333
+        # (because the rolled-back revision is newer than revision 2).
334
+        self._verify_buckets_status(
335
+            comparison_revision_id, rollback_revision_id,
336
+            {bucket_name: 'created'})

+ 4
- 4
deckhand/tests/unit/engine/test_secrets_manager.py View File

@@ -27,10 +27,10 @@ from deckhand.engine import secrets_manager
27 27
 from deckhand import errors
28 28
 from deckhand import factories
29 29
 from deckhand.tests import test_utils
30
-from deckhand.tests.unit.db import base as test_base
30
+from deckhand.tests.unit import base as test_base
31 31
 
32 32
 
33
-class TestSecretsManager(test_base.TestDbBase):
33
+class TestSecretsManager(test_base.DeckhandWithDBTestCase):
34 34
 
35 35
     def setUp(self):
36 36
         super(TestSecretsManager, self).setUp()
@@ -168,7 +168,7 @@ class TestSecretsManager(test_base.TestDbBase):
168 168
         self.assertEqual(payload, retrieved_payload)
169 169
 
170 170
 
171
-class TestSecretsSubstitution(test_base.TestDbBase):
171
+class TestSecretsSubstitution(test_base.DeckhandWithDBTestCase):
172 172
 
173 173
     def setUp(self):
174 174
         super(TestSecretsSubstitution, self).setUp()
@@ -876,7 +876,7 @@ data:
876 876
         self.assertEqual(expected, substituted_docs[0])
877 877
 
878 878
 
879
-class TestSecretsSubstitutionNegative(test_base.TestDbBase):
879
+class TestSecretsSubstitutionNegative(test_base.DeckhandWithDBTestCase):
880 880
 
881 881
     def setUp(self):
882 882
         super(TestSecretsSubstitutionNegative, self).setUp()

+ 2
- 2
deckhand/tests/unit/views/test_document_views.py View File

@@ -14,10 +14,10 @@
14 14
 
15 15
 from deckhand.control.views import document
16 16
 from deckhand.tests import test_utils
17
-from deckhand.tests.unit.db import base
17
+from deckhand.tests.unit import base
18 18
 
19 19
 
20
-class TestDocumentViews(base.TestDbBase):
20
+class TestDocumentViews(base.DeckhandWithDBTestCase):
21 21
 
22 22
     def setUp(self):
23 23
         super(TestDocumentViews, self).setUp()

+ 2
- 2
deckhand/tests/unit/views/test_revision_tag_views.py View File

@@ -15,10 +15,10 @@
15 15
 from deckhand.control.views import revision_tag
16 16
 from deckhand.db.sqlalchemy import api as db_api
17 17
 from deckhand.tests import test_utils
18
-from deckhand.tests.unit.db import base
18
+from deckhand.tests.unit import base
19 19
 
20 20
 
21
-class TestRevisionViews(base.TestDbBase):
21
+class TestRevisionViews(base.DeckhandWithDBTestCase):
22 22
 
23 23
     def setUp(self):
24 24
         super(TestRevisionViews, self).setUp()

+ 2
- 2
deckhand/tests/unit/views/test_revision_views.py View File

@@ -14,10 +14,10 @@
14 14
 
15 15
 from deckhand.control.views import revision
16 16
 from deckhand.tests import test_utils
17
-from deckhand.tests.unit.db import base
17
+from deckhand.tests.unit import base
18 18
 
19 19
 
20
-class TestRevisionViews(base.TestDbBase):
20
+class TestRevisionViews(base.DeckhandWithDBTestCase):
21 21
 
22 22
     def setUp(self):
23 23
         super(TestRevisionViews, self).setUp()

Loading…
Cancel
Save