Repository navigation
Releases: FROWNINGdev/orm-lens
Release list
v0.20.0
What's Changed
- feat(cli): SQLAlchemy models for scan, ER diagrams and N+1 (preview) by @FROWNINGdev in #199
- release: py-1.16.0, v0.20.0 — django-orm-lens is now orm-lens by @FROWNINGdev in #200
Full Changelog: v0.19.6...v0.20.0
v0.19.6
What's Changed
- ci: test Python 3.15 by @FROWNINGdev in #180
- feat(cascade): name the delete receivers Django 6.1's DB_CASCADE skips by @FROWNINGdev in #179
- release: py-1.13.8 — the delete handlers DB_CASCADE skips by @FROWNINGdev in #184
- docs: article — Django 6.1's DB_CASCADE skips your post_delete handlers by @FROWNINGdev in #185
- fix(migration-risk): stop false criticals on real-world histories (407 → 2 on six large projects) by @FROWNINGdev in #186
- fix(nplusone): precision on real-world code — writes, duplicate clauses, test modules by @FROWNINGdev in #187
- release: py-1.14.0 — real-world precision by @FROWNINGdev in #188
- docs: article — 407 migration alarms on six big Django codebases, 405 wrong by @FROWNINGdev in #189
- fix(nplusone): assignments and instance methods are not relation reads by @FROWNINGdev in #193
- fix(signals): follow signal.connect(handler, sender=Model) receivers by @1AbdullahButt in #191
- fix(rules): DOL013 reads ForeignKey arguments with nested calls by @1AbdullahButt in #192
- fix(rules): DOL013 flags OneToOneField without on_delete by @1AbdullahButt in #190
- feat(migration-risk): read Alembic revisions (preview) by @FROWNINGdev in #194
- release: py-1.15.0, v0.19.6 — Alembic revisions by @FROWNINGdev in #195
New Contributors
- @1AbdullahButt made their first contribution in #191
Full Changelog: v0.19.5...v0.19.6
py-v1.16.0 - django-orm-lens is now orm-lens, and reads SQLAlchemy models
Changed
- django-orm-lens is now orm-lens, and reads SQLAlchemy models.
The package isorm-lenson PyPI, with the commandsorm-lensand
orm-lens-mcpand the moduleorm_lens. Nothing written against the old
name breaks:pip install django-orm-lens(and[mcp]) installs the
matching orm-lens and keeps thedjango-orm-lens/django-orm-lens-mcp
commands andimport django_orm_lens, and upgrading an existing install
keeps both command names. The MCP server readsORM_LENS_ROOTand
ORM_LENS_ALLOWED_ROOTS, then the oldDJANGO_ORM_LENS_*names. The
repository moved togithub.com/FROWNINGdev/orm-lens(the old URLs
redirect), the docs tofrowningdev.github.io/orm-lens, the MCP Registry
entry toio.github.FROWNINGdev/orm-lens, and the image to
ghcr.io/frowningdev/orm-lens(ghcr.io/frowningdev/django-orm-lenskeeps
getting the same builds). Kept on purpose: the pre-commit hook ids, the
SARIF tool name (code scanning keys open alerts on it) and the blast-radius
PR-comment marker. The GitHub Action still accepts a pre-1.16version.
Added
- SQLAlchemy models: scan, ER diagrams and N+1 (preview).
scan,list,describe,hover,erandnplusone, and the matching
MCP tools, now read SQLAlchemy models: 2.0DeclarativeBase/Mapped[],
1.xdeclarative_base()/Column, Flask-SQLAlchemydb.Modeland
SQLModeltable=Trueclasses. Static as always:ast, no import, no
database. Bases and mixins are followed across files, table names come
from__tablename__or the base's naming rule, foreign keys become ER
edges, andrelationship()attributes are listed with their loader.
nplusoneflags a loop over query results that reads a lazy relationship
withoutselectinload/joinedloadin the query's options, and suggests
the option. The Django analyses (drift, blast radius, cascade, signals)
stay Django-only. Checked on Superset, Airflow, Prefect, Polar, Dispatch,
Mealie, CTFd, Redash and the FastAPI template: 12 N+1 findings, all real.
See docs/rules/sqlalchemy.md.
Fixed
- SQLAlchemy and Flask classes no longer pass for Django models. A
models.pywithclass User(db.Model)or Flask-AppBuilder'sModel
matched the parser'sModelheuristic, so a Flask project showed up as a
Django one: 65 phantom models in Superset, 138 in Polar, 69 in Dispatch.
Files that are SQLAlchemy code and never import Django are now left to
the SQLAlchemy reader. The Django projects in the census are unchanged.
py-v1.15.0 - migration-risk reads Alembic revisions (preview)
Added
migration-riskreads Alembic revisions (preview). SQLAlchemy projects
without Django (FastAPI, Flask) get the same static migration review: any
versions/revision is parsed withastand never run. Eleven rules cover
a NOT NULLadd_columnwithoutserver_default(SQLAlchemy'sdefault=only
applies on INSERT),create_indexwithoutCONCURRENTLY,alter_columnto NOT
NULL or to a new type, renamed and dropped columns and tables, unique,
foreign-key and check constraints, and multiple heads.batch_alter_table
blocks and Flask-Migrate layouts are covered. The populated-table rules
learned from the Django census apply too: a same-revisioncreate_tableor
the base revision means an empty table, and a data operation first makes the
finding a warning. Over ~1170 revisions from Superset, Airflow, Prefect,
Polar, Dispatch, Mealie and the FastAPI template there are 9 critical
findings, each a NOT NULL column that fails on a populated table. Rules:
docs/rules/alembic.md.
Fixed
-
signals/ MCPsignal_graphmissed receivers registered with
.connect(). Only@receiver(...)decorators were read, so
post_delete.connect(delete_file, sender=Attachment)in anAppConfig.ready()
was invisible, andcascadeleft it out of theDB_CASCADE
skipped_receiverslist.signal.connect(handler, sender=Model)is now an
edge like a decorator, with a bare or dotted signal (post_delete,
models.signals.post_delete),receiver=as a keyword, and nosender
meaning every model. To keep sockets and database clients out, a.connect()
call only counts when the signal is a Django built-in, aSignal()defined in
the workspace, or the call passessender=.
(#183) Contributed by @1AbdullahButt. -
nplusoneread assignments and instance methods as relation reads.
Two more patterns from the real-world census:device.cluster = clusterinside a loop (NetBox) assigns the attribute
and loads nothing.device.cluster.name = xstill loads
device.cluster, so that one is still reported.check.matches_tag_set(tags)(Healthchecks) is a method on the loop
variable, but its name matched the<model>_setreverse-manager
heuristic. A call made directly on the loop variable is never a
relation read; managers are always one attribute deeper.
v0.19.5
What's Changed
- chore(deps)(deps-dev): bump @types/node from 26.6.4 to 26.6.5 in the types group by @dependabot[bot] in #141
- fix(mcp): accept the documented 'name' argument in the impact tool by @vishalhabib99 in #138
- release: py-1.13.3 — impact MCP tool accepts 'name' (#138) by @FROWNINGdev in #142
- feat(skills): django-orm-lens agent skill (SKILL.md) by @FROWNINGdev in #144
- docs(readme): agent skill section with the Agensi listing by @FROWNINGdev in #152
- fix(migration-risk): correct AddIndex lock mode by @syedmustafan in #155
- fix(impact): serialize 1-based line numbers in impact JSON by @rudra337-lgtm in #156
- docs(skill): impact JSON line base depends on the release by @FROWNINGdev in #157
- fix: include self-referential cascades in preview by @xmejl in #154
- fix(cli): resolve blast-radius --only paths relative to project root by @NesrineGharbi77 in #158
- fix(blast-radius): scan the old name for RenameField and RenameModel (#148) by @mayuriphad in #159
- release: py-1.13.4 by @FROWNINGdev in #162
- fix(migration-risk): flag a RemoveField whose field is already gone (#140) by @vishalhabib99 in #161
- fix(dbml): report one-based model line numbers by @syedmustafan in #160
- chore(deps)(deps): bump react from 19.3.0 to 19.3.1 by @dependabot[bot] in #163
- docs(changelog): move #140 to Unreleased, add #139 by @FROWNINGdev in #165
- chore(deps)(deps): bump react-dom from 19.3.0 to 19.3.1 by @dependabot[bot] in #164
- release: py-1.13.5 by @FROWNINGdev in #166
- ci(deps)(deps): bump pypa/gh-action-pypi-publish from 1.14.2 to 1.14.3 by @dependabot[bot] in #169
- fix(nplusone): inherit outer eager loading in nested loops and suggest full relation paths by @Nitjsefnie in #168
- fix(nplusone): detect sliced querysets by @syedmustafan in #167
- release: py-1.13.6 — nplusone sliced and nested loops by @FROWNINGdev in #171
- chore(deps)(deps-dev): bump qs from 6.15.3 to 6.17.0 by @dependabot[bot] in #170
- chore(deps): bump @azure/msal-node to 7.1.2 (GHSA-xcx6-jx8p-x3h5) by @FROWNINGdev in #172
- ci: test every supported Python (3.9–3.14) and current Django (4.2–6.1) by @FROWNINGdev in #173
- fix(mcp): make the [mcp] extra installable on Python 3.9 by @FROWNINGdev in #174
- fix(cascade): bucket Django 6.1's DB_CASCADE / DB_SET_NULL / DB_SET_DEFAULT by @FROWNINGdev in #175
- fix(parser): read a positional on_delete — ForeignKey(Order, models.CASCADE) by @FROWNINGdev in #176
- fix(cascade): report on_delete=DO_NOTHING instead of dropping it by @FROWNINGdev in #177
- release: py-1.13.7, v0.19.5 — on_delete the way Django writes it by @FROWNINGdev in #178
New Contributors
- @vishalhabib99 made their first contribution in #138
- @syedmustafan made their first contribution in #155
- @rudra337-lgtm made their first contribution in #156
- @xmejl made their first contribution in #154
- @NesrineGharbi77 made their first contribution in #158
- @mayuriphad made their first contribution in #159
Full Changelog: v0.19.4...v0.19.5
py-v1.14.0 - migration-risk: 407 criticals on six big Django codebases, 2 real
Fixed
-
migration-risk: 407 criticals on six big Django codebases, 2 real.
Run over NetBox, PostHog, authentik, Plane, paperless-ngx and Healthchecks,
it reported 407 critical findings, and 2 of them were real. The rules now
follow what Django actually does to existing rows:ManyToManyField,TaggableManagerandGenericRelationadd no column,
so they are never a NOT NULL risk.blank=Trueon a text field, including project subclasses such as
ColorField(max_length=6), gets"".auto_now/auto_now_addget the
current time, anddb_defaultandGeneratedFieldfill themselves.- A table created earlier in the same migration is empty. A squashed
migration (replaces = [...]) only ever runs on a fresh database. unique=Truewithnull=Trueand no default, ordefault=None, fills
rows with NULL, which never collides.- A field removed and then re-added, renamed onto the same name, or a model
deleted and re-created, in that migration or a later one, is no longer
"still referenced". - The migration graph collapses squashed migrations the way Django's loader
does, so a squash and the migrations it replaces are not reported as
conflicting leaves. - A NOT NULL column added after a
RunPython, or after aRunSQLthat
deletes, updates or truncates rows, is a warning instead of a certain
critical, because that step may have emptied the table.
-
nplusonereported writes and test code as N+1 reads. Checked against
NetBox, PostHog, authentik, Plane, paperless-ngx and Healthchecks:post.tags.set(...),.add(),.remove(),.clear(),.create()and
.get_or_create()on a relation were caught by the attribute-chain
fallback and given a prefetch, which cannot serve a write. They are not
reported now. Loading a single-valued relation before writing to it
(profile.user.delete()) is still an N+1, and its fix is
select_related. QuerySet writes such asupdate()anddelete()on a
related manager stay findings, as before.- One field is never suggested in both
select_relatedand
prefetch_related..select_related("user").prefetch_related("user")
came from a schema-less guess onx.user.delete(). - Test modules (
test_*.py,*_test.py,tests.py,conftest.py, and
anything undertests/ortest/) are skipped, because a query per row
in a test is not a production N+1. Pass--include-teststo scan them.
This also halves the scan time on large repositories (PostHog: 80 s to
39 s).
py-v1.13.8 - cascade names the delete handlers Django 6.1's DB_CASCADE skips (#179)
Added
-
cascadenames the delete handlers Django 6.1'sDB_CASCADEskips (#179).
Withon_delete=models.DB_CASCADEthe database deletes the child
rows itself, so Django never sendspre_delete/post_deletefor them: a
post_deletehandler that removes the uploaded file, writes the audit log
or busts a cache just stops running, with no error.cascade(CLI and the
MCPcascade_previewtool) now lists each@receiverfor that model, or
for every model, under the affected relation asskipped_receivers, and
the text output prints them with file and line. The signal graph is only
built when aDB_CASCADErelation exists. -
Tested on Python 3.15 (#180). CI gained a Python 3.15 + Django 6.1 leg,
and the package metadata lists 3.15.
py-v1.13.7 - cascade and blast-radius missed a positional on_delete (#176)
Fixed
-
cascadeandblast-radiusmissed a positionalon_delete(#176).
ForeignKeyandOneToOneFieldtake(to, on_delete, ...), and Django's
owncontrib.authandcontrib.adminpass the policy positionally:
ForeignKey(ContentType, models.CASCADE). The parser only looked for
on_delete=, so such relations were left out of every bucket andcascade
could call the delete isolated. The second positional argument now counts
for FK and O2O, with any dotted prefix andSET(...);ManyToManyField,
whose second positional isrelated_name, is left alone. -
cascadeignoredon_delete=DO_NOTHING(#177). Those relations fell out
of every bucket, socascadecould call a delete isolated that the FK
constraint rejects: Django leaves the referencing rows, and the database
raisesIntegrityError. They now land in a newunhandledbucket (CLI JSON
and the MCPcascade_previewtool, additive), the text output lists them as
"left as-is by DO_NOTHING" with one line on what happens, andblast-radius
adds "N left by DO_NOTHING" to its cascade line when there are any. With
db_constraint=Falsethere is no constraint to trip, so the entry carries
"db_constraint": falseand the text says the rows keep an id that points
nowhere. -
cascadecalled a delete isolated when Django 6.1 would cascade in the
database (#175). Django 6.1 addedon_delete=models.DB_CASCADE,
DB_SET_NULLandDB_SET_DEFAULT, where the FK constraint carries
ON DELETEand the database does the work. Those relations fell out of
every bucket. They now land incascade_kills/set_nullmarked
"db_level": true, the text output tags them(db)and notes that Django
sends no delete signals for them, and DBML export maps them to
delete: cascade/set null/set default. -
pip install "django-orm-lens[mcp]"failed outright on Python 3.9
(#174). It never worked there: every mcp release, 1.x included, requires
Python 3.10, so pip and uv rejected the whole install, CLI and all.
py-1.13.0 made that explicit without noticing, by gatingmcp>=1.0,<2to
python_version < '3.10'on the belief that 1.x runs on 3.9. The extra now
asks for mcp only on 3.10+, so on 3.9 it installs the CLI and nothing else,
anddjango-orm-lens-mcpsays the server needs 3.10+ and prints a
uvx --python 3.12command instead of repeating the pip command that just
ran. 3.10+ installs are unchanged (mcp 2.x), and the bootstrap still accepts
an mcp 1.x that another tool already put in the environment.
Changed
- Tested on every supported Python, 3.9 through 3.14, and on Django 4.2,
5.2, 6.0 and 6.1 (#173). CI ran 3.10–3.12 only, which is how the 3.9
install failure above went unnoticed, and stopped at Django 5.2. The package
metadata now lists Django 6.0 and 6.1 and Python 3.14, and the 3.9 leg
checks that the[mcp]extra installs.
py-v1.13.6
Fixed
-
nplusoneskipped loops over sliced QuerySets (#145). Iterating
Post.objects.filter(...)[:50]is still lazy and can cause one related
lookup per row; it now produces the same finding and eager-loading
suggestion as an unsliced QuerySet. A slice of a slice
(qs[:50][:10]) is unwrapped too. -
nplusoneflagged nested relations already prefetched by the outer
queryset (#146). Nested loops now inherit explicit eager-loading paths
from their enclosing queryset. Suggestions retain the full schema-validated
relation path, such asproduct__category, and partial prefetches still
report the relations that remain unloaded. Prefetch-cache coverage now follows
supported documented queryset operations on related managers, classifying
ordering- and emptiness-dependent cases conservatively while retaining findings
for non-cache-served operations and using schema-backed receiver checks to
preserve loaded relation prefixes for calls on other receivers.
py-v1.13.5
Fixed
-
blast-radiushad no target for aRemoveFieldonce the field was gone
frommodels.py(#140). That is the normal state aftermakemigrations,
and exactly when code still reading the column breaks, butmigration-risk
emitted nothing for it, soblast-radiusnever scanned. It now emits an
inforisk,remove_field, whichblast-radius --severity info(orall)
turns into a target listing the remaining references.migration-risk --severity infoshows one more line per dropped column. The MCP
blast_radiustool also rejects an unknownseveritywith an
INVALID_SEVERITYerror instead of silently treating it ascritical. -
DBML ER export noted model line numbers 0-based (#139). The
Note: 'line N of app/models.py'iner --format dbmlpointed one line
above the class, while every other formatter prints 1-based lines. The
note now uses the same 1-based number.