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)