diff --git a/rattail/core.py b/rattail/core.py
new file mode 100644
index 0000000000000000000000000000000000000000..590e37d6b0fa93596f0cbe5f9a2a18d65de24938
--- /dev/null
+++ b/rattail/core.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2012 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
+# more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Rattail. If not, see .
+#
+################################################################################
+
+"""
+Core Stuff
+"""
+
+from uuid import uuid1
+
+
+__all__ = ['get_uuid']
+
+
+def get_uuid():
+ """
+ Generate a universally-unique identifier.
+
+ :returns: A 32-character hex string.
+ """
+
+ return uuid1().hex
diff --git a/rattail/db/__init__.py b/rattail/db/__init__.py
index aa42bcdf1ecba1293d7ceecf886df928846741c3..1360d5a9bd314fc11245d942aeae4e79dc244c13 100644
--- a/rattail/db/__init__.py
+++ b/rattail/db/__init__.py
@@ -23,112 +23,13 @@
################################################################################
"""
-``rattail.db`` -- Database Stuff
+Database Stuff
"""
-import logging
-import warnings
-
-from sqlalchemy.event import listen
-from sqlalchemy.orm import object_mapper, RelationshipProperty
-
-import edbob
from edbob.db import Session
-import rattail
-
-
-ignore_role_changes = None
-
-log = logging.getLogger(__name__)
-
-
-def before_flush(session, flush_context, instances):
- """
- Listens for session flush events. This function is responsible for adding
- stub records to the 'changes' table, which will in turn be used by the
- database synchronizer.
- """
-
- def ensure_uuid(instance):
- if instance.uuid:
- return
-
- # If the 'uuid' column is actually a foreign key to another
- # table...well, then we can't just generate a new value for it.
- # Instead we must traverse the relationship and fetch the existing
- # foreign key value...
-
- mapper = object_mapper(instance)
- uuid = mapper.columns['uuid']
- if uuid.foreign_keys:
-
- for prop in mapper.iterate_properties:
- if (isinstance(prop, RelationshipProperty)
- and len(prop.remote_side) == 1
- and list(prop.remote_side)[0].key == 'uuid'):
-
- foreign_instance = getattr(instance, prop.key)
- ensure_uuid(foreign_instance)
- instance.uuid = foreign_instance.uuid
- break
- assert instance.uuid
-
- # ...but if there is no foreign key, just generate a new UUID.
- else:
- instance.uuid = edbob.get_uuid()
-
- def record_change(instance, deleted=False):
-
- # No need to record changes for Change. :)
- if isinstance(instance, rattail.Change):
- return
-
- # No need to record changes for batch data.
- if isinstance(instance, (rattail.Batch,
- rattail.BatchColumn,
- rattail.BatchRow)):
- return
-
- # Ignore instances which don't use UUID.
- if not hasattr(instance, 'uuid'):
- return
-
- # Ignore Role instances, if so configured.
- if ignore_role_changes:
- if isinstance(instance, (edbob.Role, edbob.UserRole)):
- return
-
- # Provide an UUID value, if necessary.
- ensure_uuid(instance)
-
- # Record the change.
- change = session.query(rattail.Change).get(
- (instance.__class__.__name__, instance.uuid))
- if not change:
- change = rattail.Change(
- class_name=instance.__class__.__name__,
- uuid=instance.uuid)
- session.add(change)
- change.deleted = deleted
- log.debug("before_flush: recorded change: %s" % repr(change))
-
- for instance in session.deleted:
- log.debug("before_flush: deleted instance: %s" % repr(instance))
- record_change(instance, deleted=True)
-
- for instance in session.new:
- log.debug("before_flush: new instance: %s" % repr(instance))
- record_change(instance)
-
- for instance in session.dirty:
- if session.is_modified(instance, passive=True):
- log.debug("before_flush: dirty instance: %s" % repr(instance))
- record_change(instance)
-
-
-def record_changes(session):
- listen(session, 'before_flush', before_flush)
+from .core import *
+from .changes import *
def init(config):
@@ -136,6 +37,9 @@ def init(config):
Initialize the Rattail database framework.
"""
+ import edbob
+ import rattail
+
# Pretend all ``edbob`` models come from Rattail, until that is true.
from edbob.db import Base
names = []
@@ -152,15 +56,15 @@ def init(config):
from rattail.db.extension import model
edbob.graft(rattail, model)
- global ignore_role_changes
ignore_role_changes = config.getboolean(
'rattail.db', 'changes.ignore_roles', default=True)
if config.getboolean('rattail.db', 'changes.record'):
- record_changes(Session)
+ record_changes(Session, ignore_role_changes)
elif config.getboolean('rattail.db', 'record_changes'):
+ import warnings
warnings.warn("Config setting 'record_changes' in section [rattail.db] "
"is deprecated; please use 'changes.record' instead.",
DeprecationWarning)
- record_changes(Session)
+ record_changes(Session, ignore_role_changes)
diff --git a/rattail/db/changes.py b/rattail/db/changes.py
new file mode 100644
index 0000000000000000000000000000000000000000..576f3136dcdd42b56887d47a5261605f801d5447
--- /dev/null
+++ b/rattail/db/changes.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2012 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
+# more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Rattail. If not, see .
+#
+################################################################################
+
+"""
+Data Changes
+"""
+
+from sqlalchemy.event import listen
+from sqlalchemy.orm import object_mapper, RelationshipProperty
+
+from .model import Change, Batch, BatchColumn, BatchRow, Role, UserRole
+from ..core import get_uuid
+
+
+__all__ = ['record_changes']
+
+
+import logging
+log = logging.getLogger(__name__)
+
+
+def record_changes(session, ignore_role_changes=True):
+ """
+ Record all changes which occur within a session.
+
+ :param session: A ``sqlalchemy.orm.sessionmaker`` class, or an instance
+ thereof.
+
+ :param ignore_role_changes: Whether changes involving roles and role
+ membership should be ignored. This defaults to ``True``, which means
+ each database will be responsible for maintaining its own role (and by
+ extension, permissions) data.
+ """
+
+ listen(session, 'before_flush', ChangeRecorder(ignore_role_changes))
+
+
+class ChangeRecorder(object):
+ """
+ Listener for session ``before_flush`` events.
+
+ This class is responsible for adding stub records to the ``changes`` table,
+ which will in turn be used by the database synchronizer to manage change
+ data propagation.
+ """
+
+ def __init__(self, ignore_role_changes=True):
+ self.ignore_role_changes = ignore_role_changes
+
+ def __call__(self, session, flush_context, instances):
+ """
+ Method invoked when session ``before_flush`` event occurs.
+ """
+
+ for instance in session.deleted:
+ log.debug("ChangeRecorder: found deleted instance: {0}".format(repr(instance)))
+ self.record_change(session, instance, deleted=True)
+ for instance in session.new:
+ log.debug("ChangeRecorder: found new instance: {0}".format(repr(instance)))
+ self.record_change(session, instance)
+ for instance in session.dirty:
+ if session.is_modified(instance, passive=True):
+ log.debug("ChangeRecorder: found dirty instance: {0}".format(repr(instance)))
+ self.record_change(session, instance)
+
+ def record_change(self, session, instance, deleted=False):
+ """
+ Record a change record in the database.
+
+ If ``instance`` represents a change in which we are interested, then
+ this method will create (or update) a :class:`rattail.db.model.Change`
+ record.
+
+ :returns: ``True`` if a change was recorded, or ``False`` if it was
+ ignored.
+ """
+
+ # No need to record changes for changes. Must not use `isinstance()`
+ # here due to mocking in tests.
+ if (hasattr(instance.__class__, '__tablename__')
+ and instance.__class__.__tablename__ == 'changes'):
+ return False
+
+ # No need to record changes for batch data.
+ if isinstance(instance, (Batch, BatchColumn, BatchRow)):
+ return False
+
+ # Ignore instances which don't use UUID.
+ if not hasattr(instance, 'uuid'):
+ return False
+
+ # Ignore Role instances, if so configured.
+ if self.ignore_role_changes and isinstance(instance, (Role, UserRole)):
+ return False
+
+ # Provide an UUID value, if necessary.
+ self.ensure_uuid(instance)
+
+ # Record the change.
+ change = session.query(Change).get(
+ (instance.__class__.__name__, instance.uuid))
+ if not change:
+ change = Change(
+ class_name=instance.__class__.__name__,
+ uuid=instance.uuid)
+ session.add(change)
+ change.deleted = deleted
+ log.debug("ChangeRecorder.record_change: recorded change: %s" % repr(change))
+ return True
+
+ def ensure_uuid(self, instance):
+ """
+ Ensure the given instance has a UUID value.
+
+ This uses the following logic:
+
+ * If the instance already has a UUID, nothing will be done.
+
+ * If the instance contains a foreign key to another table, then that
+ relationship will be traversed and the foreign object's UUID will be used
+ to populate that of the instance.
+
+ * Otherwise, a new UUID will be generated for the instance.
+ """
+
+ if instance.uuid:
+ return
+
+ mapper = object_mapper(instance)
+ if not mapper.columns['uuid'].foreign_keys:
+ instance.uuid = get_uuid()
+ return
+
+ for prop in mapper.iterate_properties:
+ if (isinstance(prop, RelationshipProperty)
+ and len(prop.remote_side) == 1
+ and list(prop.remote_side)[0].key == 'uuid'):
+
+ foreign_instance = getattr(instance, prop.key)
+ if foreign_instance:
+ self.ensure_uuid(foreign_instance)
+ instance.uuid = foreign_instance.uuid
+ return
+
+ instance.uuid = get_uuid()
+ log.error("ChangeRecorder.ensure_uuid: unexpected scenario; generated new UUID for instance: {0}".format(repr(instance)))
diff --git a/rattail/db/core.py b/rattail/db/core.py
new file mode 100644
index 0000000000000000000000000000000000000000..22e1487c435da7deb7cf09bf6fb7755728e5a086
--- /dev/null
+++ b/rattail/db/core.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2012 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail is free software: you can redistribute it and/or modify it under the
+# terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
+# more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with Rattail. If not, see .
+#
+################################################################################
+
+"""
+Core Data Stuff
+"""
+
+from sqlalchemy import Column, String
+from ..core import get_uuid
+
+
+__all__ = ['uuid_column']
+
+
+def uuid_column(*args, **kwargs):
+ """
+ Returns a UUID column for use as a table's primary key.
+ """
+
+ kwargs.setdefault('primary_key', True)
+ kwargs.setdefault('nullable', False)
+ kwargs.setdefault('default', get_uuid)
+ return Column(String(length=32), *args, **kwargs)
diff --git a/rattail/db/extension/model.py b/rattail/db/extension/model.py
index a0a1dd3b929fe248b9ee1baafee51ae41d7a6f4b..40fbe4d4836b55e99399dd72055aa5b04f75f2d6 100644
--- a/rattail/db/extension/model.py
+++ b/rattail/db/extension/model.py
@@ -26,12 +26,12 @@
``rattail.db.extension.model`` -- Schema Definition
"""
+import re
import warnings
import logging
from sqlalchemy import Column, ForeignKey
from sqlalchemy import String, Integer, DateTime, Date, Boolean, Numeric, Text
-from sqlalchemy import types
from sqlalchemy import and_
from sqlalchemy.orm import relationship, object_session
from sqlalchemy.ext.associationproxy import association_proxy
@@ -146,6 +146,8 @@ class Batch(Base):
_rowclasses = {}
+ sil_type_pattern = re.compile(r'^(CHAR|NUMBER)\((\d+(?:\,\d+)?)\)$')
+
def __repr__(self):
return "Batch(uuid={0})".format(repr(self.uuid))
@@ -171,7 +173,7 @@ class Batch(Base):
}
for column in self.columns:
- data_type = sil.get_sqlalchemy_type(column.data_type)
+ data_type = self.get_sqlalchemy_type(column.data_type)
kwargs[column.name] = Column(data_type)
rowclass = type('BatchRow_%s' % str(self.uuid), (Base, BatchRow), kwargs)
@@ -184,6 +186,36 @@ class Batch(Base):
return self._rowclasses[self.uuid]
+ @classmethod
+ def get_sqlalchemy_type(cls, sil_type):
+ """
+ Returns a SQLAlchemy data type according to a SIL data type.
+ """
+
+ if sil_type == 'GPC(14)':
+ return GPCType()
+
+ if sil_type == 'FLAG(1)':
+ return Boolean()
+
+ m = cls.sil_type_pattern.match(sil_type)
+ if m:
+ data_type, precision = m.groups()
+ if precision.isdigit():
+ precision = int(precision)
+ scale = 0
+ else:
+ precision, scale = precision.split(',')
+ precision = int(precision)
+ scale = int(scale)
+ if data_type == 'CHAR':
+ assert not scale, "FIXME"
+ return String(precision)
+ if data_type == 'NUMBER':
+ return Numeric(precision, scale)
+
+ assert False, "FIXME"
+
def add_column(self, sil_name=None, **kwargs):
column = BatchColumn(sil_name, **kwargs)
self.columns.append(column)
diff --git a/rattail/db/model.py b/rattail/db/model.py
index 31002ef75335f0645cd7db6370ef3c8e8423c67b..943fe36d4ba3bbe70f5c83cc53c5f579e469a285 100644
--- a/rattail/db/model.py
+++ b/rattail/db/model.py
@@ -26,7 +26,8 @@
``rattail.db.model`` -- Data Model Namespace
"""
+from edbob.db.model import Base
+from edbob.db.model import *
from edbob.db.extensions.auth.model import *
from edbob.db.extensions.contact.model import *
-
from rattail.db.extension.model import *
diff --git a/rattail/sil/__init__.py b/rattail/sil/__init__.py
index 3fde469649b85d77028fbedebe1001edf305361e..6968675f64c785783cfd56f3c4bcc7ac83282d46 100644
--- a/rattail/sil/__init__.py
+++ b/rattail/sil/__init__.py
@@ -32,5 +32,4 @@ for more information.
from rattail.sil.columns import *
from rattail.sil.batches import *
-from rattail.sil.sqlalchemy import *
from rattail.sil.writer import *
diff --git a/rattail/sil/sqlalchemy.py b/rattail/sil/sqlalchemy.py
deleted file mode 100644
index f1e1d973c0b97eb9bbe2f33f707a40e2cfa3f45a..0000000000000000000000000000000000000000
--- a/rattail/sil/sqlalchemy.py
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-################################################################################
-#
-# Rattail -- Retail Software Framework
-# Copyright © 2010-2012 Lance Edgar
-#
-# This file is part of Rattail.
-#
-# Rattail is free software: you can redistribute it and/or modify it under the
-# terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option)
-# any later version.
-#
-# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
-# more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with Rattail. If not, see .
-#
-################################################################################
-
-"""
-``rattail.sil.sqlalchemy`` -- SQLAlchemy Utilities
-"""
-
-from __future__ import absolute_import
-
-import re
-
-from sqlalchemy import types
-
-from rattail.db.types import GPCType
-
-
-__all__ = ['get_sqlalchemy_type']
-
-
-sil_type_pattern = re.compile(r'^(CHAR|NUMBER)\((\d+(?:\,\d+)?)\)$')
-
-
-def get_sqlalchemy_type(sil_type):
- """
- Returns a SQLAlchemy data type according to a SIL data type.
- """
-
- if sil_type == 'GPC(14)':
- return GPCType
-
- if sil_type == 'FLAG(1)':
- return types.Boolean
-
- m = sil_type_pattern.match(sil_type)
- if m:
- data_type, precision = m.groups()
- if precision.isdigit():
- precision = int(precision)
- scale = 0
- else:
- precision, scale = precision.split(',')
- precision = int(precision)
- scale = int(scale)
- if data_type == 'CHAR':
- assert not scale, "FIXME"
- return types.String(precision)
- if data_type == 'NUMBER':
- return types.Numeric(precision, scale)
-
- assert False, "FIXME"
diff --git a/tests/db/__init__.py b/tests/db/__init__.py
index d9bcb227a3a51feececd247c28f4f12c271d2a29..9ac277d87883777a2f82af3751188d3d437aa840 100644
--- a/tests/db/__init__.py
+++ b/tests/db/__init__.py
@@ -22,7 +22,8 @@ class DataTestCase(unittest.TestCase):
def setUp(self):
self.session = Session()
for table in reversed(Base.metadata.sorted_tables):
- self.session.execute(table.delete())
+ if not table.name.startswith('batch.'):
+ self.session.execute(table.delete())
self.session.commit()
def tearDown(self):
diff --git a/tests/db/test_changes.py b/tests/db/test_changes.py
new file mode 100644
index 0000000000000000000000000000000000000000..68334f773e4d18f848aefb7449a9a14c117639e0
--- /dev/null
+++ b/tests/db/test_changes.py
@@ -0,0 +1,225 @@
+
+from unittest import TestCase
+from mock import patch, DEFAULT, Mock, MagicMock, call
+
+from rattail.db import changes
+from rattail.db.model import Change, Batch, BatchColumn, BatchRow, Role, UserRole, Product
+from sqlalchemy.orm import RelationshipProperty
+
+
+class TestChanges(TestCase):
+
+ @patch.multiple('rattail.db.changes', listen=DEFAULT, ChangeRecorder=DEFAULT)
+ def test_record_changes(self, listen, ChangeRecorder):
+ session = Mock()
+ ChangeRecorder.return_value = 'whatever'
+
+ changes.record_changes(session)
+ ChangeRecorder.assert_called_once_with(True)
+ listen.assert_called_once_with(session, 'before_flush', 'whatever')
+
+ ChangeRecorder.reset_mock()
+ listen.reset_mock()
+ changes.record_changes(session, ignore_role_changes=False)
+ ChangeRecorder.assert_called_once_with(False)
+ listen.assert_called_once_with(session, 'before_flush', 'whatever')
+
+
+class TestChangeRecorder(TestCase):
+
+ def test_init(self):
+ recorder = changes.ChangeRecorder()
+ self.assertTrue(recorder.ignore_role_changes)
+ recorder = changes.ChangeRecorder(False)
+ self.assertFalse(recorder.ignore_role_changes)
+
+ def test_call(self):
+ recorder = changes.ChangeRecorder()
+ recorder.record_change = Mock()
+
+ session = MagicMock()
+ session.deleted.__iter__.return_value = ['deleted_instance']
+ session.new.__iter__.return_value = ['new_instance']
+ session.dirty.__iter__.return_value = ['dirty_instance']
+ session.is_modified.return_value = True
+
+ recorder(session, Mock(), Mock())
+ self.assertEqual(recorder.record_change.call_count, 3)
+ recorder.record_change.assert_has_calls([
+ call(session, 'deleted_instance', deleted=True),
+ call(session, 'new_instance'),
+ call(session, 'dirty_instance'),
+ ])
+
+ def test_record_change(self):
+ session = Mock()
+ recorder = changes.ChangeRecorder()
+ recorder.ensure_uuid = Mock()
+
+ # don't record changes for changes
+ self.assertFalse(recorder.record_change(session, Change()))
+
+ # don't record changes for batch data
+ self.assertFalse(recorder.record_change(session, Batch()))
+ self.assertFalse(recorder.record_change(session, BatchColumn()))
+ self.assertFalse(recorder.record_change(session, BatchRow()))
+
+ # don't record changes for objects with no uuid attribute
+ self.assertFalse(recorder.record_change(session, object()))
+
+ # don't record changes for role data if so configured
+ recorder.ignore_role_changes = True
+ self.assertFalse(recorder.record_change(session, Role()))
+ self.assertFalse(recorder.record_change(session, UserRole()))
+
+ # none of the above should have involved a call to `ensure_uuid()`
+ self.assertFalse(recorder.ensure_uuid.called)
+
+ # make sure role data is *not* ignored if so configured
+ recorder.ignore_role_changes = False
+ self.assertTrue(recorder.record_change(session, Role()))
+ self.assertTrue(recorder.record_change(session, UserRole()))
+
+ # so far no *new* changes have been created
+ self.assertFalse(session.add.called)
+
+ # mock up session to force new change creation
+ session.query.return_value = session
+ session.get.return_value = None
+ with patch('rattail.db.changes.Change') as MockChange:
+ new_change = Mock()
+ MockChange.return_value = new_change
+ self.assertTrue(recorder.record_change(session, Product()))
+ session.add.assert_called_once_with(new_change)
+
+ @patch.multiple('rattail.db.changes', get_uuid=DEFAULT, object_mapper=DEFAULT)
+ def test_ensure_uuid(self, get_uuid, object_mapper):
+ recorder = changes.ChangeRecorder()
+ uuid_column = Mock()
+ object_mapper.return_value.columns.__getitem__.return_value = uuid_column
+
+ # uuid already present
+ product = Product(uuid='some_uuid')
+ recorder.ensure_uuid(product)
+ self.assertEqual(product.uuid, 'some_uuid')
+ self.assertFalse(get_uuid.called)
+
+ # no uuid yet, auto-generate
+ uuid_column.foreign_keys = False
+ get_uuid.return_value = 'another_uuid'
+ product = Product()
+ self.assertIsNone(product.uuid)
+ recorder.ensure_uuid(product)
+ get_uuid.assert_called_once_with()
+ self.assertEqual(product.uuid, 'another_uuid')
+
+ # some heavy mocking for following tests
+ uuid_column.foreign_keys = True
+ remote_side = MagicMock(key='uuid')
+ prop = MagicMock(__class__=RelationshipProperty, key='foreign_thing')
+ prop.remote_side.__len__.return_value = 1
+ prop.remote_side.__iter__.return_value = [remote_side]
+ object_mapper.return_value.iterate_properties.__iter__.return_value = [prop]
+
+ # uuid fetched from existing foreign key object
+ get_uuid.reset_mock()
+ instance = Mock(uuid=None, foreign_thing=Mock(uuid='secondary_uuid'))
+ recorder.ensure_uuid(instance)
+ self.assertFalse(get_uuid.called)
+ self.assertEqual(instance.uuid, 'secondary_uuid')
+
+ # foreign key object doesn't exist; uuid generated as fallback
+ get_uuid.return_value = 'fallback_uuid'
+ instance = Mock(uuid=None, foreign_thing=None)
+ recorder.ensure_uuid(instance)
+ get_uuid.assert_called_once_with()
+ self.assertEqual(instance.uuid, 'fallback_uuid')
+
+ # # uuid fetched from foreign key object
+ # product = Product(uuid='latest_uuid')
+ # product_ext = ProductExtTest(product=product)
+ # self.assertIsNone(product_ext.uuid)
+ # get_uuid.reset_mock()
+ # changes.ensure_uuid(product_ext)
+ # self.assertFalse(get_uuid.called)
+ # self.assertEqual(product_ext.uuid, 'latest_uuid')
+
+ # @patch('rattail.db.changes.get_uuid')
+ # def test_ensure_uuid(self, get_uuid):
+
+ # # fallback scenario. "extended product" is expected to be associated
+ # # with a product, but if it isn't, a UUID must still be generated
+ # product_ext = ProductExtTest()
+ # self.assertIsNone(product_ext.uuid)
+ # changes.ensure_uuid(product_ext)
+ # get_uuid.assert_called_once_with()
+ # self.assertEqual(product_ext.uuid, 'some_uuid')
+
+
+# class ProductExtTest(Base):
+# __tablename__ = 'products_extended_test'
+# uuid = uuid_column(ForeignKey('products.uuid'), default=None)
+# some_attr = Column(String(length=10))
+# product = relationship(Product)
+
+
+# class TestChanges(DataTestCase):
+
+# def setUp(self):
+# Base.metadata.tables['products_extended_test'].create(bind=engine)
+# super(TestChanges, self).setUp()
+
+# def tearDown(self):
+# super(TestChanges, self).tearDown()
+# Base.metadata.tables['products_extended_test'].drop(bind=engine)
+
+# @patch('rattail.db.changes.listen')
+# def test_record_changes(self, listen):
+# session = Mock()
+
+# changes.record_changes(session)
+
+# # # changes not being recorded yet
+# # self.session.add(Product())
+# # self.session.commit()
+# # self.assertEqual(self.session.query(Change).count(), 0)
+
+# # # okay, record them and confirm
+# # changes.record_changes(self.session)
+# # self.session.add(Product())
+# # self.session.commit()
+# # self.assertEqual(self.session.query(Change).count(), 1)
+
+# @patch('rattail.db.changes.get_uuid')
+# def test_ensure_uuid(self, get_uuid):
+
+# # uuid already present
+# product = Product(uuid='whatever')
+# changes.ensure_uuid(product)
+# self.assertEqual(product.uuid, 'whatever')
+# self.assertFalse(get_uuid.called)
+
+# # no uuid yet, auto-generate
+# get_uuid.return_value = 'some_uuid'
+# product = Product()
+# self.assertIsNone(product.uuid)
+# changes.ensure_uuid(product)
+# get_uuid.assert_called_once_with()
+# self.assertEqual(product.uuid, 'some_uuid')
+
+# # uuid fetched from foreign key object
+# product = Product(uuid='latest_uuid')
+# product_ext = ProductExtTest(product=product)
+# self.assertIsNone(product_ext.uuid)
+# get_uuid.reset_mock()
+# changes.ensure_uuid(product_ext)
+# self.assertFalse(get_uuid.called)
+# self.assertEqual(product_ext.uuid, 'latest_uuid')
+
+# # fallback scenario. "extended product" is expected to be associated
+# # with a product, but if it isn't, a UUID must still be generated
+# product_ext = ProductExtTest()
+# self.assertIsNone(product_ext.uuid)
+# changes.ensure_uuid(product_ext)
+# get_uuid.assert_called_once_with()
+# self.assertEqual(product_ext.uuid, 'some_uuid')
diff --git a/tests/db/test_core.py b/tests/db/test_core.py
new file mode 100644
index 0000000000000000000000000000000000000000..289c8716247ccc1053def1105e3fca2e8288452e
--- /dev/null
+++ b/tests/db/test_core.py
@@ -0,0 +1,24 @@
+
+from unittest import TestCase
+
+from rattail.db import core
+from sqlalchemy import Column
+
+
+class TestCore(TestCase):
+
+ def test_uuid_column(self):
+ column = core.uuid_column()
+ self.assertIsInstance(column, Column)
+ self.assertEqual(column.name, None)
+ self.assertTrue(column.primary_key)
+ self.assertFalse(column.nullable)
+ self.assertIsNotNone(column.default)
+
+ def test_uuid_column_no_default(self):
+ column = core.uuid_column(default=None)
+ self.assertIsNone(column.default)
+
+ def test_uuid_column_nullable(self):
+ column = core.uuid_column(nullable=True)
+ self.assertTrue(column.nullable)
diff --git a/tests/db/test_model.py b/tests/db/test_model.py
index e1998cbc015962414a4c26ebba1d8a44a9827059..e30e74bfd641c3a037324f8f5dc07d9430d7ebc1 100644
--- a/tests/db/test_model.py
+++ b/tests/db/test_model.py
@@ -1,11 +1,74 @@
from unittest import TestCase
from . import DataTestCase
+from mock import patch, DEFAULT, Mock, MagicMock
from rattail.db.extension import model
+from sqlalchemy import String, Boolean, Numeric
+from rattail.db.types import GPCType
from sqlalchemy.exc import IntegrityError
+class TestBatch(TestCase):
+
+ @patch('rattail.db.extension.model.object_session')
+ def test_rowclass(self, object_session):
+ object_session.return_value = object_session
+
+ # no row classes to start with
+ self.assertEqual(model.Batch._rowclasses, {})
+
+ # basic, empty row class
+ batch = model.Batch(uuid='some_uuid')
+ batch.get_sqlalchemy_type = Mock(return_value='some_type')
+ batch.columns = MagicMock()
+ rowclass = batch.rowclass
+ self.assertTrue(issubclass(rowclass, model.BatchRow))
+ self.assertEqual(model.Batch._rowclasses.keys(), ['some_uuid'])
+ self.assertIs(model.Batch._rowclasses['some_uuid'], rowclass)
+ self.assertFalse(object_session.flush.called)
+
+ # make sure rowclass.batch works
+ object_session.query.return_value.get.return_value = batch
+ self.assertIs(rowclass().batch, batch)
+ object_session.query.return_value.get.assert_called_once_with('some_uuid')
+
+ # row class with generated uuid and some columns
+ batch = model.Batch(uuid=None)
+ batch.columns = [model.BatchColumn(name='F01'), model.BatchColumn(name='F02')]
+ model.Batch.get_sqlalchemy_type = Mock(return_value=String(length=20))
+ def set_uuid():
+ batch.uuid = 'fresh_uuid'
+ object_session.flush.side_effect = set_uuid
+ rowclass = batch.rowclass
+ object_session.flush.assert_called_once_with()
+ self.assertItemsEqual(model.Batch._rowclasses.keys(), ['some_uuid', 'fresh_uuid'])
+ self.assertIs(model.Batch._rowclasses['fresh_uuid'], rowclass)
+
+ def test_get_sqlalchemy_type(self):
+
+ # gpc
+ self.assertIsInstance(model.Batch.get_sqlalchemy_type('GPC(14)'), GPCType)
+
+ # boolean
+ self.assertIsInstance(model.Batch.get_sqlalchemy_type('FLAG(1)'), Boolean)
+
+ # string
+ type_ = model.Batch.get_sqlalchemy_type('CHAR(20)')
+ self.assertIsInstance(type_, String)
+ self.assertEqual(type_.length, 20)
+
+ # numeric
+ type_ = model.Batch.get_sqlalchemy_type('NUMBER(9,3)')
+ self.assertIsInstance(type_, Numeric)
+ self.assertEqual(type_.precision, 9)
+ self.assertEqual(type_.scale, 3)
+
+ # invalid
+ self.assertRaises(AssertionError, model.Batch.get_sqlalchemy_type, 'CHAR(9,3)')
+ self.assertRaises(AssertionError, model.Batch.get_sqlalchemy_type, 'OMGWTFBBQ')
+
+
class TestCustomer(DataTestCase):
def test_repr(self):
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa1483cd37b78887123c7e4b79f44f61d8a27da3
--- /dev/null
+++ b/tests/test_core.py
@@ -0,0 +1,12 @@
+
+from unittest import TestCase
+
+from rattail import core
+
+
+class TestCore(TestCase):
+
+ def test_get_uuid(self):
+ uuid = core.get_uuid()
+ self.assertIsInstance(uuid, str)
+ self.assertEqual(len(uuid), 32)