django-db-portability 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. django_db_portability-0.1.0/LICENSE +21 -0
  2. django_db_portability-0.1.0/PKG-INFO +145 -0
  3. django_db_portability-0.1.0/README.md +126 -0
  4. django_db_portability-0.1.0/pyproject.toml +34 -0
  5. django_db_portability-0.1.0/setup.cfg +4 -0
  6. django_db_portability-0.1.0/src/db_portability/__init__.py +1 -0
  7. django_db_portability-0.1.0/src/db_portability/checks/__init__.py +29 -0
  8. django_db_portability-0.1.0/src/db_portability/checks/base.py +30 -0
  9. django_db_portability-0.1.0/src/db_portability/checks/postgres_oracle.py +199 -0
  10. django_db_portability-0.1.0/src/db_portability/cli.py +137 -0
  11. django_db_portability-0.1.0/src/db_portability/fields.py +38 -0
  12. django_db_portability-0.1.0/src/db_portability/lint.py +21 -0
  13. django_db_portability-0.1.0/src/db_portability/managers.py +26 -0
  14. django_db_portability-0.1.0/src/django_db_portability.egg-info/PKG-INFO +145 -0
  15. django_db_portability-0.1.0/src/django_db_portability.egg-info/SOURCES.txt +21 -0
  16. django_db_portability-0.1.0/src/django_db_portability.egg-info/dependency_links.txt +1 -0
  17. django_db_portability-0.1.0/src/django_db_portability.egg-info/entry_points.txt +5 -0
  18. django_db_portability-0.1.0/src/django_db_portability.egg-info/requires.txt +8 -0
  19. django_db_portability-0.1.0/src/django_db_portability.egg-info/top_level.txt +1 -0
  20. django_db_portability-0.1.0/tests/test_cli.py +66 -0
  21. django_db_portability-0.1.0/tests/test_fields.py +26 -0
  22. django_db_portability-0.1.0/tests/test_lint.py +76 -0
  23. django_db_portability-0.1.0/tests/test_managers.py +9 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carl Russell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-db-portability
