Skip to content
This repository was archived by the owner on Aug 8, 2020. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .envrc.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export ENV_DB=postgres,mysql,sqlite3
10 changes: 5 additions & 5 deletions .github/workflows/check_constraints.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest]
# os: ubuntu-latest
# os: [ubuntu-latest, macos-latest, windows-latest]
python-version: [3.5, 3.6, 3.7, 3.8, pypy3]

Expand All @@ -33,7 +33,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: actions/cache@v1
id: Linux-cache
id: Linux-pip-cache
if: startsWith(runner.os, 'Linux')
with:
path: ~/.cache/pip
Expand All @@ -42,7 +42,7 @@ jobs:
${{ runner.os }}-pip-

- uses: actions/cache@v1
id: macOS-cache
id: macOS-pip-cache
if: startsWith(runner.os, 'macOS')
with:
path: ~/Library/Caches/pip
Expand All @@ -51,7 +51,7 @@ jobs:
${{ runner.os }}-pip-

- uses: actions/cache@v1
id: Windows-cache
id: Windows-pip-cache
if: startsWith(runner.os, 'Windows')
with:
path: ~\AppData\Local\pip\Cache
Expand Down Expand Up @@ -92,7 +92,7 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
if: steps.${{ runner.os }}.outputs.cache-hit != 'true'
# if: steps.Linux-pip-cache.outputs.cache-hit != 'true'
run: make install-test

- name: Test with nox
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ build/
dist/
__pycache__/
.nox/
*.sqlite3
status.json
.envrc
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ clean-build: ## Clean project build artifacts.

test:
@echo "Running `$(PYTHON_VERSION)` test..."
@$(MANAGE_PY) test
@$(MANAGE_PY) test -v 3 --noinput --failfast

