diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 33e0ca5..13da315 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,7 +2,7 @@ History ======= -0.1.0 (2019-02-06) +0.1.0 (2019-02-12) ------------------ * First release on PyPI. diff --git a/README.rst b/README.rst index b83d61e..7f30849 100644 --- a/README.rst +++ b/README.rst @@ -2,11 +2,10 @@ Serialchemy ====================================================================== - -.. image:: https://img.shields.io/pypi/v/serialchemy.svg +.. TODO: Publish to PyPi + .. image:: https://img.shields.io/pypi/v/serialchemy.svg :target: https://pypi.python.org/pypi/serialchemy - -.. image:: https://img.shields.io/pypi/pyversions/serialchemy.svg + .. image:: https://img.shields.io/pypi/pyversions/serialchemy.svg :target: https://pypi.org/project/serialchemy .. image:: https://img.shields.io/travis/ESSS/serialchemy.svg @@ -21,10 +20,96 @@ Serialchemy .. image:: https://img.shields.io/readthedocs/pip.svg :target: https://serialchemy.readthedocs.io/en/latest/ -What is Serialchemy ? -================================================================================ +SQLAlchemy model serialization. + +Motivation +---------- + +**Serialchemy** was developed as a module of Flask-RESTAlchemy_, a lib to create Restful APIs +using Flask and SQLAlchemy. We first tried marshmallow-sqlalchemy_, probably the most +well-known lib for SQLAlchemy model serialization, but we faced `issues related to nested +models `_. We also think +that is possible to build a simpler and more maintainable solution by having SQLAlchemy_ in +mind from the ground up, as opposed to marshmallow-sqlalchemy_ that had to be +designed and built on top of marshmallow_. + +.. _SQLAlchemy: www.sqlalchemy.org +.. _marshmallow-sqlalchemy: http://marshmallow-sqlalchemy.readthedocs.io +.. _marshmallow: https://marshmallow.readthedocs.io +.. _Flask-RESTAlchemy: https://github.com/ESSS/flask-restalchemy + +How to Use it +------------- + +Serializing Generic Types +......................... + +Suppose we have an `Employee` SQLAlchemy_ model declared: :: + + class Employee(Base): + __tablename__ = 'Employee' + + id = Column(Integer, primary_key=True) + fullname = Column(String) + admission = Column(DateTime, default=datetime(2000, 1, 1)) + company_id = Column(ForeignKey('Company.id')) + company = relationship(Company) + company_name = column_property( + select([Company.name]).where(Company.id == company_id) + ) + password = Column(String) + +`Generic Types`_ are automatically serialized by `ModelSerializer`: :: + + from serialchemy import ModelSerializer + + emp = Employee(fullname='Roberto Silva', admission=datetime(2019, 4, 2)) + + serializer = ModelSerializer(Employee) + serializer.dump(emp) + + >> {'id': None, + 'fullname': 'Roberto Silva', + 'admission': '2019-04-02T00:00:00', + 'company_id': None, + 'company_name': None, + 'password': None + } + +New items can be deserialized by the same serializer: :: + + new_employee = {'fullname': 'Jobson Gomes', 'admission': '2018-02-03'} + serializer.load(new_employee) + >> + +Serializers do not commit into the database. You must do this by yourself: :: + + emp = serializer.load(new_employee) + session.add(emp) + session.commit() + +.. _`Generic Types`: https://docs.sqlalchemy.org/en/rel_1_2/core/type_basics.html#generic-types + +Custom Serializers +.................. + +For anything beyond `Generic Types`_ we must extend the `ModelSerializer` class: :: + + class EmployeeSerializer(ModelSerializer): + + password = Field(load_only=True) # passwords should be only deserialized + company = NestedModelField(Company) # dump company as nested object + + serializer = EmployeeSerializer(Employee) + serializer.dump(emp) -Serializers for SQLAlchemy models. + >> {'id': 1, + 'fullname': 'Roberto Silva', + 'admission': '2019-04-02T00:00:00', + 'company': {'id': 3, + 'name': 'Acme Co' + } + } Contributing diff --git a/docs/api.rst b/docs/api.rst deleted file mode 100644 index ece6107..0000000 --- a/docs/api.rst +++ /dev/null @@ -1,3 +0,0 @@ -============= -API Reference -============= diff --git a/docs/changelog.rst b/docs/changelog.rst deleted file mode 100644 index 565b052..0000000 --- a/docs/changelog.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../CHANGELOG.rst diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index c9e970b..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# serialchemy documentation build configuration file, created by -# sphinx-quickstart on Fri Jun 9 13:47:02 2017. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -# If extensions (or modules to document with autodoc) are in another -# directory, add these directories to sys.path here. If the directory is -# relative to the documentation root, use os.path.abspath to make it -# absolute, like shown here. -# -import os -import sys - -sys.path.insert(0, os.path.abspath("..")) - - -# -- General configuration --------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = ".rst" - -# The master toctree document. -master_doc = "index" - -# General information about the project. -project = u"Serialchemy" -copyright = u"2018, ESSS" -author = u"ESSS" - -# The version info for the project you're documenting, acts as replacement -# for |version| and |release|, also used in various other places throughout -# the built documents. -# -# The short X.Y version. -# import pkg_resources -# version = pkg_resources.get_distribution('serialchemy').ver -# The full version, including alpha/beta/rc tags. -# release = version - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "sphinx_rtd_theme" -html_logo = "img/logo.png" - -# Theme options are theme-specific and customize the look and feel of a -# theme further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -# html_static_path = ["_static"] - - -# -- Options for HTMLHelp output --------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = "serialchemydoc" - - -# -- Options for LaTeX output ------------------------------------------ - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass -# [howto, manual, or own class]). -latex_documents = [ - (master_doc, "serialchemy.tex", u"Serialchemy Documentation", u"ESSS", "manual") -] - - -# -- Options for manual page output ------------------------------------ - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "serialchemy", u"Serialchemy Documentation", [author], 1)] - - -# -- Options for Texinfo output ---------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - "serialchemy", - u"Serialchemy Documentation", - author, - "serialchemy", - "One line description of project.", - "Miscellaneous", - ) -] diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index e582053..0000000 --- a/docs/contributing.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../CONTRIBUTING.rst diff --git a/docs/img/logo.png b/docs/img/logo.png deleted file mode 100644 index b95af93..0000000 Binary files a/docs/img/logo.png and /dev/null differ diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 31f1bb9..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,19 +0,0 @@ -Welcome to Serialchemy documentation! -====================================================================== - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - readme - installation - usage - api - contributing - changelog - -Indices and tables -================== -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index c575594..0000000 --- a/docs/installation.rst +++ /dev/null @@ -1,51 +0,0 @@ -.. highlight:: shell - -============ -Installation -============ - - -Stable release --------------- - -To install Serialchemy, run this command in your terminal: - -.. code-block:: console - - $ pip install serialchemy - -This is the preferred method to install Serialchemy, as it will always install the most recent stable release. - -If you don't have `pip`_ installed, this `Python installation guide`_ can guide -you through the process. - -.. _pip: https://pip.pypa.io -.. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ - - -From sources ------------- - -The sources for Serialchemy can be downloaded from the `Github repo`_. - -You can either clone the public repository: - -.. code-block:: console - - $ git clone git://github.com/ESSS/serialchemy - -Or download the `tarball`_: - -.. code-block:: console - - $ curl -OL https://github.com/ESSS/serialchemy/tarball/master - -Once you have a copy of the source, you can install it with: - -.. code-block:: console - - $ python setup.py install - - -.. _Github repo: https://github.com/ESSS/serialchemy -.. _tarball: https://github.com/ESSS/serialchemy/tarball/master diff --git a/docs/readme.rst b/docs/readme.rst deleted file mode 100644 index 72a3355..0000000 --- a/docs/readme.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../README.rst diff --git a/docs/usage.rst b/docs/usage.rst deleted file mode 100644 index a2b55b5..0000000 --- a/docs/usage.rst +++ /dev/null @@ -1,7 +0,0 @@ -===== -Usage -===== - -To use Serialchemy in a project:: - - import serialchemy diff --git a/setup.py b/setup.py index 4e9f491..346f889 100644 --- a/setup.py +++ b/setup.py @@ -11,11 +11,14 @@ requirements = ["sqlalchemy>=1.1"] extras_require = { "docs": ["sphinx >= 1.4", "sphinx_rtd_theme", "sphinx-autodoc-typehints"], - "testing": ["codecov", "pytest", "pytest-cov", "pytest-mock", "pre-commit", "tox"], + "testing": ["codecov", "pytest", "pytest-cov", "pytest-regressions", "pre-commit", "tox"], } setuptools.setup( + name="serialchemy", + description="Serializers for SQLAlchemy models.", author='ESSS', author_email='foss@esss.co', + version='0.1.0', classifiers=[ 'Development Status :: 2 - Pre-Alpha', "Intended Audience :: Developers", @@ -23,7 +26,6 @@ "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", ], - description="Serializers for SQLAlchemy models.", extras_require=extras_require, install_requires=requirements, license="MIT license", @@ -31,7 +33,6 @@ include_package_data=True, python_requires=">=3.6", keywords="serialchemy", - name="serialchemy", packages=setuptools.find_packages(where="src"), package_dir={"": "src"}, url="http://github.com/ESSS/serialchemy", diff --git a/src/serialchemy/__init__.py b/src/serialchemy/__init__.py index 8da4542..d61cf9d 100644 --- a/src/serialchemy/__init__.py +++ b/src/serialchemy/__init__.py @@ -1,3 +1,5 @@ -from .fields import Field, NestedModelField, NestedAttributesField, PrimaryKeyField +from .field import Field +from .nested_fields import NestedModelField, NestedAttributesField, PrimaryKeyField, \ + NestedModelListField from .modelserializer import ModelSerializer -from .serializer import ColumnSerializer +from .serializer import ColumnSerializer, Serializer diff --git a/src/serialchemy/_tests/sample_model.py b/src/serialchemy/_tests/sample_model.py index 9ce25c5..3c1e0ff 100644 --- a/src/serialchemy/_tests/sample_model.py +++ b/src/serialchemy/_tests/sample_model.py @@ -4,8 +4,9 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import column_property, object_session, relationship -from serialchemy.fields import Field, NestedModelField, NestedModelListField from serialchemy.modelserializer import ModelSerializer +from serialchemy.field import Field +from serialchemy.nested_fields import NestedModelField, NestedModelListField Base = declarative_base() diff --git a/src/serialchemy/_tests/test_readme.py b/src/serialchemy/_tests/test_readme.py new file mode 100644 index 0000000..cbfe34b --- /dev/null +++ b/src/serialchemy/_tests/test_readme.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Table, select +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import column_property, relationship + +Base = declarative_base() + +class Company(Base): + __tablename__ = 'Company' + + id = Column(Integer, primary_key=True) + name = Column(String) + +class Employee(Base): + __tablename__ = 'Employee' + + id = Column(Integer, primary_key=True) + fullname = Column(String) + admission = Column(DateTime, default=datetime(2000, 1, 1)) + company_id = Column(ForeignKey('Company.id')) + company = relationship(Company) + company_name = column_property( + select([Company.name]).where(Company.id == company_id) + ) + password = Column(String) + + +def test_example_1(db_session): + from serialchemy import ModelSerializer + + emp = Employee(fullname='Roberto Silva', admission=datetime(2019, 4, 2)) + serializer = ModelSerializer(Employee) + serializer.dump(emp) + + new_employee = {'fullname': 'Jobson Gomes', 'admission': '2018-02-03T00:00:00'} + serializer.load(new_employee) diff --git a/src/serialchemy/_tests/test_serialization.py b/src/serialchemy/_tests/test_serialization.py index 1a22c35..1d08355 100644 --- a/src/serialchemy/_tests/test_serialization.py +++ b/src/serialchemy/_tests/test_serialization.py @@ -3,7 +3,8 @@ import pytest from serialchemy._tests.sample_model import Address, Company, Department, Employee -from serialchemy.fields import Field, NestedAttributesField, NestedModelField, PrimaryKeyField +from serialchemy.field import Field +from serialchemy.nested_fields import NestedAttributesField, NestedModelField, PrimaryKeyField from serialchemy.modelserializer import ModelSerializer @@ -62,29 +63,25 @@ def seed_data(db_session): db_session.commit() +def test_model_serializer(db_session, data_regression): + emp = db_session.query(Employee).get(1) + serializer = ModelSerializer(Employee) + serialized = serializer.dump(emp) + print(serialized) + data_regression.Check(serialized) + + @pytest.mark.parametrize("serializer_class", [EmployeeSerializerNestedModelFields, EmployeeSerializerNestedAttrsFields] ) -def test_serialization(serializer_class, db_session): +def test_custom_serializer(serializer_class, db_session, data_regression): emp = db_session.query(Employee).get(1) serializer = serializer_class(Employee) - serialized_dict = serializer.dump(emp) - assert serialized_dict["firstname"] == emp.firstname - assert serialized_dict["lastname"] == emp.lastname - assert serialized_dict["created_at"] == "2000-01-02T00:00:00" - assert serialized_dict["company_id"] == 5 - assert serialized_dict["company"]["name"] == "Terrans" - assert serialized_dict["company"]["location"] == "Korhal" - - assert "password" not in serialized_dict - address = serialized_dict["address"] - - assert address["id"] == 1 - assert address["number"] == "943" - assert address["street"] == "5 Av" + serialized = serializer.dump(emp) + data_regression.check(serialized, basename="test_custom_serializer_{}".format(serializer_class.__name__)) -def test_deserialize_new_model(db_session): +def test_deserialize_new_model(db_session, data_regression): serializer = EmployeeSerializerNestedModelFields(Employee) serialized = { "firstname": "John", @@ -100,11 +97,7 @@ def test_deserialize_new_model(db_session): "created_at": "2023-12-21T00:00:00", } loaded_emp = serializer.load(serialized, session=db_session) - assert loaded_emp.firstname == serialized["firstname"] - assert loaded_emp.admission == datetime.datetime(2004, 6, 1, 0, 0) - assert loaded_emp.company_id == serialized["company_id"] - assert loaded_emp.address.number == "245" - assert loaded_emp.created_at is None + data_regression.check(serializer.dump(loaded_emp)) def test_deserialize_existing_model(db_session): @@ -127,22 +120,18 @@ def test_deserialize_existing_model(db_session): assert serialized["address"]["zip"] == loaded_emp.address.zip -def test_one2one_pk_field(db_session): +def test_one2one_pk_field(db_session, data_regression): serializer = EmployeeSerializerPrimaryKeyFields(Employee) employee = db_session.query(Employee).get(2) serialized = serializer.dump(employee) - assert serialized['firstname'] == 'Sarah' - assert serialized['address'] == 1 - assert serialized['company'] == 5 + data_regression.check(serialized) -def test_one2many_pk_field(db_session): +def test_one2many_pk_field(db_session, data_regression): serializer = CompanySerializer(Company) company = db_session.query(Company).get(5) serialized = serializer.dump(company) - assert serialized['name'] == 'Terrans' - assert len(serialized['employees']) == 2 - assert serialized['employees'] == [1, 2] + data_regression.check(serialized) serialized['employees'] = [2, 3] company = serializer.load(serialized, existing_model=company, session=db_session) diff --git a/src/serialchemy/_tests/test_serialization/test_custom_serializer_EmployeeSerializerNestedAttrsFields.yml b/src/serialchemy/_tests/test_serialization/test_custom_serializer_EmployeeSerializerNestedAttrsFields.yml new file mode 100644 index 0000000..b0a12fb --- /dev/null +++ b/src/serialchemy/_tests/test_serialization/test_custom_serializer_EmployeeSerializerNestedAttrsFields.yml @@ -0,0 +1,18 @@ +address: + city: Tarsonis + id: 1 + number: '943' + street: 5 Av +address_id: 1 +admission: '2000-01-01T00:00:00' +company: + location: Korhal + name: Terrans +company_id: 5 +company_name: Terrans +created_at: '2000-01-02T00:00:00' +department: null +email: null +firstname: Jim +id: 1 +lastname: Raynor diff --git a/src/serialchemy/_tests/test_serialization/test_custom_serializer_EmployeeSerializerNestedModelFields.yml b/src/serialchemy/_tests/test_serialization/test_custom_serializer_EmployeeSerializerNestedModelFields.yml new file mode 100644 index 0000000..7d6045c --- /dev/null +++ b/src/serialchemy/_tests/test_serialization/test_custom_serializer_EmployeeSerializerNestedModelFields.yml @@ -0,0 +1,20 @@ +address: + city: Tarsonis + id: 1 + number: '943' + state: null + street: 5 Av + zip: null +address_id: 1 +admission: '2000-01-01T00:00:00' +company: + id: 5 + location: Korhal + name: Terrans +company_id: 5 +company_name: Terrans +created_at: '2000-01-02T00:00:00' +email: null +firstname: Jim +id: 1 +lastname: Raynor diff --git a/src/serialchemy/_tests/test_serialization/test_deserialize_new_model.yml b/src/serialchemy/_tests/test_serialization/test_deserialize_new_model.yml new file mode 100644 index 0000000..738efc1 --- /dev/null +++ b/src/serialchemy/_tests/test_serialization/test_deserialize_new_model.yml @@ -0,0 +1,17 @@ +address: + city: null + id: null + number: '245' + state: null + street: 6 Av + zip: 88088-000 +address_id: null +admission: '2004-06-01T00:00:00' +company: null +company_id: 5 +company_name: null +created_at: null +email: null +firstname: John +id: null +lastname: Doe diff --git a/src/serialchemy/_tests/test_serialization/test_model_serializer.yml b/src/serialchemy/_tests/test_serialization/test_model_serializer.yml new file mode 100644 index 0000000..e63a09d --- /dev/null +++ b/src/serialchemy/_tests/test_serialization/test_model_serializer.yml @@ -0,0 +1,10 @@ +address_id: 1 +admission: '2000-01-01T00:00:00' +company_id: 5 +company_name: Terrans +created_at: '2000-01-02T00:00:00' +email: null +firstname: Jim +id: 1 +lastname: Raynor +password: null diff --git a/src/serialchemy/_tests/test_serialization/test_one2many_pk_field.yml b/src/serialchemy/_tests/test_serialization/test_one2many_pk_field.yml new file mode 100644 index 0000000..26a44f0 --- /dev/null +++ b/src/serialchemy/_tests/test_serialization/test_one2many_pk_field.yml @@ -0,0 +1,6 @@ +employees: +- 1 +- 2 +id: 5 +location: Korhal +name: Terrans diff --git a/src/serialchemy/_tests/test_serialization/test_one2one_pk_field.yml b/src/serialchemy/_tests/test_serialization/test_one2one_pk_field.yml new file mode 100644 index 0000000..8bcf377 --- /dev/null +++ b/src/serialchemy/_tests/test_serialization/test_one2one_pk_field.yml @@ -0,0 +1,12 @@ +address: 1 +address_id: 1 +admission: '2000-01-01T00:00:00' +company: 5 +company_id: 5 +company_name: Terrans +created_at: '2000-01-02T00:00:00' +department: null +email: null +firstname: Sarah +id: 2 +lastname: Kerrigan diff --git a/src/serialchemy/conftest.py b/src/serialchemy/conftest.py index db8177f..ff7a857 100644 --- a/src/serialchemy/conftest.py +++ b/src/serialchemy/conftest.py @@ -7,10 +7,9 @@ @pytest.fixture() def db_session(): - engine = create_engine('sqlite:///:memory:', echo=True) + engine = create_engine('sqlite:///:memory:') Base.metadata.drop_all(engine) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() - yield session diff --git a/src/serialchemy/datetimeserializer.py b/src/serialchemy/datetimeserializer.py index 83e5030..d064108 100644 --- a/src/serialchemy/datetimeserializer.py +++ b/src/serialchemy/datetimeserializer.py @@ -1,8 +1,6 @@ import re from datetime import datetime, timedelta, timezone -from sqlalchemy import DateTime - from .serializer import ColumnSerializer @@ -33,7 +31,8 @@ def load(self, serialized, session=None): ) return dt - def _parse_tzinfo(self, offset_str): + @staticmethod + def _parse_tzinfo(offset_str): if not offset_str: return None elif offset_str.upper() == 'Z': @@ -45,17 +44,3 @@ def _parse_tzinfo(self, offset_str): if offset_str[0] == "-" and hours == 0: minutes = -minutes return timezone(timedelta(hours=hours, minutes=minutes)) - - -def is_datetime_field(col): - """ - Check if a column is DateTime (or implements DateTime) - - :param Column col: the column object to be checked - - :rtype: bool - """ - if hasattr(col.type, "impl"): - return type(col.type.impl) is DateTime - else: - return type(col.type) is DateTime diff --git a/src/serialchemy/enumserializer.py b/src/serialchemy/enumserializer.py index e653b17..bf8dcec 100644 --- a/src/serialchemy/enumserializer.py +++ b/src/serialchemy/enumserializer.py @@ -11,7 +11,3 @@ def dump(self, value): def load(self, serialized, session=None): enum = getattr(self.column.type, 'enum_class') return enum(serialized) - - -def is_enum_field(col): - return hasattr(col.type, 'enum_class') and getattr(col.type, 'enum_class') diff --git a/src/serialchemy/field.py b/src/serialchemy/field.py new file mode 100644 index 0000000..2bdeda9 --- /dev/null +++ b/src/serialchemy/field.py @@ -0,0 +1,34 @@ + +class Field(object): + """ + Configure a ModelSerializer field + """ + + def __init__(self, dump_only=False, load_only=False, serializer=None): + """ + :param bool dump_only: If True, field is not included on deserialization. + + :param bool load_only: If True, field is not included in serialization. + + :param Serializer serializer: define a custom serializer for the field. If none, + the field value is returned by dump. + """ + self.dump_only = dump_only + self.load_only = load_only + self._serializer = serializer + + @property + def serializer(self): + return self._serializer + + def dump(self, value): + if value and self.serializer: + return self.serializer.dump(value) + else: + return value + + def load(self, serialized, **kw): + if serialized and self.serializer: + return self.serializer.load(serialized, **kw) + else: + return serialized diff --git a/src/serialchemy/fields.py b/src/serialchemy/fields.py deleted file mode 100644 index 5cd5963..0000000 --- a/src/serialchemy/fields.py +++ /dev/null @@ -1,199 +0,0 @@ -from typing import Union - -from sqlalchemy.orm.dynamic import AppenderMixin - - -class Field(object): - """ - Configure a ModelSerializer field - """ - - def __init__(self, dump_only=False, load_only=False, serializer=None): - self.dump_only = dump_only - self.load_only = load_only - self._serializer = serializer - - @property - def serializer(self): - return self._serializer - - def dump(self, value): - if value and self.serializer: - return self.serializer.dump(value) - else: - return value - - def load(self, serialized, session=None): - if serialized and self.serializer: - return self.serializer.load(serialized, session=session) - else: - return serialized - - -class NestedModelListField(Field): - """ - A field to Dump and Update nested model list. - """ - - def __init__(self, declarative_class, **kw): - from .modelserializer import ModelSerializer - - super().__init__(**kw) - if self._serializer is None: - self._serializer = ModelSerializer(declarative_class) - - def load(self, serialized, session=None): - if not serialized: - return [] - class_mapper = self.serializer.model_class - pk_attr = get_pk_attr_name(class_mapper) - models = [] - for item in serialized: - pk = item.get(pk_attr) - if pk: - # Serialized object has a primary key, so we load an existing model from the database - # instead of creating one - existing_model = session.query.get(pk) - updated_model = self.serializer.load(item, existing_model, session=session) - models.append(updated_model) - else: - # No primary key, just create a new model entity - model = self.serializer.load(item) - models.append(model) - return models - - def dump(self, value): - if value and self.serializer: - return [self.serializer.dump(item) for item in value] - else: - return value - - -class NestedModelField(Field): - """ - A field to Dump and Update nested models. - """ - - def __init__(self, declarative_class, **kw): - from .modelserializer import ModelSerializer - - super().__init__(**kw) - if self._serializer is None: - self._serializer = ModelSerializer(declarative_class) - - def load(self, serialized, session=None): - if not serialized: - return None - class_mapper = self.serializer.model_class - pk_attr = get_pk_attr_name(class_mapper) - pk = serialized.get(pk_attr) - if pk: - # Serialized object has a primary key, so we load an existing model from the database - # instead of creating one - existing_model = session.query.get(pk) - return self.serializer.load(serialized, existing_model, session=session) - else: - # No primary key, just create a new model entity - return self.serializer.load(serialized, session=session) - - -class NestedAttributesField(Field): - """ - A read-only field that dump nested object attributes. - """ - from .serializer import Serializer - - class NestedAttributesSerializer(Serializer): - - def __init__(self, attributes, many): - self.attributes = attributes - self.many = many - - def dump(self, value): - if self.many: - serialized = [self._dump_item(item) for item in value] - else: - return self._dump_item(value) - return serialized - - def _dump_item(self, item): - serialized = {} - for attr_name in self.attributes: - serialized[attr_name] = getattr(item, attr_name) - return serialized - - def load(self, serialized, session=None): - raise NotImplementedError() - - def __init__(self, attributes: Union[tuple, dict], many=False): - serializer = self.NestedAttributesSerializer(attributes, many) - super().__init__(dump_only=True, serializer=serializer) - - -class PrimaryKeyField(Field): - """ - Convert relationships in a list of primary keys (for serialization and deserialization). - """ - from .serializer import Serializer - - class PrimaryKeySerializer(Serializer): - - def __init__(self, declarative_class): - self.declarative_class = declarative_class - self._pk_column = get_model_pk(self.declarative_class) - - def load(self, serialized, session=None): - pk_column = self._pk_column - query_results = session.query(self.declarative_class).filter(pk_column.in_(serialized)).all() - if len(serialized) != len(query_results): - raise ValueError("Not all primary keys found for '{}'".format(self._pk_column)) - return query_results - - def dump(self, value): - pk_column = self._pk_column - if is_tomany_attribute(value): - serialized = [getattr(item, pk_column.key) for item in value] - else: - return getattr(value, pk_column.key) - return serialized - - def __init__(self, declarative_class, **kw): - super().__init__(serializer=self.PrimaryKeySerializer(declarative_class), **kw) - - -def get_pk_attr_name(declarative_model): - """ - Get the primary key attribute name from a Declarative model class - - :param Type[DeclarativeMeta] declarative_class: a Declarative class - - :return: str: a Column name - """ - primary_keys = declarative_model.__mapper__.primary_key - assert len(primary_keys) == 1, "Nested object must have exactly one primary key" - pk_name = primary_keys[0].key - return pk_name - - -def get_model_pk(declarative_class): - """ - Get the primary key Column object from a Declarative model class - - :param Type[DeclarativeMeta] declarative_class: a Declarative class - - :rtype: Column - """ - primary_keys = declarative_class.__mapper__.primary_key - assert len(primary_keys) == 1, "Nested object must have exactly one primary key" - return primary_keys[0] - - -def is_tomany_attribute(value): - """ - Check if the Declarative relationship attribute represents a to-many relationship. - - :param value: a SQLAlchemy Declarative class relationship attribute - - :rtype: bool - """ - return isinstance(value, (list, AppenderMixin)) diff --git a/src/serialchemy/modelserializer.py b/src/serialchemy/modelserializer.py index a05d15f..f048e69 100644 --- a/src/serialchemy/modelserializer.py +++ b/src/serialchemy/modelserializer.py @@ -1,7 +1,8 @@ -from serialchemy.enumserializer import EnumSerializer, is_enum_field +from serialchemy.enumserializer import EnumSerializer +from serialchemy.serializer_checks import is_datetime_column, is_enum_column -from .datetimeserializer import DateTimeSerializer, is_datetime_field -from .fields import Field +from .datetimeserializer import DateTimeSerializer +from .field import Field from .serializer import Serializer @@ -10,6 +11,11 @@ class ModelSerializer(Serializer): Serializer for SQLAlchemy Declarative classes """ + EXTRA_SERIALIZERS = [ + (DateTimeSerializer, is_datetime_column), + (EnumSerializer, is_enum_column) + ] + def __init__(self, model_class): """ :param Type[DeclarativeMeta] model_class: the SQLAlchemy mapping class to be serialized @@ -17,16 +23,14 @@ def __init__(self, model_class): self._mapper_class = model_class self._fields = self._get_declared_fields() # Collect columns not declared in the serializer - self.session = None - for column_name in self.model_columns.keys(): + for column_name, column in self.model_columns.items(): field = self._fields.setdefault(column_name, Field()) - # Set a serializer for fields that can not be serialized by default if field.serializer is None: - column = self.model_columns[column_name] - if is_datetime_field(column): - field._serializer = DateTimeSerializer(column) - elif is_enum_field(column): - field._serializer = EnumSerializer(column) + # If no serializer is defined, check if the column type has some serialized + # registered in EXTRA_SERIALIZERS. + for serializer_class, serializer_check in self.EXTRA_SERIALIZERS: + if serializer_check(column): + field._serializer = serializer_class(column) @property def model_class(self): @@ -68,8 +72,12 @@ def load(self, serialized, existing_model=None, session=None): :param None|DeclarativeMeta existing_model: If given, the model will be updated with the serialized data. + :param None|Session session: a SQLAlchemy session. Used only to load nested models + :rtype: DeclarativeMeta """ + from .nested_fields import SessionBasedField + if existing_model: model = existing_model else: @@ -78,16 +86,17 @@ def load(self, serialized, existing_model=None, session=None): field = self._fields[field_name] if field.dump_only: continue - if field.serializer: - if session: - field.serializer.session = session - else: - field.serializer.session = self.session - deserialized = field.load(value, session=session) + if isinstance(field, SessionBasedField): + deserialized = field.load(value, session=session) + else: + deserialized = field.load(value) setattr(model, field_name, deserialized) return model - def get_model_name(self) -> str: + def get_model_name(self): + """ + :rtype: str + """ return self._mapper_class.__name__ @classmethod diff --git a/src/serialchemy/nested_fields.py b/src/serialchemy/nested_fields.py new file mode 100644 index 0000000..6e266ac --- /dev/null +++ b/src/serialchemy/nested_fields.py @@ -0,0 +1,192 @@ +from warnings import warn + +from sqlalchemy.orm.dynamic import AppenderMixin + +from .field import Field +from .serializer import Serializer +from .modelserializer import ModelSerializer + + +class SessionBasedField(Field): + """ + Base class for fields that requires a SQLAlchemy session + """ + + def load(self, serialized, session): + raise NotImplementedError('load method not implemented') + + +class PrimaryKeyField(SessionBasedField): + """ + Convert relationships in a list of primary keys (for serialization and deserialization). + """ + def __init__(self, model_class, **kwargs): + super().__init__(**kwargs) + if self._serializer is None: + self._serializer = PrimaryKeySerializer(model_class) + + def load(self, serialized, session): + return self.serializer.load(serialized, session) + + +class NestedModelField(SessionBasedField): + """ + A field to Dump and Update nested models. + """ + + def __init__(self, model_class, **kwargs): + super().__init__(**kwargs) + if self._serializer is None: + self._serializer = ModelSerializer(model_class) + + def load(self, serialized, session): + if not serialized: + return None + class_mapper = self.serializer.model_class + pk_attr = get_model_pk_attr_name(class_mapper) + pk = serialized.get(pk_attr) + if pk: + # Serialized object has a primary key, so we load an existing model from the database + # instead of creating one + existing_model = session.query(class_mapper).get(pk) + return self.serializer.load(serialized, existing_model, session=session) + else: + # No primary key, just create a new model entity + return self.serializer.load(serialized, session=session) + + +class NestedModelListField(SessionBasedField): + """ + A field to Dump and Update nested model list. + """ + + def __init__(self, model_class, **kwargs): + super().__init__(**kwargs) + if self._serializer is None: + self._serializer = ModelSerializer(model_class) + + def load(self, serialized, session): + if not serialized: + return [] + class_mapper = self.serializer.model_class + pk_attr = get_model_pk_attr_name(class_mapper) + models = [] + for item in serialized: + pk = item.get(pk_attr) + if pk: + # Serialized object has a primary key, so we load an existing model from the database + # instead of creating one + existing_model = session.query.get(pk) + updated_model = self.serializer.load(item, existing_model, session=session) + models.append(updated_model) + else: + # No primary key, just create a new model entity + model = self.serializer.load(item) + models.append(model) + return models + + def dump(self, value): + if value and self.serializer: + return [self.serializer.dump(item) for item in value] + else: + return value + + +class NestedAttributesField(Field): + """ + A read-only field that dump selected nested object attributes. + """ + + def __init__(self, attributes, many=False): + """ + + :param Union[attr|dict] attributes: + :param many: + """ + serializer = NestedAttributesSerializer(attributes, many) + super().__init__(dump_only=True, serializer=serializer) + + +class PrimaryKeySerializer(SessionBasedField): + + def __init__(self, model_class, **kwargs): + super().__init__(**kwargs) + self.model_class = model_class + self._pk_column = get_model_pk_column(self.model_class) + + def load(self, serialized, session): + pk_column = self._pk_column + query_results = session\ + .query(self.model_class)\ + .filter(pk_column.in_(serialized))\ + .all() + if len(serialized) != len(query_results): + warn("Not all primary keys found for '{}.{}'".format( + self.model_class.__name__, self._pk_column + )) + return query_results + + def dump(self, value): + + def is_tomany_attribute(column): + """ + Check if the Declarative relationship attribute represents a to-many relationship. + """ + return isinstance(column, (list, AppenderMixin)) + + pk_column = self._pk_column + if is_tomany_attribute(value): + serialized = [getattr(item, pk_column.key) for item in value] + else: + return getattr(value, pk_column.key) + return serialized + + +class NestedAttributesSerializer(Serializer): + + def __init__(self, attributes, many): + self.attributes = attributes + self.many = many + + def dump(self, value): + if self.many: + serialized = [self._dump_item(item) for item in value] + else: + return self._dump_item(value) + return serialized + + def _dump_item(self, item): + serialized = {} + for attr_name in self.attributes: + serialized[attr_name] = getattr(item, attr_name) + return serialized + + def load(self, serialized, session=None): + raise NotImplementedError() + + +def get_model_pk_attr_name(model_class): + """ + Get the primary key attribute name from a Declarative model class + + :param Type[DeclarativeMeta] model_class: a Declarative class + + :return: str: a Column name + """ + primary_keys = model_class.__mapper__.primary_key + assert len(primary_keys) == 1, "Nested object must have exactly one primary key" + pk_name = primary_keys[0].key + return pk_name + + +def get_model_pk_column(model_class): + """ + Get the primary key Column object from a Declarative model class + + :param Type[DeclarativeMeta] model_class: a Declarative class + + :rtype: Column + """ + primary_keys = model_class.__mapper__.primary_key + assert len(primary_keys) == 1, "Nested object must have exactly one primary key" + return primary_keys[0] diff --git a/src/serialchemy/serializer_checks.py b/src/serialchemy/serializer_checks.py new file mode 100644 index 0000000..76df8f8 --- /dev/null +++ b/src/serialchemy/serializer_checks.py @@ -0,0 +1,19 @@ + +def is_datetime_column(col): + """ + Check if a column is DateTime (or implements DateTime) + + :param Column col: the column object to be checked + + :rtype: bool + """ + from sqlalchemy import DateTime + + if hasattr(col.type, "impl"): + return type(col.type.impl) is DateTime + else: + return type(col.type) is DateTime + + +def is_enum_column(col): + return hasattr(col.type, 'enum_class') and getattr(col.type, 'enum_class') diff --git a/src/serialchemy/swagger_spec.py b/src/serialchemy/swagger_spec.py index a5bd5e8..dae70ab 100644 --- a/src/serialchemy/swagger_spec.py +++ b/src/serialchemy/swagger_spec.py @@ -2,7 +2,7 @@ from sqlalchemy_utils import PasswordType, JSONType from .modelserializer import ModelSerializer -from .fields import Field, NestedModelField, NestedAttributesField, PrimaryKeyField +from .field import Field, NestedModelField, NestedAttributesField, PrimaryKeyField SWAGGER_BASIC_TYPES = { str: dict(type='string'), diff --git a/tox.ini b/tox.ini index 7ae81b9..20aa0a7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py36, linting, docs +envlist = py36, linting [travis] python = 3.6: py36 @@ -16,12 +16,3 @@ skip_install = True basepython = python3.6 deps = pre-commit>=1.11.0 commands = pre-commit run --all-files --show-diff-on-failure - -[testenv:docs] -skipsdist = True -usedevelop = True -changedir = docs -extras = docs - -commands = - sphinx-build -W -b html . _build