Skip to content

Commit

Permalink
Merge pull request #110 from Opus10/better-func-rendering
Browse files Browse the repository at this point in the history
Added ``pgtrigger.Func`` for accessing model properties in function declarations.
  • Loading branch information
wesleykendall committed Oct 7, 2022
2 parents 0cfe3e7 + 4bd6abf commit 77caf5f
Show file tree
Hide file tree
Showing 8 changed files with 148 additions and 42 deletions.
41 changes: 41 additions & 0 deletions docs/cookbook.rst
Expand Up @@ -525,3 +525,44 @@ Tracking model history and changes
Check out `django-pghistory <https://django-pghistory.readthedocs.io>`__
to snapshot model changes and attach context from
your application (e.g. the authenticated user) to the event.

.. _func_model_properties:

Model properties in the func
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When writing triggers in the model ``Meta``, it's not possible
to access properties of the model like the database name or fields.
`pgtrigger.Func` solves this by exposing the following variables
you can use in a template string:

* **meta**: The ``._meta`` of the model.
* **fields**: The fields of the model, accessible as attributes.
* **columns**: The field columns. ``columns.field_name`` will return
the database column of the ``field_name`` field.

For example, say that we have the following model and trigger:

.. code-block:: python
class MyModel(models.Model):
text_field = models.TextField()
class Meta:
triggers = [
pgtrigger.Trigger(
func=pgtrigger.Func(
"""
# This is only pseudocode
SELECT {columns.text_field} FROM {meta.db_table};
"""
)
)
]
Above the `pgtrigger.Func` references the table name of the model and the column
of ``text_field``.

.. note::

Remember to escape curly bracket characters when using `pgtrigger.Func`.
6 changes: 6 additions & 0 deletions docs/faq.rst
Expand Up @@ -67,6 +67,12 @@ How do I disable triggers?
Use `pgtrigger.ignore` if you need to temporarily ignore triggers in your application (see :ref:`ignoring_triggers`). Only use the core installation commands if you want to disable or uninstall triggers globally (see the :ref:`commands` section). **Never** run the core
installation commands in application code.

How can I reference the table name in a custom function?
--------------------------------------------------------

When writing a trigger in ``Meta``, it's not possible to access other model meta properties like ``db_table``.
Use `pgtrigger.Func` to get around this. See :ref:`func_model_properties`.

How can I contact the author?
-----------------------------

Expand Down
6 changes: 6 additions & 0 deletions docs/module.rst
Expand Up @@ -66,6 +66,12 @@ Timing clause

For specifying ``DEFERRED`` as the default timing for deferrable triggers


Func clause
-----------

.. autoclass:: pgtrigger.Func

Conditions
----------
.. autoclass:: pgtrigger.Condition
Expand Down
2 changes: 2 additions & 0 deletions pgtrigger/__init__.py
Expand Up @@ -14,6 +14,7 @@
Deferred,
Delete,
F,
Func,
Immediate,
Insert,
InsteadOf,
Expand Down Expand Up @@ -69,6 +70,7 @@
"enable",
"F",
"FSM",
"Func",
"ignore",
"Immediate",
"Insert",
Expand Down
36 changes: 31 additions & 5 deletions pgtrigger/core.py
Expand Up @@ -11,10 +11,7 @@
from django.db.utils import ProgrammingError
import psycopg2.extensions

from pgtrigger import compiler
from pgtrigger import features
from pgtrigger import registry
from pgtrigger import utils
from pgtrigger import compiler, features, registry, utils


# Postgres only allows identifiers to be 63 chars max. Since "pgtrigger_"
Expand Down Expand Up @@ -291,6 +288,24 @@ def resolve(self, model):
return sql % args


class Func:
"""
Allows for rendering a function with access to the "meta", "fields",
and "columns" variables of the current model.
For example, ``func=Func("SELECT {columns.id} FROM {meta.db_table};")`` makes it
possible to do inline SQL in the ``Meta`` of a model and reference its properties.
"""

def __init__(self, func):
self.func = func

def render(self, model):
fields = utils.AttrDict({field.name: field for field in model._meta.fields})
columns = utils.AttrDict({field.name: field.column for field in model._meta.fields})
return self.func.format(meta=model._meta, fields=fields, columns=columns)


