Skip to content

Commit

Permalink
SQLAlchemy data layer initial support
Browse files Browse the repository at this point in the history
Initial support for SQLAlchemy data layer.
  • Loading branch information
Tefnet committed Apr 16, 2013
1 parent 7e52d87 commit 963769f
Show file tree
Hide file tree
Showing 4 changed files with 455 additions and 0 deletions.
14 changes: 14 additions & 0 deletions eve/io/sqlalchemy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-

"""
eve.io.sqlalchemy
~~~~~~~~~~~~
This package implements the SQLAlchemy data layer.
:copyright: (c) 2013 by Nicola Iarocci, Tomasz Jezierski (Tefnet)
:license: BSD, see LICENSE for more details.
"""

from sqlalchemy import SQLAlchemy
from validation import Validator
135 changes: 135 additions & 0 deletions eve/io/sqlalchemy/parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-

"""
eve.io.sqlalchemy.parser
~~~~~~~~~~~~~~~~~~~
This module implements a Python-to-SQLAlchemy syntax parser. Allows the SQLAlchemy
data-layer to seamlessy respond to a Python-like query.
:copyright: (c) 2013 by Nicola Iarocci, Tomasz Jezierski (Tefnet).
:license: BSD, see LICENSE for more details.
"""

import ast
from datetime import datetime
import flask.ext.sqlalchemy as flask_sqlalchemy
sqla_op = flask_sqlalchemy.sqlalchemy.sql.expression.operators
sqla_exp = flask_sqlalchemy.sqlalchemy.sql.expression

def parse(expression, model):
"""Given a python-like conditional statement, returns the equivalent
SQLAlchemy-like query expression. Conditional and boolean operators (==, <=, >=,
!=, >, <) are supported.
"""
v = SQLAVisitor(model)
v.visit(ast.parse(expression))
return v.sqla_query


class ParseError(ValueError):
pass


class SQLAVisitor(ast.NodeVisitor):
"""Implements the python-to-sqlalchemy parser. Only Python conditional
statements are supported, however nested, combined with most common compare
and boolean operators (And and Or).
Supported compare operators: ==, >, <, !=, >=, <=
Supported boolean operators: And, Or
"""
op_mapper = {
ast.Eq: sqla_op.eq,
ast.Gt: sqla_op.gt,
ast.GtE: sqla_op.ge,
ast.Lt: sqla_op.lt,
ast.LtE: sqla_op.le,
ast.NotEq: sqla_op.ne,
ast.Or: sqla_exp.or_,
ast.And: sqla_exp.and_
}

def __init__(self, model):
super(SQLAVisitor, self).__init__()
self.model = model

def visit_Module(self, node):
""" Module handler, our entry point.
"""
self.sqla_query = []
self.ops = []
self.current_value = None

# perform the magic.
self.generic_visit(node)

# if we didn't obtain a query, it is likely that an unsopported
# python expression has been passed.
if self.sqla_query == {}:
raise ParseError("Only conditional statements with boolean "
"(and, or) and comparison operators are "
"supported.")

def visit_Expr(self, node):
""" Make sure that we are parsing compare or boolean operators
"""
if not (isinstance(node.value, ast.Compare) or
isinstance(node.value, ast.BoolOp)):
raise ParseError("Will only parse conditional statements")
self.generic_visit(node)

def visit_Compare(self, node):
""" Compare operator handler.
"""

self.visit(node.left)
left = getattr(self.model, self.current_value)

operator = self.op_mapper[node.ops[0].__class__]

This comment has been minimized.

Copy link
@nicolaiarocci

nicolaiarocci Apr 17, 2013

cute! My C# legacy is showing!


if node.comparators:
comparator = node.comparators[0]
self.visit(comparator)

value = self.current_value

if self.ops:
self.ops[-1]['args'].append(operator(left, value))
else:
self.sqla_query.append(operator(left, value))

