diff --git a/deckhand/control/base.py b/deckhand/control/base.py index f8fbfe90..f88a8b2b 100644 --- a/deckhand/control/base.py +++ b/deckhand/control/base.py @@ -72,7 +72,7 @@ class BaseResource(object): def return_error(self, resp, status_code, message="", retry=False): resp.body = json.dumps( - {'type': 'error', 'message': message, 'retry': retry}) + {'type': 'error', 'message': str(message), 'retry': retry}) resp.status = status_code diff --git a/deckhand/db/sqlalchemy/api.py b/deckhand/db/sqlalchemy/api.py index 9c8d2260..39704218 100644 --- a/deckhand/db/sqlalchemy/api.py +++ b/deckhand/db/sqlalchemy/api.py @@ -121,16 +121,15 @@ def document_create(values, session=None): values['schema_version'] = values.pop('schemaVersion') session = session or get_session() - filters = copy.copy(models.Document.UNIQUE_CONSTRAINTS) - filters = [f for f in filters if f != 'revision_index'] + filters = models.Document.UNIQUE_CONSTRAINTS existing_document = document_get(**{c: values[c] for c in filters}) + created_document = {} + def _document_changed(): - other_document = copy.deepcopy(existing_document) - other_document = other_document.to_dict() # The document has changed if at least one value in ``values`` differs. for key, val in values.items(): - if val != other_document[key]: + if val != existing_document[key]: return True return False @@ -139,62 +138,87 @@ def document_create(values, session=None): with session.begin(): document.update(values) document.save(session=session) - return document + return document.to_dict() - created_document = {} if existing_document: # Only generate a new revision and entirely new document if anything # was changed. if _document_changed(): - revision_index = revision_update( - revision_index=existing_document['revision_index'])['id'] - values['revision_index'] = revision_index - created_document = _document_create().to_dict() - # TODO: indicate that now document was actually created. + created_document = _document_create() + revision_update(created_document['id'], existing_document['id']) else: - revision_index = revision_create()['id'] - values['revision_index'] = revision_index - created_document = _document_create().to_dict() + created_document = _document_create() + revision_create(created_document['id']) return created_document def document_get(session=None, **filters): session = session or get_session() - - document = session.query(models.Document)\ - .filter_by(**filters)\ - .options(sa_orm.joinedload("revision_index"))\ - .order_by(desc(models.Revision.created_at))\ - .first() - + document = session.query(models.Document).filter_by(**filters).first() return document.to_dict() if document else {} #################### -def revision_create(session=None): +def revision_create(document_id, session=None): session = session or get_session() revision = models.Revision() with session.begin(): + revision.update({'document_id': document_id}) revision.save(session=session) return revision.to_dict() -def revision_update(session=None, revision_index=None): +def revision_get(document_id, session=None): session = session or get_session() - previous_revision = session.query(models.Revision).get(revision_index) + revision = session.query(models.Revision)\ + .filter_by(document_id=document_id).first() + return revision.to_dict() - new_revision = models.Revision() + +def revision_update(document_id, child_document_id, session=None): + """Create a parent revision and update the child revision. + + The ``document_id`` references the newly created document that is a more + up-to-date revision. Create a new (parent) revision that references + ``document_id`` and whose ``child_id`` is ``child_document_id``. + + Set the ``parent_id`` for ``child_revision`` to ``document_id``. + + After this function has executed, the following relationship is true: + + parent_document <-- parent_revision + ^ / + \ (has child) + \ / + \ / + \ / + / \ + / \ + / \ + / (has parent) + v \ + child_document <-- child_revision + + :param document_id: The ID corresponding to the up-to-date document. + :param child_document_id: The ID corresponding tothe out-of-date document. + :param session: The database session. + :returns: The dictionary representation of the newly created revision. + """ + session = session or get_session() + parent_revision = models.Revision() with session.begin(): - # Create the new revision with a reference to the previous revision. - new_revision.update({'previous': revision_index}) - new_revision.save(session=session) + parent_revision.update({'document_id': document_id, + 'child_id': child_document_id}) + parent_revision.save(session=session) - # Update the previous revision with a reference to the new revision. - previous_revision.update({'next': new_revision.id}) - previous_revision.save(session=session) + child_revision = session.query(models.Revision)\ + .filter_by(document_id=child_document_id).first() + with session.begin(): + child_revision.update({'parent_id': document_id}) + child_revision.save(session=session) - return new_revision.to_dict() + return parent_revision.to_dict() diff --git a/deckhand/db/sqlalchemy/models.py b/deckhand/db/sqlalchemy/models.py index 9eba2402..c5245c55 100644 --- a/deckhand/db/sqlalchemy/models.py +++ b/deckhand/db/sqlalchemy/models.py @@ -128,20 +128,20 @@ class Revision(BASE, DeckhandBase): id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) - previous = Column(Integer, ForeignKey('revisions.id'), nullable=True) - next = Column(Integer, ForeignKey('revisions.id'), nullable=True) + document_id = Column(Integer, ForeignKey('documents.id'), nullable=True) + parent_id = Column(Integer, ForeignKey('documents.id'), nullable=True) + child_id = Column(Integer, ForeignKey('documents.id'), nullable=True) + document = relationship("Document", back_populates="revision", + foreign_keys=[document_id]) class Document(BASE, DeckhandBase): - UNIQUE_CONSTRAINTS = ('schema_version', 'kind', 'revision_index') + UNIQUE_CONSTRAINTS = ('schema_version', 'kind') __tablename__ = 'documents' - __table_args__ = (DeckhandBase.gen_unqiue_contraint(*UNIQUE_CONSTRAINTS),) + #__table_args__ = (DeckhandBase.gen_unqiue_contraint(*UNIQUE_CONSTRAINTS),) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) - revision_index = Column(Integer, ForeignKey('revisions.id'), - nullable=False) - revision = relationship(Revision, backref=backref('revisions')) schema_version = Column(String(64), nullable=False) kind = Column(String(64), nullable=False) # NOTE: Do not define a maximum length for these JSON data below. However, @@ -149,6 +149,9 @@ class Document(BASE, DeckhandBase): # "metadata" is reserved, so use "doc_metadata" instead. doc_metadata = Column(JSONEncodedDict(), nullable=False) data = Column(JSONEncodedDict(), nullable=False) + revision = relationship("Revision", uselist=False, + back_populates="document", + foreign_keys="[Revision.document_id]") def register_models(engine): diff --git a/deckhand/tests/unit/db/test_documents.py b/deckhand/tests/unit/db/test_documents.py index 4c72a960..d2256a27 100644 --- a/deckhand/tests/unit/db/test_documents.py +++ b/deckhand/tests/unit/db/test_documents.py @@ -13,8 +13,10 @@ # limitations under the License. import mock +import uuid import testtools +from testtools import matchers from deckhand.db.sqlalchemy import api as db_api from deckhand.tests.unit import base @@ -24,7 +26,7 @@ class DocumentFixture(object): def get_minimal_fixture(self, **kwargs): fixture = {'data': 'fake document data', - 'metadata': 'fake meta', + 'metadata': 'fake metadata', 'kind': 'FakeConfigType', 'schemaVersion': 'deckhand/v1'} fixture.update(kwargs) @@ -43,7 +45,60 @@ class TestDocumentsApi(base.DeckhandWithDBTestCase): self.assertIn(key, actual) self.assertEqual(val, actual[key]) + def _validate_revision(self, revision): + expected_attrs = ('id', 'document_id', 'child_id', 'parent_id') + for attr in expected_attrs: + self.assertIn(attr, revision) + self.assertThat(revision[attr], matchers.MatchesAny( + matchers.Is(None), matchers.IsInstance(unicode))) + def test_create_document(self): fixture = DocumentFixture().get_minimal_fixture() document = db_api.document_create(fixture) self._validate_document(fixture, document) + + revision = db_api.revision_get(document['id']) + self._validate_revision(revision) + self.assertEqual(document['id'], revision['document_id']) + + def test_create_and_update_document(self): + """ + Check that the following relationship is true: + + parent_document <-- parent_revision + ^ / + \ (has child) + \ / + \ / + \ / + / \ + / \ + / \ + / (has parent) + v \ + child_document <-- child_revision + """ + fixture = DocumentFixture().get_minimal_fixture() + child_document = db_api.document_create(fixture) + + fixture['metadata'] = 'modified fake metadata' + parent_document = db_api.document_create(fixture) + self._validate_document(fixture, parent_document) + + # Validate that the new document was created. + self.assertEqual('modified fake metadata', + parent_document['doc_metadata']) + self.assertNotEqual(child_document['id'], parent_document['id']) + + # Validate that the parent document has a different revision and + # that the revisions and document links are correct. + child_revision = db_api.revision_get(child_document['id']) + parent_revision = db_api.revision_get(parent_document['id']) + for revision in (child_revision, parent_revision): + self._validate_revision(revision) + + self.assertNotEqual(child_revision['id'], parent_revision['id']) + self.assertEqual(parent_document['id'], + parent_revision['document_id']) + self.assertEqual(child_document['id'], parent_revision['child_id']) + self.assertEqual(parent_document['id'], child_revision['parent_id'])