Skip to content
This repository was archived by the owner on Aug 8, 2020. It is now read-only.

Commit 2c3f319

Browse files
authored
Merge pull request #6 from jackton1/feature/increase-test-coverage
Increased the test coverage.
2 parents 8655414 + d0e966c commit 2c3f319

File tree

20 files changed

+442
-53
lines changed

20 files changed

+442
-53
lines changed

.envrc.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export ENV_DB=postgres,mysql,sqlite3

.github/workflows/check_constraints.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jobs:
77
runs-on: ubuntu-latest
88
strategy:
99
matrix:
10-
os: [ubuntu-latest]
10+
# os: ubuntu-latest
1111
# os: [ubuntu-latest, macos-latest, windows-latest]
1212
python-version: [3.5, 3.6, 3.7, 3.8, pypy3]
1313

@@ -33,7 +33,7 @@ jobs:
3333
steps:
3434
- uses: actions/checkout@v2
3535
- uses: actions/cache@v1
36-
id: Linux-cache
36+
id: Linux-pip-cache
3737
if: startsWith(runner.os, 'Linux')
3838
with:
3939
path: ~/.cache/pip
@@ -42,7 +42,7 @@ jobs:
4242
${{ runner.os }}-pip-
4343
4444
- uses: actions/cache@v1
45-
id: macOS-cache
45+
id: macOS-pip-cache
4646
if: startsWith(runner.os, 'macOS')
4747
with:
4848
path: ~/Library/Caches/pip
@@ -51,7 +51,7 @@ jobs:
5151
${{ runner.os }}-pip-
5252
5353
- uses: actions/cache@v1
54-
id: Windows-cache
54+
id: Windows-pip-cache
5555
if: startsWith(runner.os, 'Windows')
5656
with:
5757
path: ~\AppData\Local\pip\Cache
@@ -92,7 +92,7 @@ jobs:
9292
python-version: ${{ matrix.python-version }}
9393

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

9898
- name: Test with nox

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ build/
44
dist/
55
__pycache__/
66
.nox/
7+
*.sqlite3
8+
status.json
9+
.envrc

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ clean-build: ## Clean project build artifacts.
3232

3333
test:
3434
@echo "Running `$(PYTHON_VERSION)` test..."
35-
@$(MANAGE_PY) test
35+
@$(MANAGE_PY) test -v 3 --noinput --failfast
3636

