diff --git a/CHANGES.rst b/CHANGES.rst index fe2f416..f1abdcc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,13 @@ Changelog Here you can see the full list of changes between each SQLAlchemy-Utils release. +0.9.0 (2013-04-11) +^^^^^^^^^^^^^^^^^^ + +- Added CaseInsensitiveComparator +- Added Email type + + 0.8.4 (2013-04-08) ^^^^^^^^^^^^^^^^^^ diff --git a/setup.py b/setup.py index 002a1e3..f8c4de3 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ class PyTest(Command): setup( name='SQLAlchemy-Utils', - version='0.8.4', + version='0.9', url='https://github.com/kvesteri/sqlalchemy-utils', license='BSD', author='Konsta Vesterinen', diff --git a/sqlalchemy_utils/__init__.py b/sqlalchemy_utils/__init__.py index 72691ed..e2d5d61 100644 --- a/sqlalchemy_utils/__init__.py +++ b/sqlalchemy_utils/__init__.py @@ -1,6 +1,7 @@ from .functions import sort_query, defer_except, escape_like from .merge import merge, Merger from .types import ( + Email, instrumented_list, InstrumentedList, PhoneNumber, @@ -20,6 +21,7 @@ __all__ = ( escape_like, instrumented_list, merge, + Email, InstrumentedList, Merger, NumberRange, diff --git a/sqlalchemy_utils/operators.py b/sqlalchemy_utils/operators.py new file mode 100644 index 0000000..4fc801a --- /dev/null +++ b/sqlalchemy_utils/operators.py @@ -0,0 +1,50 @@ +import sqlalchemy as sa + + +class CaseInsensitiveComparator(sa.Unicode.Comparator): + @classmethod + def lowercase_arg(cls, func): + def operation(self, other, **kwargs): + if other is None: + return getattr(sa.Unicode.Comparator, func)( + self, other, **kwargs + ) + return getattr(sa.Unicode.Comparator, func)( + self, sa.func.lower(other), **kwargs + ) + return operation + + def in_(self, other): + if isinstance(other, list) or isinstance(other, tuple): + other = map(sa.func.lower, other) + return sa.Unicode.Comparator.in_(self, other) + + def notin_(self, other): + if isinstance(other, list) or isinstance(other, tuple): + other = map(sa.func.lower, other) + return sa.Unicode.Comparator.notin_(self, other) + + +string_operator_funcs = [ + '__eq__', + '__ne__', + '__lt__', + '__le__', + '__gt__', + '__ge__', + 'concat', + 'contains', + 'ilike', + 'like', + 'notlike', + 'notilike', + 'startswith', + 'endswith', +] + +for func in string_operator_funcs: + setattr( + CaseInsensitiveComparator, + func, + CaseInsensitiveComparator.lowercase_arg(func) + ) diff --git a/sqlalchemy_utils/types.py b/sqlalchemy_utils/types.py index 6816f47..1e2790e 100644 --- a/sqlalchemy_utils/types.py +++ b/sqlalchemy_utils/types.py @@ -3,6 +3,7 @@ from functools import wraps import sqlalchemy as sa from sqlalchemy.orm.collections import InstrumentedList as _InstrumentedList from sqlalchemy import types +from .operators import CaseInsensitiveComparator class PhoneNumber(phonenumbers.phonenumber.PhoneNumber): @@ -118,6 +119,16 @@ class ScalarList(types.TypeDecorator): ) +class Email(sa.types.TypeDecorator): + impl = sa.Unicode(255) + comparator_factory = CaseInsensitiveComparator + + def process_bind_param(self, value, dialect): + if value is not None: + return value.lower() + return value + + class NumberRangeRawType(types.UserDefinedType): """ Raw number range type, only supports PostgreSQL for now. diff --git a/tests/test_case_insensitive_comparator.py b/tests/test_case_insensitive_comparator.py new file mode 100644 index 0000000..6a15dbc --- /dev/null +++ b/tests/test_case_insensitive_comparator.py @@ -0,0 +1,44 @@ +import sqlalchemy as sa +from sqlalchemy_utils import Email +from tests import DatabaseTestCase + + +class TestCaseInsensitiveComparator(DatabaseTestCase): + def create_models(self): + class User(self.Base): + __tablename__ = 'user' + id = sa.Column(sa.Integer, primary_key=True) + email = sa.Column(Email) + + def __repr__(self): + return 'Building(%r)' % self.id + + self.User = User + + def test_supports_equals(self): + query = ( + self.session.query(self.User) + .filter(self.User.email == u'email@example.com') + ) + + assert '"user".email = lower(:lower_1)' in str(query) + + def test_supports_in_(self): + query = ( + self.session.query(self.User) + .filter(self.User.email.in_([u'email@example.com', u'a'])) + ) + assert ( + '"user".email IN (lower(:lower_1), lower(:lower_2))' + in str(query) + ) + + def test_supports_notin_(self): + query = ( + self.session.query(self.User) + .filter(self.User.email.notin_([u'email@example.com', u'a'])) + ) + assert ( + '"user".email NOT IN (lower(:lower_1), lower(:lower_2))' + in str(query) + ) diff --git a/tests/test_email.py b/tests/test_email.py new file mode 100644 index 0000000..161eaf6 --- /dev/null +++ b/tests/test_email.py @@ -0,0 +1,27 @@ +import sqlalchemy as sa +from sqlalchemy_utils import Email +from tests import DatabaseTestCase + + +class TestEmailType(DatabaseTestCase): + def create_models(self): + class User(self.Base): + __tablename__ = 'user' + id = sa.Column(sa.Integer, primary_key=True) + email = sa.Column(Email) + + def __repr__(self): + return 'Building(%r)' % self.id + + self.User = User + + def test_saves_email_as_lowercased(self): + user = self.User( + email=u'Someone@example.com' + ) + + self.session.add(user) + self.session.commit() + + user = self.session.query(self.User).first() + assert user.email == u'someone@example.com' diff --git a/tests/test_number_range.py b/tests/test_number_range.py index b1dea40..c97e7b6 100644 --- a/tests/test_number_range.py +++ b/tests/test_number_range.py @@ -28,6 +28,21 @@ class TestNumberRangeType(DatabaseTestCase): assert building.persons_at_night.min_value == 1 assert building.persons_at_night.max_value == 3 + def test_nullify_number_range(self): + building = self.Building( + persons_at_night=NumberRange(1, 3) + ) + + self.session.add(building) + self.session.commit() + + building = self.session.query(self.Building).first() + building.persons_at_night = None + self.session.commit() + + building = self.session.query(self.Building).first() + assert building.persons_at_night is None + class TestNumberRange(object): def test_equality_operator(self):