plain.models 0.39.2__py3-none-any.whl → 0.40.1__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/AGENTS.md ADDED
@@ -0,0 +1,4 @@
1
+ # Plain Models AGENTS.md
2
+
3
+ - Use the `plain makemigrations` command to create new migrations. Only write migrations by hand if they are custom data migrations.
4
+ - Use `plain migrate --backup` to run migrations.
plain/models/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # plain-models changelog
2
2
 
3
+ ## [0.40.1](https://github.com/dropseed/plain/releases/plain-models@0.40.1) (2025-09-03)
4
+
5
+ ### What's changed
6
+
7
+ - Internal documentation updates for agent commands ([df3edbf0bd](https://github.com/dropseed/plain/commit/df3edbf0bd))
8
+
9
+ ### Upgrade instructions
10
+
11
+ - No changes required
12
+
13
+ ## [0.40.0](https://github.com/dropseed/plain/releases/plain-models@0.40.0) (2025-08-05)
14
+
15
+ ### What's changed
16
+
17
+ - 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))
18
+ - Added `--no-input` option to `plain migrate` command to skip user prompts ([0bdaf0409e](https://github.com/dropseed/plain/commit/0bdaf0409e))
19
+ - Removed the `plain models optimize-migration` command ([6e4131ab29](https://github.com/dropseed/plain/commit/6e4131ab29))
20
+ - Removed the `--fake-initial` option from `plain migrate` command ([6506a8bfb9](https://github.com/dropseed/plain/commit/6506a8bfb9))
21
+ - Fixed CLI help text to reference `plain` commands instead of `manage.py` ([8071854d61](https://github.com/dropseed/plain/commit/8071854d61))
22
+
23
+ ### Upgrade instructions
24
+
25
+ - Remove any usage of `plain models optimize-migration` command - it is no longer available
26
+ - Remove any usage of `--fake-initial` option from `plain migrate` commands - it is no longer supported
27
+ - 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.
28
+
3
29
  ## [0.39.2](https://github.com/dropseed/plain/releases/plain-models@0.39.2) (2025-07-25)
4
30
 
5
31
  ### 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
- ## Installation
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
- TODO
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
- TODO
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
- TODO
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
- TODO
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
- TODO
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
- TODO
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
- TODO
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 'manage.py migrate' if they are not marked as "
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 click.confirm(
580
- "\nYou are in DEBUG mode. Would you like to make a database backup before running migrations?",
581
- default=True,
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 'manage.py makemigrations' to make new "
646
- "migrations, and then re-run 'manage.py migrate' to "
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 'manage.py migrate' to finish recording."
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" python manage.py showmigrations {package_label}\n"
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, fake_initial=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, fake_initial):
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, fake_initial=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
- if fake_initial:
129
- # Test to see if this is an already-applied initial migration
130
- applied, state = self.detect_soft_applied(state, migration)
131
- if applied:
132
- fake = True
133
- if not fake:
134
- # Alright, do it normally
135
- with self.connection.schema_editor(
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.1
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,10 @@
1
- plain/models/CHANGELOG.md,sha256=sZMW5wLHmFJ3v1aZeUHsVI3QX8tnKhpivshBn7sNlJs,8028
2
- plain/models/README.md,sha256=c5Z7YHxsS72mBS9SZuVoJhudwuAQJDm7tYBeh5fwib0,2591
1
+ plain/models/AGENTS.md,sha256=xQQW-z-DehnCUyjiGSBfLqUjoSUdo_W1b0JmwYmWieA,209
2
+ plain/models/CHANGELOG.md,sha256=f47T5-zyFFFqERNBUuRgdBLoz-6t9VF53TuuQ4Hrjrw,9575
3
+ plain/models/README.md,sha256=uibhtLwH-JUGzpW3tCFUrQo19i2RAvet1EkpyItdFxM,7269
3
4
  plain/models/__init__.py,sha256=LJhlJauhTfUySY2hTJ9qBhCbEKMxTDKpeVrjYXZnsCw,2964
4
5
  plain/models/aggregates.py,sha256=P0mhsMl1VZt2CVHMuCHnNI8SxZ9citjDLEgioN6NOpo,7240
5
6
  plain/models/base.py,sha256=FJlWJ_LUdjk3Bizi45R4IsYzcuLAoiUHS-R3FxxNmWk,66857
6
- plain/models/cli.py,sha256=f303FCV4HWJMVHCW-bK3CUOblXblpCknGjgvgnnbs-U,40409
7
+ plain/models/cli.py,sha256=f6VF49l4wWtPn8feTF6IL6qIrcYxk3x9nTf03BOWSp4,36564
7
8
  plain/models/config.py,sha256=OF7eIEtXNZyGwgc3eMEpb5uEAup5RXeT-0um60dfBeU,636
8
9
  plain/models/connections.py,sha256=RBNa2FZ0x3C9un6PaYL-IYzH_OesRSpdHNGKvYHGiOM,2276
9
10
  plain/models/constants.py,sha256=ndnj9TOTKW0p4YcIPLOLEbsH6mOgFi6B1-rIzr_iwwU,210
@@ -34,7 +35,7 @@ plain/models/backends/utils.py,sha256=VN9b_hnGeLqndVAcCx00X7KhFC6jY2Tn6J3E62HqDE
34
35
  plain/models/backends/base/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
35
36
  plain/models/backends/base/base.py,sha256=I5040pUAe7-yNTb9wfMNOYjeiFQLZ5dcRI4rtmVKJ04,26457
36
37
  plain/models/backends/base/client.py,sha256=90Ffs6zZYCli3tJjwsPH8TItZ8tz1Pp-zhQa-EpsNqc,937
37
- plain/models/backends/base/creation.py,sha256=i0QQXPAiPrPxsmJhgaxTXavOJXzmpy1UhEmkwvaVsdI,9337
38
+ plain/models/backends/base/creation.py,sha256=H-xx667pLediQ19HDmE_mP-b03tDKQriBmWPKigJ5t0,9332
38
39
  plain/models/backends/base/features.py,sha256=1AehdhpeC7VobhggwpeXIt7HJNY2EWY700j4gYX8Xjs,7856
39
40
  plain/models/backends/base/introspection.py,sha256=8icKf9h8y4kobmyrbo8JWTDcpQIAt4oS_4FtCnY7FqQ,6815
40
41
  plain/models/backends/base/operations.py,sha256=tzuw7AgVb3aZn2RHamReGNco7wIt4UBkAHx8HebDZ_s,25652
@@ -75,7 +76,7 @@ plain/models/fields/__init__.py,sha256=PcricU7Z5qxIw-RIJhVZlMgHWWSJStJjuzU6l0P82
75
76
  plain/models/fields/json.py,sha256=OdGW4EYBSHQgkuugB-EiIXOqGstUqcMKUvOTOVUHqEQ,18049
76
77
  plain/models/fields/mixins.py,sha256=K_ocrSbb6pPuGwYZeqgzoZskwCIMFIB6IV3T5CrU5J0,1805
77
78
  plain/models/fields/related.py,sha256=7Zbpz17zzmMwg9jBQhwPH-dyAW01ClaQGJMMwbArWCM,51027
78
- plain/models/fields/related_descriptors.py,sha256=4LdV6bLxXsbpYrmD8tnyphE_aX4k0TngUE9bLL6yAEA,38591
79
+ plain/models/fields/related_descriptors.py,sha256=VRWNdR4ptXwxQ0jyBajPV9h2Gv5jc5lzBDl9XZgT4Xk,39094
79
80
  plain/models/fields/related_lookups.py,sha256=rtDTS-GvN4goka7CP36dMg1ayUyOEAE39VKXyRMV5g0,7785
80
81
  plain/models/fields/reverse_related.py,sha256=AqHPSx1tZ6uj9rESyy1tN1qT415N7eifkgKZHJZWH2k,10767
81
82
  plain/models/functions/__init__.py,sha256=aglCm_JtzDYk2KmxubDN_78CGG3JCfRWnfJ74Oj5YJ4,2658
@@ -88,7 +89,7 @@ plain/models/functions/window.py,sha256=3S0QIZc_pIVcWpE5Qq-OxixmtATLb8rZrWkvCfVt
88
89
  plain/models/migrations/__init__.py,sha256=ZAQUGrfr_OxYMIO7vUBIHLs_M3oZ4iQSjDzCHRFUdtI,96
89
90
  plain/models/migrations/autodetector.py,sha256=8GpvNHdtTshKB05YiP2AWy4Vw5a2ClAm0peA4K7_-WE,61780
90
91
  plain/models/migrations/exceptions.py,sha256=_bGjIMaBP2Py9ePUxUhiH0p1zXrQM4JhJO4lWfyF8-g,1044
91
- plain/models/migrations/executor.py,sha256=0xvduDwB_6KuCFMXsArhmlkEQkTigoMJFptwUTqPOoc,11204
92
+ plain/models/migrations/executor.py,sha256=2_1bWM7Dp3s8z6PADAEN-Y0KnIiRzAqsUkn_nRQl5TA,6757
92
93
  plain/models/migrations/graph.py,sha256=nrztu_8dU0wAUSxKUqqFWpvZcSQxGEqE6dXWkPytmCU,12570
93
94
  plain/models/migrations/loader.py,sha256=qUTmaEYI1_mV6goQPQYZKjSz8rMbE6G1wqvrAsmuGwA,16464
94
95
  plain/models/migrations/migration.py,sha256=22YwRHnaRnCkBpW5p7K89tAU6h4QSsG5yiq-o7W-cSI,6505
@@ -114,8 +115,8 @@ plain/models/sql/where.py,sha256=ezE9Clt2BmKo-I7ARsgqZ_aVA-1UdayCwr6ULSWZL6c,126
114
115
  plain/models/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
115
116
  plain/models/test/pytest.py,sha256=KD5-mxonBxOYIhUh9Ql5uJOIiC9R4t-LYfb6sjA0UdE,3486
116
117
  plain/models/test/utils.py,sha256=S3d6zf3OFWDxB_kBJr0tDvwn51bjwDVWKPumv37N-p8,467
117
- plain_models-0.39.2.dist-info/METADATA,sha256=Qa0gXCRQ59--VybcXoc-dOsLzVn-FCNST50xAqPk4w0,2886
118
- plain_models-0.39.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
119
- plain_models-0.39.2.dist-info/entry_points.txt,sha256=IYJAW9MpL3PXyXFWmKmALagAGXC_5rzBn2eEGJlcV04,112
120
- plain_models-0.39.2.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
121
- plain_models-0.39.2.dist-info/RECORD,,
118
+ plain_models-0.40.1.dist-info/METADATA,sha256=4tylNlEN55nQYCWTRQisZW-F1SKJnf9kKqUojkIXiMQ,7581
119
+ plain_models-0.40.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
120
+ plain_models-0.40.1.dist-info/entry_points.txt,sha256=IYJAW9MpL3PXyXFWmKmALagAGXC_5rzBn2eEGJlcV04,112
121
+ plain_models-0.40.1.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
122
+ plain_models-0.40.1.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
- ```