migration.py 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. from django.db import migrations, models
  2. from netbox.config import ConfigItem
  3. __all__ = (
  4. 'InstallDenormalizationTrigger',
  5. 'custom_deconstruct',
  6. )
  7. EXEMPT_ATTRS = (
  8. 'choices',
  9. 'help_text',
  10. 'verbose_name',
  11. )
  12. _deconstruct = models.Field.deconstruct
  13. def custom_deconstruct(field):
  14. """
  15. Imitate the behavior of the stock deconstruct() method, but ignore the field attributes listed above.
  16. """
  17. name, path, args, kwargs = _deconstruct(field)
  18. # Remove any ignored attributes
  19. for attr in EXEMPT_ATTRS:
  20. kwargs.pop(attr, None)
  21. # Ignore any field defaults which reference a ConfigItem
  22. kwargs = {
  23. k: v for k, v in kwargs.items() if not isinstance(v, ConfigItem)
  24. }
  25. return name, path, args, kwargs
  26. class InstallDenormalizationTrigger(migrations.operations.base.Operation):
  27. """
  28. Install a PostgreSQL trigger that keeps denormalized columns on a dependent table in sync with their
  29. source object.
  30. When a row in `source_table` is updated, the trigger copies the values of the mapped source columns into
  31. the corresponding denormalized columns on every `dependent_table` row that references it via `fk_column`.
  32. This replaces the Python `post_save` handler formerly defined in `netbox.denormalized`.
  33. Args:
  34. dependent_table: The table carrying the denormalized columns (e.g. 'ipam_prefix').
  35. source_table: The table whose changes are propagated (e.g. 'dcim_site').
  36. fk_column: The column on `dependent_table` referencing `source_table` (e.g. '_site_id').
  37. mappings: A mapping of {dependent_column: source_column}, using actual database column names
  38. (e.g. {'_region_id': 'region_id', '_site_group_id': 'group_id'}).
  39. The trigger fires AFTER UPDATE of the source columns, and only when at least one of them actually changed.
  40. Like the handler it replaces, it does not fire on INSERT (a newly created source row has no dependents
  41. yet) and it does not cascade: updating the denormalized columns does not itself trigger further
  42. denormalization.
  43. """
  44. reversible = True
  45. def __init__(self, dependent_table, source_table, fk_column, mappings):
  46. self.dependent_table = dependent_table
  47. self.source_table = source_table
  48. self.fk_column = fk_column
  49. self.mappings = mappings
  50. @property
  51. def function_name(self):
  52. return f'{self.dependent_table}_denorm_from_{self.source_table}_fn'
  53. @property
  54. def trigger_name(self):
  55. return f'{self.dependent_table}_denorm_from_{self.source_table}'
  56. def state_forwards(self, app_label, state):
  57. # Triggers are not part of Django's model state.
  58. pass
  59. def database_forwards(self, app_label, schema_editor, from_state, to_state):
  60. source_columns = list(self.mappings.values())
  61. set_clause = ', '.join(f'"{dest}" = NEW."{src}"' for dest, src in self.mappings.items())
  62. update_of = ', '.join(f'"{col}"' for col in source_columns)
  63. when_clause = ' OR '.join(f'OLD."{col}" IS DISTINCT FROM NEW."{col}"' for col in source_columns)
  64. schema_editor.execute(f'''
  65. CREATE OR REPLACE FUNCTION "{self.function_name}"() RETURNS TRIGGER AS $$
  66. BEGIN
  67. UPDATE "{self.dependent_table}"
  68. SET {set_clause}
  69. WHERE "{self.fk_column}" = NEW.id;
  70. RETURN NULL;
  71. END
  72. $$ LANGUAGE plpgsql;
  73. ''')
  74. schema_editor.execute(f'''
  75. CREATE TRIGGER "{self.trigger_name}"
  76. AFTER UPDATE OF {update_of} ON "{self.source_table}"
  77. FOR EACH ROW WHEN ({when_clause})
  78. EXECUTE FUNCTION "{self.function_name}"();
  79. ''')
  80. def database_backwards(self, app_label, schema_editor, from_state, to_state):
  81. schema_editor.execute(f'DROP TRIGGER IF EXISTS "{self.trigger_name}" ON "{self.source_table}";')
  82. schema_editor.execute(f'DROP FUNCTION IF EXISTS "{self.function_name}"();')
  83. def describe(self):
  84. return f'Install denormalization trigger on {self.source_table} updating {self.dependent_table}'