Commit eaaccd92 by David Robinson

Made Queryset Consistent, Moved user.py into __init__

Many changes:

- Removed the QueryManager class- all querying is now handled by the
Queryset class. This was necessary to manage chained query lines like

GameScore.Query.gte("score", 1000).lt("score",
2000).order("playerName")

- Queryset methods for comparison and options are now added
programatically (makes it easier to change their operation all at
once).

- Added test coverage of querying methods, including comparison
operators and limit/skip options.

- Removed user.py, moving its classes into `__init__.py`. The reason is
that lines like

import parse_rest
from parse_rest.user import User

parse_rest.APPLICATION_ID = "something"

meant the module attribute APPLICATION_ID was actually not accessible
from the User class. Perhaps there's a way to make this work- I just
couldn't find it yet.

- Set up createdAt and updatedAt to return date time objects rather
than strings
parent d226b713
...@@ -110,14 +110,14 @@ That's it! You're ready to start saving data on Parse. ...@@ -110,14 +110,14 @@ That's it! You're ready to start saving data on Parse.
Object Metadata Object Metadata
--------------- ---------------
The methods objectId(), createdAt(), and updatedAt() return metadata about a _Object_ that cannot be modified through the API: The attributes objectId, createdAt, and updatedAt show metadata about a _Object_ that cannot be modified through the API:
~~~~~ {python} ~~~~~ {python}
gameScore.objectId() gameScore.objectId
# 'xxwXx9eOec' # 'xxwXx9eOec'
gameScore.createdAt() gameScore.createdAt
# datetime.datetime(2011, 9, 16, 21, 51, 36, 784000) # datetime.datetime(2011, 9, 16, 21, 51, 36, 784000)
gameScore.updatedAt() gameScore.updatedAt
# datetime.datetime(2011, 9, 118, 14, 18, 23, 152000) # datetime.datetime(2011, 9, 118, 14, 18, 23, 152000)
~~~~~ ~~~~~
...@@ -130,18 +130,6 @@ If we want to store data in a Object, we should wrap it in a ParseBinaryDataWrap ...@@ -130,18 +130,6 @@ If we want to store data in a Object, we should wrap it in a ParseBinaryDataWrap
gameScore.victoryImage = parse_rest.ParseBinaryDataWrapper('\x03\xf3\r\n\xc7\x81\x7fNc ... ') gameScore.victoryImage = parse_rest.ParseBinaryDataWrapper('\x03\xf3\r\n\xc7\x81\x7fNc ... ')
~~~~~ ~~~~~
We can store a reference to another Object by assigning it to an attribute:
~~~~~ {python}
class CollectedItem(parse_rest.Object):
pass
collectedItem = CollectedItem(type="Sword", isAwesome=True)
collectedItem.save() # we have to save it before it can be referenced
gameScore.item = collectedItem
~~~~~
We can also store geoPoint dataTypes as attributes using the format <code>'POINT(longitude latitude)'</code>, with latitude and longitude as float values We can also store geoPoint dataTypes as attributes using the format <code>'POINT(longitude latitude)'</code>, with latitude and longitude as float values
~~~~~ {python} ~~~~~ {python}
...@@ -160,14 +148,14 @@ Querying ...@@ -160,14 +148,14 @@ Querying
To retrieve an object with a Parse class of `GameScore` and an `objectId` of `xxwXx9eOec`, run: To retrieve an object with a Parse class of `GameScore` and an `objectId` of `xxwXx9eOec`, run:
~~~~~ {python} ~~~~~ {python}
gameScore = GameScore.Query.get("xxwXx9eOec") gameScore = GameScore.Query.where(objectId="xxwXx9eOec").get()
~~~~~ ~~~~~
We can also run more complex queries to retrieve a range of objects. For example, if we want to get a list of _GameScore_ objects with scores between 1000 and 2000 ordered by _playerName_, we would call: We can also run more complex queries to retrieve a range of objects. For example, if we want to get a list of _GameScore_ objects with scores between 1000 and 2000 ordered by _playerName_, we would call:
~~~~~ {python} ~~~~~ {python}
query = GameScore.Query.gte("score", 1000).lt("score", 2000).order("playerName") query = GameScore.Query.gte("score", 1000).lt("score", 2000).order("playerName")
game_scores = query.fetch() game_scores = query.all()
~~~~~ ~~~~~
Notice how queries are built by chaining filter functions. The available filter functions are: Notice how queries are built by chaining filter functions. The available filter functions are:
...@@ -250,11 +238,11 @@ Parse.Cloud.define("averageStars", function(request, response) { ...@@ -250,11 +238,11 @@ Parse.Cloud.define("averageStars", function(request, response) {
Then you can call either of these functions using the `parse_rest.Function` class: Then you can call either of these functions using the `parse_rest.Function` class:
~~~~~ {python} ~~~~~ {python}
>>> hello_func = parse_rest.Function("hello") hello_func = parse_rest.Function("hello")
>>> hello_func() hello_func()
{u'result': u'Hello world!'} {u'result': u'Hello world!'}
>>> star_func = parse_rest.Function("averageStars") star_func = parse_rest.Function("averageStars")
>>> star_func(movie="The Matrix") star_func(movie="The Matrix")
{u'result': 4.5} {u'result': 4.5}
~~~~~ ~~~~~
......
...@@ -21,7 +21,7 @@ import re ...@@ -21,7 +21,7 @@ import re
import logging import logging
from query import QueryManager from query import Queryset
API_ROOT = 'https://api.parse.com/1' API_ROOT = 'https://api.parse.com/1'
...@@ -84,11 +84,19 @@ class Date(ParseType): ...@@ -84,11 +84,19 @@ class Date(ParseType):
@classmethod @classmethod
def from_native(cls, **kw): def from_native(cls, **kw):
date_str = kw.get('iso', '')[:-1] + 'UTC' return cls(self._from_str(kw.get('iso', '')))
return cls(datetime.datetime.strptime(date_str, Date.FORMAT))
@staticmethod
def _from_str(date_str):
"""turn a ISO 8601 string into a datetime object"""
return datetime.datetime.strptime(date_str[:-1] + 'UTC', Date.FORMAT)
def __init__(self, date): def __init__(self, date):
self._date = date """Can be initialized either with a string or a datetime"""
if isinstance(date, datetime.datetime):
self._date = date
elif isinstance(date, unicode):
self._date = Date._from_str(date)
def _to_native(self): def _to_native(self):
return { return {
...@@ -279,14 +287,14 @@ class ParseResource(ParseBase): ...@@ -279,14 +287,14 @@ class ParseResource(ParseBase):
objectId = property(_get_object_id, _set_object_id) objectId = property(_get_object_id, _set_object_id)
createdAt = property(_get_created_datetime, _set_created_datetime) createdAt = property(_get_created_datetime, _set_created_datetime)
updatedAt = property(_get_updated_datetime, _set_created_datetime) updatedAt = property(_get_updated_datetime, _set_updated_datetime)
class ObjectMetaclass(type): class ObjectMetaclass(type):
def __new__(cls, name, bases, dct): def __new__(cls, name, bases, dct):
cls = super(ObjectMetaclass, cls).__new__(cls, name, bases, dct) cls = super(ObjectMetaclass, cls).__new__(cls, name, bases, dct)
cls.set_endpoint_root() cls.set_endpoint_root()
cls.Query = QueryManager(cls) cls.Query = Queryset(cls)
return cls return cls
...@@ -308,13 +316,6 @@ class Object(ParseResource): ...@@ -308,13 +316,6 @@ class Object(ParseResource):
cls.ENDPOINT_ROOT = root cls.ENDPOINT_ROOT = root
return cls.ENDPOINT_ROOT return cls.ENDPOINT_ROOT
def __new__(cls, *args, **kw):
cls.set_endpoint_root()
manager = getattr(cls, 'Query', QueryManager(cls))
if not (hasattr(cls, 'Query') and manager.model_class is cls):
cls.Query = manager
return ParseResource.__new__(cls)
@property @property
def _absolute_url(self): def _absolute_url(self):
if not self.objectId: if not self.objectId:
...@@ -337,6 +338,68 @@ class Object(ParseResource): ...@@ -337,6 +338,68 @@ class Object(ParseResource):
self.__dict__[key] += amount self.__dict__[key] += amount
def login_required(func):
'''decorator describing User methods that need to be logged in'''
def ret(obj, *args, **kw):
if not hasattr(obj, 'sessionToken'):
message = '%s requires a logged-in session' % func.__name__
raise ResourceRequestLoginRequired(message)
func(obj, *args, **kw)
return ret
class User(ParseResource):
'''
A User is like a regular Parse object (can be modified and saved) but
it requires additional methods and functionality
'''
ENDPOINT_ROOT = '/'.join([API_ROOT, 'users'])
PROTECTED_ATTRIBUTES = ParseResource.PROTECTED_ATTRIBUTES + [
'username', 'sessionToken']
def is_authenticated(self):
return getattr(self, 'sessionToken', None) or False
@login_required
def save(self):
session_header = {'X-Parse-Session-Token': self.sessionToken}
return self.__class__.PUT(
self._absolute_url,
extra_headers=session_header,
**self._to_native())
@login_required
def delete(self):
session_header = {'X-Parse-Session-Token': self.sessionToken}
return self.DELETE(self._absolute_url, extra_headers=session_header)
@staticmethod
def signup(username, password, **kw):
return User(**User.POST('', username=username, password=password,
**kw))
@staticmethod
def login(username, password):
login_url = '/'.join([API_ROOT, 'login'])
return User(**User.GET(login_url, username=username,
password=password))
@staticmethod
def request_password_reset(email):
'''Trigger Parse's Password Process. Return True/False
indicate success/failure on the request'''
url = '/'.join([API_ROOT, 'requestPasswordReset'])
try:
User.POST(url, email=email)
return True
except Exception, why:
return False
User.Query = Queryset(User)
class ParseError(Exception): class ParseError(Exception):
'''Base exceptions from requests made to Parse''' '''Base exceptions from requests made to Parse'''
pass pass
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
import json import json
import collections import collections
import copy
class QueryResourceDoesNotExist(Exception): class QueryResourceDoesNotExist(Exception):
...@@ -39,60 +40,68 @@ class QueryManager(object): ...@@ -39,60 +40,68 @@ class QueryManager(object):
return Queryset(self).where(**kw).get() return Queryset(self).where(**kw).get()
class QuerysetMetaclass(type):
"""metaclass to add the dynamically generated comparison functions"""
def __new__(cls, name, bases, dct):
cls = super(QuerysetMetaclass, cls).__new__(cls, name, bases, dct)
# add comparison functions and option functions
for fname in ["lt", "lte", "gt", "gte", "ne"]:
def func(self, name, value, fname=fname):
s = copy.deepcopy(self)
s._where[name]["$" + fname] = value
return s
setattr(cls, fname, func)
for fname in ["limit", "skip"]:
def func(self, value, fname=fname):
s = copy.deepcopy(self)
s._options[fname] = value
return s
setattr(cls, fname, func)
return cls
class Queryset(object): class Queryset(object):
__metaclass__ = QuerysetMetaclass
def __init__(self, manager): def __init__(self, model_class):
self._manager = manager self.model_class = model_class
self._where = collections.defaultdict(dict) self._where = collections.defaultdict(dict)
self._options = {} self._options = {}
def __iter__(self): def __iter__(self):
return iter(self._fetch()) return iter(self._fetch())
def copy_method(f):
"""Represents functions that have to make a copy before running"""
def newf(self, *a, **kw):
s = copy.deepcopy(self)
return f(s, *a, **kw)
return newf
def all(self):
"""return as a list"""
return list(self)
@copy_method
def where(self, **kw): def where(self, **kw):
for key, value in kw.items(): for key, value in kw.items():
self.eq(key, value) self = self.eq(key, value)
return self return self
@copy_method
def eq(self, name, value): def eq(self, name, value):
self._where[name] = value self._where[name] = value
return self return self
# It's tempting to generate the comparison functions programatically, @copy_method
# but probably not worth the decrease in readability of the code.
def lt(self, name, value):
self._where[name]['$lt'] = value
return self
def lte(self, name, value):
self._where[name]['$lte'] = value
return self
def gt(self, name, value):
self._where[name]['$gt'] = value
return self
def gte(self, name, value):
self._where[name]['$gte'] = value
return self
def ne(self, name, value):
self._where[name]['$ne'] = value
return self
def order(self, order, decending=False): def order(self, order, decending=False):
# add a minus sign before the order value if decending == True # add a minus sign before the order value if decending == True
self._options['order'] = decending and ('-' + order) or order self._options['order'] = decending and ('-' + order) or order
return self return self
def limit(self, limit):
self._options['limit'] = limit
return self
def skip(self, skip):
self._options['skip'] = skip
return self
def exists(self): def exists(self):
results = self._fetch() results = self._fetch()
return len(results) > 0 return len(results) > 0
...@@ -112,6 +121,6 @@ class Queryset(object): ...@@ -112,6 +121,6 @@ class Queryset(object):
where = json.dumps(self._where) where = json.dumps(self._where)
options.update({'where': where}) options.update({'where': where})
klass = self._manager.model_class klass = self.model_class
uri = self._manager.model_class.ENDPOINT_ROOT uri = self.model_class.ENDPOINT_ROOT
return [klass(**it) for it in klass.GET(uri, **options).get('results')] return [klass(**it) for it in klass.GET(uri, **options).get('results')]
...@@ -14,6 +14,7 @@ import datetime ...@@ -14,6 +14,7 @@ import datetime
import __init__ as parse_rest import __init__ as parse_rest
from __init__ import GeoPoint, Object from __init__ import GeoPoint, Object
from user import User from user import User
import query
try: try:
...@@ -86,10 +87,17 @@ class TestObject(unittest.TestCase): ...@@ -86,10 +87,17 @@ class TestObject(unittest.TestCase):
self.score.save() self.score.save()
self.assert_(self.score.objectId is not None, 'Can not create object') self.assert_(self.score.objectId is not None, 'Can not create object')
self.assert_(type(self.score.objectId) == unicode)
self.assert_(type(self.score.createdAt) == datetime.datetime)
self.assert_(GameScore.Query.where(
objectId=self.score.objectId).exists(),
'Can not create object')
def testCanUpdateExistingObject(self): def testCanUpdateExistingObject(self):
self.sao_paulo.save() self.sao_paulo.save()
self.sao_paulo.country = 'Brazil' self.sao_paulo.country = 'Brazil'
self.sao_paulo.save() self.sao_paulo.save()
self.assert_(type(self.sao_paulo.updatedAt) == datetime.datetime)
city = City.Query.where(name='São Paulo').get() city = City.Query.where(name='São Paulo').get()
self.assert_(city.country == 'Brazil', 'Could not update object') self.assert_(city.country == 'Brazil', 'Could not update object')
...@@ -109,6 +117,93 @@ class TestObject(unittest.TestCase): ...@@ -109,6 +117,93 @@ class TestObject(unittest.TestCase):
'Failed to increment score on backend') 'Failed to increment score on backend')
class TestQuery(unittest.TestCase):
"""Tests of an object's Queryset"""
def setUp(self):
"""save a bunch of GameScore objects with varying scores"""
# first delete any that exist
for s in GameScore.Query.all():
s.delete()
self.scores = [GameScore(score=s, player_name='John Doe')
for s in range(1, 6)]
for s in self.scores:
s.save()
def testExists(self):
"""test the Queryset.exists() method"""
for s in range(1, 6):
self.assert_(GameScore.Query.where(score=s).exists(),
"exists giving false negative")
self.assert_(not GameScore.Query.where(score=10).exists(),
"exists giving false positive")
def testWhereGet(self):
"""test the Queryset.where() and Queryset.get() methods"""
for s in self.scores:
qobj = GameScore.Query.where(objectId=s.objectId).get()
self.assert_(qobj.objectId == s.objectId,
"Getting object with .where() failed")
self.assert_(qobj.score == s.score,
"Getting object with .where() failed")
# test the two exceptions get can raise
self.assertRaises(query.QueryResourceDoesNotExist,
GameScore.Query.gt("score", 20).get)
self.assertRaises(query.QueryResourceMultipleResultsReturned,
GameScore.Query.gt("score", 3).get)
def testComparisons(self):
"""test comparison operators- gt, gte, lt, lte, ne"""
scores_gt_3 = list(GameScore.Query.gt("score", 3))
self.assertEqual(len(scores_gt_3), 2)
self.assert_(all([s.score > 3 for s in scores_gt_3]))
scores_gte_3 = list(GameScore.Query.gte("score", 3))
self.assertEqual(len(scores_gte_3), 3)
self.assert_(all([s.score >= 3 for s in scores_gt_3]))
scores_lt_4 = list(GameScore.Query.lt("score", 4))
self.assertEqual(len(scores_lt_4), 3)
self.assert_(all([s.score < 4 for s in scores_lt_4]))
scores_lte_4 = list(GameScore.Query.lte("score", 4))
self.assertEqual(len(scores_lte_4), 4)
self.assert_(all([s.score <= 4 for s in scores_lte_4]))
scores_ne_2 = list(GameScore.Query.ne("score", 2))
self.assertEqual(len(scores_ne_2), 4)
self.assert_(all([s.score != 2 for s in scores_ne_2]))
# test chaining
lt_4_gt_2 = list(GameScore.Query.lt("score", 4).gt("score", 2))
self.assert_(len(lt_4_gt_2) == 1, "chained lt+gt not working")
self.assert_(lt_4_gt_2[0].score == 3, "chained lt+gt not working")
q = GameScore.Query.gt("score", 3).lt("score", 3)
self.assert_(not q.exists(), "chained lt+gt not working")
def testOptions(self):
"""test three options- order, limit, and skip"""
scores_ordered = list(GameScore.Query.order("score"))
self.assertEqual([s.score for s in scores_ordered],
[1, 2, 3, 4, 5])
scores_ordered_desc = list(GameScore.Query.order("score", True))
self.assertEqual([s.score for s in scores_ordered_desc],
[5, 4, 3, 2, 1])
scores_limit_3 = list(GameScore.Query.limit(3))
self.assert_(len(scores_limit_3) == 3, "Limit did not return 3 items")
scores_skip_3 = list(GameScore.Query.skip(3))
self.assert_(len(scores_skip_3) == 2, "Skip did not return 2 items")
def tearDown(self):
"""delete all GameScore objects"""
for s in GameScore.Query.all():
s.delete()
class TestFunction(unittest.TestCase): class TestFunction(unittest.TestCase):
def setUp(self): def setUp(self):
"""create and deploy cloud functions""" """create and deploy cloud functions"""
......
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
from __init__ import ParseResource, API_ROOT
from __init__ import ResourceRequestLoginRequired, ResourceRequestBadRequest
from query import QueryManager
def login_required(func):
'''decorator describing User methods that need to be logged in'''
def ret(obj, *args, **kw):
if not hasattr(obj, 'sessionToken'):
message = '%s requires a logged-in session' % func.__name__
raise ResourceRequestLoginRequired(message)
func(obj, *args, **kw)
return ret
class User(ParseResource):
'''
A User is like a regular Parse object (can be modified and saved) but
it requires additional methods and functionality
'''
ENDPOINT_ROOT = '/'.join([API_ROOT, 'users'])
PROTECTED_ATTRIBUTES = ParseResource.PROTECTED_ATTRIBUTES + [
'username', 'sessionToken']
def is_authenticated(self):
return getattr(self, 'sessionToken', None) or False
@login_required
def save(self):
session_header = {'X-Parse-Session-Token': self.sessionToken}
return self.__class__.PUT(
self._absolute_url,
extra_headers=session_header,
**self._to_native())
@login_required
def delete(self):
session_header = {'X-Parse-Session-Token': self.sessionToken}
return self.DELETE(self._absolute_url, extra_headers=session_header)
@staticmethod
def signup(username, password, **kw):
return User(**User.POST('', username=username, password=password,
**kw))
@staticmethod
def login(username, password):
login_url = '/'.join([API_ROOT, 'login'])
return User(**User.GET(login_url, username=username,
password=password))
@staticmethod
def request_password_reset(email):
'''Trigger Parse's Password Process. Return True/False
indicate success/failure on the request'''
url = '/'.join([API_ROOT, 'requestPasswordReset'])
try:
User.POST(url, email=email)
return True
except Exception, why:
return False
User.Query = QueryManager(User)
...@@ -22,7 +22,7 @@ class TestCommand(Command): ...@@ -22,7 +22,7 @@ class TestCommand(Command):
setup( setup(
name='parse_rest', name='parse_rest',
version='0.8.2013', version='0.9.2013',
description='A client library for Parse.com\'.s REST API', description='A client library for Parse.com\'.s REST API',
url='https://github.com/dgrtwo/ParsePy', url='https://github.com/dgrtwo/ParsePy',
packages=['parse_rest'], packages=['parse_rest'],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment