cleanup
This commit is contained in:
parent
57094ffdfd
commit
041e749996
|
|
@ -4,7 +4,7 @@ of the Python `post_save` handler formerly registered in netbox.denormalized.
|
|||
"""
|
||||
from django.db import migrations
|
||||
|
||||
from utilities.migration import InstallDenormalizationTrigger
|
||||
from utilities.migration import cached_scope_triggers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
|
@ -15,19 +15,4 @@ class Migration(migrations.Migration):
|
|||
('dcim', '0238_ltree_paths'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# When a Site's region/group changes, propagate to terminations assigned to it.
|
||||
InstallDenormalizationTrigger(
|
||||
dependent_table='circuits_circuittermination',
|
||||
source_table='dcim_site',
|
||||
fk_column='_site_id',
|
||||
mappings={'_region_id': 'region_id', '_site_group_id': 'group_id'},
|
||||
),
|
||||
# When a Location's site changes, propagate to terminations assigned to it.
|
||||
InstallDenormalizationTrigger(
|
||||
dependent_table='circuits_circuittermination',
|
||||
source_table='dcim_location',
|
||||
fk_column='_location_id',
|
||||
mappings={'_site_id': 'site_id'},
|
||||
),
|
||||
]
|
||||
operations = cached_scope_triggers('circuits_circuittermination')
|
||||
|
|
|
|||
|
|
@ -206,8 +206,12 @@ class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
|
|||
self.assertEqual(termination._site_group, group_b)
|
||||
|
||||
def test_location_site_change_propagates_to_termination(self):
|
||||
site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
region_a = Region.objects.create(name='Region A', slug='region-a')
|
||||
region_b = Region.objects.create(name='Region B', slug='region-b')
|
||||
group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
|
||||
group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
|
||||
site_a = Site.objects.create(name='Site A', slug='site-a', region=region_a, group=group_a)
|
||||
site_b = Site.objects.create(name='Site B', slug='site-b', region=region_b, group=group_b)
|
||||
location = Location.objects.create(name='Loc', slug='loc', site=site_a)
|
||||
|
||||
termination = CircuitTermination.objects.create(
|
||||
|
|
@ -216,12 +220,15 @@ class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
|
|||
self.assertEqual(termination._site, site_a)
|
||||
self.assertEqual(termination._location, location)
|
||||
|
||||
# Move the Location to a different Site; the trigger should update _site.
|
||||
# Move the Location to a different Site; the trigger updates _site and pulls the new
|
||||
# site's region/group through in the same statement.
|
||||
location.site = site_b
|
||||
location.save()
|
||||
|
||||
termination.refresh_from_db()
|
||||
self.assertEqual(termination._site, site_b)
|
||||
self.assertEqual(termination._region, region_b)
|
||||
self.assertEqual(termination._site_group, group_b)
|
||||
|
||||
def test_bulk_update_of_site_propagates_to_termination(self):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,11 +1,31 @@
|
|||
"""
|
||||
Maintain CableTermination's denormalized device/rack/location/site columns via PostgreSQL triggers instead
|
||||
of the Python `post_save` handler formerly registered in netbox.denormalized.
|
||||
Maintain denormalized device/rack/location/site columns via PostgreSQL triggers instead of Python
|
||||
`post_save` handlers:
|
||||
|
||||
- CableTermination's _device/_rack/_location/_site (formerly netbox.denormalized).
|
||||
- Each device component's _site/_location/_rack (formerly dcim.signals.handle_device_site_change /
|
||||
handle_rack_site_change / handle_location_site_change). These are derived solely from the parent
|
||||
Device, so a single Device-sourced trigger per component table covers direct device edits as well as
|
||||
the Rack/Location cascades (which write Device.site/location and thus fire this trigger).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
from utilities.migration import InstallDenormalizationTrigger
|
||||
|
||||
# Device component tables carrying _site/_location/_rack denormalized from their parent Device.
|
||||
COMPONENT_TABLES = (
|
||||
'dcim_consoleport',
|
||||
'dcim_consoleserverport',
|
||||
'dcim_devicebay',
|
||||
'dcim_frontport',
|
||||
'dcim_interface',
|
||||
'dcim_inventoryitem',
|
||||
'dcim_modulebay',
|
||||
'dcim_poweroutlet',
|
||||
'dcim_powerport',
|
||||
'dcim_rearport',
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
|
|
@ -35,4 +55,16 @@ class Migration(migrations.Migration):
|
|||
fk_column='_location_id',
|
||||
mappings={'_site_id': 'site_id'},
|
||||
),
|
||||
# Device components: mirror the parent Device's site/location/rack onto each component.
|
||||
# The Rack/Location → Device cascades (dcim.signals) write Device.site/location, which fires
|
||||
# this same trigger, so no separate Rack/Location-sourced component triggers are needed.
|
||||
*[
|
||||
InstallDenormalizationTrigger(
|
||||
dependent_table=table,
|
||||
source_table='dcim_device',
|
||||
fk_column='device_id',
|
||||
mappings={'_site_id': 'site_id', '_location_id': 'location_id', '_rack_id': 'rack_id'},
|
||||
)
|
||||
for table in COMPONENT_TABLES
|
||||
],
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,59 +5,36 @@ from django.db.models.signals import post_delete, post_save
|
|||
from django.dispatch import receiver
|
||||
|
||||
from dcim.choices import CableEndChoices, LinkStatusChoices
|
||||
from ipam.models import Prefix
|
||||
from virtualization.models import Cluster, VMInterface
|
||||
from wireless.models import WirelessLAN
|
||||
from virtualization.models import VMInterface
|
||||
|
||||
from .models import (
|
||||
Cable,
|
||||
CablePath,
|
||||
CableTermination,
|
||||
ConsolePort,
|
||||
ConsoleServerPort,
|
||||
Device,
|
||||
DeviceBay,
|
||||
FrontPort,
|
||||
Interface,
|
||||
InventoryItem,
|
||||
Location,
|
||||
ModuleBay,
|
||||
PathEndpoint,
|
||||
PortMapping,
|
||||
PowerOutlet,
|
||||
PowerPanel,
|
||||
PowerPort,
|
||||
Rack,
|
||||
RearPort,
|
||||
Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
from .models.cables import trace_paths
|
||||
from .utils import create_cablepaths, rebuild_paths
|
||||
|
||||
COMPONENT_MODELS = (
|
||||
ConsolePort,
|
||||
ConsoleServerPort,
|
||||
DeviceBay,
|
||||
FrontPort,
|
||||
Interface,
|
||||
InventoryItem,
|
||||
ModuleBay,
|
||||
PowerOutlet,
|
||||
PowerPort,
|
||||
RearPort,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Location/rack/device assignment
|
||||
#
|
||||
|
||||
|
||||
@receiver(post_save, sender=Location)
|
||||
def handle_location_site_change(instance, created, **kwargs):
|
||||
"""
|
||||
Update child objects if Site assignment has changed. We intentionally recurse through each child
|
||||
object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
|
||||
Cascade a Location's Site assignment down to the Racks, Devices, and PowerPanels it contains
|
||||
(and to descendant Locations). The denormalized cache columns on cable terminations and device
|
||||
components are maintained by PostgreSQL triggers, which fire on these Site/Location/Rack/Device
|
||||
column writes.
|
||||
"""
|
||||
if not created:
|
||||
instance.get_descendants().update(site=instance.site)
|
||||
|
|
@ -65,39 +42,16 @@ def handle_location_site_change(instance, created, **kwargs):
|
|||
Rack.objects.filter(location__in=locations).update(site=instance.site)
|
||||
Device.objects.filter(location__in=locations).update(site=instance.site)
|
||||
PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
|
||||
CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
|
||||
# Update component models for devices in these locations
|
||||
for model in COMPONENT_MODELS:
|
||||
model.objects.filter(device__location__in=locations).update(_site=instance.site)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Rack)
|
||||
def handle_rack_site_change(instance, created, **kwargs):
|
||||
"""
|
||||
Update child Devices if Site or Location assignment has changed.
|
||||
Cascade a Rack's Site/Location assignment down to the Devices it contains. The denormalized cache
|
||||
columns on those devices' components are maintained by PostgreSQL triggers.
|
||||
"""
|
||||
if not created:
|
||||
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
|
||||
# Update component models for devices in this rack
|
||||
for model in COMPONENT_MODELS:
|
||||
model.objects.filter(device__rack=instance).update(
|
||||
_site=instance.site,
|
||||
_location=instance.location,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Device)
|
||||
def handle_device_site_change(instance, created, **kwargs):
|
||||
"""
|
||||
Update child components to update the parent Site, Location, and Rack when a Device is saved.
|
||||
"""
|
||||
if not created:
|
||||
for model in COMPONENT_MODELS:
|
||||
model.objects.filter(device=instance).update(
|
||||
_site=instance.site,
|
||||
_location=instance.location,
|
||||
_rack=instance.rack,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
|
|
@ -210,44 +164,3 @@ def update_mac_address_interface(instance, created, raw, **kwargs):
|
|||
if created and not raw and instance.primary_mac_address:
|
||||
instance.primary_mac_address.assigned_object = instance
|
||||
instance.primary_mac_address.save()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Location)
|
||||
@receiver(post_save, sender=Site)
|
||||
def sync_cached_scope_fields(instance, created, **kwargs):
|
||||
"""
|
||||
Rebuild cached scope fields for all CachedScopeMixin-based models
|
||||
affected by a change in a Region, SiteGroup, Site, or Location.
|
||||
|
||||
This method is safe to run for objects created in the past and does
|
||||
not rely on incremental updates. Cached fields are recomputed from
|
||||
authoritative relationships.
|
||||
"""
|
||||
if created:
|
||||
return
|
||||
|
||||
if isinstance(instance, Location):
|
||||
filters = {'_location': instance}
|
||||
elif isinstance(instance, Site):
|
||||
filters = {'_site': instance}
|
||||
else:
|
||||
return
|
||||
|
||||
# These models are explicitly listed because they all subclass CachedScopeMixin
|
||||
# and therefore require their cached scope fields to be recomputed.
|
||||
for model in (Prefix, Cluster, WirelessLAN):
|
||||
qs = model.objects.filter(**filters)
|
||||
|
||||
# Bulk update cached fields to avoid O(N) performance issues with large datasets.
|
||||
# This does not trigger post_save signals, avoiding spurious change log entries.
|
||||
objects_to_update = []
|
||||
for obj in qs:
|
||||
# Recompute cache using the same logic as save()
|
||||
obj.cache_related_objects()
|
||||
objects_to_update.append(obj)
|
||||
|
||||
if objects_to_update:
|
||||
model.objects.bulk_update(
|
||||
objects_to_update,
|
||||
['_location', '_site', '_site_group', '_region']
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from types import SimpleNamespace
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import connection
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
|
||||
from dcim import signals
|
||||
|
|
@ -26,6 +27,8 @@ from dcim.models import (
|
|||
SiteGroup,
|
||||
VirtualChassis,
|
||||
)
|
||||
from dcim.models.device_components import ComponentModel
|
||||
from dcim.models.mixins import CachedScopeMixin
|
||||
from ipam.models import Prefix
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
from wireless.models import WirelessLAN
|
||||
|
|
@ -117,10 +120,11 @@ class RackSiteChangeSignalTestCase(TestCase):
|
|||
self.assertEqual(interface._location, self.location_b)
|
||||
|
||||
|
||||
class DeviceSiteChangeSignalTestCase(TestCase):
|
||||
class DeviceComponentScopeTriggerTestCase(TestCase):
|
||||
"""
|
||||
Verify dcim.signals.handle_device_site_change propagates a Device's site/location/rack
|
||||
to its components on save.
|
||||
Verify the PostgreSQL trigger (dcim migration 0239) that propagates a Device's site/location/rack
|
||||
onto its components' denormalized _site/_location/_rack columns. This replaces the former
|
||||
dcim.signals.handle_device_site_change handler.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
|
|
@ -147,6 +151,25 @@ class DeviceSiteChangeSignalTestCase(TestCase):
|
|||
interface.refresh_from_db()
|
||||
self.assertEqual(interface._site, self.site_b)
|
||||
|
||||
def test_bulk_update_of_device_updates_components_cached_scope(self):
|
||||
"""
|
||||
A bulk QuerySet.update() bypasses post_save (the old handler never fired for it); the DB
|
||||
trigger fires regardless. This is also the path the Rack/Location cascades take.
|
||||
"""
|
||||
device = Device.objects.create(
|
||||
name='Device',
|
||||
site=self.site_a,
|
||||
device_type=self.device_type,
|
||||
role=self.device_role,
|
||||
)
|
||||
interface = Interface.objects.create(device=device, name='Interface 1')
|
||||
self.assertEqual(interface._site, self.site_a)
|
||||
|
||||
Device.objects.filter(pk=device.pk).update(site=self.site_b)
|
||||
|
||||
interface.refresh_from_db()
|
||||
self.assertEqual(interface._site, self.site_b)
|
||||
|
||||
|
||||
class VirtualChassisMasterSignalTestCase(TestCase):
|
||||
"""
|
||||
|
|
@ -386,10 +409,12 @@ class MACAddressInterfaceSignalTestCase(TestCase):
|
|||
self.assertIsNone(mac.assigned_object)
|
||||
|
||||
|
||||
class SyncCachedScopeFieldsSignalTestCase(TestCase):
|
||||
class CachedScopeFieldTriggerTestCase(TestCase):
|
||||
"""
|
||||
Verify dcim.signals.sync_cached_scope_fields recomputes cached scope fields on
|
||||
Prefix, Cluster, and WirelessLAN when a Site or Location is modified.
|
||||
Verify the PostgreSQL triggers (ipam/virtualization/wireless denormalization migrations) that keep
|
||||
the CachedScopeMixin scope columns (_site/_location/_region/_site_group) on Prefix, Cluster, and
|
||||
WirelessLAN in sync when a scoped Site or Location is modified. These replace the former
|
||||
dcim.signals.sync_cached_scope_fields handler.
|
||||
"""
|
||||
|
||||
def test_site_group_change_updates_prefix_cached_scope(self):
|
||||
|
|
@ -429,11 +454,9 @@ class SyncCachedScopeFieldsSignalTestCase(TestCase):
|
|||
self.assertEqual(prefix._location, location)
|
||||
self.assertEqual(prefix._site, site_b)
|
||||
|
||||
def test_signal_updates_cluster_and_wirelesslan_cached_scope(self):
|
||||
# Lock down the explicit (Prefix, Cluster, WirelessLAN) tuple in the
|
||||
# signal by exercising Cluster and WirelessLAN alongside Prefix. If a
|
||||
# future change drops Cluster or WirelessLAN from that tuple, this test
|
||||
# will catch it.
|
||||
def test_triggers_update_cluster_and_wirelesslan_cached_scope(self):
|
||||
# Cluster and WirelessLAN each carry their own Site/Location triggers (installed by the
|
||||
# virtualization and wireless denormalization migrations); exercise both alongside Prefix.
|
||||
group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
|
||||
group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
|
||||
site = Site.objects.create(name='Site', slug='site', group=group_a)
|
||||
|
|
@ -547,3 +570,48 @@ class CableTerminationDenormalizationTriggerTestCase(TestCase):
|
|||
|
||||
termination.refresh_from_db()
|
||||
self.assertEqual(termination._site, self.site_b)
|
||||
|
||||
|
||||
def _concrete_subclasses(base):
|
||||
"""Yield every non-abstract model descending from an abstract base model."""
|
||||
for subclass in base.__subclasses__():
|
||||
if subclass._meta.abstract:
|
||||
yield from _concrete_subclasses(subclass)
|
||||
else:
|
||||
yield subclass
|
||||
|
||||
|
||||
def _installed_triggers():
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute('SELECT tgname FROM pg_trigger WHERE NOT tgisinternal')
|
||||
return {row[0] for row in cursor.fetchall()}
|
||||
|
||||
|
||||
class DenormalizationTriggerCoverageTestCase(TestCase):
|
||||
"""
|
||||
Guard against a new model silently shipping without its denormalization triggers. The set of
|
||||
device-component tables and CachedScopeMixin dependents is hand-listed in migrations; this test
|
||||
derives those sets from the model layer and asserts the expected triggers are installed, so adding
|
||||
a new component / scoped model without a matching trigger migration fails CI.
|
||||
"""
|
||||
|
||||
def test_device_components_have_device_trigger(self):
|
||||
triggers = _installed_triggers()
|
||||
for model in _concrete_subclasses(ComponentModel):
|
||||
table = model._meta.db_table
|
||||
self.assertIn(
|
||||
f'{table}_denorm_from_dcim_device', triggers,
|
||||
msg=f'{model.__name__} has no dcim_device denormalization trigger (add it to '
|
||||
f'dcim migration 0239 COMPONENT_TABLES)',
|
||||
)
|
||||
|
||||
def test_cached_scope_models_have_site_and_location_triggers(self):
|
||||
triggers = _installed_triggers()
|
||||
for model in _concrete_subclasses(CachedScopeMixin):
|
||||
table = model._meta.db_table
|
||||
for source in ('dcim_site', 'dcim_location'):
|
||||
self.assertIn(
|
||||
f'{table}_denorm_from_{source}', triggers,
|
||||
msg=f'{model.__name__} (CachedScopeMixin) has no {source} denormalization trigger; '
|
||||
f'add cached_scope_triggers({table!r}) in a migration for its app',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"""
|
||||
Maintain Prefix's denormalized site/region/site-group columns via PostgreSQL triggers instead of the
|
||||
Python `post_save` handler formerly registered in netbox.denormalized.
|
||||
Python `post_save` handler formerly registered in netbox.denormalized (and dcim.signals.sync_cached_scope_fields).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
from utilities.migration import InstallDenormalizationTrigger
|
||||
from utilities.migration import cached_scope_triggers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
|
@ -15,19 +15,4 @@ class Migration(migrations.Migration):
|
|||
('dcim', '0238_ltree_paths'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# When a Site's region/group changes, propagate to prefixes assigned to it.
|
||||
InstallDenormalizationTrigger(
|
||||
dependent_table='ipam_prefix',
|
||||
source_table='dcim_site',
|
||||
fk_column='_site_id',
|
||||
mappings={'_region_id': 'region_id', '_site_group_id': 'group_id'},
|
||||
),
|
||||
# When a Location's site changes, propagate to prefixes assigned to it.
|
||||
InstallDenormalizationTrigger(
|
||||
dependent_table='ipam_prefix',
|
||||
source_table='dcim_location',
|
||||
fk_column='_location_id',
|
||||
mappings={'_site_id': 'site_id'},
|
||||
),
|
||||
]
|
||||
operations = cached_scope_triggers('ipam_prefix')
|
||||
|
|
|
|||
|
|
@ -264,8 +264,12 @@ class PrefixDenormalizationTriggerTestCase(TestCase):
|
|||
self.assertEqual(prefix._site_group, group_b)
|
||||
|
||||
def test_location_site_change_propagates_to_prefix(self):
|
||||
site_a = Site.objects.create(name='Site A', slug='site-a')
|
||||
site_b = Site.objects.create(name='Site B', slug='site-b')
|
||||
region_a = Region.objects.create(name='Region A', slug='region-a')
|
||||
region_b = Region.objects.create(name='Region B', slug='region-b')
|
||||
group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
|
||||
group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
|
||||
site_a = Site.objects.create(name='Site A', slug='site-a', region=region_a, group=group_a)
|
||||
site_b = Site.objects.create(name='Site B', slug='site-b', region=region_b, group=group_b)
|
||||
location = Location.objects.create(name='Loc', slug='loc', site=site_a)
|
||||
prefix = Prefix.objects.create(
|
||||
prefix='10.0.0.0/24',
|
||||
|
|
@ -274,11 +278,15 @@ class PrefixDenormalizationTriggerTestCase(TestCase):
|
|||
)
|
||||
self.assertEqual(prefix._site, site_a)
|
||||
|
||||
# Move the Location to a different Site; the trigger updates _site and pulls the new
|
||||
# site's region/group through in the same statement.
|
||||
location.site = site_b
|
||||
location.save()
|
||||
|
||||
prefix.refresh_from_db()
|
||||
self.assertEqual(prefix._site, site_b)
|
||||
self.assertEqual(prefix._region, region_b)
|
||||
self.assertEqual(prefix._site_group, group_b)
|
||||
|
||||
def test_bulk_update_of_site_propagates_to_prefix(self):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from netbox.config import ConfigItem
|
|||
|
||||
__all__ = (
|
||||
'InstallDenormalizationTrigger',
|
||||
'cached_scope_triggers',
|
||||
'custom_deconstruct',
|
||||
)
|
||||
|
||||
|
|
@ -40,29 +41,43 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
|
|||
Install a PostgreSQL trigger that keeps denormalized columns on a dependent table in sync with their
|
||||
source object.
|
||||
|
||||
When a row in `source_table` is updated, the trigger copies the values of the mapped source columns into
|
||||
the corresponding denormalized columns on every `dependent_table` row that references it via `fk_column`.
|
||||
This replaces the Python `post_save` handler formerly defined in `netbox.denormalized`.
|
||||
When rows in `source_table` are updated, the trigger copies the values of the mapped source columns into
|
||||
the corresponding denormalized columns on every `dependent_table` row that references them via
|
||||
`fk_column`. This replaces the Python `post_save` handlers formerly defined in `netbox.denormalized` and
|
||||
`dcim.signals`.
|
||||
|
||||
The trigger is statement-level (`FOR EACH STATEMENT`) and uses transition tables: a single bulk source
|
||||
update (`UPDATE ... WHERE ...`, `QuerySet.update()`, `bulk_update()`) fires the trigger once and is
|
||||
propagated with a single set-based UPDATE joining the changed rows, rather than once per affected row.
|
||||
|
||||
Args:
|
||||
dependent_table: The table carrying the denormalized columns (e.g. 'ipam_prefix').
|
||||
source_table: The table whose changes are propagated (e.g. 'dcim_site').
|
||||
fk_column: The column on `dependent_table` referencing `source_table` (e.g. '_site_id').
|
||||
mappings: A mapping of {dependent_column: source_column}, using actual database column names
|
||||
(e.g. {'_region_id': 'region_id', '_site_group_id': 'group_id'}).
|
||||
(e.g. {'_region_id': 'region_id', '_site_group_id': 'group_id'}). Each is copied directly
|
||||
from the changed source row.
|
||||
related_mappings: An optional iterable of related-table lookups for columns that live one hop
|
||||
beyond `source_table`. Each entry is a dict with keys `table` (the related table), `source_fk`
|
||||
(a column on `source_table` referencing `related_table.id`), and `mappings`
|
||||
({dependent_column: related_column}). Each is resolved by joining the related table once.
|
||||
This closes the chain gap when a denormalized column is derived through an intermediate object
|
||||
(e.g. a Location's Site change must refresh the dependent's region/site-group, not just its site).
|
||||
|
||||
The trigger fires AFTER UPDATE of the source columns, and only when at least one of them actually changed.
|
||||
Like the handler it replaces, it does not fire on INSERT (a newly created source row has no dependents
|
||||
yet) and it does not cascade: updating the denormalized columns does not itself trigger further
|
||||
denormalization.
|
||||
The trigger fires AFTER UPDATE of the watched source columns (the direct `mappings` sources plus each
|
||||
related `source_fk`), and only propagates to rows whose watched column(s) actually changed (the body
|
||||
joins the OLD/NEW transition tables and filters with IS DISTINCT FROM). It does not fire on INSERT (a
|
||||
newly created source row has no dependents yet) and it does not recurse: the dependent tables carry no
|
||||
triggers of their own.
|
||||
"""
|
||||
reversible = True
|
||||
|
||||
def __init__(self, dependent_table, source_table, fk_column, mappings):
|
||||
def __init__(self, dependent_table, source_table, fk_column, mappings, related_mappings=()):
|
||||
self.dependent_table = dependent_table
|
||||
self.source_table = source_table
|
||||
self.fk_column = fk_column
|
||||
self.mappings = mappings
|
||||
self.related_mappings = list(related_mappings)
|
||||
|
||||
@property
|
||||
def function_name(self):
|
||||
|
|
@ -77,17 +92,36 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
|
|||
pass
|
||||
|
||||
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
||||
source_columns = list(self.mappings.values())
|
||||
set_clause = ', '.join(f'"{dest}" = NEW."{src}"' for dest, src in self.mappings.items())
|
||||
update_of = ', '.join(f'"{col}"' for col in source_columns)
|
||||
when_clause = ' OR '.join(f'OLD."{col}" IS DISTINCT FROM NEW."{col}"' for col in source_columns)
|
||||
# `n`/`o` are the NEW/OLD transition tables (all rows changed by the source statement). Direct
|
||||
# mappings copy from the new source row; related mappings join the related table once.
|
||||
set_parts = [f'"{dest}" = n."{src}"' for dest, src in self.mappings.items()]
|
||||
watched = list(self.mappings.values())
|
||||
related_joins = []
|
||||
for i, rel in enumerate(self.related_mappings):
|
||||
alias = f'r{i}'
|
||||
related_joins.append(f'LEFT JOIN "{rel["table"]}" AS {alias} ON {alias}.id = n."{rel["source_fk"]}"')
|
||||
for dest, rel_col in rel['mappings'].items():
|
||||
set_parts.append(f'"{dest}" = {alias}."{rel_col}"')
|
||||
watched.append(rel['source_fk'])
|
||||
|
||||
# Deduplicate watched columns while preserving order (a direct mapping and a related lookup may
|
||||
# both key off the same source column, e.g. site_id).
|
||||
watched_columns = list(dict.fromkeys(watched))
|
||||
|
||||
set_clause = ', '.join(set_parts)
|
||||
update_of = ', '.join(f'"{col}"' for col in watched_columns)
|
||||
change_filter = ' OR '.join(f'o."{col}" IS DISTINCT FROM n."{col}"' for col in watched_columns)
|
||||
joins = ('\n ' + '\n '.join(related_joins)) if related_joins else ''
|
||||
|
||||
schema_editor.execute(f'''
|
||||
CREATE OR REPLACE FUNCTION "{self.function_name}"() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE "{self.dependent_table}"
|
||||
UPDATE "{self.dependent_table}" AS dep
|
||||
SET {set_clause}
|
||||
WHERE "{self.fk_column}" = NEW.id;
|
||||
FROM new_rows AS n
|
||||
JOIN old_rows AS o ON o.id = n.id{joins}
|
||||
WHERE dep."{self.fk_column}" = n.id
|
||||
AND ({change_filter});
|
||||
RETURN NULL;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
|
@ -95,8 +129,8 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
|
|||
schema_editor.execute(f'''
|
||||
CREATE TRIGGER "{self.trigger_name}"
|
||||
AFTER UPDATE OF {update_of} ON "{self.source_table}"
|
||||
FOR EACH ROW WHEN ({when_clause})
|
||||
EXECUTE FUNCTION "{self.function_name}"();
|
||||
REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION "{self.function_name}"();
|
||||
''')
|
||||
|
||||
def database_backwards(self, app_label, schema_editor, from_state, to_state):
|
||||
|
|
@ -105,3 +139,41 @@ class InstallDenormalizationTrigger(migrations.operations.base.Operation):
|
|||
|
||||
def describe(self):
|
||||
return f'Install denormalization trigger on {self.source_table} updating {self.dependent_table}'
|
||||
|
||||
|
||||
# Site/region/site-group lookup shared by every CachedScopeMixin-style dependent (see cached_scope_triggers).
|
||||
SITE_SCOPE_RELATED_MAPPINGS = (
|
||||
{
|
||||
'table': 'dcim_site',
|
||||
'source_fk': 'site_id',
|
||||
'mappings': {'_region_id': 'region_id', '_site_group_id': 'group_id'},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def cached_scope_triggers(dependent_table):
|
||||
"""
|
||||
Return the Site + Location `InstallDenormalizationTrigger` pair for a dependent table carrying the
|
||||
standard cached-scope columns (_site/_location/_region/_site_group) — i.e. any CachedScopeMixin model
|
||||
(Prefix, Cluster, WirelessLAN) plus CircuitTermination, which share the same denormalization shape.
|
||||
|
||||
Region- and SiteGroup-scoped rows need no trigger: their cached FK is the scoped object itself and
|
||||
never changes underneath them. So two triggers fully cover the cache:
|
||||
- dcim_site: region/group changed -> refresh _region/_site_group on rows scoped to that site
|
||||
- dcim_location: site changed -> refresh _site (and the new site's region/group)
|
||||
"""
|
||||
return [
|
||||
InstallDenormalizationTrigger(
|
||||
dependent_table=dependent_table,
|
||||
source_table='dcim_site',
|
||||
fk_column='_site_id',
|
||||
mappings={'_region_id': 'region_id', '_site_group_id': 'group_id'},
|
||||
),
|
||||
InstallDenormalizationTrigger(
|
||||
dependent_table=dependent_table,
|
||||
source_table='dcim_location',
|
||||
fk_column='_location_id',
|
||||
mappings={'_site_id': 'site_id'},
|
||||
related_mappings=SITE_SCOPE_RELATED_MAPPINGS,
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
"""
|
||||
Maintain Cluster's denormalized scope columns (CachedScopeMixin: _site/_location/_region/_site_group)
|
||||
via PostgreSQL triggers instead of the Python `dcim.signals.sync_cached_scope_fields` handler.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
from utilities.migration import cached_scope_triggers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('virtualization', '0056_virtualmachine_render_config_permission'),
|
||||
# Source tables (dcim_site, dcim_location) must already exist.
|
||||
('dcim', '0238_ltree_paths'),
|
||||
]
|
||||
|
||||
operations = cached_scope_triggers('virtualization_cluster')
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"""
|
||||
Maintain WirelessLAN's denormalized scope columns (CachedScopeMixin: _site/_location/_region/
|
||||
_site_group) via PostgreSQL triggers instead of the Python `dcim.signals.sync_cached_scope_fields`
|
||||
handler.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
from utilities.migration import cached_scope_triggers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wireless', '0020_ltree_paths'),
|
||||
# Source tables (dcim_site, dcim_location) must already exist.
|
||||
('dcim', '0238_ltree_paths'),
|
||||
]
|
||||
|
||||
operations = cached_scope_triggers('wireless_wirelesslan')
|
||||
Loading…
Reference in New Issue