Skip to main content

Model your data and store it in a database.

Project description

plain.models

Model your data and store it in a database.

Overview

# app/users/models.py
from datetime import datetime

from plain import models
from plain.models import types
from plain.passwords.models import PasswordField


@models.register_model
class User(models.Model):
    email: str = types.EmailField()
    password = PasswordField()
    is_admin: bool = types.BooleanField(default=False)
    created_at: datetime = types.DateTimeField(auto_now_add=True)

    def __str__(self) -> str:
        return self.email

Every model automatically includes an id field which serves as the primary key. The name id is reserved and can't be used for other fields.

Create, update, and delete instances of your models:

from .models import User


# Create a new user
user = User.query.create(
    email="test@example.com",
    password="password",
)

# Update a user
user.email = "new@example.com"
user.save()

# Delete a user
user.delete()

# Query for users
admin_users = User.query.filter(is_admin=True)

Database connection

To connect to a database, you can provide a DATABASE_URL environment variable:

DATABASE_URL=postgresql://user:password@localhost:5432/dbname

Or you can manually define the DATABASE setting:

# app/settings.py
DATABASE = {
    "ENGINE": "plain.models.backends.postgresql",
    "NAME": "dbname",
    "USER": "user",
    "PASSWORD": "password",
    "HOST": "localhost",
    "PORT": "5432",
}

Multiple backends are supported, including Postgres, MySQL, and SQLite.

Querying

Models come with a powerful query API through their QuerySet interface:

# Get all users
all_users = User.query.all()

# Filter users
admin_users = User.query.filter(is_admin=True)
recent_users = User.query.filter(created_at__gte=datetime.now() - timedelta(days=7))

# Get a single user
user = User.query.get(email="test@example.com")

# Complex queries with Q objects
from plain.models import Q
users = User.query.filter(
    Q(is_admin=True) | Q(email__endswith="@example.com")
)

# Ordering
users = User.query.order_by("-created_at")

# Limiting results
first_10_users = User.query.all()[:10]

For more advanced querying options, see the QuerySet class.

Migrations

Migrations track changes to your models and update the database schema accordingly:

# Create migrations for model changes
plain makemigrations

# Apply migrations to the database
plain migrate

# See migration status
plain migrations list

Migrations are Python files that describe database schema changes. They're stored in your app's migrations/ directory.

Fields

Plain provides many field types for different data:

from plain import models

class Product(models.Model):
    # Text fields
    name = models.CharField(max_length=200)
    description = models.TextField()

    # Numeric fields
    price = models.DecimalField(max_digits=10, decimal_places=2)
    quantity = models.IntegerField(default=0)

    # Boolean fields
    is_active = models.BooleanField(default=True)

    # Date and time fields
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    # Relationships
    category = models.ForeignKey("Category", on_delete=models.CASCADE)
    tags = models.ManyToManyField("Tag")

Common field types include:

Reverse relationships

When you define a ForeignKey or ManyToManyField, Plain automatically creates a reverse accessor on the related model (like author.book_set). You can explicitly declare these reverse relationships using ReverseForeignKey and ReverseManyToMany:

from plain import models

@models.register_model
class Author(models.Model):
    name = models.CharField(max_length=200)
    # Explicit reverse accessor for all books by this author
    books = models.ReverseForeignKey(to="Book", field="author")

@models.register_model
class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)

# Usage
author = Author.query.get(name="Jane Doe")
for book in author.books.all():
    print(book.title)

# Add a new book
author.books.create(title="New Book")

For many-to-many relationships:

@models.register_model
class Feature(models.Model):
    name = models.CharField(max_length=100)
    # Explicit reverse accessor for all cars with this feature
    cars = models.ReverseManyToMany(to="Car", field="features")

@models.register_model
class Car(models.Model):
    model = models.CharField(max_length=100)
    features = models.ManyToManyField(Feature)

# Usage
feature = Feature.query.get(name="Sunroof")
for car in feature.cars.all():
    print(car.model)

Why use explicit reverse relations?

  • Self-documenting: The reverse accessor is visible in the model definition
  • Better IDE support: Autocomplete works for reverse accessors
  • Type safety: When combined with type annotations, type checkers understand the relationship
  • Control: You choose the accessor name instead of relying on automatic _set naming

Reverse relations are optional - if you don't declare them, the automatic {model}_set accessor still works. You can also use both approaches in the same codebase.

Typing

For better IDE support and type checking, use plain.models.types with type annotations:

from plain import models
from plain.models import types

@models.register_model
class User(models.Model):
    email: str = types.EmailField()
    username: str = types.CharField(max_length=150)
    is_admin: bool = types.BooleanField(default=False)

For nullable fields, add | None to the annotation:

published_at: datetime | None = types.DateTimeField(allow_null=True, required=False)

Foreign keys are typed with the related model:

author: Author = types.ForeignKey(Author, on_delete=models.CASCADE)

All field types from the Fields section are available through types. Typed and untyped fields can be mixed in the same model. The database behavior is identical - typed fields only add type checking.

Reverse relationships can also be typed - see the Reverse relationships section for details.

Typing QuerySets

For better type checking of query results, you can explicitly type the query attribute:

from __future__ import annotations