install: clean-build ## Install project dependencies.
@echo "Installing project in dependencies..."
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
![Create New Release](https://github.com/jackton1/django-check-constraint/workflows/Create%20New%20Release/badge.svg)


Extends [Django's Check](https://docs.djangoproject.com/en/3.0/ref/models/options/#constraints) constraint with support for annotations and calling db functions.
Extends [Django's Check](https://docs.djangoproject.com/en/3.0/ref/models/options/#constraints)
constraint with support for UDF(User defined functions/db functions) and annotations.


#### Installation
Expand Down Expand Up @@ -58,7 +59,7 @@ non_null_count

Defining a check constraint with this function

The equivalent of
The equivalent of (PostgresSQL)

```postgresql
ALTER TABLE app_name_test_modoel ADD CONSTRAINT app_name_test_model_optional_field_provided
Expand Down Expand Up @@ -132,4 +133,5 @@ TODO's
------

- [ ] Add support for schema based functions.
- [ ] Remove skipped sqlite3 test.
- [ ] Add warning about mysql lack of user defined check constraint support.
- [ ] Remove skipped sqlite3 test.
2 changes: 1 addition & 1 deletion check_constraint/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

class AnnotatedCheckConstraint(models.CheckConstraint):
def __init__(self, *args, annotations=None, **kwargs):
super().__init__(*args, **kwargs)
self.annotations = annotations or {}
super(AnnotatedCheckConstraint, self).__init__(*args, **kwargs)

def _get_check_sql(self, model, schema_editor):
query = Query(model=model)
Expand Down
51 changes: 43 additions & 8 deletions check_constraint/tests.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,51 @@
import os
from decimal import Decimal

from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import IntegrityError, DatabaseError
from django.test import TestCase

DATABASES = ["default"]
from demo.models import Book


if "ENV_DB" in os.environ:
DATABASES += [os.environ["ENV_DB"]]
# TODO: Fix sqlite
User = get_user_model()


class AnnotateCheckConstraintTestCase(TestCase):
databases = DATABASES
databases = settings.TEST_ENV_DB

@classmethod
def setUpTestData(cls):
for db_name in cls._databases_names(include_mirrors=False):
cls.user = User.objects.db_manager(db_name).create_superuser(
username="Admin", email="admin@admin.com", password="test",
)

def test_create_passes_with_annotated_check_constraint(self):
for db_name in self._databases_names(include_mirrors=False):
book = Book.objects.using(db_name).create(
name="Business of the 21st Century",
created_by=self.user,
amount=Decimal("50"),
amount_off=Decimal("20.58"),
)

self.assertEqual(book.name, "Business of the 21st Century")
self.assertEqual(book.created_by, self.user)

def test_dummy_setup(self):
self.assertEqual(1, 1)
def test_create_is_invalid_with_annotated_check_constraint(self):
for db_name in self._databases_names(include_mirrors=False):
if db_name == "mysql":
with self.assertRaises(DatabaseError):
Book.objects.using(db_name).create(
name="Business of the 21st Century",
created_by=self.user,
amount=Decimal("50"),
)
else:
with self.assertRaises(IntegrityError):
Book.objects.using(db_name).create(
name="Business of the 21st Century",
created_by=self.user,
amount=Decimal("50"),
)
101 changes: 101 additions & 0 deletions demo/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Generated by Django 2.2.10 on 2020-02-17 07:33

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="Book",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("archived", models.BooleanField(default=False)),
("amount", models.DecimalField(decimal_places=2, max_digits=9)),
(
"amount_off",
models.DecimalField(
blank=True, decimal_places=2, max_digits=7, null=True
),
),
(
"percentage",
models.DecimalField(
blank=True, decimal_places=0, max_digits=3, null=True
),
),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="Library",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name="LibraryBook",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"books",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="demo.Book"
),
),
(
"library",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="library_books",
to="demo.Library",
),
),
],
),
migrations.AddField(
model_name="library",
name="books",
field=models.ManyToManyField(through="demo.LibraryBook", to="demo.Book"),
),
]
117 changes: 117 additions & 0 deletions demo/migrations/0002_auto_20200218_0733.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Generated by Django 2.2.10 on 2020-02-17 09:44

from django.db import migrations


def non_null_count(*values):
none_values = [i for i in values if i == None]

return len(none_values)


DB_FUNCTIONS = {
"postgresql": {
"forward": lambda conn, cursor: cursor.execute(
"""
CREATE OR REPLACE FUNCTION public.non_null_count(VARIADIC arg_array ANYARRAY)
RETURNS BIGINT AS
$$
SELECT COUNT(x) FROM UNNEST($1) AS x
$$ LANGUAGE SQL IMMUTABLE;
"""
),
"reverse": lambda conn, cursor: cursor.execute(
"""
DROP FUNCTION IF EXISTS public.non_null_count(VARIADIC arg_array ANYARRAY);
"""
),
},
"sqlite": {
"forward": lambda conn, cursor: conn.create_function(
"non_null_count", -1, non_null_count
),
"reverse": lambda conn, cursor: conn.create_function(
"non_null_count", -1, None
),
},
"mysql": {
"forward": lambda conn, cursor: cursor.execute(
"""
CREATE FUNCTION non_null_count (params JSON)
RETURNS INT
DETERMINISTIC
READS SQL DATA
BEGIN
DECLARE n INT DEFAULT JSON_LENGTH(params);
DECLARE i INT DEFAULT 0;
DECLARE current BOOLEAN DEFAULT false;
DECLARE val INT DEFAULT 0;

WHILE i < n DO
SET current = if(JSON_TYPE(JSON_EXTRACT(params, concat('$[', i , ']'))) != 'NULL', true, false);
IF current THEN
SET val = val + 1;
END IF;
SET i = i + 1;
END WHILE;
RETURN val;
END;
CREATE TRIGGER demo_book_validate before INSERT ON demo_book
FOR each row
BEGIN
if non_null_count(JSON_ARRAY(new.amount_off, new.percentage)) = 0
THEN
signal SQLSTATE '45000' SET message_text = 'Both amount_off and percentage cannot
be null';
END if;
END;


CREATE TRIGGER demo_book_validate_2 before UPDATE ON demo_book
FOR each row
BEGIN
if non_null_count(JSON_ARRAY(new.amount_off, new.percentage)) = 0
THEN
signal SQLSTATE '45000' SET message_text = 'Both amount_off and percentage cannot
be null';
END if;
END;
"""
),
"reverse": lambda conn, cursor: cursor.execute(
"""
DROP FUNCTION non_null_count;
DROP TRIGGER demo_book_validate;
DROP TRIGGER demo_book_validate_2;
"""
),
},
}


def forwards_func(apps, schema_editor):
conn = schema_editor.connection
vendor = conn.vendor

with conn.cursor() as cursor:
func = DB_FUNCTIONS[vendor]["forward"]

func(conn.connection, cursor)


def reverse_func(apps, schema_editor):
conn = schema_editor.connection
db_alias = conn.db_alias

with conn.cursor() as cursor:
func = DB_FUNCTIONS[db_alias]["reverse"]

func(conn, cursor)


class Migration(migrations.Migration):
dependencies = [
("demo", "0001_initial"),
]

operations = [migrations.RunPython(forwards_func, reverse_func)]
Loading