Improved declarative SQLAlchemy models
Project description
# SQLAlchemy Unchained
Enhanced declarative models for SQLAlchemy.
## Usage
### 1. Install:
```bash
$ pip install sqlalchemy-unchained
```
And let's create a directory structure to work with:
```bash
mkdir your-project && cd your-project
mkdir your_package && mkdir db && touch setup.py
touch your_package/config.py your_package/db.py your_package/models.py
```
From now it is assumed that you are working from the `your-project` directory. All file paths at the top of code samples will be relative to this directory, and all commands should be run from this directory (unless otherwise noted).
### 2. Configure:
```python
# your_package/config.py
import os
class Config:
PROJECT_ROOT = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
DB_URI = 'sqlite:///' + os.path.join(PROJECT_ROOT, 'db', 'dev.sqlite')
```
Here we're creating an on-disk SQLite database at `project-root/db/dev.sqlite`. See the official documentation on [SQLAlchemy Dialects](https://docs.sqlalchemy.org/en/latest/dialects/) to learn more about connecting to other database engines.
### 3. Connect:
```python
# your_package/db.py
from sqlalchemy.orm import relationship as _relationship
from sqlalchemy_unchained import *
from sqlalchemy_unchained import _wrap_with_default_query_class
from .config import Config
_registry = ModelRegistry()
engine = create_engine(Config.DB_URI)
Session = scoped_session_factory(bind=engine)
Model = declarative_base(Session, bind=engine)
relationship = _wrap_with_default_query_class(_relationship, Model.query_class)
```
This pattern is so common that as long as you don't need to customize any of the arguments to `create_engine`, you can use the `init_sqlalchemy_unchained` convenience function:
```python
# your_package/db.py
from sqlalchemy_unchained import *
from .config import Config
engine, Session, Model, relationship = init_sqlalchemy_unchained(Config.DB_URI)
```
### 4. Create some models
```python
# your_package/models.py
from . import db
class Parent(db.Model):
name = db.Column(db.String, nullable=False)
children = db.relationship('Child', back_populates='parent')
class Child(db.Model):
name = db.Column(db.String, nullable=False)
parent_id = db.foreign_key('Parent', nullable=False)
parent = db.relationship('Parent', back_populates='children')
```
This is the first bit that's different from using stock SQLAlchemy. By default, models in SQLAlchemy Unchained automatically include a primary key column `id`, as well as the automatically-timestamped columns `created_at` and `updated_at`.
This is, of course, customizable. For example, if you wanted to rename the columns on `Parent` and disable timestamping on `Child`:
```python
# your_package/models.py
from . import db
class Parent(db.Model):
class Meta:
pk = 'pk'
created_at = 'created'
updated_at = 'updated'
name = db.Column(db.String, nullable=False)
children = db.relationship('Child', back_populates='parent')
class Child(db.Model):
class Meta:
created_at = None
updated_at = None
name = db.Column(db.String, nullable=False)
parent_id = db.foreign_key('Parent', nullable=False)
parent = db.relationship('Parent', back_populates='children')
```
The are other `Meta` options that SQLAlchemy Unchained supports, and we'll have a look at those in a bit. We'll also cover how to change the defaults for all models, as well as how to add support for your own custom `Meta` options. But for now, let's get migrations configured before we continue any further.
### 5. Configure database migrations
Install Alembic:
```bash
pip install alembic && alembic init db/migrations
```
Next, we need to configure Alembic to use the same database as we've already configured. This happens towards the top of the `db/migrations/env.py` file, which the `alembic init db/migrations` command generated for us. Modify the following lines:
```python
from your_package.config import Config
from your_package.db import Model
from your_package.models import *
```
For these import statements to work, we need to install our package. Let's create a minimal `setup.py`:
```python
# setup.py
from setuptools import setup, find_packages
setup(
name='your-project',
version='0.1.0',
packages=find_packages(exclude=['docs', 'tests']),
include_package_data=True,
zip_safe=False,
install_requires=[
'sqlalchemy-unchained>=0.1',
],
)
```
And install our package into the virtual environment you're using for development:
```bash
pip install -e .
```
That should be all that's required to get migrations working. Let's generate a migration for our models, and run it:
```bash
alembic revision --autogenerate -m 'create models'
# verify the generated migration is going to do what you want, and then run it:
alembic upgrade head
```
## Included Meta Options
### Table
```python
class Foo(db.Model):
class Meta:
table: str = 'foo'
```
Set to customize the name of the table in the database for the model. By default, we use the model's class name converted to snake case.
NOTE: The snake case logic used is slightly different from that of Flask-SQLAlchemy, so if you're porting your models over and any of them have sequential upper-case letters, you will probably need to change the default.
### Primary Key
```python
class Foo(db.Model):
class Meta:
pk: Union[str, None] = 'id' # 'id' is the default
```
Set to a string to customize the column name used for the primary key, or set to `None` to disable the column.
### Created At
```python
class Foo(db.Model):
class Meta:
created_at: Union[str, None] = 'created_at' # 'created_at' is the default
```
Set to a string to customize the column name used for the creation timestamp, or set to `None` to disable the column.
### Updated At
```python
class Foo(db.Model):
class Meta:
updated_at: Union[str, None] = 'updated_at' # 'updated_at' is the default
```
Set to a string to customize the column name used for the updated timestamp, or set to `None` to disable the column.
### Repr
```python
class Foo(db.Model):
class Meta:
repr: Tuple[str, ...] = ('id',) # ('id',) is the default
print(Foo()) # prints: Foo(id=1)
```
Set to a tuple of attribute names to customize the representation of models.
### Validation
```python
class Foo(db.Model):
class Meta:
validation: bool = True # True is the default
```
Set to `False` to disable validation of model instances.
### Polymorphic
```python
class Foo(db.Model):
class Meta:
polymorphic: Union[bool, str, None] = True # None is the default
class Bar(Foo):
pass
```
This meta option is disabled by default, and can be set to one of `'joined'`, `True` (an alias for `'joined'`), or `'single'`. See [here](https://docs.sqlalchemy.org/en/latest/orm/inheritance.html) for more info.
When `polymorphic` is enabled, there are two other meta options available to further customize its behavior:
```python
class Foo(db.Model):
class Meta:
polymorphic = True
polymorphic_on: str = 'discriminator' # the name of the column to use
polymorphic_identity: str = 'models.Foo' # the unique identifier to use for this model
class Bar(Foo):
class Meta:
polymorphic_identity = 'models.Bar'
```
`polymorphic_on` defaults to `'discriminator'`, and is the name of the column used to store the `polymorphic_identity`, which is the unique identifier used by SQLAlchemy to distinguish which model class a row should use. `polymorphic_identity` defaults to using each model class's name.
## Customizing Meta Options
The meta options available are configurable. Let's take a look at the implementation of the primary key meta option:
```python
import sqlalchemy as sa
from py_meta_utils import McsArgs, MetaOption
class ColumnMetaOption(MetaOption):
def get_value(self, meta, base_model_meta, mcs_args: McsArgs):
value = super().get_value(meta, base_model_meta, mcs_args)
return self.default if value is True else value
def check_value(self, value, mcs_args: McsArgs):
msg = f'{self.name} Meta option on {mcs_args.repr} ' \
f'must be a str, bool or None'
assert value is None or isinstance(value, (bool, str)), msg
def contribute_to_class(self, mcs_args: McsArgs, col_name):
is_polymorphic = mcs_args.Meta.polymorphic
is_polymorphic_base = mcs_args.Meta._is_base_polymorphic_model
if (mcs_args.Meta.abstract
or (is_polymorphic and not is_polymorphic_base)):
return
if col_name and col_name not in mcs_args.clsdict:
mcs_args.clsdict[col_name] = self.get_column(mcs_args)
def get_column(self, mcs_args: McsArgs):
raise NotImplementedError
class PrimaryKeyColumnMetaOption(ColumnMetaOption):
def __init__(self, name='pk', default='id', inherit=True):
super().__init__(name=name, default=default, inherit=inherit)
def get_column(self, mcs_args: McsArgs):
return sa.Column(sa.Integer, primary_key=True)
```
For examples sake, let's say you wanted every model to have a required name column. First we need to implement a `ColumnMetaOption`:
```python
# your_package/base_model.py
import sqlalchemy as sa
from py_meta_utils import McsArgs
from sqlalchemy_unchained import (BaseModel as _BaseModel, ColumnMetaOption,
ModelMetaOptionsFactory)
class NameColumnMetaOption(ColumnMetaOption):
def __init__(self):
super().__init__('name', default='name', inherit=True)
def get_column(self, mcs_args: McsArgs):
return sa.Column(sa.String, nullable=False)
class CustomModelMetaOptionsFactory(ModelMetaOptionsFactory):
def _get_meta_options(self):
return super()._get_meta_options() + [NameColumnMetaOption()]
class BaseModel(_BaseModel):
_meta_options_factory_class = CustomModelMetaOptionsFactory
```
The last step is to use our customized `BaseModel` class:
```python
# your_package/db.py
from sqlalchemy_unchained import *
from .base_model import BaseModel
from .config import Config
engine, Session, Model, relationship = init_sqlalchemy_unchained(Config.DB_URI,
model=BaseModel)
```
Enhanced declarative models for SQLAlchemy.
## Usage
### 1. Install:
```bash
$ pip install sqlalchemy-unchained
```
And let's create a directory structure to work with:
```bash
mkdir your-project && cd your-project
mkdir your_package && mkdir db && touch setup.py
touch your_package/config.py your_package/db.py your_package/models.py
```
From now it is assumed that you are working from the `your-project` directory. All file paths at the top of code samples will be relative to this directory, and all commands should be run from this directory (unless otherwise noted).
### 2. Configure:
```python
# your_package/config.py
import os
class Config:
PROJECT_ROOT = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
DB_URI = 'sqlite:///' + os.path.join(PROJECT_ROOT, 'db', 'dev.sqlite')
```
Here we're creating an on-disk SQLite database at `project-root/db/dev.sqlite`. See the official documentation on [SQLAlchemy Dialects](https://docs.sqlalchemy.org/en/latest/dialects/) to learn more about connecting to other database engines.
### 3. Connect:
```python
# your_package/db.py
from sqlalchemy.orm import relationship as _relationship
from sqlalchemy_unchained import *
from sqlalchemy_unchained import _wrap_with_default_query_class
from .config import Config
_registry = ModelRegistry()
engine = create_engine(Config.DB_URI)
Session = scoped_session_factory(bind=engine)
Model = declarative_base(Session, bind=engine)
relationship = _wrap_with_default_query_class(_relationship, Model.query_class)
```
This pattern is so common that as long as you don't need to customize any of the arguments to `create_engine`, you can use the `init_sqlalchemy_unchained` convenience function:
```python
# your_package/db.py
from sqlalchemy_unchained import *
from .config import Config
engine, Session, Model, relationship = init_sqlalchemy_unchained(Config.DB_URI)
```
### 4. Create some models
```python
# your_package/models.py
from . import db
class Parent(db.Model):
name = db.Column(db.String, nullable=False)
children = db.relationship('Child', back_populates='parent')
class Child(db.Model):
name = db.Column(db.String, nullable=False)
parent_id = db.foreign_key('Parent', nullable=False)
parent = db.relationship('Parent', back_populates='children')
```
This is the first bit that's different from using stock SQLAlchemy. By default, models in SQLAlchemy Unchained automatically include a primary key column `id`, as well as the automatically-timestamped columns `created_at` and `updated_at`.
This is, of course, customizable. For example, if you wanted to rename the columns on `Parent` and disable timestamping on `Child`:
```python
# your_package/models.py
from . import db
class Parent(db.Model):
class Meta:
pk = 'pk'
created_at = 'created'
updated_at = 'updated'
name = db.Column(db.String, nullable=False)
children = db.relationship('Child', back_populates='parent')
class Child(db.Model):
class Meta:
created_at = None
updated_at = None
name = db.Column(db.String, nullable=False)
parent_id = db.foreign_key('Parent', nullable=False)
parent = db.relationship('Parent', back_populates='children')
```
The are other `Meta` options that SQLAlchemy Unchained supports, and we'll have a look at those in a bit. We'll also cover how to change the defaults for all models, as well as how to add support for your own custom `Meta` options. But for now, let's get migrations configured before we continue any further.
### 5. Configure database migrations
Install Alembic:
```bash
pip install alembic && alembic init db/migrations
```
Next, we need to configure Alembic to use the same database as we've already configured. This happens towards the top of the `db/migrations/env.py` file, which the `alembic init db/migrations` command generated for us. Modify the following lines:
```python
from your_package.config import Config
from your_package.db import Model
from your_package.models import *
```
For these import statements to work, we need to install our package. Let's create a minimal `setup.py`:
```python
# setup.py
from setuptools import setup, find_packages
setup(
name='your-project',
version='0.1.0',
packages=find_packages(exclude=['docs', 'tests']),
include_package_data=True,
zip_safe=False,
install_requires=[
'sqlalchemy-unchained>=0.1',
],
)
```
And install our package into the virtual environment you're using for development:
```bash
pip install -e .
```
That should be all that's required to get migrations working. Let's generate a migration for our models, and run it:
```bash
alembic revision --autogenerate -m 'create models'
# verify the generated migration is going to do what you want, and then run it:
alembic upgrade head
```
## Included Meta Options
### Table
```python
class Foo(db.Model):
class Meta:
table: str = 'foo'
```
Set to customize the name of the table in the database for the model. By default, we use the model's class name converted to snake case.
NOTE: The snake case logic used is slightly different from that of Flask-SQLAlchemy, so if you're porting your models over and any of them have sequential upper-case letters, you will probably need to change the default.
### Primary Key
```python
class Foo(db.Model):
class Meta:
pk: Union[str, None] = 'id' # 'id' is the default
```
Set to a string to customize the column name used for the primary key, or set to `None` to disable the column.
### Created At
```python
class Foo(db.Model):
class Meta:
created_at: Union[str, None] = 'created_at' # 'created_at' is the default
```
Set to a string to customize the column name used for the creation timestamp, or set to `None` to disable the column.
### Updated At
```python
class Foo(db.Model):
class Meta:
updated_at: Union[str, None] = 'updated_at' # 'updated_at' is the default
```
Set to a string to customize the column name used for the updated timestamp, or set to `None` to disable the column.
### Repr
```python
class Foo(db.Model):
class Meta:
repr: Tuple[str, ...] = ('id',) # ('id',) is the default
print(Foo()) # prints: Foo(id=1)
```
Set to a tuple of attribute names to customize the representation of models.
### Validation
```python
class Foo(db.Model):
class Meta:
validation: bool = True # True is the default
```
Set to `False` to disable validation of model instances.
### Polymorphic
```python
class Foo(db.Model):
class Meta:
polymorphic: Union[bool, str, None] = True # None is the default
class Bar(Foo):
pass
```
This meta option is disabled by default, and can be set to one of `'joined'`, `True` (an alias for `'joined'`), or `'single'`. See [here](https://docs.sqlalchemy.org/en/latest/orm/inheritance.html) for more info.
When `polymorphic` is enabled, there are two other meta options available to further customize its behavior:
```python
class Foo(db.Model):
class Meta:
polymorphic = True
polymorphic_on: str = 'discriminator' # the name of the column to use
polymorphic_identity: str = 'models.Foo' # the unique identifier to use for this model
class Bar(Foo):
class Meta:
polymorphic_identity = 'models.Bar'
```
`polymorphic_on` defaults to `'discriminator'`, and is the name of the column used to store the `polymorphic_identity`, which is the unique identifier used by SQLAlchemy to distinguish which model class a row should use. `polymorphic_identity` defaults to using each model class's name.
## Customizing Meta Options
The meta options available are configurable. Let's take a look at the implementation of the primary key meta option:
```python
import sqlalchemy as sa
from py_meta_utils import McsArgs, MetaOption
class ColumnMetaOption(MetaOption):
def get_value(self, meta, base_model_meta, mcs_args: McsArgs):
value = super().get_value(meta, base_model_meta, mcs_args)
return self.default if value is True else value
def check_value(self, value, mcs_args: McsArgs):
msg = f'{self.name} Meta option on {mcs_args.repr} ' \
f'must be a str, bool or None'
assert value is None or isinstance(value, (bool, str)), msg
def contribute_to_class(self, mcs_args: McsArgs, col_name):
is_polymorphic = mcs_args.Meta.polymorphic
is_polymorphic_base = mcs_args.Meta._is_base_polymorphic_model
if (mcs_args.Meta.abstract
or (is_polymorphic and not is_polymorphic_base)):
return
if col_name and col_name not in mcs_args.clsdict:
mcs_args.clsdict[col_name] = self.get_column(mcs_args)
def get_column(self, mcs_args: McsArgs):
raise NotImplementedError
class PrimaryKeyColumnMetaOption(ColumnMetaOption):
def __init__(self, name='pk', default='id', inherit=True):
super().__init__(name=name, default=default, inherit=inherit)
def get_column(self, mcs_args: McsArgs):
return sa.Column(sa.Integer, primary_key=True)
```
For examples sake, let's say you wanted every model to have a required name column. First we need to implement a `ColumnMetaOption`:
```python
# your_package/base_model.py
import sqlalchemy as sa
from py_meta_utils import McsArgs
from sqlalchemy_unchained import (BaseModel as _BaseModel, ColumnMetaOption,
ModelMetaOptionsFactory)
class NameColumnMetaOption(ColumnMetaOption):
def __init__(self):
super().__init__('name', default='name', inherit=True)
def get_column(self, mcs_args: McsArgs):
return sa.Column(sa.String, nullable=False)
class CustomModelMetaOptionsFactory(ModelMetaOptionsFactory):
def _get_meta_options(self):
return super()._get_meta_options() + [NameColumnMetaOption()]
class BaseModel(_BaseModel):
_meta_options_factory_class = CustomModelMetaOptionsFactory
```
The last step is to use our customized `BaseModel` class:
```python
# your_package/db.py
from sqlalchemy_unchained import *
from .base_model import BaseModel
from .config import Config
engine, Session, Model, relationship = init_sqlalchemy_unchained(Config.DB_URI,
model=BaseModel)
```
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
SQLAlchemy Unchained-0.3.0.tar.gz
(18.5 kB
view hashes)
Built Distribution
Close
Hashes for SQLAlchemy Unchained-0.3.0.tar.gz
Algorithm | Hash digest | |
---|---|---|
SHA256 | a24a9d30eeafd97a1afeed3342de79e16b110a7c284f0f8e98f52d6a4d55933b |
|
MD5 | 5ded3cb9ab4fb7a12a36c8d6eb901d16 |
|
BLAKE2b-256 | 9fd4944f8e366db1631b31323ab2a43cbc0f4360fb21f79c398032329a8b818d |
Close
Hashes for SQLAlchemy_Unchained-0.3.0-py3-none-any.whl
Algorithm | Hash digest | |
---|---|---|
SHA256 | f97a30c6b710e906e6982673633f61898e2fa3604b7a2b7ec94001727bc0d045 |
|
MD5 | 716cd806df77b7146274509de06fc790 |
|
BLAKE2b-256 | acd49ac8f6df8df6788d7c2aec98abd465206d0c0a07ab6b4d070881b0a48abf |