Skip to content

Commit

Permalink
Changes to sync with Protean 0.0.9 (#7)
Browse files Browse the repository at this point in the history
* add the lookups and function to build the filters

* add test cases for all the lookups

* add test cases for q object

* add support for Relation and Associations

* change the test command to include flake8

* bump the version of protean

* changes to travis and isort corrections

* add correct distro for 3.7
  • Loading branch information
abhishek-ram authored and Subhash Bhushan committed Mar 11, 2019
1 parent 924feae commit 38babd6
Show file tree
Hide file tree
Showing 14 changed files with 603 additions and 108 deletions.
4 changes: 3 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
language: python
dist: xenial
python:
- '3.6'
- '3.7'
install:
- python setup.py install
- pip install -I -r requirements/test.txt
script:
- pytest --cov=protean_sqlalchemy --cov-config .coveragerc tests
- pytest --cov=protean_sqlalchemy --cov-config .coveragerc --flake8
after_success:
- pip install codecov
- codecov
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ Changelog
------------------

* Match with version 0.0.7 of protean


0.0.9 (2019-03-11)
------------------

* Match with version 0.0.9 of protean
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
protean==0.0.7
protean==0.0.9
click==7.0
sqlalchemy==1.2.14
-r requirements/dev.txt
1 change: 1 addition & 0 deletions requirements/test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ pytest==3.6.3
pytest-cov==2.5.1
pluggy==0.6.0
pytest-mock==1.10.0
pytest-flake8==1.0.4
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def read(*names, **kwargs):

setup(
name='protean-sqlalchemy',
version='0.0.7',
version='0.0.9',
license='BSD 3-Clause License',
description='Protean Sqlalachemy Extension',
long_description='%s\n%s' % (
Expand Down Expand Up @@ -64,7 +64,7 @@ def read(*names, **kwargs):
],
install_requires=[
'click==7.0',
'protean==0.0.7',
'protean==0.0.9',
'sqlalchemy==1.2.14'
# eg: 'aspectlib==1.1.1', 'six>=1.7',
],
Expand Down
2 changes: 1 addition & 1 deletion src/protean_sqlalchemy/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.0.7'
__version__ = '0.0.9'
221 changes: 191 additions & 30 deletions src/protean_sqlalchemy/repository.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
"""This module holds the definition of Database connectivity"""

from protean.core import field
from protean.core.exceptions import ConfigurationError
from protean.core.repository import BaseAdapter
from protean.core.repository import BaseConnectionHandler
from protean.core.repository import BaseModel
from protean.core.repository import Lookup
from protean.core.repository import Pagination
from protean.utils.query import Q
from sqlalchemy import and_
from sqlalchemy import create_engine
from sqlalchemy import or_
from sqlalchemy import orm
from sqlalchemy.engine.url import make_url
from sqlalchemy.exc import DatabaseError
Expand Down Expand Up @@ -52,47 +57,60 @@ def __tablename__(cls):
@classmethod
def from_entity(cls, entity):
""" Convert the entity to a model object """
return cls(**entity.to_dict())
item_dict = {}
for field_obj in cls.opts_.entity_cls.declared_fields.values():
if isinstance(field_obj, field.Reference):
item_dict[field_obj.relation.field_name] = \
field_obj.relation.value
else:
item_dict[field_obj.field_name] = getattr(
entity, field_obj.field_name)
return cls(**item_dict)

@classmethod
def to_entity(cls, model_obj):
""" Convert the model object to an entity """
item_dict = {}
for field_name in cls.opts_.entity_cls.meta_.declared_fields:
for field_name in cls.opts_.entity_cls.declared_fields:
item_dict[field_name] = getattr(model_obj, field_name, None)
return cls.opts_.entity_cls(item_dict)


class Adapter(BaseAdapter):
"""Adapter implementation for the Databases compliant with SQLAlchemy"""

def _filter_objects(self, page: int = 1, per_page: int = 10, # noqa: C901
order_by: list = (), excludes_: dict = None,
**filters) -> Pagination:
""" Filter objects from the sqlalchemy database """
qs = self.conn.query(self.model_cls)
def _build_filters(self, criteria: Q):
""" Recursively Build the filters from the criteria object"""
# Decide the function based on the connector type
func = and_ if criteria.connector == criteria.AND else or_
params = []
for child in criteria.children:
if isinstance(child, Q):
# Call the function again with the child
params.append(self._build_filters(child))
else:
# Find the lookup class and the key
stripped_key, lookup_class = self._extract_lookup(child[0])

# check for sqlalchemy filters
filter_ = filters.pop('filter_', None)
if filter_ is not None:
qs = qs.filter(filter_)
# Instantiate the lookup class and get the expression
lookup = lookup_class(stripped_key, child[1], self.model_cls)
if criteria.negated:
params.append(~lookup.as_expression())
else:
params.append(lookup.as_expression())

# apply the rest of the filters and excludes
for fk, fv in filters.items():
col = getattr(self.model_cls, fk)
if type(fv) in (list, tuple):
qs = qs.filter(col.in_(fv))
else:
qs = qs.filter(col == fv)
return func(*params)

for ek, ev in excludes_.items():
col = getattr(self.model_cls, ek)
if type(ev) in (list, tuple):
qs = qs.filter(~col.in_(ev))
else:
qs = qs.filter(col != ev)
def _filter_objects(self, criteria: Q, page: int = 1, per_page: int = 10,
order_by: list = ()) -> Pagination:
""" Filter objects from the sqlalchemy database """
qs = self.conn.query(self.model_cls)

# apply the ordering
# Build the filters from the criteria
if criteria.children:
qs = qs.filter(self._build_filters(criteria))

# Apply the order by clause if present
order_cols = []
for order_col in order_by:
col = getattr(self.model_cls, order_col.lstrip('-'))
Expand All @@ -109,12 +127,13 @@ def _filter_objects(self, page: int = 1, per_page: int = 10, # noqa: C901

# Return the results
try:
total = qs.count()
items = qs.all()
total = qs.count() if per_page > 0 else len(items)
result = Pagination(
page=page,
per_page=per_page,
total=total,
items=qs.all())
items=items)
except DatabaseError:
self.conn.rollback()
raise
Expand All @@ -127,7 +146,7 @@ def _create_object(self, model_obj):

try:
# If the model has Auto fields then flush to get them
if self.entity_cls.meta_.has_auto_field:
if self.entity_cls.auto_fields:
self.conn.flush()
self.conn.commit()
except DatabaseError:
Expand All @@ -140,13 +159,17 @@ def _update_object(self, model_obj):
""" Update a record in the sqlalchemy database"""
primary_key, data = {}, {}
for field_name, field_obj in \
self.entity_cls.meta_.declared_fields.items():
self.entity_cls.declared_fields.items():
if field_obj.identifier:
primary_key = {
field_name: getattr(model_obj, field_name)
}
else:
data[field_name] = getattr(model_obj, field_name, None)
if isinstance(field_obj, field.Reference):
data[field_obj.relation.field_name] = \
field_obj.relation.value
else:
data[field_name] = getattr(model_obj, field_name, None)

# Run the update query and commit the results
try:
Expand All @@ -170,3 +193,141 @@ def _delete_objects(self, **filters):
self.conn.rollback()
raise
return del_count


operators = {
'exact': '__eq__',
'iexact': 'ilike',
'contains': 'contains',
'icontains': 'ilike',
'startswith': 'startswith',
'endswith': 'endswith',
'gt': '__gt__',
'gte': '__ge__',
'lt': '__lt__',
'lte': '__le__',
'in': 'in_',
'overlap': 'overlap',
'any': 'any',
}


class DefaultLookup(Lookup):
"""Base class with default implementation of expression construction"""

def __init__(self, source, target, model_cls):
"""Source is LHS and Target is RHS of a comparsion"""
self.model_cls = model_cls
super().__init__(source, target)

def process_source(self):
"""Return source with transformations, if any"""
source_col = getattr(self.model_cls, self.source)
return source_col

def process_target(self):
"""Return target with transformations, if any"""
return self.target

def as_expression(self):
lookup_func = getattr(self.process_source(),
operators[self.lookup_name])
return lookup_func(self.process_target())


@Adapter.register_lookup
class Exact(DefaultLookup):
"""Exact Match Query"""
lookup_name = 'exact'


@Adapter.register_lookup
class IExact(DefaultLookup):
"""Exact Case-Insensitive Match Query"""
lookup_name = 'iexact'


@Adapter.register_lookup
class Contains(DefaultLookup):
"""Exact Contains Query"""
lookup_name = 'contains'


@Adapter.register_lookup
class IContains(DefaultLookup):
"""Exact Case-Insensitive Contains Query"""
lookup_name = 'icontains'

def process_target(self):
"""Return target in lowercase"""
assert isinstance(self.target, str)
return f"%{super().process_target()}%"


@Adapter.register_lookup
class Startswith(DefaultLookup):
"""Exact Contains Query"""
lookup_name = 'startswith'


@Adapter.register_lookup
class Endswith(DefaultLookup):
"""Exact Contains Query"""
lookup_name = 'endswith'


@Adapter.register_lookup
class GreaterThan(DefaultLookup):
"""Greater than Query"""
lookup_name = 'gt'


@Adapter.register_lookup
class GreaterThanOrEqual(DefaultLookup):
"""Greater than or Equal Query"""
lookup_name = 'gte'


@Adapter.register_lookup
class LessThan(DefaultLookup):
"""Less than Query"""
lookup_name = 'lt'


@Adapter.register_lookup
class LessThanOrEqual(DefaultLookup):
"""Less than or Equal Query"""
lookup_name = 'lte'


@Adapter.register_lookup
class In(DefaultLookup):
"""In Query"""
lookup_name = 'in'

def process_target(self):
"""Ensure target is a list or tuple"""
assert type(self.target) in (list, tuple)
return super().process_target()


@Adapter.register_lookup
class Overlap(DefaultLookup):
"""In Query"""
lookup_name = 'in'

def process_target(self):
"""Ensure target is a list or tuple"""
assert type(self.target) in (list, tuple)
return super().process_target()


@Adapter.register_lookup
class Any(DefaultLookup):
"""In Query"""
lookup_name = 'in'

def process_target(self):
"""Ensure target is a list or tuple"""
assert type(self.target) in (list, tuple)
return super().process_target()
23 changes: 17 additions & 6 deletions src/protean_sqlalchemy/sa.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
"""

from protean.core import field
from protean.utils.meta import OptionsMeta
from protean.core.repository.base import BaseModelMeta
from protean.core.repository import repo_factory

from sqlalchemy import types as sa_types, Column
from sqlalchemy.ext import declarative as sa_dec


class DeclarativeMeta(sa_dec.DeclarativeMeta, OptionsMeta):
class DeclarativeMeta(sa_dec.DeclarativeMeta, BaseModelMeta):
""" Metaclass for the Sqlalchemy declarative schema """
field_mapping = {
field.Auto: sa_types.Integer,
Expand All @@ -28,15 +30,25 @@ def __init__(cls, classname, bases, dict_):
# Update the class attrs with the entity attributes
if cls.__dict__.get('opts_'):
entity_cls = cls.__dict__['opts_'].entity_cls
for field_name, field_obj in entity_cls.meta_.\
for field_name, field_obj in entity_cls.\
declared_fields.items():

# Map the field if not in attributes
if field_name not in cls.__dict__:
field_cls = type(field_obj)
sa_type_cls = cls.field_mapping.get(field_cls)
if field_cls == field.Reference:
related_ent = repo_factory.get_entity(field_obj.to_cls)
if field_obj.via:
related_attr = getattr(
related_ent, field_obj.via)
else:
related_attr = related_ent.id_field
field_name = field_obj.get_attribute_name()
field_cls = type(related_attr)

# Default to the text type
# Get the SA type and default to the text type if no
# mapping is found
sa_type_cls = cls.field_mapping.get(field_cls)
if not sa_type_cls:
sa_type_cls = sa_types.String

Expand All @@ -55,5 +67,4 @@ def __init__(cls, classname, bases, dict_):
# Update the attributes of the class
setattr(cls, field_name,
Column(sa_type_cls(**type_args), **col_args))

super().__init__(classname, bases, dict_)
Loading

0 comments on commit 38babd6

Please sign in to comment.