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