# Allows Trigger methods to be used as context managers, mostly for
# testing purposes
@contextlib.contextmanager
Expand Down Expand Up @@ -462,14 +477,25 @@ def render_execute(self, model):
"""
return f"{self.get_pgid(model)}()"

def render_func(self, model):
"""
Renders the func
"""
func = self.get_func(model)

if isinstance(func, Func):
return func.render(model)
else:
return func

def compile(self, model):
return compiler.Trigger(
name=self.name,
sql=compiler.UpsertTriggerSql(
ignore_func_name=_ignore_func_name(),
pgid=self.get_pgid(model),
declare=self.render_declare(model),
func=self.get_func(model),
func=self.render_func(model),
table=model._meta.db_table,
constraint="CONSTRAINT" if self.timing else "",
when=self.when,
Expand Down
80 changes: 43 additions & 37 deletions pgtrigger/tests/models.py
Expand Up @@ -105,48 +105,54 @@ class LogEntry(models.Model):
new_field = models.CharField(max_length=16, null=True)


@pgtrigger.register(
pgtrigger.Trigger(
name="update_of_statement_test",
level=pgtrigger.Statement,
operation=pgtrigger.UpdateOf("field"),
when=pgtrigger.After,
func=f"""
INSERT INTO {LogEntry._meta.db_table}(level)
VALUES ('STATEMENT');
RETURN NULL;
""",
),
pgtrigger.Trigger(
name="after_update_statement_test",
level=pgtrigger.Statement,
operation=pgtrigger.Update,
when=pgtrigger.After,
referencing=pgtrigger.Referencing(old="old_values", new="new_values"),
func=f"""
INSERT INTO {LogEntry._meta.db_table}(level, old_field, new_field)
SELECT 'STATEMENT' AS level,
old_values.field AS old_field,
new_values.field AS new_field
FROM old_values
JOIN new_values ON old_values.id = new_values.id;
RETURN NULL;
""",
),
pgtrigger.Trigger(
name="after_update_row_test",
level=pgtrigger.Row,
operation=pgtrigger.Update,
when=pgtrigger.After,
condition=pgtrigger.Q(old__field__df=pgtrigger.F("new__field")),
func=(f"INSERT INTO {LogEntry._meta.db_table}(level) VALUES ('ROW'); RETURN NULL;"),
),
)
class ToLogModel(models.Model):
"""For testing triggers that log records at statement and row level"""

field = models.CharField(max_length=16)

class Meta:
triggers = [
pgtrigger.Trigger(
name="update_of_statement_test",
level=pgtrigger.Statement,
operation=pgtrigger.UpdateOf("field"),
when=pgtrigger.After,
func=pgtrigger.Func(
f"""
INSERT INTO {LogEntry._meta.db_table}(level)
VALUES ('STATEMENT');
RETURN NULL;
"""
),
),
pgtrigger.Trigger(
name="after_update_statement_test",
level=pgtrigger.Statement,
operation=pgtrigger.Update,
when=pgtrigger.After,
referencing=pgtrigger.Referencing(old="old_values", new="new_values"),
func=f"""
INSERT INTO {LogEntry._meta.db_table}(level, old_field, new_field)
SELECT 'STATEMENT' AS level,
old_values.field AS old_field,
new_values.field AS new_field
FROM old_values
JOIN new_values ON old_values.id = new_values.id;
RETURN NULL;
""",
),
pgtrigger.Trigger(
name="after_update_row_test",
level=pgtrigger.Row,
operation=pgtrigger.Update,
when=pgtrigger.After,
condition=pgtrigger.Q(old__field__df=pgtrigger.F("new__field")),
func=(
f"INSERT INTO {LogEntry._meta.db_table}(level) VALUES ('ROW'); RETURN NULL;"
),
),
]


class CharPk(models.Model):
custom_pk = models.CharField(primary_key=True, max_length=32)
Expand Down
11 changes: 11 additions & 0 deletions pgtrigger/tests/test_core.py
Expand Up @@ -11,6 +11,17 @@
from pgtrigger.tests import models, utils


def test_func():
"""Tests using custom Func object"""
trigger = pgtrigger.Trigger(
name="example",
when=pgtrigger.After,
operation=pgtrigger.Delete,
func=pgtrigger.Func("SELECT {columns.int_field} FROM {meta.db_table}"),
)
assert trigger.render_func(models.TestModel) == "SELECT int_field FROM tests_testmodel"


@pytest.mark.django_db
def test_partition():
p1 = ddf.G(models.PartitionModel, timestamp=dt.datetime(2019, 1, 3))
Expand Down
8 changes: 8 additions & 0 deletions pgtrigger/utils.py
Expand Up @@ -2,6 +2,14 @@
from django.db import connections, DEFAULT_DB_ALIAS


class AttrDict(dict):
"""A dictionary where keys can be accessed as attributes"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__dict__ = self


def connection(database=None):
"""
Obtains the connection used for a trigger / model pair. The database
Expand Down

0 comments on commit 77caf5f

Please sign in to comment.