├── .bumpversion.cfg ├── .github └── workflows │ ├── pullrequests.yml │ └── tag.yml ├── .gitignore ├── AUTHORS ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── README.rst ├── pyproject.toml ├── requirements.txt ├── setup.py ├── soql ├── __init__.py ├── attributes.py ├── loaders.py ├── model.py ├── model_registry.py ├── nodes.py ├── path_builder.py ├── select.py └── utils.py └── tests ├── __init__.py ├── helpers.py ├── test_attributes.py ├── test_loader.py ├── test_model.py ├── test_nodes.py ├── test_path_builder.py └── test_select.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.1.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | 8 | -------------------------------------------------------------------------------- /.github/workflows/pullrequests.yml: -------------------------------------------------------------------------------- 1 | name: flask-rebar Pull Request Tests 2 | 3 | on: 4 | - pull_request 5 | 6 | jobs: 7 | tests: 8 | name: Testing on Python ${{ matrix.python }} 9 | runs-on: ubuntu-latest 10 | strategy: 11 | max-parallel: 20 12 | fail-fast: false 13 | matrix: 14 | python: 15 | - 3.7 16 | - 3.8 17 | - 3.9 18 | - "3.10" 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python:${{ matrix.python }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python }} 26 | - name: "Test with ${{matrix.libraries}}" 27 | run: | 28 | python -m pip install -U pip 29 | python -m pip install -r requirements.txt 30 | python -m pip freeze 31 | - name: Run Tests 32 | run: | 33 | python -m pytest 34 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: flask-rebar Release Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Build and publish Python 🐍 distributions 📦 to PyPI 11 | runs-on: ubuntu-18.04 12 | steps: 13 | - uses: actions/checkout@master 14 | - name: Set up Python 3.8 15 | uses: actions/setup-python@v1 16 | with: 17 | python-version: 3.8 18 | - name: Install pep517 19 | run: | 20 | python -m pip install pep517 --user 21 | - name: Build a binary wheel and a source tarball 22 | run: | 23 | python -m pep517.build . 24 | - name: Publish distribution 📦 to PyPI 25 | uses: pypa/gh-action-pypi-publish@master 26 | with: 27 | password: ${{ secrets.pypi_password }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | *.zip 7 | 8 | # Packages 9 | *.egg 10 | *.egg-info 11 | .eggs 12 | dist 13 | build 14 | eggs 15 | parts 16 | bin 17 | var 18 | sdist 19 | develop-eggs 20 | .installed.cfg 21 | lib 22 | lib64 23 | 24 | # Installer logs 25 | pip-log.txt 26 | 27 | # Unit test / coverage reports 28 | .coverage 29 | .tox 30 | nosetests.xml 31 | 32 | # Translations 33 | *.mo 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | 40 | # Complexity 41 | output/*.html 42 | output/*/index.html 43 | 44 | # Sphinx 45 | docs/_build 46 | 47 | .DS_Store 48 | 49 | 50 | # pycharm 51 | .idea 52 | 53 | # pip? 54 | src 55 | 56 | .coverage 57 | 58 | # virtualenv 59 | .venv 60 | venv 61 | .Python 62 | include/ 63 | man/ 64 | # merge artifacts 65 | *.orig 66 | 67 | #emacs 68 | \#* 69 | \.#* 70 | 71 | #vim 72 | *.swp 73 | 74 | # pytest 75 | .pytest_cache 76 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | soql is developed and maintained by the PlanGrid team and community 2 | contributors. It was created by Barak Alon. 3 | 4 | A full list of contributors is available from git with:: 5 | 6 | git shortlog -sne 7 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Version 0.1.0 5 | ------------- 6 | 7 | Initial release! 8 | 9 | Version 1.2.0 10 | ------------- 11 | 12 | Added the ability to make Relationships nullable -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | We're excited about new contributors and want to make it easy for you to help improve this project. If you run into problems, please open a GitHub issue. 5 | 6 | If you want to contribute a fix, or a minor change that doesn't change the API, go ahead and open a pull requests, see details on pull requests below. 7 | 8 | If you're interested in making a larger change, we recommend you to open an issue for discussion first. That way we can ensure that the change is within the scope of this project before you put a lot of effort into it. 9 | 10 | 11 | Issues 12 | ------ 13 | 14 | We use GitHub issues to track public bugs. Please ensure your description is clear and has sufficient instructions to be able to reproduce the issue. 15 | 16 | 17 | Developing 18 | ---------- 19 | 20 | We recommend using a `virtual environment `_ for development. Once within a virtual environment install the ``soql`` package: 21 | 22 | .. code-block:: bash 23 | 24 | pip install -r requirements.txt 25 | 26 | To run the test suite with the current version of Python/virtual environment, use pytest: 27 | 28 | .. code-block:: bash 29 | 30 | pytest 31 | 32 | soql supports multiple versions of Python and uses Travis CI to run the test suite with different combinations of dependency versions. These tests are required before a PR is merged. 33 | 34 | 35 | Pull Requests 36 | ------------- 37 | 38 | 1. Fork the repo and create your branch from ``master``. 39 | 2. If you've added code that should be tested, add tests. 40 | 3. If you've changed APIs, update the documentation. 41 | 4. Add an entry to the ``CHANGELOG.md`` for any breaking changes, enhancements, or bug fixes. 42 | 43 | 44 | Releasing to PyPI 45 | ----------------- 46 | 47 | Travis CI handles releasing package versions to PyPI. 48 | 49 | soql uses `semantic versions `_. Once you know the appropriate version part to bump, use the ``bumpversion`` tool to bump the package version, add a commit, and tag the commit appropriately: 50 | 51 | .. code-block:: bash 52 | 53 | git checkout master 54 | bumpversion MINOR 55 | 56 | Then push the new commit and tags to master: 57 | 58 | .. code-block:: bash 59 | 60 | git push origin master --tags 61 | 62 | Voila. 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present PlanGrid 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | SOQL 2 | ==== 3 | 4 | .. image:: https://travis-ci.org/plangrid/soql.svg?branch=master 5 | :target: https://travis-ci.org/plangrid/soql 6 | :alt: CI Status 7 | 8 | .. image:: https://badge.fury.io/py/soql.svg 9 | :target: https://badge.fury.io/py/soql 10 | :alt: PyPI status 11 | 12 | | 13 | 14 | This package provides declarative models for Salesforce objects and utilities for generating `Salesforce Object Query Language (SOQL) `_ queries from these models. 15 | 16 | This package works well with `Simple Salesforce `_. 17 | 18 | 19 | Usage 20 | ----- 21 | 22 | .. code-block:: python 23 | 24 | from simple_salesforce import Salesforce 25 | from soql import attributes 26 | from soql import load_models_from_salesforce_data 27 | from soql import Model 28 | from soql import select 29 | 30 | 31 | class Account(Model): 32 | id = attributes.String('Id') 33 | deleted = attributes.Boolean('IsDeleted') 34 | name = attributes.String('Name') 35 | owner = attributes.Relationship('Owner', related_model=User) 36 | custom_field = attributes.String('CustomField__c', nullable=True) 37 | 38 | class User(Model): 39 | id = attributes.String('Id') 40 | email = attributes.String('Email') 41 | 42 | sf = Salesforce(...) 43 | 44 | query = select(Account) \ 45 | .where(Account.id == '50130000000014c') \ 46 | .join(Account.owner) 47 | 48 | resp = sf.query(str(query)) 49 | 50 | account = load_models_from_salesforce_data(resp)[0] 51 | 52 | print(account.id) 53 | print(account.owner.id) 54 | 55 | 56 | Models 57 | ~~~~~~ 58 | 59 | Models define in-memory representations of Salesforce object, and provide an idiomatic way to access the data. 60 | 61 | .. code-block:: python 62 | 63 | from soql import attributes 64 | from soql import Model 65 | 66 | class User(Model): 67 | # The first argument to an attribute is its name in Salesforce. 68 | id = attributes.String('Id') 69 | email = attributes.String('Email') 70 | 71 | user = User(id='123', email='a@b.com') 72 | 73 | assert user.id == '123' 74 | 75 | Helpers are available to load models directly from ``simple_salesforce``: 76 | 77 | .. code-block:: python 78 | 79 | query = select(User) 80 | 81 | resp = sf.query(str(query)) 82 | 83 | users = load_models_from_salesforce_data(resp) 84 | 85 | Relationships can also be declared: 86 | 87 | .. code-block:: python 88 | 89 | class Account(Model): 90 | id = attributes.String('Id') 91 | owner = attributes.Relationship('Owner', related_model=User) 92 | contacts = attributes.Relationship('Contacts', related_model=User, many=True, nullable=True) 93 | 94 | Queries 95 | ~~~~~~~ 96 | 97 | SOQL queries can be generated from models: 98 | 99 | .. code-block:: python 100 | 101 | from soql import select 102 | 103 | query = select(User).where(User.id == '123') 104 | 105 | assert str(query) == "SELECT User.Id, User.Email FROM User WHERE User.Id = '123'" 106 | 107 | Most of SOQL is supported, including... 108 | 109 | Joins: 110 | 111 | .. code-block:: python 112 | 113 | from soql import select 114 | 115 | query = select(Account).join(Account.contacts) 116 | 117 | assert str(query) == "SELECT Account.Id, (SELECT User.Id, User.Email FROM Account.Contacts) FROM Account" 118 | 119 | Subqueries: 120 | 121 | .. code-block:: python 122 | 123 | from soql import select 124 | 125 | subquery = select(User).columns(User.email).subquery() 126 | query = select(User).where(User.email.in_(subquery)) 127 | 128 | assert str(query) == "SELECT User.Id, User.Email FROM User WHERE User.Email IN (SELECT User.Email FROM User)" 129 | 130 | And more! 131 | 132 | 133 | Installation 134 | ------------ 135 | 136 | .. code-block:: 137 | 138 | pip install soql 139 | 140 | 141 | Contributing 142 | ------------ 143 | 144 | There is still work to be done, and contributions are encouraged! Check out the `contribution guide `_ for more information. 145 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 40.0.4", 4 | "setuptools_scm >= 2.0.0, <4", 5 | "wheel >= 0.29.0", 6 | ] 7 | build-backend = 'setuptools.build_meta' -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | 3 | -e . 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | 5 | if __name__ == '__main__': 6 | setup( 7 | name='soql', 8 | version='1.2.0', 9 | author='Barak Alon', 10 | author_email='barak.s.alon@gmail.com', 11 | description='Models and query generator for Salesforce Object Query Language (SOQL)', 12 | long_description=open('README.rst').read(), 13 | keywords=['salesforce', 'soql', 'salesforce.com'], 14 | license='MIT', 15 | packages=['soql'], 16 | install_requires=[ 17 | 'python-dateutil', 18 | 'six', 19 | ], 20 | zip_safe=True, 21 | url='https://github.com/plangrid/soql', 22 | classifiers=[ 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 2', 28 | 'Programming Language :: Python :: 2.7', 29 | 'Programming Language :: Python :: 3', 30 | 'Programming Language :: Python :: 3.4', 31 | 'Programming Language :: Python :: 3.5', 32 | 'Programming Language :: Python :: 3.6', 33 | 'Topic :: Software Development :: Libraries :: Python Modules', 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /soql/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from soql.attributes import ( 4 | Boolean, 5 | Date, 6 | DateTime, 7 | Float, 8 | Integer, 9 | NullSalesforceColumnError, 10 | Relationship, 11 | String, 12 | ) 13 | 14 | from soql.loaders import ( 15 | load_model_from_salesforce_data, 16 | load_models_from_salesforce_data, 17 | get_total_count, 18 | ) 19 | 20 | from soql.model import ( 21 | AttributeNotLoaded, 22 | AttributeNotSet, 23 | ExpectedColumnMissing, 24 | Model, 25 | ) 26 | 27 | from soql.model_registry import ( 28 | ModelNotRegistered, 29 | ModelNotRegistered, 30 | model_registry 31 | ) 32 | 33 | from soql.nodes import ( 34 | asc, 35 | desc, 36 | nulls_first, 37 | nulls_last, 38 | ) 39 | 40 | from soql.select import ( 41 | select, 42 | SelectClauseIsntValidSubquery, 43 | ) 44 | -------------------------------------------------------------------------------- /soql/attributes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Attributes 3 | ~~~~~~~~~~ 4 | 5 | Attributes for declarative models. 6 | 7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS. 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | from __future__ import absolute_import 11 | from datetime import datetime, date 12 | 13 | from dateutil.parser import parse 14 | from dateutil.tz import tzutc 15 | 16 | from soql.model_registry import model_registry 17 | from soql.loaders import load_models_from_salesforce_data 18 | from soql.loaders import load_model_from_salesforce_data 19 | 20 | 21 | class NullSalesforceColumnError(Exception): 22 | def __init__(self, attribute): 23 | self.attribute = attribute 24 | msg = '{attribute} is unexpectedly null'.format( 25 | attribute=attribute.salesforce_name, 26 | ) 27 | super(NullSalesforceColumnError, self).__init__(msg) 28 | 29 | 30 | class AttributeBase(object): 31 | def __init__(self, salesforce_name, nullable=False): 32 | """ 33 | Base Attribute class for declarative models. 34 | 35 | :param str salesforce_name: This should be the attribute name as it 36 | is in Salesforce. The ORM will use this when loading data from 37 | the API and for compiling SOQL statements. 38 | :param bool nullable: If True, this field can be None. 39 | """ 40 | self.salesforce_name = salesforce_name 41 | self.nullable = nullable 42 | 43 | def load(self, value): 44 | """ 45 | This is called when loading data from the Salesforce API, and can be 46 | used to pre process data before coercion. 47 | 48 | :param value: the attribute value coming from Salesforce (note - this 49 | value will have already been JSON loaded) 50 | :return: the processed value. 51 | """ 52 | return value 53 | 54 | def coerce(self, value): 55 | """ 56 | This is called on model instantiation and is used to coerce the value 57 | to the attribute's type. 58 | 59 | :param value: the pre-processed attribute value. 60 | :return: the coerced value. 61 | """ 62 | if self.nullable and value is None: 63 | return value 64 | if not self.nullable and value is None: 65 | raise NullSalesforceColumnError(attribute=self) 66 | else: 67 | return self._coerce(value=value) 68 | 69 | def _coerce(self, value): 70 | return value 71 | 72 | 73 | class Relationship(AttributeBase): 74 | def __init__(self, salesforce_name, related_model, many=False, nullable=False): 75 | """ 76 | This special attribute represents a relationship to another model. 77 | 78 | :param salesforce_name: 79 | :param admin_api_2.salesforce.orm.model.Model|str related_model: The 80 | object type this is a relationship to. The Model itself or a string 81 | can be used - the latter of which allows for circular references. 82 | :param many: If False (default), this represents a many-to-one 83 | relationship. If True, this represents a one-to-many relationship. 84 | :param bool nullable: If True, this field can be None. 85 | """ 86 | super(Relationship, self).__init__(salesforce_name=salesforce_name, nullable=nullable) 87 | self._related_model = related_model 88 | self.many = many 89 | 90 | @property 91 | def related_model(self): 92 | if isinstance(self._related_model, str): 93 | # Looks like the related model is a string.... that's ok, as it 94 | # allows for back references. Fetch the model from the registry. 95 | return model_registry.get_by_model_name(self._related_model) 96 | return self._related_model 97 | 98 | def load(self, value): 99 | if self.many and value is None: 100 | return [] 101 | if self.many: 102 | return load_models_from_salesforce_data(data=value) 103 | if value is None: 104 | return None 105 | return load_model_from_salesforce_data(data=value) 106 | 107 | 108 | class Column(AttributeBase): 109 | def serialize(self, value): 110 | return value 111 | 112 | 113 | class String(Column): 114 | def _coerce(self, value): 115 | try: 116 | return str(value) 117 | except UnicodeEncodeError: 118 | return value 119 | 120 | 121 | class Integer(Column): 122 | def _coerce(self, value): 123 | return int(value) 124 | 125 | 126 | class Float(Column): 127 | def _coerce(self, value): 128 | return float(value) 129 | 130 | 131 | class Boolean(Column): 132 | def _coerce(self, value): 133 | return bool(value) 134 | 135 | 136 | class Date(Column): 137 | def _coerce(self, value): 138 | if isinstance(value, datetime): 139 | return date( 140 | year=value.year, 141 | month=value.month, 142 | day=value.day 143 | ) 144 | if isinstance(value, date): 145 | return value 146 | else: 147 | dt = parse(value) 148 | return date( 149 | year=dt.year, 150 | month=dt.month, 151 | day=dt.day 152 | ) 153 | 154 | def serialize(self, value): 155 | return value.isoformat() 156 | 157 | 158 | class DateTime(Column): 159 | def _coerce(self, value): 160 | if isinstance(value, datetime): 161 | if not value.tzinfo: 162 | # Assume timezone naive datetimes are UTC 163 | return value.replace(tzinfo=tzutc()) 164 | return value 165 | if isinstance(value, date): 166 | return datetime( 167 | year=value.year, 168 | month=value.month, 169 | day=value.day, 170 | tzinfo=tzutc() 171 | ) 172 | else: 173 | return self.coerce(parse(value)) 174 | 175 | def serialize(self, value): 176 | return value.isoformat() 177 | -------------------------------------------------------------------------------- /soql/loaders.py: -------------------------------------------------------------------------------- 1 | """ 2 | Loaders 3 | ~~~~~~~ 4 | 5 | Utilities for loading models from Salesforce API data. 6 | 7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS. 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | from __future__ import absolute_import 11 | 12 | from soql.model_registry import model_registry 13 | 14 | 15 | def load_model_from_salesforce_data(data): 16 | """ 17 | Loads a dictionary representing a single Salesforce object into a model. 18 | 19 | :param dict data: data from the Salesforce API 20 | :rtype: admin_api_2.salesforce.orm.model.Model 21 | """ 22 | object_type = data['attributes']['type'] 23 | model = model_registry.get_by_salesforce_object_name(name=object_type) 24 | return model.load(data) 25 | 26 | 27 | def load_models_from_salesforce_data(data): 28 | """ 29 | Loads a dictionary representing multiple Salesforce objects into a models. 30 | 31 | :param dict data: data from the Salesforce API 32 | :rtype: list 33 | """ 34 | return list(map(load_model_from_salesforce_data, data['records'])) 35 | 36 | 37 | def get_total_count(data): 38 | """ 39 | Retrieves the total count from a Salesforce SOQL query. 40 | 41 | :param dict data: data from the Salesforce API 42 | :rtype: int 43 | """ 44 | return data['totalSize'] 45 | -------------------------------------------------------------------------------- /soql/model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model 3 | ~~~~~ 4 | 5 | Base class for declarative models. 6 | 7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS. 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | 11 | from __future__ import absolute_import 12 | from collections import OrderedDict 13 | from copy import copy 14 | 15 | from six import with_metaclass 16 | 17 | from soql.attributes import AttributeBase 18 | from soql.attributes import Column 19 | from soql.attributes import Relationship 20 | from soql.model_registry import model_registry 21 | from soql.path_builder import PathBuilder 22 | 23 | 24 | class UNLOADED(object): 25 | """Placeholder for a model attribute value for data that was never loaded 26 | from the API.""" 27 | pass 28 | 29 | 30 | class UNSET(object): 31 | """Placeholder for a model attribute value for data that was never set.""" 32 | pass 33 | 34 | 35 | class AttributeNotLoaded(Exception): 36 | pass 37 | 38 | 39 | class AttributeNotSet(Exception): 40 | pass 41 | 42 | 43 | class ExpectedColumnMissing(Exception): 44 | pass 45 | 46 | 47 | class _ModelMeta(type): 48 | """ 49 | Turns boring classes into magical, declarative style models! 50 | """ 51 | def __new__(mcs, name, bases, cls_attrs): 52 | """ 53 | Metaclasses are classes for classes... which means this method is 54 | responsible for creating a new class. That means we can hijack the 55 | class declaration to do magical things... 56 | 57 | :param name: The name of the class being declared 58 | :param bases: The base classes the class is being declared with 59 | :param cls_attrs: A dictionary of attributes the class is being declared with 60 | :return: the new class 61 | """ 62 | # This handles inheritance by crawling through all the base classes 63 | # and grabbing their declared attributes. 64 | inherited_attributes = OrderedDict() 65 | for base_ in bases: 66 | if hasattr(base_, 'declared_attrs'): 67 | inherited_attributes.update( 68 | copy(getattr(base_, 'declared_attrs'))) 69 | 70 | # Find any attributes that are declared in the model. 71 | # It helps testability tremendously if the attributes come in a reliable 72 | # order... hence the sorting and OrderedDict 73 | attributes = OrderedDict() 74 | for key in sorted(cls_attrs.keys()): 75 | if isinstance(cls_attrs[key], AttributeBase): 76 | attributes[key] = cls_attrs[key] 77 | 78 | # Pop them out of the attribute dictionary so they don't get declared 79 | # as normal attributes.... 80 | for key, attr in attributes.items(): 81 | cls_attrs.pop(key) 82 | 83 | # Now that the attributes have been pruned, we can create the class 84 | klass = super(_ModelMeta, mcs).__new__(mcs, name, bases, cls_attrs) 85 | 86 | # We'll attach those attributes, along with the attributes from the 87 | # inherited classes, to a single attribute. 88 | setattr(klass, 'declared_attrs', OrderedDict()) 89 | klass.declared_attrs.update(inherited_attributes) 90 | klass.declared_attrs.update(attributes) 91 | 92 | # Finally, register the model in the model registry. 93 | if not cls_attrs.get('__abstract__'): 94 | model_registry.add(klass) 95 | 96 | return klass 97 | 98 | def __getattr__(self, item): 99 | """ 100 | This little bit of magic helps us do things like: 101 | 102 | select(SomeModel).where(SomeModel.name == 'BLAM!') 103 | 104 | While still having instances of the model return the attribute's value: 105 | 106 | assert SomeModel().name == 'BLAM!' 107 | 108 | (Note - because this is a meta class, "self" is the model) 109 | """ 110 | if item not in self.declared_attrs: 111 | raise AttributeError("{} has no attribute '{}'".format(self, item)) 112 | 113 | return PathBuilder(model=self).extend_path(item=item) 114 | 115 | 116 | class Model(with_metaclass(_ModelMeta)): 117 | """ 118 | Declarative style models! 119 | 120 | Example usage: 121 | 122 | class SomeSalesforceObject(Model): 123 | # Optionally specify the name of the object as known by Salesforce. 124 | # This defaults to the class name. 125 | __salesforce_object_name__ = 'UglyAssName__c' 126 | 127 | id = attributes.String('Id') 128 | name = attributes.String('Name') 129 | 130 | instance = SomeSalesforceObject(id='123', name='BLAM!') 131 | assert instance.id == '123' 132 | """ 133 | def __init__(self, **kwargs): 134 | """ 135 | Constructs an instance of the Model. 136 | 137 | :param kwargs: attributes to set on the instance. These should map to 138 | the model attribute names, NOT the names as known by Salesforce. 139 | """ 140 | self._instance_attrs = {} 141 | self._changed_attrs = set() 142 | 143 | # Crawl through the attributes declared in the class, pulling them 144 | # out of the provided kwargs and setting them as instance attributes. 145 | for key, attr in self.declared_attrs.items(): 146 | if key not in kwargs: 147 | self._instance_attrs[key] = UNSET 148 | elif kwargs[key] is UNLOADED: 149 | self._instance_attrs[key] = UNLOADED 150 | else: 151 | self._instance_attrs[key] = attr.coerce(kwargs[key]) 152 | 153 | def __getattr__(self, item): 154 | """ 155 | Any attempts to retrieve to an attribute that doesn't seem to exist 156 | is redirected to the the model's mapped attributes. 157 | """ 158 | if item not in self._instance_attrs: 159 | raise AttributeError("{} has no attribute '{}'".format(self, item)) 160 | 161 | value = self._instance_attrs[item] 162 | if value is UNLOADED: 163 | raise AttributeNotLoaded(item) 164 | if value is UNSET: 165 | raise AttributeNotSet(item) 166 | return value 167 | 168 | def __setattr__(self, key, value): 169 | if key == '_instance_attrs' or key not in self._instance_attrs: 170 | super(Model, self).__setattr__(key, value) 171 | elif isinstance(self.__class__.declared_attrs[key], Relationship): 172 | super(Model, self).__setattr__(key, value) 173 | else: 174 | coerced = self.__class__.declared_attrs[key].coerce(value) 175 | if coerced != getattr(self, key): 176 | self._changed_attrs.add(key) 177 | 178 | self._instance_attrs[key] = coerced 179 | 180 | def __eq__(self, other): 181 | """ 182 | Compares a model instance to another model instance. 183 | 184 | :param other: the other model instance being compared 185 | :rtype: bool 186 | """ 187 | if not isinstance(other, self.__class__): 188 | return False 189 | return self._instance_attrs == other._instance_attrs 190 | 191 | @classmethod 192 | def __soql_name__(cls): 193 | """ 194 | Returns the name of the model, as known by Salesforce. 195 | 196 | :rtype: str 197 | """ 198 | return getattr(cls, '__salesforce_object_name__', cls.__name__) 199 | 200 | @classmethod 201 | def iter_columns(cls): 202 | """ 203 | Iterates over the column attributes of a model (ignoring nasty things 204 | like relationships. Yuck.). 205 | """ 206 | for key, attr in cls.declared_attrs.items(): 207 | if isinstance(attr, Column): 208 | yield key 209 | 210 | @classmethod 211 | def load(cls, data): 212 | """ 213 | Constructs an instance of the Model from Salesforce API data. 214 | 215 | :param dict data: dictionary of data coming straight from the 216 | Salesforce API. 217 | :rtype: admin_api_2.salesforce.orm.model.Model 218 | """ 219 | attrs = {} 220 | for key, attr in cls.declared_attrs.items(): 221 | if isinstance(attr, Column) and attr.salesforce_name not in data: 222 | # Hard fail if we were expecting a column from the API and 223 | # it didn't show up 224 | msg = "Expected {} for model {}. Didn't find that in: {}" \ 225 | "".format(attr.salesforce_name, cls, data) 226 | raise ExpectedColumnMissing(msg) 227 | elif isinstance(attr, Relationship) and attr.salesforce_name not in data: 228 | # Relationships, on the other hand, have to be explicitly 229 | # joined and its ok for them to be missing. 230 | attrs[key] = UNLOADED 231 | else: 232 | attrs[key] = attr.load(data[attr.salesforce_name]) 233 | 234 | return cls(**attrs) 235 | 236 | def changes(self): 237 | model = self.__class__ 238 | 239 | changes = {} 240 | for key in self._changed_attrs: 241 | attr = model.declared_attrs[key] 242 | value = attr.serialize(getattr(self, key)) 243 | changes[attr.salesforce_name] = value 244 | 245 | return changes 246 | 247 | def reset_instance_state(self): 248 | self._changed_attrs = set() 249 | -------------------------------------------------------------------------------- /soql/model_registry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model Registry 3 | ~~~~~~~~~~~~~~ 4 | 5 | Global registry for models. 6 | 7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS. 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | 11 | class ModelAlreadyRegistered(Exception): 12 | pass 13 | 14 | 15 | class ModelNotRegistered(Exception): 16 | pass 17 | 18 | 19 | class ModelRegistry(object): 20 | """A simple store for all models that have been declared.""" 21 | 22 | def __init__(self): 23 | # To avoid O(n) lookups, We'll maintain a mapping from the model's 24 | # class name to the model AND a mapping from the model's associated 25 | # Saleforce object name to the model. 26 | self._registry_by_sf_obj_name = {} 27 | self._registry_by_model_name = {} 28 | 29 | def add(self, model): 30 | """ 31 | Adds a model to the store. 32 | 33 | :param admin_api_2.salesforce.orm.model.Model model: 34 | """ 35 | salesforce_object_name = model.__soql_name__() 36 | model_name = model.__name__ 37 | 38 | if salesforce_object_name in self._registry_by_sf_obj_name: 39 | raise ModelAlreadyRegistered( 40 | 'Salesforce object name: {}'.format(salesforce_object_name) 41 | ) 42 | if model_name in self._registry_by_model_name: 43 | raise ModelAlreadyRegistered( 44 | 'Model name: {}'.format(model_name) 45 | ) 46 | 47 | self._registry_by_sf_obj_name[salesforce_object_name] = model 48 | self._registry_by_model_name[model_name] = model 49 | 50 | def get_by_salesforce_object_name(self, name): 51 | """ 52 | Retrieves a model from the registry with the object's name as known 53 | by Salesforce. 54 | 55 | :param str name: 56 | :rtype: admin_api_2.salesforce.orm.model.Model 57 | """ 58 | if name not in self._registry_by_sf_obj_name: 59 | raise ModelNotRegistered('Salesforce object name: {}'.format(name)) 60 | return self._registry_by_sf_obj_name[name] 61 | 62 | def get_by_model_name(self, name): 63 | """ 64 | Retrieves a model from the registry with model's class name. 65 | 66 | :param str name: 67 | :rtype: admin_api_2.salesforce.orm.model.Model 68 | """ 69 | if name not in self._registry_by_model_name: 70 | raise ModelNotRegistered('Model name: {}'.format(name)) 71 | return self._registry_by_model_name[name] 72 | 73 | 74 | model_registry = ModelRegistry() 75 | -------------------------------------------------------------------------------- /soql/nodes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Nodes 3 | ~~~~~ 4 | 5 | Nodes for using composite pattern to implement a query builder. 6 | 7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS. 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | 11 | from __future__ import absolute_import 12 | from __future__ import unicode_literals 13 | from datetime import datetime 14 | 15 | import six 16 | from dateutil.tz import tzutc 17 | 18 | from soql.utils import AttrDict 19 | from soql.utils import to_unicode 20 | 21 | 22 | # The various operations you can perform in SOQL 23 | OPS = AttrDict( 24 | AND='AND', 25 | OR='OR', 26 | NOT='NOT', 27 | EQ='=', 28 | LT='<', 29 | LTE='<=', 30 | GT='>', 31 | GTE='>=', 32 | NE='!=', 33 | IN='IN', 34 | NOT_IN='NOT IN', 35 | IS='IS', 36 | IS_NOT='IS NOT', 37 | LIKE='LIKE', 38 | INCLUDES='INCLUDES', 39 | EXCLUDES='EXCLUDES' 40 | ) 41 | 42 | # Reserved SOQL words 43 | WORDS = AttrDict( 44 | SELECT='SELECT', 45 | FROM='FROM', 46 | WHERE='WHERE', 47 | GROUP_BY='GROUP BY', 48 | ORDER_BY='ORDER BY', 49 | LIMIT='LIMIT', 50 | OFFSET='OFFSET', 51 | ASC='ASC', 52 | DESC='DESC', 53 | NULL='NULL', 54 | TRUE='TRUE', 55 | FALSE='FALSE', 56 | NULLS_FIRST='NULLS FIRST', 57 | NULLS_LAST='NULLS LAST' 58 | ) 59 | 60 | # SOQL functions 61 | FUNCTIONS = AttrDict( 62 | AVG='AVG', 63 | COUNT='COUNT', 64 | COUNT_DISTINCT='COUNT_DISTINCT', 65 | MIN='MIN', 66 | MAX='MAX', 67 | SUM='SUM' 68 | ) 69 | 70 | 71 | def _stringify(value): 72 | """Converts a native Python object to its SOQL representation.""" 73 | if isinstance(value, six.string_types): 74 | return "'{}'".format(value) 75 | if value is None: 76 | return WORDS.NULL 77 | if value is True: 78 | return WORDS.TRUE 79 | if value is False: 80 | return WORDS.FALSE 81 | if isinstance(value, datetime): 82 | if not value.tzinfo: 83 | return value.replace(tzinfo=tzutc()).isoformat() 84 | else: 85 | return value.isoformat() 86 | return str(value) 87 | 88 | 89 | class Node(object): 90 | """Base node for building a graph representation of a SOQL query.""" 91 | def __init__(self, child_nodes, sep=''): 92 | self.child_nodes = child_nodes 93 | self.sep = sep 94 | 95 | def _as_string(self, method): 96 | return self.sep.join([method(node) for node in self.child_nodes]) 97 | 98 | def __unicode__(self): 99 | return to_unicode(self._as_string(to_unicode)) 100 | 101 | def __str__(self): 102 | return str(self._as_string(str)) 103 | 104 | 105 | class Grouped(Node): 106 | def __init__(self, node, braces='()'): 107 | super(Grouped, self).__init__(child_nodes=[braces[0], node, braces[1]]) 108 | 109 | 110 | class Expression(Node): 111 | def __init__(self, lhs, op, rhs): 112 | child_nodes = [_stringify(lhs), op, _stringify(rhs)] 113 | super(Expression, self).__init__(child_nodes=child_nodes, sep=' ') 114 | 115 | 116 | class CommaSeparated(Node): 117 | def __init__(self, items): 118 | super(CommaSeparated, self).__init__(child_nodes=items, sep=', ') 119 | 120 | 121 | class AndSeparated(Node): 122 | def __init__(self, items): 123 | sep = ' {} '.format(OPS.AND) 124 | super(AndSeparated, self).__init__(child_nodes=items, sep=sep) 125 | 126 | 127 | class Array(Node): 128 | def __init__(self, items): 129 | array = Grouped(node=CommaSeparated(items=items)) 130 | super(Array, self).__init__(child_nodes=[array]) 131 | 132 | 133 | class Function(Node): 134 | def __init__(self, function_name, args=None): 135 | child_nodes = [function_name, Array(args or [])] 136 | super(Function, self).__init__(child_nodes=child_nodes, sep='') 137 | 138 | 139 | class Count(Function): 140 | def __init__(self, field_name=None): 141 | args = [field_name] if field_name is not None else [] 142 | super(Count, self).__init__(function_name=FUNCTIONS.COUNT, args=args) 143 | 144 | 145 | class SelectClause(Node): 146 | def __init__( 147 | self, 148 | select_columns, 149 | from_table, 150 | where_conditions=None, 151 | order_bys=None, 152 | limit=None, 153 | offset=None 154 | ): 155 | nodes = [ 156 | WORDS.SELECT, 157 | CommaSeparated(items=select_columns), 158 | WORDS.FROM, 159 | from_table 160 | ] 161 | 162 | if where_conditions: 163 | nodes.append(WORDS.WHERE) 164 | nodes.append(AndSeparated(items=where_conditions)) 165 | 166 | if order_bys: 167 | nodes.append(WORDS.ORDER_BY) 168 | nodes.append(CommaSeparated(items=order_bys)) 169 | 170 | if limit is not None: 171 | nodes.append(WORDS.LIMIT) 172 | nodes.append(Node(child_nodes=[limit])) 173 | 174 | if offset is not None: 175 | nodes.append(WORDS.OFFSET) 176 | nodes.append(Node(child_nodes=[offset])) 177 | 178 | super(SelectClause, self).__init__(child_nodes=nodes, sep=' ') 179 | 180 | 181 | class OrderByClause(Node): 182 | def __init__(self, column, direction=None, nulls_position=None): 183 | child_nodes = [column] 184 | if direction: 185 | child_nodes.append(direction) 186 | if nulls_position: 187 | child_nodes.append(nulls_position) 188 | super(OrderByClause, self).__init__(child_nodes=child_nodes, sep=' ') 189 | 190 | 191 | class SubqueryClause(Node): 192 | def __init__( 193 | self, 194 | select_columns, 195 | from_table, 196 | where_conditions=None 197 | ): 198 | query = SelectClause( 199 | select_columns=select_columns, 200 | from_table=from_table, 201 | where_conditions=where_conditions 202 | ) 203 | grouped = Grouped(node=query) 204 | super(SubqueryClause, self).__init__(child_nodes=[grouped]) 205 | 206 | 207 | def _op_factory(op): 208 | """Quick little factory for a little bit of DRY when defining magic methods 209 | on Operatable.""" 210 | def inner(self, rhs): 211 | return Expression(lhs=self, op=op, rhs=rhs) 212 | return inner 213 | 214 | 215 | def _get_container_node(value): 216 | """Used for building container expressions - the input could either be 217 | a Python iterable, or another node.""" 218 | if isinstance(value, Node): 219 | # This looks like a subquery! 220 | return value 221 | else: 222 | return Array(items=[_stringify(i) for i in value]) 223 | 224 | 225 | class Operatable(object): 226 | """A mixin to give a node operation functionality. I.e., this is where 227 | some of the magic happens, and allows for the nice filter syntax: 228 | 229 | SomeModel.name == 'John' 230 | SomeModel.id.in_([1, 2, 3]) 231 | etc. 232 | """ 233 | __eq__ = _op_factory(OPS.EQ) 234 | __ne__ = _op_factory(OPS.NE) 235 | __gt__ = _op_factory(OPS.GT) 236 | __ge__ = _op_factory(OPS.GTE) 237 | __lt__ = _op_factory(OPS.LT) 238 | __le__ = _op_factory(OPS.LTE) 239 | 240 | def like_(self, value): 241 | return Expression(lhs=self, op=OPS.LIKE, rhs=value) 242 | 243 | def in_(self, iterable): 244 | container = _get_container_node(value=iterable) 245 | return Expression(lhs=self, op=OPS.IN, rhs=container) 246 | 247 | def not_in_(self, iterable): 248 | container = _get_container_node(value=iterable) 249 | return Expression(lhs=self, op=OPS.NOT_IN, rhs=container) 250 | 251 | def includes_(self, iterable): 252 | container = _get_container_node(value=iterable) 253 | return Expression(lhs=self, op=OPS.INCLUDES, rhs=container) 254 | 255 | def excludes_(self, iterable): 256 | container = _get_container_node(value=iterable) 257 | return Expression(lhs=self, op=OPS.EXCLUDES, rhs=container) 258 | 259 | 260 | class ColumnPath(Node, Operatable): 261 | def __init__(self, *elements): 262 | super(ColumnPath, self).__init__(child_nodes=elements, sep='.') 263 | 264 | 265 | def _logical_expression(nodes, op): 266 | expression = Expression(lhs=nodes[0], op=op, rhs=nodes[1]) 267 | for node in nodes[2:]: 268 | expression = Expression(lhs=expression, op=op, rhs=node) 269 | return Grouped(node=expression) 270 | 271 | 272 | def and_(*nodes): 273 | return _logical_expression(nodes=nodes, op=OPS.AND) 274 | 275 | 276 | def or_(*nodes): 277 | return _logical_expression(nodes=nodes, op=OPS.OR) 278 | 279 | 280 | def not_(node): 281 | return Node(child_nodes=[OPS.NOT, node], sep=' ') 282 | 283 | 284 | # These values are needed publicly, so we'll create some nice aliases. 285 | asc = WORDS.ASC 286 | desc = WORDS.DESC 287 | nulls_first = WORDS.NULLS_FIRST 288 | nulls_last = WORDS.NULLS_LAST 289 | 290 | 291 | -------------------------------------------------------------------------------- /soql/path_builder.py: -------------------------------------------------------------------------------- 1 | """ 2 | Path Builder 3 | ~~~~~~~~~~~~ 4 | 5 | Helper for building relationship queries. 6 | 7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS. 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | from __future__ import absolute_import 11 | 12 | from soql.attributes import Relationship 13 | from soql.nodes import ColumnPath 14 | from soql.utils import to_unicode 15 | 16 | 17 | class PathBuilder(object): 18 | """ 19 | Tracks chained model-relationship references and exposes helper methods 20 | for interpreting the chain. 21 | """ 22 | def __init__(self, model, path=None): 23 | """ 24 | El constructor. 25 | 26 | :param admin_api_2.salesforce.orm.model.Model model: 27 | :param list path: Optionally instantiate the instance with an existing 28 | path. This is useful when chaining relationships. 29 | """ 30 | self.model = model 31 | self.path = path or [] 32 | 33 | def _iterate_path(self, path_index): 34 | """ 35 | Iterates through the path, starting from the base model to the last 36 | relationship reference. 37 | 38 | This yields tuples of the relationship's model and the attribute 39 | for the relationship. 40 | 41 | For example, if we had..... 42 | 43 | 44 | relationship = attributes.Relationship('Parent') 45 | 46 | class SomeModel(Model): 47 | id = attributes.String('Id') 48 | parent = relationship 49 | 50 | path_builder = PathBuilder(SomeModel)\ 51 | .extend_path(parent)\ 52 | .extend_path(parent) 53 | 54 | This would yield..... 55 | 56 | (SomeModel, relationship) 57 | (SomeModel, relationship) 58 | 59 | :param integer path_index: Only iterate up to this point in the path. 60 | Use -1 to iterate over the entire path. 61 | """ 62 | if path_index == -1: 63 | # -1 + 1 = 0... but -1 means we want to iterate through the entire 64 | # path, so we'll check for this special case. 65 | inclusive_index = None 66 | else: 67 | inclusive_index = path_index + 1 68 | 69 | last_model = self.model 70 | 71 | for relationship in self.path[0:inclusive_index]: 72 | model_relationship = last_model.declared_attrs[relationship] 73 | last_model = model_relationship.related_model 74 | yield last_model, model_relationship 75 | 76 | def _build_soql_column_path(self): 77 | """This builds the SOQL representation of this path.""" 78 | # The column path starts with the name of the original object 79 | path = [self.model.__soql_name__()] 80 | 81 | for _, attr in self._iterate_path(path_index=-1): 82 | path.append(attr.salesforce_name) 83 | 84 | return path 85 | 86 | def get_model_in_path(self, path_index): 87 | """Get the model of a certain point in the relationship path.""" 88 | last_model = self.model 89 | for model, _ in self._iterate_path(path_index=path_index): 90 | last_model = model 91 | return last_model 92 | 93 | def get_relationship_attr_in_path(self, path_index): 94 | """Get the relationship attribute of a certain point in the 95 | relationship path.""" 96 | last_model_relationship_attr = None 97 | for _, attr in self._iterate_path(path_index=path_index): 98 | last_model_relationship_attr = attr 99 | return last_model_relationship_attr 100 | 101 | def get_column_node(self): 102 | """Builds a Node representing this path.""" 103 | return ColumnPath(*self._build_soql_column_path()) 104 | 105 | def get_last_model_column_nodes(self): 106 | """Returns SOQL representations of all the columns for the final 107 | model in the relationship path.""" 108 | last_model = self.get_model_in_path(path_index=-1) 109 | return [ 110 | ColumnPath(self.extend_path(attr)) 111 | for attr in last_model.iter_columns() 112 | ] 113 | 114 | def extend_path(self, item): 115 | """Extends the path with another reference. 116 | 117 | If that reference is another relationship, this returns a new instance. 118 | 119 | If that reference is column, we've reached the end of our path, and this 120 | returns a Colum node. 121 | """ 122 | last_model = self.get_model_in_path(path_index=-1) 123 | attr = last_model.declared_attrs[item] 124 | 125 | if isinstance(attr, Relationship): 126 | # Looks like someone is requesting another relationship, so we'll 127 | # keep extending the chain! 128 | return PathBuilder( 129 | model=self.model, 130 | path=self.path + [item] 131 | ) 132 | 133 | # Looks like someone is requesting a column, which is a leaf in a path, 134 | # so we'll return a column node. 135 | path = self._build_soql_column_path() + [attr.salesforce_name] 136 | return ColumnPath(*path) 137 | 138 | def __getattr__(self, item): 139 | """ 140 | This little bit of magic let's us do things like... 141 | 142 | select(SomeModel).where(SomeModel.relationship.name == 'BLAM!') 143 | """ 144 | return self.extend_path(item=item) 145 | 146 | def __unicode__(self): 147 | return to_unicode(self.get_column_node()) 148 | 149 | def __str__(self): 150 | return str(self.get_column_node()) 151 | -------------------------------------------------------------------------------- /soql/select.py: -------------------------------------------------------------------------------- 1 | """ 2 | Select 3 | ~~~~~~ 4 | 5 | Entrypoint for generating SOQL from models. 6 | 7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS. 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | 11 | from __future__ import absolute_import 12 | from collections import OrderedDict 13 | from copy import copy 14 | 15 | from soql.nodes import SubqueryClause 16 | from soql.nodes import OrderByClause 17 | from soql.nodes import Count 18 | from soql.nodes import SelectClause 19 | from soql.path_builder import PathBuilder 20 | from soql.utils import to_unicode 21 | 22 | 23 | class SelectClauseIsntValidSubquery(Exception): 24 | pass 25 | 26 | 27 | class RelationshipNode(object): 28 | """ 29 | Joins can be recursive. This little class let's us represent all the joined 30 | relationships of a select query as a graph. 31 | """ 32 | def __init__(self, relationship=None): 33 | """ 34 | Constructs a new node in a graph. 35 | 36 | :param str|NoneType relationship: The attribute name of the 37 | relationship. None represents the root node. 38 | """ 39 | self.relationship = relationship 40 | 41 | # It helps testability tremendously if the joins come in a 42 | # reliable order... hence the OrderedDict 43 | self.child_relationships = OrderedDict() 44 | 45 | def __copy__(self): 46 | node = RelationshipNode(relationship=self.relationship) 47 | node.child_relationships = copy(self.child_relationships) 48 | return node 49 | 50 | def __iter__(self): 51 | for value in self.child_relationships.values(): 52 | yield value 53 | 54 | def add_child(self, relationship): 55 | """Adds a child node.""" 56 | self.child_relationships[relationship] = RelationshipNode(relationship) 57 | 58 | def add_path(self, path): 59 | """Adds the complete path to this node, creating any child nodes that 60 | don't exist yet.""" 61 | node = self 62 | for relationship in path: 63 | if relationship not in node.child_relationships: 64 | node.add_child(relationship) 65 | node = node.child_relationships[relationship] 66 | 67 | 68 | class SelectClauseBuilder(object): 69 | """ 70 | Builds SOQL SELECT statements. 71 | """ 72 | def __init__(self, model): 73 | """ 74 | The model being selected from. 75 | 76 | :param admin_api_2.salesforce.orm.model.Model model: 77 | """ 78 | self._model = model 79 | self._columns = PathBuilder(model=self._model).get_last_model_column_nodes() 80 | self._relationship_graph = RelationshipNode() 81 | self._order_bys = [] 82 | self._filters = [] 83 | self._limit = None 84 | self._offset = None 85 | self._count = False 86 | 87 | def __copy__(self): 88 | builder = SelectClauseBuilder(model=self._model) 89 | builder._columns = copy(self._columns) 90 | builder._filters = copy(self._filters) 91 | builder._order_bys = copy(self._order_bys) 92 | builder._relationship_graph = copy(self._relationship_graph) 93 | builder._limit = self._limit 94 | builder._offset = self._offset 95 | builder._count = self._count 96 | return builder 97 | 98 | def join(self, path_builder): 99 | """ 100 | Add a join to the clause. 101 | 102 | :param path_builder: 103 | :rtype: SelectClauseBuilder 104 | """ 105 | self._relationship_graph.add_path(path_builder.path) 106 | return copy(self) 107 | 108 | def where(self, expression): 109 | """ 110 | Add a filter to the clause. 111 | 112 | :param expression: 113 | :rtype: SelectClauseBuilder 114 | """ 115 | self._filters.append(expression) 116 | return copy(self) 117 | 118 | def order_by(self, column, direction=None, nulls_position=None): 119 | """ 120 | Order the results of the clause. 121 | 122 | :param column: 123 | :param str direction: optionally declare the direction to order. 124 | :param str nulls_position: optionally declare whether the null values 125 | should be at the top or bottom of the results. 126 | :rtype: SelectClauseBuilder 127 | """ 128 | clause = OrderByClause( 129 | column=column, 130 | direction=direction, 131 | nulls_position=nulls_position 132 | ) 133 | self._order_bys.append(clause) 134 | 135 | return copy(self) 136 | 137 | def limit(self, limit): 138 | """ 139 | Limit the amount of results. 140 | 141 | :param limit: 142 | :rtype: SelectClauseBuilder 143 | """ 144 | self._limit = limit 145 | return copy(self) 146 | 147 | def offset(self, offset): 148 | """ 149 | Skip the first amount of results. 150 | 151 | :param offset: 152 | :rtype: SelectClauseBuilder 153 | """ 154 | self._offset = offset 155 | return copy(self) 156 | 157 | def count(self): 158 | """ 159 | Instead of fetching the results, just count the total. 160 | 161 | :rtype: SelectClauseBuilder 162 | """ 163 | self._count = True 164 | return copy(self) 165 | 166 | def columns(self, *columns): 167 | """ 168 | Override the columns that are selected. 169 | 170 | :param columns: 171 | :rtype: SelectClauseBuilder 172 | """ 173 | self._columns = list(columns) 174 | return copy(self) 175 | 176 | def subquery(self): 177 | """ 178 | Convert the clause to a subquery. This is useful for things like... 179 | 180 | subquery = select(SomeModel.id).where(SomeModel.id == 123).subquery() 181 | 182 | select(SomeOtherModel).where(SomeOtherModel.fk.in_(subquery)) 183 | 184 | :rtype: admin_api_2.salesforce.orm.soql.SubqueryClause 185 | """ 186 | if self._order_bys or self._limit or self._offset: 187 | raise SelectClauseIsntValidSubquery() 188 | 189 | return SubqueryClause( 190 | select_columns=self._get_columns(), 191 | from_table=self._model.__soql_name__(), 192 | where_conditions=self._filters 193 | ) 194 | 195 | def __unicode__(self): 196 | """Converts the instance to a SOQL string.""" 197 | return to_unicode(self._get_select_clause()) 198 | 199 | def __str__(self): 200 | return str(self._get_select_clause()) 201 | 202 | def _get_columns(self): 203 | if self._count: 204 | return [Count()] 205 | 206 | joins = self._compile_joins() 207 | return self._columns + joins 208 | 209 | def _get_select_clause(self): 210 | return SelectClause( 211 | select_columns=self._get_columns(), 212 | from_table=self._model.__soql_name__(), 213 | where_conditions=self._filters, 214 | order_bys=self._order_bys, 215 | limit=self._limit, 216 | offset=self._offset 217 | ) 218 | 219 | def _compile_joins(self): 220 | return self._recursively_compile_joins( 221 | path=PathBuilder(model=self._model), 222 | node=self._relationship_graph 223 | ) 224 | 225 | def _recursively_compile_joins(self, path, node): 226 | """ 227 | Depth-first-searches the relationship graph to build a SOQL 228 | representation of the joins. 229 | 230 | :param admin_api_2.salesforce.orm.path_builder.PathBuilder path: 231 | :param RelationshipNode node: 232 | :return: list of join clauses 233 | """ 234 | joins = [] 235 | 236 | for child_node in node: 237 | child_path = path.extend_path(child_node.relationship) 238 | attr = child_path.get_relationship_attr_in_path(path_index=-1) 239 | if attr.many: 240 | # Joining a one-to-many relationship needs to be done as a 241 | # subquery, where the subquery is scoped to the object being 242 | # joined. 243 | aggregate_join = self._recursively_compile_joins( 244 | path=PathBuilder(model=attr.related_model), 245 | node=child_node 246 | ) 247 | from_table = child_path.get_column_node() 248 | subquery = SubqueryClause( 249 | select_columns=aggregate_join, 250 | from_table=from_table, 251 | ) 252 | joins.append(subquery) 253 | 254 | else: 255 | column_joins = self._recursively_compile_joins( 256 | path=child_path, 257 | node=child_node 258 | ) 259 | 260 | # The joined columns need to come before any subqueries 261 | joins = column_joins + joins 262 | 263 | if node.relationship is None: 264 | # Looks like we're back at the root node. 265 | return joins 266 | 267 | joins = path.get_last_model_column_nodes() + joins 268 | return joins 269 | 270 | 271 | select = SelectClauseBuilder 272 | -------------------------------------------------------------------------------- /soql/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities 3 | ~~~~~~~~~ 4 | 5 | Random helpers. 6 | 7 | :copyright: Copyright 2018 PlanGrid, Inc., see AUTHORS. 8 | :license: MIT, see LICENSE for details. 9 | """ 10 | import sys 11 | 12 | 13 | class AttrDict(dict): 14 | """This is just a little convenience class to make dealing with 15 | static sets prettier.""" 16 | def __getattr__(self, attr): 17 | return self[attr] 18 | 19 | 20 | if sys.version_info[0] < 3: 21 | to_unicode = unicode 22 | else: 23 | to_unicode = str 24 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plangrid/soql/c0f371e846806c43b435457e1e2a2a70f6787409/tests/__init__.py -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | from soql.utils import to_unicode 2 | 3 | 4 | class SoqlAssertions(object): 5 | def assertSoqlEqual(self, node, soql): 6 | self.assertEqual(to_unicode(node), to_unicode(soql)) 7 | -------------------------------------------------------------------------------- /tests/test_attributes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals 4 | import unittest 5 | from datetime import datetime, date 6 | 7 | from dateutil.tz import tzutc 8 | 9 | from soql import attributes 10 | from soql import NullSalesforceColumnError 11 | from soql import Model 12 | 13 | 14 | class RelatedModel(Model): 15 | id = attributes.Integer('Id') 16 | 17 | 18 | class RelationshipTest(unittest.TestCase): 19 | def test_load(self): 20 | attr = attributes.Relationship('Attr', related_model=RelatedModel) 21 | data = { 22 | 'attributes': {'type': 'RelatedModel'}, 23 | 'Id': 1 24 | } 25 | self.assertEqual(attr.load(data), RelatedModel.load({'Id': 1})) 26 | 27 | def test_load_many(self): 28 | attr = attributes.Relationship('Attr', related_model=RelatedModel, many=True) 29 | data = { 30 | 'records': [ 31 | {'attributes': {'type': 'RelatedModel'}, 'Id': 1} 32 | ] 33 | } 34 | self.assertEqual(attr.load(data), [RelatedModel.load({'Id': 1})]) 35 | 36 | def test_load_when_relationship_is_none(self): 37 | attr = attributes.Relationship('Attr', related_model=RelatedModel) 38 | self.assertEqual(attr.load(None), None) 39 | 40 | def test_load_many_when_relationship_is_none(self): 41 | attr = attributes.Relationship('Attr', related_model=RelatedModel, many=True) 42 | self.assertEqual(attr.load(None), []) 43 | 44 | def test_coerce_when_relationship_is_nullable(self): 45 | attr = attributes.Relationship('Attr', related_model=RelatedModel, nullable=True) 46 | self.assertEqual(attr.coerce(None), None) 47 | 48 | def test_coerce_when_relationship_is_not_nullable(self): 49 | attr = attributes.Relationship('Attr', related_model=RelatedModel, nullable=False) 50 | self.assertRaises(NullSalesforceColumnError, attr.coerce, None) 51 | 52 | 53 | class StringTest(unittest.TestCase): 54 | def test_coerce(self): 55 | attr = attributes.String('Attr') 56 | self.assertEqual(attr.coerce(1), '1') 57 | self.assertEqual(attr.coerce('1'), '1') 58 | self.assertEqual(attr.coerce('È'), 'È') 59 | self.assertRaises(NullSalesforceColumnError, attr.coerce, None) 60 | 61 | def test_nullable(self): 62 | attr = attributes.String('Attr', nullable=True) 63 | self.assertIsNone(attr.coerce(None)) 64 | 65 | def test_serialize(self): 66 | attr = attributes.String('Attr') 67 | self.assertEqual(attr.serialize('1'), '1') 68 | 69 | 70 | class IntegerTest(unittest.TestCase): 71 | def test_coerce(self): 72 | attr = attributes.Integer('Attr') 73 | self.assertEqual(attr.coerce('1'), 1) 74 | self.assertEqual(attr.coerce(1), 1) 75 | self.assertRaises(NullSalesforceColumnError, attr.coerce, None) 76 | 77 | def test_nullable(self): 78 | attr = attributes.Integer('Attr', nullable=True) 79 | self.assertIsNone(attr.coerce(None)) 80 | 81 | def test_serialize(self): 82 | attr = attributes.Integer('Attr') 83 | self.assertEqual(attr.serialize(1), 1) 84 | 85 | 86 | class FloatTest(unittest.TestCase): 87 | def test_coerce(self): 88 | attr = attributes.Float('Attr') 89 | self.assertEqual(attr.coerce('1.0'), 1.0) 90 | self.assertEqual(attr.coerce(1.0), 1.0) 91 | 92 | def test_nullable(self): 93 | attr = attributes.Float('Attr', nullable=True) 94 | self.assertIsNone(attr.coerce(None)) 95 | 96 | def test_serialize(self): 97 | attr = attributes.Float('Attr') 98 | self.assertEqual(attr.serialize(1.1), 1.1) 99 | 100 | 101 | class BooleanTest(unittest.TestCase): 102 | def test_coerce(self): 103 | attr = attributes.Boolean('Attr') 104 | self.assertEqual(attr.coerce(True), True) 105 | self.assertEqual(attr.coerce(False), False) 106 | self.assertRaises(NullSalesforceColumnError, attr.coerce, None) 107 | 108 | def test_nullable(self): 109 | attr = attributes.Boolean('Attr', nullable=True) 110 | self.assertIsNone(attr.coerce(None)) 111 | 112 | def test_serialize(self): 113 | attr = attributes.Boolean('Attr') 114 | self.assertEqual(attr.serialize(True), True) 115 | 116 | 117 | class DateTimeTest(unittest.TestCase): 118 | def test_coerce(self): 119 | attr = attributes.DateTime('Attr') 120 | 121 | datetime_ = datetime(year=1990, month=10, day=19, tzinfo=tzutc()) 122 | date_ = date(year=1990, month=10, day=19) 123 | 124 | self.assertEqual(attr.coerce(datetime_), datetime_) 125 | self.assertEqual(attr.coerce(datetime_.isoformat()), datetime_) 126 | self.assertEqual(attr.coerce(datetime_.replace(tzinfo=None)), datetime_) 127 | self.assertEqual(attr.coerce(date_), datetime_) 128 | 129 | tz_naive_string = datetime_.replace(tzinfo=None).isoformat() 130 | self.assertEqual(attr.coerce(tz_naive_string), datetime_) 131 | self.assertRaises(NullSalesforceColumnError, attr.coerce, None) 132 | 133 | def test_nullable(self): 134 | attr = attributes.DateTime('Attr', nullable=True) 135 | self.assertIsNone(attr.coerce(None)) 136 | 137 | def test_serialize(self): 138 | datetime_ = datetime(year=1990, month=10, day=19, tzinfo=tzutc()) 139 | 140 | attr = attributes.DateTime('Attr') 141 | self.assertEqual(attr.serialize(datetime_), datetime_.isoformat()) 142 | 143 | 144 | class DateTest(unittest.TestCase): 145 | def test_coerce(self): 146 | attr = attributes.Date('Attr') 147 | 148 | datetime_ = datetime(year=1990, month=10, day=19, hour=5, tzinfo=tzutc()) 149 | date_ = date(year=1990, month=10, day=19) 150 | 151 | self.assertEqual(attr.coerce(date_), date_) 152 | self.assertEqual(attr.coerce(date_.isoformat()), date_) 153 | self.assertEqual(attr.coerce(datetime_), date_) 154 | self.assertRaises(NullSalesforceColumnError, attr.coerce, None) 155 | 156 | def test_nullable(self): 157 | attr = attributes.Date('Attr', nullable=True) 158 | self.assertIsNone(attr.coerce(None)) 159 | 160 | def test_serialize(self): 161 | date_ = date(year=1990, month=10, day=19) 162 | 163 | attr = attributes.Date('Attr') 164 | self.assertEqual(attr.serialize(date_), date_.isoformat()) 165 | -------------------------------------------------------------------------------- /tests/test_loader.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from soql import attributes 4 | from soql import Model 5 | from soql import ModelNotRegistered 6 | from soql import load_model_from_salesforce_data 7 | from soql import load_models_from_salesforce_data 8 | from soql import get_total_count 9 | 10 | 11 | class ModelBase(Model): 12 | __abstract__ = True 13 | id = attributes.Integer('Id') 14 | 15 | 16 | class LoadedToMany(ModelBase): 17 | __salesforce_object_name = 'LoadedToMany__c' 18 | 19 | 20 | class LoadedToOne(ModelBase): 21 | __salesforce_object_name__ = 'LoadedToOne__c' 22 | 23 | 24 | class Loaded(ModelBase): 25 | id = attributes.Integer('Id') 26 | one = attributes.Relationship('One', related_model=LoadedToOne) 27 | many = attributes.Relationship('Many', related_model=LoadedToMany, many=True) 28 | 29 | 30 | class LoaderTest(unittest.TestCase): 31 | def format_dict_as_api_data(self, data, object_type): 32 | formatted = { 33 | 'attributes': {'type': object_type}, 34 | } 35 | formatted.update(data) 36 | return formatted 37 | 38 | def format_collection_as_api_data(self, collection, object_type): 39 | formatted = { 40 | 'totalSize': len(collection), 41 | 'records': [ 42 | self.format_dict_as_api_data(data=o, object_type=object_type) 43 | for o in collection 44 | ] 45 | } 46 | return formatted 47 | 48 | def test_load_model_from_salesforce_data(self): 49 | one = self.format_dict_as_api_data( 50 | data={'Id': 1}, 51 | object_type=LoadedToOne.__soql_name__() 52 | ) 53 | many = self.format_collection_as_api_data( 54 | collection=[{'Id': 2}, {'Id': 3}], 55 | object_type=LoadedToMany.__soql_name__() 56 | ) 57 | data = self.format_dict_as_api_data( 58 | data={'Id': 4, 'One': one, 'Many': many}, 59 | object_type=Loaded.__soql_name__() 60 | ) 61 | loaded = load_model_from_salesforce_data(data=data) 62 | 63 | self.assertEqual( 64 | loaded, 65 | Loaded( 66 | id=4, 67 | one=LoadedToOne(id=1), 68 | many=[LoadedToMany(id=2), LoadedToMany(id=3)] 69 | ) 70 | ) 71 | 72 | def test_load_models_from_salesforce_data(self): 73 | one = self.format_dict_as_api_data( 74 | data={'Id': 1}, 75 | object_type=LoadedToOne.__soql_name__() 76 | ) 77 | many = self.format_collection_as_api_data( 78 | collection=[{'Id': 2}, {'Id': 3}], 79 | object_type=LoadedToMany.__soql_name__() 80 | ) 81 | data = self.format_collection_as_api_data( 82 | collection=[{'Id': 4, 'One': one, 'Many': many}], 83 | object_type=Loaded.__soql_name__() 84 | ) 85 | loaded = load_models_from_salesforce_data(data=data) 86 | 87 | self.assertEqual( 88 | loaded, 89 | [ 90 | Loaded( 91 | id=4, 92 | one=LoadedToOne(id=1), 93 | many=[LoadedToMany(id=2), LoadedToMany(id=3)] 94 | ) 95 | ] 96 | ) 97 | 98 | def test_get_total_count(self): 99 | data = self.format_collection_as_api_data( 100 | collection=[{'Id': 1}, {'Id': 2}], 101 | object_type=Loaded.__soql_name__() 102 | ) 103 | self.assertEqual(get_total_count(data=data), 2) 104 | 105 | def test_abstract_models_are_ignored(self): 106 | data = self.format_dict_as_api_data( 107 | data={'Id': 1}, 108 | object_type=ModelBase.__soql_name__() 109 | ) 110 | 111 | with self.assertRaises(ModelNotRegistered): 112 | load_model_from_salesforce_data(data=data) 113 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import date, datetime 3 | 4 | from dateutil.tz import tzutc 5 | 6 | from soql import attributes 7 | from soql import Model 8 | from soql import AttributeNotSet 9 | from soql import AttributeNotLoaded 10 | from soql import ExpectedColumnMissing 11 | from soql.model import UNLOADED 12 | from soql.model import UNSET 13 | 14 | 15 | class AParent(Model): 16 | id = attributes.Integer('Id') 17 | name = attributes.String('Name') 18 | age = attributes.Integer('Age') 19 | 20 | 21 | class AChild(Model): 22 | id = attributes.Integer('Id') 23 | name = attributes.String('Name') 24 | mom = attributes.Relationship('Mom', related_model=AParent) 25 | 26 | 27 | class AllZeTypes(Model): 28 | id = attributes.Integer('Id') 29 | name = attributes.String('Name') 30 | price = attributes.Float('Price') 31 | start_date = attributes.Date('StartDate') 32 | created_time = attributes.DateTime('CreatedTime') 33 | 34 | 35 | class AllZeRelationships(Model): 36 | parent = attributes.Relationship('Parent', related_model=AParent) 37 | children = attributes.Relationship('Children', related_model=AChild, many=True) 38 | 39 | 40 | class ModelTest(unittest.TestCase): 41 | def test_loading_from_salesforce_api_data(self): 42 | start_date = date(year=1990, month=10, day=19) 43 | created_time = datetime(year=1990, month=10, day=19, tzinfo=tzutc()) 44 | 45 | instance = AllZeTypes.load({ 46 | 'attributes': {'type': 'AllZeTypes'}, 47 | 'Id': '123', 48 | 'Name': 11, 49 | 'Price': '3.50', 50 | 'StartDate': str(start_date), 51 | 'CreatedTime': created_time.isoformat() 52 | }) 53 | self.assertEqual(instance.id, 123) 54 | self.assertEqual(instance.name, '11') 55 | self.assertEqual(instance.price, 3.50) 56 | self.assertEqual(instance.start_date, start_date) 57 | self.assertEqual(instance.created_time, created_time) 58 | 59 | instance = AllZeRelationships.load({ 60 | 'Children': { 61 | 'records': [ 62 | {'attributes': {'type': 'AChild'}, 'Id': 123, 'Name': 'Jack'}, 63 | {'attributes': {'type': 'AChild'}, 'Id': 456, 'Name': 'Jill'} 64 | ] 65 | } 66 | }) 67 | self.assertEqual(instance.children, [ 68 | AChild(id=123, name='Jack', mom=UNLOADED), 69 | AChild(id=456, name='Jill', mom=UNLOADED) 70 | ]) 71 | self.assertRaises(AttributeNotLoaded, lambda: instance.parent) 72 | 73 | instance = AllZeRelationships.load({ 74 | 'Parent': { 75 | 'attributes': {'type': 'AParent'}, 76 | 'Id': 123, 77 | 'Name': 'Jack', 78 | 'Age': 45} 79 | }) 80 | self.assertEqual(instance.parent, AParent(id=123, name='Jack', age=45)) 81 | self.assertRaises(AttributeNotLoaded, lambda: instance.children) 82 | 83 | # Assert loading hard fails if a column is missing 84 | with self.assertRaises(ExpectedColumnMissing): 85 | instance = AllZeTypes.load({ 86 | 'attributes': {'type': 'AllZeTypes'}, 87 | 'Id': '123', 88 | 'Name': 11, 89 | }) 90 | 91 | def test_instantiation(self): 92 | start_date = date(year=1990, month=10, day=19) 93 | created_time = datetime(year=1990, month=10, day=19, tzinfo=tzutc()) 94 | 95 | instance = AllZeTypes( 96 | id=123, 97 | name='11', 98 | price=3.50, 99 | start_date=start_date, 100 | created_time=created_time 101 | ) 102 | self.assertEqual(instance.id, 123) 103 | self.assertEqual(instance.name, '11') 104 | self.assertEqual(instance.price, 3.50) 105 | self.assertEqual(instance.start_date, start_date) 106 | self.assertEqual(instance.created_time, created_time) 107 | 108 | instance = AllZeRelationships( 109 | children=[AChild(id=123, name='Jack')] 110 | ) 111 | self.assertEqual(instance.children, [ 112 | AChild(id=123, name='Jack', mom=UNSET) 113 | ]) 114 | self.assertRaises(AttributeNotSet, lambda: instance.parent) 115 | 116 | instance = AllZeRelationships( 117 | parent=AParent(id=123, name='Jack', age=45) 118 | ) 119 | self.assertEqual(instance.parent, AParent(id=123, name='Jack', age=45)) 120 | self.assertRaises(AttributeNotSet, lambda: instance.children) 121 | 122 | def test_multiple_inheritance(self): 123 | class Preferences(Model): 124 | candy = attributes.String('Candy') 125 | 126 | class FavoriteChild(AChild, Preferences): 127 | pass 128 | 129 | favorite_child = FavoriteChild( 130 | id=123, 131 | name='Jennifer', 132 | candy='twizzlers' 133 | ) 134 | 135 | self.assertEqual(favorite_child.id, 123) 136 | self.assertEqual(favorite_child.name, 'Jennifer') 137 | self.assertEqual(favorite_child.candy, 'twizzlers') 138 | 139 | def test_back_references(self): 140 | class Foo(Model): 141 | bar = attributes.Relationship('Bar', related_model='Bar') 142 | 143 | class Bar(Model): 144 | foos = attributes.Relationship('Foos', related_model=Foo, many=True) 145 | 146 | bar = Bar() 147 | foo = Foo(bar=bar) 148 | self.assertEqual(foo.bar, bar) 149 | 150 | foos = [Foo() for _ in range(3)] 151 | bar = Bar(foos=foos) 152 | self.assertEqual(bar.foos, foos) 153 | 154 | def test_set_attributes(self): 155 | child = AChild(id=456, name='Jill') 156 | child.name = 'MOTHA FUCKIN JILL' 157 | self.assertEqual(child.name, 'MOTHA FUCKIN JILL') 158 | 159 | def test_changes(self): 160 | start_date = date(year=1990, month=10, day=19) 161 | new_date = date(year=1990, month=10, day=20) 162 | 163 | instance = AllZeTypes(id=456, name='Jill', start_date=start_date) 164 | 165 | self.assertEqual(instance.changes(), {}) 166 | 167 | instance.name = 'Bill' 168 | 169 | self.assertEqual(instance.changes(), { 170 | 'Name': 'Bill' 171 | }) 172 | 173 | # Test that the value is coerced 174 | instance.start_date = new_date 175 | 176 | self.assertEqual(instance.changes(), { 177 | 'StartDate': new_date.isoformat(), 178 | 'Name': 'Bill' 179 | }) 180 | 181 | instance.reset_instance_state() 182 | 183 | self.assertEqual(instance.changes(), {}) 184 | 185 | # No changes! 186 | instance.name = 'Bill' 187 | 188 | self.assertEqual(instance.changes(), {}) 189 | 190 | instance.name = 'Jill' 191 | 192 | self.assertEqual(instance.changes(), { 193 | 'Name': 'Jill' 194 | }) 195 | -------------------------------------------------------------------------------- /tests/test_nodes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import unittest 4 | from datetime import date, datetime 5 | 6 | from dateutil.tz import tzutc 7 | 8 | from soql.nodes import ColumnPath, Expression, OPS, _stringify 9 | from soql.nodes import and_, or_, not_ 10 | from tests.helpers import SoqlAssertions 11 | 12 | 13 | class ExpressionTest(unittest.TestCase, SoqlAssertions): 14 | def test_operations(self): 15 | for operation in OPS.values(): 16 | exp = Expression(lhs=123, op=operation, rhs=456) 17 | self.assertSoqlEqual(exp, '123 {} 456'.format(operation)) 18 | 19 | def test_chained_expressions(self): 20 | exp1 = Expression(lhs=123, op=OPS.EQ, rhs=456) 21 | exp2 = Expression(lhs=789, op=OPS.AND, rhs=exp1) 22 | self.assertSoqlEqual(exp2, '789 AND 123 = 456') 23 | 24 | def test_type_rendering(self): 25 | exp1 = Expression(lhs='123', op=OPS.EQ, rhs=456) 26 | self.assertSoqlEqual(exp1, "'123' = 456") 27 | 28 | exp2 = Expression(lhs='789', op=OPS.AND, rhs=exp1) 29 | self.assertSoqlEqual(exp2, "'789' AND '123' = 456") 30 | 31 | 32 | class StringifyTest(unittest.TestCase): 33 | def test_stringify(self): 34 | self.assertEqual(_stringify('Apples'), "'Apples'") 35 | 36 | self.assertEqual(_stringify(u'CATMONKÈ-123490'), u"'CATMONKÈ-123490'") 37 | 38 | self.assertEqual(_stringify(1), '1') 39 | 40 | self.assertEqual(_stringify(True), 'TRUE') 41 | 42 | self.assertEqual(_stringify(False), 'FALSE') 43 | 44 | self.assertEqual(_stringify(None), 'NULL') 45 | 46 | date_ = date(year=1990, month=10, day=19) 47 | self.assertEqual(_stringify(date_), '1990-10-19') 48 | 49 | datetime_ = datetime( 50 | year=1990, 51 | month=10, 52 | day=19, 53 | hour=1, 54 | minute=2, 55 | second=3, 56 | tzinfo=tzutc() 57 | ) 58 | 59 | self.assertEqual(_stringify(datetime_), '1990-10-19T01:02:03+00:00') 60 | 61 | naive_datetime = datetime( 62 | year=1990, 63 | month=10, 64 | day=19, 65 | hour=1, 66 | minute=2, 67 | second=3 68 | ) 69 | self.assertEqual(_stringify(naive_datetime), '1990-10-19T01:02:03+00:00') 70 | 71 | 72 | class OperationsTest(unittest.TestCase): 73 | def test_operations(self): 74 | column_path = ColumnPath('Monkey.Tail') 75 | 76 | self.assertEqual(str(column_path == '123'), "Monkey.Tail = '123'") 77 | self.assertEqual(str(column_path == 56), "Monkey.Tail = 56") 78 | 79 | self.assertEqual(str(column_path < 5), "Monkey.Tail < 5") 80 | self.assertEqual(str(column_path > 5), "Monkey.Tail > 5") 81 | self.assertEqual(str(column_path <= 5), "Monkey.Tail <= 5") 82 | self.assertEqual(str(column_path >= 5), "Monkey.Tail >= 5") 83 | 84 | self.assertEqual(str(column_path != None), "Monkey.Tail != NULL") 85 | self.assertEqual(str(column_path == True), "Monkey.Tail = TRUE") 86 | self.assertEqual(str(column_path == False), "Monkey.Tail = FALSE") 87 | 88 | self.assertEqual( 89 | str(column_path.like_('Va%')), 90 | "Monkey.Tail LIKE 'Va%'" 91 | ) 92 | self.assertEqual( 93 | str(column_path.in_(['Jin', 'Jan'])), 94 | "Monkey.Tail IN ('Jin', 'Jan')" 95 | ) 96 | self.assertEqual( 97 | str(column_path.in_([u'Jin', u'Jan'])), 98 | "Monkey.Tail IN ('Jin', 'Jan')" 99 | ) 100 | self.assertEqual( 101 | str(column_path.not_in_(['Jin', 'Jan'])), 102 | "Monkey.Tail NOT IN ('Jin', 'Jan')" 103 | ) 104 | self.assertEqual( 105 | str(column_path.includes_(['Jin', 'Jan'])), 106 | "Monkey.Tail INCLUDES ('Jin', 'Jan')" 107 | ) 108 | self.assertEqual( 109 | str(column_path.excludes_(['Jin', 'Jan'])), 110 | "Monkey.Tail EXCLUDES ('Jin', 'Jan')" 111 | ) 112 | 113 | self.assertEqual( 114 | str(and_(column_path == 'R', column_path < 10)), 115 | "(Monkey.Tail = 'R' AND Monkey.Tail < 10)" 116 | ) 117 | self.assertEqual( 118 | str(and_(column_path == 'R', column_path < 10, column_path != 'G')), 119 | "(Monkey.Tail = 'R' AND Monkey.Tail < 10 AND Monkey.Tail != 'G')" 120 | ) 121 | 122 | self.assertEqual( 123 | str(or_(column_path == 'R', column_path < 10)), 124 | "(Monkey.Tail = 'R' OR Monkey.Tail < 10)" 125 | ) 126 | self.assertEqual( 127 | str(or_(column_path == 'R', column_path < 10, column_path != 'G')), 128 | "(Monkey.Tail = 'R' OR Monkey.Tail < 10 OR Monkey.Tail != 'G')" 129 | ) 130 | 131 | self.assertEqual( 132 | str(not_(column_path == 'R')), 133 | "NOT Monkey.Tail = 'R'" 134 | ) 135 | 136 | self.assertEqual( 137 | str(not_(and_(column_path == 'R', 138 | or_(column_path == 10, column_path == 'G')))), 139 | "NOT (Monkey.Tail = 'R' AND (Monkey.Tail = 10 OR Monkey.Tail = 'G'))" 140 | ) 141 | -------------------------------------------------------------------------------- /tests/test_path_builder.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from soql import attributes 4 | from soql import Model 5 | from soql.path_builder import PathBuilder 6 | from tests.helpers import SoqlAssertions 7 | 8 | 9 | # Declare the relationships separately so we can assert some stuff in the tests 10 | # more easily... 11 | next_ = attributes.Relationship('Next', related_model='LinkedList') 12 | list_ = attributes.Relationship('List', related_model='LinkedList') 13 | children = attributes.Relationship('Children', related_model='GraphNode', many=True) 14 | 15 | 16 | class LinkedList(Model): 17 | id = attributes.Integer('Id') 18 | next = next_ 19 | 20 | 21 | class GraphNode(Model): 22 | id = attributes.Integer('Id') 23 | value = attributes.Integer('Value') 24 | list = list_ 25 | children = children 26 | 27 | 28 | class PathBuilderTest(unittest.TestCase, SoqlAssertions): 29 | def assertColumnNodesEqual(self, node_list, expected): 30 | self.assertEqual( 31 | sorted([str(node) for node in node_list]), 32 | sorted(expected) 33 | ) 34 | 35 | def test_getattr(self): 36 | self.assertSoqlEqual(LinkedList.id, 'LinkedList.Id') 37 | self.assertSoqlEqual(LinkedList.next.id, 'LinkedList.Next.Id') 38 | self.assertSoqlEqual(LinkedList.next.next.id, 'LinkedList.Next.Next.Id') 39 | self.assertSoqlEqual(GraphNode.children.list.next.id, 'GraphNode.Children.List.Next.Id') 40 | self.assertSoqlEqual(GraphNode.children.children, 'GraphNode.Children.Children') 41 | 42 | def test_get_relationship_attr_in_path(self): 43 | path = GraphNode.children.list.next 44 | self.assertEqual(path.get_relationship_attr_in_path(path_index=0), children) 45 | self.assertEqual(path.get_relationship_attr_in_path(path_index=1), list_) 46 | self.assertEqual(path.get_relationship_attr_in_path(path_index=2), next_) 47 | self.assertEqual(path.get_relationship_attr_in_path(path_index=-1), next_) 48 | 49 | def test_get_model_in_path(self): 50 | path = GraphNode.children.list.next 51 | self.assertEqual(path.get_model_in_path(path_index=0), GraphNode) 52 | self.assertEqual(path.get_model_in_path(path_index=1), LinkedList) 53 | self.assertEqual(path.get_model_in_path(path_index=2), LinkedList) 54 | self.assertEqual(path.get_model_in_path(path_index=-1), LinkedList) 55 | 56 | def test_get_last_model_column_nodes(self): 57 | path_builder = PathBuilder(model=GraphNode) 58 | self.assertColumnNodesEqual( 59 | path_builder.get_last_model_column_nodes(), 60 | ['GraphNode.Id', 'GraphNode.Value'] 61 | ) 62 | 63 | path_builder = path_builder.extend_path('children') 64 | self.assertColumnNodesEqual( 65 | path_builder.get_last_model_column_nodes(), 66 | ['GraphNode.Children.Id', 'GraphNode.Children.Value'] 67 | ) 68 | 69 | path_builder = path_builder.extend_path('list') 70 | self.assertColumnNodesEqual( 71 | path_builder.get_last_model_column_nodes(), 72 | ['GraphNode.Children.List.Id'] 73 | ) 74 | 75 | -------------------------------------------------------------------------------- /tests/test_select.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import unittest 4 | 5 | from soql.attributes import Integer, Relationship, String 6 | from soql import Model 7 | from soql import select 8 | from soql import SelectClauseIsntValidSubquery 9 | from soql import asc, desc, nulls_first, nulls_last 10 | from tests.helpers import SoqlAssertions 11 | 12 | 13 | class Grandparent(Model): 14 | id = Integer('Id') 15 | 16 | 17 | class Parent(Model): 18 | id = Integer('Id') 19 | name = String('Name') 20 | age = Integer('Age') 21 | mom = Relationship('Mom', related_model=Grandparent) 22 | 23 | 24 | class Child(Model): 25 | id = Integer('Id') 26 | name = String('Name') 27 | mom = Relationship('Mom', related_model=Parent) 28 | dad = Relationship('Dad', related_model=Parent) 29 | teacher = Relationship('Teacher', related_model='Teacher') 30 | 31 | 32 | class Teacher(Model): 33 | id = Integer('Id') 34 | students = Relationship('Students', related_model=Child, many=True) 35 | 36 | 37 | class SelectTest(unittest.TestCase, SoqlAssertions): 38 | def test_select(self): 39 | self.assertSoqlEqual( 40 | select(Child), 41 | "SELECT Child.Id, Child.Name " 42 | "FROM Child" 43 | ) 44 | 45 | def test_joins(self): 46 | self.assertSoqlEqual( 47 | select(Child).join(Child.mom), 48 | "SELECT Child.Id, Child.Name, Child.Mom.Age, Child.Mom.Id, Child.Mom.Name " 49 | "FROM Child" 50 | ) 51 | 52 | self.assertSoqlEqual( 53 | select(Teacher).join(Teacher.students), 54 | "SELECT Teacher.Id, (SELECT Child.Id, Child.Name FROM Teacher.Students) " 55 | "FROM Teacher" 56 | ) 57 | 58 | self.assertSoqlEqual( 59 | select(Teacher).join(Teacher.students).join(Teacher.students.mom), 60 | "SELECT Teacher.Id, " 61 | "(SELECT Child.Id, Child.Name, Child.Mom.Age, Child.Mom.Id, Child.Mom.Name FROM Teacher.Students) " 62 | "FROM Teacher" 63 | ) 64 | 65 | self.assertSoqlEqual( 66 | select(Teacher).join(Teacher.students.mom), 67 | "SELECT Teacher.Id, " 68 | "(SELECT Child.Id, Child.Name, Child.Mom.Age, Child.Mom.Id, Child.Mom.Name FROM Teacher.Students) " 69 | "FROM Teacher" 70 | ) 71 | 72 | self.assertSoqlEqual( 73 | select(Child).join(Child.mom.mom), 74 | "SELECT Child.Id, Child.Name, Child.Mom.Age, " 75 | "Child.Mom.Id, Child.Mom.Name, Child.Mom.Mom.Id " 76 | "FROM Child" 77 | ) 78 | 79 | self.assertSoqlEqual( 80 | select(Teacher).join(Teacher.students.mom).join( 81 | Teacher.students.dad), 82 | "SELECT Teacher.Id, " 83 | "(SELECT Child.Id, Child.Name, Child.Dad.Age, Child.Dad.Id, Child.Dad.Name, " 84 | "Child.Mom.Age, Child.Mom.Id, Child.Mom.Name FROM Teacher.Students) " 85 | "FROM Teacher" 86 | ) 87 | 88 | self.assertSoqlEqual( 89 | select(Child).join(Child.teacher.students.mom), 90 | "SELECT Child.Id, Child.Name, Child.Teacher.Id, " 91 | "(SELECT Child.Id, Child.Name, Child.Mom.Age, Child.Mom.Id, " 92 | "Child.Mom.Name FROM Child.Teacher.Students) " 93 | "FROM Child" 94 | ) 95 | 96 | def test_filters(self): 97 | self.assertSoqlEqual( 98 | select(Child).where(Child.id == '123'), 99 | "SELECT Child.Id, Child.Name " 100 | "FROM Child " 101 | "WHERE Child.Id = '123'" 102 | ) 103 | 104 | self.assertSoqlEqual( 105 | select(Child).where(Child.id == '123').where(Child.name == 'Jill'), 106 | "SELECT Child.Id, Child.Name " 107 | "FROM Child " 108 | "WHERE Child.Id = '123' AND Child.Name = 'Jill'" 109 | ) 110 | 111 | self.assertSoqlEqual( 112 | select(Child).where(Child.name == u'CATMONKÈ-123490'), 113 | u"SELECT Child.Id, Child.Name " 114 | u"FROM Child " 115 | u"WHERE Child.Name = 'CATMONKÈ-123490'" 116 | ) 117 | 118 | def test_order_by(self): 119 | self.assertSoqlEqual( 120 | select(Parent).order_by(Parent.age), 121 | "SELECT Parent.Age, Parent.Id, Parent.Name " 122 | "FROM Parent " 123 | "ORDER BY Parent.Age" 124 | ) 125 | 126 | self.assertSoqlEqual( 127 | select(Parent).order_by(Parent.age).order_by(Parent.id), 128 | "SELECT Parent.Age, Parent.Id, Parent.Name " 129 | "FROM Parent " 130 | "ORDER BY Parent.Age, Parent.Id" 131 | ) 132 | 133 | self.assertSoqlEqual( 134 | select(Parent).order_by(Parent.age, direction=desc), 135 | "SELECT Parent.Age, Parent.Id, Parent.Name " 136 | "FROM Parent " 137 | "ORDER BY Parent.Age DESC" 138 | ) 139 | 140 | self.assertSoqlEqual( 141 | select(Parent).order_by(Parent.age, direction=desc).order_by(Parent.id, direction=asc), 142 | "SELECT Parent.Age, Parent.Id, Parent.Name " 143 | "FROM Parent " 144 | "ORDER BY Parent.Age DESC, Parent.Id ASC" 145 | ) 146 | 147 | self.assertSoqlEqual( 148 | select(Parent).order_by(Parent.age, direction=asc, nulls_position=nulls_first), 149 | "SELECT Parent.Age, Parent.Id, Parent.Name " 150 | "FROM Parent " 151 | "ORDER BY Parent.Age ASC NULLS FIRST" 152 | ) 153 | 154 | self.assertSoqlEqual( 155 | select(Parent).order_by(Parent.age, direction=desc, nulls_position=nulls_last), 156 | "SELECT Parent.Age, Parent.Id, Parent.Name " 157 | "FROM Parent " 158 | "ORDER BY Parent.Age DESC NULLS LAST" 159 | ) 160 | 161 | def test_count(self): 162 | self.assertSoqlEqual( 163 | select(Child).count(), 164 | "SELECT COUNT() " 165 | "FROM Child" 166 | ) 167 | 168 | def test_offset_and_limit(self): 169 | self.assertSoqlEqual( 170 | select(Child).limit(100), 171 | "SELECT Child.Id, Child.Name " 172 | "FROM Child " 173 | "LIMIT 100" 174 | ) 175 | 176 | self.assertSoqlEqual( 177 | select(Child).offset(100), 178 | "SELECT Child.Id, Child.Name " 179 | "FROM Child " 180 | "OFFSET 100" 181 | ) 182 | 183 | self.assertSoqlEqual( 184 | select(Parent).order_by(Parent.age).offset(100).limit(100), 185 | "SELECT Parent.Age, Parent.Id, Parent.Name " 186 | "FROM Parent " 187 | "ORDER BY Parent.Age " 188 | "LIMIT 100 " 189 | "OFFSET 100" 190 | ) 191 | 192 | def test_override_columns(self): 193 | self.assertSoqlEqual( 194 | select(Parent).columns(Parent.id), 195 | "SELECT Parent.Id " 196 | "FROM Parent" 197 | ) 198 | 199 | self.assertSoqlEqual( 200 | select(Parent).columns(Parent.id, Parent.name), 201 | "SELECT Parent.Id, Parent.Name " 202 | "FROM Parent" 203 | ) 204 | 205 | def test_subquery(self): 206 | self.assertSoqlEqual( 207 | select(Parent).columns(Parent.id).subquery(), 208 | "(SELECT Parent.Id FROM Parent)" 209 | ) 210 | 211 | subquery = select(Parent).columns(Parent.name).subquery() 212 | self.assertSoqlEqual( 213 | select(Child).where(Child.name.in_(subquery)), 214 | "SELECT Child.Id, Child.Name " 215 | "FROM Child " 216 | "WHERE Child.Name IN (SELECT Parent.Name FROM Parent)" 217 | ) 218 | 219 | with self.assertRaises(SelectClauseIsntValidSubquery): 220 | select(Parent).offset(100).subquery() 221 | --------------------------------------------------------------------------------