├── src └── serialchemy │ ├── _tests │ ├── __init__.py │ ├── test_serialization │ │ ├── test_one2many_pk_field_declarative_.yml │ │ ├── test_one2many_pk_field_imperative_.yml │ │ ├── test_model_dump.yml │ │ ├── test_dump_choice_type.yml │ │ ├── test_enum_key_field_load.yml │ │ ├── test_model_load_declarative_.yml │ │ ├── test_one2one_pk_field.yml │ │ ├── test_enum_key_field_load_declarative_.yml │ │ ├── test_model_load_imperative_.yml │ │ ├── test_enum_key_field_load_imperative_.yml │ │ └── test_enum_key_field_dump.yml │ ├── test_func │ │ └── test_dump.yml │ ├── test_nested_fields │ │ ├── test_deserialize_with_custom_serializer_declarative_.yml │ │ ├── test_custom_serializer_EmployeeSerializerNestedAttrsFields.yml │ │ ├── test_deserialize_with_custom_serializer_imperative_.yml │ │ ├── test_load_with_nested_polymorphic_same_table_pk_names.yml │ │ ├── test_custom_serializer_EmployeeSerializerNestedModelFields.yml │ │ ├── test_load_with_nested_polymorphic_with_different_table_pk_names_imperative_.yml │ │ ├── test_load_with_nested_polymorphic_with_different_table_pk_names_declarative_.yml │ │ ├── test_dump_with_nested_polymorphic_declarative_.yml │ │ └── test_dump_with_nested_polymorphic_imperative_.yml │ ├── test_field.py │ ├── test_func.py │ ├── test_readme.py │ ├── test_polymorphic_serializer.py │ ├── test_datetimeserializer.py │ ├── sample_model_imperative │ │ ├── model.py │ │ └── orm_mapping.py │ ├── sample_model.py │ ├── test_nested_fields.py │ └── test_serialization.py │ ├── serializer.py │ ├── enum_field.py │ ├── __init__.py │ ├── enum_serializer.py │ ├── serializer_checks.py │ ├── func.py │ ├── conftest.py │ ├── field.py │ ├── datetime_serializer.py │ ├── polymorphic_serializer.py │ ├── nested_fields.py │ └── model_serializer.py ├── .readthedocs.yml ├── mypy.ini ├── sonar-project.properties ├── .project ├── .editorconfig ├── .pydevproject ├── .codecov.yml ├── .isort.cfg ├── LICENSE ├── .pre-commit-config.yaml ├── .gitignore ├── .gitattributes ├── CONTRIBUTING.rst ├── pixi.toml ├── pyproject.toml ├── CHANGELOG.rst ├── .github └── workflows │ └── main.yml └── README.rst /src/serialchemy/_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | 2 | build: 3 | image: latest 4 | 5 | python: 6 | version: 3.6 7 | setup_py_install: true 8 | pip_install: true 9 | extra_requirements: 10 | - docs 11 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files = src 3 | ignore_missing_imports = True 4 | no_implicit_optional = True 5 | show_error_codes = True 6 | strict_equality = True 7 | warn_redundant_casts = True 8 | warn_unused_configs = True 9 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization/test_one2many_pk_field_declarative_.yml: -------------------------------------------------------------------------------- 1 | employees: 2 | - 1 3 | - 2 4 | - 3 5 | - 4 6 | id: 5 7 | location: Korhal 8 | master_engeneer_id: null 9 | master_manager_id: null 10 | name: Terrans 11 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization/test_one2many_pk_field_imperative_.yml: -------------------------------------------------------------------------------- 1 | employees: 2 | - 1 3 | - 2 4 | - 3 5 | - 4 6 | id: 5 7 | location: Korhal 8 | master_engeneer_id: null 9 | master_manager_id: null 10 | name: Terrans 11 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=esss-sonarcloud 2 | sonar.projectKey=ESSS_serialchemy 3 | 4 | # relative paths to source directories. More details and properties are described 5 | # in https://sonarcloud.io/documentation/project-administration/narrowing-the-focus/ 6 | sonar.sources=./src/serialchemy 7 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | serialchemy 4 | 5 | 6 | 7 | 8 | 9 | 10 | org.python.pydev.pythonNature 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization/test_model_dump.yml: -------------------------------------------------------------------------------- 1 | address_id: 1 2 | admission: '2000-01-01' 3 | company_id: 5 4 | contract_type: Contractor 5 | created_at: '2000-01-02T00:00:00' 6 | email: some 7 | firstname: Jim 8 | id: 1 9 | lastname: Raynor 10 | marital_status: Married 11 | password: mypass 12 | role: Manager 13 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization/test_dump_choice_type.yml: -------------------------------------------------------------------------------- 1 | address_id: 1 2 | admission: '2000-01-01' 3 | company_id: 5 4 | contract_type: Employee 5 | created_at: '2000-01-02T00:00:00' 6 | email: some 7 | firstname: Tychus 8 | id: 3 9 | lastname: Findlay 10 | marital_status: Single 11 | password: mypass 12 | role: Employee 13 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization/test_enum_key_field_load.yml: -------------------------------------------------------------------------------- 1 | address_id: null 2 | admission: '2152-01-02' 3 | company_id: null 4 | contract_type: null 5 | created_at: null 6 | email: sarahk@blitz.com 7 | firstname: Sarah 8 | id: null 9 | lastname: Kerrigan 10 | marital_status: Married 11 | password: null 12 | role: Employee 13 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization/test_model_load_declarative_.yml: -------------------------------------------------------------------------------- 1 | address_id: null 2 | admission: '2152-01-02' 3 | company_id: null 4 | contract_type: null 5 | created_at: null 6 | email: sarahk@blitz.com 7 | firstname: Sarah 8 | id: null 9 | lastname: Kerrigan 10 | marital_status: Married 11 | password: null 12 | role: Employee 13 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization/test_one2one_pk_field.yml: -------------------------------------------------------------------------------- 1 | address: 1 2 | address_id: 1 3 | admission: '2000-01-01' 4 | company: 5 5 | company_id: 5 6 | contract_type: Other 7 | created_at: '2000-01-02T00:00:00' 8 | email: some 9 | firstname: Sarah 10 | id: 2 11 | lastname: Kerrigan 12 | marital_status: Married 13 | role: Engineer 14 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization/test_enum_key_field_load_declarative_.yml: -------------------------------------------------------------------------------- 1 | address_id: null 2 | admission: '2152-01-02' 3 | company_id: null 4 | contract_type: null 5 | created_at: null 6 | email: sarahk@blitz.com 7 | firstname: Sarah 8 | id: null 9 | lastname: Kerrigan 10 | marital_status: Married 11 | password: pass 12 | role: Employee 13 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization/test_model_load_imperative_.yml: -------------------------------------------------------------------------------- 1 | address_id: null 2 | admission: '2152-01-02' 3 | company_id: null 4 | contract_type: null 5 | created_at: '2000-01-02T00:00:00' 6 | email: sarahk@blitz.com 7 | firstname: Sarah 8 | id: null 9 | lastname: Kerrigan 10 | marital_status: Married 11 | password: null 12 | role: Employee 13 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization/test_enum_key_field_load_imperative_.yml: -------------------------------------------------------------------------------- 1 | address_id: null 2 | admission: '2152-01-02' 3 | company_id: null 4 | contract_type: null 5 | created_at: '2000-01-02T00:00:00' 6 | email: sarahk@blitz.com 7 | firstname: Sarah 8 | id: null 9 | lastname: Kerrigan 10 | marital_status: Married 11 | password: pass 12 | role: Employee 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /src/serialchemy/serializer.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from abc import abstractmethod 3 | 4 | 5 | class Serializer(ABC): 6 | @abstractmethod 7 | def dump(self, value): 8 | pass 9 | 10 | @abstractmethod 11 | def load(self, serialized, **kw): 12 | pass 13 | 14 | 15 | class ColumnSerializer(Serializer): 16 | def __init__(self, column): 17 | self.column = column 18 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_func/test_dump.yml: -------------------------------------------------------------------------------- 1 | address: null 2 | admission: '2000-01-01' 3 | company: 4 | id: 5 5 | location: Korhal 6 | master_engeneer_id: null 7 | master_manager_id: null 8 | name: Terrans 9 | contract_type: null 10 | created_at: '2000-01-02T00:00:00' 11 | email: some@email.com 12 | firstname: Sarah 13 | id: 2 14 | lastname: Kerrigan 15 | marital_status: Divorced 16 | password: somepass 17 | role: Employee 18 | -------------------------------------------------------------------------------- /.pydevproject: -------------------------------------------------------------------------------- 1 | 2 | 3 | Default 4 | python interpreter 5 | 6 | /${PROJECT_DIR_NAME}/src 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_nested_fields/test_deserialize_with_custom_serializer_declarative_.yml: -------------------------------------------------------------------------------- 1 | address: 2 | city: Tarsonis 3 | id: 1 4 | number: '245' 5 | state: Mich 6 | street: 6 Av 7 | zip: 88088-000 8 | address_id: null 9 | admission: '2004-06-01' 10 | company: null 11 | company_id: 5 12 | contract_type: null 13 | created_at: null 14 | email: some@email.com 15 | firstname: John 16 | id: null 17 | lastname: Doe 18 | marital_status: Married 19 | role: Employee 20 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization/test_enum_key_field_dump.yml: -------------------------------------------------------------------------------- 1 | address: 2 | city: Tarsonis 3 | id: 1 4 | number: '943' 5 | state: NA 6 | street: 5 Av 7 | zip: null 8 | address_id: 1 9 | admission: '2000-01-01' 10 | company_id: 5 11 | company_name: Terrans 12 | contacts: [] 13 | contract_type: Contractor 14 | created_at: '2000-01-02T00:00:00' 15 | email: some 16 | firstname: Jim 17 | id: 1 18 | lastname: Raynor 19 | marital_status: MARRIED 20 | role: Manager 21 | -------------------------------------------------------------------------------- /src/serialchemy/enum_field.py: -------------------------------------------------------------------------------- 1 | from serialchemy.enum_serializer import EnumKeySerializer 2 | from serialchemy.field import Field 3 | 4 | 5 | class EnumKeyField(Field): 6 | def __init__(self, enum_class, dump_only=False, load_only=False, creation_only=False): 7 | super().__init__( 8 | dump_only=dump_only, 9 | load_only=load_only, 10 | creation_only=creation_only, 11 | serializer=EnumKeySerializer(enum_class), 12 | ) 13 | -------------------------------------------------------------------------------- /src/serialchemy/__init__.py: -------------------------------------------------------------------------------- 1 | from .enum_field import EnumKeyField 2 | from .field import Field 3 | from .model_serializer import ModelSerializer 4 | from .nested_fields import NestedAttributesField 5 | from .nested_fields import NestedModelField 6 | from .nested_fields import NestedModelListField 7 | from .nested_fields import PrimaryKeyField 8 | from .polymorphic_serializer import PolymorphicModelSerializer 9 | from .serializer import ColumnSerializer 10 | from .serializer import Serializer 11 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_nested_fields/test_custom_serializer_EmployeeSerializerNestedAttrsFields.yml: -------------------------------------------------------------------------------- 1 | address: 2 | city: Tarsonis 3 | id: 1 4 | number: '943' 5 | street: 5 Av 6 | address_id: 1 7 | admission: '2000-01-01' 8 | company: 9 | location: Korhal 10 | name: Terrans 11 | company_id: 5 12 | contract_type: null 13 | created_at: '2000-01-02T00:00:00' 14 | email: some@email.com 15 | firstname: Jim 16 | id: 1 17 | lastname: Raynor 18 | marital_status: Married 19 | role: Manager 20 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_nested_fields/test_deserialize_with_custom_serializer_imperative_.yml: -------------------------------------------------------------------------------- 1 | address: 2 | city: Tarsonis 3 | id: 1 4 | number: '245' 5 | state: Mich 6 | street: 6 Av 7 | zip: 88088-000 8 | address_id: null 9 | admission: '2004-06-01' 10 | company: null 11 | company_id: 5 12 | contract_type: null 13 | created_at: '2000-01-02T00:00:00' 14 | email: some@email.com 15 | firstname: John 16 | id: null 17 | lastname: Doe 18 | marital_status: Married 19 | role: Employee 20 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | 2 | codecov: 3 | notify: 4 | require_ci_to_pass: yes 5 | 6 | coverage: 7 | precision: 2 8 | round: down 9 | range: "70...100" 10 | 11 | status: 12 | project: yes 13 | patch: yes 14 | changes: no 15 | 16 | parsers: 17 | gcov: 18 | branch_detection: 19 | conditional: yes 20 | loop: yes 21 | method: no 22 | macro: no 23 | 24 | comment: 25 | layout: "header, diff" 26 | behavior: default 27 | require_changes: no 28 | 29 | ignore: 30 | - "setup.py" 31 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_nested_fields/test_load_with_nested_polymorphic_same_table_pk_names.yml: -------------------------------------------------------------------------------- 1 | id: 5 2 | location: Delawere 3 | master_engeneer: null 4 | master_engeneer_id: null 5 | master_manager: 6 | address_id: 1 7 | admission: '2000-01-01' 8 | company_id: 5 9 | contract_type: null 10 | created_at: '2000-01-02T00:00:00' 11 | email: some@email.com 12 | firstname: Jim 13 | id: 1 14 | lastname: Raynor 15 | manager_name: null 16 | marital_status: Married 17 | password: somepass 18 | role: Manager 19 | master_manager_id: null 20 | name: Company 1 21 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_nested_fields/test_custom_serializer_EmployeeSerializerNestedModelFields.yml: -------------------------------------------------------------------------------- 1 | address: 2 | city: Tarsonis 3 | id: 1 4 | number: '943' 5 | state: Mich 6 | street: 5 Av 7 | zip: null 8 | address_id: 1 9 | admission: '2000-01-01' 10 | company: 11 | id: 5 12 | location: Korhal 13 | master_engeneer_id: 4 14 | master_manager_id: 1 15 | name: Terrans 16 | company_id: 5 17 | contract_type: null 18 | created_at: '2000-01-02T00:00:00' 19 | email: some@email.com 20 | firstname: Jim 21 | id: 1 22 | lastname: Raynor 23 | marital_status: Married 24 | role: Manager 25 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_field.py: -------------------------------------------------------------------------------- 1 | from serialchemy import Field 2 | from serialchemy import Serializer 3 | 4 | 5 | class CustomSerializer(Serializer): 6 | def dump(self, value): 7 | return f'dumped {value}' 8 | 9 | def load(self, serialized, **kw): 10 | return f'loaded {serialized}' 11 | 12 | 13 | def test_field_serializer_is_called_for_falsy_values(): 14 | field = Field() 15 | assert field.dump('') == '' 16 | assert field.load('') == '' 17 | 18 | custom_field = Field(serializer=CustomSerializer()) 19 | assert custom_field.dump('') == 'dumped ' 20 | assert custom_field.load('') == 'loaded ' 21 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_nested_fields/test_load_with_nested_polymorphic_with_different_table_pk_names_imperative_.yml: -------------------------------------------------------------------------------- 1 | id: 5 2 | location: Delawere 3 | master_engeneer: 4 | address_id: null 5 | admission: '2000-01-01' 6 | company_id: null 7 | contract_type: null 8 | created_at: '2000-01-02T00:00:00' 9 | email: some@email.com 10 | engineer_name: null 11 | firstname: Doran 12 | id: 4 13 | lastname: Routhe 14 | marital_status: Married 15 | password: somepass 16 | role: Specialist Engineer 17 | specialization: Mechanical 18 | master_engeneer_id: null 19 | master_manager: null 20 | master_manager_id: null 21 | name: Company 1 22 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_nested_fields/test_load_with_nested_polymorphic_with_different_table_pk_names_declarative_.yml: -------------------------------------------------------------------------------- 1 | id: 5 2 | location: Delawere 3 | master_engeneer: 4 | address_id: null 5 | admission: '2000-01-01' 6 | company_id: null 7 | contract_type: null 8 | created_at: '2000-01-02T00:00:00' 9 | email: some@email.com 10 | engineer_name: null 11 | firstname: Doran 12 | id: 4 13 | lastname: Routhe 14 | marital_status: Married 15 | password: somepass 16 | role: Specialist Engineer 17 | specialization: Mechanical 18 | master_engeneer_id: null 19 | master_manager: null 20 | master_manager_id: null 21 | name: Company 1 22 | -------------------------------------------------------------------------------- /src/serialchemy/enum_serializer.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Type 3 | 4 | from .serializer import ColumnSerializer 5 | from .serializer import Serializer 6 | 7 | 8 | class EnumSerializer(ColumnSerializer): 9 | def dump(self, value): 10 | if not value: 11 | return None 12 | return value.value 13 | 14 | def load(self, serialized, session=None): 15 | enum = getattr(self.column.type, 'enum_class') 16 | return enum(serialized) 17 | 18 | 19 | class EnumKeySerializer(Serializer): 20 | def __init__(self, enum_class: Type[Enum]) -> None: 21 | super().__init__() 22 | self.enum_class = enum_class 23 | 24 | def dump(self, value): 25 | if not value: 26 | return None 27 | return value.name 28 | 29 | def load(self, serialized, session=None): 30 | return self.enum_class[serialized] 31 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | line_length=100 3 | multi_line_output=4 4 | use_parentheses=true 5 | known_standard_library=Bastion,CGIHTTPServer,DocXMLRPCServer,HTMLParser,MimeWriter,SimpleHTTPServer,UserDict,UserList,UserString,aifc,antigravity,ast,audiodev,bdb,binhex,cgi,chunk,code,codeop,colorsys,cookielib,copy_reg,dummy_thread,dummy_threading,formatter,fpformat,ftplib,genericpath,htmlentitydefs,htmllib,httplib,ihooks,imghdr,imputil,keyword,macpath,macurl2path,mailcap,markupbase,md5,mimetools,mimetypes,mimify,modulefinder,multifile,mutex,netrc,new,nntplib,ntpath,nturl2path,numbers,opcode,os2emxpath,pickletools,popen2,poplib,posixfile,posixpath,pty,py_compile,quopri,repr,rexec,rfc822,runpy,sets,sgmllib,sha,sndhdr,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statvfs,stringold,stringprep,sunau,sunaudio,symbol,symtable,telnetlib,this,toaiff,token,tokenize,tty,types,typing,user,uu,wave,xdrlib,xmllib 6 | known_third_party=six,six.moves,sip 7 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_nested_fields/test_dump_with_nested_polymorphic_declarative_.yml: -------------------------------------------------------------------------------- 1 | id: 5 2 | location: Korhal 3 | master_engeneer: 4 | address_id: null 5 | admission: '2000-01-01' 6 | company_id: null 7 | contract_type: null 8 | created_at: '2000-01-02T00:00:00' 9 | email: some@email.com 10 | engineer_name: null 11 | firstname: Doran 12 | id: 4 13 | lastname: Routhe 14 | marital_status: Married 15 | password: somepass 16 | role: Specialist Engineer 17 | specialization: Mechanical 18 | master_engeneer_id: 4 19 | master_manager: 20 | address_id: 1 21 | admission: '2000-01-01' 22 | company_id: 5 23 | contract_type: null 24 | created_at: '2000-01-02T00:00:00' 25 | email: some@email.com 26 | firstname: Jim 27 | id: 1 28 | lastname: Raynor 29 | manager_name: null 30 | marital_status: Married 31 | password: somepass 32 | role: Manager 33 | master_manager_id: 1 34 | name: Terrans 35 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_nested_fields/test_dump_with_nested_polymorphic_imperative_.yml: -------------------------------------------------------------------------------- 1 | id: 5 2 | location: Korhal 3 | master_engeneer: 4 | address_id: null 5 | admission: '2000-01-01' 6 | company_id: null 7 | contract_type: null 8 | created_at: '2000-01-02T00:00:00' 9 | email: some@email.com 10 | engineer_name: null 11 | firstname: Doran 12 | id: 4 13 | lastname: Routhe 14 | marital_status: Married 15 | password: somepass 16 | role: Specialist Engineer 17 | specialization: Mechanical 18 | master_engeneer_id: 4 19 | master_manager: 20 | address_id: 1 21 | admission: '2000-01-01' 22 | company_id: 5 23 | contract_type: null 24 | created_at: '2000-01-02T00:00:00' 25 | email: some@email.com 26 | firstname: Jim 27 | id: 1 28 | lastname: Raynor 29 | manager_name: null 30 | marital_status: Married 31 | password: somepass 32 | role: Manager 33 | master_manager_id: 1 34 | name: Terrans 35 | -------------------------------------------------------------------------------- /src/serialchemy/serializer_checks.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column 2 | 3 | 4 | def is_datetime_column(col): 5 | """ 6 | Check if a column is DateTime (or implements DateTime) 7 | 8 | :param Column col: the column object to be checked 9 | 10 | :rtype: bool 11 | """ 12 | from sqlalchemy import DateTime 13 | 14 | if not isinstance(col, Column): 15 | return False 16 | 17 | if hasattr(col.type, "impl"): 18 | return type(col.type.impl) is DateTime 19 | else: 20 | return type(col.type) is DateTime 21 | 22 | 23 | def is_date_column(col): 24 | if not isinstance(col, Column): 25 | return False 26 | 27 | from sqlalchemy import Date 28 | 29 | return type(col.type) is Date 30 | 31 | 32 | def is_enum_column(col): 33 | if not isinstance(col, Column): 34 | return False 35 | 36 | return hasattr(col.type, 'enum_class') and getattr(col.type, 'enum_class') 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 ESSS 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 | -------------------------------------------------------------------------------- /src/serialchemy/func.py: -------------------------------------------------------------------------------- 1 | from serialchemy import ModelSerializer 2 | 3 | 4 | def dump(model, nest_foreign_keys=False): 5 | """ 6 | Serialize a SQLAlchemy model. 7 | 8 | :param model: the SQLAlchemy model instance 9 | :type model: sqlalchemy.ext.declarative.DeclarativeMeta 10 | 11 | :param bool nest_foreign_keys: If True, serialize any foreign key column as a nested object. 12 | 13 | :rtype: dict 14 | """ 15 | serializer = ModelSerializer(model.__class__, nest_foreign_keys=nest_foreign_keys) 16 | return serializer.dump(model) 17 | 18 | 19 | def load(serialized, model_class, nest_foreign_keys=False): 20 | """ 21 | Deserialize a dict into a SQLAlchemy model. 22 | 23 | :param dict serialized: the serialized object. 24 | 25 | :param model_class: the SQLAlchemy model class. 26 | :type model_class: Type[sqlalchemy.ext.declarative.DeclarativeMeta] 27 | 28 | :param bool nest_foreign_keys: If True, serialized content has foreign keys as nested object. 29 | 30 | :rtype: model_class 31 | """ 32 | serializer = ModelSerializer(model_class, nest_foreign_keys=nest_foreign_keys) 33 | return serializer.load(serialized) 34 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 22.10.0 4 | hooks: 5 | - id: black 6 | args: [--safe, --quiet] 7 | language_version: python3 8 | - repo: https://github.com/asottile/blacken-docs 9 | rev: v1.12.1 10 | hooks: 11 | - id: blacken-docs 12 | additional_dependencies: [black==21.12b0] 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v4.3.0 15 | hooks: 16 | - id: trailing-whitespace 17 | - id: end-of-file-fixer 18 | - id: debug-statements 19 | - repo: https://github.com/asottile/reorder_python_imports 20 | rev: v3.9.0 21 | hooks: 22 | - id: reorder-python-imports 23 | args: ['--application-directories=.:src', --py36-plus] 24 | - repo: local 25 | hooks: 26 | - id: rst 27 | name: rst 28 | entry: rst-lint --encoding utf-8 29 | files: ^(CHANGELOG.rst|HOWTORELEASE.rst|README.rst)$ 30 | language: python 31 | additional_dependencies: [pygments, restructuredtext_lint] 32 | - repo: https://github.com/pre-commit/mirrors-mypy 33 | rev: v0.982 34 | hooks: 35 | - id: mypy 36 | files: ^(src/) 37 | args: [] 38 | additional_dependencies: [types-attrs] 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | .*cache 4 | *.egg-info 5 | .installed.cfg 6 | *.egg 7 | .~* 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Project settings 40 | .idea/ 41 | .settings/ 42 | .project 43 | .pydevproject 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | 61 | /docs/build/ 62 | *tmp/ 63 | 64 | # conda env 65 | environment.yml 66 | 67 | # VSCode local history 68 | .history 69 | .vscode 70 | 71 | # mypy 72 | .mypy_cache/ 73 | # pixi environments 74 | .pixi/* 75 | !.pixi/config.toml 76 | 77 | # Generated version file 78 | src/serialchemy/_version.py 79 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_func.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from serialchemy import func 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def seed_data(model, db_session): 8 | company = model.Company(id=5, name='Terrans', location='Korhal') 9 | employee = model.Employee( 10 | id=2, 11 | firstname='Sarah', 12 | lastname='Kerrigan', 13 | email='some@email.com', 14 | password='somepass', 15 | role='Employee', 16 | company=company, 17 | marital_status=model.MaritalStatus.DIVORCED, 18 | ) 19 | db_session.add(employee) 20 | db_session.commit() 21 | 22 | 23 | def test_dump(model, db_session, data_regression): 24 | employee = db_session.get(model.Employee, 2) 25 | serial = func.dump(employee, nest_foreign_keys=True) 26 | data_regression.check(serial, basename='test_dump') 27 | 28 | 29 | def test_load(model, db_session): 30 | data = dict( 31 | email='some@email.com', 32 | role='Employee', 33 | firstname='Sarah', 34 | lastname='Kerrigan', 35 | company=dict(location='some', name='Terrans'), 36 | ) 37 | employee = func.load(data, model.Employee, nest_foreign_keys=True) 38 | assert employee.role == "Employee" 39 | assert employee.firstname == "Sarah" 40 | assert employee.company_name == "Terrans" 41 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Source code 5 | *.c text 6 | *.cpp text 7 | *.h text 8 | *.hpp text 9 | *.cxx text 10 | *.hxx text 11 | *.py text 12 | *.pyx text 13 | *.pxd text 14 | # Configuration files and scripts 15 | *.sh text eol=lf 16 | *.bat text eol=crlf 17 | *.cmd text eol=crlf 18 | *.cfg text 19 | *.csv text eol=lf 20 | *.cmake text 21 | *.json text 22 | *.jinja2 text 23 | *.yaml text 24 | *.yml text 25 | *.xml text 26 | *.md text 27 | *.txt text 28 | *.ini text 29 | *.ps1 text 30 | .coveragerc text 31 | .gitignore text 32 | .mu_repo text 33 | .cproject text 34 | .project text 35 | .pydevproject text 36 | # Documentation 37 | *.css text 38 | *.html text 39 | *.rst text 40 | *.in text 41 | LICENSE text 42 | Doxyfile text 43 | # Binary files 44 | *.png binary 45 | *.jpg binary 46 | *.jpeg binary 47 | *.db binary 48 | *.pickle binary 49 | *.h5 binary 50 | *.hdf binary 51 | *.xls binary 52 | *.xlsx binary 53 | *.db binary 54 | *.p binary 55 | *.pkl binary 56 | *.pyc binary 57 | *.pyd binary 58 | *.pyo binary 59 | # SCM syntax highlighting & preventing 3-way merges 60 | pixi.lock merge=binary linguist-language=YAML linguist-generated=true 61 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | 11 | Get Started! 12 | ------------ 13 | 14 | Ready to contribute? Here's how to set up `serialchemy` for local development. 15 | 16 | #. Fork the `serialchemy` repo on GitHub. 17 | #. Clone your fork locally:: 18 | 19 | $ git clone git@github.com:your_github_username_here/serialchemy.git 20 | 21 | #. Install pixi 22 | 23 | See https://pixi.sh/latest/installation/ 24 | 25 | #. Install pre-commit 26 | 27 | $ pixi run setup 28 | 29 | #. Create a branch for local development:: 30 | 31 | $ git checkout -b name-of-your-bugfix-or-feature 32 | 33 | Now you can make your changes locally. 34 | 35 | #. When you're done making changes, run the tests:: 36 | 37 | $ pixi run test 38 | 39 | #. Commit your changes and push your branch to GitHub:: 40 | 41 | $ git add . 42 | $ git commit -m "Your detailed description of your changes." 43 | $ git push origin name-of-your-bugfix-or-feature 44 | 45 | #. Submit a pull request through the GitHub website. 46 | 47 | Pull Request Guidelines 48 | ----------------------- 49 | 50 | Before you submit a pull request, check that it meets these guidelines: 51 | 52 | 1. The pull request should include tests. 53 | 2. If the pull request adds functionality, the docs should be updated. 54 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_readme.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import Column 4 | from sqlalchemy import DateTime 5 | from sqlalchemy import ForeignKey 6 | from sqlalchemy import Integer 7 | from sqlalchemy import select 8 | from sqlalchemy import String 9 | from sqlalchemy.orm import column_property 10 | from sqlalchemy.orm import declarative_base 11 | from sqlalchemy.orm import relationship 12 | 13 | Base = declarative_base() 14 | 15 | 16 | class Company(Base): # type: ignore 17 | __tablename__ = 'Company' 18 | 19 | id = Column(Integer, primary_key=True) 20 | name = Column(String) 21 | 22 | 23 | class Employee(Base): # type: ignore 24 | __tablename__ = 'Employee' 25 | 26 | id = Column(Integer, primary_key=True) 27 | fullname = Column(String) 28 | admission = Column(DateTime, default=datetime(2000, 1, 1)) 29 | company_id = Column(ForeignKey('Company.id')) 30 | company = relationship(Company) 31 | company_name = column_property( 32 | select(Company.name).where(Company.id == company_id).scalar_subquery() 33 | ) 34 | password = Column(String) 35 | 36 | 37 | def test_example_1(db_session): 38 | from serialchemy import ModelSerializer 39 | 40 | emp = Employee(fullname='Roberto Silva', admission=datetime(2019, 4, 2)) 41 | serializer = ModelSerializer(Employee) 42 | serializer.dump(emp) 43 | 44 | new_employee = {'fullname': 'Jobson Gomes', 'admission': '2018-02-03T00:00:00'} 45 | serializer.load(new_employee) 46 | -------------------------------------------------------------------------------- /src/serialchemy/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | import pytest 4 | from sqlalchemy import create_engine 5 | from sqlalchemy.orm import sessionmaker 6 | 7 | MappingType = Literal['imperative', 'declarative'] 8 | 9 | 10 | @pytest.fixture() 11 | def engine(): 12 | engine = create_engine('sqlite:///:memory:') 13 | return engine 14 | 15 | 16 | def get_metadata(mapping_type): 17 | if mapping_type == 'imperative': 18 | from serialchemy._tests.sample_model_imperative.orm_mapping import mapper_registry 19 | 20 | return mapper_registry.metadata 21 | else: 22 | from serialchemy._tests.sample_model import Base 23 | 24 | return Base.metadata 25 | 26 | 27 | @pytest.fixture(params=['imperative', 'declarative']) 28 | def mapping_type(request) -> MappingType: 29 | return request.param 30 | 31 | 32 | @pytest.fixture() 33 | def model(mapping_type: MappingType): 34 | if mapping_type == 'imperative': 35 | from serialchemy._tests.sample_model_imperative import model 36 | 37 | get_metadata(mapping_type) # apply mapping 38 | return model 39 | else: 40 | from serialchemy._tests import sample_model 41 | 42 | return sample_model 43 | 44 | 45 | @pytest.fixture() 46 | def db_session(mapping_type: MappingType, engine): 47 | metadata = get_metadata(mapping_type) 48 | metadata.drop_all(engine) 49 | metadata.create_all(engine) 50 | 51 | Session = sessionmaker(bind=engine) 52 | session = Session() 53 | yield session 54 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_polymorphic_serializer.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from sqlalchemy import Column 4 | from sqlalchemy import ForeignKey 5 | from sqlalchemy import Integer 6 | from sqlalchemy import String 7 | from sqlalchemy.orm import declarative_base 8 | 9 | from serialchemy import PolymorphicModelSerializer 10 | 11 | 12 | def test_polymorphic_pure_enum_identity_handling(db_session): 13 | Base = declarative_base() 14 | 15 | class TestEnum(Enum): 16 | TYPE_A = "type_a" 17 | TYPE_B = "type_b" 18 | 19 | class BaseTest(Base): 20 | __tablename__ = "base_test" 21 | id = Column(Integer, primary_key=True) 22 | type = Column(String) 23 | 24 | __mapper_args__ = { 25 | "polymorphic_on": type, 26 | "polymorphic_identity": None, 27 | } 28 | 29 | class TestA(BaseTest): 30 | __tablename__ = "test_a" 31 | id = Column(Integer, ForeignKey("base_test.id"), primary_key=True) 32 | 33 | __mapper_args__ = { 34 | "polymorphic_identity": TestEnum.TYPE_A, # Pure enum 35 | } 36 | 37 | Base.metadata.create_all(db_session.bind) 38 | 39 | obj = TestA(type=TestEnum.TYPE_A.value) 40 | db_session.add(obj) 41 | db_session.commit() 42 | 43 | serializer = PolymorphicModelSerializer(BaseTest) 44 | serialized_obj = serializer.dump(obj) 45 | 46 | # Verify that the serialized value is the .value of the enum (not the Enum itself) 47 | assert serialized_obj["type"] == TestEnum.TYPE_A.value 48 | 49 | loaded_obj = serializer.load(serialized_obj, session=db_session) 50 | assert isinstance(loaded_obj, TestA) 51 | -------------------------------------------------------------------------------- /pixi.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | authors = ["ESSS "] 3 | channels = ["conda-forge"] 4 | name = "serialchemy" 5 | platforms = ["linux-64", "win-64"] 6 | 7 | [tasks] 8 | test="pytest" 9 | test-cov="pytest --cov=src/serialchemy --cov-report=xml" 10 | setup="pre-commit install -f" 11 | lint="pre-commit run --all --show-diff-on-failure" 12 | build="python -m build" 13 | 14 | [feature.test.dependencies] 15 | pytest="*" 16 | pytest-cov="*" 17 | pytest-datadir="*" 18 | pytest-mock=">=1.10" 19 | pytest-regressions=">=1.0.0" 20 | pytest-freezegun=">=0.4.2" 21 | sqlalchemy-utc=">=0.10" 22 | sqlalchemy-utils=">=0.33" 23 | 24 | [feature.lint.dependencies] 25 | black=">=19.3b0" 26 | mypy="*" 27 | pre-commit="*" 28 | 29 | [feature.build.pypi-dependencies] 30 | build="*" 31 | 32 | [feature.python310.dependencies] 33 | python="3.10.*" 34 | 35 | [feature.python311.dependencies] 36 | python="3.11.*" 37 | 38 | [feature.python312.dependencies] 39 | python="3.12.*" 40 | 41 | [feature.python313.dependencies] 42 | python="3.13.*" 43 | 44 | [feature.sqla14.dependencies] 45 | sqlalchemy="1.4.*" 46 | 47 | [feature.sqla20.dependencies] 48 | sqlalchemy="2.0.*" 49 | 50 | 51 | [environments] 52 | default = { features=["python310", "sqla14", "test", "lint", "build"]} 53 | build = { features=["python312", "build"]} 54 | 55 | py310-sqla14 = { features=["test", "python310", "sqla14"]} 56 | py310-sqla20 = { features=["test", "python310", "sqla20"]} 57 | 58 | py311-sqla14 = { features=["test", "python311", "sqla14"]} 59 | py311-sqla20 = { features=["test", "python311", "sqla20"]} 60 | 61 | py312-sqla14 = { features=["test", "python312", "sqla14"]} 62 | py312-sqla20 = { features=["test", "python312", "sqla20"]} 63 | 64 | py313-sqla14 = { features=["test", "python313", "sqla14"]} 65 | py313-sqla20 = { features=["test", "python313", "sqla20"]} 66 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=77.0.3", 4 | "setuptools_scm[toml]>=8", 5 | "wheel", 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | 9 | [tool.setuptools.packages.find] 10 | where = ["src"] 11 | 12 | [tool.setuptools_scm] 13 | version_file = "src/serialchemy/_version.py" 14 | local_scheme = "no-local-version" 15 | 16 | [tool.setuptools_scm.scm.git] 17 | pre_parse = "fail_on_missing_submodules" 18 | describe_command = "git describe --dirty --tags --long --match v*" 19 | 20 | 21 | [project] 22 | name = "serialchemy" 23 | dynamic = ["version"] 24 | authors = [ 25 | { name="ESSS", email="foss@esss.co" }, 26 | ] 27 | description = "Serializers for SQLAlchemy models." 28 | readme = "README.rst" 29 | keywords=["serialchemy", "serializer", "sqlalchemy"] 30 | requires-python = ">=3.10" 31 | classifiers = [ 32 | "Development Status :: 3 - Alpha", 33 | "Intended Audience :: Developers", 34 | "Programming Language :: Python :: 3.10", 35 | "Programming Language :: Python :: 3.11", 36 | "Programming Language :: Python :: 3.12", 37 | "Programming Language :: Python :: 3.13", 38 | ] 39 | license = "MIT" 40 | license-files = ["LICEN[CS]E*"] 41 | dependencies = ["sqlalchemy>=1.4,<3.0"] 42 | 43 | [project.optional-dependencies] 44 | docs=["sphinx >= 1.4", "sphinx_rtd_theme", "sphinx-autodoc-typehints", "typing_extensions"] 45 | testing=[ 46 | "codecov", 47 | "mypy", 48 | "pytest", 49 | "pytest-cov", 50 | "pytest-regressions", 51 | "pytest-freezegun", 52 | "pre-commit", 53 | "setuptools", 54 | "sqlalchemy_utils", 55 | "tox", 56 | ] 57 | 58 | [project.urls] 59 | Repository = "https://github.com/ESSS/serialchemy" 60 | Issues = "https://github.com/ESSS/serialchemy/issues" 61 | 62 | 63 | [tool.black] 64 | line-length = 100 65 | skip-string-normalization = true 66 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | History 2 | ======= 3 | 1.0.3 (2025-10-21) 4 | ------------------ 5 | * Add support for SQLAlchemy 2.0 6 | * Drop support for Python 3.8 and 3.9 as they reach end of life. 7 | 8 | 1.0.2 (2025-07-08) 9 | ------------------ 10 | * Adjust PolymorphicModelSerializer to accept a pure Enum as polymorphic identity 11 | 12 | 1.0.1 (2023-17-11) 13 | ------------------ 14 | * Fix license placement on setup.py 15 | 16 | 1.0.0 (2023-14-11) 17 | ------------------ 18 | * Add support for SQLAlchemy imperative (classical) mapping 19 | * Drop support for Python versions bellow 3.8 20 | * Drop support for SQLAlchemy 1.3 21 | 22 | 0.4.0 (2023-12-11) 23 | ------------------ 24 | * Fix to get model attribute name instead of table column name on polymorphic serializer 25 | * Extends the PolymorphicModelSerializer to accept also column descriptors when searching 26 | for the polymorphic column key. 27 | * Add support for serialization of Python Enums 28 | * Change PolymorphicModelSerializer to support inherited models of inherited models 29 | * Change Field to use a default serializer for not None values 30 | * Added support for sqlalchemy 1.4 31 | * Add EnumKeySerializer 32 | 33 | 0.3.0 (2019-17-07) 34 | ------------------ 35 | * Add the composite fields to list of properties of model, to serialize that fields if it type is in EXTRA_SERIALIZERS. 36 | * Fix error for SQLAlchemy composite attributes 37 | * Added free functions dump and load so users can quickly dump a SQLAlchemy model without having to instancialize 38 | ModelSerializer. 39 | 40 | 0.2.0 (2019-03-22) 41 | ------------------ 42 | 43 | * Fix: Error when deserializing of nested models when SQLAlchemy model primary 44 | key attribute name differs from the column name 45 | * Allow EXTRA_SERIALIZERS to be defined in runtime 46 | * Check if a session was given when serializing/deserializing nested fields 47 | 48 | 0.1.0 (2019-02-12) 49 | ------------------ 50 | 51 | * First release on PyPI. 52 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_datetimeserializer.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from datetime import datetime 3 | from datetime import timedelta 4 | from datetime import timezone 5 | 6 | import pytest 7 | 8 | from serialchemy.datetime_serializer import DateSerializer 9 | from serialchemy.datetime_serializer import DateTimeSerializer 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "serialized_date", ["1994-07-17T20:53", "1994-07-17 20:53:00", "1994-07-17T20:53:00.000"] 14 | ) 15 | def test_datetime(serialized_date): 16 | date_obj = datetime(1994, 7, 17, 20, 53) 17 | assert date_obj == DateTimeSerializer.load(serialized_date) 18 | 19 | 20 | def test_datetime_with_sec_tz(): 21 | serializer = DateTimeSerializer 22 | assert datetime(1994, 7, 17, 20, 53, 12, tzinfo=timezone.utc) == serializer.load( 23 | "1994-07-17T20:53:12+0000" 24 | ) 25 | assert datetime(1994, 7, 17, 20, 53, 12, tzinfo=timezone.utc) == serializer.load( 26 | "1994-07-17T20:53:12Z" 27 | ) 28 | assert "1994-07-17T20:53:12+00:00" == serializer.dump(serializer.load("1994-07-17T20:53:12Z")) 29 | 30 | assert datetime(1994, 7, 17, 20, 53, 12, 302) == serializer.load("1994-07-17T20:53:12.302") 31 | 32 | assert timezone(timedelta(hours=3)) == serializer.load("1994-07-17T20:53:12+0300").tzinfo 33 | assert ( 34 | timezone(timedelta(hours=-2, minutes=30)) 35 | == serializer.load("1994-07-17T20:53:12.0320-0230").tzinfo 36 | ) 37 | 38 | assert datetime.strptime("1994-07-17T20:53-0030", "%Y-%m-%dT%H:%M%z") == serializer.load( 39 | "1994-07-17T20:53-0030" 40 | ) 41 | 42 | 43 | def test_date_dump(): 44 | assert DateSerializer.dump(date(1994, 7, 17)) == "1994-07-17" 45 | 46 | 47 | def test_date_load(): 48 | loaded = DateSerializer.load("2010-02-03") 49 | assert loaded == date(2010, 2, 3) 50 | assert isinstance(loaded, date) 51 | 52 | 53 | def test_date_with_time_warning(): 54 | with pytest.warns(UserWarning, match="date shouldn't have non-zero values"): 55 | DateSerializer.load("2010-02-03T02:15") 56 | -------------------------------------------------------------------------------- /src/serialchemy/field.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from serialchemy.serializer import Serializer 4 | 5 | 6 | class DefaultFieldSerializer(Serializer): 7 | def dump(self, value): 8 | if isinstance(value, Enum): 9 | # We consider Enum a "basic type", so we check for Enums and to convert them to a json 10 | # serializable value to avoid requiring a special "serializer" for it. 11 | return value.value 12 | else: 13 | return value 14 | 15 | def load(self, serialized, **kw): 16 | return serialized 17 | 18 | 19 | class Field(object): 20 | """ 21 | Configure a ModelSerializer field 22 | """ 23 | 24 | def __init__(self, dump_only=False, load_only=False, creation_only=False, serializer=None): 25 | """ 26 | :param bool dump_only: If True, field is not included on deserialization. 27 | 28 | :param bool load_only: If True, field is not included in serialization. 29 | 30 | :param bool creation_only: If True, the field is included in serialization only when the load creates a new 31 | entity, and is ignored when the load is updating an existent entity. 32 | 33 | Warning: If dump_only flag is True this flag has no effect. 34 | 35 | :param Serializer serializer: define a custom serializer for the field. If none, 36 | the field value is returned by dump. 37 | """ 38 | self.dump_only = dump_only 39 | self.load_only = load_only 40 | self.creation_only = creation_only 41 | if serializer and not isinstance(serializer, Serializer): 42 | raise TypeError(f"'{serializer}' is not an instance of 'Serializer'") 43 | self._serializer = serializer or DefaultFieldSerializer() 44 | 45 | @property 46 | def serializer(self) -> Serializer: 47 | return self._serializer 48 | 49 | def dump(self, value): 50 | if value is None: 51 | return None 52 | return self.serializer.dump(value) 53 | 54 | def load(self, serialized, **kw): 55 | if serialized is None: 56 | return None 57 | return self.serializer.load(serialized, **kw) 58 | -------------------------------------------------------------------------------- /src/serialchemy/datetime_serializer.py: -------------------------------------------------------------------------------- 1 | import re 2 | import warnings 3 | from datetime import date 4 | from datetime import datetime 5 | from datetime import timedelta 6 | from datetime import timezone 7 | 8 | from .serializer import ColumnSerializer 9 | from .serializer import Serializer 10 | 11 | DATETIME_REGEX = ( 12 | r"(?P\d{2,4})-(?P\d{2})-(?P\d{2})" 13 | + r"([T ])?" 14 | + r"((?P\d{2}):(?P\d{2}))?" 15 | + r"(:(?P\d{2}))?(\.(?P\d+))?" 16 | + r"(?P([\+-]\d{2}:?\d{2})|[Zz])?" 17 | ) 18 | 19 | 20 | class DateTimeSerializer(Serializer): 21 | """ 22 | Serializer for DateTime objects 23 | """ 24 | 25 | DATETIME_RE = re.compile(DATETIME_REGEX) 26 | 27 | @classmethod 28 | def dump(cls, value): 29 | return value.isoformat() 30 | 31 | @classmethod 32 | def load(cls, serialized, session=None): 33 | match = cls.DATETIME_RE.match(serialized) 34 | if not match: 35 | raise ValueError("Could not parse DateTime: '{}'".format(serialized)) 36 | parts = match.groupdict() 37 | dt = datetime( 38 | int(parts["Y"]), 39 | int(parts["m"]), 40 | int(parts["d"]), 41 | int(parts.get("H") or 0), 42 | int(parts.get("M") or 0), 43 | int(parts.get("S") or 0), 44 | int(parts.get("f") or 0), 45 | tzinfo=cls._parse_tzinfo(parts["tz"]), 46 | ) 47 | return dt 48 | 49 | @staticmethod 50 | def _parse_tzinfo(offset_str): 51 | if not offset_str: 52 | return None 53 | elif offset_str.upper() == 'Z': 54 | return timezone.utc 55 | else: 56 | hours = int(offset_str[:3]) 57 | minutes = int(offset_str[-2:]) 58 | # Invert minutes sign if hours == 0 59 | if offset_str[0] == "-" and hours == 0: 60 | minutes = -minutes 61 | return timezone(timedelta(hours=hours, minutes=minutes)) 62 | 63 | 64 | class DateSerializer(DateTimeSerializer): 65 | @classmethod 66 | def load(cls, serialized, session=None): 67 | dt = super().load(serialized, session) 68 | if dt.hour or dt.minute or dt.second: 69 | warnings.warn( 70 | "Serialized date shouldn't have non-zero values " 71 | f"for hour, min or sec: {dt.strftime('%Hh%Mm%Ss')}" 72 | ) 73 | return dt.date() 74 | 75 | 76 | class DateTimeColumnSerializer(DateTimeSerializer, ColumnSerializer): 77 | pass 78 | 79 | 80 | class DateColumnSerializer(DateSerializer, ColumnSerializer): 81 | pass 82 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/sample_model_imperative/model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from dataclasses import field 3 | from datetime import datetime 4 | from enum import Enum 5 | from typing import List 6 | from typing import Optional 7 | 8 | 9 | @dataclass 10 | class Company: 11 | 12 | name: str 13 | location: str 14 | id: Optional[int] = None 15 | master_engeneer: Optional['SpecialistEngineer'] = None 16 | master_manager: Optional['Manager'] = None 17 | employees: List['Employee'] = field(default_factory=list) 18 | 19 | 20 | @dataclass 21 | class Department: 22 | 23 | id: int 24 | name: str 25 | 26 | 27 | @dataclass 28 | class Address: 29 | 30 | street: str 31 | number: str 32 | city: str 33 | state: str 34 | id: Optional[int] = None 35 | zip: Optional[str] = None 36 | 37 | 38 | @dataclass 39 | class ContactType: 40 | 41 | label: str 42 | id: Optional[int] = None 43 | 44 | 45 | @dataclass 46 | class Contact: 47 | 48 | type: ContactType 49 | value: str 50 | employee: 'Employee' 51 | id: Optional[int] = None 52 | 53 | 54 | class ContractType(Enum): 55 | 56 | EMPLOYEE = 'Employee' 57 | CONTRACTOR = 'Contractor' 58 | OTHER = 'Other' 59 | 60 | 61 | class MaritalStatus(Enum): 62 | SINGLE = 'Single' 63 | MARRIED = 'Married' 64 | DIVORCED = 'Divorced' 65 | 66 | 67 | @dataclass 68 | class Employee: 69 | 70 | firstname: str 71 | lastname: str 72 | email: str 73 | role: str 74 | 75 | password: Optional[str] = None 76 | address: Optional[Address] = None 77 | contract_type: Optional[ContractType] = None 78 | marital_status: Optional[MaritalStatus] = None 79 | company: Optional[Company] = None 80 | _salary: Optional[float] = None 81 | id: Optional[int] = None 82 | admission: datetime = datetime(2000, 1, 1) 83 | created_at: datetime = datetime(2000, 1, 2) 84 | departments: List[Department] = field(default_factory=list) 85 | contacts: List[Contact] = field(default_factory=list) 86 | 87 | @property 88 | def company_name(self): 89 | return self.company.name 90 | 91 | @property 92 | def full_name(self): 93 | return " ".join([self.firstname, self.lastname]) 94 | 95 | 96 | @dataclass 97 | class Engineer(Employee): 98 | engineer_name: Optional[str] = None 99 | 100 | 101 | @dataclass 102 | class SpecialistEngineer(Engineer): 103 | specialization: Optional[str] = None 104 | 105 | 106 | @dataclass 107 | class Manager(Employee): 108 | manager_name: Optional[str] = None 109 | -------------------------------------------------------------------------------- /src/serialchemy/polymorphic_serializer.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from sqlalchemy.orm import class_mapper 4 | 5 | from serialchemy import ModelSerializer 6 | 7 | 8 | def _get_identity(cls): 9 | identity_value = class_mapper(cls).polymorphic_identity 10 | if isinstance(identity_value, enum.Enum): 11 | return identity_value.value 12 | return identity_value 13 | 14 | 15 | def _get_identity_key(cls): 16 | identityColumn = class_mapper(cls).polymorphic_on 17 | assert hasattr(identityColumn, 'key') 18 | column_db_name = identityColumn.key 19 | for attribute_name, attribute in class_mapper(cls).c.items(): 20 | if attribute.key == column_db_name: 21 | return attribute_name 22 | raise AttributeError( 23 | f"'polymorphic_on' attribute set incorrectly, are you sure it should be {column_db_name}?" 24 | ) 25 | 26 | 27 | def has_sqlalchemy_polymorphic_decendants(cls): 28 | # if the class mapper has descendants, then it is a polymorphic structure and is not a leaf 29 | return len(class_mapper(cls).self_and_descendants) > 1 30 | 31 | 32 | class PolymorphicModelSerializer(ModelSerializer): 33 | """ 34 | Serializer for models that have a common base class. Can be used as serializer for endpoints that have objects 35 | from different classes (but have a common base) 36 | """ 37 | 38 | def __init__(self, declarative_class): 39 | super().__init__(declarative_class) 40 | # maped = class_mapper(declarative_class) 41 | if has_sqlalchemy_polymorphic_decendants(declarative_class): 42 | self.is_polymorphic = True 43 | self.sub_serializers = self._get_sub_serializers(declarative_class) 44 | self.identity_key = _get_identity_key(declarative_class) 45 | else: 46 | self.is_polymorphic = False 47 | 48 | @classmethod 49 | def _get_sub_serializers(cls, declarative_class): 50 | 51 | serializers_sub_class_map = { 52 | sub_cls.get_identity(): sub_cls 53 | for sub_cls in cls.__subclasses__() 54 | if sub_cls.get_identity() 55 | } 56 | 57 | def get_subclasses(declarative_class): 58 | """Recursively finds all subclasses of the current class""" 59 | subclasses = declarative_class.__subclasses__() 60 | for s in subclasses: 61 | subclasses.extend(get_subclasses(s)) 62 | return subclasses 63 | 64 | return { 65 | _get_identity(sub_cls): serializers_sub_class_map.get(_get_identity(sub_cls), cls)( 66 | sub_cls 67 | ) 68 | for sub_cls in get_subclasses(declarative_class) 69 | } 70 | 71 | @classmethod 72 | def get_identity(cls): 73 | return _get_identity(cls.__model_class__) if hasattr(cls, '__model_class__') else None 74 | 75 | def load(self, serialized, existing_model=None, session=None): 76 | if self.is_polymorphic: 77 | model_identity = serialized.get(self.identity_key) 78 | if model_identity and self.sub_serializers.get(model_identity): 79 | return self.sub_serializers[model_identity].load( 80 | serialized, existing_model, session 81 | ) 82 | return super().load(serialized, existing_model, session) 83 | 84 | def dump(self, model): 85 | if self.is_polymorphic: 86 | model_identity = _get_identity(model.__class__) 87 | if model_identity in self.sub_serializers: 88 | return self.sub_serializers[model_identity].dump(model) 89 | return super().dump(model) 90 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - rb-* 8 | tags: 9 | - v* 10 | 11 | pull_request: 12 | 13 | jobs: 14 | test: 15 | 16 | runs-on: ${{ matrix.os }} 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | python: ["310", "311", "312", "313"] 22 | sqlalchemy: ["14", "20"] 23 | os: [ubuntu-latest, windows-latest] 24 | 25 | steps: 26 | - uses: actions/checkout@v1 27 | - uses: prefix-dev/setup-pixi@v0.9.1 28 | with: 29 | pixi-version: v0.49.0 30 | - name: Test 31 | run: pixi run -e py${{ matrix.python }}-sqla${{ matrix.sqlalchemy }} test-cov 32 | - name: Upload codecov 33 | uses: codecov/codecov-action@v3 34 | with: 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | fail_ci_if_error: true 37 | 38 | lint: 39 | 40 | runs-on: ubuntu-latest 41 | 42 | strategy: 43 | fail-fast: false 44 | 45 | steps: 46 | - uses: actions/checkout@v1 47 | - uses: prefix-dev/setup-pixi@v0.9.1 48 | with: 49 | pixi-version: v0.49.0 50 | - name: Test 51 | run: pixi run lint 52 | 53 | build: 54 | 55 | runs-on: ubuntu-latest 56 | 57 | needs: test 58 | 59 | steps: 60 | - uses: actions/checkout@v2 61 | - uses: prefix-dev/setup-pixi@v0.9.1 62 | with: 63 | pixi-version: v0.49.0 64 | - name: Build package 65 | run: | 66 | pixi run -e build build 67 | - name: Archive build artifacts 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: dist 71 | path: | 72 | dist 73 | 74 | test-build: 75 | name: Test build files 76 | runs-on: ubuntu-latest 77 | needs: build 78 | steps: 79 | - name: Load build artifacts 80 | uses: actions/download-artifact@v4 81 | with: 82 | name: dist 83 | path: | 84 | dist 85 | 86 | - name: Set up Python 87 | uses: actions/setup-python@v2 88 | with: 89 | python-version: "3.10" 90 | - name: Install local package 91 | run: | 92 | python -m pip install --upgrade pip 93 | python -m pip install dist/serialchemy*.whl 94 | - name: Test local package 95 | run: | 96 | python -c "import serialchemy" 97 | 98 | pypi-publish: 99 | 100 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') 101 | 102 | name: Publish package PyPI 103 | runs-on: ubuntu-latest 104 | needs: test-build 105 | environment: 106 | name: pypi 107 | url: https://pypi.org/p/serialchemy 108 | permissions: 109 | id-token: write 110 | steps: 111 | - name: Load build artifacts 112 | uses: actions/download-artifact@v4 113 | with: 114 | name: dist 115 | path: | 116 | dist 117 | 118 | - name: Publish package distributions to PyPI 119 | uses: pypa/gh-action-pypi-publish@release/v1 120 | 121 | sonarcloud: 122 | 123 | runs-on: ubuntu-latest 124 | 125 | steps: 126 | - uses: actions/checkout@v2 127 | with: 128 | # Disabling shallow clone is recommended for improving relevancy of reporting 129 | fetch-depth: 0 130 | - name: SonarCloud Scan 131 | uses: sonarsource/sonarcloud-github-action@master 132 | env: 133 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 134 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 135 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/sample_model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | 4 | from sqlalchemy import Column 5 | from sqlalchemy import Date 6 | from sqlalchemy import DateTime 7 | from sqlalchemy import Float 8 | from sqlalchemy import ForeignKey 9 | from sqlalchemy import Integer 10 | from sqlalchemy import String 11 | from sqlalchemy import Table 12 | from sqlalchemy.ext.associationproxy import association_proxy 13 | from sqlalchemy.ext.hybrid import hybrid_property 14 | from sqlalchemy.orm import declarative_base 15 | from sqlalchemy.orm import object_session 16 | from sqlalchemy.orm import relationship 17 | from sqlalchemy.sql import sqltypes 18 | from sqlalchemy_utils import ChoiceType 19 | 20 | from serialchemy.enum_field import EnumKeyField 21 | from serialchemy.field import Field 22 | from serialchemy.model_serializer import ModelSerializer 23 | from serialchemy.nested_fields import NestedModelField 24 | from serialchemy.nested_fields import NestedModelListField 25 | 26 | Base = declarative_base() 27 | 28 | 29 | class Company(Base): # type: ignore 30 | 31 | __tablename__ = 'Company' 32 | 33 | id = Column(Integer, primary_key=True) 34 | name = Column(String) 35 | location = Column(String) 36 | employees = relationship("Employee", lazy='dynamic') 37 | master_engeneer_id = Column(Integer, ForeignKey('SpecialistEngineer.esp_id')) 38 | master_engeneer = relationship('SpecialistEngineer', foreign_keys=[master_engeneer_id]) 39 | master_manager_id = Column(Integer, ForeignKey('Manager.id')) 40 | master_manager = relationship('Manager', foreign_keys=[master_manager_id]) 41 | 42 | 43 | class Department(Base): # type: ignore 44 | 45 | __tablename__ = 'Department' 46 | 47 | id = Column(Integer, primary_key=True) 48 | name = Column(String) 49 | 50 | 51 | class Address(Base): # type: ignore 52 | 53 | __tablename__ = 'Address' 54 | 55 | id = Column(Integer, primary_key=True) 56 | street = Column(String) 57 | number = Column(String) 58 | zip = Column(String) 59 | city = Column(String) 60 | state = Column(String) 61 | 62 | 63 | class ContactType(Base): # type: ignore 64 | 65 | __tablename__ = 'ContactType' 66 | 67 | id = Column(Integer, primary_key=True) 68 | label = Column(String(15)) 69 | 70 | 71 | class Contact(Base): # type: ignore 72 | 73 | __tablename__ = 'Contact' 74 | 75 | id = Column(Integer, primary_key=True) 76 | type = relationship(ContactType) 77 | type_id = Column(ForeignKey('ContactType.id')) 78 | value = Column(String) 79 | employee_id = Column(ForeignKey('Employee.id')) 80 | 81 | 82 | class ContractType(Enum): 83 | 84 | EMPLOYEE = 'Employee' 85 | CONTRACTOR = 'Contractor' 86 | OTHER = 'Other' 87 | 88 | 89 | class MaritalStatus(Enum): 90 | SINGLE = 'Single' 91 | MARRIED = 'Married' 92 | DIVORCED = 'Divorced' 93 | 94 | 95 | class Employee(Base): # type: ignore 96 | 97 | __tablename__ = 'Employee' 98 | 99 | id = Column('id', Integer, primary_key=True) 100 | firstname = Column(String) 101 | lastname = Column(String) 102 | email = Column(String) 103 | admission = Column(Date, default=datetime(2000, 1, 1)) 104 | company_id = Column(ForeignKey('Company.id')) 105 | company = relationship(Company, back_populates='employees') 106 | company_name = association_proxy('company', 'name') 107 | address_id = Column(ForeignKey('Address.id')) 108 | address = relationship(Address) 109 | departments = relationship('Department', secondary='employee_department') 110 | contacts = relationship(Contact, cascade='all, delete-orphan') 111 | role = Column(String) 112 | _salary = Column(Float) 113 | contract_type = Column(ChoiceType(ContractType)) 114 | marital_status = Column(sqltypes.Enum(MaritalStatus)) 115 | 116 | password = Column(String) 117 | created_at = Column(DateTime, default=datetime(2000, 1, 2)) 118 | 119 | __mapper_args__ = {'polymorphic_identity': 'Employee', 'polymorphic_on': role} 120 | 121 | @property 122 | def colleagues(self): 123 | return object_session(self).query(Employee).filter(Employee.company_id == self.company_id) 124 | 125 | @hybrid_property 126 | def full_name(self): 127 | return " ".join([self.firstname, self.lastname]) 128 | 129 | 130 | employee_department = Table( 131 | 'employee_department', 132 | Base.metadata, 133 | Column('employee_id', Integer, ForeignKey('Employee.id')), 134 | Column('department_id', Integer, ForeignKey('Department.id')), 135 | ) 136 | 137 | 138 | class Engineer(Employee): 139 | 140 | __tablename__ = 'Engineer' 141 | 142 | id = Column('eng_id', Integer, ForeignKey('Employee.id'), primary_key=True) 143 | engineer_name = Column(String(30)) 144 | 145 | __mapper_args__ = {'polymorphic_identity': 'Engineer', 'polymorphic_on': Employee.role} 146 | 147 | 148 | class SpecialistEngineer(Engineer): 149 | 150 | __tablename__ = 'SpecialistEngineer' 151 | 152 | id = Column('esp_id', Integer, ForeignKey('Engineer.eng_id'), primary_key=True) 153 | specialization = Column(String(30)) 154 | 155 | __mapper_args__ = { 156 | 'polymorphic_identity': 'Specialist Engineer', 157 | } 158 | 159 | 160 | class Manager(Employee): 161 | 162 | __tablename__ = 'Manager' 163 | 164 | id = Column('id', Integer, ForeignKey('Employee.id'), primary_key=True) 165 | manager_name = Column(String(30)) 166 | 167 | __mapper_args__ = { 168 | 'polymorphic_identity': 'Manager', 169 | } 170 | 171 | 172 | class EmployeeSerializer(ModelSerializer): 173 | 174 | password = Field(load_only=True) 175 | created_at = Field(dump_only=True) 176 | company_name = Field(dump_only=True) 177 | address = NestedModelField(Address) 178 | contacts = NestedModelListField(Contact) 179 | marital_status = EnumKeyField(MaritalStatus) 180 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/sample_model_imperative/orm_mapping.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column 2 | from sqlalchemy import ForeignKey 3 | from sqlalchemy import Integer 4 | from sqlalchemy import String 5 | from sqlalchemy import Table 6 | from sqlalchemy.ext.associationproxy import association_proxy 7 | from sqlalchemy.ext.hybrid import hybrid_property 8 | from sqlalchemy.orm import registry 9 | from sqlalchemy.orm import relationship 10 | from sqlalchemy.sql import sqltypes 11 | from sqlalchemy.types import Date 12 | from sqlalchemy.types import DateTime 13 | from sqlalchemy.types import Float 14 | from sqlalchemy_utils import ChoiceType 15 | 16 | from serialchemy._tests.sample_model_imperative.model import Address 17 | from serialchemy._tests.sample_model_imperative.model import Company 18 | from serialchemy._tests.sample_model_imperative.model import Contact 19 | from serialchemy._tests.sample_model_imperative.model import ContactType 20 | from serialchemy._tests.sample_model_imperative.model import ContractType 21 | from serialchemy._tests.sample_model_imperative.model import Department 22 | from serialchemy._tests.sample_model_imperative.model import Employee 23 | from serialchemy._tests.sample_model_imperative.model import Engineer 24 | from serialchemy._tests.sample_model_imperative.model import Manager 25 | from serialchemy._tests.sample_model_imperative.model import MaritalStatus 26 | from serialchemy._tests.sample_model_imperative.model import SpecialistEngineer 27 | 28 | mapper_registry = registry() 29 | 30 | 31 | mapper_registry.map_imperatively( 32 | ContactType, 33 | Table( 34 | 'ContactType', 35 | mapper_registry.metadata, 36 | Column('id', Integer, primary_key=True), 37 | Column('street', String), 38 | Column('number', String), 39 | Column('zip', String), 40 | Column('city', String), 41 | Column('state', String), 42 | ), 43 | ) 44 | 45 | company_table = Table( 46 | "Company", 47 | mapper_registry.metadata, 48 | Column('id', Integer, primary_key=True), 49 | Column('name', String), 50 | Column('location', String), 51 | Column('master_engeneer_id', Integer, ForeignKey('SpecialistEngineer.id')), 52 | Column('master_manager_id', Integer, ForeignKey('Manager.id')), 53 | ) 54 | mapper_registry.map_imperatively( 55 | Company, 56 | company_table, 57 | properties={ 58 | 'employees': relationship('Employee', lazy='dynamic'), 59 | 'master_engeneer': relationship( 60 | 'SpecialistEngineer', foreign_keys=[company_table.c.master_engeneer_id] 61 | ), 62 | 'master_manager': relationship('Manager', foreign_keys=[company_table.c.master_manager_id]), 63 | }, 64 | ) 65 | mapper_registry.map_imperatively( 66 | Department, 67 | Table( 68 | 'Department', 69 | mapper_registry.metadata, 70 | Column('id', Integer, primary_key=True), 71 | Column('name', String), 72 | ), 73 | ) 74 | mapper_registry.map_imperatively( 75 | Address, 76 | Table( 77 | 'Address', 78 | mapper_registry.metadata, 79 | Column('id', Integer, primary_key=True), 80 | Column('street', String), 81 | Column('number', String), 82 | Column('zip', String), 83 | Column('city', String), 84 | Column('state', String), 85 | ), 86 | ) 87 | mapper_registry.map_imperatively( 88 | Contact, 89 | Table( 90 | 'Contact', 91 | mapper_registry.metadata, 92 | Column('id', Integer, primary_key=True), 93 | Column('value', String), 94 | Column('employee_id', ForeignKey('Employee.id')), 95 | Column('type_id', ForeignKey('ContactType.id')), 96 | ), 97 | properties={ 98 | 'type': relationship(ContactType), 99 | 'employee': relationship('Employee'), 100 | }, 101 | ) 102 | 103 | employee_department = Table( 104 | 'employee_department', 105 | mapper_registry.metadata, 106 | Column('employee_id', Integer, ForeignKey('Employee.id')), 107 | Column('department_id', Integer, ForeignKey('Department.id')), 108 | ) 109 | 110 | employee_table = Table( 111 | 'Employee', 112 | mapper_registry.metadata, 113 | Column('id', Integer, primary_key=True), 114 | Column('firstname', String), 115 | Column('lastname', String), 116 | Column('email', String), 117 | Column('admission', Date), 118 | Column('role', String), 119 | Column('_salary', Float), 120 | Column('password', String), 121 | Column('created_at', DateTime), 122 | Column('company_id', ForeignKey('Company.id')), 123 | Column('address_id', ForeignKey('Address.id')), 124 | Column('contract_type', ChoiceType(ContractType)), 125 | Column('marital_status', sqltypes.Enum(MaritalStatus)), 126 | ) 127 | 128 | 129 | mapper_registry.map_imperatively( 130 | Employee, 131 | employee_table, 132 | properties={ 133 | 'company': relationship(Company, back_populates='employees'), 134 | 'address': relationship(Address), 135 | 'departments': relationship(Department, secondary='employee_department'), 136 | 'contacts': relationship(Contact, cascade='all, delete-orphan'), 137 | }, 138 | polymorphic_identity='Employee', 139 | polymorphic_on=employee_table.c.role, 140 | ) 141 | mapper_registry.map_imperatively( 142 | Engineer, 143 | Table( 144 | 'Engineer', 145 | mapper_registry.metadata, 146 | Column('eng_id', Integer, ForeignKey('Employee.id'), key='id', primary_key=True), 147 | Column('engineer_name', String(30)), 148 | ), 149 | inherits=Employee, 150 | polymorphic_identity='Engineer', 151 | polymorphic_on=employee_table.c.role, 152 | ) 153 | mapper_registry.map_imperatively( 154 | SpecialistEngineer, 155 | Table( 156 | 'SpecialistEngineer', 157 | mapper_registry.metadata, 158 | Column('esp_id', Integer, ForeignKey('Engineer.id'), key='id', primary_key=True), 159 | Column('specialization', String(30)), 160 | ), 161 | inherits=Engineer, 162 | polymorphic_identity='Specialist Engineer', 163 | ) 164 | mapper_registry.map_imperatively( 165 | Manager, 166 | Table( 167 | 'Manager', 168 | mapper_registry.metadata, 169 | Column('id', Integer, ForeignKey('Employee.id'), primary_key=True), 170 | Column('manager_name', String(30)), 171 | ), 172 | inherits=Employee, 173 | polymorphic_identity='Manager', 174 | ) 175 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====================================================================== 2 | Serialchemy 3 | ====================================================================== 4 | 5 | 6 | .. image:: https://img.shields.io/pypi/v/serialchemy.svg 7 | :target: https://pypi.python.org/pypi/serialchemy 8 | 9 | .. image:: https://img.shields.io/pypi/pyversions/serialchemy.svg 10 | :target: https://pypi.org/project/serialchemy 11 | 12 | .. image:: https://github.com/ESSS/serialchemy/workflows/build/badge.svg 13 | :target: https://github.com/ESSS/serialchemy/actions 14 | 15 | .. image:: https://codecov.io/gh/ESSS/serialchemy/branch/master/graph/badge.svg 16 | :target: https://codecov.io/gh/ESSS/serialchemy 17 | 18 | .. image:: https://img.shields.io/readthedocs/serialchemy.svg 19 | :target: https://serialchemy.readthedocs.io/en/latest/ 20 | 21 | .. image:: https://sonarcloud.io/api/project_badges/measure?project=ESSS_serialchemy&metric=alert_status 22 | :target: https://sonarcloud.io/project/overview?id=ESSS_serialchemy 23 | 24 | 25 | SQLAlchemy model serialization. 26 | =============================== 27 | 28 | Motivation 29 | ---------- 30 | 31 | **Serialchemy** was developed as a module of Flask-RESTAlchemy_, a lib to create Restful APIs 32 | using Flask and SQLAlchemy. We first tried marshmallow-sqlalchemy_, probably the most 33 | well-known lib for SQLAlchemy model serialization, but we faced `issues related to nested 34 | models `_. We also think 35 | that is possible to build a simpler and more maintainable solution by having SQLAlchemy_ in 36 | mind from the ground up, as opposed to marshmallow-sqlalchemy_ that had to be 37 | designed and built on top of marshmallow_. 38 | 39 | .. _SQLAlchemy: www.sqlalchemy.org 40 | .. _marshmallow-sqlalchemy: http://marshmallow-sqlalchemy.readthedocs.io 41 | .. _marshmallow: https://marshmallow.readthedocs.io 42 | .. _Flask-RESTAlchemy: https://github.com/ESSS/flask-restalchemy 43 | 44 | How to Use it 45 | ------------- 46 | 47 | Serializing Generic Types 48 | ......................... 49 | 50 | Suppose we have an `Employee` SQLAlchemy_ model declared: 51 | 52 | .. code-block:: python 53 | 54 | class Employee(Base): 55 | __tablename__ = "Employee" 56 | 57 | id = Column(Integer, primary_key=True) 58 | fullname = Column(String) 59 | admission = Column(DateTime, default=datetime(2000, 1, 1)) 60 | company_id = Column(ForeignKey("Company.id")) 61 | company = relationship(Company) 62 | company_name = column_property( 63 | select([Company.name]).where(Company.id == company_id) 64 | ) 65 | password = Column(String) 66 | 67 | `Generic Types`_ are automatically serialized by `ModelSerializer`: 68 | 69 | .. code-block:: python 70 | 71 | from serialchemy import ModelSerializer 72 | 73 | emp = Employee(fullname="Roberto Silva", admission=datetime(2019, 4, 2)) 74 | 75 | serializer = ModelSerializer(Employee) 76 | serializer.dump(emp) 77 | 78 | # >> 79 | { 80 | "id": None, 81 | "fullname": "Roberto Silva", 82 | "admission": "2019-04-02T00:00:00", 83 | "company_id": None, 84 | "company_name": None, 85 | "password": None, 86 | } 87 | 88 | New items can be deserialized by the same serializer: 89 | 90 | .. code-block:: python 91 | 92 | new_employee = {"fullname": "Jobson Gomes", "admission": "2018-02-03"} 93 | serializer.load(new_employee) 94 | 95 | # >> 96 | 97 | Serializers do not commit into the database. You must do this by yourself: 98 | 99 | .. code-block:: python 100 | 101 | emp = serializer.load(new_employee) 102 | session.add(emp) 103 | session.commit() 104 | 105 | .. _`Generic Types`: https://docs.sqlalchemy.org/en/rel_1_2/core/type_basics.html#generic-types 106 | 107 | Custom Serializers 108 | .................. 109 | 110 | For anything beyond `Generic Types`_ we must extend the `ModelSerializer` class: 111 | 112 | .. code-block:: python 113 | 114 | class EmployeeSerializer(ModelSerializer): 115 | 116 | password = Field(load_only=True) # passwords should be only deserialized 117 | company = NestedModelField(Company) # dump company as nested object 118 | 119 | 120 | serializer = EmployeeSerializer(Employee) 121 | serializer.dump(emp) 122 | # >> 123 | { 124 | "id": 1, 125 | "fullname": "Roberto Silva", 126 | "admission": "2019-04-02T00:00:00", 127 | "company": {"id": 3, "name": "Acme Co"}, 128 | } 129 | 130 | 131 | Extend Polymorphic Serializer 132 | +++++++++++++++++++++++++++++ 133 | One of the possibilities is to serialize SQLalchemy joined table inheritance and 134 | it child tables as well. To do such it's necessary to set a variable with 135 | the desired model class name. Take this `Employee` class with for instance and let us 136 | assume it have a joined table inheritance: 137 | 138 | .. code-block:: python 139 | 140 | class Employee(Base): 141 | ... 142 | type = Column(String(50)) 143 | 144 | __mapper_args__ = {"polymorphic_identity": "employee", "polymorphic_on": type} 145 | 146 | 147 | class Engineer(Employee): 148 | __tablename__ = "Engineer" 149 | id = Column(Integer, ForeignKey("employee.id"), primary_key=True) 150 | association = relationship(Association) 151 | 152 | __mapper_args__ = { 153 | "polymorphic_identity": "engineer", 154 | } 155 | 156 | To use a extended `ModelSerializer` class on the `Engineer` class, you should create 157 | the serializer as it follows: 158 | 159 | .. code-block:: python 160 | 161 | class EmployeeSerializer( 162 | PolymorphicModelSerializer 163 | ): # Since this class will be polymorphic 164 | 165 | password = Field(load_only=True) 166 | company = NestedModelField(Company) 167 | 168 | 169 | class EngineerSerializer(EmployeeSerializer): 170 | __model_class__ = Engineer # This is the table Serialchemy will refer to 171 | association = NestedModelField(Association) 172 | 173 | Contributing 174 | ------------ 175 | 176 | For guidance on setting up a development environment and how to make a 177 | contribution to serialchemy, see the `contributing guidelines`_. 178 | 179 | .. _contributing guidelines: https://github.com/ESSS/serialchemy/blob/master/CONTRIBUTING.rst 180 | 181 | 182 | Release 183 | ------- 184 | A reminder for the maintainers on how to make a new release. 185 | 186 | Note that the VERSION should folow the semantic versioning as X.Y.Z Ex.: v1.0.5 187 | 188 | Create a release-VERSION branch from upstream/master. 189 | Update CHANGELOG.rst. 190 | Push a branch with the changes. 191 | Once all builds pass, push a VERSION tag to upstream. Ex: git tag v1.0.5; git push origin --tags 192 | Merge the PR. 193 | -------------------------------------------------------------------------------- /src/serialchemy/nested_fields.py: -------------------------------------------------------------------------------- 1 | from warnings import warn 2 | 3 | from sqlalchemy.orm import class_mapper 4 | from sqlalchemy.orm.dynamic import AppenderMixin 5 | 6 | from .field import Field 7 | from .model_serializer import ModelSerializer 8 | from .serializer import Serializer 9 | 10 | 11 | class SessionBasedField(Field): 12 | """ 13 | Base class for fields that requires a SQLAlchemy session 14 | """ 15 | 16 | def load(self, serialized, session): 17 | raise NotImplementedError('load method not implemented') 18 | 19 | 20 | class PrimaryKeyField(SessionBasedField): 21 | """ 22 | Convert relationships in a list of primary keys (for serialization and deserialization). 23 | """ 24 | 25 | def __init__(self, model_class, **kwargs): 26 | super().__init__(**kwargs) 27 | self.model_class = model_class 28 | self._pk_column = get_model_pk_column(self.model_class) 29 | 30 | def load(self, serialized, session): 31 | pk_column = self._pk_column 32 | query_results = session.query(self.model_class).filter(pk_column.in_(serialized)).all() 33 | if len(serialized) != len(query_results): 34 | warn( 35 | "Not all primary keys found for '{}.{}'".format( 36 | self.model_class.__name__, self._pk_column 37 | ) 38 | ) 39 | return query_results 40 | 41 | def dump(self, value): 42 | def is_tomany_attribute(column): 43 | """ 44 | Check if the Declarative relationship attribute represents a to-many relationship. 45 | """ 46 | return isinstance(column, (list, AppenderMixin)) 47 | 48 | pk_column = self._pk_column 49 | if is_tomany_attribute(value): 50 | serialized = [getattr(item, pk_column.key) for item in value] 51 | else: 52 | return getattr(value, pk_column.key) 53 | return serialized 54 | 55 | 56 | class NestedModelField(SessionBasedField): 57 | """ 58 | A field to Dump and Update nested models. 59 | """ 60 | 61 | def __init__(self, model_class, **kwargs): 62 | if kwargs.get('serializer') is None: 63 | kwargs['serializer'] = ModelSerializer(model_class) 64 | super().__init__(**kwargs) 65 | 66 | def load(self, serialized, session): 67 | if not serialized: 68 | return None 69 | class_mapper = self.serializer.model_class 70 | pk_attr = get_model_pk_attr_name(class_mapper) 71 | pk = serialized.get(pk_attr) 72 | if pk: 73 | # Serialized object has a primary key, so we load an existing model from the database 74 | # instead of creating one 75 | if session is None: 76 | raise RuntimeError("Session object is required to deserialize a nested object") 77 | with session.no_autoflush: 78 | existing_model = session.get(class_mapper, pk) 79 | return self.serializer.load(serialized, existing_model, session=session) 80 | else: 81 | # No primary key, just create a new model entity 82 | return self.serializer.load(serialized, session=session) 83 | 84 | 85 | class NestedModelListField(SessionBasedField): 86 | """ 87 | A field to Dump and Update nested model list. 88 | """ 89 | 90 | def __init__(self, model_class, **kwargs): 91 | if kwargs.get('serializer') is None: 92 | kwargs['serializer'] = ModelSerializer(model_class) 93 | super().__init__(**kwargs) 94 | 95 | def load(self, serialized, session): 96 | if not serialized: 97 | return [] 98 | class_mapper = self.serializer.model_class 99 | pk_attr = get_model_pk_attr_name(class_mapper) 100 | models = [] 101 | for item in serialized: 102 | pk = item.get(pk_attr) 103 | if pk: 104 | # Serialized object has a primary key, so we load an existing model from the database 105 | # instead of creating one 106 | if session is None: 107 | raise RuntimeError("Session object is required to deserialize a nested object") 108 | existing_model = session.query(class_mapper).get(pk) 109 | updated_model = self.serializer.load(item, existing_model, session=session) 110 | models.append(updated_model) 111 | else: 112 | # No primary key, just create a new model entity 113 | model = self.serializer.load(item, session=session) 114 | models.append(model) 115 | return models 116 | 117 | def dump(self, value): 118 | return [self.serializer.dump(item) for item in value] if value is not None else [] 119 | 120 | 121 | class NestedAttributesField(Field): 122 | """ 123 | A read-only field that dump selected nested object attributes. 124 | """ 125 | 126 | def __init__(self, attributes, many=False): 127 | """ 128 | 129 | :param Union[attr|dict] attributes: 130 | :param many: 131 | """ 132 | serializer = NestedAttributesSerializer(attributes, many) 133 | super().__init__(dump_only=True, serializer=serializer) 134 | 135 | 136 | class NestedAttributesSerializer(Serializer): 137 | def __init__(self, attributes, many): 138 | self.attributes = attributes 139 | self.many = many 140 | 141 | def dump(self, value): 142 | if self.many: 143 | serialized = [self._dump_item(item) for item in value] 144 | else: 145 | return self._dump_item(value) 146 | return serialized 147 | 148 | def _dump_item(self, item): 149 | serialized = {} 150 | for attr_name in self.attributes: 151 | serialized[attr_name] = getattr(item, attr_name) 152 | return serialized 153 | 154 | def load(self, serialized, session=None): 155 | raise NotImplementedError() 156 | 157 | 158 | def get_model_pk_attr_name(model_class): 159 | """ 160 | Get the primary key attribute name from a Declarative model class 161 | 162 | :param Type[DeclarativeMeta] model_class: a Declarative class 163 | 164 | :return: str: the attribute name for the column with primary key 165 | """ 166 | primary_key_columns = list( 167 | filter(lambda attr_col: attr_col[1].primary_key, class_mapper(model_class).columns.items()) 168 | ) 169 | primary_key_names = set(column[0] for column in primary_key_columns) 170 | if len(primary_key_names) == 1: 171 | return primary_key_names.pop() 172 | elif len(primary_key_names) < 1: 173 | raise RuntimeError(f"Couldn't find attribute for {model_class}") 174 | else: 175 | raise RuntimeError("Multiple primary keys still not supported") 176 | 177 | 178 | def get_model_pk_column(model_class): 179 | """ 180 | Get the primary key Column object from a Declarative model class 181 | 182 | :param Type[DeclarativeMeta] model_class: a Declarative class 183 | 184 | :rtype: Column 185 | """ 186 | primary_keys = class_mapper(model_class).primary_key 187 | assert len(primary_keys) == 1, "Nested object must have exactly one primary key" 188 | return primary_keys[0] 189 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_nested_fields.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from freezegun import freeze_time 3 | 4 | from serialchemy import ModelSerializer 5 | from serialchemy.field import Field 6 | from serialchemy.nested_fields import NestedAttributesField 7 | from serialchemy.nested_fields import NestedModelField 8 | 9 | 10 | def getEmployeeSerializerNestedModelFields(model): 11 | class EmployeeSerializerNestedModelFields(ModelSerializer): 12 | 13 | password = Field(load_only=True) 14 | created_at = Field(dump_only=True) 15 | address = NestedModelField(model.Address) 16 | company = NestedModelField(model.Company) 17 | 18 | return EmployeeSerializerNestedModelFields 19 | 20 | 21 | class EmployeeSerializerNestedAttrsFields(ModelSerializer): 22 | 23 | password = Field(load_only=True) 24 | created_at = Field(dump_only=True) 25 | address = NestedAttributesField(("id", "street", "number", "city")) 26 | company = NestedAttributesField(("name", "location")) 27 | 28 | 29 | def getCompanySerializer(model): 30 | class CompanySerializer(ModelSerializer): 31 | 32 | master_engeneer = NestedModelField(model.SpecialistEngineer) 33 | master_manager = NestedModelField(model.Manager) 34 | 35 | return CompanySerializer 36 | 37 | 38 | @pytest.fixture(autouse=True) 39 | def setup(model, db_session): 40 | company = model.Company(id=5, name='Terrans', location='Korhal') 41 | emp1 = model.Manager( 42 | id=1, 43 | firstname='Jim', 44 | lastname='Raynor', 45 | email='some@email.com', 46 | password='somepass', 47 | role='Manager', 48 | _salary=400, 49 | company=company, 50 | marital_status=model.MaritalStatus.MARRIED, 51 | ) 52 | emp2 = model.Engineer( 53 | id=2, 54 | firstname='Sarah', 55 | lastname='Kerrigan', 56 | email='some@email.com', 57 | password='somepass', 58 | role='Engineer', 59 | company=company, 60 | ) 61 | emp3 = model.Employee( 62 | id=3, 63 | firstname='Tychus', 64 | lastname='Findlay', 65 | email='some@email.com', 66 | password='somepass', 67 | role='Employee', 68 | ) 69 | emp4 = model.SpecialistEngineer( 70 | id=4, 71 | firstname='Doran', 72 | lastname='Routhe', 73 | specialization='Mechanical', 74 | email='some@email.com', 75 | password='somepass', 76 | role='Specialist Engineer', 77 | marital_status=model.MaritalStatus.MARRIED, 78 | ) 79 | 80 | addr1 = model.Address(street="5 Av", number="943", city="Tarsonis", state='Mich') 81 | emp1.address = addr1 82 | emp2.address = addr1 83 | 84 | db_session.add_all([company, emp1, emp2, emp3, emp4]) 85 | db_session.commit() 86 | 87 | company.master_engeneer = emp4 88 | company.master_manager = emp1 89 | db_session.commit() 90 | 91 | 92 | @pytest.mark.parametrize( 93 | "serializer_strategy", 94 | ["NestedModelFields", "NestedAttrsFields"], 95 | ) 96 | def test_custom_serializer(model, serializer_strategy, db_session, data_regression): 97 | serializer_class = ( 98 | getEmployeeSerializerNestedModelFields(model) 99 | if serializer_strategy == "NestedModelFields" 100 | else EmployeeSerializerNestedAttrsFields 101 | ) 102 | emp = db_session.get(model.Employee, 1) 103 | serializer = serializer_class(model.Employee) 104 | serialized = serializer.dump(emp) 105 | data_regression.check( 106 | serialized, 107 | basename="test_custom_serializer_{}".format(serializer_class.__name__), 108 | ) 109 | 110 | 111 | def test_deserialize_with_custom_serializer(model, db_session, data_regression): 112 | serializer = getEmployeeSerializerNestedModelFields(model)(model.Employee) 113 | serialized = { 114 | "firstname": "John", 115 | "lastname": "Doe", 116 | "marital_status": "Married", 117 | "email": "some@email.com", 118 | "role": "Employee", 119 | "company_id": 5, 120 | "admission": "2004-06-01T00:00:00", 121 | "address": {"id": 1, "number": "245", "street": "6 Av", "zip": "88088-000"}, 122 | # Dump only field, must be ignored 123 | "created_at": "2023-12-21T00:00:00", 124 | } 125 | loaded_emp = serializer.load(serialized, session=db_session) 126 | data_regression.check(serializer.dump(loaded_emp)) 127 | 128 | 129 | def test_deserialize_existing_model(model, db_session): 130 | original = db_session.get(model.Employee, 1) 131 | assert original.firstname == "Jim" 132 | assert original.address.zip is None 133 | 134 | serializer = getEmployeeSerializerNestedModelFields(model)(model.Employee) 135 | serialized = { 136 | "id": 1, 137 | "firstname": "James", 138 | "lastname": "Eugene", 139 | "email": "some@email.com", 140 | "password": "somepass", 141 | "role": "Employee", 142 | "address": { 143 | "zip": "88088-000", 144 | "street": "st. 23", 145 | "number": 23, 146 | "city": "Columbia", 147 | "state": "SC", 148 | }, 149 | } 150 | 151 | loaded_emp = serializer.load(serialized, session=db_session) 152 | assert serialized["id"] == loaded_emp.id 153 | assert serialized["firstname"] == loaded_emp.firstname 154 | assert serialized["address"]["zip"] == loaded_emp.address.zip 155 | 156 | 157 | def test_empty_nested(model, db_session): 158 | serializer = getEmployeeSerializerNestedModelFields(model)(model.Employee) 159 | serialized = serializer.dump(db_session.get(model.Employee, 3)) 160 | assert serialized["company"] is None 161 | model = serializer.load(serialized, session=db_session) 162 | assert model.company is None 163 | 164 | 165 | def test_dump_with_nested_polymorphic(model, db_session, data_regression): 166 | serializer = getCompanySerializer(model)(model.Company) 167 | serialized = serializer.dump(db_session.query(model.Company).first()) 168 | data_regression.check(serialized) 169 | 170 | 171 | @freeze_time("2021-06-15") 172 | def test_load_with_nested_polymorphic_with_different_table_pk_names( 173 | model, db_session, data_regression 174 | ): 175 | # SpecializedEngeneer and its base class Engeneer have different names for the primary key on the database table 176 | serializer = getCompanySerializer(model)(model.Company) 177 | serialized = { 178 | 'id': 5, 179 | 'master_engeneer': {'id': 4}, 180 | 'name': 'Company 1', 181 | 'location': 'Delawere', 182 | } 183 | model = serializer.load(serialized, session=db_session) 184 | data_regression.check(serializer.dump(model)) 185 | 186 | 187 | def test_load_with_nested_polymorphic_same_table_pk_names(model, db_session, data_regression): 188 | # Manager and its base class Empoyee have the same name for the primary key on the database table 189 | serializer = getCompanySerializer(model)(model.Company) 190 | serialized = { 191 | 'id': 5, 192 | 'master_manager': {'id': 1}, 193 | 'name': 'Company 1', 194 | 'location': 'Delawere', 195 | } 196 | entity = serializer.load(serialized, session=db_session) 197 | data_regression.check( 198 | serializer.dump(entity), basename='test_load_with_nested_polymorphic_same_table_pk_names' 199 | ) 200 | -------------------------------------------------------------------------------- /src/serialchemy/model_serializer.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import warnings 3 | from functools import cached_property 4 | from typing import Any 5 | from typing import Dict 6 | 7 | from sqlalchemy.orm import class_mapper 8 | from sqlalchemy.orm import Mapper 9 | 10 | from .datetime_serializer import DateColumnSerializer 11 | from .datetime_serializer import DateTimeColumnSerializer 12 | from .field import DefaultFieldSerializer 13 | from .field import Field 14 | from .serializer import Serializer 15 | from serialchemy.enum_serializer import EnumSerializer 16 | from serialchemy.serializer_checks import is_date_column 17 | from serialchemy.serializer_checks import is_datetime_column 18 | from serialchemy.serializer_checks import is_enum_column 19 | 20 | 21 | class ModelSerializer(Serializer): 22 | """ 23 | Serializer for SQLAlchemy Declarative classes 24 | """ 25 | 26 | _class_mapper: Mapper 27 | 28 | EXTRA_SERIALIZERS = [ 29 | (DateTimeColumnSerializer, is_datetime_column), 30 | (DateColumnSerializer, is_date_column), 31 | (EnumSerializer, is_enum_column), 32 | ] 33 | 34 | def __init__(self, model_class, nest_foreign_keys=False): 35 | """ 36 | :param Type[DeclarativeMeta] model_class: the SQLAlchemy mapping class to be serialized 37 | """ 38 | self._model_class = model_class 39 | self._class_mapper = class_mapper(model_class) 40 | self._fields = self._get_declared_fields() 41 | self._initialize_fields(nest_foreign_keys) 42 | 43 | @property 44 | def model_class(self): 45 | return self._model_class 46 | 47 | @cached_property 48 | def model_class_parameters(self): 49 | return inspect.signature(self.model_class.__init__).parameters 50 | 51 | @property 52 | def mapper(self) -> Mapper: 53 | return self._class_mapper 54 | 55 | @property 56 | def model_columns(self): 57 | return self.mapper.c 58 | 59 | @property 60 | def model_composites(self): 61 | return self.mapper.composites 62 | 63 | @property 64 | def model_properties(self): 65 | model_properties = {} 66 | if self.model_columns: 67 | model_properties.update(self.model_columns) 68 | if self.model_composites: 69 | model_properties.update(self.model_composites) 70 | return model_properties 71 | 72 | @property 73 | def fields(self): 74 | return self._fields 75 | 76 | def dump(self, model): 77 | """ 78 | Create a serialized dict from a Declarative model 79 | 80 | :param DeclarativeMeta model: the model to be serialized 81 | 82 | :rtype: dict 83 | """ 84 | serial = {} 85 | for attr, field in self._fields.items(): 86 | if field.load_only: 87 | continue 88 | if not hasattr(model, attr): 89 | warnings.warn(f"{model.__class__} does not have attribute '{attr}'") 90 | value = None 91 | else: 92 | value = getattr(model, attr) 93 | if field: 94 | self._assign_default_serializer(field, attr) 95 | serialized = field.dump(value) 96 | else: 97 | serialized = value 98 | serial[attr] = serialized 99 | return serial 100 | 101 | def load(self, serialized, existing_model=None, session=None): 102 | """ 103 | Initialize a Declarative model from a serialized dict 104 | 105 | :param dict serialized: the serialized object. 106 | 107 | :param None|DeclarativeMeta existing_model: If given, the model will be updated with the serialized data. 108 | 109 | :param None|Session session: a SQLAlchemy session. Used only to load nested models 110 | """ 111 | from .nested_fields import SessionBasedField 112 | 113 | prepared_attrs = {} 114 | for field_name, value in serialized.items(): 115 | if field_name not in self._fields: 116 | warnings.warn(f"Field '{field_name}' not defined for {self._model_class.__name__}") 117 | continue 118 | field = self._fields[field_name] 119 | if field.dump_only: 120 | continue 121 | if field.creation_only and existing_model: 122 | continue 123 | self._assign_default_serializer(field, field_name) 124 | if isinstance(field, SessionBasedField): 125 | deserialized = field.load(value, session=session) 126 | else: 127 | deserialized = field.load(value) 128 | prepared_attrs[field_name] = deserialized 129 | 130 | if existing_model: 131 | model = existing_model 132 | for key, value in prepared_attrs.items(): 133 | setattr(model, key, value) 134 | else: 135 | model = self._create_model(prepared_attrs) 136 | assert model is not None, "ModelSerializer._create_model cannot return None" 137 | for key, value in prepared_attrs.items(): 138 | if key in self.model_class_parameters: 139 | continue 140 | setattr(model, key, value) 141 | return model 142 | 143 | def get_model_name(self): 144 | """ 145 | :rtype: str 146 | """ 147 | return self._model_class.__name__ 148 | 149 | def _create_model(self, serialized: Dict[str, Any]): 150 | """ 151 | Can be overridden in a derived class to customize model initialization. 152 | 153 | :param dict serialized: the serialized object 154 | 155 | :rtype: DeclarativeMeta 156 | """ 157 | kwargs = { 158 | key: value for key, value in serialized.items() if key in self.model_class_parameters 159 | } 160 | try: 161 | return self.model_class(**kwargs) 162 | except TypeError as e: 163 | raise Exception( 164 | f'Error while trying to create instance of class {self.model_class.__name__}: {e}' 165 | ) 166 | 167 | def _initialize_fields(self, nest_foreign_keys): 168 | """ 169 | Collect columns not declared in the serializer 170 | """ 171 | for attribute_name, attribute in self.model_properties.items(): 172 | if attribute_name.startswith('_'): 173 | continue 174 | if nest_foreign_keys and attribute.foreign_keys: 175 | field_name, nested_field = self._create_nested_field_from_foreign_key(attribute) 176 | self._fields.setdefault(field_name, nested_field) 177 | else: 178 | self._fields.setdefault(attribute_name, Field()) 179 | 180 | def _assign_default_serializer(self, field, property_name): 181 | """ 182 | If no serializer is defined, check if the column type has some serializer 183 | registered in EXTRA_SERIALIZERS. 184 | 185 | :param Field field: the field to assign default serializer 186 | 187 | :param str property_name: sqlalchemy column name on model 188 | """ 189 | model_property = self.model_properties.get(property_name) 190 | if isinstance(field.serializer, DefaultFieldSerializer) and model_property is not None: 191 | for serializer_class, serializer_check in self.EXTRA_SERIALIZERS: 192 | if serializer_check(model_property): 193 | field._serializer = serializer_class(model_property) 194 | 195 | @classmethod 196 | def _get_declared_fields(cls) -> dict: 197 | fields: Dict[str, Any] = {} 198 | # Fields should be only defined ModelSerializer subclasses, 199 | if cls is ModelSerializer: 200 | return fields 201 | for attr_name in dir(cls): 202 | value = getattr(cls, attr_name) 203 | if isinstance(value, Field): 204 | fields[attr_name] = value 205 | return fields 206 | 207 | def _create_nested_field_from_foreign_key(self, column_object): 208 | """ 209 | Create a NestedModelField for the given `column_object` that represents a foreign key. 210 | 211 | :param Column column_object: the model column object 212 | 213 | :rtype: Tuple[str, NestedModelField] 214 | """ 215 | from serialchemy import NestedModelField 216 | 217 | try: 218 | fk_list = list(column_object.foreign_keys) 219 | except AttributeError: 220 | raise TypeError(f"{column_object} is not a foreign key Column") 221 | fk_column = fk_list[0].column 222 | for name, rp in self.mapper.relationships.items(): 223 | if fk_column in rp.remote_side: 224 | nested_model_class = rp.argument 225 | return name, NestedModelField(nested_model_class) 226 | else: 227 | raise RuntimeError(f"Unexpected condition for {column_object}") 228 | -------------------------------------------------------------------------------- /src/serialchemy/_tests/test_serialization.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from serialchemy.enum_field import EnumKeyField 4 | from serialchemy.field import Field 5 | from serialchemy.func import dump 6 | from serialchemy.model_serializer import ModelSerializer 7 | from serialchemy.nested_fields import NestedModelField 8 | from serialchemy.nested_fields import NestedModelListField 9 | from serialchemy.nested_fields import PrimaryKeyField 10 | from serialchemy.polymorphic_serializer import PolymorphicModelSerializer 11 | 12 | 13 | def seed_data(session, model): 14 | 15 | company = model.Company(id=5, name='Terrans', location='Korhal') 16 | addr1 = model.Address(street="5 Av", number="943", city="Tarsonis", state='NA') 17 | emp1 = model.Manager( 18 | id=1, 19 | firstname='Jim', 20 | lastname='Raynor', 21 | role='Manager', 22 | _salary=400, 23 | company=company, 24 | email='some', 25 | address=addr1, 26 | password='mypass', 27 | contract_type=model.ContractType.CONTRACTOR, 28 | marital_status=model.MaritalStatus.MARRIED, 29 | ) 30 | emp2 = model.Engineer( 31 | id=2, 32 | firstname='Sarah', 33 | lastname='Kerrigan', 34 | role='Engineer', 35 | company=company, 36 | email='some', 37 | address=addr1, 38 | _salary=21.12, 39 | password='mypass', 40 | contract_type=model.ContractType.OTHER, 41 | marital_status=model.MaritalStatus.MARRIED, 42 | ) 43 | emp3 = model.Employee( 44 | id=3, 45 | firstname='Tychus', 46 | lastname='Findlay', 47 | email='some', 48 | company=company, 49 | address=addr1, 50 | role='Employee', 51 | _salary=21.12, 52 | password='mypass', 53 | contract_type=model.ContractType.EMPLOYEE, 54 | marital_status=model.MaritalStatus.SINGLE, 55 | ) 56 | emp4 = model.SpecialistEngineer( 57 | id=4, 58 | firstname='Doran', 59 | lastname='Routhe', 60 | specialization='Mechanical', 61 | role='Specialist Engineer', 62 | company=company, 63 | email='some', 64 | address=addr1, 65 | _salary=21.12, 66 | password='mypass', 67 | contract_type=model.ContractType.OTHER, 68 | ) 69 | 70 | session.add_all([company, emp1, emp2, emp3, emp4]) 71 | session.commit() 72 | 73 | 74 | def getEmployeeSerializer(model): 75 | class EmployeeSerializer(ModelSerializer): 76 | 77 | password = Field(load_only=True) 78 | created_at = Field(dump_only=True) 79 | company_name = Field(dump_only=True) 80 | address = NestedModelField(model.Address) 81 | contacts = NestedModelListField(model.Contact) 82 | marital_status = EnumKeyField(model.MaritalStatus) 83 | 84 | return EmployeeSerializer 85 | 86 | 87 | def test_model_dump(model, db_session, data_regression): 88 | seed_data(db_session, model) 89 | 90 | emp = db_session.get(model.Employee, 1) 91 | serializer = ModelSerializer(model.Employee) 92 | serialized = serializer.dump(emp) 93 | data_regression.check(serialized, basename='test_model_dump') 94 | 95 | 96 | def test_enum_key_field_dump(model, db_session, data_regression): 97 | seed_data(db_session, model) 98 | 99 | emp = db_session.get(model.Employee, 1) 100 | serializer = getEmployeeSerializer(model)(model.Employee) 101 | serialized = serializer.dump(emp) 102 | data_regression.check(serialized, basename='test_enum_key_field_dump') 103 | 104 | 105 | def test_model_load(model, data_regression): 106 | serializer = ModelSerializer(model.Employee) 107 | employee_dict = { 108 | "firstname": "Sarah", 109 | "lastname": "Kerrigan", 110 | "email": "sarahk@blitz.com", 111 | "admission": "2152-01-02T00:00:00", 112 | "marital_status": "Married", 113 | "role": "Employee", 114 | } 115 | model = serializer.load(employee_dict) 116 | data_regression.check(dump(model)) 117 | 118 | 119 | def test_enum_key_field_load(model, data_regression): 120 | 121 | serializer = getEmployeeSerializer(model)(model.Employee) 122 | employee_dict = { 123 | "firstname": "Sarah", 124 | "lastname": "Kerrigan", 125 | "email": "sarahk@blitz.com", 126 | "admission": "2152-01-02T00:00:00", 127 | "marital_status": "MARRIED", 128 | "password": 'pass', 129 | "role": 'Employee', 130 | } 131 | entity = serializer.load(employee_dict) 132 | data_regression.check(dump(entity)) 133 | 134 | 135 | def test_one2one_pk_field(model, db_session, data_regression): 136 | seed_data(db_session, model) 137 | 138 | class EmployeeSerializerPrimaryKeyFields(ModelSerializer): 139 | password = Field(load_only=True) 140 | created_at = Field(dump_only=True) 141 | address = PrimaryKeyField(model.Address) 142 | company = PrimaryKeyField(model.Company) 143 | 144 | serializer = EmployeeSerializerPrimaryKeyFields(model.Employee) 145 | employee = db_session.get(model.Employee, 2) 146 | serialized = serializer.dump(employee) 147 | data_regression.check(serialized, basename='test_one2one_pk_field') 148 | 149 | 150 | def test_one2many_pk_field(model, db_session, data_regression): 151 | seed_data(db_session, model) 152 | 153 | class CompanySerializer(ModelSerializer): 154 | employees = PrimaryKeyField(model.Employee) 155 | 156 | serializer = CompanySerializer(model.Company) 157 | company = db_session.get(model.Company, 5) 158 | serialized = serializer.dump(company) 159 | data_regression.check(serialized) 160 | 161 | serialized['employees'] = [2, 3] 162 | company = serializer.load(serialized, existing_model=company, session=db_session) 163 | assert company.employees[0] == db_session.get(model.Employee, 2) 164 | assert company.employees[1] == db_session.get(model.Employee, 3) 165 | 166 | 167 | def test_property_serialization(model, db_session): 168 | seed_data(db_session, model) 169 | 170 | class EmployeeSerializerHybridProperty(ModelSerializer): 171 | full_name = Field(dump_only=True) 172 | 173 | serializer = EmployeeSerializerHybridProperty(model.Employee) 174 | serialized = serializer.dump(db_session.get(model.Employee, 2)) 175 | assert serialized['full_name'] is not None 176 | 177 | 178 | def test_protected_field_default_creation(model, db_session): 179 | seed_data(db_session, model) 180 | 181 | serializer = ModelSerializer(model.Employee) 182 | employee = db_session.get(model.Employee, 1) 183 | assert employee._salary == 400 184 | serialized = serializer.dump(employee) 185 | assert serialized.get('role') == 'Manager' 186 | assert serialized.get('_salary') is None 187 | 188 | entity = serializer.load(serialized, session=db_session) 189 | assert entity.role == 'Manager' 190 | assert entity._salary is None 191 | 192 | 193 | def test_inherited_model_serialization(model, db_session): 194 | seed_data(db_session, model) 195 | 196 | serializer = PolymorphicModelSerializer(model.Employee) 197 | 198 | manager = db_session.get(model.Employee, 1) 199 | assert isinstance(manager, model.Manager) 200 | 201 | serialized = serializer.dump(manager) 202 | assert serialized.get('role') == 'Manager' 203 | entity = serializer.load(serialized, session=db_session) 204 | assert hasattr(entity, 'manager_name') 205 | 206 | engineer = db_session.get(model.Employee, 2) 207 | assert isinstance(engineer, model.Engineer) 208 | 209 | serialized = serializer.dump(engineer) 210 | assert serialized.get('role') == 'Engineer' 211 | entity = serializer.load(serialized, session=db_session) 212 | assert hasattr(entity, 'engineer_name') 213 | 214 | engineer = db_session.get(model.Employee, 4) 215 | assert isinstance(engineer, model.SpecialistEngineer) 216 | 217 | serialized = serializer.dump(engineer) 218 | assert serialized.get('role') == 'Specialist Engineer' 219 | entity = serializer.load(serialized, session=db_session) 220 | assert hasattr(entity, 'specialization') 221 | 222 | 223 | def test_nested_inherited_model_serialization(model, db_session: Session) -> None: 224 | seed_data(db_session, model) 225 | 226 | serializer = PolymorphicModelSerializer(model.Engineer) 227 | 228 | engineer = db_session.get(model.Employee, 2) 229 | assert isinstance(engineer, model.Engineer) 230 | serialized = serializer.dump(engineer) 231 | assert serialized.get('role') == 'Engineer' 232 | assert 'specialization' not in serialized.keys() 233 | 234 | specialist_engineer = db_session.get(model.Employee, 4) 235 | assert isinstance(specialist_engineer, model.SpecialistEngineer) 236 | serialized = serializer.dump(specialist_engineer) 237 | assert serialized.get('role') == 'Specialist Engineer' 238 | assert 'specialization' in serialized.keys() 239 | assert serialized.get('specialization') == 'Mechanical' 240 | 241 | 242 | def test_creation_only_flag(model, db_session): 243 | seed_data(db_session, model) 244 | 245 | class EmployeeSerializerCreationOnlyField(ModelSerializer): 246 | password = Field(load_only=True) 247 | created_at = Field(dump_only=True) 248 | email = Field(creation_only=True) 249 | 250 | serializer = EmployeeSerializerCreationOnlyField(model.Employee) 251 | 252 | serialized = { 253 | "password": "some", 254 | "email": "spoc@cap.co", 255 | "firstname": "S'Chn", 256 | "lastname": "Spock", 257 | "role": "Employee", 258 | } 259 | 260 | employee = serializer.load(serialized) 261 | db_session.add(employee) 262 | db_session.commit() 263 | 264 | assert employee.id is not None 265 | assert employee.email == 'spoc@cap.co' 266 | assert employee.firstname == "S'Chn" 267 | assert employee.lastname == 'Spock' 268 | 269 | serialized = { 270 | "password": "some", 271 | "email": "other_spoc@cap.co", 272 | "firstname": "Other", 273 | "lastname": "Spock", 274 | "role": "Employee", 275 | } 276 | 277 | changed_employee = serializer.load(serialized, existing_model=employee) 278 | 279 | assert changed_employee.email == 'spoc@cap.co' 280 | assert employee.firstname == 'Other' 281 | 282 | 283 | def test_dump_choice_type(model, db_session: Session, data_regression): 284 | seed_data(db_session, model) 285 | 286 | tychus = db_session.get(model.Employee, 3) 287 | serializer = ModelSerializer(model.Employee) 288 | dump = serializer.dump(tychus) 289 | data_regression.check(dump, basename='test_dump_choice_type') 290 | 291 | 292 | def test_load_choice_type(model, db_session): 293 | seed_data(db_session, model) 294 | 295 | json = { 296 | "password": "some", 297 | "email": "other_spoc@cap.co", 298 | "firstname": "Other", 299 | "lastname": "Spock", 300 | "role": "Employee", 301 | "contract_type": "Other", 302 | } 303 | 304 | serializer = ModelSerializer(model.Employee) 305 | loaded = serializer.load(json) 306 | db_session.add(loaded) 307 | db_session.commit() 308 | 309 | assert loaded.contract_type == model.ContractType.OTHER 310 | --------------------------------------------------------------------------------