django-db-schema-doc 1.0.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_schema_doc-1.0.0/LICENSE +21 -0
- django_db_schema_doc-1.0.0/PKG-INFO +100 -0
- django_db_schema_doc-1.0.0/README.md +65 -0
- django_db_schema_doc-1.0.0/db_schema_doc/__init__.py +3 -0
- django_db_schema_doc-1.0.0/db_schema_doc/apps.py +7 -0
- django_db_schema_doc-1.0.0/db_schema_doc/collector.py +379 -0
- django_db_schema_doc-1.0.0/db_schema_doc/management/__init__.py +0 -0
- django_db_schema_doc-1.0.0/db_schema_doc/management/commands/__init__.py +0 -0
- django_db_schema_doc-1.0.0/db_schema_doc/management/commands/generate_database_doc.py +133 -0
- django_db_schema_doc-1.0.0/db_schema_doc/py.typed +0 -0
- django_db_schema_doc-1.0.0/db_schema_doc/writer.py +282 -0
- django_db_schema_doc-1.0.0/django_db_schema_doc.egg-info/PKG-INFO +100 -0
- django_db_schema_doc-1.0.0/django_db_schema_doc.egg-info/SOURCES.txt +16 -0
- django_db_schema_doc-1.0.0/django_db_schema_doc.egg-info/dependency_links.txt +1 -0
- django_db_schema_doc-1.0.0/django_db_schema_doc.egg-info/requires.txt +6 -0
- django_db_schema_doc-1.0.0/django_db_schema_doc.egg-info/top_level.txt +1 -0
- django_db_schema_doc-1.0.0/pyproject.toml +54 -0
- django_db_schema_doc-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 django-db-schema-doc contributors
|
|
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,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-db-schema-doc
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Generate DATABASE.md schema documentation for Django projects and LLM agents
|
|
5
|
+
Author: MrHiB
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/behzad-njf/django-db-schema-doc
|
|
8
|
+
Project-URL: Documentation, https://github.com/behzad-njf/django-db-schema-doc#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/behzad-njf/django-db-schema-doc
|
|
10
|
+
Project-URL: Issues, https://github.com/behzad-njf/django-db-schema-doc/issues
|
|
11
|
+
Keywords: django,database,schema,documentation,llm,markdown,introspection
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Database
|
|
25
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: Django>=4.2
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
32
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# django-db-schema-doc
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/django-db-schema-doc/)
|
|
39
|
+
[](https://pypi.org/project/django-db-schema-doc/)
|
|
40
|
+
|
|
41
|
+
Generate **DATABASE.md** — full schema documentation for developers and LLM/AI agents — from any database configured in your Django project's `DATABASES`.
|
|
42
|
+
|
|
43
|
+
Supports **PostgreSQL**, **MySQL/MariaDB**, **Microsoft SQL Server**, **SQLite**, **Oracle**, and other backends Django can introspect.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install django-db-schema-doc
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Setup
|
|
52
|
+
|
|
53
|
+
Add to `INSTALLED_APPS`:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
INSTALLED_APPS = [
|
|
57
|
+
# ...
|
|
58
|
+
"db_schema_doc",
|
|
59
|
+
]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python manage.py generate_database_doc
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Writes `DATABASE.md` in your project `BASE_DIR` by default.
|
|
69
|
+
|
|
70
|
+
### Common options
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python manage.py generate_database_doc -o docs/schema.md
|
|
74
|
+
python manage.py generate_database_doc --with-row-counts
|
|
75
|
+
python manage.py generate_database_doc --database reporting
|
|
76
|
+
python manage.py generate_database_doc --project-hints "See accounts_childuserrelation for parent-child links."
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Run `python manage.py generate_database_doc --help` for all options.
|
|
80
|
+
|
|
81
|
+
## What is generated
|
|
82
|
+
|
|
83
|
+
- Connection metadata (engine, vendor, database — no passwords)
|
|
84
|
+
- Tables grouped by name prefix (`app_model` style)
|
|
85
|
+
- Hub tables (most referenced by foreign keys)
|
|
86
|
+
- Table of contents with anchors
|
|
87
|
+
- Foreign key index
|
|
88
|
+
- Per table: columns, types, PKs, indexes, incoming/outgoing FKs
|
|
89
|
+
|
|
90
|
+
On PostgreSQL, MySQL, and SQL Server, foreign keys include `ON DELETE` / `ON UPDATE` rules when available.
|
|
91
|
+
|
|
92
|
+
## Requirements
|
|
93
|
+
|
|
94
|
+
- Python 3.10+
|
|
95
|
+
- Django 4.2+
|
|
96
|
+
- Your project's database driver (e.g. `psycopg`, `mysqlclient`, `mssql-django`)
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# django-db-schema-doc
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/django-db-schema-doc/)
|
|
4
|
+
[](https://pypi.org/project/django-db-schema-doc/)
|
|
5
|
+
|
|
6
|
+
Generate **DATABASE.md** — full schema documentation for developers and LLM/AI agents — from any database configured in your Django project's `DATABASES`.
|
|
7
|
+
|
|
8
|
+
Supports **PostgreSQL**, **MySQL/MariaDB**, **Microsoft SQL Server**, **SQLite**, **Oracle**, and other backends Django can introspect.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install django-db-schema-doc
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
Add to `INSTALLED_APPS`:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
INSTALLED_APPS = [
|
|
22
|
+
# ...
|
|
23
|
+
"db_schema_doc",
|
|
24
|
+
]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python manage.py generate_database_doc
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Writes `DATABASE.md` in your project `BASE_DIR` by default.
|
|
34
|
+
|
|
35
|
+
### Common options
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python manage.py generate_database_doc -o docs/schema.md
|
|
39
|
+
python manage.py generate_database_doc --with-row-counts
|
|
40
|
+
python manage.py generate_database_doc --database reporting
|
|
41
|
+
python manage.py generate_database_doc --project-hints "See accounts_childuserrelation for parent-child links."
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run `python manage.py generate_database_doc --help` for all options.
|
|
45
|
+
|
|
46
|
+
## What is generated
|
|
47
|
+
|
|
48
|
+
- Connection metadata (engine, vendor, database — no passwords)
|
|
49
|
+
- Tables grouped by name prefix (`app_model` style)
|
|
50
|
+
- Hub tables (most referenced by foreign keys)
|
|
51
|
+
- Table of contents with anchors
|
|
52
|
+
- Foreign key index
|
|
53
|
+
- Per table: columns, types, PKs, indexes, incoming/outgoing FKs
|
|
54
|
+
|
|
55
|
+
On PostgreSQL, MySQL, and SQL Server, foreign keys include `ON DELETE` / `ON UPDATE` rules when available.
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
- Python 3.10+
|
|
60
|
+
- Django 4.2+
|
|
61
|
+
- Your project's database driver (e.g. `psycopg`, `mysqlclient`, `mssql-django`)
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collect database schema metadata using Django's introspection API.
|
|
3
|
+
|
|
4
|
+
Works with any database backend Django supports (PostgreSQL, MySQL/MariaDB,
|
|
5
|
+
Microsoft SQL Server, Oracle, SQLite, etc.). Optional INFORMATION_SCHEMA
|
|
6
|
+
queries enrich foreign-key ON DELETE / ON UPDATE rules when available.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from django.db import connections
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ColumnInfo:
|
|
20
|
+
name: str
|
|
21
|
+
type_display: str
|
|
22
|
+
nullable: bool
|
|
23
|
+
default: str | None
|
|
24
|
+
ordinal: int
|
|
25
|
+
is_primary_key: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ForeignKeyInfo:
|
|
30
|
+
from_table: str
|
|
31
|
+
from_column: str
|
|
32
|
+
to_table: str
|
|
33
|
+
to_column: str
|
|
34
|
+
constraint_name: str = ""
|
|
35
|
+
on_delete: str = ""
|
|
36
|
+
on_update: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class IndexInfo:
|
|
41
|
+
name: str
|
|
42
|
+
columns: list[str]
|
|
43
|
+
unique: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class TableInfo:
|
|
48
|
+
name: str
|
|
49
|
+
schema: str = "dbo"
|
|
50
|
+
columns: list[ColumnInfo] = field(default_factory=list)
|
|
51
|
+
primary_key: list[str] = field(default_factory=list)
|
|
52
|
+
outgoing_fks: list[ForeignKeyInfo] = field(default_factory=list)
|
|
53
|
+
incoming_fks: list[ForeignKeyInfo] = field(default_factory=list)
|
|
54
|
+
indexes: list[IndexInfo] = field(default_factory=list)
|
|
55
|
+
row_count: int | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class DatabaseSchema:
|
|
60
|
+
vendor: str
|
|
61
|
+
database_name: str
|
|
62
|
+
host: str
|
|
63
|
+
port: str
|
|
64
|
+
engine: str
|
|
65
|
+
tables: list[TableInfo] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def fk_count(self) -> int:
|
|
69
|
+
return sum(len(t.outgoing_fks) for t in self.tables)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def column_count(self) -> int:
|
|
73
|
+
return sum(len(t.columns) for t in self.tables)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SchemaCollector:
|
|
77
|
+
"""Introspect a Django database connection and build a DatabaseSchema."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, database: str = "default", include_views: bool = False):
|
|
80
|
+
self.database = database
|
|
81
|
+
self.include_views = include_views
|
|
82
|
+
self.connection = connections[database]
|
|
83
|
+
|
|
84
|
+
def collect(self, row_counts: bool = False) -> DatabaseSchema:
|
|
85
|
+
introspection = self.connection.introspection
|
|
86
|
+
settings_dict = self.connection.settings_dict
|
|
87
|
+
|
|
88
|
+
with self.connection.cursor() as cursor:
|
|
89
|
+
db_name = self._database_name(cursor)
|
|
90
|
+
table_names = introspection.table_names(
|
|
91
|
+
cursor, include_views=self.include_views
|
|
92
|
+
)
|
|
93
|
+
table_names = sorted(set(table_names))
|
|
94
|
+
|
|
95
|
+
fk_rules = self._fetch_fk_rules(cursor)
|
|
96
|
+
tables: list[TableInfo] = []
|
|
97
|
+
|
|
98
|
+
for table_name in table_names:
|
|
99
|
+
table = self._collect_table(
|
|
100
|
+
cursor, introspection, table_name, fk_rules
|
|
101
|
+
)
|
|
102
|
+
if row_counts:
|
|
103
|
+
table.row_count = self._row_count(cursor, table_name)
|
|
104
|
+
tables.append(table)
|
|
105
|
+
|
|
106
|
+
incoming = self._build_incoming_fks(tables)
|
|
107
|
+
for table in tables:
|
|
108
|
+
table.incoming_fks = incoming.get(table.name, [])
|
|
109
|
+
|
|
110
|
+
return DatabaseSchema(
|
|
111
|
+
vendor=self.connection.vendor,
|
|
112
|
+
database_name=db_name,
|
|
113
|
+
host=str(settings_dict.get("HOST", "")),
|
|
114
|
+
port=str(settings_dict.get("PORT", "")),
|
|
115
|
+
engine=str(settings_dict.get("ENGINE", "")),
|
|
116
|
+
tables=tables,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _database_name(self, cursor) -> str:
|
|
120
|
+
try:
|
|
121
|
+
if self.connection.vendor == "postgresql":
|
|
122
|
+
cursor.execute("SELECT current_database()")
|
|
123
|
+
elif self.connection.vendor == "mysql":
|
|
124
|
+
cursor.execute("SELECT DATABASE()")
|
|
125
|
+
elif self.connection.vendor == "microsoft":
|
|
126
|
+
cursor.execute("SELECT DB_NAME()")
|
|
127
|
+
elif self.connection.vendor == "sqlite":
|
|
128
|
+
return str(self.connection.settings_dict.get("NAME", "sqlite"))
|
|
129
|
+
else:
|
|
130
|
+
cursor.execute("SELECT 1")
|
|
131
|
+
return str(self.connection.settings_dict.get("NAME", ""))
|
|
132
|
+
return str(cursor.fetchone()[0])
|
|
133
|
+
except Exception:
|
|
134
|
+
return str(self.connection.settings_dict.get("NAME", "unknown"))
|
|
135
|
+
|
|
136
|
+
def _collect_table(
|
|
137
|
+
self, cursor, introspection, table_name: str, fk_rules: dict
|
|
138
|
+
) -> TableInfo:
|
|
139
|
+
pk_columns = list(introspection.get_primary_key_columns(cursor, table_name))
|
|
140
|
+
descriptions = introspection.get_table_description(cursor, table_name)
|
|
141
|
+
|
|
142
|
+
columns: list[ColumnInfo] = []
|
|
143
|
+
for ordinal, col in enumerate(descriptions, start=1):
|
|
144
|
+
type_display = introspection.get_field_type(col.type_code, col)
|
|
145
|
+
columns.append(
|
|
146
|
+
ColumnInfo(
|
|
147
|
+
name=col.name,
|
|
148
|
+
type_display=type_display,
|
|
149
|
+
nullable=bool(col.null_ok),
|
|
150
|
+
default=self._format_default(col.default),
|
|
151
|
+
ordinal=ordinal,
|
|
152
|
+
is_primary_key=col.name in pk_columns,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
outgoing = self._collect_outgoing_fks(
|
|
157
|
+
cursor, introspection, table_name, fk_rules
|
|
158
|
+
)
|
|
159
|
+
indexes = self._collect_indexes(introspection, cursor, table_name, pk_columns)
|
|
160
|
+
|
|
161
|
+
return TableInfo(
|
|
162
|
+
name=table_name,
|
|
163
|
+
columns=columns,
|
|
164
|
+
primary_key=pk_columns,
|
|
165
|
+
outgoing_fks=outgoing,
|
|
166
|
+
indexes=indexes,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _collect_outgoing_fks(
|
|
170
|
+
self, cursor, introspection, table_name: str, fk_rules: dict
|
|
171
|
+
) -> list[ForeignKeyInfo]:
|
|
172
|
+
fks: list[ForeignKeyInfo] = []
|
|
173
|
+
seen: set[tuple[str, str, str, str]] = set()
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
key_columns = introspection.get_key_columns(cursor, table_name)
|
|
177
|
+
except NotImplementedError:
|
|
178
|
+
key_columns = []
|
|
179
|
+
|
|
180
|
+
for from_col, to_table, to_col in key_columns:
|
|
181
|
+
key = (table_name, from_col, to_table, to_col)
|
|
182
|
+
if key in seen:
|
|
183
|
+
continue
|
|
184
|
+
seen.add(key)
|
|
185
|
+
rules = fk_rules.get((table_name, from_col), {})
|
|
186
|
+
fks.append(
|
|
187
|
+
ForeignKeyInfo(
|
|
188
|
+
from_table=table_name,
|
|
189
|
+
from_column=from_col,
|
|
190
|
+
to_table=to_table,
|
|
191
|
+
to_column=to_col,
|
|
192
|
+
constraint_name=rules.get("constraint_name", ""),
|
|
193
|
+
on_delete=rules.get("on_delete", ""),
|
|
194
|
+
on_update=rules.get("on_update", ""),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Fallback: constraints API (some backends only expose FKs here)
|
|
199
|
+
try:
|
|
200
|
+
constraints = introspection.get_constraints(cursor, table_name)
|
|
201
|
+
except NotImplementedError:
|
|
202
|
+
constraints = {}
|
|
203
|
+
|
|
204
|
+
for cname, info in constraints.items():
|
|
205
|
+
fk = info.get("foreign_key")
|
|
206
|
+
if not fk:
|
|
207
|
+
continue
|
|
208
|
+
to_table, to_col = fk
|
|
209
|
+
for from_col in info.get("columns", []):
|
|
210
|
+
key = (table_name, from_col, to_table, to_col)
|
|
211
|
+
if key in seen:
|
|
212
|
+
continue
|
|
213
|
+
seen.add(key)
|
|
214
|
+
rules = fk_rules.get((table_name, from_col), {})
|
|
215
|
+
fks.append(
|
|
216
|
+
ForeignKeyInfo(
|
|
217
|
+
from_table=table_name,
|
|
218
|
+
from_column=from_col,
|
|
219
|
+
to_table=to_table,
|
|
220
|
+
to_column=to_col,
|
|
221
|
+
constraint_name=cname,
|
|
222
|
+
on_delete=rules.get("on_delete", ""),
|
|
223
|
+
on_update=rules.get("on_update", ""),
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return sorted(fks, key=lambda f: (f.from_column, f.to_table))
|
|
228
|
+
|
|
229
|
+
def _collect_indexes(
|
|
230
|
+
self, introspection, cursor, table_name: str, pk_columns: list[str]
|
|
231
|
+
) -> list[IndexInfo]:
|
|
232
|
+
indexes: list[IndexInfo] = []
|
|
233
|
+
try:
|
|
234
|
+
constraints = introspection.get_constraints(cursor, table_name)
|
|
235
|
+
except NotImplementedError:
|
|
236
|
+
return indexes
|
|
237
|
+
|
|
238
|
+
for name, info in constraints.items():
|
|
239
|
+
if not info.get("index"):
|
|
240
|
+
continue
|
|
241
|
+
if info.get("primary_key"):
|
|
242
|
+
continue
|
|
243
|
+
cols = info.get("columns") or []
|
|
244
|
+
if not cols:
|
|
245
|
+
continue
|
|
246
|
+
indexes.append(
|
|
247
|
+
IndexInfo(
|
|
248
|
+
name=name,
|
|
249
|
+
columns=list(cols),
|
|
250
|
+
unique=bool(info.get("unique")),
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
return sorted(indexes, key=lambda i: i.name)
|
|
254
|
+
|
|
255
|
+
def _fetch_fk_rules(self, cursor) -> dict[tuple[str, str], dict[str, str]]:
|
|
256
|
+
"""Optional FK ON DELETE/UPDATE from INFORMATION_SCHEMA (where supported)."""
|
|
257
|
+
vendor = self.connection.vendor
|
|
258
|
+
if vendor == "microsoft":
|
|
259
|
+
return self._fk_rules_mssql(cursor)
|
|
260
|
+
if vendor == "postgresql":
|
|
261
|
+
return self._fk_rules_postgresql(cursor)
|
|
262
|
+
if vendor == "mysql":
|
|
263
|
+
return self._fk_rules_mysql(cursor)
|
|
264
|
+
return {}
|
|
265
|
+
|
|
266
|
+
def _fk_rules_mssql(self, cursor) -> dict[tuple[str, str], dict[str, str]]:
|
|
267
|
+
sql = """
|
|
268
|
+
SELECT
|
|
269
|
+
fk.TABLE_NAME,
|
|
270
|
+
fk.COLUMN_NAME,
|
|
271
|
+
rc.CONSTRAINT_NAME,
|
|
272
|
+
rc.DELETE_RULE,
|
|
273
|
+
rc.UPDATE_RULE
|
|
274
|
+
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
|
|
275
|
+
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE fk
|
|
276
|
+
ON rc.CONSTRAINT_NAME = fk.CONSTRAINT_NAME
|
|
277
|
+
AND rc.CONSTRAINT_SCHEMA = fk.CONSTRAINT_SCHEMA
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
cursor.execute(sql)
|
|
281
|
+
rows = cursor.fetchall()
|
|
282
|
+
except Exception:
|
|
283
|
+
return {}
|
|
284
|
+
result = {}
|
|
285
|
+
for table, column, cname, on_delete, on_update in rows:
|
|
286
|
+
result[(table, column)] = {
|
|
287
|
+
"constraint_name": cname,
|
|
288
|
+
"on_delete": on_delete or "",
|
|
289
|
+
"on_update": on_update or "",
|
|
290
|
+
}
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
def _fk_rules_postgresql(self, cursor) -> dict[tuple[str, str], dict[str, str]]:
|
|
294
|
+
sql = """
|
|
295
|
+
SELECT
|
|
296
|
+
tc.table_name,
|
|
297
|
+
kcu.column_name,
|
|
298
|
+
tc.constraint_name,
|
|
299
|
+
rc.delete_rule,
|
|
300
|
+
rc.update_rule
|
|
301
|
+
FROM information_schema.table_constraints tc
|
|
302
|
+
JOIN information_schema.key_column_usage kcu
|
|
303
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
304
|
+
AND tc.table_schema = kcu.table_schema
|
|
305
|
+
JOIN information_schema.referential_constraints rc
|
|
306
|
+
ON tc.constraint_name = rc.constraint_name
|
|
307
|
+
AND tc.table_schema = rc.constraint_schema
|
|
308
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
cursor.execute(sql)
|
|
312
|
+
rows = cursor.fetchall()
|
|
313
|
+
except Exception:
|
|
314
|
+
return {}
|
|
315
|
+
result = {}
|
|
316
|
+
for table, column, cname, on_delete, on_update in rows:
|
|
317
|
+
result[(table, column)] = {
|
|
318
|
+
"constraint_name": cname,
|
|
319
|
+
"on_delete": on_delete or "",
|
|
320
|
+
"on_update": on_update or "",
|
|
321
|
+
}
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
def _fk_rules_mysql(self, cursor) -> dict[tuple[str, str], dict[str, str]]:
|
|
325
|
+
sql = """
|
|
326
|
+
SELECT
|
|
327
|
+
kcu.TABLE_NAME,
|
|
328
|
+
kcu.COLUMN_NAME,
|
|
329
|
+
kcu.CONSTRAINT_NAME,
|
|
330
|
+
rc.DELETE_RULE,
|
|
331
|
+
rc.UPDATE_RULE
|
|
332
|
+
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
|
333
|
+
JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
|
|
334
|
+
ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
|
|
335
|
+
AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
|
|
336
|
+
WHERE kcu.REFERENCED_TABLE_NAME IS NOT NULL
|
|
337
|
+
AND kcu.TABLE_SCHEMA = DATABASE()
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
cursor.execute(sql)
|
|
341
|
+
rows = cursor.fetchall()
|
|
342
|
+
except Exception:
|
|
343
|
+
return {}
|
|
344
|
+
result = {}
|
|
345
|
+
for table, column, cname, on_delete, on_update in rows:
|
|
346
|
+
result[(table, column)] = {
|
|
347
|
+
"constraint_name": cname,
|
|
348
|
+
"on_delete": on_delete or "",
|
|
349
|
+
"on_update": on_update or "",
|
|
350
|
+
}
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
def _build_incoming_fks(
|
|
354
|
+
self, tables: list[TableInfo]
|
|
355
|
+
) -> dict[str, list[ForeignKeyInfo]]:
|
|
356
|
+
incoming: dict[str, list[ForeignKeyInfo]] = defaultdict(list)
|
|
357
|
+
for table in tables:
|
|
358
|
+
for fk in table.outgoing_fks:
|
|
359
|
+
incoming[fk.to_table].append(fk)
|
|
360
|
+
for refs in incoming.values():
|
|
361
|
+
refs.sort(key=lambda f: (f.from_table, f.from_column))
|
|
362
|
+
return dict(incoming)
|
|
363
|
+
|
|
364
|
+
def _row_count(self, cursor, table_name: str) -> int | None:
|
|
365
|
+
qn = self.connection.ops.quote_name(table_name)
|
|
366
|
+
try:
|
|
367
|
+
cursor.execute(f"SELECT COUNT(*) FROM {qn}")
|
|
368
|
+
return int(cursor.fetchone()[0])
|
|
369
|
+
except Exception:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _format_default(value: Any) -> str | None:
|
|
374
|
+
if value is None:
|
|
375
|
+
return None
|
|
376
|
+
text = str(value).strip()
|
|
377
|
+
if len(text) > 80:
|
|
378
|
+
return text[:77] + "..."
|
|
379
|
+
return text
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Management command: generate DATABASE.md (or custom path) from the configured database.
|
|
3
|
+
|
|
4
|
+
Portable across Django projects — add ``db_schema_doc`` to INSTALLED_APPS (or copy
|
|
5
|
+
this package into your project) and run::
|
|
6
|
+
|
|
7
|
+
python manage.py generate_database_doc
|
|
8
|
+
|
|
9
|
+
See db_schema_doc/README.md for setup in other projects.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from django.conf import settings
|
|
17
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
18
|
+
from django.db import connections
|
|
19
|
+
|
|
20
|
+
from db_schema_doc.collector import SchemaCollector
|
|
21
|
+
from db_schema_doc.writer import MarkdownWriter
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Command(BaseCommand):
|
|
25
|
+
help = (
|
|
26
|
+
"Generate Markdown database schema documentation (DATABASE.md) "
|
|
27
|
+
"from the configured Django database connection."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def add_arguments(self, parser):
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"-o",
|
|
33
|
+
"--output",
|
|
34
|
+
default="DATABASE.md",
|
|
35
|
+
help="Output Markdown file path (default: DATABASE.md in project root).",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--database",
|
|
39
|
+
default="default",
|
|
40
|
+
help="DATABASES alias to introspect (default: default).",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--title",
|
|
44
|
+
default="",
|
|
45
|
+
help="Document title (default: '<db_name> Database Schema Reference').",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--include-views",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="Include database views in the documentation.",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--with-row-counts",
|
|
54
|
+
action="store_true",
|
|
55
|
+
help="Run COUNT(*) per table (slow on large databases).",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--hub-limit",
|
|
59
|
+
type=int,
|
|
60
|
+
default=25,
|
|
61
|
+
help="Number of hub tables to list in the overview (default: 25).",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--no-fk-index",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="Omit the foreign-key relationship index section.",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--no-hub-section",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="Omit the hub-tables overview section.",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--project-hints",
|
|
75
|
+
default="",
|
|
76
|
+
help="Optional Markdown paragraph with project-specific notes for agents.",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--stdout",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="Print Markdown to stdout instead of writing a file.",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def handle(self, *args, **options):
|
|
85
|
+
database = options["database"]
|
|
86
|
+
if database not in connections.databases:
|
|
87
|
+
raise CommandError(
|
|
88
|
+
f"Unknown database alias {database!r}. "
|
|
89
|
+
f"Available: {', '.join(sorted(connections.databases))}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
self.stdout.write(f"Introspecting database {database!r}...")
|
|
93
|
+
collector = SchemaCollector(
|
|
94
|
+
database=database,
|
|
95
|
+
include_views=options["include_views"],
|
|
96
|
+
)
|
|
97
|
+
schema = collector.collect(row_counts=options["with_row_counts"])
|
|
98
|
+
|
|
99
|
+
title = options["title"] or None
|
|
100
|
+
hints = options["project_hints"] or None
|
|
101
|
+
writer = MarkdownWriter(
|
|
102
|
+
schema,
|
|
103
|
+
title=title,
|
|
104
|
+
include_fk_index=not options["no_fk_index"],
|
|
105
|
+
include_hub_section=not options["no_hub_section"],
|
|
106
|
+
hub_limit=options["hub_limit"],
|
|
107
|
+
project_hints=hints,
|
|
108
|
+
)
|
|
109
|
+
markdown = writer.write()
|
|
110
|
+
|
|
111
|
+
if options["stdout"]:
|
|
112
|
+
self.stdout.write(markdown)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
output_path = self._resolve_output_path(options["output"])
|
|
116
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
output_path.write_text(markdown, encoding="utf-8")
|
|
118
|
+
|
|
119
|
+
size_kb = output_path.stat().st_size / 1024
|
|
120
|
+
self.stdout.write(
|
|
121
|
+
self.style.SUCCESS(
|
|
122
|
+
f"Wrote {output_path} "
|
|
123
|
+
f"({len(schema.tables)} tables, {schema.column_count} columns, "
|
|
124
|
+
f"{schema.fk_count} FKs, {size_kb:.1f} KB)"
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _resolve_output_path(self, output: str) -> Path:
|
|
129
|
+
path = Path(output)
|
|
130
|
+
if path.is_absolute():
|
|
131
|
+
return path
|
|
132
|
+
base = Path(getattr(settings, "BASE_DIR", Path.cwd()))
|
|
133
|
+
return base / path
|
|
File without changes
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Render DatabaseSchema as Markdown for LLM / human consumption.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
from .collector import DatabaseSchema, ForeignKeyInfo, TableInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Known framework table prefixes (any Django project)
|
|
14
|
+
FRAMEWORK_PREFIXES = (
|
|
15
|
+
"django_",
|
|
16
|
+
"auth_",
|
|
17
|
+
"django_celery_beat_",
|
|
18
|
+
"jet_",
|
|
19
|
+
"south_",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def infer_domain(table_name: str) -> str:
|
|
24
|
+
"""Group tables by first underscore segment, with framework heuristics."""
|
|
25
|
+
for prefix in FRAMEWORK_PREFIXES:
|
|
26
|
+
if table_name.startswith(prefix):
|
|
27
|
+
return "django_system"
|
|
28
|
+
if "_" in table_name:
|
|
29
|
+
return table_name.split("_", 1)[0]
|
|
30
|
+
return "other"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def hub_tables(schema: DatabaseSchema, limit: int = 25) -> list[tuple[str, int, int | None]]:
|
|
34
|
+
"""Tables most often referenced by FKs (incoming), with optional row counts."""
|
|
35
|
+
in_degree: dict[str, int] = defaultdict(int)
|
|
36
|
+
row_by_name = {t.name: t.row_count for t in schema.tables}
|
|
37
|
+
for table in schema.tables:
|
|
38
|
+
for fk in table.outgoing_fks:
|
|
39
|
+
in_degree[fk.to_table] += 1
|
|
40
|
+
ranked = sorted(in_degree.items(), key=lambda x: (-x[1], x[0]))[:limit]
|
|
41
|
+
return [(name, deg, row_by_name.get(name)) for name, deg in ranked]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MarkdownWriter:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
schema: DatabaseSchema,
|
|
48
|
+
*,
|
|
49
|
+
title: str | None = None,
|
|
50
|
+
include_fk_index: bool = True,
|
|
51
|
+
include_hub_section: bool = True,
|
|
52
|
+
hub_limit: int = 25,
|
|
53
|
+
project_hints: str | None = None,
|
|
54
|
+
):
|
|
55
|
+
self.schema = schema
|
|
56
|
+
self.title = title or f"{schema.database_name} Database Schema Reference"
|
|
57
|
+
self.include_fk_index = include_fk_index
|
|
58
|
+
self.include_hub_section = include_hub_section
|
|
59
|
+
self.hub_limit = hub_limit
|
|
60
|
+
self.project_hints = project_hints
|
|
61
|
+
|
|
62
|
+
def write(self) -> str:
|
|
63
|
+
lines: list[str] = []
|
|
64
|
+
w = lines.append
|
|
65
|
+
|
|
66
|
+
w(f"# {self.title}")
|
|
67
|
+
w("")
|
|
68
|
+
w(
|
|
69
|
+
f"> Auto-generated by `python manage.py generate_database_doc` "
|
|
70
|
+
f"| {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}"
|
|
71
|
+
)
|
|
72
|
+
w(
|
|
73
|
+
f"> Tables: {len(self.schema.tables)} | "
|
|
74
|
+
f"Columns: {self.schema.column_count} | "
|
|
75
|
+
f"Foreign keys: {self.schema.fk_count}"
|
|
76
|
+
)
|
|
77
|
+
w("")
|
|
78
|
+
w("---")
|
|
79
|
+
w("")
|
|
80
|
+
self._section_purpose(w)
|
|
81
|
+
self._section_connection(w)
|
|
82
|
+
if self.project_hints:
|
|
83
|
+
w("### Project notes")
|
|
84
|
+
w("")
|
|
85
|
+
w(self.project_hints)
|
|
86
|
+
w("")
|
|
87
|
+
w("---")
|
|
88
|
+
w("")
|
|
89
|
+
self._section_domains(w)
|
|
90
|
+
if self.include_hub_section:
|
|
91
|
+
self._section_hubs(w)
|
|
92
|
+
self._section_toc(w)
|
|
93
|
+
if self.include_fk_index:
|
|
94
|
+
self._section_fk_index(w)
|
|
95
|
+
self._section_tables(w)
|
|
96
|
+
|
|
97
|
+
return "\n".join(lines) + "\n"
|
|
98
|
+
|
|
99
|
+
def _section_purpose(self, w) -> None:
|
|
100
|
+
w("## 1. Purpose and how to use this document")
|
|
101
|
+
w("")
|
|
102
|
+
w("This file describes the **live database schema** behind your Django project's")
|
|
103
|
+
w("`DATABASES` configuration. Use it to:")
|
|
104
|
+
w("")
|
|
105
|
+
w("- Write correct SQL with real table and column names")
|
|
106
|
+
w("- Understand relationships via foreign keys")
|
|
107
|
+
w("- Navigate tables grouped by name prefix / domain")
|
|
108
|
+
w("")
|
|
109
|
+
w("**Conventions:**")
|
|
110
|
+
w("")
|
|
111
|
+
w("- Table names match the database (often `app_label_modelname` when using Django migrations).")
|
|
112
|
+
w("- Column types are shown as Django field class names from introspection (e.g. `CharField`, `BigAutoField`).")
|
|
113
|
+
w("- 🔑 marks primary key columns.")
|
|
114
|
+
w("")
|
|
115
|
+
w("---")
|
|
116
|
+
w("")
|
|
117
|
+
|
|
118
|
+
def _section_connection(self, w) -> None:
|
|
119
|
+
w("## 2. Connection metadata")
|
|
120
|
+
w("")
|
|
121
|
+
w("| Setting | Value |")
|
|
122
|
+
w("|--------|-------|")
|
|
123
|
+
w(f"| Engine | `{self.schema.engine}` |")
|
|
124
|
+
w(f"| Vendor | `{self.schema.vendor}` |")
|
|
125
|
+
w(f"| Database | `{self.schema.database_name}` |")
|
|
126
|
+
w(f"| Host | `{self.schema.host}` |")
|
|
127
|
+
w(f"| Port | `{self.schema.port}` |")
|
|
128
|
+
w("")
|
|
129
|
+
w("Credentials come from environment variables / settings — never commit passwords.")
|
|
130
|
+
w("")
|
|
131
|
+
w("---")
|
|
132
|
+
w("")
|
|
133
|
+
|
|
134
|
+
def _section_domains(self, w) -> None:
|
|
135
|
+
w("## 3. Domain overview")
|
|
136
|
+
w("")
|
|
137
|
+
domains: dict[str, list[TableInfo]] = defaultdict(list)
|
|
138
|
+
for table in self.schema.tables:
|
|
139
|
+
domains[infer_domain(table.name)].append(table)
|
|
140
|
+
|
|
141
|
+
w("| Domain | Tables |")
|
|
142
|
+
w("|--------|--------|")
|
|
143
|
+
for dom in sorted(domains.keys(), key=lambda x: (-len(domains[x]), x)):
|
|
144
|
+
w(f"| `{dom}` | {len(domains[dom])} |")
|
|
145
|
+
w("")
|
|
146
|
+
w("---")
|
|
147
|
+
w("")
|
|
148
|
+
|
|
149
|
+
def _section_hubs(self, w) -> None:
|
|
150
|
+
w("## 4. Hub tables (most referenced by foreign keys)")
|
|
151
|
+
w("")
|
|
152
|
+
hubs = hub_tables(self.schema, self.hub_limit)
|
|
153
|
+
if not hubs:
|
|
154
|
+
w("*(No foreign keys detected.)*")
|
|
155
|
+
w("")
|
|
156
|
+
w("---")
|
|
157
|
+
w("")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
w("| Table | Referenced by | Rows |")
|
|
161
|
+
w("|-------|---------------|------|")
|
|
162
|
+
for name, deg, rows in hubs:
|
|
163
|
+
row_s = str(rows) if rows is not None else "—"
|
|
164
|
+
w(f"| `{name}` | {deg} | {row_s} |")
|
|
165
|
+
w("")
|
|
166
|
+
w("---")
|
|
167
|
+
w("")
|
|
168
|
+
|
|
169
|
+
def _section_toc(self, w) -> None:
|
|
170
|
+
section = "5" if self.include_hub_section else "4"
|
|
171
|
+
w(f"## {section}. Table of contents (by domain)")
|
|
172
|
+
w("")
|
|
173
|
+
domains: dict[str, list[TableInfo]] = defaultdict(list)
|
|
174
|
+
for table in self.schema.tables:
|
|
175
|
+
domains[infer_domain(table.name)].append(table)
|
|
176
|
+
|
|
177
|
+
for dom in sorted(domains.keys(), key=lambda x: (-len(domains[x]), x)):
|
|
178
|
+
w(f"### {dom}")
|
|
179
|
+
for table in sorted(domains[dom], key=lambda t: t.name):
|
|
180
|
+
anchor = table.name.lower()
|
|
181
|
+
w(f"- [{table.name}](#table-{anchor})")
|
|
182
|
+
w("")
|
|
183
|
+
w("---")
|
|
184
|
+
w("")
|
|
185
|
+
|
|
186
|
+
def _section_fk_index(self, w) -> None:
|
|
187
|
+
section = "6" if self.include_hub_section else "5"
|
|
188
|
+
w(f"## {section}. Foreign key relationship index")
|
|
189
|
+
w("")
|
|
190
|
+
w("Outgoing foreign keys per table (`child.column` → `parent.column`).")
|
|
191
|
+
w("")
|
|
192
|
+
for table in self.schema.tables:
|
|
193
|
+
if not table.outgoing_fks:
|
|
194
|
+
continue
|
|
195
|
+
w(f"### `{table.name}`")
|
|
196
|
+
for fk in table.outgoing_fks:
|
|
197
|
+
w(self._fk_line(fk))
|
|
198
|
+
w("")
|
|
199
|
+
w("---")
|
|
200
|
+
w("")
|
|
201
|
+
|
|
202
|
+
def _section_tables(self, w) -> None:
|
|
203
|
+
base = 7 if self.include_hub_section else 6
|
|
204
|
+
if not self.include_fk_index:
|
|
205
|
+
base -= 1
|
|
206
|
+
w(f"## {base}. Tables — columns, keys, indexes")
|
|
207
|
+
w("")
|
|
208
|
+
|
|
209
|
+
for table in sorted(self.schema.tables, key=lambda t: t.name):
|
|
210
|
+
self._write_table(w, table)
|
|
211
|
+
w("---")
|
|
212
|
+
w("")
|
|
213
|
+
|
|
214
|
+
def _write_table(self, w, table: TableInfo) -> None:
|
|
215
|
+
anchor = table.name.lower()
|
|
216
|
+
dom = infer_domain(table.name)
|
|
217
|
+
|
|
218
|
+
w(f'<a id="table-{anchor}"></a>')
|
|
219
|
+
w(f"### `{table.name}`")
|
|
220
|
+
w("")
|
|
221
|
+
w(f"- **Domain:** `{dom}`")
|
|
222
|
+
if table.primary_key:
|
|
223
|
+
w(f"- **Primary key:** `{', '.join(table.primary_key)}`")
|
|
224
|
+
else:
|
|
225
|
+
w("- **Primary key:** *(none)*")
|
|
226
|
+
if table.row_count is not None:
|
|
227
|
+
w(f"- **Row count:** {table.row_count}")
|
|
228
|
+
w("")
|
|
229
|
+
|
|
230
|
+
if table.outgoing_fks:
|
|
231
|
+
w("**References (outgoing):**")
|
|
232
|
+
w("")
|
|
233
|
+
for fk in table.outgoing_fks:
|
|
234
|
+
w(self._fk_line(fk, prefix="- "))
|
|
235
|
+
w("")
|
|
236
|
+
|
|
237
|
+
if table.incoming_fks:
|
|
238
|
+
w("**Referenced by (incoming):**")
|
|
239
|
+
w("")
|
|
240
|
+
for fk in table.incoming_fks:
|
|
241
|
+
w(
|
|
242
|
+
f"- `{fk.from_table}.{fk.from_column}` → "
|
|
243
|
+
f"`{table.name}.{fk.to_column}`"
|
|
244
|
+
)
|
|
245
|
+
w("")
|
|
246
|
+
|
|
247
|
+
w("**Columns:**")
|
|
248
|
+
w("")
|
|
249
|
+
w("| # | Column | Type | Nullable | Default |")
|
|
250
|
+
w("|---|--------|------|----------|---------|")
|
|
251
|
+
for col in table.columns:
|
|
252
|
+
pk = " 🔑" if col.is_primary_key else ""
|
|
253
|
+
null = "YES" if col.nullable else "NO"
|
|
254
|
+
default = col.default or ""
|
|
255
|
+
w(
|
|
256
|
+
f"| {col.ordinal} | `{col.name}`{pk} | {col.type_display} "
|
|
257
|
+
f"| {null} | {default} |"
|
|
258
|
+
)
|
|
259
|
+
w("")
|
|
260
|
+
|
|
261
|
+
if table.indexes:
|
|
262
|
+
w("**Indexes:**")
|
|
263
|
+
w("")
|
|
264
|
+
for idx in table.indexes:
|
|
265
|
+
uniq = "UNIQUE " if idx.unique else ""
|
|
266
|
+
cols = ", ".join(idx.columns)
|
|
267
|
+
w(f"- `{idx.name}` ({uniq}): {cols}")
|
|
268
|
+
w("")
|
|
269
|
+
|
|
270
|
+
@staticmethod
|
|
271
|
+
def _fk_line(fk: ForeignKeyInfo, prefix: str = "- ") -> str:
|
|
272
|
+
rules = ""
|
|
273
|
+
if fk.on_delete or fk.on_update:
|
|
274
|
+
parts = []
|
|
275
|
+
if fk.on_delete:
|
|
276
|
+
parts.append(f"ON DELETE `{fk.on_delete}`")
|
|
277
|
+
if fk.on_update:
|
|
278
|
+
parts.append(f"ON UPDATE `{fk.on_update}`")
|
|
279
|
+
rules = " — " + ", ".join(parts)
|
|
280
|
+
return (
|
|
281
|
+
f"{prefix}`{fk.from_column}` → `{fk.to_table}.{fk.to_column}`{rules}"
|
|
282
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-db-schema-doc
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Generate DATABASE.md schema documentation for Django projects and LLM agents
|
|
5
|
+
Author: MrHiB
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/behzad-njf/django-db-schema-doc
|
|
8
|
+
Project-URL: Documentation, https://github.com/behzad-njf/django-db-schema-doc#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/behzad-njf/django-db-schema-doc
|
|
10
|
+
Project-URL: Issues, https://github.com/behzad-njf/django-db-schema-doc/issues
|
|
11
|
+
Keywords: django,database,schema,documentation,llm,markdown,introspection
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Database
|
|
25
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: Django>=4.2
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
32
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# django-db-schema-doc
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/django-db-schema-doc/)
|
|
39
|
+
[](https://pypi.org/project/django-db-schema-doc/)
|
|
40
|
+
|
|
41
|
+
Generate **DATABASE.md** — full schema documentation for developers and LLM/AI agents — from any database configured in your Django project's `DATABASES`.
|
|
42
|
+
|
|
43
|
+
Supports **PostgreSQL**, **MySQL/MariaDB**, **Microsoft SQL Server**, **SQLite**, **Oracle**, and other backends Django can introspect.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install django-db-schema-doc
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Setup
|
|
52
|
+
|
|
53
|
+
Add to `INSTALLED_APPS`:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
INSTALLED_APPS = [
|
|
57
|
+
# ...
|
|
58
|
+
"db_schema_doc",
|
|
59
|
+
]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python manage.py generate_database_doc
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Writes `DATABASE.md` in your project `BASE_DIR` by default.
|
|
69
|
+
|
|
70
|
+
### Common options
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python manage.py generate_database_doc -o docs/schema.md
|
|
74
|
+
python manage.py generate_database_doc --with-row-counts
|
|
75
|
+
python manage.py generate_database_doc --database reporting
|
|
76
|
+
python manage.py generate_database_doc --project-hints "See accounts_childuserrelation for parent-child links."
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Run `python manage.py generate_database_doc --help` for all options.
|
|
80
|
+
|
|
81
|
+
## What is generated
|
|
82
|
+
|
|
83
|
+
- Connection metadata (engine, vendor, database — no passwords)
|
|
84
|
+
- Tables grouped by name prefix (`app_model` style)
|
|
85
|
+
- Hub tables (most referenced by foreign keys)
|
|
86
|
+
- Table of contents with anchors
|
|
87
|
+
- Foreign key index
|
|
88
|
+
- Per table: columns, types, PKs, indexes, incoming/outgoing FKs
|
|
89
|
+
|
|
90
|
+
On PostgreSQL, MySQL, and SQL Server, foreign keys include `ON DELETE` / `ON UPDATE` rules when available.
|
|
91
|
+
|
|
92
|
+
## Requirements
|
|
93
|
+
|
|
94
|
+
- Python 3.10+
|
|
95
|
+
- Django 4.2+
|
|
96
|
+
- Your project's database driver (e.g. `psycopg`, `mysqlclient`, `mssql-django`)
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
db_schema_doc/__init__.py
|
|
5
|
+
db_schema_doc/apps.py
|
|
6
|
+
db_schema_doc/collector.py
|
|
7
|
+
db_schema_doc/py.typed
|
|
8
|
+
db_schema_doc/writer.py
|
|
9
|
+
db_schema_doc/management/__init__.py
|
|
10
|
+
db_schema_doc/management/commands/__init__.py
|
|
11
|
+
db_schema_doc/management/commands/generate_database_doc.py
|
|
12
|
+
django_db_schema_doc.egg-info/PKG-INFO
|
|
13
|
+
django_db_schema_doc.egg-info/SOURCES.txt
|
|
14
|
+
django_db_schema_doc.egg-info/dependency_links.txt
|
|
15
|
+
django_db_schema_doc.egg-info/requires.txt
|
|
16
|
+
django_db_schema_doc.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
db_schema_doc
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-db-schema-doc"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Generate DATABASE.md schema documentation for Django projects and LLM agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "MrHiB" },
|
|
13
|
+
]
|
|
14
|
+
keywords = ["django", "database", "schema", "documentation", "llm", "markdown", "introspection"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Web Environment",
|
|
18
|
+
"Framework :: Django",
|
|
19
|
+
"Framework :: Django :: 4.2",
|
|
20
|
+
"Framework :: Django :: 5.0",
|
|
21
|
+
"Framework :: Django :: 5.1",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Topic :: Database",
|
|
29
|
+
"Topic :: Software Development :: Documentation",
|
|
30
|
+
]
|
|
31
|
+
requires-python = ">=3.10"
|
|
32
|
+
dependencies = [
|
|
33
|
+
"Django>=4.2",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"build>=1.0",
|
|
39
|
+
"twine>=4.0",
|
|
40
|
+
"ruff>=0.1",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/behzad-njf/django-db-schema-doc"
|
|
45
|
+
Documentation = "https://github.com/behzad-njf/django-db-schema-doc#readme"
|
|
46
|
+
Repository = "https://github.com/behzad-njf/django-db-schema-doc"
|
|
47
|
+
Issues = "https://github.com/behzad-njf/django-db-schema-doc/issues"
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
where = ["."]
|
|
51
|
+
include = ["db_schema_doc*"]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.package-data]
|
|
54
|
+
db_schema_doc = ["py.typed"]
|