from plain import models
from plain.models import types

@models.register_model
class User(models.Model):
    email: str = types.EmailField()
    is_admin: bool = types.BooleanField(default=False)

    query: models.QuerySet[User] = models.QuerySet()

With this annotation, type checkers will know that User.query.get() returns a User instance and User.query.filter() returns QuerySet[User]. This is optional - the query attribute works without the annotation, but adding it improves IDE autocomplete and type checking.

Validation

Models can be validated before saving:

class User(models.Model):
    email = models.EmailField(unique=True)
    age = models.IntegerField()

    def clean(self):
        if self.age < 18:
            raise ValidationError("User must be 18 or older")

    def save(self, *args, **kwargs):
        self.full_clean()  # Runs validation
        super().save(*args, **kwargs)

Field-level validation happens automatically based on field types and constraints.

Indexes and constraints

Optimize queries and ensure data integrity with indexes and constraints:

class User(models.Model):
    email = models.EmailField()
    username = models.CharField(max_length=150)
    age = models.IntegerField()

    model_options = models.Options(
        indexes=[
            models.Index(fields=["email"]),
            models.Index(fields=["-created_at"], name="user_created_idx"),
        ],
        constraints=[
            models.UniqueConstraint(fields=["email", "username"], name="unique_user"),
            models.CheckConstraint(check=models.Q(age__gte=0), name="age_positive"),
        ],
    )

Custom QuerySets

With the Manager functionality now merged into QuerySet, you can customize QuerySet classes to provide specialized query methods.

Define a custom QuerySet and assign it to your model's query attribute:

from typing import Self

class PublishedQuerySet(models.QuerySet["Article"]):
    def published_only(self) -> Self:
        return self.filter(status="published")

    def draft_only(self) -> Self:
        return self.filter(status="draft")

@models.register_model
class Article(models.Model):
    title = models.CharField(max_length=200)
    status = models.CharField(max_length=20)

    query = PublishedQuerySet()

# Usage - all methods available on Article.query
all_articles = Article.query.all()
published_articles = Article.query.published_only()
draft_articles = Article.query.draft_only()

Custom methods can be chained with built-in QuerySet methods:

# Chaining works naturally
recent_published = Article.query.published_only().order_by("-created_at")[:10]

Programmatic QuerySet usage

For internal code that needs to create QuerySet instances programmatically, use from_model():

class SpecialQuerySet(models.QuerySet["Article"]):
    def special_filter(self) -> Self:
        return self.filter(special=True)

# Create and use the QuerySet programmatically
special_qs = SpecialQuerySet.from_model(Article)
special_articles = special_qs.special_filter()

Forms

Models integrate with Plain's form system:

from plain import forms
from .models import User

class UserForm(forms.ModelForm):
    class Meta:
        model = User
        fields = ["email", "is_admin"]

# Usage
form = UserForm(data=request.data)
if form.is_valid():
    user = form.save()

Sharing fields across models

To share common fields across multiple models, use Python classes as mixins. The final, registered model must inherit directly from models.Model and the mixins should not.

from plain import models


# Regular Python class for shared fields
class TimestampedMixin:
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)


# Models inherit from the mixin AND models.Model
@models.register_model
class User(TimestampedMixin, models.Model):
    email = models.EmailField()
    password = PasswordField()
    is_admin = models.BooleanField(default=False)


@models.register_model
class Note(TimestampedMixin, models.Model):
    content = models.TextField(max_length=1024)
    liked = models.BooleanField(default=False)

Installation

Install the plain.models package from PyPI:

uv add plain.models

Then add to your INSTALLED_PACKAGES:

# app/settings.py
INSTALLED_PACKAGES = [
    ...
    "plain.models",
]

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

plain_models-0.61.1.tar.gz (392.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

plain_models-0.61.1-py3-none-any.whl (449.6 kB view details)

Uploaded Python 3

File details

Details for the file plain_models-0.61.1.tar.gz.

File metadata

  • Download URL: plain_models-0.61.1.tar.gz
  • Upload date:
  • Size: 392.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.10 {"installer":{"name":"uv","version":"0.9.10"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for plain_models-0.61.1.tar.gz
Algorithm Hash digest
SHA256 d6e3b8d17ec9dc6ed7d6e6a201b9c0794fa5830daa309a7990e073f71773b509
MD5 d9d3b130bbb6d8f71498b75bea23cd80
BLAKE2b-256 fe026abd6a5e53acc271ac30841286d2807601259fc113db5d4d05e6c3869dc6

See more details on using hashes here.

File details

Details for the file plain_models-0.61.1-py3-none-any.whl.

File metadata

  • Download URL: plain_models-0.61.1-py3-none-any.whl
  • Upload date:
  • Size: 449.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.10 {"installer":{"name":"uv","version":"0.9.10"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for plain_models-0.61.1-py3-none-any.whl
Algorithm Hash digest
SHA256 b5772c36733043eb9a5be3d3709036f12001dfe6606c00f571400ced19a65dbd
MD5 c78776df065604d17874299fa67388c3
BLAKE2b-256 d0e04099dd63d9f55312f1a3340f0e3b0cf6547e6c11aeed64252917c05a0f3e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page