def visit_BoolOp(self, node):
""" Boolean operator handler.
"""
op = self.op_mapper[node.op.__class__]
self.ops.append({'op':op, 'args':[]})
for value in node.values:
self.visit(value)

tops = self.ops.pop()
if self.ops:
self.ops[-1]['args'].append(tops['op'](*tops['args']))
else:
self.sqla_query.append(tops['op'](*tops['args']))

def visit_Call(self, node):
# TODO ?
pass

def visit_Attribute(self, node):
# FIXME ?
self.visit(node.value)
self.current_value += "." + node.attr

def visit_Name(self, node):
""" Names """
self.current_value = node.id

def visit_Num(self, node):
""" Numbers """
self.current_value = node.n

def visit_Str(self, node):
""" Strings """
self.current_value = node.s
226 changes: 226 additions & 0 deletions eve/io/sqlalchemy/sqlalchemy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-

"""
eve.io.sqlalchemy.sqlalchemy (eve.io.sqlalchemy)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The actual implementation of the SQLAlchemy data layer.
:copyright: (c) 2013 by Tomasz Jezierski (Tefnet)
:license: BSD, see LICENSE for more details.
"""

from collections import MutableMapping
import copy
import ast
import simplejson as json
from flask import abort, request
import flask.ext.sqlalchemy as flask_sqlalchemy
from datetime import datetime
from parser import parse, ParseError
from eve.io.base import DataLayer, ConnectionException
from eve.utils import config

class SQLAJSONDecoder(json.JSONDecoder):
def decode(self, s):
# Turn RFC-1123 strings into datetime values.
rv = super(SQLAJSONDecoder, self).decode(s)
try:
key, val = rv.iteritems().next()
return dict(key=datetime.strptime(val, config.DATE_FORMAT))
except:
return rv

class SQLAResult(MutableMapping):
def __init__(self, result):
self._result = result

def __getitem__(self, key):
if key in [config.LAST_UPDATED, config.DATE_CREATED] and key not in self:

This comment has been minimized.

Copy link
@nicolaiarocci

nicolaiarocci Apr 17, 2013

missing LAST_UPDATED and DATE_CREATED are handled in the get.py module, and with different defaults so you might want to skip this here.

# if SQLA model doesn't have LAST_UPDATED or DATE_CREATED return current datetime
return datetime.now()
elif key == config.ID_FIELD:
pkey = self._get_pkey()
if len(pkey) > 1:
raise ValueError # TODO: composite primary key
return pkey[0]
return getattr(self._result, key)

def __setitem__(self, key, value):
setattr(self._result, key, value)

def __contains__(self, key):
return key in self.keys()

def __delitem__(self, key):
pass

def __iter__(self):
for k in self.keys():
yield k

def __len__(self):
return len(self.keys())

def keys(self):
return [prop.key for prop in flask_sqlalchemy.sqlalchemy.orm.object_mapper(self._result).iterate_properties]

def _asdict(self):
return dict(self)

def _get_pkey(self):
mapper = flask_sqlalchemy.sqlalchemy.orm.object_mapper(self._result)
return mapper.primary_key_from_instance(self._result)

class SQLAResultCollection(object):
result_item_cls = SQLAResult
def __init__(self, cursor):
self._cursor = cursor

def __iter__(self):
for i in self._cursor:
yield SQLAResult(i)

def count(self):
return self._cursor.count()


class SQLAlchemy(DataLayer):
""" SQLAlchemy data access layer for Eve REST API.
"""
json_decoder_cls = SQLAJSONDecoder

def init_app(self, app):
try:
self.driver = flask_sqlalchemy.SQLAlchemy(app)
except Exception, e:
raise ConnectionException(e)

def lookup_model(self, model_name):
"""Lookup SQLAlchemy model class by its name
"""
return self.driver.Model._decl_class_registry[model_name.capitalize()]


