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
Fields
Validation
Indexes and constraints
Managers
Forms
1from collections import Counter, defaultdict
2from functools import partial, reduce
3from itertools import chain
4from operator import attrgetter, or_
5
6from plain.models import (
7 query_utils,
8 signals,
9 sql,
10 transaction,
11)
12from plain.models.db import IntegrityError, connections
13from plain.models.query import QuerySet
14
15
16class ProtectedError(IntegrityError):
17 def __init__(self, msg, protected_objects):
18 self.protected_objects = protected_objects
19 super().__init__(msg, protected_objects)
20
21
22class RestrictedError(IntegrityError):
23 def __init__(self, msg, restricted_objects):
24 self.restricted_objects = restricted_objects
25 super().__init__(msg, restricted_objects)
26
27
28def CASCADE(collector, field, sub_objs, using):
29 collector.collect(
30 sub_objs,
31 source=field.remote_field.model,
32 source_attr=field.name,
33 nullable=field.null,
34 fail_on_restricted=False,
35 )
36 if field.null and not connections[using].features.can_defer_constraint_checks:
37 collector.add_field_update(field, None, sub_objs)
38
39
40def PROTECT(collector, field, sub_objs, using):
41 raise ProtectedError(
42 "Cannot delete some instances of model '{}' because they are "
43 "referenced through a protected foreign key: '{}.{}'".format(
44 field.remote_field.model.__name__,
45 sub_objs[0].__class__.__name__,
46 field.name,
47 ),
48 sub_objs,
49 )
50
51
52def RESTRICT(collector, field, sub_objs, using):
53 collector.add_restricted_objects(field, sub_objs)
54 collector.add_dependency(field.remote_field.model, field.model)
55
56
57def SET(value):
58 if callable(value):
59
60 def set_on_delete(collector, field, sub_objs, using):
61 collector.add_field_update(field, value(), sub_objs)
62
63 else:
64
65 def set_on_delete(collector, field, sub_objs, using):
66 collector.add_field_update(field, value, sub_objs)
67
68 set_on_delete.deconstruct = lambda: ("plain.models.SET", (value,), {})
69 set_on_delete.lazy_sub_objs = True
70 return set_on_delete
71
72
73def SET_NULL(collector, field, sub_objs, using):
74 collector.add_field_update(field, None, sub_objs)
75
76
77SET_NULL.lazy_sub_objs = True
78
79
80def SET_DEFAULT(collector, field, sub_objs, using):
81 collector.add_field_update(field, field.get_default(), sub_objs)
82
83
84SET_DEFAULT.lazy_sub_objs = True
85
86
87def DO_NOTHING(collector, field, sub_objs, using):
88 pass
89
90
91def get_candidate_relations_to_delete(opts):
92 # The candidate relations are the ones that come from N-1 and 1-1 relations.
93 # N-N (i.e., many-to-many) relations aren't candidates for deletion.
94 return (
95 f
96 for f in opts.get_fields(include_hidden=True)
97 if f.auto_created and not f.concrete and (f.one_to_one or f.one_to_many)
98 )
99
100
101class Collector:
102 def __init__(self, using, origin=None):
103 self.using = using
104 # A Model or QuerySet object.
105 self.origin = origin
106 # Initially, {model: {instances}}, later values become lists.
107 self.data = defaultdict(set)
108 # {(field, value): [instances, …]}
109 self.field_updates = defaultdict(list)
110 # {model: {field: {instances}}}
111 self.restricted_objects = defaultdict(partial(defaultdict, set))
112 # fast_deletes is a list of queryset-likes that can be deleted without
113 # fetching the objects into memory.
114 self.fast_deletes = []
115
116 # Tracks deletion-order dependency for databases without transactions
117 # or ability to defer constraint checks. Only concrete model classes
118 # should be included, as the dependencies exist only between actual
119 # database tables.
120 self.dependencies = defaultdict(set) # {model: {models}}
121
122 def add(self, objs, source=None, nullable=False, reverse_dependency=False):
123 """
124 Add 'objs' to the collection of objects to be deleted. If the call is
125 the result of a cascade, 'source' should be the model that caused it,
126 and 'nullable' should be set to True if the relation can be null.
127
128 Return a list of all objects that were not already collected.
129 """
130 if not objs:
131 return []
132 new_objs = []
133 model = objs[0].__class__
134 instances = self.data[model]
135 for obj in objs:
136 if obj not in instances:
137 new_objs.append(obj)
138 instances.update(new_objs)
139 # Nullable relationships can be ignored -- they are nulled out before
140 # deleting, and therefore do not affect the order in which objects have
141 # to be deleted.
142 if source is not None and not nullable:
143 self.add_dependency(source, model, reverse_dependency=reverse_dependency)
144 return new_objs
145
146 def add_dependency(self, model, dependency, reverse_dependency=False):
147 if reverse_dependency:
148 model, dependency = dependency, model
149 self.dependencies[model._meta.concrete_model].add(
150 dependency._meta.concrete_model
151 )
152 self.data.setdefault(dependency, self.data.default_factory())
153
154 def add_field_update(self, field, value, objs):
155 """
156 Schedule a field update. 'objs' must be a homogeneous iterable
157 collection of model instances (e.g. a QuerySet).
158 """
159 self.field_updates[field, value].append(objs)
160
161 def add_restricted_objects(self, field, objs):
162 if objs:
163 model = objs[0].__class__
164 self.restricted_objects[model][field].update(objs)
165
166 def clear_restricted_objects_from_set(self, model, objs):
167 if model in self.restricted_objects:
168 self.restricted_objects[model] = {
169 field: items - objs
170 for field, items in self.restricted_objects[model].items()
171 }
172
173 def clear_restricted_objects_from_queryset(self, model, qs):
174 if model in self.restricted_objects:
175 objs = set(
176 qs.filter(
177 pk__in=[
178 obj.pk
179 for objs in self.restricted_objects[model].values()
180 for obj in objs
181 ]
182 )
183 )
184 self.clear_restricted_objects_from_set(model, objs)
185
186 def _has_signal_listeners(self, model):
187 return signals.pre_delete.has_listeners(
188 model
189 ) or signals.post_delete.has_listeners(model)
190
191 def can_fast_delete(self, objs, from_field=None):
192 """
193 Determine if the objects in the given queryset-like or single object
194 can be fast-deleted. This can be done if there are no cascades, no
195 parents and no signal listeners for the object class.
196
197 The 'from_field' tells where we are coming from - we need this to
198 determine if the objects are in fact to be deleted. Allow also
199 skipping parent -> child -> parent chain preventing fast delete of
200 the child.
201 """
202 if from_field and from_field.remote_field.on_delete is not CASCADE:
203 return False
204 if hasattr(objs, "_meta"):
205 model = objs._meta.model
206 elif hasattr(objs, "model") and hasattr(objs, "_raw_delete"):
207 model = objs.model
208 else:
209 return False
210 if self._has_signal_listeners(model):
211 return False
212 # The use of from_field comes from the need to avoid cascade back to
213 # parent when parent delete is cascading to child.
214 opts = model._meta
215 return (
216 all(
217 link == from_field
218 for link in opts.concrete_model._meta.parents.values()
219 )
220 and
221 # Foreign keys pointing to this model.
222 all(
223 related.field.remote_field.on_delete is DO_NOTHING
224 for related in get_candidate_relations_to_delete(opts)
225 )
226 and (
227 # Something like generic foreign key.
228 not any(
229 hasattr(field, "bulk_related_objects")
230 for field in opts.private_fields
231 )
232 )
233 )
234
235 def get_del_batches(self, objs, fields):
236 """
237 Return the objs in suitably sized batches for the used connection.
238 """
239 field_names = [field.name for field in fields]
240 conn_batch_size = max(
241 connections[self.using].ops.bulk_batch_size(field_names, objs), 1
242 )
243 if len(objs) > conn_batch_size:
244 return [
245 objs[i : i + conn_batch_size]
246 for i in range(0, len(objs), conn_batch_size)
247 ]
248 else:
249 return [objs]
250
251 def collect(
252 self,
253 objs,
254 source=None,
255 nullable=False,
256 collect_related=True,
257 source_attr=None,
258 reverse_dependency=False,
259 keep_parents=False,
260 fail_on_restricted=True,
261 ):
262 """
263 Add 'objs' to the collection of objects to be deleted as well as all
264 parent instances. 'objs' must be a homogeneous iterable collection of
265 model instances (e.g. a QuerySet). If 'collect_related' is True,
266 related objects will be handled by their respective on_delete handler.
267
268 If the call is the result of a cascade, 'source' should be the model
269 that caused it and 'nullable' should be set to True, if the relation
270 can be null.
271
272 If 'reverse_dependency' is True, 'source' will be deleted before the
273 current model, rather than after. (Needed for cascading to parent
274 models, the one case in which the cascade follows the forwards
275 direction of an FK rather than the reverse direction.)
276
277 If 'keep_parents' is True, data of parent model's will be not deleted.
278
279 If 'fail_on_restricted' is False, error won't be raised even if it's
280 prohibited to delete such objects due to RESTRICT, that defers
281 restricted object checking in recursive calls where the top-level call
282 may need to collect more objects to determine whether restricted ones
283 can be deleted.
284 """
285 if self.can_fast_delete(objs):
286 self.fast_deletes.append(objs)
287 return
288 new_objs = self.add(
289 objs, source, nullable, reverse_dependency=reverse_dependency
290 )
291 if not new_objs:
292 return
293
294 model = new_objs[0].__class__
295
296 if not keep_parents:
297 # Recursively collect concrete model's parent models, but not their
298 # related objects. These will be found by meta.get_fields()
299 concrete_model = model._meta.concrete_model
300 for ptr in concrete_model._meta.parents.values():
301 if ptr:
302 parent_objs = [getattr(obj, ptr.name) for obj in new_objs]
303 self.collect(
304 parent_objs,
305 source=model,
306 source_attr=ptr.remote_field.related_name,
307 collect_related=False,
308 reverse_dependency=True,
309 fail_on_restricted=False,
310 )
311 if not collect_related:
312 return
313
314 if keep_parents:
315 parents = set(model._meta.get_parent_list())
316 model_fast_deletes = defaultdict(list)
317 protected_objects = defaultdict(list)
318 for related in get_candidate_relations_to_delete(model._meta):
319 # Preserve parent reverse relationships if keep_parents=True.
320 if keep_parents and related.model in parents:
321 continue
322 field = related.field
323 on_delete = field.remote_field.on_delete
324 if on_delete == DO_NOTHING:
325 continue
326 related_model = related.related_model
327 if self.can_fast_delete(related_model, from_field=field):
328 model_fast_deletes[related_model].append(field)
329 continue
330 batches = self.get_del_batches(new_objs, [field])
331 for batch in batches:
332 sub_objs = self.related_objects(related_model, [field], batch)
333 # Non-referenced fields can be deferred if no signal receivers
334 # are connected for the related model as they'll never be
335 # exposed to the user. Skip field deferring when some
336 # relationships are select_related as interactions between both
337 # features are hard to get right. This should only happen in
338 # the rare cases where .related_objects is overridden anyway.
339 if not (
340 sub_objs.query.select_related
341 or self._has_signal_listeners(related_model)
342 ):
343 referenced_fields = set(
344 chain.from_iterable(
345 (rf.attname for rf in rel.field.foreign_related_fields)
346 for rel in get_candidate_relations_to_delete(
347 related_model._meta
348 )
349 )
350 )
351 sub_objs = sub_objs.only(*tuple(referenced_fields))
352 if getattr(on_delete, "lazy_sub_objs", False) or sub_objs:
353 try:
354 on_delete(self, field, sub_objs, self.using)
355 except ProtectedError as error:
356 key = f"'{field.model.__name__}.{field.name}'"
357 protected_objects[key] += error.protected_objects
358 if protected_objects:
359 raise ProtectedError(
360 "Cannot delete some instances of model {!r} because they are "
361 "referenced through protected foreign keys: {}.".format(
362 model.__name__,
363 ", ".join(protected_objects),
364 ),
365 set(chain.from_iterable(protected_objects.values())),
366 )
367 for related_model, related_fields in model_fast_deletes.items():
368 batches = self.get_del_batches(new_objs, related_fields)
369 for batch in batches:
370 sub_objs = self.related_objects(related_model, related_fields, batch)
371 self.fast_deletes.append(sub_objs)
372 for field in model._meta.private_fields:
373 if hasattr(field, "bulk_related_objects"):
374 # It's something like generic foreign key.
375 sub_objs = field.bulk_related_objects(new_objs, self.using)
376 self.collect(
377 sub_objs, source=model, nullable=True, fail_on_restricted=False
378 )
379
380 if fail_on_restricted:
381 # Raise an error if collected restricted objects (RESTRICT) aren't
382 # candidates for deletion also collected via CASCADE.
383 for related_model, instances in self.data.items():
384 self.clear_restricted_objects_from_set(related_model, instances)
385 for qs in self.fast_deletes:
386 self.clear_restricted_objects_from_queryset(qs.model, qs)
387 if self.restricted_objects.values():
388 restricted_objects = defaultdict(list)
389 for related_model, fields in self.restricted_objects.items():
390 for field, objs in fields.items():
391 if objs:
392 key = f"'{related_model.__name__}.{field.name}'"
393 restricted_objects[key] += objs
394 if restricted_objects:
395 raise RestrictedError(
396 "Cannot delete some instances of model {!r} because "
397 "they are referenced through restricted foreign keys: "
398 "{}.".format(
399 model.__name__,
400 ", ".join(restricted_objects),
401 ),
402 set(chain.from_iterable(restricted_objects.values())),
403 )
404
405 def related_objects(self, related_model, related_fields, objs):
406 """
407 Get a QuerySet of the related model to objs via related fields.
408 """
409 predicate = query_utils.Q.create(
410 [(f"{related_field.name}__in", objs) for related_field in related_fields],
411 connector=query_utils.Q.OR,
412 )
413 return related_model._base_manager.using(self.using).filter(predicate)
414
415 def instances_with_model(self):
416 for model, instances in self.data.items():
417 for obj in instances:
418 yield model, obj
419
420 def sort(self):
421 sorted_models = []
422 concrete_models = set()
423 models = list(self.data)
424 while len(sorted_models) < len(models):
425 found = False
426 for model in models:
427 if model in sorted_models:
428 continue
429 dependencies = self.dependencies.get(model._meta.concrete_model)
430 if not (dependencies and dependencies.difference(concrete_models)):
431 sorted_models.append(model)
432 concrete_models.add(model._meta.concrete_model)
433 found = True
434 if not found:
435 return
436 self.data = {model: self.data[model] for model in sorted_models}
437
438 def delete(self):
439 # sort instance collections
440 for model, instances in self.data.items():
441 self.data[model] = sorted(instances, key=attrgetter("pk"))
442
443 # if possible, bring the models in an order suitable for databases that
444 # don't support transactions or cannot defer constraint checks until the
445 # end of a transaction.
446 self.sort()
447 # number of objects deleted for each model label
448 deleted_counter = Counter()
449
450 # Optimize for the case with a single obj and no dependencies
451 if len(self.data) == 1 and len(instances) == 1:
452 instance = list(instances)[0]
453 if self.can_fast_delete(instance):
454 with transaction.mark_for_rollback_on_error(self.using):
455 count = sql.DeleteQuery(model).delete_batch(
456 [instance.pk], self.using
457 )
458 setattr(instance, model._meta.pk.attname, None)
459 return count, {model._meta.label: count}
460
461 with transaction.atomic(using=self.using, savepoint=False):
462 # send pre_delete signals
463 for model, obj in self.instances_with_model():
464 if not model._meta.auto_created:
465 signals.pre_delete.send(
466 sender=model,
467 instance=obj,
468 using=self.using,
469 origin=self.origin,
470 )
471
472 # fast deletes
473 for qs in self.fast_deletes:
474 count = qs._raw_delete(using=self.using)
475 if count:
476 deleted_counter[qs.model._meta.label] += count
477
478 # update fields
479 for (field, value), instances_list in self.field_updates.items():
480 updates = []
481 objs = []
482 for instances in instances_list:
483 if (
484 isinstance(instances, QuerySet)
485 and instances._result_cache is None
486 ):
487 updates.append(instances)
488 else:
489 objs.extend(instances)
490 if updates:
491 combined_updates = reduce(or_, updates)
492 combined_updates.update(**{field.name: value})
493 if objs:
494 model = objs[0].__class__
495 query = sql.UpdateQuery(model)
496 query.update_batch(
497 list({obj.pk for obj in objs}), {field.name: value}, self.using
498 )
499
500 # reverse instance collections
501 for instances in self.data.values():
502 instances.reverse()
503
504 # delete instances
505 for model, instances in self.data.items():
506 query = sql.DeleteQuery(model)
507 pk_list = [obj.pk for obj in instances]
508 count = query.delete_batch(pk_list, self.using)
509 if count:
510 deleted_counter[model._meta.label] += count
511
512 if not model._meta.auto_created:
513 for obj in instances:
514 signals.post_delete.send(
515 sender=model,
516 instance=obj,
517 using=self.using,
518 origin=self.origin,
519 )
520
521 for model, instances in self.data.items():
522 for instance in instances:
523 setattr(instance, model._meta.pk.attname, None)
524 return sum(deleted_counter.values()), dict(deleted_counter)