INW016 orm-naming¶
What it does¶
Opt-in, and an error when on. Holds the names of database tables and of datetime and date columns to one scheme. By default that is the scheme fastapi-best-practices sets: table names in lower_case_snake and in the singular (post, post_like), _at at the end of a datetime column (created_at) and _date at the end of a date column (published_date). One finding per name that breaks it, on the name, with the name to use instead.
What it reads:
| Construct | Table name | Datetime and date columns |
|---|---|---|
| SQLAlchemy declarative | __tablename__ = "post" |
Mapped[datetime], Mapped[date] (also inside Optional[...] or ... \| None); mapped_column(DateTime), Column(Date), and TIMESTAMP, DATETIME, DATE |
| SQLAlchemy Core | Table("post", metadata, ...) |
Column("created_at", DateTime) |
| SQLModel | __tablename__, or the name SQLModel derives for a table=True class without one: the class name, lower-cased (Heroes gives heroes) |
created_at: datetime and published_date: date fields of a table=True class |
| Django | class Meta: db_table = "post" |
models.DateTimeField(), models.DateField() |
A column's name is the one the database gets: the string a call passes (mapped_column("created_at", DateTime), db_column="created_at"), else the attribute's. An explicit type wins over the annotation: Mapped[datetime] = mapped_column(Date) is a date column.
A whole-project run (inwards check without paths, or the Stop gate with stop-gate = "project") also reports one finding when the project has SQLAlchemy or SQLModel tables and no MetaData(naming_convention=...) anywhere: on the first table in the files the rule covers. An assignment to <something>.naming_convention, such as SQLModel.metadata.naming_convention = {...}, counts too. Django tables don't take part: Django names its constraints itself.
A project with another scheme¶
A whole-project run also notices a project that follows another scheme on purpose. When at least 5 names are checked and 90% or more of them break the configured scheme the same way, it reports one finding for them, on the first such name, instead of one finding per name. The finding says what the project does and which option value matches it:
| What the project does | The finding asks for |
|---|---|
90% of its lower_case_snake table names read as plural, under table-name = "snake_singular" |
table-name = "snake" |
90% of its datetime (or date) column names end with one other time suffix, such as _on |
datetime-suffix = "_on" (or date-suffix) |
| 90% of its datetime (or date) column names miss the suffix, with no one suffix shared | datetime-suffix = false (or date-suffix) |
The names that follow the configured scheme anyway (a singular table in a plural project) aren't reported. Neither is the scheme when fewer than 90% of the names follow it: a project halfway through a rename gets a finding per name, so it can see what is left. A name that breaks the scheme another way, such as a table name that isn't lower_case_snake, keeps its own finding.
A check of some paths, the per-edit hook and the editor see one file or a few, not the project's habit, so they report each name against the configured scheme, the default included. Reading every model on each edit to learn the habit would make an edit's result depend on files it didn't touch, and cost a parse of every model per edit. The project-wide finding names the option instead: once the user sets it, the per-edit checks agree with the project. Until then, a plural table's per-name finding mentions table-name = "snake" in its fix.
Files are checked whatever layer they are in, apart from test modules (check-tests = true brings them back, options tables); modules narrows the rule to some of them, such as the models packages. The names a project-wide finding counts come from the same files, so a test's models don't count toward the 90%.
Why is this bad¶
A schema lives longer than the code that first wrote it, and every query, migration, report and dashboard repeats its names. Mixed names (posts next to post_like, created next to updated_at, start_date holding a timestamp) make every reader stop and check, and a renamed table or column later needs a migration and a change in every place that uses it. Agents copy whatever the model next to them does, or what the tutorial they remember does, so a project drifts one model at a time. The suffixes also carry the type: _at says "a point in time, with a time zone question to ask", _date says "a calendar day".
SQLAlchemy names an index, but not a unique, check, foreign key or primary key constraint, unless the MetaData has a naming convention: "neither Alembic nor SQLAlchemy currently create names for constraint objects where the name is otherwise unspecified" (SQLAlchemy: Configuring Constraint Naming Conventions). The database then picks the names, and a later migration that drops or alters such a constraint has to guess them. fastapi-best-practices sets the convention for that reason (Set DB keys naming conventions).
No other linter reads SQLAlchemy's table and column semantics for names: flake8-sqlalchemy checks other things, and Ruff's naming rules look at Python identifiers, not at the strings a table is named with.
Example¶
[tool.inwards]
layers = [
{ name = "db", modules = ["blog.db"] },
]
[tool.inwards.rules]
extend-select = ["INW016"]
from datetime import datetime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
created: Mapped[datetime]
blog/db/models.py:11:21: INW016 Table name "posts" is plural; this project names tables in the singular: "post".
fix: Rename the table "posts" to "post".
1. Change the name where it is written to "post": `__tablename__`, the first argument of `Table(...)`, or `db_table`.
2. If the table already exists, add a migration that renames it, such as Alembic's `op.rename_table("posts", "post")`, and update raw SQL that names it.
3. If "posts" is singular in fact (a mass noun such as "news"), ask the user to add it to `allow-tables` in [tool.inwards.rules.orm-naming]; if the project names all its tables in the plural, ask for `table-name = "snake"` there instead. Don't edit [tool.inwards] yourself.
docs: https://sircypkowskyy.github.io/inwards/rules/INW016/
blog/db/models.py:15:5: INW016 Datetime column `created` doesn't end with `_at`: name it `created_at`.
fix: Rename the column `created` to `created_at`.
A whole-project run adds the missing naming convention, on the first table:
blog/db/models.py:11:21: INW016 No `MetaData(naming_convention=...)` in the project, so SQLAlchemy leaves unique, check, foreign key and primary key constraints unnamed and the database picks their names.
fix: Give the declarative base's MetaData a naming convention.
In a project whose tables are all plural, such as Polar's 98 of 104, a whole-project run reports them once, on the first, instead of the plural finding on each:
server/polar/models/account.py:35:21: INW016 This project names its tables in the plural, which the singular scheme reports on every table: `table-name = "snake"` matches it.
fix: Ask the user to set `table-name = "snake"` in [tool.inwards.rules.orm-naming], or rename the tables to the singular.
Fixed: a singular table, created_at, and a naming convention on the declarative base. The whole project passes:
from datetime import datetime
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
POSTGRES_INDEXES_NAMING_CONVENTION = {
"ix": "%(column_0_label)s_idx",
"uq": "%(table_name)s_%(column_0_name)s_key",
"ck": "%(table_name)s_%(constraint_name)s_check",
"fk": "%(table_name)s_%(column_0_name)s_fkey",
"pk": "%(table_name)s_pkey",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)
class Post(Base):
__tablename__ = "post"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
created_at: Mapped[datetime]
How it works¶
A text pre-filter runs first: a file that names none of __tablename__, Table(, table=True, Mapped, Column(, mapped_column, db_table, DateTimeField and DateField is never parsed. A file that passes gets one parse of its own.
- Names are qualified through the file's imports, so
import sqlalchemy as saandsa.DateTime(timezone=True)read as SQLAlchemy'sDateTime, andfrom datetime import datetime as dtandMapped[dt]as a datetime. ATable,Column,mapped_column,MetaDataor column type counts when it comes fromsqlalchemyorsqlmodel; a Django field when it comes fromdjango.db.models. - Table names must be string literals or same-file constants; a name built at run time, such as a
declared_attrfunction, isn't read. - lower_case_snake is lower-case words of letters and digits joined by single underscores:
post,post_like,oauth2_token. The suggestion splits camel case and replaces other characters:UserAccountgivesuser_account. - Singular is a heuristic on the last word of the name. A word ending in
sreads as plural unless it ends inss,us,isorics(address,status,analysis,analytics), is three letters or fewer, or is one of a few words that are the same in both numbers (news,series,species,alias). A few plurals without ans(people,children,men) count too. The suggestion undoes-ies,-esafter a sibilant and-s:categoriesgivescategory,addressesgivesaddress,post_likesgivespost_like. - The suffix suggestion swaps another time suffix the name ends with (
_date,_time,_on,_ts,_dt,_datetime,_timestamp,_at) for the configured one, moves a leadingdate_,date_of_,time_ortimestamp_to the end as the suffix, or adds it:due_dateholding a datetime givesdue_at,date_joinedgivesjoined_at,date_of_birthholding a date givesbirth_date,createdgivescreated_at. It never repeats a word: a name that is only time words (date,timestamp,date_time) gets no suggestion, and the finding asks for a name that says what the time records; so does a name that still holds a word of the suffix, such asstart_date_utcunder_date. - The project's scheme is decided from every table and column the whole-project run read in files the rule covers. It reuses what the per-file checks read, so it parses nothing more: on Polar the run took about 25 ms longer (median of 7 runs, 674 ms against 649 ms).
- The naming convention is searched in every file of the run, whether the rule covers it or not, so a convention in
app/database.pycounts for models inapp/*/models.pythatmodulesselects. A file is parsed for it only when its text saysnaming_convention.
How to fix¶
- Rename the table or column where it is declared, and every place that uses the old name.
- If the table or column already exists, add a migration that renames it: Alembic's
op.rename_table("posts", "post")orop.alter_column("post", "created", new_column_name="created_at"). - For the naming convention, give the
MetaDataevery model shares anaming_convention, then generate a migration: existing constraints keep their names until it renames them.
Fix safety¶
No automatic fix (autofix: false). A rename changes the database schema: it needs a migration, and every query, serializer, fixture and report that names the table or column changes with it.
Configuration¶
Severity error. Opt-in: turn it on with extend-select. The options live in [tool.inwards.rules.orm-naming] (options tables):
[tool.inwards.rules]
extend-select = ["INW016"]
[tool.inwards.rules.orm-naming]
modules = ["shop.*.models"] # optional: only these modules
table-name = "snake_singular" # or "snake", or false
datetime-suffix = "_at" # or false
date-suffix = "_date" # or false
allow-tables = ["user_settings"] # names accepted as written
require-naming-convention = true # whole-project runs only
table-name:"snake_singular"(the default) wants lower_case_snake names in the singular,"snake"lower_case_snake only, andfalseturns the table check off.datetime-suffix,date-suffix: the suffix each datetime or date column name ends with, lower-case letters, digits and underscores;falseturns that check off. The defaults are"_at"and"_date".allow-tables: table names the rule accepts as they are written, such as a mass noun the singular heuristic takes for a plural, or a legacy table you can't rename yet.require-naming-convention:falsedrops the project-wide finding for a missingMetaData(naming_convention=...).
A single name can be suppressed inline on its line: # inwards: ignore[INW016] reason="...". The project-wide findings aren't tied to a line they own, so they can't be:
- The naming convention finding goes away with
require-naming-convention = false. - A scheme finding goes away with the option value it names, which also tells the per-edit checks about the scheme.
inwards baselineaccepts it too: its message has no counts or examples, so it stays accepted while tables and columns are added, as long as the first name it sits on stays in the same module. The counts and examples are in its fix. - A comment on a name the scheme finding replaces has nothing left to hide in a whole-project run.
A template can turn INW016 on for one of its roles: models = { orm-naming = true } sets modules to the role's modules. inwards init --style fastapi writes models = { orm-naming = "warning" } into its fastapi-domain template, so every domain's models follow this scheme from the start (install guide).
Known limitations¶
- English only. The singular heuristic knows English plurals, and not all of them:
cachesgivescach, andsettingsreads as plural. Add such names toallow-tables. - Only the last word of a table name is checked for the plural:
posts_likepasses. - Derived names. A table name from a
declared_attr, a mixin's__tablename__function or a string built at run time isn't read, and neither is Django's default<app>_<model>name, which needs the app label. - Types the rule can't see. A column whose type comes from a
type_annotation_mapentry, a customTypeDecorator, a type alias imported from another module, or SQLModel'ssa_column=Column(DateTime)without a name isn't read as a datetime or date. - The naming convention is looked for in whole-project runs only: a check of some paths, the per-edit hook and the editor don't report it. A convention set in a way the rule doesn't read (a
MetaDatapassed**kwargs, a convention in a library) gives a false finding; setrequire-naming-convention = false. - Alembic migrations aren't read. A file that imports
alembicrecords the schema's history: the rename belongs in the model and a new migration, and the model's own finding asks for it. - The 90% threshold is a guess fitted to one project: Polar's tables are 94% plural. A project at 85% gets a finding per name.
- Measured on the corpus (#182, chapter 6) with the defaults: 173 findings before the migration rule, 152 after (121 in Polar, 26 in Saleor), every column finding and every table name looked at; all 152 break the default scheme as reported. With the project-wide scheme finding (#361), Polar's 98 plural table names became one finding: 121 → 24. Saleor stays at 26: 46 of its 72 datetime columns end with
_at, so itsdate_joinedandstart_dateare a mix, not a scheme. 13 of the 50 column findings left no longer suggest a name made of time words only (date_at,timestamp_at), anddate_of_birthgetsbirth_date.
References¶
- #297: the rule; #98: the role-scoped content rules it comes from.
- fastapi-best-practices: Set DB naming conventions: lower_case_snake, singular,
_atand_date. - SQLAlchemy: Configuring Constraint Naming Conventions and Declarative Table:
Mapped[datetime]maps toDateTime,Mapped[date]toDate. - Django: Model Meta options,
db_table.