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.
- django_db_portability-0.1.0/LICENSE +21 -0
- django_db_portability-0.1.0/PKG-INFO +145 -0
- django_db_portability-0.1.0/README.md +126 -0
- django_db_portability-0.1.0/pyproject.toml +34 -0
- django_db_portability-0.1.0/setup.cfg +4 -0
- django_db_portability-0.1.0/src/db_portability/__init__.py +1 -0
- django_db_portability-0.1.0/src/db_portability/checks/__init__.py +29 -0
- django_db_portability-0.1.0/src/db_portability/checks/base.py +30 -0
- django_db_portability-0.1.0/src/db_portability/checks/postgres_oracle.py +199 -0
- django_db_portability-0.1.0/src/db_portability/cli.py +137 -0
- django_db_portability-0.1.0/src/db_portability/fields.py +38 -0
- django_db_portability-0.1.0/src/db_portability/lint.py +21 -0
- django_db_portability-0.1.0/src/db_portability/managers.py +26 -0
- django_db_portability-0.1.0/src/django_db_portability.egg-info/PKG-INFO +145 -0
- django_db_portability-0.1.0/src/django_db_portability.egg-info/SOURCES.txt +21 -0
- django_db_portability-0.1.0/src/django_db_portability.egg-info/dependency_links.txt +1 -0
- django_db_portability-0.1.0/src/django_db_portability.egg-info/entry_points.txt +5 -0
- django_db_portability-0.1.0/src/django_db_portability.egg-info/requires.txt +8 -0
- django_db_portability-0.1.0/src/django_db_portability.egg-info/top_level.txt +1 -0
- django_db_portability-0.1.0/tests/test_cli.py +66 -0
- django_db_portability-0.1.0/tests/test_fields.py +26 -0
- django_db_portability-0.1.0/tests/test_lint.py +76 -0
- 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 @@
|
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
db_portability
|
|
@@ -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 == []
|