def find(self, resource, req):
"""Retrieves a set of documents matching a given request. Queries can
be expressed in two different formats: the mongo query syntax, and the
python syntax. The first kind of query would look like: ::
?where={"name": "john doe}
while the second would look like: ::
?where=name=="john doe"
The resultset if paginated.
:param resource: resource name.
:param req: a :class:`ParsedRequest`instance.
"""

spec = {}

datasource, spec = self._datasource_ex(resource, spec)
model = self.lookup_model(datasource)

if req.where:
try:
spec = json.loads(req.where, cls=self.json_decoder_cls)
# FIXME: Not yet supported

This comment has been minimized.

Copy link
@nicolaiarocci

nicolaiarocci Apr 17, 2013

I guess that in the case of SQLAlchemy, support for the mongodb syntax doesn't make any sense, unless you want to support a json-like syntax (but you'd miss several niceties). You could just ditch it (one reason why the parsing is done at the data layer is to allow for alternative/additional/different query syntaxes).

abort(400)
except:
try:
spec = parse(req.where, model)
except ParseError:
abort(400)

# TODO
#if req.if_modified_since:
# spec[config.LAST_UPDATED] = \
# {'$gt': req.if_modified_since}

query = self.driver.session.query(model)
if spec:
query = query.filter(*spec)

if req.sort:
ql = []
for key,asc in ast.literal_eval(req.sort).iteritems(): # why not json.loads?

This comment has been minimized.

Copy link
@nicolaiarocci

nicolaiarocci Apr 17, 2013

If I recall correctly, I had some issues with json.loads as mongodb syntax for sort is not straightforward. Will check, and let you know (I could have just fucked up).

ql.append(getattr(model, key) if asc == 1 else getattr(model, key).desc())
query = query.order_by(*ql)

if req.max_results:
query = query.limit(req.max_results)
if req.page > 1:
query = query.offset((req.page - 1) * req.max_results)

return SQLAResultCollection(query)

def find_one(self, resource, **lookup):
"""Retrieves a single document.
:param resource: resource name.
:param **lookup: lookup query.
"""
datasource, filter_ = self._datasource_ex(resource, lookup)
model = self.lookup_model(datasource)
query = self.driver.session.query(model)

return SQLAResult(query.filter_by(**filter_).one())

def insert(self, resource, doc_or_docs):
"""Inserts a document into a resource collection.
"""
rv = []
datasource, filter_ = self._datasource_ex(resource)
model = self.lookup_model(datasource)
for document in doc_or_docs:
sqla_document = copy.deepcopy(document)
# remove date if SQLA model doesn't have LAST_UPDATED or DATE_CREATED
if not hasattr(model, config.LAST_UPDATED):
del sqla_document[config.LAST_UPDATED]

if not hasattr(model, config.DATE_CREATED):
del sqla_document[config.DATE_CREATED]

model_instance = model(**sqla_document)
self.driver.session.add(model_instance)
self.driver.session.commit()
mapper = self.driver.object_mapper(model_instance)
pkey = mapper.primary_key_from_instance(model_instance)
if len(pkey)>1:
raise ValueError # TODO: composite primary key
rv.append(pkey[0])
return rv

def update(self, resource, id_, updates):
"""Updates a collection document.
"""
raise NotImplementedError
# TODO update support

def remove(self, resource, id_=None):
"""Removes a document or the entire set of documents from a collection.
"""
raise NotImplementedError

def _datasource_ex(self, resource, query=None):
""" Returns both db collection and exact query (base filter included)
to which an API resource refers to
"""

datasource, filter_ = self._datasource(resource)
if filter_:
if query:
query.update(filter_)
else:
query = filter_

# if 'user-restricted resource access' is enabled and there's an Auth
# request active, add the username field to the query
username_field = config.DOMAIN[resource].get('auth_username_field')
if username_field and request.authorization and query:
query.update({username_field: request.authorization.username})

return datasource, query
Loading

0 comments on commit 963769f

Please sign in to comment.