├── 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 |
--------------------------------------------------------------------------------