From a9f24ede8039e5b86e92d7b8f65ec1dbb57834f4 Mon Sep 17 00:00:00 2001 From: David Sanders <> Date: Thu, 11 Jul 2024 23:27:36 +1000 Subject: [PATCH] Added ability to define custom Funcs as constraints to colocate the fuction's definition --- README.md | 1 + func_as_constraint/README.md | 73 +++++++++++++++++++ func_as_constraint/__init__.py | 0 func_as_constraint/apps.py | 6 ++ func_as_constraint/migrations/0001_initial.py | 35 +++++++++ func_as_constraint/migrations/__init__.py | 0 func_as_constraint/models.py | 46 ++++++++++++ func_as_constraint/tests.py | 13 ++++ stupid_django_tricks/settings.py | 1 + 9 files changed, 175 insertions(+) create mode 100644 func_as_constraint/README.md create mode 100644 func_as_constraint/__init__.py create mode 100644 func_as_constraint/apps.py create mode 100644 func_as_constraint/migrations/0001_initial.py create mode 100644 func_as_constraint/migrations/__init__.py create mode 100644 func_as_constraint/models.py create mode 100644 func_as_constraint/tests.py diff --git a/README.md b/README.md index ce22fa7..d48a713 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,4 @@ Various tricks with Django - some silly, some quite useful. 20. [CloneDbTestCase](./clone_db_testcase) 21. [Additional Code Formatters](./isort_migrations) 22. [Bulk Create Form](./bulk_create_form) +23. [Custom Funcs as Constraints](./func_as_constraint) diff --git a/func_as_constraint/README.md b/func_as_constraint/README.md new file mode 100644 index 0000000..ade16e5 --- /dev/null +++ b/func_as_constraint/README.md @@ -0,0 +1,73 @@ +Functions as Constraints +======================== + +July 2024 + + +We've seen that we can use the constraints API to add artifacts to the database. An interesting application +is to colocate the definition for custom functions defined as `Func`: + + +```python +class HelloWorld(Func): + function = "hello_world" + output_field = models.CharField() + + create_function = """\ + CREATE OR REPLACE FUNCTION hello_world() + RETURNS varchar + AS $$ + BEGIN + RETURN 'Hello World!'; + END; + $$ LANGUAGE plpgsql; + """ + + +class PlaceholderModel(models.Model): + class Meta: + constraints = [ + func_as_constraint(HelloWorld), + ] +``` + +Using a simple function we can create a `RawSQL` constraint defined in [../abusing_constraints](Having Fun with Constraints) + +```python +def func_as_constraint(func_class): + sql = textwrap.dedent(func_class.create_function).strip() + return RawSQL( + name=func_class.function, + sql=sql, + ... + ) +``` + +A next step might be to create a reverse for RawSQL that drops the function: + +```python +def func_as_constraint(func_class): + sql = textwrap.dedent(func_class.create_function).strip() + return RawSQL( + name=func_class.function, + sql=sql, + reverse_sql=f"DROP FUNCTION IF EXISTS {func_class.function}", + ) +``` + +We can even go so far as to add a comment to the function if the function class has a docstring: + +```python +def func_as_constraint(func_class): + sql = textwrap.dedent(func_class.create_function).strip() + if func_class.__doc__: + comment = psycopg_any_sql.quote(textwrap.dedent(func_class.__doc__).strip()) + if sql[-1] != ";": + sql += ";" + sql += f"COMMENT ON FUNCTION {func_class.function} IS {comment};" + return RawSQL( + name=func_class.function, + sql=sql, + reverse_sql=f"DROP FUNCTION IF EXISTS {func_class.function}", + ) +``` diff --git a/func_as_constraint/__init__.py b/func_as_constraint/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/func_as_constraint/apps.py b/func_as_constraint/apps.py new file mode 100644 index 0000000..bc902cd --- /dev/null +++ b/func_as_constraint/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FuncAsConstraintConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "func_as_constraint" diff --git a/func_as_constraint/migrations/0001_initial.py b/func_as_constraint/migrations/0001_initial.py new file mode 100644 index 0000000..8955503 --- /dev/null +++ b/func_as_constraint/migrations/0001_initial.py @@ -0,0 +1,35 @@ +from django.db import migrations +from django.db import models + +import abusing_constraints.constraints + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="PlaceholderModel", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="placeholdermodel", + constraint=abusing_constraints.constraints.RawSQL( + name="hello_world", + reverse_sql="DROP FUNCTION IF EXISTS hello_world", + sql="CREATE OR REPLACE FUNCTION hello_world()\nRETURNS varchar\nAS $$\nBEGIN\n RETURN 'Hello World!';\nEND;\n$$ LANGUAGE plpgsql;COMMENT ON FUNCTION hello_world IS 'Print hello world!';", + ), + ), + ] diff --git a/func_as_constraint/migrations/__init__.py b/func_as_constraint/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/func_as_constraint/models.py b/func_as_constraint/models.py new file mode 100644 index 0000000..a4659c5 --- /dev/null +++ b/func_as_constraint/models.py @@ -0,0 +1,46 @@ +import textwrap + +from django.db import models +from django.db.backends.postgresql.psycopg_any import sql as psycopg_any_sql +from django.db.models.expressions import Func + +from abusing_constraints.constraints import RawSQL + + +def func_as_constraint(func_class): + sql = textwrap.dedent(func_class.create_function).strip() + if func_class.__doc__: + comment = psycopg_any_sql.quote(textwrap.dedent(func_class.__doc__).strip()) + if sql[-1] != ";": + sql += ";" + sql += f"COMMENT ON FUNCTION {func_class.function} IS {comment};" + return RawSQL( + name=func_class.function, + sql=sql, + reverse_sql=f"DROP FUNCTION IF EXISTS {func_class.function}", + ) + + +class HelloWorld(Func): + """ + Print hello world! + """ + + function = "hello_world" + output_field = models.CharField() + create_function = """\ + CREATE OR REPLACE FUNCTION hello_world() + RETURNS varchar + AS $$ + BEGIN + RETURN 'Hello World!'; + END; + $$ LANGUAGE plpgsql; + """ + + +class PlaceholderModel(models.Model): + class Meta: + constraints = [ + func_as_constraint(HelloWorld), + ] diff --git a/func_as_constraint/tests.py b/func_as_constraint/tests.py new file mode 100644 index 0000000..9dcf1a1 --- /dev/null +++ b/func_as_constraint/tests.py @@ -0,0 +1,13 @@ +import pytest + +from .models import HelloWorld, PlaceholderModel + +pytestmark = pytest.mark.django_db + + +def test_hello_world(): + PlaceholderModel.objects.create() + + qs = PlaceholderModel.objects.annotate(hello_world=HelloWorld()) + + assert qs[0].hello_world == "Hello World!" diff --git a/stupid_django_tricks/settings.py b/stupid_django_tricks/settings.py index 631d513..3cda7fb 100644 --- a/stupid_django_tricks/settings.py +++ b/stupid_django_tricks/settings.py @@ -33,6 +33,7 @@ # Application definition INSTALLED_APPS = [ + "func_as_constraint", "clone_db_testcase", "negative_indexing_querysets", "xor_function",