Plain is headed towards 1.0! Subscribe for development updates →

plain.models

Model your data and store it in a database.

# app/users/models.py
from plain import models
from plain.passwords.models import PasswordField


class User(models.Model):
    email = models.EmailField(unique=True)
    password = PasswordField()
    is_staff = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.email

Create, update, and delete instances of your models:

from .models import User


# Create a new user
user = User.objects.create(
    email="[email protected]",
    password="password",
)

# Update a user
user.email = "[email protected]"
user.save()

# Delete a user
user.delete()

# Query for users
staff_users = User.objects.filter(is_staff=True)

Installation

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

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 DATABASES setting.

# app/settings.py
DATABASES = {
    "default": {
        "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

Migrations

Migration docs

Fields

Field docs

Validation

Indexes and constraints

Managers

Forms

  1# Copyright (c) Kenneth Reitz & individual contributors
  2# All rights reserved.
  3
  4# Redistribution and use in source and binary forms, with or without modification,
  5# are permitted provided that the following conditions are met:
  6
  7#     1. Redistributions of source code must retain the above copyright notice,
  8#        this list of conditions and the following disclaimer.
  9
 10#     2. Redistributions in binary form must reproduce the above copyright
 11#        notice, this list of conditions and the following disclaimer in the
 12#        documentation and/or other materials provided with the distribution.
 13
 14#     3. Neither the name of Plain nor the names of its contributors may be used
 15#        to endorse or promote products derived from this software without
 16#        specific prior written permission.
 17
 18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 19# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 20# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 21# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
 22# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 23# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 24# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
 25# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 26# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 27# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 28import logging
 29import os
 30import urllib.parse as urlparse
 31from typing import Any, TypedDict
 32
 33DEFAULT_ENV = "DATABASE_URL"
 34
 35SCHEMES = {
 36    "postgres": "plain.models.backends.postgresql",
 37    "postgresql": "plain.models.backends.postgresql",
 38    "pgsql": "plain.models.backends.postgresql",
 39    "mysql": "plain.models.backends.mysql",
 40    "mysql2": "plain.models.backends.mysql",
 41    "sqlite": "plain.models.backends.sqlite3",
 42}
 43
 44# Register database schemes in URLs.
 45for key in SCHEMES.keys():
 46    urlparse.uses_netloc.append(key)
 47
 48
 49class DBConfig(TypedDict, total=False):
 50    AUTOCOMMIT: bool
 51    CONN_MAX_AGE: int | None
 52    CONN_HEALTH_CHECKS: bool
 53    DISABLE_SERVER_SIDE_CURSORS: bool
 54    ENGINE: str
 55    HOST: str
 56    NAME: str
 57    OPTIONS: dict[str, Any] | None
 58    PASSWORD: str
 59    PORT: str | int
 60    TEST: dict[str, Any]
 61    TIME_ZONE: str
 62    USER: str
 63
 64
 65def config(
 66    env: str = DEFAULT_ENV,
 67    default: str | None = None,
 68    engine: str | None = None,
 69    conn_max_age: int | None = 0,
 70    conn_health_checks: bool = False,
 71    ssl_require: bool = False,
 72    test_options: dict | None = None,
 73) -> DBConfig:
 74    """Returns configured DATABASE dictionary from DATABASE_URL."""
 75    s = os.environ.get(env, default)
 76
 77    if s is None:
 78        logging.warning(
 79            "No %s environment variable set, and so no databases setup" % env
 80        )
 81
 82    if s:
 83        return parse(
 84            s, engine, conn_max_age, conn_health_checks, ssl_require, test_options
 85        )
 86
 87    return {}
 88
 89
 90def parse(
 91    url: str,
 92    engine: str | None = None,
 93    conn_max_age: int | None = 0,
 94    conn_health_checks: bool = False,
 95    ssl_require: bool = False,
 96    test_options: dict | None = None,
 97) -> DBConfig:
 98    """Parses a database URL."""
 99    if url == "sqlite://:memory:":
100        # this is a special case, because if we pass this URL into
101        # urlparse, urlparse will choke trying to interpret "memory"
102        # as a port number
103        return {"ENGINE": SCHEMES["sqlite"], "NAME": ":memory:"}
104        # note: no other settings are required for sqlite
105
106    # otherwise parse the url as normal
107    parsed_config: DBConfig = {}
108
109    if test_options is None:
110        test_options = {}
111
112    spliturl = urlparse.urlsplit(url)
113
114    # Split query strings from path.
115    path = spliturl.path[1:]
116    query = urlparse.parse_qs(spliturl.query)
117
118    # If we are using sqlite and we have no path, then assume we
119    # want an in-memory database (this is the behaviour of sqlalchemy)
120    if spliturl.scheme == "sqlite" and path == "":
121        path = ":memory:"
122
123    # Handle postgres percent-encoded paths.
124    hostname = spliturl.hostname or ""
125    if "%" in hostname:
126        # Switch to url.netloc to avoid lower cased paths
127        hostname = spliturl.netloc
128        if "@" in hostname:
129            hostname = hostname.rsplit("@", 1)[1]
130        # Use URL Parse library to decode % encodes
131        hostname = urlparse.unquote(hostname)
132
133    # Lookup specified engine.
134    if engine is None:
135        engine = SCHEMES.get(spliturl.scheme)
136        if engine is None:
137            raise ValueError(
138                "No support for '{}'. We support: {}".format(
139                    spliturl.scheme, ", ".join(sorted(SCHEMES.keys()))
140                )
141            )
142
143    port = spliturl.port
144
145    # Update with environment configuration.
146    parsed_config.update(
147        {
148            "NAME": urlparse.unquote(path or ""),
149            "USER": urlparse.unquote(spliturl.username or ""),
150            "PASSWORD": urlparse.unquote(spliturl.password or ""),
151            "HOST": hostname,
152            "PORT": port or "",
153            "CONN_MAX_AGE": conn_max_age,
154            "CONN_HEALTH_CHECKS": conn_health_checks,
155            "ENGINE": engine,
156        }
157    )
158    if test_options:
159        parsed_config.update(
160            {
161                "TEST": test_options,
162            }
163        )
164
165    # Pass the query string into OPTIONS.
166    options: dict[str, Any] = {}
167    for key, values in query.items():
168        if spliturl.scheme == "mysql" and key == "ssl-ca":
169            options["ssl"] = {"ca": values[-1]}
170            continue
171
172        options[key] = values[-1]
173
174    if ssl_require:
175        options["sslmode"] = "require"
176
177    # Support for Postgres Schema URLs
178    if "currentSchema" in options and engine in (
179        "plain.models.backends.postgresql_psycopg2",
180        "plain.models.backends.postgresql",
181    ):
182        options["options"] = "-c search_path={}".format(options.pop("currentSchema"))
183
184    if options:
185        parsed_config["OPTIONS"] = options
186
187    return parsed_config