django-overlay 0.1.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.
- django_overlay/__init__.py +0 -0
- django_overlay/_templating.py +22 -0
- django_overlay/apps.py +20 -0
- django_overlay/checks.py +59 -0
- django_overlay/constraints.py +18 -0
- django_overlay/fields.py +76 -0
- django_overlay/management/__init__.py +0 -0
- django_overlay/management/commands/__init__.py +0 -0
- django_overlay/management/commands/makemigrations.py +156 -0
- django_overlay/management/commands/resync_overlay_views.py +30 -0
- django_overlay/models.py +195 -0
- django_overlay/operations.py +261 -0
- django_overlay/sources.py +14 -0
- django_overlay/sql.py +138 -0
- django_overlay/sql_templates/ddl/drop_trigger.sql.j2 +1 -0
- django_overlay/sql_templates/ddl/drop_view.sql.j2 +1 -0
- django_overlay/sql_templates/introspection/current_schema.sql.j2 +1 -0
- django_overlay/sql_templates/pk_defaults/sequence_nextval.sql.j2 +1 -0
- django_overlay/sql_templates/pk_defaults/uuid4.sql.j2 +1 -0
- django_overlay/sql_templates/pk_defaults/uuid7.sql.j2 +1 -0
- django_overlay/sql_templates/pk_defaults/uuid7_polyfill.sql.j2 +8 -0
- django_overlay/sql_templates/triggers/constraint_trigger.sql.j2 +26 -0
- django_overlay/sql_templates/triggers/instead_of_delete.sql.j2 +13 -0
- django_overlay/sql_templates/triggers/instead_of_delete_soft.sql.j2 +21 -0
- django_overlay/sql_templates/triggers/instead_of_insert.sql.j2 +16 -0
- django_overlay/sql_templates/triggers/instead_of_update.sql.j2 +30 -0
- django_overlay/sql_templates/triggers/unique_constraint_trigger.sql.j2 +38 -0
- django_overlay/sql_templates/view/view.sql.j2 +18 -0
- django_overlay/strategies.py +46 -0
- django_overlay/sync.py +72 -0
- django_overlay/uuid7.py +17 -0
- django_overlay-0.1.0.dist-info/METADATA +59 -0
- django_overlay-0.1.0.dist-info/RECORD +35 -0
- django_overlay-0.1.0.dist-info/WHEEL +4 -0
- django_overlay-0.1.0.dist-info/licenses/LICENCE +21 -0
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shared Jinja env for sql_templates/. Split out of sql.py so
|
|
2
|
+
strategies.py can render templates too without a circular import."""
|
|
3
|
+
|
|
4
|
+
import jinja2
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _qi(name: str) -> str:
|
|
8
|
+
"""Jinja filter: quote a Postgres identifier, e.g. reserved words like `order`."""
|
|
9
|
+
return '"' + name.replace('"', '""') + '"'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_ENV = jinja2.Environment(
|
|
13
|
+
loader=jinja2.PackageLoader("django_overlay", "sql_templates"),
|
|
14
|
+
autoescape=False,
|
|
15
|
+
trim_blocks=True,
|
|
16
|
+
lstrip_blocks=True,
|
|
17
|
+
)
|
|
18
|
+
_ENV.filters["qi"] = _qi
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render(template_name: str, **context) -> str:
|
|
22
|
+
return _ENV.get_template(template_name).render(**context).strip()
|
django_overlay/apps.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DjangoOverlayConfig(AppConfig):
|
|
6
|
+
name = "django_overlay"
|
|
7
|
+
verbose_name = "Django Overlay"
|
|
8
|
+
default_auto_field = "django.db.models.AutoField"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
from . import checks # also registers the manage.py check
|
|
12
|
+
|
|
13
|
+
# Runs on every process boot, not just `manage.py check` — a bad
|
|
14
|
+
# FK/M2M against an overlay model should fail loudly right away.
|
|
15
|
+
errors = checks.check_no_plain_fk_to_overlay_models(None)
|
|
16
|
+
if errors:
|
|
17
|
+
raise ImproperlyConfigured(
|
|
18
|
+
"django_overlay found unsafe references to overlay view models:\n"
|
|
19
|
+
+ "\n".join(f"{error.id}: {error.msg}" for error in errors)
|
|
20
|
+
)
|
django_overlay/checks.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from django.apps import apps
|
|
2
|
+
from django.core import checks
|
|
3
|
+
from django.db import models
|
|
4
|
+
|
|
5
|
+
from .fields import OverlayForeignKey
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@checks.register(checks.Tags.models)
|
|
9
|
+
def check_no_plain_fk_to_overlay_models(app_configs, **kwargs):
|
|
10
|
+
"""Fails `manage.py check` for a plain ForeignKey/OneToOneField
|
|
11
|
+
pointing at an OverlayModel, or a ManyToManyField pointing at one
|
|
12
|
+
without an explicit through= model."""
|
|
13
|
+
errors = []
|
|
14
|
+
for model in apps.get_models(include_auto_created=True):
|
|
15
|
+
for field in model._meta.get_fields():
|
|
16
|
+
if not field.is_relation:
|
|
17
|
+
continue
|
|
18
|
+
related_model = field.related_model
|
|
19
|
+
if related_model is None or not getattr(related_model, "_is_overlay_view_model", False):
|
|
20
|
+
continue
|
|
21
|
+
|
|
22
|
+
# M2M fields are never "concrete" (even the declaring side), so
|
|
23
|
+
# check the type directly instead.
|
|
24
|
+
if isinstance(field, models.ManyToManyField):
|
|
25
|
+
if not field.remote_field.through._meta.auto_created:
|
|
26
|
+
continue
|
|
27
|
+
errors.append(
|
|
28
|
+
checks.Error(
|
|
29
|
+
f"{model.__name__}.{field.name} is a plain ManyToManyField pointing at "
|
|
30
|
+
f"{related_model.__name__}, which is a django_overlay view model. Django's "
|
|
31
|
+
"auto-created through table would use a plain ForeignKey, which can't hold "
|
|
32
|
+
"a real FK constraint against a view.",
|
|
33
|
+
hint="Use django_overlay.fields.OverlayManyToManyField (through=... is "
|
|
34
|
+
"required) or write your own explicit through model with OverlayForeignKey "
|
|
35
|
+
"fields.",
|
|
36
|
+
obj=field,
|
|
37
|
+
id="django_overlay.E002",
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
# Every concrete, non-M2M relation field Django has is a
|
|
43
|
+
# ForeignKey or OneToOneField (or a subclass), so this is
|
|
44
|
+
# already effectively "is a plain FK/O2O".
|
|
45
|
+
if not getattr(field, "concrete", False):
|
|
46
|
+
continue
|
|
47
|
+
if isinstance(field, OverlayForeignKey):
|
|
48
|
+
continue
|
|
49
|
+
errors.append(
|
|
50
|
+
checks.Error(
|
|
51
|
+
f"{model.__name__}.{field.name} is a plain {type(field).__name__} pointing at "
|
|
52
|
+
f"{related_model.__name__}, which is a django_overlay view model. Postgres cannot "
|
|
53
|
+
f"hold a real FK constraint against a view.",
|
|
54
|
+
hint=f"Use django_overlay.fields.OverlayForeignKey instead of {type(field).__name__}.",
|
|
55
|
+
obj=field,
|
|
56
|
+
id="django_overlay.E001",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return errors
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
from .models import OverlayConfigurationError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OverlayUniqueConstraint(models.UniqueConstraint):
|
|
7
|
+
"""Also guards against a value already present in the source table.
|
|
8
|
+
Postgres can't enforce that across a view's UNION ALL, so that half is
|
|
9
|
+
a trigger instead (see AddOverlayUniqueConstraint)."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, *args, **kwargs):
|
|
12
|
+
if kwargs.get("condition") is not None:
|
|
13
|
+
raise OverlayConfigurationError(
|
|
14
|
+
"OverlayUniqueConstraint doesn't support condition= — the source-vs-base trigger "
|
|
15
|
+
"has no way to apply it, so it would silently check for collisions the condition "
|
|
16
|
+
"should have excluded."
|
|
17
|
+
)
|
|
18
|
+
super().__init__(*args, **kwargs)
|
django_overlay/fields.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
from .models import OverlayConfigurationError
|
|
4
|
+
from .strategies import negates_source_ids
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OverlayForeignKey(models.ForeignKey):
|
|
8
|
+
"""FK for pointing at an OverlayModel. Postgres can't hold a real FK
|
|
9
|
+
against a view, so this never creates a db constraint; referential
|
|
10
|
+
integrity instead comes from a constraint trigger (see
|
|
11
|
+
operations.AddOverlayConstraint)."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, to, *args, **kwargs):
|
|
14
|
+
if "db_constraint" in kwargs:
|
|
15
|
+
raise OverlayConfigurationError(
|
|
16
|
+
"OverlayForeignKey always sets db_constraint=False (Postgres can't hold a real FK "
|
|
17
|
+
"against a view) — don't pass db_constraint yourself."
|
|
18
|
+
)
|
|
19
|
+
kwargs["db_constraint"] = False
|
|
20
|
+
super().__init__(to, *args, **kwargs)
|
|
21
|
+
|
|
22
|
+
def deconstruct(self):
|
|
23
|
+
name, path, args, kwargs = super().deconstruct()
|
|
24
|
+
kwargs.pop("db_constraint", None)
|
|
25
|
+
return name, path, args, kwargs
|
|
26
|
+
|
|
27
|
+
def trigger_name(self, model) -> str:
|
|
28
|
+
return f"overlayfk_{model._meta.db_table}_{self.column}"[:63]
|
|
29
|
+
|
|
30
|
+
def target_tables(self, tenant_schema: str) -> list[dict]:
|
|
31
|
+
"""The target's base table plus its source, if any — see target_tables_for()."""
|
|
32
|
+
return target_tables_for(self.remote_field.model, tenant_schema)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def target_tables_for(target, tenant_schema: str) -> list[dict]:
|
|
36
|
+
"""[{"schema", "table", "id_column", "negate", "soft_delete"}, ...] for
|
|
37
|
+
`target`'s base table (never negated) plus its source, if any (negated
|
|
38
|
+
for a NEGATIVE_ID target). Takes the target model directly, not `self`, so a
|
|
39
|
+
migration operation can call it against a *live* model even when the
|
|
40
|
+
referencing field only exists in migration-historical state."""
|
|
41
|
+
tables = [
|
|
42
|
+
{
|
|
43
|
+
"schema": tenant_schema,
|
|
44
|
+
"table": target._base_model._meta.db_table,
|
|
45
|
+
"id_column": "id",
|
|
46
|
+
"negate": False,
|
|
47
|
+
"soft_delete": target._overlay_meta.soft_delete,
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
source = target.get_source()
|
|
51
|
+
if source is not None:
|
|
52
|
+
negate = negates_source_ids(target._overlay_meta.strategy)
|
|
53
|
+
tables.append(
|
|
54
|
+
{
|
|
55
|
+
"schema": source.schema,
|
|
56
|
+
"table": source.table,
|
|
57
|
+
"id_column": source.id_column,
|
|
58
|
+
"negate": negate,
|
|
59
|
+
"soft_delete": False,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
return tables
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OverlayOneToOneField(OverlayForeignKey, models.OneToOneField):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OverlayManyToManyField(models.ManyToManyField):
|
|
70
|
+
"""M2M field for relating to an OverlayModel. Requires an explicit
|
|
71
|
+
through= model with OverlayForeignKey fields — Django's auto-created
|
|
72
|
+
through table always uses a plain ForeignKey, which can never be safe
|
|
73
|
+
against a view."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, to, *args, through, **kwargs):
|
|
76
|
+
super().__init__(to, *args, through=through, **kwargs)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from django.apps import apps as django_apps
|
|
2
|
+
from django.core.management.commands.makemigrations import Command as BaseCommand
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
from ...constraints import OverlayUniqueConstraint
|
|
6
|
+
from ...fields import OverlayForeignKey
|
|
7
|
+
from ...models import OverlayModelBase
|
|
8
|
+
from ...operations import (
|
|
9
|
+
AddOverlayConstraint,
|
|
10
|
+
AddOverlayUniqueConstraint,
|
|
11
|
+
DropOverlayView,
|
|
12
|
+
RemoveOverlayConstraint,
|
|
13
|
+
RemoveOverlayUniqueConstraint,
|
|
14
|
+
SyncOverlayView,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Operation types whose references_model/references_field are actually
|
|
19
|
+
# implemented (the Operation base class default just returns True always).
|
|
20
|
+
# IndexOperation subclasses (AddConstraint, RemoveConstraint) fall in that
|
|
21
|
+
# "always True" bucket, so they're handled separately below.
|
|
22
|
+
_FIELD_SHAPE_OPS = (
|
|
23
|
+
migrations.AddField,
|
|
24
|
+
migrations.RemoveField,
|
|
25
|
+
migrations.AlterField,
|
|
26
|
+
migrations.RenameField,
|
|
27
|
+
migrations.CreateModel,
|
|
28
|
+
migrations.RenameModel,
|
|
29
|
+
)
|
|
30
|
+
_FK_RETRIGGER_OPS = (migrations.RenameField, migrations.AlterField)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _overlay_base_to_view(app_label):
|
|
34
|
+
"""{base_model_name (lowercase): view_model_name} for every OverlayModel
|
|
35
|
+
in this app."""
|
|
36
|
+
return {
|
|
37
|
+
model._meta.model_name: model._view_model.__name__
|
|
38
|
+
for model in django_apps.get_app_config(app_label).get_models()
|
|
39
|
+
if isinstance(model, OverlayModelBase) and hasattr(model, "_view_model")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _overlay_foreign_key_fields(app_label):
|
|
44
|
+
"""{(model_name (lowercase), field_name)} for every OverlayForeignKey
|
|
45
|
+
field declared anywhere in this app — the declaring model doesn't have
|
|
46
|
+
to be an OverlayModel itself (e.g. a bonus FK/through table)."""
|
|
47
|
+
fields = set()
|
|
48
|
+
for model in django_apps.get_app_config(app_label).get_models(include_auto_created=True):
|
|
49
|
+
for field in model._meta.get_fields():
|
|
50
|
+
if isinstance(field, OverlayForeignKey) and getattr(field, "concrete", False):
|
|
51
|
+
fields.add((model._meta.model_name, field.name))
|
|
52
|
+
return fields
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _new_overlay_unique_constraints(op):
|
|
56
|
+
"""[(model_name, constraint), ...] for OverlayUniqueConstraints this
|
|
57
|
+
operation introduces — from a brand-new model's `options["constraints"]`
|
|
58
|
+
(post-optimizer shape) or a standalone AddConstraint on an existing one."""
|
|
59
|
+
if isinstance(op, migrations.CreateModel):
|
|
60
|
+
return [(op.name, c) for c in op.options.get("constraints", []) if isinstance(c, OverlayUniqueConstraint)]
|
|
61
|
+
if isinstance(op, migrations.AddConstraint) and isinstance(op.constraint, OverlayUniqueConstraint):
|
|
62
|
+
return [(op.model_name, op.constraint)]
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _insert_view_drops_before_deletes(app_label, operations):
|
|
67
|
+
"""A DeleteModel drops the model's table, but its view (if any) depends
|
|
68
|
+
on it — Postgres refuses to DROP TABLE while a dependent view exists.
|
|
69
|
+
Insert a DropOverlayView immediately before every DeleteModel,
|
|
70
|
+
unconditionally, same reasoning as RemoveField/RemoveConstraint below:
|
|
71
|
+
DROP VIEW IF EXISTS makes it safe even for a plain model with no view."""
|
|
72
|
+
new_operations = []
|
|
73
|
+
for op in operations:
|
|
74
|
+
if isinstance(op, migrations.DeleteModel):
|
|
75
|
+
new_operations.append(DropOverlayView(app_label, op.name))
|
|
76
|
+
new_operations.append(op)
|
|
77
|
+
return new_operations
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def extra_ops_for_migration(app_label, operations, base_to_view, fk_fields):
|
|
81
|
+
"""The extra operations one migration should get, given this app's
|
|
82
|
+
OverlayModel registry (see _overlay_base_to_view/_overlay_foreign_key_fields).
|
|
83
|
+
Split out of Command.write_migration_files so it's directly testable.
|
|
84
|
+
|
|
85
|
+
RemoveField/RemoveConstraint are handled unconditionally, not keyed
|
|
86
|
+
against the registry: the removed field/constraint is already gone from
|
|
87
|
+
live code by the time this runs, so there's nothing to check its type
|
|
88
|
+
against. DROP TRIGGER IF EXISTS makes appending them speculatively safe."""
|
|
89
|
+
extra_ops = []
|
|
90
|
+
synced_views = set()
|
|
91
|
+
added_constraints = set()
|
|
92
|
+
added_unique_constraints = set()
|
|
93
|
+
|
|
94
|
+
for op in operations:
|
|
95
|
+
if isinstance(op, _FIELD_SHAPE_OPS):
|
|
96
|
+
for base_name, view_name in base_to_view.items():
|
|
97
|
+
if view_name not in synced_views and op.references_model(base_name, app_label):
|
|
98
|
+
synced_views.add(view_name)
|
|
99
|
+
extra_ops.append(SyncOverlayView(app_label, view_name))
|
|
100
|
+
|
|
101
|
+
if isinstance(op, migrations.RemoveField):
|
|
102
|
+
extra_ops.append(RemoveOverlayConstraint(app_label, op.model_name, op.name))
|
|
103
|
+
|
|
104
|
+
if isinstance(op, migrations.RemoveConstraint):
|
|
105
|
+
view_name = base_to_view.get(op.model_name.lower())
|
|
106
|
+
if view_name is not None:
|
|
107
|
+
extra_ops.append(RemoveOverlayUniqueConstraint(app_label, view_name, op.name))
|
|
108
|
+
|
|
109
|
+
if isinstance(op, migrations.AddField) and isinstance(op.field, OverlayForeignKey):
|
|
110
|
+
key = (op.model_name.lower(), op.name)
|
|
111
|
+
if key not in added_constraints:
|
|
112
|
+
added_constraints.add(key)
|
|
113
|
+
extra_ops.append(AddOverlayConstraint(app_label, op.model_name, op.name))
|
|
114
|
+
elif isinstance(op, migrations.CreateModel):
|
|
115
|
+
for field_name, field in op.fields:
|
|
116
|
+
if isinstance(field, OverlayForeignKey):
|
|
117
|
+
key = (op.name.lower(), field_name)
|
|
118
|
+
if key not in added_constraints:
|
|
119
|
+
added_constraints.add(key)
|
|
120
|
+
extra_ops.append(AddOverlayConstraint(app_label, op.name, field_name))
|
|
121
|
+
elif isinstance(op, _FK_RETRIGGER_OPS):
|
|
122
|
+
for model_name, field_name in fk_fields:
|
|
123
|
+
key = (model_name, field_name)
|
|
124
|
+
if key not in added_constraints and op.references_field(model_name, field_name, app_label):
|
|
125
|
+
added_constraints.add(key)
|
|
126
|
+
extra_ops.append(AddOverlayConstraint(app_label, model_name, field_name))
|
|
127
|
+
|
|
128
|
+
for model_name, constraint in _new_overlay_unique_constraints(op):
|
|
129
|
+
view_name = base_to_view.get(model_name.lower())
|
|
130
|
+
if view_name is None:
|
|
131
|
+
continue
|
|
132
|
+
key = (model_name.lower(), constraint.name)
|
|
133
|
+
if key not in added_unique_constraints:
|
|
134
|
+
added_unique_constraints.add(key)
|
|
135
|
+
extra_ops.append(AddOverlayUniqueConstraint(app_label, view_name, constraint.name))
|
|
136
|
+
|
|
137
|
+
return extra_ops
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Command(BaseCommand):
|
|
141
|
+
"""Same as Django's makemigrations, except any operation touching an
|
|
142
|
+
OverlayModel's base table gets a SyncOverlayView appended, any
|
|
143
|
+
OverlayForeignKey/OverlayUniqueConstraint add/rename/remove gets its
|
|
144
|
+
matching Add/RemoveOverlay(Unique)Constraint appended, and any
|
|
145
|
+
DeleteModel gets a DropOverlayView inserted just before it."""
|
|
146
|
+
|
|
147
|
+
def write_migration_files(self, changes, *args, **kwargs):
|
|
148
|
+
for app_label, app_migrations in changes.items():
|
|
149
|
+
base_to_view = _overlay_base_to_view(app_label)
|
|
150
|
+
fk_fields = _overlay_foreign_key_fields(app_label)
|
|
151
|
+
for migration in app_migrations:
|
|
152
|
+
migration.operations = _insert_view_drops_before_deletes(app_label, migration.operations)
|
|
153
|
+
migration.operations.extend(
|
|
154
|
+
extra_ops_for_migration(app_label, migration.operations, base_to_view, fk_fields)
|
|
155
|
+
)
|
|
156
|
+
return super().write_migration_files(changes, *args, **kwargs)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from django.apps import apps as django_apps
|
|
2
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
3
|
+
|
|
4
|
+
from ...sync import resync_view
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Command(BaseCommand):
|
|
8
|
+
help = (
|
|
9
|
+
"Regenerate one or more OverlayModel views + triggers from their "
|
|
10
|
+
"current field list and get_source(), without a migration. Use "
|
|
11
|
+
"this whenever a tenant's resolved source changes without a field "
|
|
12
|
+
"change (e.g. moved from one vendor's table to another's)."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def add_arguments(self, parser):
|
|
16
|
+
parser.add_argument("models", nargs="+", metavar="app_label.ModelName")
|
|
17
|
+
parser.add_argument("--database", default="default")
|
|
18
|
+
|
|
19
|
+
def handle(self, *args, **options):
|
|
20
|
+
for label in options["models"]:
|
|
21
|
+
try:
|
|
22
|
+
app_label, model_name = label.split(".")
|
|
23
|
+
except ValueError:
|
|
24
|
+
raise CommandError(f"Expected app_label.ModelName, got {label!r}") from None
|
|
25
|
+
try:
|
|
26
|
+
model = django_apps.get_model(app_label, model_name)
|
|
27
|
+
except LookupError as exc:
|
|
28
|
+
raise CommandError(str(exc)) from exc
|
|
29
|
+
resync_view(model, using=options["database"])
|
|
30
|
+
self.stdout.write(self.style.SUCCESS(f"Resynced {label}"))
|
django_overlay/models.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
5
|
+
from django.db import models
|
|
6
|
+
|
|
7
|
+
from .strategies import Strategy, default_id_field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OverlayConfigurationError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _default_strategy() -> Strategy:
|
|
15
|
+
"""Lets a project set its own default strategy via
|
|
16
|
+
settings.DJANGO_OVERLAY_DEFAULT_STRATEGY instead of Strategy.UUID4. A
|
|
17
|
+
model can still override this with .with_strategy(...)."""
|
|
18
|
+
configured = getattr(settings, "DJANGO_OVERLAY_DEFAULT_STRATEGY", Strategy.UUID4)
|
|
19
|
+
if not isinstance(configured, Strategy):
|
|
20
|
+
raise ImproperlyConfigured(
|
|
21
|
+
"settings.DJANGO_OVERLAY_DEFAULT_STRATEGY must be a django_overlay.strategies.Strategy "
|
|
22
|
+
f"member (e.g. Strategy.NEGATIVE_ID), got {configured!r}."
|
|
23
|
+
)
|
|
24
|
+
return configured
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _default_soft_delete() -> bool:
|
|
28
|
+
"""Lets a project opt every model into soft delete by default via
|
|
29
|
+
settings.DJANGO_OVERLAY_DEFAULT_SOFT_DELETE. A model can still override
|
|
30
|
+
this with its own OverlayMeta.soft_delete."""
|
|
31
|
+
configured = getattr(settings, "DJANGO_OVERLAY_DEFAULT_SOFT_DELETE", False)
|
|
32
|
+
if not isinstance(configured, bool):
|
|
33
|
+
raise ImproperlyConfigured(f"settings.DJANGO_OVERLAY_DEFAULT_SOFT_DELETE must be a bool, got {configured!r}.")
|
|
34
|
+
return configured
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Options that emit DDL go on the base (managed=True) model; everything
|
|
38
|
+
# else (ordering, verbose_name, ...) goes on the view model, since that's
|
|
39
|
+
# what's actually queried.
|
|
40
|
+
_BASE_ONLY_META_OPTIONS = ("constraints", "indexes", "unique_together", "index_together", "db_table_comment")
|
|
41
|
+
|
|
42
|
+
# Neither model is a sound home for these: the view model is unmanaged, so
|
|
43
|
+
# create_permissions() silently skips it; the base model isn't something
|
|
44
|
+
# app code should reference. Reject instead of picking a bad default.
|
|
45
|
+
_UNSUPPORTED_META_OPTIONS = ("permissions", "default_permissions")
|
|
46
|
+
|
|
47
|
+
# Both models must agree on which app they belong to — Django can usually
|
|
48
|
+
# infer this from the module either model is defined in, but an explicit
|
|
49
|
+
# override needs to reach both, not just whichever side it happened to land on.
|
|
50
|
+
_BOTH_META_OPTIONS = ("app_label",)
|
|
51
|
+
|
|
52
|
+
# The metaclass sets these itself on both models — declaring your own would
|
|
53
|
+
# just get silently overwritten, so reject instead.
|
|
54
|
+
_FORCED_META_OPTIONS = {
|
|
55
|
+
"db_table": "table naming is controlled entirely by OverlayMeta.table_name (defaults to the lowercased class name)",
|
|
56
|
+
"managed": "the base model is always managed=True and the view model is always managed=False",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _split_meta_options(model_name: str, user_meta) -> tuple[dict, dict]:
|
|
61
|
+
if user_meta is None:
|
|
62
|
+
return {}, {}
|
|
63
|
+
options = {k: v for k, v in vars(user_meta).items() if not k.startswith("_")}
|
|
64
|
+
forced = [k for k in _FORCED_META_OPTIONS if k in options]
|
|
65
|
+
if forced:
|
|
66
|
+
raise OverlayConfigurationError(
|
|
67
|
+
f"{model_name}.Meta.{forced[0]} isn't supported on an OverlayModel — "
|
|
68
|
+
f"{_FORCED_META_OPTIONS[forced[0]]}; it would just be silently overwritten."
|
|
69
|
+
)
|
|
70
|
+
unsupported = [k for k in _UNSUPPORTED_META_OPTIONS if k in options]
|
|
71
|
+
if unsupported:
|
|
72
|
+
raise OverlayConfigurationError(
|
|
73
|
+
f"{model_name}.Meta.{unsupported[0]} isn't supported on an OverlayModel — there's no "
|
|
74
|
+
"model to attach it to that makes sense (see _UNSUPPORTED_META_OPTIONS)."
|
|
75
|
+
)
|
|
76
|
+
base_options = {k: v for k, v in options.items() if k in _BASE_ONLY_META_OPTIONS + _BOTH_META_OPTIONS}
|
|
77
|
+
view_options = {k: v for k, v in options.items() if k not in _BASE_ONLY_META_OPTIONS}
|
|
78
|
+
return base_options, view_options
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OverlayMeta:
|
|
82
|
+
"""Base class for a model's inner OverlayMeta. Subclass it (directly,
|
|
83
|
+
or via with_strategy()) and add table_name / get_source()."""
|
|
84
|
+
|
|
85
|
+
Strategy = Strategy
|
|
86
|
+
strategy = _default_strategy()
|
|
87
|
+
soft_delete = _default_soft_delete()
|
|
88
|
+
pk_default_sql = None
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def with_strategy(cls, strategy: Strategy):
|
|
92
|
+
return type(f"OverlayMeta_{strategy.value}", (cls,), {"strategy": strategy})
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def get_source():
|
|
96
|
+
raise NotImplementedError("OverlayMeta subclasses must implement get_source().")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class OverlayModelBase(models.base.ModelBase):
|
|
100
|
+
"""Splits one `class Person(OverlayModel)` into a hidden managed=True
|
|
101
|
+
base table and the managed=False view model the app actually imports."""
|
|
102
|
+
|
|
103
|
+
def __new__(mcs, name, bases, namespace, **kwargs):
|
|
104
|
+
if namespace.pop("_overlay_root", False):
|
|
105
|
+
return super().__new__(mcs, name, bases, namespace, **kwargs)
|
|
106
|
+
|
|
107
|
+
is_overlay_subclass = any(isinstance(b, OverlayModelBase) for b in bases)
|
|
108
|
+
meta = namespace.get("Meta")
|
|
109
|
+
if not is_overlay_subclass or (meta is not None and getattr(meta, "abstract", False)):
|
|
110
|
+
return super().__new__(mcs, name, bases, namespace, **kwargs)
|
|
111
|
+
|
|
112
|
+
overlay_meta = namespace.pop("OverlayMeta", None)
|
|
113
|
+
if overlay_meta is None or not issubclass(overlay_meta, OverlayMeta):
|
|
114
|
+
raise OverlayConfigurationError(f"{name}.OverlayMeta must subclass django_overlay.models.OverlayMeta.")
|
|
115
|
+
if "get_source" not in overlay_meta.__dict__:
|
|
116
|
+
raise OverlayConfigurationError(
|
|
117
|
+
f"{name}.OverlayMeta must implement get_source() returning a SourceTable | None."
|
|
118
|
+
)
|
|
119
|
+
if not isinstance(overlay_meta.strategy, Strategy):
|
|
120
|
+
raise OverlayConfigurationError(
|
|
121
|
+
f"{name}.OverlayMeta.strategy must be a Strategy member (e.g. Strategy.NEGATIVE_ID "
|
|
122
|
+
f"or .with_strategy(...)), got {overlay_meta.strategy!r}."
|
|
123
|
+
)
|
|
124
|
+
if not isinstance(overlay_meta.soft_delete, bool):
|
|
125
|
+
raise OverlayConfigurationError(
|
|
126
|
+
f"{name}.OverlayMeta.soft_delete must be a bool, got {overlay_meta.soft_delete!r}."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# M2M fields go on the view model only — copying one to both models
|
|
130
|
+
# would create two independent through tables.
|
|
131
|
+
m2m_items = {k: v for k, v in namespace.items() if isinstance(v, models.ManyToManyField)}
|
|
132
|
+
field_items = {k: v for k, v in namespace.items() if isinstance(v, models.Field) and k not in m2m_items}
|
|
133
|
+
rest_items = {k: v for k, v in namespace.items() if k not in field_items and k not in m2m_items}
|
|
134
|
+
table_name = getattr(overlay_meta, "table_name", name.lower())
|
|
135
|
+
|
|
136
|
+
if overlay_meta.soft_delete and "_overlay_deleted" in field_items:
|
|
137
|
+
raise OverlayConfigurationError(
|
|
138
|
+
f"{name} can't declare its own `_overlay_deleted` field — django_overlay reserves "
|
|
139
|
+
"that name for its soft_delete shadow flag."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if "id" not in field_items:
|
|
143
|
+
injected = default_id_field(overlay_meta.strategy)
|
|
144
|
+
if injected is not None:
|
|
145
|
+
field_items["id"] = injected
|
|
146
|
+
|
|
147
|
+
base_meta_options, view_meta_options = _split_meta_options(name, namespace.get("Meta"))
|
|
148
|
+
|
|
149
|
+
base_ns = {**rest_items, **{k: copy.deepcopy(v) for k, v in field_items.items()}}
|
|
150
|
+
if overlay_meta.soft_delete:
|
|
151
|
+
# Base-only shadow flag — never copied to the view model, so it
|
|
152
|
+
# never shows up as a queryable column there.
|
|
153
|
+
base_ns["_overlay_deleted"] = models.BooleanField(default=False, editable=False)
|
|
154
|
+
base_ns["__qualname__"] = f"{name}Base"
|
|
155
|
+
# No default_permissions for the base table — nobody should see
|
|
156
|
+
# "Can add <name>base" in an admin permission list.
|
|
157
|
+
base_ns["Meta"] = type("Meta", (), {**base_meta_options, "db_table": table_name, "default_permissions": ()})
|
|
158
|
+
base_model = super().__new__(mcs, f"{name}Base", bases, base_ns, **kwargs)
|
|
159
|
+
|
|
160
|
+
view_ns = {**rest_items, **{k: copy.deepcopy(v) for k, v in field_items.items()}, **m2m_items}
|
|
161
|
+
view_ns["Meta"] = type("Meta", (), {**view_meta_options, "db_table": f"{table_name}_view", "managed": False})
|
|
162
|
+
view_model = super().__new__(mcs, name, bases, view_ns, **kwargs)
|
|
163
|
+
|
|
164
|
+
view_model._base_model = base_model
|
|
165
|
+
view_model._overlay_meta = overlay_meta
|
|
166
|
+
view_model._is_overlay_view_model = True
|
|
167
|
+
base_model._view_model = view_model
|
|
168
|
+
return view_model
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class OverlayModel(models.Model, metaclass=OverlayModelBase):
|
|
172
|
+
_overlay_root = True
|
|
173
|
+
|
|
174
|
+
Strategy = Strategy
|
|
175
|
+
|
|
176
|
+
class Meta:
|
|
177
|
+
abstract = True
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def base_table(cls):
|
|
181
|
+
"""The hidden concrete model backing this view. Migration/tooling
|
|
182
|
+
use only — application code should never query or write to it."""
|
|
183
|
+
return cls._base_model
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def get_source(cls):
|
|
187
|
+
return cls._overlay_meta.get_source()
|
|
188
|
+
|
|
189
|
+
def reset_to_source(self):
|
|
190
|
+
"""Discard this row's local materialization/soft-deletion and fall
|
|
191
|
+
back to whatever the source shows for its id (nothing, if there's no
|
|
192
|
+
source row). Not a delete — doesn't run Django's on_delete collector,
|
|
193
|
+
since the identity itself isn't necessarily going away. See
|
|
194
|
+
docs/concepts/DELETION.md."""
|
|
195
|
+
self._base_model.objects.filter(pk=self.pk).delete()
|