3
+ Version: 0.1.0
4
+ Summary: Catch Django code that breaks when ported to another database (postgres -> oracle today), plus runtime helpers for the NULL/empty-string divergence between backends
5
+ Author-email: Rayan Mahdinejad <rayan.mahdinejad@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rayanmahdinejad/django-db-portability
8
+ Project-URL: Repository, https://github.com/rayanmahdinejad/django-db-portability
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: flake8>=6.0
13
+ Provides-Extra: django
14
+ Requires-Dist: django>=4.2; extra == "django"
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=7.0; extra == "test"
17
+ Requires-Dist: django>=4.2; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # django-db-portability
21
+
22
+ Catches Django code written for one database that will break when ported to
23
+ another, and provides runtime helpers for the one divergence static analysis
24
+ can't catch: Oracle silently treats `''` as `NULL`, PostgreSQL doesn't.
25
+
26
+ This does **not** try to make Django fully database-agnostic — Django's ORM
27
+ already handles the common cases (pagination, joins, sequences). It targets
28
+ the specific, well-documented gaps that leak through: source-DB-only
29
+ `contrib` modules, raw SQL with source-DB-only syntax, and the
30
+ empty-string/NULL trap.
31
+
32
+ Checks are organized by `(source, target)` pair. **Only `postgres -> oracle`
33
+ is implemented today** — see `src/db_portability/checks/`. Adding another
34
+ pair (e.g. `mysql -> oracle`) means writing a sibling module and registering
35
+ it; `dbp-scan`'s `--from`/`--to` picks it up automatically once it exists.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install django-db-portability # lint checks only
41
+ pip install "django-db-portability[django]" # + runtime helpers (fields/managers)
42
+ ```
43
+
44
+ ## 1. Static checks
45
+
46
+ ### flake8 plugin
47
+
48
+ Runs automatically once installed — flake8 picks up plugins from
49
+ `flake8.extension` entry points. Always runs the `postgres -> oracle` checks
50
+ (flake8 plugins have no natural way to expose a `--from`/`--to` pair):
51
+
52
+ ```bash
53
+ flake8 --select=DBP myproject/
54
+ ```
55
+
56
+ | Code | Flags |
57
+ |------|-------|
58
+ | DBP001 | Postgres-only field (`ArrayField`, `HStoreField`, `CITextField`, range fields, ...) |
59
+ | DBP002 | Postgres full-text search (`SearchVector`, `SearchQuery`, `TrigramSimilarity`, ...) |
60
+ | DBP003 | Postgres-only aggregate (`ArrayAgg`, `StringAgg`, `BoolAnd`, ...) |
61
+ | DBP004 | `.extra()` — raw SQL fragment, needs manual review |
62
+ | DBP005 | Raw SQL (`RunSQL`, `cursor.execute`, `.raw()`) containing Postgres-only syntax (`ON CONFLICT`, `RETURNING`, `ILIKE`, `::` casts, ...) |
63
+ | DBP006 | `CharField`/`TextField(unique=True, blank=True)` without `null=True` — the NULL/empty-string trap below |
64
+ | DBP007 | Other Postgres-only `contrib` modules (indexes, constraints, operations) |
65
+
66
+ DBP0xx is reserved for `postgres -> oracle`. A future pair gets its own
67
+ block (DBP1xx, DBP2xx, ...) so codes stay stable as pairs are added.
68
+
69
+ Add to your CI lint step or `setup.cfg`:
70
+
71
+ ```ini
72
+ [flake8]
73
+ select = E,F,DBP
74
+ ```
75
+
76
+ ### `dbp-scan` — readable terminal output
77
+
78
+ `flake8 --select=DBP` prints one flat line per finding, which turns into an
79
+ unreadable wall of text on a real project. `dbp-scan` runs the same checks
80
+ but groups findings by file and colorizes them, and lets you pick the
81
+ `--from`/`--to` pair (`postgres -> oracle` today):
82
+
83
+ ```bash
84
+ dbp-scan myproject/ # postgres -> oracle (default)
85
+ dbp-scan --from postgres --to oracle myproject/
86
+ dbp-scan --quiet myproject/ # summary line only
87
+ dbp-scan --no-color myproject/ > report.txt
88
+ ```
89
+
90
+ It skips `migrations/`, `.venv`, `.git`, `__pycache__`, `node_modules`,
91
+ `.tox`, `build`, and `dist` by default (`--exclude NAME` adds more), and
92
+ exits `1` if any issues were found — same convention as flake8, so it's
93
+ safe to use as a CI gate too. An unregistered pair (e.g. `--from mysql`)
94
+ exits `2` with the list of pairs that are actually implemented.
95
+
96
+ ## 2. The NULL / empty-string trap
97
+
98
+ Oracle coerces `''` to `NULL` for `VARCHAR2`/`CLOB` columns. PostgreSQL does
99
+ not. Django's own convention — "never set `null=True` on `CharField`" — is
100
+ exactly what makes the two backends disagree: identical code, identical
101
+ input, different stored value depending on which `DATABASES` alias the query
102
+ hits. It's data-dependent, so it won't show up as a test failure until you
103
+ have the right data.
104
+
105
+ ### Option A — swap the field type
106
+
107
+ ```python
108
+ from db_portability.fields import PortableCharField
109
+
110
+ class Widget(models.Model):
111
+ code = PortableCharField(max_length=20, null=True, blank=True)
112
+ ```
113
+
114
+ `PortableCharField`/`PortableTextField` normalize `''` → `None` in Python
115
+ before the value reaches *either* database, so both backends store and
116
+ return the same thing. This requires `null=True` — that's intentional.
117
+
118
+ ### Option B — can't change the field? Query both cases explicitly
119
+
120
+ ```python
121
+ from db_portability.managers import empty_or_null_q
122
+
123
+ Widget.objects.filter(empty_or_null_q("code"))
124
+ ```
125
+
126
+ or use the manager mixin:
127
+
128
+ ```python
129
+ from db_portability.managers import PortableManager
130
+
131
+ class Widget(models.Model):
132
+ code = models.CharField(max_length=20, blank=True)
133
+ objects = PortableManager()
134
+
135
+ Widget.objects.empty_or_null("code")
136
+ Widget.objects.exclude_empty_or_null("code")
137
+ ```
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ python -m venv .venv
143
+ .venv\Scripts\pip install -e ".[test]"
144
+ .venv\Scripts\pytest
145
+ ```
@@ -0,0 +1,126 @@
1
+ # django-db-portability
2
+
3
+ Catches Django code written for one database that will break when ported to
4
+ another, and provides runtime helpers for the one divergence static analysis
5
+ can't catch: Oracle silently treats `''` as `NULL`, PostgreSQL doesn't.
6
+
7
+ This does **not** try to make Django fully database-agnostic — Django's ORM
8
+ already handles the common cases (pagination, joins, sequences). It targets
9
+ the specific, well-documented gaps that leak through: source-DB-only
10
+ `contrib` modules, raw SQL with source-DB-only syntax, and the
11
+ empty-string/NULL trap.
12
+
13
+ Checks are organized by `(source, target)` pair. **Only `postgres -> oracle`
14
+ is implemented today** — see `src/db_portability/checks/`. Adding another
15
+ pair (e.g. `mysql -> oracle`) means writing a sibling module and registering
16
+ it; `dbp-scan`'s `--from`/`--to` picks it up automatically once it exists.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install django-db-portability # lint checks only
22
+ pip install "django-db-portability[django]" # + runtime helpers (fields/managers)
23
+ ```
24
+
25
+ ## 1. Static checks
26
+
27
+ ### flake8 plugin
28
+
29
+ Runs automatically once installed — flake8 picks up plugins from
30
+ `flake8.extension` entry points. Always runs the `postgres -> oracle` checks
31
+ (flake8 plugins have no natural way to expose a `--from`/`--to` pair):
32
+
33
+ ```bash
34
+ flake8 --select=DBP myproject/
35
+ ```
36
+
37
+ | Code | Flags |
38
+ |------|-------|
39
+ | DBP001 | Postgres-only field (`ArrayField`, `HStoreField`, `CITextField`, range fields, ...) |
40
+ | DBP002 | Postgres full-text search (`SearchVector`, `SearchQuery`, `TrigramSimilarity`, ...) |
41
+ | DBP003 | Postgres-only aggregate (`ArrayAgg`, `StringAgg`, `BoolAnd`, ...) |
42
+ | DBP004 | `.extra()` — raw SQL fragment, needs manual review |
43
+ | DBP005 | Raw SQL (`RunSQL`, `cursor.execute`, `.raw()`) containing Postgres-only syntax (`ON CONFLICT`, `RETURNING`, `ILIKE`, `::` casts, ...) |
44
+ | DBP006 | `CharField`/`TextField(unique=True, blank=True)` without `null=True` — the NULL/empty-string trap below |
45
+ | DBP007 | Other Postgres-only `contrib` modules (indexes, constraints, operations) |
46
+
47
+ DBP0xx is reserved for `postgres -> oracle`. A future pair gets its own
48
+ block (DBP1xx, DBP2xx, ...) so codes stay stable as pairs are added.
49
+
50
+ Add to your CI lint step or `setup.cfg`:
51
+
52
+ ```ini
53
+ [flake8]
54
+ select = E,F,DBP
55
+ ```
56
+
57
+ ### `dbp-scan` — readable terminal output
58
+
59
+ `flake8 --select=DBP` prints one flat line per finding, which turns into an
60
+ unreadable wall of text on a real project. `dbp-scan` runs the same checks
61
+ but groups findings by file and colorizes them, and lets you pick the
62
+ `--from`/`--to` pair (`postgres -> oracle` today):
63
+
64
+ ```bash
65
+ dbp-scan myproject/ # postgres -> oracle (default)
66
+ dbp-scan --from postgres --to oracle myproject/
67
+ dbp-scan --quiet myproject/ # summary line only
68
+ dbp-scan --no-color myproject/ > report.txt
69
+ ```
70
+
71
+ It skips `migrations/`, `.venv`, `.git`, `__pycache__`, `node_modules`,
72
+ `.tox`, `build`, and `dist` by default (`--exclude NAME` adds more), and
73
+ exits `1` if any issues were found — same convention as flake8, so it's
74
+ safe to use as a CI gate too. An unregistered pair (e.g. `--from mysql`)
75
+ exits `2` with the list of pairs that are actually implemented.
76
+
77
+ ## 2. The NULL / empty-string trap
78
+
79
+ Oracle coerces `''` to `NULL` for `VARCHAR2`/`CLOB` columns. PostgreSQL does
80
+ not. Django's own convention — "never set `null=True` on `CharField`" — is
81
+ exactly what makes the two backends disagree: identical code, identical
82
+ input, different stored value depending on which `DATABASES` alias the query
83
+ hits. It's data-dependent, so it won't show up as a test failure until you
84
+ have the right data.
85
+
86
+ ### Option A — swap the field type
87
+
88
+ ```python
89
+ from db_portability.fields import PortableCharField
90
+
91
+ class Widget(models.Model):
92
+ code = PortableCharField(max_length=20, null=True, blank=True)
93
+ ```
94
+
95
+ `PortableCharField`/`PortableTextField` normalize `''` → `None` in Python
96
+ before the value reaches *either* database, so both backends store and
97
+ return the same thing. This requires `null=True` — that's intentional.
98
+
99
+ ### Option B — can't change the field? Query both cases explicitly
100
+
101
+ ```python
102
+ from db_portability.managers import empty_or_null_q
103
+
104
+ Widget.objects.filter(empty_or_null_q("code"))
105
+ ```
106
+
107
+ or use the manager mixin:
108
+
109
+ ```python
110
+ from db_portability.managers import PortableManager
111
+
112
+ class Widget(models.Model):
113
+ code = models.CharField(max_length=20, blank=True)
114
+ objects = PortableManager()
115
+
116
+ Widget.objects.empty_or_null("code")
117
+ Widget.objects.exclude_empty_or_null("code")
118
+ ```
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ python -m venv .venv
124
+ .venv\Scripts\pip install -e ".[test]"
125
+ .venv\Scripts\pytest
126
+ ```
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "django-db-portability"
7
+ version = "0.1.0"
8
+ description = "Catch Django code that breaks when ported to another database (postgres -> oracle today), plus runtime helpers for the NULL/empty-string divergence between backends"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Rayan Mahdinejad", email = "rayan.mahdinejad@gmail.com" },
14
+ ]
15
+ dependencies = [
16
+ "flake8>=6.0",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/rayanmahdinejad/django-db-portability"
21
+ Repository = "https://github.com/rayanmahdinejad/django-db-portability"
22
+
23
+ [project.optional-dependencies]
24
+ django = ["django>=4.2"]
25
+ test = ["pytest>=7.0", "django>=4.2"]
26
+
27
+ [project.entry-points."flake8.extension"]
28
+ DBP = "db_portability.lint:PostgresPortabilityChecker"
29
+
30
+ [project.scripts]
31
+ dbp-scan = "db_portability.cli:main"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,29 @@
1
+ """
2
+ Registry of database portability checks, keyed by (source, target).
3
+
4
+ Only postgres -> oracle exists today. To add another pair (e.g.
5
+ mysql -> oracle), write a sibling module exposing SOURCE, TARGET, and
6
+ run(tree), then register it below - dbp-scan's --from/--to picks it up
7
+ automatically.
8
+ """
9
+ from db_portability.checks import postgres_oracle
10
+
11
+ REGISTRY = {
12
+ (postgres_oracle.SOURCE, postgres_oracle.TARGET): postgres_oracle,
13
+ }
14
+
15
+
16
+ def get_checks(source, target):
17
+ key = (source.lower(), target.lower())
18
+ try:
19
+ return REGISTRY[key]
20
+ except KeyError:
21
+ supported = ", ".join(f"{s} -> {t}" for s, t in available_pairs())
22
+ raise ValueError(
23
+ f"no checks registered for {source} -> {target}. "
24
+ f"Supported pairs: {supported}"
25
+ ) from None
26
+
27
+
28
+ def available_pairs():
29
+ return sorted(REGISTRY)
@@ -0,0 +1,30 @@
1
+ """AST-walking helpers shared by every (source, target) rule module."""
2
+ import ast
3
+
4
+
5
+ def dotted_name(node):
6
+ """Best-effort reconstruction of a dotted attribute/name chain."""
7
+ parts = []
8
+ while isinstance(node, ast.Attribute):
9
+ parts.append(node.attr)
10
+ node = node.value
11
+ if isinstance(node, ast.Name):
12
+ parts.append(node.id)
13
+ return ".".join(reversed(parts))
14
+
15
+
16
+ def string_constant(node):
17
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
18
+ return node.value
19
+ return None
20
+
21
+
22
+ def keyword_value(call, name):
23
+ for kw in call.keywords:
24
+ if kw.arg == name:
25
+ return kw.value
26
+ return None
27
+
28
+
29
+ def is_true(node):
30
+ return isinstance(node, ast.Constant) and node.value is True
@@ -0,0 +1,199 @@
1
+ """
2
+ Rules for the postgres -> oracle pair: flags Django ORM / raw-SQL usage that
3
+ is known to work on PostgreSQL but silently break (or behave differently) on
4
+ Oracle.
5
+
6
+ This does not try to make a project fully database-agnostic - Django's ORM
7
+ already handles that for the common cases. It only flags the specific,
8
+ well-documented leaks: Postgres-only contrib modules, raw SQL with
9
+ Postgres-only syntax, and the empty-string/NULL divergence between the two
10
+ backends.
11
+
12
+ Codes DBP0xx are reserved for this pair. A future pair (e.g. mysql -> oracle)
13
+ should use its own block (DBP1xx, DBP2xx, ...) so codes stay stable as pairs
14
+ are added - see db_portability.checks.REGISTRY.
15
+ """
16
+ import ast
17
+
18
+ from db_portability.checks.base import dotted_name, is_true, keyword_value, string_constant
19
+
20
+ SOURCE = "postgres"
21
+ TARGET = "oracle"
22
+
23
+ POSTGRES_FIELD_NAMES = {
24
+ "ArrayField",
25
+ "HStoreField",
26
+ "CITextField",
27
+ "CICharField",
28
+ "CIEmailField",
29
+ "RangeField",
30
+ "IntegerRangeField",
31
+ "BigIntegerRangeField",
32
+ "DecimalRangeField",
33
+ "DateTimeRangeField",
34
+ "DateRangeField",
35
+ }
36
+
37
+ POSTGRES_SEARCH_NAMES = {
38
+ "SearchVector",
39
+ "SearchQuery",
40
+ "SearchRank",
41
+ "SearchVectorField",
42
+ "SearchHeadline",
43
+ "TrigramSimilarity",
44
+ "TrigramDistance",
45
+ "TrigramWordSimilarity",
46
+ "TrigramWordDistance",
47
+ "TrigramStrictWordSimilarity",
48
+ "TrigramStrictWordDistance",
49
+ }
50
+
51
+ POSTGRES_AGGREGATE_NAMES = {
52
+ "ArrayAgg",
53
+ "BitAnd",
54
+ "BitOr",
55
+ "BitXor",
56
+ "BoolAnd",
57
+ "BoolOr",
58
+ "JSONBAgg",
59
+ "StringAgg",
60
+ "Corr",
61
+ "CovarPop",
62
+ "RegrAvgX",
63
+ "RegrAvgY",
64
+ "RegrCount",
65
+ "RegrIntercept",
66
+ "RegrR2",
67
+ "RegrSlope",
68
+ "RegrSXX",
69
+ "RegrSXY",
70
+ "RegrSYY",
71
+ "StatAggregate",
72
+ }
73
+
74
+ # Everything else under django.contrib.postgres (indexes, constraints,
75
+ # operations) - also Postgres-only, but less common, so lumped into one code.
76
+ POSTGRES_OTHER_MODULES = {
77
+ "django.contrib.postgres.indexes",
78
+ "django.contrib.postgres.constraints",
79
+ "django.contrib.postgres.operations",
80
+ "django.contrib.postgres.functions",
81
+ "django.contrib.postgres.validators",
82
+ }
83
+
84
+ RAW_SQL_MARKERS = (
85
+ "on conflict",
86
+ "returning",
87
+ "ilike",
88
+ "serial",
89
+ "gen_random_uuid(",
90
+ "->>",
91
+ "#>>",
92
+ "::",
93
+ )
94
+
95
+ RAW_SQL_CALL_NAMES = {"RunSQL", "execute", "raw"}
96
+
97
+ CHAR_BASED_FIELDS = {
98
+ "CharField",
99
+ "TextField",
100
+ "SlugField",
101
+ "EmailField",
102
+ "URLField",
103
+ }
104
+
105
+ # DBP004 (.extra()) and DBP006 (NULL/empty-string trap) need manual review /
106
+ # are data-dependent rather than guaranteed breakage - callers may want to
107
+ # report them at a lower severity than the rest.
108
+ WARN_CODES = {"DBP004", "DBP006"}
109
+
110
+
111
+ class _Visitor(ast.NodeVisitor):
112
+ def __init__(self):
113
+ self.errors = []
114
+
115
+ def _add(self, node, code, message):
116
+ self.errors.append((node.lineno, node.col_offset, f"{code} {message}"))
117
+
118
+ def visit_ImportFrom(self, node):
119
+ module = node.module or ""
120
+ if module == "django.contrib.postgres.fields":
121
+ for alias in node.names:
122
+ if alias.name in POSTGRES_FIELD_NAMES or alias.name == "*":
123
+ self._add(
124
+ node,
125
+ "DBP001",
126
+ f"'{alias.name}' is a PostgreSQL-only field type and "
127
+ "has no Oracle equivalent",
128
+ )
129
+ elif module == "django.contrib.postgres.search":
130
+ for alias in node.names:
131
+ self._add(
132
+ node,
133
+ "DBP002",
134
+ f"'{alias.name}' is PostgreSQL full-text search and will "
135
+ "not work against Oracle",
136
+ )
137
+ elif module == "django.contrib.postgres.aggregates":
138
+ for alias in node.names:
139
+ self._add(
140
+ node,
141
+ "DBP003",
142
+ f"'{alias.name}' is a PostgreSQL-only aggregate",
143
+ )
144
+ elif module in POSTGRES_OTHER_MODULES:
145
+ self._add(
146
+ node,
147
+ "DBP007",
148
+ f"'{module}' is PostgreSQL-specific (indexes/constraints/"
149
+ "operations do not exist on Oracle)",
150
+ )
151
+ self.generic_visit(node)
152
+
153
+ def visit_Call(self, node):
154
+ func_name = dotted_name(node.func)
155
+ short_name = func_name.rsplit(".", 1)[-1] if func_name else ""
156
+
157
+ if short_name == "extra":
158
+ self._add(
159
+ node,
160
+ "DBP004",
161
+ ".extra() injects raw SQL fragments - review for "
162
+ "PostgreSQL-only syntax before running on Oracle",
163
+ )
164
+
165
+ if short_name in RAW_SQL_CALL_NAMES and node.args:
166
+ sql = string_constant(node.args[0])
167
+ if sql is not None:
168
+ lowered = sql.lower()
169
+ hits = [m for m in RAW_SQL_MARKERS if m in lowered]
170
+ if hits:
171
+ self._add(
172
+ node,
173
+ "DBP005",
174
+ f"raw SQL contains PostgreSQL-specific syntax "
175
+ f"({', '.join(hits)}) - will not run on Oracle as-is",
176
+ )
177
+
178
+ if short_name in CHAR_BASED_FIELDS:
179
+ unique = keyword_value(node, "unique")
180
+ blank = keyword_value(node, "blank")
181
+ null = keyword_value(node, "null")
182
+ if is_true(unique) and is_true(blank) and not is_true(null):
183
+ self._add(
184
+ node,
185
+ "DBP006",
186
+ f"{short_name}(unique=True, blank=True) without "
187
+ "null=True: Oracle coerces '' to NULL but PostgreSQL "
188
+ "does not, so unique/empty behavior will diverge "
189
+ "between backends",
190
+ )
191
+
192
+ self.generic_visit(node)
193
+
194
+
195
+ def run(tree):
196
+ """Return sorted (lineno, col, "CODE message") tuples for a parsed module."""
197
+ visitor = _Visitor()
198
+ visitor.visit(tree)
199
+ return sorted(visitor.errors)
@@ -0,0 +1,137 @@
1
+ """
2
+ Standalone scanner CLI (`dbp-scan`) for reading db-portability findings on a
3
+ real project. Runs the checks registered in db_portability.checks for the
4
+ requested --from/--to pair (postgres -> oracle by default - the only pair
5
+ implemented so far), but prints them grouped by file and colorized instead
6
+ of a flat wall of `path:line:col: code message` lines.
7
+ """
8
+ import argparse
9
+ import ast
10
+ import os
11
+ import sys
12
+
13
+ from db_portability.checks import available_pairs, get_checks
14
+
15
+ DEFAULT_EXCLUDES = {
16
+ ".venv", "venv", ".git", "__pycache__", "node_modules",
17
+ "migrations", ".tox", "build", "dist",
18
+ }
19
+
20
+ COLORS = {
21
+ "error": "\033[31m",
22
+ "warn": "\033[33m",
23
+ "bold": "\033[1m",
24
+ "dim": "\033[2m",
25
+ "green": "\033[32m",
26
+ "reset": "\033[0m",
27
+ }
28
+
29
+
30
+ def _use_color(no_color_flag):
31
+ if no_color_flag or os.environ.get("NO_COLOR") is not None:
32
+ return False
33
+ return sys.stdout.isatty()
34
+
35
+
36
+ def iter_python_files(paths, excludes):
37
+ for path in paths:
38
+ if os.path.isfile(path):
39
+ if path.endswith(".py"):
40
+ yield path
41
+ continue
42
+ for root, dirs, files in os.walk(path):
43
+ dirs[:] = [d for d in dirs if d not in excludes and not d.startswith(".")]
44
+ for name in files:
45
+ if name.endswith(".py"):
46
+ yield os.path.join(root, name)
47
+
48
+
49
+ def scan_file(path, checks_module):
50
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
51
+ source = fh.read()
52
+ try:
53
+ tree = ast.parse(source, filename=path)
54
+ except SyntaxError as exc:
55
+ return None, exc
56
+ return checks_module.run(tree), None
57
+
58
+
59
+ def main(argv=None):
60
+ supported = ", ".join(f"{s} -> {t}" for s, t in available_pairs())
61
+ parser = argparse.ArgumentParser(
62
+ prog="dbp-scan",
63
+ description="Scan a Django project for database code that will break when ported to another backend.",
64
+ epilog=f"Supported --from/--to pairs: {supported}",
65
+ )
66
+ parser.add_argument(
67
+ "paths", nargs="*", default=["."],
68
+ help="Files or directories to scan (default: current directory)",
69
+ )
70
+ parser.add_argument(
71
+ "--from", dest="source", default="postgres", metavar="DB",
72
+ help="Source database currently in use (default: postgres)",
73
+ )
74
+ parser.add_argument(
75
+ "--to", dest="target", default="oracle", metavar="DB",
76
+ help="Target database being ported to (default: oracle)",
77
+ )
78
+ parser.add_argument("--no-color", action="store_true", help="Disable colored output")
79
+ parser.add_argument(
80
+ "--exclude", action="append", default=[],
81
+ help="Additional directory name to skip (repeatable)",
82
+ )
83
+ parser.add_argument("--quiet", action="store_true", help="Only print the summary line")
84
+ args = parser.parse_args(argv)
85
+
86
+ color = _use_color(args.no_color)
87
+
88
+ def c(kind, text):
89
+ return f"{COLORS[kind]}{text}{COLORS['reset']}" if color else text
90
+
91
+ try:
92
+ checks_module = get_checks(args.source, args.target)
93
+ except ValueError as exc:
94
+ print(c("error", str(exc)), file=sys.stderr)
95
+ return 2
96
+
97
+ warn_codes = getattr(checks_module, "WARN_CODES", set())
98
+ excludes = DEFAULT_EXCLUDES | set(args.exclude)
99
+
100
+ files = sorted(iter_python_files(args.paths, excludes))
101
+ total = 0
102
+ counts = {}
103
+ files_with_issues = 0
104
+
105
+ for path in files:
106
+ errors, syntax_err = scan_file(path, checks_module)
107
+ if syntax_err is not None:
108
+ print(c("warn", f"! {path}: could not parse ({syntax_err.msg}, line {syntax_err.lineno})"))
109
+ continue
110
+ if not errors:
111
+ continue
112
+
113
+ files_with_issues += 1
114
+ if not args.quiet:
115
+ print(c("bold", path))
116
+ for lineno, col, message in errors:
117
+ code = message.split(" ", 1)[0]
118
+ counts[code] = counts.get(code, 0) + 1
119
+ total += 1
120
+ if not args.quiet:
121
+ severity = "warn" if code in warn_codes else "error"
122
+ loc = c("dim", f"{lineno}:{col + 1}")
123
+ print(f" {loc} {c(severity, message)}")
124
+ if not args.quiet:
125
+ print()
126
+
127
+ if total == 0:
128
+ print(c("green", f"No {args.source} -> {args.target} portability issues found."))
129
+ return 0
130
+
131
+ summary = ", ".join(f"{code} x{n}" for code, n in sorted(counts.items()))
132
+ print(c("bold", f"{total} issue(s) in {files_with_issues} file(s): ") + summary)
133
+ return 1
134
+
135
+
136
+ if __name__ == "__main__":
137
+ sys.exit(main())
@@ -0,0 +1,38 @@
1
+ """
2
+ Runtime helpers for the NULL / empty-string divergence between PostgreSQL
3
+ and Oracle.
4
+
5
+ Oracle silently coerces an empty string ('') to NULL for VARCHAR2/CLOB
6
+ columns; PostgreSQL stores '' as-is. Django's own convention is "never set
7
+ null=True on CharField/TextField", which is exactly what makes the two
8
+ backends disagree: the same model, same code, same input produces a
9
+ NULL on one database and '' on the other.
10
+
11
+ These fields sidestep the disagreement by normalizing '' -> None in Python,
12
+ before the value ever reaches either database, so both backends end up
13
+ storing (and returning) the same thing. This requires null=True on the
14
+ field - that is intentional, not an oversight.
15
+ """
16
+ from django.db import models
17
+
18
+
19
+ class PortableFieldMixin:
20
+ def get_prep_value(self, value):
21
+ value = super().get_prep_value(value)
22
+ if value == "":
23
+ return None
24
+ return value
25
+
26
+ def to_python(self, value):
27
+ value = super().to_python(value)
28
+ if value == "":
29
+ return None
30
+ return value
31
+
32
+
33
+ class PortableCharField(PortableFieldMixin, models.CharField):
34
+ pass
35
+
36
+
37
+ class PortableTextField(PortableFieldMixin, models.TextField):
38
+ pass
@@ -0,0 +1,21 @@
1
+ """
2
+ flake8 entry point. Always runs the postgres -> oracle checks (see
3
+ db_portability.checks.postgres_oracle): flake8 plugins don't have a natural
4
+ way to expose a --from/--to pair the way the `dbp-scan` CLI does, so this
5
+ runs the one pair most Django/Postgres shops need. Other pairs registered in
6
+ db_portability.checks can still be run through `dbp-scan --from --to`.
7
+ """
8
+ from db_portability.checks.postgres_oracle import run as run_postgres_oracle
9
+
10
+
11
+ class PostgresPortabilityChecker:
12
+ name = "db-portability"
13
+ version = "0.1.0"
14
+
15
+ def __init__(self, tree, filename="(none)"):
16
+ self.tree = tree
17
+ self.filename = filename
18
+
19
+ def run(self):
20
+ for lineno, col, message in run_postgres_oracle(self.tree):
21
+ yield lineno, col, message, type(self)
@@ -0,0 +1,26 @@
1
+ """
2
+ Queryset/manager helper for fields you can't (yet) switch over to
3
+ PortableCharField/PortableTextField, but still need to query consistently
4
+ across PostgreSQL and Oracle.
5
+
6
+ Oracle treats '' and NULL as the same value for VARCHAR2/CLOB columns;
7
+ PostgreSQL does not. `.empty_or_null("field")` builds the OR'd Q object so
8
+ callers don't have to remember to write it out by hand at every call site.
9
+ """
10
+ from django.db import models
11
+ from django.db.models import Q
12
+
13
+
14
+ def empty_or_null_q(field_name):
15
+ return Q(**{f"{field_name}__isnull": True}) | Q(**{field_name: ""})
16
+
17
+
18
+ class PortableQuerySet(models.QuerySet):
19
+ def empty_or_null(self, field_name):
20
+ return self.filter(empty_or_null_q(field_name))
21
+
22
+ def exclude_empty_or_null(self, field_name):
23
+ return self.exclude(empty_or_null_q(field_name))
24
+
25
+
26
+ PortableManager = models.Manager.from_queryset(PortableQuerySet)
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-db-portability
3
+ Version: 0.1.0
4
+ Summary: Catch Django code that breaks when ported to another database (postgres -> oracle today), plus runtime helpers for the NULL/empty-string divergence between backends
5
+ Author-email: Rayan Mahdinejad <rayan.mahdinejad@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rayanmahdinejad/django-db-portability
8
+ Project-URL: Repository, https://github.com/rayanmahdinejad/django-db-portability
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: flake8>=6.0
13
+ Provides-Extra: django
14
+ Requires-Dist: django>=4.2; extra == "django"
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=7.0; extra == "test"
17
+ Requires-Dist: django>=4.2; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # django-db-portability
21
+
22
+ Catches Django code written for one database that will break when ported to
23
+ another, and provides runtime helpers for the one divergence static analysis
24
+ can't catch: Oracle silently treats `''` as `NULL`, PostgreSQL doesn't.
25
+
26
+ This does **not** try to make Django fully database-agnostic — Django's ORM
27
+ already handles the common cases (pagination, joins, sequences). It targets
28
+ the specific, well-documented gaps that leak through: source-DB-only
29
+ `contrib` modules, raw SQL with source-DB-only syntax, and the
30
+ empty-string/NULL trap.
31
+
32
+ Checks are organized by `(source, target)` pair. **Only `postgres -> oracle`
33
+ is implemented today** — see `src/db_portability/checks/`. Adding another
34
+ pair (e.g. `mysql -> oracle`) means writing a sibling module and registering
35
+ it; `dbp-scan`'s `--from`/`--to` picks it up automatically once it exists.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install django-db-portability # lint checks only
41
+ pip install "django-db-portability[django]" # + runtime helpers (fields/managers)
42
+ ```
43
+
44
+ ## 1. Static checks
45
+
46
+ ### flake8 plugin
47
+
48
+ Runs automatically once installed — flake8 picks up plugins from
49
+ `flake8.extension` entry points. Always runs the `postgres -> oracle` checks
50
+ (flake8 plugins have no natural way to expose a `--from`/`--to` pair):
51
+
52
+ ```bash
53
+ flake8 --select=DBP myproject/
54
+ ```
55
+
56
+ | Code | Flags |
57
+ |------|-------|
58
+ | DBP001 | Postgres-only field (`ArrayField`, `HStoreField`, `CITextField`, range fields, ...) |
59
+ | DBP002 | Postgres full-text search (`SearchVector`, `SearchQuery`, `TrigramSimilarity`, ...) |
60
+ | DBP003 | Postgres-only aggregate (`ArrayAgg`, `StringAgg`, `BoolAnd`, ...) |
61
+ | DBP004 | `.extra()` — raw SQL fragment, needs manual review |
62
+ | DBP005 | Raw SQL (`RunSQL`, `cursor.execute`, `.raw()`) containing Postgres-only syntax (`ON CONFLICT`, `RETURNING`, `ILIKE`, `::` casts, ...) |
63
+ | DBP006 | `CharField`/`TextField(unique=True, blank=True)` without `null=True` — the NULL/empty-string trap below |
64
+ | DBP007 | Other Postgres-only `contrib` modules (indexes, constraints, operations) |
65
+
66
+ DBP0xx is reserved for `postgres -> oracle`. A future pair gets its own
67
+ block (DBP1xx, DBP2xx, ...) so codes stay stable as pairs are added.
68
+
69
+ Add to your CI lint step or `setup.cfg`:
70
+
71
+ ```ini
72
+ [flake8]
73
+ select = E,F,DBP
74
+ ```
75
+
76
+ ### `dbp-scan` — readable terminal output
77
+
78
+ `flake8 --select=DBP` prints one flat line per finding, which turns into an
79
+ unreadable wall of text on a real project. `dbp-scan` runs the same checks
80
+ but groups findings by file and colorizes them, and lets you pick the
81
+ `--from`/`--to` pair (`postgres -> oracle` today):
82
+
83
+ ```bash
84
+ dbp-scan myproject/ # postgres -> oracle (default)
85
+ dbp-scan --from postgres --to oracle myproject/
86
+ dbp-scan --quiet myproject/ # summary line only
87
+ dbp-scan --no-color myproject/ > report.txt
88
+ ```
89
+
90
+ It skips `migrations/`, `.venv`, `.git`, `__pycache__`, `node_modules`,
91
+ `.tox`, `build`, and `dist` by default (`--exclude NAME` adds more), and
92
+ exits `1` if any issues were found — same convention as flake8, so it's
93
+ safe to use as a CI gate too. An unregistered pair (e.g. `--from mysql`)
94
+ exits `2` with the list of pairs that are actually implemented.
95
+
96
+ ## 2. The NULL / empty-string trap
97
+
98
+ Oracle coerces `''` to `NULL` for `VARCHAR2`/`CLOB` columns. PostgreSQL does
99
+ not. Django's own convention — "never set `null=True` on `CharField`" — is
100
+ exactly what makes the two backends disagree: identical code, identical
101
+ input, different stored value depending on which `DATABASES` alias the query
102
+ hits. It's data-dependent, so it won't show up as a test failure until you
103
+ have the right data.
104
+
105
+ ### Option A — swap the field type
106
+
107
+ ```python
108
+ from db_portability.fields import PortableCharField
109
+
110
+ class Widget(models.Model):
111
+ code = PortableCharField(max_length=20, null=True, blank=True)
112
+ ```
113
+
114
+ `PortableCharField`/`PortableTextField` normalize `''` → `None` in Python
115
+ before the value reaches *either* database, so both backends store and
116
+ return the same thing. This requires `null=True` — that's intentional.
117
+
118
+ ### Option B — can't change the field? Query both cases explicitly
119
+
120
+ ```python
121
+ from db_portability.managers import empty_or_null_q
122
+
123
+ Widget.objects.filter(empty_or_null_q("code"))
124
+ ```
125
+
126
+ or use the manager mixin:
127
+
128
+ ```python
129
+ from db_portability.managers import PortableManager
130
+
131
+ class Widget(models.Model):
132
+ code = models.CharField(max_length=20, blank=True)
133
+ objects = PortableManager()
134
+
135
+ Widget.objects.empty_or_null("code")
136
+ Widget.objects.exclude_empty_or_null("code")
137
+ ```
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ python -m venv .venv
143
+ .venv\Scripts\pip install -e ".[test]"
144
+ .venv\Scripts\pytest
145
+ ```
@@ -0,0 +1,21 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/db_portability/__init__.py
5
+ src/db_portability/cli.py
6
+ src/db_portability/fields.py
7
+ src/db_portability/lint.py
8
+ src/db_portability/managers.py
9
+ src/db_portability/checks/__init__.py
10
+ src/db_portability/checks/base.py
11
+ src/db_portability/checks/postgres_oracle.py
12
+ src/django_db_portability.egg-info/PKG-INFO
13
+ src/django_db_portability.egg-info/SOURCES.txt
14
+ src/django_db_portability.egg-info/dependency_links.txt
15
+ src/django_db_portability.egg-info/entry_points.txt
16
+ src/django_db_portability.egg-info/requires.txt
17
+ src/django_db_portability.egg-info/top_level.txt
18
+ tests/test_cli.py
19
+ tests/test_fields.py
20
+ tests/test_lint.py
21
+ tests/test_managers.py
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ dbp-scan = db_portability.cli:main
3
+
4
+ [flake8.extension]
5
+ DBP = db_portability.lint:PostgresPortabilityChecker
@@ -0,0 +1,8 @@
1
+ flake8>=6.0
2
+
3
+ [django]
4
+ django>=4.2
5
+
6
+ [test]
7
+ pytest>=7.0
8
+ django>=4.2
@@ -0,0 +1,66 @@
1
+ from db_portability import cli
2
+ from db_portability.checks import postgres_oracle
3
+
4
+
5
+ def test_scan_file_collects_sorted_errors(tmp_path):
6
+ f = tmp_path / "models.py"
7
+ f.write_text(
8
+ "from django.contrib.postgres.fields import ArrayField\n"
9
+ "from django.contrib.postgres.aggregates import ArrayAgg\n"
10
+ )
11
+ errors, syntax_err = cli.scan_file(str(f), postgres_oracle)
12
+ assert syntax_err is None
13
+ codes = [msg.split()[0] for _, _, msg in errors]
14
+ assert codes == ["DBP001", "DBP003"]
15
+
16
+
17
+ def test_scan_file_reports_syntax_error(tmp_path):
18
+ f = tmp_path / "broken.py"
19
+ f.write_text("def broken(:\n")
20
+ errors, syntax_err = cli.scan_file(str(f), postgres_oracle)
21
+ assert errors is None
22
+ assert syntax_err is not None
23
+
24
+
25
+ def test_iter_python_files_skips_excluded_dirs(tmp_path):
26
+ (tmp_path / "migrations").mkdir()
27
+ (tmp_path / "migrations" / "0001_initial.py").write_text("x = 1\n")
28
+ (tmp_path / "models.py").write_text("x = 1\n")
29
+
30
+ found = set(cli.iter_python_files([str(tmp_path)], cli.DEFAULT_EXCLUDES))
31
+ assert str(tmp_path / "models.py") in found
32
+ assert str(tmp_path / "migrations" / "0001_initial.py") not in found
33
+
34
+
35
+ def test_main_returns_1_when_issues_found(tmp_path, capsys):
36
+ f = tmp_path / "models.py"
37
+ f.write_text("from django.contrib.postgres.fields import ArrayField\n")
38
+ exit_code = cli.main(["--no-color", str(f)])
39
+ out = capsys.readouterr().out
40
+ assert exit_code == 1
41
+ assert "DBP001" in out
42
+
43
+
44
+ def test_main_returns_0_when_clean(tmp_path, capsys):
45
+ f = tmp_path / "models.py"
46
+ f.write_text("x = 1\n")
47
+ exit_code = cli.main(["--no-color", str(f)])
48
+ out = capsys.readouterr().out
49
+ assert exit_code == 0
50
+ assert "No postgres -> oracle portability issues found." in out
51
+
52
+
53
+ def test_main_supports_from_to_flags(tmp_path, capsys):
54
+ f = tmp_path / "models.py"
55
+ f.write_text("x = 1\n")
56
+ exit_code = cli.main(["--no-color", "--from", "postgres", "--to", "oracle", str(f)])
57
+ assert exit_code == 0
58
+
59
+
60
+ def test_main_rejects_unregistered_pair(tmp_path, capsys):
61
+ f = tmp_path / "models.py"
62
+ f.write_text("x = 1\n")
63
+ exit_code = cli.main(["--no-color", "--from", "mysql", "--to", "oracle", str(f)])
64
+ err = capsys.readouterr().err
65
+ assert exit_code == 2
66
+ assert "mysql" in err
@@ -0,0 +1,26 @@
1
+ from db_portability.fields import PortableCharField, PortableTextField
2
+
3
+
4
+ def test_char_field_normalizes_empty_string_to_none_on_prep():
5
+ field = PortableCharField(max_length=10, null=True, blank=True)
6
+ assert field.get_prep_value("") is None
7
+
8
+
9
+ def test_char_field_keeps_non_empty_value():
10
+ field = PortableCharField(max_length=10, null=True, blank=True)
11
+ assert field.get_prep_value("hello") == "hello"
12
+
13
+
14
+ def test_char_field_keeps_none():
15
+ field = PortableCharField(max_length=10, null=True, blank=True)
16
+ assert field.get_prep_value(None) is None
17
+
18
+
19
+ def test_char_field_to_python_normalizes_empty_string():
20
+ field = PortableCharField(max_length=10, null=True, blank=True)
21
+ assert field.to_python("") is None
22
+
23
+
24
+ def test_text_field_normalizes_empty_string_to_none_on_prep():
25
+ field = PortableTextField(null=True, blank=True)
26
+ assert field.get_prep_value("") is None
@@ -0,0 +1,76 @@
1
+ import ast
2
+
3
+ from db_portability.lint import PostgresPortabilityChecker
4
+
5
+
6
+ def run(source):
7
+ tree = ast.parse(source)
8
+ checker = PostgresPortabilityChecker(tree, filename="test.py")
9
+ return [(lineno, col, msg) for lineno, col, msg, _ in checker.run()]
10
+
11
+
12
+ def test_flags_postgres_array_field_import():
13
+ errors = run("from django.contrib.postgres.fields import ArrayField\n")
14
+ assert any(msg.startswith("DBP001") for _, _, msg in errors)
15
+
16
+
17
+ def test_flags_postgres_search_import():
18
+ errors = run(
19
+ "from django.contrib.postgres.search import SearchVector, SearchQuery\n"
20
+ )
21
+ codes = [msg.split()[0] for _, _, msg in errors]
22
+ assert codes == ["DBP002", "DBP002"]
23
+
24
+
25
+ def test_flags_postgres_aggregate_import():
26
+ errors = run("from django.contrib.postgres.aggregates import ArrayAgg\n")
27
+ assert any(msg.startswith("DBP003") for _, _, msg in errors)
28
+
29
+
30
+ def test_flags_postgres_index_module():
31
+ errors = run("from django.contrib.postgres.indexes import GinIndex\n")
32
+ assert any(msg.startswith("DBP007") for _, _, msg in errors)
33
+
34
+
35
+ def test_ignores_unrelated_imports():
36
+ errors = run("from django.db import models\n")
37
+ assert errors == []
38
+
39
+
40
+ def test_flags_extra_call():
41
+ errors = run("qs = SomeModel.objects.extra(where=['1=1'])\n")
42
+ assert any(msg.startswith("DBP004") for _, _, msg in errors)
43
+
44
+
45
+ def test_flags_raw_sql_on_conflict():
46
+ errors = run(
47
+ "migrations.RunSQL('INSERT INTO t VALUES (1) ON CONFLICT DO NOTHING')\n"
48
+ )
49
+ assert any(msg.startswith("DBP005") for _, _, msg in errors)
50
+
51
+
52
+ def test_flags_raw_sql_ilike():
53
+ errors = run("cursor.execute(\"SELECT * FROM t WHERE name ILIKE 'a%'\")\n")
54
+ assert any(msg.startswith("DBP005") for _, _, msg in errors)
55
+
56
+
57
+ def test_ignores_plain_raw_sql():
58
+ errors = run("cursor.execute('SELECT * FROM t WHERE id = %s', [1])\n")
59
+ assert errors == []
60
+
61
+
62
+ def test_flags_unique_blank_without_null():
63
+ errors = run("code = models.CharField(max_length=10, unique=True, blank=True)\n")
64
+ assert any(msg.startswith("DBP006") for _, _, msg in errors)
65
+
66
+
67
+ def test_does_not_flag_unique_blank_with_null():
68
+ errors = run(
69
+ "code = models.CharField(max_length=10, unique=True, blank=True, null=True)\n"
70
+ )
71
+ assert errors == []
72
+
73
+
74
+ def test_does_not_flag_unique_without_blank():
75
+ errors = run("code = models.CharField(max_length=10, unique=True)\n")
76
+ assert errors == []
@@ -0,0 +1,9 @@
1
+ from django.db.models import Q
2
+
3
+ from db_portability.managers import empty_or_null_q
4
+
5
+
6
+ def test_empty_or_null_q_builds_expected_conditions():
7
+ q = empty_or_null_q("code")
8
+ expected = Q(code__isnull=True) | Q(code="")
9
+ assert q == expected