3737
install: clean-build ## Install project dependencies.
3838
@echo "Installing project in dependencies..."

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
![Create New Release](https://github.com/jackton1/django-check-constraint/workflows/Create%20New%20Release/badge.svg)
99

1010

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

1314

1415
#### Installation
@@ -58,7 +59,7 @@ non_null_count
5859

5960
Defining a check constraint with this function
6061

61-
The equivalent of
62+
The equivalent of (PostgresSQL)
6263

6364
```postgresql
6465
ALTER TABLE app_name_test_modoel ADD CONSTRAINT app_name_test_model_optional_field_provided
@@ -132,4 +133,5 @@ TODO's
132133
------
133134

134135
- [ ] Add support for schema based functions.
135-
- [ ] Remove skipped sqlite3 test.
136+
- [ ] Add warning about mysql lack of user defined check constraint support.
137+
- [ ] Remove skipped sqlite3 test.

check_constraint/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
class AnnotatedCheckConstraint(models.CheckConstraint):
77
def __init__(self, *args, annotations=None, **kwargs):
8-
super().__init__(*args, **kwargs)
98
self.annotations = annotations or {}
9+
super(AnnotatedCheckConstraint, self).__init__(*args, **kwargs)
1010

1111
def _get_check_sql(self, model, schema_editor):
1212
query = Query(model=model)

check_constraint/tests.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,51 @@
1-
import os
1+
from decimal import Decimal
22

3+
from django.conf import settings
4+
from django.contrib.auth import get_user_model
5+
from django.db import IntegrityError, DatabaseError
36
from django.test import TestCase
47

5-
DATABASES = ["default"]
8+
from demo.models import Book
69

7-
8-
if "ENV_DB" in os.environ:
9-
DATABASES += [os.environ["ENV_DB"]]
10+
# TODO: Fix sqlite
11+
User = get_user_model()
1012

1113

1214
class AnnotateCheckConstraintTestCase(TestCase):
13-
databases = DATABASES
15+
databases = settings.TEST_ENV_DB
16+
17+
@classmethod
18+
def setUpTestData(cls):
19+
for db_name in cls._databases_names(include_mirrors=False):
20+
cls.user = User.objects.db_manager(db_name).create_superuser(
21+
username="Admin", email="[email protected]", password="test",
22+
)
23+
24+
def test_create_passes_with_annotated_check_constraint(self):
25+
for db_name in self._databases_names(include_mirrors=False):
26+
book = Book.objects.using(db_name).create(
27+
name="Business of the 21st Century",
28+
created_by=self.user,
29+
amount=Decimal("50"),
30+
amount_off=Decimal("20.58"),
31+
)
32+
33+
self.assertEqual(book.name, "Business of the 21st Century")
34+
self.assertEqual(book.created_by, self.user)
1435

15-
def test_dummy_setup(self):
16-
self.assertEqual(1, 1)
36+
def test_create_is_invalid_with_annotated_check_constraint(self):
37+
for db_name in self._databases_names(include_mirrors=False):
38+
if db_name == "mysql":
39+
with self.assertRaises(DatabaseError):
40+
Book.objects.using(db_name).create(
41+
name="Business of the 21st Century",
42+
created_by=self.user,
43+
amount=Decimal("50"),
44+
)
45+
else:
46+
with self.assertRaises(IntegrityError):
47+
Book.objects.using(db_name).create(
48+
name="Business of the 21st Century",
49+
created_by=self.user,
50+
amount=Decimal("50"),
51+
)

demo/migrations/0001_initial.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Generated by Django 2.2.10 on 2020-02-17 07:33
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
initial = True
11+
12+
dependencies = [
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="Book",
19+
fields=[
20+
(
21+
"id",
22+
models.AutoField(
23+
auto_created=True,
24+
primary_key=True,
25+
serialize=False,
26+
verbose_name="ID",
27+
),
28+
),
29+
("name", models.CharField(max_length=255)),
30+
("archived", models.BooleanField(default=False)),
31+
("amount", models.DecimalField(decimal_places=2, max_digits=9)),
32+
(
33+
"amount_off",
34+
models.DecimalField(
35+
blank=True, decimal_places=2, max_digits=7, null=True
36+
),
37+
),
38+
(
39+
"percentage",
40+
models.DecimalField(
41+
blank=True, decimal_places=0, max_digits=3, null=True
42+
),
43+
),
44+
(
45+
"created_by",
46+
models.ForeignKey(
47+
on_delete=django.db.models.deletion.CASCADE,
48+
to=settings.AUTH_USER_MODEL,
49+
),
50+
),
51+
],
52+
),
53+
migrations.CreateModel(
54+
name="Library",
55+
fields=[
56+
(
57+
"id",
58+
models.AutoField(
59+
auto_created=True,
60+
primary_key=True,
61+
serialize=False,
62+
verbose_name="ID",
63+
),
64+
),
65+
("name", models.CharField(max_length=255)),
66+
],
67+
),
68+
migrations.CreateModel(
69+
name="LibraryBook",
70+
fields=[
71+
(
72+
"id",
73+
models.AutoField(
74+
auto_created=True,
75+
primary_key=True,
76+
serialize=False,
77+
verbose_name="ID",
78+
),
79+
),
80+
(
81+
"books",
82+
models.ForeignKey(
83+
on_delete=django.db.models.deletion.PROTECT, to="demo.Book"
84+
),
85+
),
86+
(
87+
"library",
88+
models.ForeignKey(
89+
on_delete=django.db.models.deletion.CASCADE,
90+
related_name="library_books",
91+
to="demo.Library",
92+
),
93+
),
94+
],
95+
),
96+
migrations.AddField(
97+
model_name="library",
98+
name="books",
99+
field=models.ManyToManyField(through="demo.LibraryBook", to="demo.Book"),
100+
),
101+
]
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Generated by Django 2.2.10 on 2020-02-17 09:44
2+
3+
from django.db import migrations
4+
5+
6+
def non_null_count(*values):
7+
none_values = [i for i in values if i == None]
8+
9+
return len(none_values)
10+
11+
12+
DB_FUNCTIONS = {
13+
"postgresql": {
14+
"forward": lambda conn, cursor: cursor.execute(
15+
"""
16+
CREATE OR REPLACE FUNCTION public.non_null_count(VARIADIC arg_array ANYARRAY)
17+
RETURNS BIGINT AS
18+
$$
19+
SELECT COUNT(x) FROM UNNEST($1) AS x
20+
$$ LANGUAGE SQL IMMUTABLE;
21+
"""
22+
),
23+
"reverse": lambda conn, cursor: cursor.execute(
24+
"""
25+
DROP FUNCTION IF EXISTS public.non_null_count(VARIADIC arg_array ANYARRAY);
26+
"""
27+
),
28+
},
29+
"sqlite": {
30+
"forward": lambda conn, cursor: conn.create_function(
31+
"non_null_count", -1, non_null_count
32+
),
33+
"reverse": lambda conn, cursor: conn.create_function(
34+
"non_null_count", -1, None
35+
),
36+
},
37+
"mysql": {
38+
"forward": lambda conn, cursor: cursor.execute(
39+
"""
40+
CREATE FUNCTION non_null_count (params JSON)
41+
RETURNS INT
42+
DETERMINISTIC
43+
READS SQL DATA
44+
BEGIN
45+
DECLARE n INT DEFAULT JSON_LENGTH(params);
46+
DECLARE i INT DEFAULT 0;
47+
DECLARE current BOOLEAN DEFAULT false;
48+
DECLARE val INT DEFAULT 0;
49+
50+
WHILE i < n DO
51+
SET current = if(JSON_TYPE(JSON_EXTRACT(params, concat('$[', i , ']'))) != 'NULL', true, false);
52+
IF current THEN
53+
SET val = val + 1;
54+
END IF;
55+
SET i = i + 1;
56+
END WHILE;
57+
RETURN val;
58+
END;
59+
CREATE TRIGGER demo_book_validate before INSERT ON demo_book
60+
FOR each row
61+
BEGIN
62+
if non_null_count(JSON_ARRAY(new.amount_off, new.percentage)) = 0
63+
THEN
64+
signal SQLSTATE '45000' SET message_text = 'Both amount_off and percentage cannot
65+
be null';
66+
END if;
67+
END;
68+
69+
70+
CREATE TRIGGER demo_book_validate_2 before UPDATE ON demo_book
71+
FOR each row
72+
BEGIN
73+
if non_null_count(JSON_ARRAY(new.amount_off, new.percentage)) = 0
74+
THEN
75+
signal SQLSTATE '45000' SET message_text = 'Both amount_off and percentage cannot
76+
be null';
77+
END if;
78+
END;
79+
"""
80+
),
81+
"reverse": lambda conn, cursor: cursor.execute(
82+
"""
83+
DROP FUNCTION non_null_count;
84+
DROP TRIGGER demo_book_validate;
85+
DROP TRIGGER demo_book_validate_2;
86+
"""
87+
),
88+
},
89+
}
90+
91+
92+
def forwards_func(apps, schema_editor):
93+
conn = schema_editor.connection
94+
vendor = conn.vendor
95+
96+
with conn.cursor() as cursor:
97+
func = DB_FUNCTIONS[vendor]["forward"]
98+
99+
func(conn.connection, cursor)
100+
101+
102+
def reverse_func(apps, schema_editor):
103+
conn = schema_editor.connection
104+
db_alias = conn.db_alias
105+
106+
with conn.cursor() as cursor:
107+
func = DB_FUNCTIONS[db_alias]["reverse"]
108+
109+
func(conn, cursor)
110+
111+
112+
class Migration(migrations.Migration):
113+
dependencies = [
114+
("demo", "0001_initial"),
115+
]
116+
117+
operations = [migrations.RunPython(forwards_func, reverse_func)]

0 commit comments

Comments
 (0)