plain.models 0.39.2__py3-none-any.whl → 0.40.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain/models/CHANGELOG.md +16 -0
- plain/models/README.md +185 -20
- plain/models/backends/base/creation.py +1 -1
- plain/models/cli.py +19 -118
- plain/models/fields/related_descriptors.py +9 -0
- plain/models/migrations/executor.py +13 -110
- plain_models-0.40.0.dist-info/METADATA +312 -0
- {plain_models-0.39.2.dist-info → plain_models-0.40.0.dist-info}/RECORD +11 -11
- plain_models-0.39.2.dist-info/METADATA +0 -147
- {plain_models-0.39.2.dist-info → plain_models-0.40.0.dist-info}/WHEEL +0 -0
- {plain_models-0.39.2.dist-info → plain_models-0.40.0.dist-info}/entry_points.txt +0 -0
- {plain_models-0.39.2.dist-info → plain_models-0.40.0.dist-info}/licenses/LICENSE +0 -0
plain/models/CHANGELOG.md
CHANGED
@@ -1,5 +1,21 @@
|
|
1
1
|
# plain-models changelog
|
2
2
|
|
3
|
+
## [0.40.0](https://github.com/dropseed/plain/releases/plain-models@0.40.0) (2025-08-05)
|
4
|
+
|
5
|
+
### What's changed
|
6
|
+
|
7
|
+
- Foreign key fields now accept lazy objects (like `SimpleLazyObject` used for `request.user`) by automatically evaluating them ([eb78dcc76d](https://github.com/dropseed/plain/commit/eb78dcc76d))
|
8
|
+
- Added `--no-input` option to `plain migrate` command to skip user prompts ([0bdaf0409e](https://github.com/dropseed/plain/commit/0bdaf0409e))
|
9
|
+
- Removed the `plain models optimize-migration` command ([6e4131ab29](https://github.com/dropseed/plain/commit/6e4131ab29))
|
10
|
+
- Removed the `--fake-initial` option from `plain migrate` command ([6506a8bfb9](https://github.com/dropseed/plain/commit/6506a8bfb9))
|
11
|
+
- Fixed CLI help text to reference `plain` commands instead of `manage.py` ([8071854d61](https://github.com/dropseed/plain/commit/8071854d61))
|
12
|
+
|
13
|
+
### Upgrade instructions
|
14
|
+
|
15
|
+
- Remove any usage of `plain models optimize-migration` command - it is no longer available
|
16
|
+
- Remove any usage of `--fake-initial` option from `plain migrate` commands - it is no longer supported
|
17
|
+
- It is no longer necessary to do `user=request.user or None`, for example, when setting foreign key fields with a lazy object like `request.user`. These will now be automatically evaluated.
|
18
|
+
|
3
19
|
## [0.39.2](https://github.com/dropseed/plain/releases/plain-models@0.39.2) (2025-07-25)
|
4
20
|
|
5
21
|
### What's changed
|
plain/models/README.md
CHANGED
@@ -2,6 +2,20 @@
|
|
2
2
|
|
3
3
|
**Model your data and store it in a database.**
|
4
4
|
|
5
|
+
- [Overview](#overview)
|
6
|
+
- [Database connection](#database-connection)
|
7
|
+
- [Querying](#querying)
|
8
|
+
- [Migrations](#migrations)
|
9
|
+
- [Fields](#fields)
|
10
|
+
- [Validation](#validation)
|
11
|
+
- [Indexes and constraints](#indexes-and-constraints)
|
12
|
+
- [Managers](#managers)
|
13
|
+
- [Forms](#forms)
|
14
|
+
- [Sharing fields across models](#sharing-fields-across-models)
|
15
|
+
- [Installation](#installation)
|
16
|
+
|
17
|
+
## Overview
|
18
|
+
|
5
19
|
```python
|
6
20
|
# app/users/models.py
|
7
21
|
from plain import models
|
@@ -45,25 +59,15 @@ user.delete()
|
|
45
59
|
admin_users = User.objects.filter(is_admin=True)
|
46
60
|
```
|
47
61
|
|
48
|
-
##
|
49
|
-
|
50
|
-
Install `plain.models` from PyPI, then add it to your `INSTALLED_PACKAGES`.
|
51
|
-
|
52
|
-
```python
|
53
|
-
# app/settings.py
|
54
|
-
INSTALLED_PACKAGES = [
|
55
|
-
...
|
56
|
-
"plain.models",
|
57
|
-
]
|
58
|
-
```
|
62
|
+
## Database connection
|
59
63
|
|
60
|
-
To connect to a database, you can provide a `DATABASE_URL` environment variable
|
64
|
+
To connect to a database, you can provide a `DATABASE_URL` environment variable:
|
61
65
|
|
62
66
|
```sh
|
63
67
|
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
64
68
|
```
|
65
69
|
|
66
|
-
Or you can manually define the `DATABASE` setting
|
70
|
+
Or you can manually define the `DATABASE` setting:
|
67
71
|
|
68
72
|
```python
|
69
73
|
# app/settings.py
|
@@ -81,31 +85,174 @@ Multiple backends are supported, including Postgres, MySQL, and SQLite.
|
|
81
85
|
|
82
86
|
## Querying
|
83
87
|
|
84
|
-
|
88
|
+
Models come with a powerful query API through their [`Manager`](./manager.py#Manager) interface:
|
89
|
+
|
90
|
+
```python
|
91
|
+
# Get all users
|
92
|
+
all_users = User.objects.all()
|
93
|
+
|
94
|
+
# Filter users
|
95
|
+
admin_users = User.objects.filter(is_admin=True)
|
96
|
+
recent_users = User.objects.filter(created_at__gte=datetime.now() - timedelta(days=7))
|
97
|
+
|
98
|
+
# Get a single user
|
99
|
+
user = User.objects.get(email="test@example.com")
|
100
|
+
|
101
|
+
# Complex queries with Q objects
|
102
|
+
from plain.models import Q
|
103
|
+
users = User.objects.filter(
|
104
|
+
Q(is_admin=True) | Q(email__endswith="@example.com")
|
105
|
+
)
|
106
|
+
|
107
|
+
# Ordering
|
108
|
+
users = User.objects.order_by("-created_at")
|
109
|
+
|
110
|
+
# Limiting results
|
111
|
+
first_10_users = User.objects.all()[:10]
|
112
|
+
```
|
113
|
+
|
114
|
+
For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) class.
|
85
115
|
|
86
116
|
## Migrations
|
87
117
|
|
88
|
-
|
118
|
+
Migrations track changes to your models and update the database schema accordingly:
|
119
|
+
|
120
|
+
```bash
|
121
|
+
# Create migrations for model changes
|
122
|
+
plain makemigrations
|
123
|
+
|
124
|
+
# Apply migrations to the database
|
125
|
+
plain migrate
|
126
|
+
|
127
|
+
# See migration status
|
128
|
+
plain models show-migrations
|
129
|
+
```
|
130
|
+
|
131
|
+
Migrations are Python files that describe database schema changes. They're stored in your app's `migrations/` directory.
|
89
132
|
|
90
133
|
## Fields
|
91
134
|
|
92
|
-
|
135
|
+
Plain provides many field types for different data:
|
136
|
+
|
137
|
+
```python
|
138
|
+
from plain import models
|
139
|
+
|
140
|
+
class Product(models.Model):
|
141
|
+
# Text fields
|
142
|
+
name = models.CharField(max_length=200)
|
143
|
+
description = models.TextField()
|
144
|
+
|
145
|
+
# Numeric fields
|
146
|
+
price = models.DecimalField(max_digits=10, decimal_places=2)
|
147
|
+
quantity = models.IntegerField(default=0)
|
148
|
+
|
149
|
+
# Boolean fields
|
150
|
+
is_active = models.BooleanField(default=True)
|
151
|
+
|
152
|
+
# Date and time fields
|
153
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
154
|
+
updated_at = models.DateTimeField(auto_now=True)
|
155
|
+
|
156
|
+
# Relationships
|
157
|
+
category = models.ForeignKey("Category", on_delete=models.CASCADE)
|
158
|
+
tags = models.ManyToManyField("Tag")
|
159
|
+
```
|
160
|
+
|
161
|
+
Common field types include:
|
162
|
+
|
163
|
+
- [`CharField`](./fields/__init__.py#CharField)
|
164
|
+
- [`TextField`](./fields/__init__.py#TextField)
|
165
|
+
- [`IntegerField`](./fields/__init__.py#IntegerField)
|
166
|
+
- [`DecimalField`](./fields/__init__.py#DecimalField)
|
167
|
+
- [`BooleanField`](./fields/__init__.py#BooleanField)
|
168
|
+
- [`DateTimeField`](./fields/__init__.py#DateTimeField)
|
169
|
+
- [`EmailField`](./fields/__init__.py#EmailField)
|
170
|
+
- [`URLField`](./fields/__init__.py#URLField)
|
171
|
+
- [`UUIDField`](./fields/__init__.py#UUIDField)
|
93
172
|
|
94
173
|
## Validation
|
95
174
|
|
96
|
-
|
175
|
+
Models can be validated before saving:
|
176
|
+
|
177
|
+
```python
|
178
|
+
class User(models.Model):
|
179
|
+
email = models.EmailField(unique=True)
|
180
|
+
age = models.IntegerField()
|
181
|
+
|
182
|
+
def clean(self):
|
183
|
+
if self.age < 18:
|
184
|
+
raise ValidationError("User must be 18 or older")
|
185
|
+
|
186
|
+
def save(self, *args, **kwargs):
|
187
|
+
self.full_clean() # Runs validation
|
188
|
+
super().save(*args, **kwargs)
|
189
|
+
```
|
190
|
+
|
191
|
+
Field-level validation happens automatically based on field types and constraints.
|
97
192
|
|
98
193
|
## Indexes and constraints
|
99
194
|
|
100
|
-
|
195
|
+
Optimize queries and ensure data integrity with indexes and constraints:
|
196
|
+
|
197
|
+
```python
|
198
|
+
class User(models.Model):
|
199
|
+
email = models.EmailField()
|
200
|
+
username = models.CharField(max_length=150)
|
201
|
+
age = models.IntegerField()
|
202
|
+
|
203
|
+
class Meta:
|
204
|
+
indexes = [
|
205
|
+
models.Index(fields=["email"]),
|
206
|
+
models.Index(fields=["-created_at"], name="user_created_idx"),
|
207
|
+
]
|
208
|
+
constraints = [
|
209
|
+
models.UniqueConstraint(fields=["email", "username"], name="unique_user"),
|
210
|
+
models.CheckConstraint(check=models.Q(age__gte=0), name="age_positive"),
|
211
|
+
]
|
212
|
+
```
|
101
213
|
|
102
214
|
## Managers
|
103
215
|
|
104
|
-
|
216
|
+
[`Manager`](./manager.py#Manager) objects provide the interface for querying models:
|
217
|
+
|
218
|
+
```python
|
219
|
+
class PublishedManager(models.Manager):
|
220
|
+
def get_queryset(self):
|
221
|
+
return super().get_queryset().filter(status="published")
|
222
|
+
|
223
|
+
class Article(models.Model):
|
224
|
+
title = models.CharField(max_length=200)
|
225
|
+
status = models.CharField(max_length=20)
|
226
|
+
|
227
|
+
# Default manager
|
228
|
+
objects = models.Manager()
|
229
|
+
|
230
|
+
# Custom manager
|
231
|
+
published = PublishedManager()
|
232
|
+
|
233
|
+
# Usage
|
234
|
+
all_articles = Article.objects.all()
|
235
|
+
published_articles = Article.published.all()
|
236
|
+
```
|
105
237
|
|
106
238
|
## Forms
|
107
239
|
|
108
|
-
|
240
|
+
Models integrate with Plain's form system:
|
241
|
+
|
242
|
+
```python
|
243
|
+
from plain import forms
|
244
|
+
from .models import User
|
245
|
+
|
246
|
+
class UserForm(forms.ModelForm):
|
247
|
+
class Meta:
|
248
|
+
model = User
|
249
|
+
fields = ["email", "is_admin"]
|
250
|
+
|
251
|
+
# Usage
|
252
|
+
form = UserForm(data=request.data)
|
253
|
+
if form.is_valid():
|
254
|
+
user = form.save()
|
255
|
+
```
|
109
256
|
|
110
257
|
## Sharing fields across models
|
111
258
|
|
@@ -134,3 +281,21 @@ class Note(TimestampedMixin, models.Model):
|
|
134
281
|
content = models.TextField(max_length=1024)
|
135
282
|
liked = models.BooleanField(default=False)
|
136
283
|
```
|
284
|
+
|
285
|
+
## Installation
|
286
|
+
|
287
|
+
Install the `plain.models` package from [PyPI](https://pypi.org/project/plain.models/):
|
288
|
+
|
289
|
+
```bash
|
290
|
+
uv add plain.models
|
291
|
+
```
|
292
|
+
|
293
|
+
Then add to your `INSTALLED_PACKAGES`:
|
294
|
+
|
295
|
+
```python
|
296
|
+
# app/settings.py
|
297
|
+
INSTALLED_PACKAGES = [
|
298
|
+
...
|
299
|
+
"plain.models",
|
300
|
+
]
|
301
|
+
```
|
@@ -53,11 +53,11 @@ class BaseDatabaseCreation:
|
|
53
53
|
package_label=None,
|
54
54
|
migration_name=None,
|
55
55
|
fake=False,
|
56
|
-
fake_initial=False,
|
57
56
|
plan=False,
|
58
57
|
check_unapplied=False,
|
59
58
|
backup=False,
|
60
59
|
prune=False,
|
60
|
+
no_input=True,
|
61
61
|
verbosity=max(verbosity - 1, 0),
|
62
62
|
)
|
63
63
|
|
plain/models/cli.py
CHANGED
@@ -325,11 +325,6 @@ def makemigrations(package_labels, dry_run, empty, no_input, name, check, verbos
|
|
325
325
|
@click.option(
|
326
326
|
"--fake", is_flag=True, help="Mark migrations as run without actually running them."
|
327
327
|
)
|
328
|
-
@click.option(
|
329
|
-
"--fake-initial",
|
330
|
-
is_flag=True,
|
331
|
-
help="Detect if tables already exist and fake-apply initial migrations if so. Make sure that the current database schema matches your initial migration before using this flag. Plain will only check for an existing table name.",
|
332
|
-
)
|
333
328
|
@click.option(
|
334
329
|
"--plan",
|
335
330
|
is_flag=True,
|
@@ -353,6 +348,13 @@ def makemigrations(package_labels, dry_run, empty, no_input, name, check, verbos
|
|
353
348
|
is_flag=True,
|
354
349
|
help="Delete nonexistent migrations from the plainmigrations table.",
|
355
350
|
)
|
351
|
+
@click.option(
|
352
|
+
"--no-input",
|
353
|
+
"--noinput",
|
354
|
+
"no_input",
|
355
|
+
is_flag=True,
|
356
|
+
help="Tells Plain to NOT prompt the user for input of any kind.",
|
357
|
+
)
|
356
358
|
@click.option(
|
357
359
|
"-v",
|
358
360
|
"--verbosity",
|
@@ -364,11 +366,11 @@ def migrate(
|
|
364
366
|
package_label,
|
365
367
|
migration_name,
|
366
368
|
fake,
|
367
|
-
fake_initial,
|
368
369
|
plan,
|
369
370
|
check_unapplied,
|
370
371
|
backup,
|
371
372
|
prune,
|
373
|
+
no_input,
|
372
374
|
verbosity,
|
373
375
|
):
|
374
376
|
"""Updates database schema. Manages both packages with migrations and those without."""
|
@@ -500,7 +502,7 @@ def migrate(
|
|
500
502
|
click.echo(f" {package}.{name}")
|
501
503
|
click.echo(
|
502
504
|
click.style(
|
503
|
-
" Re-run
|
505
|
+
" Re-run `plain migrate` if they are not marked as "
|
504
506
|
"applied, and remove 'replaces' attributes in their "
|
505
507
|
"Migration classes.",
|
506
508
|
fg="yellow",
|
@@ -576,9 +578,12 @@ def migrate(
|
|
576
578
|
if backup or (
|
577
579
|
backup is None
|
578
580
|
and settings.DEBUG
|
579
|
-
and
|
580
|
-
|
581
|
-
|
581
|
+
and (
|
582
|
+
no_input
|
583
|
+
or click.confirm(
|
584
|
+
"\nYou are in DEBUG mode. Would you like to make a database backup before running migrations?",
|
585
|
+
default=True,
|
586
|
+
)
|
582
587
|
)
|
583
588
|
):
|
584
589
|
backup_name = f"migrate_{time.strftime('%Y%m%d_%H%M%S')}"
|
@@ -600,7 +605,6 @@ def migrate(
|
|
600
605
|
plan=migration_plan,
|
601
606
|
state=pre_migrate_state.clone(),
|
602
607
|
fake=fake,
|
603
|
-
fake_initial=fake_initial,
|
604
608
|
)
|
605
609
|
# post_migrate signals have access to all models. Ensure that all models
|
606
610
|
# are reloaded in case any are delayed.
|
@@ -642,117 +646,14 @@ def migrate(
|
|
642
646
|
)
|
643
647
|
click.echo(
|
644
648
|
click.style(
|
645
|
-
" Run
|
646
|
-
"migrations, and then re-run
|
649
|
+
" Run `plain makemigrations` to make new "
|
650
|
+
"migrations, and then re-run `plain migrate` to "
|
647
651
|
"apply them.",
|
648
652
|
fg="yellow",
|
649
653
|
)
|
650
654
|
)
|
651
655
|
|
652
656
|
|
653
|
-
@cli.command()
|
654
|
-
@click.argument("package_label")
|
655
|
-
@click.argument("migration_name")
|
656
|
-
@click.option(
|
657
|
-
"--check",
|
658
|
-
is_flag=True,
|
659
|
-
help="Exit with a non-zero status if the migration can be optimized.",
|
660
|
-
)
|
661
|
-
@click.option(
|
662
|
-
"-v",
|
663
|
-
"--verbosity",
|
664
|
-
type=int,
|
665
|
-
default=1,
|
666
|
-
help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
|
667
|
-
)
|
668
|
-
def optimize_migration(package_label, migration_name, check, verbosity):
|
669
|
-
"""Optimizes the operations for the named migration."""
|
670
|
-
try:
|
671
|
-
packages_registry.get_package_config(package_label)
|
672
|
-
except LookupError as err:
|
673
|
-
raise click.ClickException(str(err))
|
674
|
-
|
675
|
-
# Load the current graph state.
|
676
|
-
loader = MigrationLoader(None)
|
677
|
-
if package_label not in loader.migrated_packages:
|
678
|
-
raise click.ClickException(
|
679
|
-
f"Package '{package_label}' does not have migrations."
|
680
|
-
)
|
681
|
-
|
682
|
-
# Find a migration.
|
683
|
-
try:
|
684
|
-
migration = loader.get_migration_by_prefix(package_label, migration_name)
|
685
|
-
except AmbiguityError:
|
686
|
-
raise click.ClickException(
|
687
|
-
f"More than one migration matches '{migration_name}' in package "
|
688
|
-
f"'{package_label}'. Please be more specific."
|
689
|
-
)
|
690
|
-
except KeyError:
|
691
|
-
raise click.ClickException(
|
692
|
-
f"Cannot find a migration matching '{migration_name}' from package "
|
693
|
-
f"'{package_label}'."
|
694
|
-
)
|
695
|
-
|
696
|
-
# Optimize the migration.
|
697
|
-
optimizer = MigrationOptimizer()
|
698
|
-
new_operations = optimizer.optimize(migration.operations, migration.package_label)
|
699
|
-
if len(migration.operations) == len(new_operations):
|
700
|
-
if verbosity > 0:
|
701
|
-
click.echo("No optimizations possible.")
|
702
|
-
return
|
703
|
-
else:
|
704
|
-
if verbosity > 0:
|
705
|
-
click.echo(
|
706
|
-
f"Optimizing from {len(migration.operations)} operations to {len(new_operations)} operations."
|
707
|
-
)
|
708
|
-
if check:
|
709
|
-
sys.exit(1)
|
710
|
-
|
711
|
-
# Set the new migration optimizations.
|
712
|
-
migration.operations = new_operations
|
713
|
-
|
714
|
-
# Write out the optimized migration file.
|
715
|
-
writer = MigrationWriter(migration)
|
716
|
-
migration_file_string = writer.as_string()
|
717
|
-
if writer.needs_manual_porting:
|
718
|
-
if migration.replaces:
|
719
|
-
raise click.ClickException(
|
720
|
-
"Migration will require manual porting but is already a squashed "
|
721
|
-
"migration.\nTransition to a normal migration first."
|
722
|
-
)
|
723
|
-
# Make a new migration with those operations.
|
724
|
-
subclass = type(
|
725
|
-
"Migration",
|
726
|
-
(migrations.Migration,),
|
727
|
-
{
|
728
|
-
"dependencies": migration.dependencies,
|
729
|
-
"operations": new_operations,
|
730
|
-
"replaces": [(migration.package_label, migration.name)],
|
731
|
-
},
|
732
|
-
)
|
733
|
-
optimized_migration_name = f"{migration.name}_optimized"
|
734
|
-
optimized_migration = subclass(optimized_migration_name, package_label)
|
735
|
-
writer = MigrationWriter(optimized_migration)
|
736
|
-
migration_file_string = writer.as_string()
|
737
|
-
if verbosity > 0:
|
738
|
-
click.echo(click.style("Manual porting required", fg="yellow", bold=True))
|
739
|
-
click.echo(
|
740
|
-
" Your migrations contained functions that must be manually "
|
741
|
-
"copied over,\n"
|
742
|
-
" as we could not safely copy their implementation.\n"
|
743
|
-
" See the comment at the top of the optimized migration for "
|
744
|
-
"details."
|
745
|
-
)
|
746
|
-
|
747
|
-
with open(writer.path, "w", encoding="utf-8") as fh:
|
748
|
-
fh.write(migration_file_string)
|
749
|
-
|
750
|
-
if verbosity > 0:
|
751
|
-
click.echo(
|
752
|
-
click.style(f"Optimized migration {writer.path}", fg="green", bold=True)
|
753
|
-
)
|
754
|
-
|
755
|
-
|
756
657
|
@cli.command()
|
757
658
|
@click.argument("package_labels", nargs=-1)
|
758
659
|
@click.option(
|
@@ -817,7 +718,7 @@ def show_migrations(package_labels, format, verbosity):
|
|
817
718
|
if plan_node in recorded_migrations:
|
818
719
|
output = f" [X] {title}"
|
819
720
|
else:
|
820
|
-
title += " Run
|
721
|
+
title += " Run `plain migrate` to finish recording."
|
821
722
|
output = f" [-] {title}"
|
822
723
|
if verbosity >= 2 and hasattr(applied_migration, "applied"):
|
823
724
|
output += f" (applied at {applied_migration.applied.strftime('%Y-%m-%d %H:%M:%S')})"
|
@@ -997,7 +898,7 @@ def squash_migrations(
|
|
997
898
|
f"The migration '{start_migration}' cannot be found. Maybe it comes after "
|
998
899
|
f"the migration '{migration}'?\n"
|
999
900
|
f"Have a look at:\n"
|
1000
|
-
f"
|
901
|
+
f" plain models show-migrations {package_label}\n"
|
1001
902
|
f"to debug this issue."
|
1002
903
|
)
|
1003
904
|
|
@@ -60,6 +60,7 @@ from plain.models.lookups import GreaterThan, LessThanOrEqual
|
|
60
60
|
from plain.models.query import QuerySet
|
61
61
|
from plain.models.query_utils import DeferredAttribute, Q
|
62
62
|
from plain.models.utils import resolve_callables
|
63
|
+
from plain.utils.functional import LazyObject
|
63
64
|
|
64
65
|
|
65
66
|
class ForeignKeyDeferredAttribute(DeferredAttribute):
|
@@ -222,6 +223,14 @@ class ForwardManyToOneDescriptor:
|
|
222
223
|
- ``instance`` is the ``child`` instance
|
223
224
|
- ``value`` is the ``parent`` instance on the right of the equal sign
|
224
225
|
"""
|
226
|
+
# If value is a LazyObject (like SimpleLazyObject used for request.user),
|
227
|
+
# force its evaluation. For ForeignKey fields, the value should only be
|
228
|
+
# None or a model instance, never a boolean or other type.
|
229
|
+
if isinstance(value, LazyObject):
|
230
|
+
# This forces evaluation: if it's None, value becomes None;
|
231
|
+
# if it's a User instance, value becomes that instance.
|
232
|
+
value = value if value else None
|
233
|
+
|
225
234
|
# An object must be an instance of the related class.
|
226
235
|
if value is not None and not isinstance(
|
227
236
|
value, self.field.remote_field.model._meta.concrete_model
|
@@ -1,5 +1,3 @@
|
|
1
|
-
from plain.models import migrations
|
2
|
-
|
3
1
|
from .loader import MigrationLoader
|
4
2
|
from .recorder import MigrationRecorder
|
5
3
|
from .state import ProjectState
|
@@ -54,7 +52,7 @@ class MigrationExecutor:
|
|
54
52
|
migration.mutate_state(state, preserve=False)
|
55
53
|
return state
|
56
54
|
|
57
|
-
def migrate(self, targets, plan=None, state=None, fake=False
|
55
|
+
def migrate(self, targets, plan=None, state=None, fake=False):
|
58
56
|
"""
|
59
57
|
Migrate the database up to the given targets.
|
60
58
|
|
@@ -84,15 +82,13 @@ class MigrationExecutor:
|
|
84
82
|
if state is None:
|
85
83
|
# The resulting state should still include applied migrations.
|
86
84
|
state = self._create_project_state(with_applied_migrations=True)
|
87
|
-
state = self._migrate_all_forwards(
|
88
|
-
state, plan, full_plan, fake=fake, fake_initial=fake_initial
|
89
|
-
)
|
85
|
+
state = self._migrate_all_forwards(state, plan, full_plan, fake=fake)
|
90
86
|
|
91
87
|
self.check_replacements()
|
92
88
|
|
93
89
|
return state
|
94
90
|
|
95
|
-
def _migrate_all_forwards(self, state, plan, full_plan, fake
|
91
|
+
def _migrate_all_forwards(self, state, plan, full_plan, fake):
|
96
92
|
"""
|
97
93
|
Take a list of 2-tuples of the form (migration instance, False) and
|
98
94
|
apply them in the order they occur in the full_plan.
|
@@ -112,33 +108,25 @@ class MigrationExecutor:
|
|
112
108
|
state.models_registry # Render all -- performance critical
|
113
109
|
if self.progress_callback:
|
114
110
|
self.progress_callback("render_success")
|
115
|
-
state = self.apply_migration(
|
116
|
-
state, migration, fake=fake, fake_initial=fake_initial
|
117
|
-
)
|
111
|
+
state = self.apply_migration(state, migration, fake=fake)
|
118
112
|
migrations_to_run.remove(migration)
|
119
113
|
|
120
114
|
return state
|
121
115
|
|
122
|
-
def apply_migration(self, state, migration, fake=False
|
116
|
+
def apply_migration(self, state, migration, fake=False):
|
123
117
|
"""Run a migration forwards."""
|
124
118
|
migration_recorded = False
|
125
119
|
if self.progress_callback:
|
126
120
|
self.progress_callback("apply_start", migration, fake)
|
127
121
|
if not fake:
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
atomic=migration.atomic
|
137
|
-
) as schema_editor:
|
138
|
-
state = migration.apply(state, schema_editor)
|
139
|
-
if not schema_editor.deferred_sql:
|
140
|
-
self.record_migration(migration)
|
141
|
-
migration_recorded = True
|
122
|
+
# Alright, do it normally
|
123
|
+
with self.connection.schema_editor(
|
124
|
+
atomic=migration.atomic
|
125
|
+
) as schema_editor:
|
126
|
+
state = migration.apply(state, schema_editor)
|
127
|
+
if not schema_editor.deferred_sql:
|
128
|
+
self.record_migration(migration)
|
129
|
+
migration_recorded = True
|
142
130
|
if not migration_recorded:
|
143
131
|
self.record_migration(migration)
|
144
132
|
# Report progress
|
@@ -170,88 +158,3 @@ class MigrationExecutor:
|
|
170
158
|
all_applied = all(m in applied for m in migration.replaces)
|
171
159
|
if all_applied and key not in applied:
|
172
160
|
self.recorder.record_applied(*key)
|
173
|
-
|
174
|
-
def detect_soft_applied(self, project_state, migration):
|
175
|
-
"""
|
176
|
-
Test whether a migration has been implicitly applied - that the
|
177
|
-
tables or columns it would create exist. This is intended only for use
|
178
|
-
on initial migrations (as it only looks for CreateModel and AddField).
|
179
|
-
"""
|
180
|
-
|
181
|
-
if migration.initial is None:
|
182
|
-
# Bail if the migration isn't the first one in its app
|
183
|
-
if any(
|
184
|
-
app == migration.package_label for app, name in migration.dependencies
|
185
|
-
):
|
186
|
-
return False, project_state
|
187
|
-
elif migration.initial is False:
|
188
|
-
# Bail if it's NOT an initial migration
|
189
|
-
return False, project_state
|
190
|
-
|
191
|
-
if project_state is None:
|
192
|
-
after_state = self.loader.project_state(
|
193
|
-
(migration.package_label, migration.name), at_end=True
|
194
|
-
)
|
195
|
-
else:
|
196
|
-
after_state = migration.mutate_state(project_state)
|
197
|
-
models_registry = after_state.models_registry
|
198
|
-
found_create_model_migration = False
|
199
|
-
found_add_field_migration = False
|
200
|
-
fold_identifier_case = self.connection.features.ignores_table_name_case
|
201
|
-
with self.connection.cursor() as cursor:
|
202
|
-
existing_table_names = set(
|
203
|
-
self.connection.introspection.table_names(cursor)
|
204
|
-
)
|
205
|
-
if fold_identifier_case:
|
206
|
-
existing_table_names = {
|
207
|
-
name.casefold() for name in existing_table_names
|
208
|
-
}
|
209
|
-
# Make sure all create model and add field operations are done
|
210
|
-
for operation in migration.operations:
|
211
|
-
if isinstance(operation, migrations.CreateModel):
|
212
|
-
model = models_registry.get_model(
|
213
|
-
migration.package_label, operation.name
|
214
|
-
)
|
215
|
-
|
216
|
-
db_table = model._meta.db_table
|
217
|
-
if fold_identifier_case:
|
218
|
-
db_table = db_table.casefold()
|
219
|
-
if db_table not in existing_table_names:
|
220
|
-
return False, project_state
|
221
|
-
found_create_model_migration = True
|
222
|
-
elif isinstance(operation, migrations.AddField):
|
223
|
-
model = models_registry.get_model(
|
224
|
-
migration.package_label, operation.model_name
|
225
|
-
)
|
226
|
-
|
227
|
-
table = model._meta.db_table
|
228
|
-
field = model._meta.get_field(operation.name)
|
229
|
-
|
230
|
-
# Handle implicit many-to-many tables created by AddField.
|
231
|
-
if field.many_to_many:
|
232
|
-
through_db_table = field.remote_field.through._meta.db_table
|
233
|
-
if fold_identifier_case:
|
234
|
-
through_db_table = through_db_table.casefold()
|
235
|
-
if through_db_table not in existing_table_names:
|
236
|
-
return False, project_state
|
237
|
-
else:
|
238
|
-
found_add_field_migration = True
|
239
|
-
continue
|
240
|
-
with self.connection.cursor() as cursor:
|
241
|
-
columns = self.connection.introspection.get_table_description(
|
242
|
-
cursor, table
|
243
|
-
)
|
244
|
-
for column in columns:
|
245
|
-
field_column = field.column
|
246
|
-
column_name = column.name
|
247
|
-
if fold_identifier_case:
|
248
|
-
column_name = column_name.casefold()
|
249
|
-
field_column = field_column.casefold()
|
250
|
-
if column_name == field_column:
|
251
|
-
found_add_field_migration = True
|
252
|
-
break
|
253
|
-
else:
|
254
|
-
return False, project_state
|
255
|
-
# If we get this far and we found at least one CreateModel or AddField
|
256
|
-
# migration, the migration is considered implicitly applied.
|
257
|
-
return (found_create_model_migration or found_add_field_migration), after_state
|
@@ -0,0 +1,312 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: plain.models
|
3
|
+
Version: 0.40.0
|
4
|
+
Summary: Model your data and store it in a database.
|
5
|
+
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
6
|
+
License-File: LICENSE
|
7
|
+
Requires-Python: >=3.11
|
8
|
+
Requires-Dist: plain<1.0.0
|
9
|
+
Requires-Dist: sqlparse>=0.3.1
|
10
|
+
Description-Content-Type: text/markdown
|
11
|
+
|
12
|
+
# plain.models
|
13
|
+
|
14
|
+
**Model your data and store it in a database.**
|
15
|
+
|
16
|
+
- [Overview](#overview)
|
17
|
+
- [Database connection](#database-connection)
|
18
|
+
- [Querying](#querying)
|
19
|
+
- [Migrations](#migrations)
|
20
|
+
- [Fields](#fields)
|
21
|
+
- [Validation](#validation)
|
22
|
+
- [Indexes and constraints](#indexes-and-constraints)
|
23
|
+
- [Managers](#managers)
|
24
|
+
- [Forms](#forms)
|
25
|
+
- [Sharing fields across models](#sharing-fields-across-models)
|
26
|
+
- [Installation](#installation)
|
27
|
+
|
28
|
+
## Overview
|
29
|
+
|
30
|
+
```python
|
31
|
+
# app/users/models.py
|
32
|
+
from plain import models
|
33
|
+
from plain.passwords.models import PasswordField
|
34
|
+
|
35
|
+
|
36
|
+
@models.register_model
|
37
|
+
class User(models.Model):
|
38
|
+
email = models.EmailField()
|
39
|
+
password = PasswordField()
|
40
|
+
is_admin = models.BooleanField(default=False)
|
41
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
42
|
+
|
43
|
+
def __str__(self):
|
44
|
+
return self.email
|
45
|
+
```
|
46
|
+
|
47
|
+
Every model automatically includes an `id` field which serves as the primary
|
48
|
+
key. The name `id` is reserved and can't be used for other fields.
|
49
|
+
|
50
|
+
Create, update, and delete instances of your models:
|
51
|
+
|
52
|
+
```python
|
53
|
+
from .models import User
|
54
|
+
|
55
|
+
|
56
|
+
# Create a new user
|
57
|
+
user = User.objects.create(
|
58
|
+
email="test@example.com",
|
59
|
+
password="password",
|
60
|
+
)
|
61
|
+
|
62
|
+
# Update a user
|
63
|
+
user.email = "new@example.com"
|
64
|
+
user.save()
|
65
|
+
|
66
|
+
# Delete a user
|
67
|
+
user.delete()
|
68
|
+
|
69
|
+
# Query for users
|
70
|
+
admin_users = User.objects.filter(is_admin=True)
|
71
|
+
```
|
72
|
+
|
73
|
+
## Database connection
|
74
|
+
|
75
|
+
To connect to a database, you can provide a `DATABASE_URL` environment variable:
|
76
|
+
|
77
|
+
```sh
|
78
|
+
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
79
|
+
```
|
80
|
+
|
81
|
+
Or you can manually define the `DATABASE` setting:
|
82
|
+
|
83
|
+
```python
|
84
|
+
# app/settings.py
|
85
|
+
DATABASE = {
|
86
|
+
"ENGINE": "plain.models.backends.postgresql",
|
87
|
+
"NAME": "dbname",
|
88
|
+
"USER": "user",
|
89
|
+
"PASSWORD": "password",
|
90
|
+
"HOST": "localhost",
|
91
|
+
"PORT": "5432",
|
92
|
+
}
|
93
|
+
```
|
94
|
+
|
95
|
+
Multiple backends are supported, including Postgres, MySQL, and SQLite.
|
96
|
+
|
97
|
+
## Querying
|
98
|
+
|
99
|
+
Models come with a powerful query API through their [`Manager`](./manager.py#Manager) interface:
|
100
|
+
|
101
|
+
```python
|
102
|
+
# Get all users
|
103
|
+
all_users = User.objects.all()
|
104
|
+
|
105
|
+
# Filter users
|
106
|
+
admin_users = User.objects.filter(is_admin=True)
|
107
|
+
recent_users = User.objects.filter(created_at__gte=datetime.now() - timedelta(days=7))
|
108
|
+
|
109
|
+
# Get a single user
|
110
|
+
user = User.objects.get(email="test@example.com")
|
111
|
+
|
112
|
+
# Complex queries with Q objects
|
113
|
+
from plain.models import Q
|
114
|
+
users = User.objects.filter(
|
115
|
+
Q(is_admin=True) | Q(email__endswith="@example.com")
|
116
|
+
)
|
117
|
+
|
118
|
+
# Ordering
|
119
|
+
users = User.objects.order_by("-created_at")
|
120
|
+
|
121
|
+
# Limiting results
|
122
|
+
first_10_users = User.objects.all()[:10]
|
123
|
+
```
|
124
|
+
|
125
|
+
For more advanced querying options, see the [`QuerySet`](./query.py#QuerySet) class.
|
126
|
+
|
127
|
+
## Migrations
|
128
|
+
|
129
|
+
Migrations track changes to your models and update the database schema accordingly:
|
130
|
+
|
131
|
+
```bash
|
132
|
+
# Create migrations for model changes
|
133
|
+
plain makemigrations
|
134
|
+
|
135
|
+
# Apply migrations to the database
|
136
|
+
plain migrate
|
137
|
+
|
138
|
+
# See migration status
|
139
|
+
plain models show-migrations
|
140
|
+
```
|
141
|
+
|
142
|
+
Migrations are Python files that describe database schema changes. They're stored in your app's `migrations/` directory.
|
143
|
+
|
144
|
+
## Fields
|
145
|
+
|
146
|
+
Plain provides many field types for different data:
|
147
|
+
|
148
|
+
```python
|
149
|
+
from plain import models
|
150
|
+
|
151
|
+
class Product(models.Model):
|
152
|
+
# Text fields
|
153
|
+
name = models.CharField(max_length=200)
|
154
|
+
description = models.TextField()
|
155
|
+
|
156
|
+
# Numeric fields
|
157
|
+
price = models.DecimalField(max_digits=10, decimal_places=2)
|
158
|
+
quantity = models.IntegerField(default=0)
|
159
|
+
|
160
|
+
# Boolean fields
|
161
|
+
is_active = models.BooleanField(default=True)
|
162
|
+
|
163
|
+
# Date and time fields
|
164
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
165
|
+
updated_at = models.DateTimeField(auto_now=True)
|
166
|
+
|
167
|
+
# Relationships
|
168
|
+
category = models.ForeignKey("Category", on_delete=models.CASCADE)
|
169
|
+
tags = models.ManyToManyField("Tag")
|
170
|
+
```
|
171
|
+
|
172
|
+
Common field types include:
|
173
|
+
|
174
|
+
- [`CharField`](./fields/__init__.py#CharField)
|
175
|
+
- [`TextField`](./fields/__init__.py#TextField)
|
176
|
+
- [`IntegerField`](./fields/__init__.py#IntegerField)
|
177
|
+
- [`DecimalField`](./fields/__init__.py#DecimalField)
|
178
|
+
- [`BooleanField`](./fields/__init__.py#BooleanField)
|
179
|
+
- [`DateTimeField`](./fields/__init__.py#DateTimeField)
|
180
|
+
- [`EmailField`](./fields/__init__.py#EmailField)
|
181
|
+
- [`URLField`](./fields/__init__.py#URLField)
|
182
|
+
- [`UUIDField`](./fields/__init__.py#UUIDField)
|
183
|
+
|
184
|
+
## Validation
|
185
|
+
|
186
|
+
Models can be validated before saving:
|
187
|
+
|
188
|
+
```python
|
189
|
+
class User(models.Model):
|
190
|
+
email = models.EmailField(unique=True)
|
191
|
+
age = models.IntegerField()
|
192
|
+
|
193
|
+
def clean(self):
|
194
|
+
if self.age < 18:
|
195
|
+
raise ValidationError("User must be 18 or older")
|
196
|
+
|
197
|
+
def save(self, *args, **kwargs):
|
198
|
+
self.full_clean() # Runs validation
|
199
|
+
super().save(*args, **kwargs)
|
200
|
+
```
|
201
|
+
|
202
|
+
Field-level validation happens automatically based on field types and constraints.
|
203
|
+
|
204
|
+
## Indexes and constraints
|
205
|
+
|
206
|
+
Optimize queries and ensure data integrity with indexes and constraints:
|
207
|
+
|
208
|
+
```python
|
209
|
+
class User(models.Model):
|
210
|
+
email = models.EmailField()
|
211
|
+
username = models.CharField(max_length=150)
|
212
|
+
age = models.IntegerField()
|
213
|
+
|
214
|
+
class Meta:
|
215
|
+
indexes = [
|
216
|
+
models.Index(fields=["email"]),
|
217
|
+
models.Index(fields=["-created_at"], name="user_created_idx"),
|
218
|
+
]
|
219
|
+
constraints = [
|
220
|
+
models.UniqueConstraint(fields=["email", "username"], name="unique_user"),
|
221
|
+
models.CheckConstraint(check=models.Q(age__gte=0), name="age_positive"),
|
222
|
+
]
|
223
|
+
```
|
224
|
+
|
225
|
+
## Managers
|
226
|
+
|
227
|
+
[`Manager`](./manager.py#Manager) objects provide the interface for querying models:
|
228
|
+
|
229
|
+
```python
|
230
|
+
class PublishedManager(models.Manager):
|
231
|
+
def get_queryset(self):
|
232
|
+
return super().get_queryset().filter(status="published")
|
233
|
+
|
234
|
+
class Article(models.Model):
|
235
|
+
title = models.CharField(max_length=200)
|
236
|
+
status = models.CharField(max_length=20)
|
237
|
+
|
238
|
+
# Default manager
|
239
|
+
objects = models.Manager()
|
240
|
+
|
241
|
+
# Custom manager
|
242
|
+
published = PublishedManager()
|
243
|
+
|
244
|
+
# Usage
|
245
|
+
all_articles = Article.objects.all()
|
246
|
+
published_articles = Article.published.all()
|
247
|
+
```
|
248
|
+
|
249
|
+
## Forms
|
250
|
+
|
251
|
+
Models integrate with Plain's form system:
|
252
|
+
|
253
|
+
```python
|
254
|
+
from plain import forms
|
255
|
+
from .models import User
|
256
|
+
|
257
|
+
class UserForm(forms.ModelForm):
|
258
|
+
class Meta:
|
259
|
+
model = User
|
260
|
+
fields = ["email", "is_admin"]
|
261
|
+
|
262
|
+
# Usage
|
263
|
+
form = UserForm(data=request.data)
|
264
|
+
if form.is_valid():
|
265
|
+
user = form.save()
|
266
|
+
```
|
267
|
+
|
268
|
+
## Sharing fields across models
|
269
|
+
|
270
|
+
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.
|
271
|
+
|
272
|
+
```python
|
273
|
+
from plain import models
|
274
|
+
|
275
|
+
|
276
|
+
# Regular Python class for shared fields
|
277
|
+
class TimestampedMixin:
|
278
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
279
|
+
updated_at = models.DateTimeField(auto_now=True)
|
280
|
+
|
281
|
+
|
282
|
+
# Models inherit from the mixin AND models.Model
|
283
|
+
@models.register_model
|
284
|
+
class User(TimestampedMixin, models.Model):
|
285
|
+
email = models.EmailField()
|
286
|
+
password = PasswordField()
|
287
|
+
is_admin = models.BooleanField(default=False)
|
288
|
+
|
289
|
+
|
290
|
+
@models.register_model
|
291
|
+
class Note(TimestampedMixin, models.Model):
|
292
|
+
content = models.TextField(max_length=1024)
|
293
|
+
liked = models.BooleanField(default=False)
|
294
|
+
```
|
295
|
+
|
296
|
+
## Installation
|
297
|
+
|
298
|
+
Install the `plain.models` package from [PyPI](https://pypi.org/project/plain.models/):
|
299
|
+
|
300
|
+
```bash
|
301
|
+
uv add plain.models
|
302
|
+
```
|
303
|
+
|
304
|
+
Then add to your `INSTALLED_PACKAGES`:
|
305
|
+
|
306
|
+
```python
|
307
|
+
# app/settings.py
|
308
|
+
INSTALLED_PACKAGES = [
|
309
|
+
...
|
310
|
+
"plain.models",
|
311
|
+
]
|
312
|
+
```
|
@@ -1,9 +1,9 @@
|
|
1
|
-
plain/models/CHANGELOG.md,sha256=
|
2
|
-
plain/models/README.md,sha256=
|
1
|
+
plain/models/CHANGELOG.md,sha256=aNi4zsWMbFRKdV1O5x9Du-fKepQl3jj6x7Ksu3rcIXI,9295
|
2
|
+
plain/models/README.md,sha256=uibhtLwH-JUGzpW3tCFUrQo19i2RAvet1EkpyItdFxM,7269
|
3
3
|
plain/models/__init__.py,sha256=LJhlJauhTfUySY2hTJ9qBhCbEKMxTDKpeVrjYXZnsCw,2964
|
4
4
|
plain/models/aggregates.py,sha256=P0mhsMl1VZt2CVHMuCHnNI8SxZ9citjDLEgioN6NOpo,7240
|
5
5
|
plain/models/base.py,sha256=FJlWJ_LUdjk3Bizi45R4IsYzcuLAoiUHS-R3FxxNmWk,66857
|
6
|
-
plain/models/cli.py,sha256=
|
6
|
+
plain/models/cli.py,sha256=f6VF49l4wWtPn8feTF6IL6qIrcYxk3x9nTf03BOWSp4,36564
|
7
7
|
plain/models/config.py,sha256=OF7eIEtXNZyGwgc3eMEpb5uEAup5RXeT-0um60dfBeU,636
|
8
8
|
plain/models/connections.py,sha256=RBNa2FZ0x3C9un6PaYL-IYzH_OesRSpdHNGKvYHGiOM,2276
|
9
9
|
plain/models/constants.py,sha256=ndnj9TOTKW0p4YcIPLOLEbsH6mOgFi6B1-rIzr_iwwU,210
|
@@ -34,7 +34,7 @@ plain/models/backends/utils.py,sha256=VN9b_hnGeLqndVAcCx00X7KhFC6jY2Tn6J3E62HqDE
|
|
34
34
|
plain/models/backends/base/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
35
35
|
plain/models/backends/base/base.py,sha256=I5040pUAe7-yNTb9wfMNOYjeiFQLZ5dcRI4rtmVKJ04,26457
|
36
36
|
plain/models/backends/base/client.py,sha256=90Ffs6zZYCli3tJjwsPH8TItZ8tz1Pp-zhQa-EpsNqc,937
|
37
|
-
plain/models/backends/base/creation.py,sha256=
|
37
|
+
plain/models/backends/base/creation.py,sha256=H-xx667pLediQ19HDmE_mP-b03tDKQriBmWPKigJ5t0,9332
|
38
38
|
plain/models/backends/base/features.py,sha256=1AehdhpeC7VobhggwpeXIt7HJNY2EWY700j4gYX8Xjs,7856
|
39
39
|
plain/models/backends/base/introspection.py,sha256=8icKf9h8y4kobmyrbo8JWTDcpQIAt4oS_4FtCnY7FqQ,6815
|
40
40
|
plain/models/backends/base/operations.py,sha256=tzuw7AgVb3aZn2RHamReGNco7wIt4UBkAHx8HebDZ_s,25652
|
@@ -75,7 +75,7 @@ plain/models/fields/__init__.py,sha256=PcricU7Z5qxIw-RIJhVZlMgHWWSJStJjuzU6l0P82
|
|
75
75
|
plain/models/fields/json.py,sha256=OdGW4EYBSHQgkuugB-EiIXOqGstUqcMKUvOTOVUHqEQ,18049
|
76
76
|
plain/models/fields/mixins.py,sha256=K_ocrSbb6pPuGwYZeqgzoZskwCIMFIB6IV3T5CrU5J0,1805
|
77
77
|
plain/models/fields/related.py,sha256=7Zbpz17zzmMwg9jBQhwPH-dyAW01ClaQGJMMwbArWCM,51027
|
78
|
-
plain/models/fields/related_descriptors.py,sha256=
|
78
|
+
plain/models/fields/related_descriptors.py,sha256=VRWNdR4ptXwxQ0jyBajPV9h2Gv5jc5lzBDl9XZgT4Xk,39094
|
79
79
|
plain/models/fields/related_lookups.py,sha256=rtDTS-GvN4goka7CP36dMg1ayUyOEAE39VKXyRMV5g0,7785
|
80
80
|
plain/models/fields/reverse_related.py,sha256=AqHPSx1tZ6uj9rESyy1tN1qT415N7eifkgKZHJZWH2k,10767
|
81
81
|
plain/models/functions/__init__.py,sha256=aglCm_JtzDYk2KmxubDN_78CGG3JCfRWnfJ74Oj5YJ4,2658
|
@@ -88,7 +88,7 @@ plain/models/functions/window.py,sha256=3S0QIZc_pIVcWpE5Qq-OxixmtATLb8rZrWkvCfVt
|
|
88
88
|
plain/models/migrations/__init__.py,sha256=ZAQUGrfr_OxYMIO7vUBIHLs_M3oZ4iQSjDzCHRFUdtI,96
|
89
89
|
plain/models/migrations/autodetector.py,sha256=8GpvNHdtTshKB05YiP2AWy4Vw5a2ClAm0peA4K7_-WE,61780
|
90
90
|
plain/models/migrations/exceptions.py,sha256=_bGjIMaBP2Py9ePUxUhiH0p1zXrQM4JhJO4lWfyF8-g,1044
|
91
|
-
plain/models/migrations/executor.py,sha256=
|
91
|
+
plain/models/migrations/executor.py,sha256=2_1bWM7Dp3s8z6PADAEN-Y0KnIiRzAqsUkn_nRQl5TA,6757
|
92
92
|
plain/models/migrations/graph.py,sha256=nrztu_8dU0wAUSxKUqqFWpvZcSQxGEqE6dXWkPytmCU,12570
|
93
93
|
plain/models/migrations/loader.py,sha256=qUTmaEYI1_mV6goQPQYZKjSz8rMbE6G1wqvrAsmuGwA,16464
|
94
94
|
plain/models/migrations/migration.py,sha256=22YwRHnaRnCkBpW5p7K89tAU6h4QSsG5yiq-o7W-cSI,6505
|
@@ -114,8 +114,8 @@ plain/models/sql/where.py,sha256=ezE9Clt2BmKo-I7ARsgqZ_aVA-1UdayCwr6ULSWZL6c,126
|
|
114
114
|
plain/models/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
115
115
|
plain/models/test/pytest.py,sha256=KD5-mxonBxOYIhUh9Ql5uJOIiC9R4t-LYfb6sjA0UdE,3486
|
116
116
|
plain/models/test/utils.py,sha256=S3d6zf3OFWDxB_kBJr0tDvwn51bjwDVWKPumv37N-p8,467
|
117
|
-
plain_models-0.
|
118
|
-
plain_models-0.
|
119
|
-
plain_models-0.
|
120
|
-
plain_models-0.
|
121
|
-
plain_models-0.
|
117
|
+
plain_models-0.40.0.dist-info/METADATA,sha256=jIaS3eoDJumh9HhSOzA-bXfTYB75h5MketBZH37kV0Q,7581
|
118
|
+
plain_models-0.40.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
119
|
+
plain_models-0.40.0.dist-info/entry_points.txt,sha256=IYJAW9MpL3PXyXFWmKmALagAGXC_5rzBn2eEGJlcV04,112
|
120
|
+
plain_models-0.40.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
|
121
|
+
plain_models-0.40.0.dist-info/RECORD,,
|
@@ -1,147 +0,0 @@
|
|
1
|
-
Metadata-Version: 2.4
|
2
|
-
Name: plain.models
|
3
|
-
Version: 0.39.2
|
4
|
-
Summary: Database models for Plain.
|
5
|
-
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
6
|
-
License-File: LICENSE
|
7
|
-
Requires-Python: >=3.11
|
8
|
-
Requires-Dist: plain<1.0.0
|
9
|
-
Requires-Dist: sqlparse>=0.3.1
|
10
|
-
Description-Content-Type: text/markdown
|
11
|
-
|
12
|
-
# plain.models
|
13
|
-
|
14
|
-
**Model your data and store it in a database.**
|
15
|
-
|
16
|
-
```python
|
17
|
-
# app/users/models.py
|
18
|
-
from plain import models
|
19
|
-
from plain.passwords.models import PasswordField
|
20
|
-
|
21
|
-
|
22
|
-
@models.register_model
|
23
|
-
class User(models.Model):
|
24
|
-
email = models.EmailField()
|
25
|
-
password = PasswordField()
|
26
|
-
is_admin = models.BooleanField(default=False)
|
27
|
-
created_at = models.DateTimeField(auto_now_add=True)
|
28
|
-
|
29
|
-
def __str__(self):
|
30
|
-
return self.email
|
31
|
-
```
|
32
|
-
|
33
|
-
Every model automatically includes an `id` field which serves as the primary
|
34
|
-
key. The name `id` is reserved and can't be used for other fields.
|
35
|
-
|
36
|
-
Create, update, and delete instances of your models:
|
37
|
-
|
38
|
-
```python
|
39
|
-
from .models import User
|
40
|
-
|
41
|
-
|
42
|
-
# Create a new user
|
43
|
-
user = User.objects.create(
|
44
|
-
email="test@example.com",
|
45
|
-
password="password",
|
46
|
-
)
|
47
|
-
|
48
|
-
# Update a user
|
49
|
-
user.email = "new@example.com"
|
50
|
-
user.save()
|
51
|
-
|
52
|
-
# Delete a user
|
53
|
-
user.delete()
|
54
|
-
|
55
|
-
# Query for users
|
56
|
-
admin_users = User.objects.filter(is_admin=True)
|
57
|
-
```
|
58
|
-
|
59
|
-
## Installation
|
60
|
-
|
61
|
-
Install `plain.models` from PyPI, then add it to your `INSTALLED_PACKAGES`.
|
62
|
-
|
63
|
-
```python
|
64
|
-
# app/settings.py
|
65
|
-
INSTALLED_PACKAGES = [
|
66
|
-
...
|
67
|
-
"plain.models",
|
68
|
-
]
|
69
|
-
```
|
70
|
-
|
71
|
-
To connect to a database, you can provide a `DATABASE_URL` environment variable.
|
72
|
-
|
73
|
-
```sh
|
74
|
-
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
75
|
-
```
|
76
|
-
|
77
|
-
Or you can manually define the `DATABASE` setting.
|
78
|
-
|
79
|
-
```python
|
80
|
-
# app/settings.py
|
81
|
-
DATABASE = {
|
82
|
-
"ENGINE": "plain.models.backends.postgresql",
|
83
|
-
"NAME": "dbname",
|
84
|
-
"USER": "user",
|
85
|
-
"PASSWORD": "password",
|
86
|
-
"HOST": "localhost",
|
87
|
-
"PORT": "5432",
|
88
|
-
}
|
89
|
-
```
|
90
|
-
|
91
|
-
Multiple backends are supported, including Postgres, MySQL, and SQLite.
|
92
|
-
|
93
|
-
## Querying
|
94
|
-
|
95
|
-
TODO
|
96
|
-
|
97
|
-
## Migrations
|
98
|
-
|
99
|
-
TODO
|
100
|
-
|
101
|
-
## Fields
|
102
|
-
|
103
|
-
TODO
|
104
|
-
|
105
|
-
## Validation
|
106
|
-
|
107
|
-
TODO
|
108
|
-
|
109
|
-
## Indexes and constraints
|
110
|
-
|
111
|
-
TODO
|
112
|
-
|
113
|
-
## Managers
|
114
|
-
|
115
|
-
TODO
|
116
|
-
|
117
|
-
## Forms
|
118
|
-
|
119
|
-
TODO
|
120
|
-
|
121
|
-
## Sharing fields across models
|
122
|
-
|
123
|
-
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.
|
124
|
-
|
125
|
-
```python
|
126
|
-
from plain import models
|
127
|
-
|
128
|
-
|
129
|
-
# Regular Python class for shared fields
|
130
|
-
class TimestampedMixin:
|
131
|
-
created_at = models.DateTimeField(auto_now_add=True)
|
132
|
-
updated_at = models.DateTimeField(auto_now=True)
|
133
|
-
|
134
|
-
|
135
|
-
# Models inherit from the mixin AND models.Model
|
136
|
-
@models.register_model
|
137
|
-
class User(TimestampedMixin, models.Model):
|
138
|
-
email = models.EmailField()
|
139
|
-
password = PasswordField()
|
140
|
-
is_admin = models.BooleanField(default=False)
|
141
|
-
|
142
|
-
|
143
|
-
@models.register_model
|
144
|
-
class Note(TimestampedMixin, models.Model):
|
145
|
-
content = models.TextField(max_length=1024)
|
146
|
-
liked = models.BooleanField(default=False)
|
147
|
-
```
|
File without changes
|
File without changes
|
File without changes
|