Commit 9fce3b0d by Miles Richardson Committed by GitHub

Merge pull request #131 from johnkawakami/master

Adds additional support for Relations.
parents 31f23b19 bc210077
......@@ -453,6 +453,59 @@ for post in posts_by_joe:
**TODO**: Slicing of Querysets
Relations
---------
A Relation is field that contains references to multiple objects.
You can query this subset of objects.
(Note that Parse's relations are "one sided" and don't involve a join table. [See the docs.](https://parse.com/docs/js/guide#relations-many-to-many))
For example, if we have Game and GameScore classes, and one game
can have multiple GameScores, you can use relations to associate
those GameScores with a Game.
~~~~~ {python}
game = Game(name="3-way Battle")
game.save()
score1 = GameScore(player_name='Ronald', score=100)
score2 = GameScore(player_name='Rebecca', score=140)
score3 = GameScore(player_name='Sara', score=190)
relation = game.relation('scores')
relation.add([score1, score2, score3])
~~~~~
A Game gets added, three GameScores get added, and three relations
are created associating the GameScores with the Game.
To retreive the related scores for a game, you use query() to get a
Queryset for the relation.
~~~~~ {python}
scores = relation.query()
for gamescore in scores:
print gamescore.player_name, gamescore.score
~~~~~
The query is limited to the objects previously added to the
relation.
~~~~~ {python}
scores = relation.query().order_by('score', descending=True)
for gamescore in scores:
print gamescore.player_name, gamescore.score
~~~~~
To remove objects from a relation, you use remove(). This example
removes all the related objects.
~~~~~ {python}
scores = relation.query()
for gamescore in scores:
relation.remove(gamescore)
~~~~~
Users
-----
......
......@@ -130,7 +130,109 @@ class EmbeddedObject(ParseType):
class Relation(ParseType):
@classmethod
def from_native(cls, **kw):
pass
return cls(**kw)
def with_parent(self, **kw):
"""The parent calls this if the Relation already exists."""
if 'parentObject' in kw:
self.parentObject = kw['parentObject']
self.key = kw['key']
return self
def __init__(self, **kw):
"""Called either via Relation(), or via from_native().
In both cases, the Relation object cannot perform
queries until we know what classes are on both sides
of the relation.
If it's called via from_native, then a later call to
with_parent() provides parent information.
If it's called as Relation(), the relatedClassName is
discovered either on the first added object, or
by querying the server to retrieve the schema.
"""
# Name of the key on the parent object.
self.key = None
self.parentObject = None
self.relatedClassName = None
# Called via from_native()
if 'className' in kw:
self.relatedClassName = kw['className']
# Called via Relation(...)
if 'parentObject' in kw:
self.parentObject = kw['parentObject']
self.key = kw['key']
def __repr__(self):
className = objectId = None
if self.parentObject is not None:
className = self.parentObject.className
objectId = self.parentObject.objectId
repr = u'<Relation where %s:%s for %s>' % \
(className,
objectId,
self.relatedClassName)
return repr
def _to_native(self):
return {
'__type': 'Relation',
'className': self.relatedClassName
}
def add(self, objs):
"""Adds a Parse.Object or an array of Parse.Objects to the relation."""
if type(objs) is not list:
objs = [objs]
if self.relatedClassName is None:
# find the related class from the first object added
self.relatedClassName = objs[0].className
setattr(self.parentObject, self.key, self)
objectsId = []
for obj in objs:
if not hasattr(obj, 'objectId') or obj.objectId is None:
obj.save()
objectsId.append(obj.objectId)
self.parentObject.addRelation(self.key,
self.relatedClassName,
objectsId)
def remove(self, objs):
"""Removes an array of, or one Parse.Object from this relation."""
if type(objs) is not list:
objs = [objs]
objectsId = []
for obj in objs:
if hasattr(obj, 'objectId'):
objectsId.append(obj.objectId)
self.parentObject.removeRelation(self.key,
self.relatedClassName,
objectsId)
def query(self):
"""Returns a Parse.Query limited to objects in this relation."""
if self.relatedClassName is None:
self._probe_for_relation_class()
key = '%s__relatedTo' % (self.key,)
kw = {key: self.parentObject}
relatedClass = Object.factory(self.relatedClassName)
q = relatedClass.Query.all().filter(**kw)
return q
def _probe_for_relation_class(self):
"""Retrive the schema from the server to find related class."""
schema = self.parentObject.__class__.schema()
fields = schema['fields']
relatedColumn = fields[self.key]
columnType = relatedColumn['type']
if columnType == 'Relation':
self.relatedClassName = relatedColumn['targetClass']
else:
raise ParseError(
'Column type is %s, expected Relation' % (columnType,))
@complex_type()
......@@ -443,6 +545,27 @@ class Object(six.with_metaclass(ObjectMetaclass, ParseResource)):
cls.ENDPOINT_ROOT = root
return cls.ENDPOINT_ROOT
@classmethod
def schema(cls):
"""Retrieves the class' schema."""
root = '/'.join([API_ROOT, 'schemas', cls.__name__])
schema = cls.GET(root)
return schema
@classmethod
def schema_delete_field(cls, key):
"""Deletes a field."""
root = '/'.join([API_ROOT, 'schemas', cls.__name__])
payload = {
'className': cls.__name__,
'fields': {
key: {
'__op': 'Delete'
}
}
}
cls.PUT(root, **payload)
@property
def _absolute_url(self):
if not self.objectId:
......@@ -500,4 +623,12 @@ class Object(six.with_metaclass(ObjectMetaclass, ParseResource)):
}
}
self.__class__.PUT(self._absolute_url, **payload)
self.__dict__[key] = ''
# self.__dict__[key] = ''
def relation(self, key):
if not hasattr(self, key):
return Relation(parentObject=self, key=key)
try:
return getattr(self, key).with_parent(parentObject=self, key=key)
except:
raise ParseError("Column '%s' is not a Relation." % (key,))
#!/usr/bin/env python
#-*- coding: utf-8 -*-
"""
Contains unit tests for the Python Parse REST API wrapper
"""
from __future__ import print_function
import os
import sys
import subprocess
import unittest
import datetime
import six
from itertools import chain
from parse_rest.core import ResourceRequestNotFound
from parse_rest.core import ResourceRequestBadRequest
from parse_rest.core import ParseError
from parse_rest.connection import register, ParseBatcher
from parse_rest.datatypes import GeoPoint, Object, Function, Pointer, Relation
from parse_rest.user import User
from parse_rest import query
from parse_rest.installation import Push
try:
import settings_local
except ImportError:
sys.exit('You must create a settings_local.py file with APPLICATION_ID, ' \
'REST_API_KEY, MASTER_KEY variables set')
register(
getattr(settings_local, 'APPLICATION_ID'),
getattr(settings_local, 'REST_API_KEY'),
master_key=getattr(settings_local, 'MASTER_KEY')
)
GLOBAL_JSON_TEXT = """{
"applications": {
"_default": {
"link": "parseapi"
},
"parseapi": {
"applicationId": "%s",
"masterKey": "%s"
}
},
"global": {
"parseVersion": "1.1.16"
}
}
"""
class Game(Object):
pass
class GameScore(Object):
pass
class TestNoRelation(unittest.TestCase):
def setUp(self):
try:
Game.schema_delete_field('scores')
except ResourceRequestBadRequest:
# fails if the field doesn't exist
pass
self.game = Game(name="foobar")
def testQueryWithNoRelationOnline(self):
"""If the online schema lacks the relation, we cannot query."""
with self.assertRaises(KeyError):
rel = self.game.relation('scores')
rel.query()
class TestRelation(unittest.TestCase):
@classmethod
def setUpClass(cls):
# prime the schema with a relation field for GameScore
score1 = GameScore(score=1337, player_name='John Doe', cheat_mode=False)
game = Game(name="foobar")
game.save()
rel = game.relation('scores')
rel.add(score1)
def setUp(self):
self.score1 = GameScore(score=1337, player_name='John Doe', cheat_mode=False)
self.score2 = GameScore(score=1337, player_name='Jane Doe', cheat_mode=False)
self.score3 = GameScore(score=1337, player_name='Joan Doe', cheat_mode=False)
self.score4 = GameScore(score=1337, player_name='Jeff Doe', cheat_mode=False)
self.game = Game(name="foobar")
self.game.save()
self.rel = self.game.relation('scores')
def tearDown(self):
game_score = getattr(self.score1, 'score', None)
game_name = getattr(self.game, 'name', None)
if game_score:
ParseBatcher().batch_delete(GameScore.Query.filter(score=game_score))
if game_name:
ParseBatcher().batch_delete(Game.Query.filter(name=game_name))
@classmethod
def tearDownClass(cls):
Game.schema_delete_field('scores')
def testRelationsAdd(self):
"""Add multiple objects to a relation."""
self.rel.add(self.score1)
scores = self.rel.query()
self.assertEqual(len(scores), 1)
self.assertEqual(scores[0].player_name, 'John Doe')
self.rel.add(self.score2)
scores = self.rel.query()
self.assertEqual(len(scores), 2)
self.rel.add([self.score3, self.score4])
scores = self.rel.query()
self.assertEqual(len(scores), 4)
def testRelationQueryLimitsToRelation(self):
"""Relational query limits results to objects in the relation."""
self.rel.add([self.score1, self.score2])
gamescore3 = GameScore(score=1337)
gamescore3.save()
# score saved but not associated with the game
q = self.rel.query()
scores = q.filter(score__gte=1337)
self.assertEqual(len(scores), 2)
def testRemoval(self):
"""Test if a specific object can be removed from a relation."""
self.rel.add([self.score1, self.score2, self.score3])
self.rel.remove(self.score1)
self.rel.remove(self.score2)
scores = self.rel.query()
self.assertEqual(scores[0].player_name, 'Joan Doe')
def testSchema(self):
"""Retrieve a schema for the class."""
schema = Game.schema()
self.assertEqual(schema['className'], 'Game')
fields = schema['fields']
self.assertEqual(fields['scores']['type'], 'Relation')
def testWrongType(self):
"""Adding wrong type fails silently."""
self.rel.add(self.score1)
self.rel.add(self.score2)
self.rel.add(self.game) # should fail to add this
scores = self.rel.query()
self.assertEqual(len(scores), 2)
def testNoTypeSetParseHasColumn(self):
"""Query can run before anything is added to the relation,
if the schema online has already defined the relation.
"""
scores = self.rel.query()
self.assertEqual(len(scores), 0)
def testWrongColumnForRelation(self):
"""Should get a ParseError if we specify a relation on
a column that is not a relation.
"""
with self.assertRaises(ParseError):
rel = self.game.relation("name")
rel.query()
def testNonexistentColumnForRelation(self):
"""Should get a ParseError if we specify a relation on
a column that is not a relation.
"""
with self.assertRaises(KeyError):
rel = self.game.relation("nonexistent")
rel.query()
def testRepr(self):
s = "*** %s ***" % (self.rel)
self.assertRegex(s, '<Relation where .+ for .+>')
def testWithParent(self):
"""Rehydrating a relation from an instance on the server.
With_parent is called by relation() when the object was
retrieved from the server. This test is for code coverage.
"""
game2 = Game.Query.get(objectId=self.game.objectId)
self.assertTrue(hasattr(game2, 'scores'))
rel2 = game2.relation('scores')
self.assertIsInstance(rel2, Relation)
def run_tests():
"""Run all tests in the parse_rest package"""
tests = unittest.TestLoader().loadTestsFromNames(['parse_rest.tests'])
t = unittest.TextTestRunner(verbosity=1)
t.run(tests)
if __name__ == "__main__":
# command line
unittest.main()
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