INW016 orm-naming¶
Co robi¶
Opt-in, a po włączeniu błąd. Pilnuje, żeby nazwy tabel w bazie danych oraz kolumn z datą i czasem trzymały się jednego schematu. Domyślnie jest to schemat z fastapi-best-practices: nazwy tabel w lower_case_snake i w liczbie pojedynczej (post, post_like), _at na końcu kolumny z datą i czasem (created_at) i _date na końcu kolumny z samą datą (published_date). Jedno zgłoszenie na każdą nazwę, która łamie schemat, na samej nazwie, razem z nazwą, której należy użyć.
Co czyta:
| Konstrukcja | Nazwa tabeli | Kolumny daty i czasu |
|---|---|---|
| SQLAlchemy, styl deklaratywny | __tablename__ = "post" |
Mapped[datetime], Mapped[date] (także wewnątrz Optional[...] albo ... \| None); mapped_column(DateTime), Column(Date), a także TIMESTAMP, DATETIME, DATE |
| SQLAlchemy Core | Table("post", metadata, ...) |
Column("created_at", DateTime) |
| SQLModel | __tablename__ albo nazwa, którą SQLModel wyprowadza dla klasy table=True bez niego: nazwa klasy małymi literami (Heroes daje heroes) |
pola created_at: datetime i published_date: date klasy table=True |
| Django | class Meta: db_table = "post" |
models.DateTimeField(), models.DateField() |
Nazwa kolumny to ta, którą dostaje baza danych: napis przekazany w wywołaniu (mapped_column("created_at", DateTime), db_column="created_at"), a jeśli go nie ma, nazwa atrybutu. Jawny typ wygrywa z adnotacją: Mapped[datetime] = mapped_column(Date) to kolumna z samą datą.
Uruchomienie dla całego projektu (inwards check bez ścieżek albo Stop gate z stop-gate = "project") zgłasza też jedno naruszenie, gdy projekt ma tabele SQLAlchemy albo SQLModel i nigdzie nie ma MetaData(naming_convention=...): na pierwszej tabeli w plikach objętych regułą. Liczy się też przypisanie do <coś>.naming_convention, na przykład SQLModel.metadata.naming_convention = {...}. Tabele Django się nie liczą: Django samo nazywa swoje ograniczenia.
Projekt z innym schematem¶
Uruchomienie dla całego projektu zauważa też projekt, który celowo trzyma się innego schematu. Gdy sprawdzanych nazw jest co najmniej 5, a 90% lub więcej z nich łamie skonfigurowany schemat w ten sam sposób, zgłasza je jednym naruszeniem, na pierwszej takiej nazwie, zamiast naruszenia na każdej nazwie. Zgłoszenie mówi, co robi projekt i która wartość opcji do tego pasuje:
| Co robi projekt | Zgłoszenie prosi o |
|---|---|
90% jego nazw tabel w lower_case_snake wygląda na liczbę mnogą, przy table-name = "snake_singular" |
table-name = "snake" |
90% nazw jego kolumn z datą i czasem (albo z samą datą) kończy się jednym innym sufiksem czasu, na przykład _on |
datetime-suffix = "_on" (albo date-suffix) |
| 90% nazw jego kolumn z datą i czasem (albo z samą datą) nie ma sufiksu, a żaden jeden sufiks nie jest wspólny | datetime-suffix = false (albo date-suffix) |
Nazwy, które i tak trzymają się skonfigurowanego schematu (tabela w liczbie pojedynczej w projekcie z mnogimi), nie są zgłaszane. Schemat nie jest zgłaszany, gdy trzyma się go mniej niż 90% nazw: projekt w połowie zmiany nazw dostaje naruszenie na każdej nazwie, żeby widzieć, co zostało. Nazwa, która łamie schemat inaczej, na przykład nazwa tabeli spoza lower_case_snake, zachowuje własne zgłoszenie.
Sprawdzenie wybranych ścieżek, hook po każdej edycji i edytor widzą jeden plik albo kilka, a nie zwyczaj projektu, więc każdą nazwę sprawdzają względem skonfigurowanego schematu, także domyślnego. Czytanie wszystkich modeli przy każdej edycji, żeby poznać zwyczaj, uzależniłoby wynik edycji od plików, których nie dotknęła, i kosztowałoby parsowanie każdego modelu przy każdej edycji. Zgłoszenie dla całego projektu podaje zamiast tego opcję: gdy użytkownik ją ustawi, sprawdzenia po edycji zgadzają się z projektem. Do tego czasu poprawka zgłoszenia mnogiej tabeli wspomina table-name = "snake".
Pliki są sprawdzane niezależnie od warstwy, poza modułami testowymi (przywraca je check-tests = true, zob. tabele opcji); modules zawęża regułę do części z nich, na przykład do pakietów z modelami. Nazwy liczone przez zgłoszenie dla całego projektu pochodzą z tych samych plików, więc modele testów nie wliczają się do 90%.
Dlaczego to źle¶
Schemat bazy żyje dłużej niż kod, który go pierwszy zapisał, a każde zapytanie, migracja, raport i dashboard powtarza jego nazwy. Pomieszane nazwy (posts obok post_like, created obok updated_at, start_date z datą i czasem) każą każdemu czytelnikowi się zatrzymać i sprawdzić, a zmiana nazwy tabeli albo kolumny później wymaga migracji i poprawki w każdym miejscu, które jej używa. Agenci kopiują to, co robi model obok, albo to, co pamiętają z tutoriala, więc projekt rozjeżdża się model po modelu. Sufiksy niosą też typ: _at mówi „moment w czasie, z pytaniem o strefę czasową”, _date mówi „dzień w kalendarzu”.
SQLAlchemy nadaje nazwę indeksowi, ale nie ograniczeniu unikalności, check, klucza obcego ani klucza głównego, chyba że MetaData ma konwencję nazw: ani Alembic, ani SQLAlchemy nie tworzą nazw ograniczeń, którym nikt nazwy nie podał (SQLAlchemy: Configuring Constraint Naming Conventions). Nazwy wybiera wtedy baza danych, a późniejsza migracja, która usuwa albo zmienia takie ograniczenie, musi je zgadywać. fastapi-best-practices ustawia konwencję właśnie z tego powodu (Set DB keys naming conventions).
Żaden inny linter nie czyta semantyki tabel i kolumn SQLAlchemy pod kątem nazw: flake8-sqlalchemy sprawdza co innego, a reguły nazewnictwa Ruffa patrzą na identyfikatory Pythona, nie na napisy, którymi nazwano tabelę.
Przykład¶
[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`.
Uruchomienie dla całego projektu dodaje brak konwencji nazw, na pierwszej tabeli:
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.
W projekcie, którego tabele są w liczbie mnogiej, jak 98 ze 104 tabel Polara, uruchomienie dla całego projektu zgłasza je raz, na pierwszej, zamiast zgłoszenia liczby mnogiej na każdej:
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.
Po poprawce: tabela w liczbie pojedynczej, created_at i konwencja nazw na bazie deklaratywnej. Cały projekt przechodzi:
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]
Jak działa¶
Najpierw działa filtr tekstowy: plik, który nie wymienia żadnego z __tablename__, Table(, table=True, Mapped, Column(, mapped_column, db_table, DateTimeField i DateField, nie jest parsowany. Plik, który przejdzie, dostaje jedno własne parsowanie.
- Nazwy są kwalifikowane przez importy pliku, więc
import sqlalchemy as saisa.DateTime(timezone=True)czytają się jakoDateTimez SQLAlchemy, afrom datetime import datetime as dtiMapped[dt]jako data z czasem.Table,Column,mapped_column,MetaDataalbo typ kolumny liczą się, gdy pochodzą zsqlalchemyalbosqlmodel; pole Django, gdy pochodzi zdjango.db.models. - Nazwy tabel muszą być literałami napisowymi albo stałymi z tego samego pliku; nazwa budowana w czasie działania, na przykład funkcja
declared_attr, nie jest czytana. - lower_case_snake to słowa z małych liter i cyfr połączone pojedynczymi podkreśleniami:
post,post_like,oauth2_token. Podpowiedź rozdziela camel case i zamienia inne znaki:UserAccountdajeuser_account. - Liczba pojedyncza to heurystyka na ostatnim słowie nazwy. Słowo zakończone na
suchodzi za liczbę mnogą, chyba że kończy się nass,us,isalboics(address,status,analysis,analytics), ma najwyżej trzy litery albo jest jednym z kilku słów, które są takie same w obu liczbach (news,series,species,alias). Liczy się też kilka form mnogich bezs(people,children,men). Podpowiedź cofa-ies,-espo głosce syczącej i-s:categoriesdajecategory,addressesdajeaddress,post_likesdajepost_like. - Podpowiedź sufiksu zamienia inny sufiks czasu, na który kończy się nazwa (
_date,_time,_on,_ts,_dt,_datetime,_timestamp,_at), na skonfigurowany, przenosi początkowedate_,date_of_,time_albotimestamp_na koniec jako sufiks albo go dopisuje:due_datez datą i czasem dajedue_at,date_joineddajejoined_at,date_of_birthz samą datą dajebirth_date,createddajecreated_at. Nigdy nie powtarza słowa: nazwa złożona tylko ze słów o czasie (date,timestamp,date_time) nie dostaje podpowiedzi, a zgłoszenie prosi o nazwę, która mówi, co ten czas zapisuje; tak samo nazwa, która wciąż zawiera słowo sufiksu, na przykładstart_date_utcprzy_date. - Schemat projektu wynika z każdej tabeli i kolumny, które uruchomienie dla całego projektu przeczytało w plikach objętych regułą. Korzysta z tego, co przeczytały sprawdzenia poszczególnych plików, więc niczego nie parsuje ponownie: na Polarze uruchomienie trwało około 25 ms dłużej (mediana z 7 uruchomień, 674 ms wobec 649 ms).
- Konwencji nazw szuka w każdym pliku uruchomienia, objętym regułą albo nie, więc konwencja w
app/database.pyliczy się dla modeli wapp/*/models.py, które wybieramodules. Plik jest pod tym kątem parsowany tylko wtedy, gdy w jego tekście jestnaming_convention.
Jak naprawić¶
- Zmień nazwę tabeli albo kolumny tam, gdzie jest zadeklarowana, i w każdym miejscu, które używa starej nazwy.
- Jeśli tabela albo kolumna już istnieje, dodaj migrację, która zmienia jej nazwę:
op.rename_table("posts", "post")alboop.alter_column("post", "created", new_column_name="created_at")z Alembica. - Przy konwencji nazw daj
MetaData, które dzielą wszystkie modele,naming_convention, a potem wygeneruj migrację: istniejące ograniczenia zachowują swoje nazwy, dopóki migracja ich nie zmieni.
Bezpieczeństwo poprawki¶
Brak automatycznej poprawki (autofix: false). Zmiana nazwy zmienia schemat bazy danych: wymaga migracji, a każde zapytanie, serializer, fixture i raport, który nazywa tabelę albo kolumnę, zmienia się razem z nią.
Konfiguracja¶
Ważność: błąd. Opt-in: włącz ją przez extend-select. Opcje są w [tool.inwards.rules.orm-naming] (tabele opcji):
[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"(domyślnie) wymaga nazw w lower_case_snake i w liczbie pojedynczej,"snake"tylko lower_case_snake, afalsewyłącza sprawdzanie tabel.datetime-suffix,date-suffix: sufiks, na który kończy się nazwa każdej kolumny z datą i czasem albo z samą datą, z małych liter, cyfr i podkreśleń;falsewyłącza to sprawdzenie. Domyślnie"_at"i"_date".allow-tables: nazwy tabel, które reguła przyjmuje tak, jak są zapisane, na przykład rzeczownik niepoliczalny, który heurystyka bierze za liczbę mnogą, albo stara tabela, której nie da się jeszcze przemianować.require-naming-convention:falseusuwa ogólnoprojektowe zgłoszenie brakuMetaData(naming_convention=...).
Pojedynczą nazwę można wyciszyć w jej linii: # inwards: ignore[INW016] reason="...". Zgłoszeń dla całego projektu nie da się tak wyciszyć, bo nie mają własnej linii:
- Zgłoszenie o konwencji nazw wyłącza
require-naming-convention = false. - Zgłoszenie schematu znika po ustawieniu wartości opcji, którą podaje; ta sama wartość mówi o schemacie sprawdzeniom po edycji.
inwards baselineteż je przyjmuje: jego komunikat nie ma liczb ani przykładów, więc zostaje przyjęte, gdy przybywa tabel i kolumn, dopóki pierwsza nazwa, na której stoi, zostaje w tym samym module. Liczby i przykłady są w jego poprawce. - Komentarz na nazwie, którą zastępuje zgłoszenie schematu, przy uruchomieniu dla całego projektu nie ma już czego ukrywać.
Szablon może włączyć INW016 dla jednej ze swoich ról: models = { orm-naming = true } ustawia modules na moduły roli. inwards init --style fastapi wpisuje models = { orm-naming = "warning" } do swojego szablonu fastapi-domain, więc modele każdej domeny od początku trzymają się tego schematu (przewodnik instalacji).
Znane ograniczenia¶
- Tylko angielski. Heurystyka liczby pojedynczej zna angielskie formy mnogie, i to nie wszystkie:
cachesdajecach, asettingsuchodzi za liczbę mnogą. Takie nazwy dopisz doallow-tables. - Tylko ostatnie słowo nazwy tabeli jest sprawdzane pod kątem liczby mnogiej:
posts_likeprzechodzi. - Nazwy wyprowadzane. Nazwa tabeli z
declared_attr, z funkcji__tablename__w mixinie albo z napisu budowanego w czasie działania nie jest czytana, podobnie jak domyślna nazwa Django<app>_<model>, która wymaga etykiety aplikacji. - Typy, których reguła nie widzi. Kolumna, której typ pochodzi z wpisu w
type_annotation_map, z własnegoTypeDecorator, z aliasu typu importowanego z innego modułu albo zsa_column=Column(DateTime)w SQLModel bez nazwy, nie jest czytana jako kolumna daty i czasu. - Konwencji nazw reguła szuka tylko przy uruchomieniu dla całego projektu: sprawdzenie wybranych ścieżek, hook po każdej edycji i edytor jej nie zgłaszają. Konwencja ustawiona w sposób, którego reguła nie czyta (
MetaDataz**kwargs, konwencja w bibliotece), daje fałszywe zgłoszenie; ustaw wtedyrequire-naming-convention = false. - Migracje Alembica nie są czytane. Plik, który importuje
alembic, zapisuje historię schematu: zmiana nazwy należy do modelu i nowej migracji, a prosi o nią zgłoszenie samego modelu. - Próg 90% to przybliżenie dopasowane do jednego projektu: tabele Polara są w 94% w liczbie mnogiej. Projekt z 85% dostaje zgłoszenie na każdej nazwie.
- Zmierzona na korpusie (#182, rozdział 6) z ustawieniami domyślnymi: 173 zgłoszenia przed regułą o migracjach, 152 po niej (121 w Polarze, 26 w Saleorze), każde zgłoszenie kolumny i każda nazwa tabeli obejrzane; wszystkie 152 łamią domyślny schemat tak, jak podają. Ze zgłoszeniem schematu dla całego projektu (#361) 98 mnogich nazw tabel Polara stało się jednym zgłoszeniem: 121 → 24. Saleor zostaje przy 26: 46 z jego 72 kolumn z datą i czasem kończy się na
_at, więc jegodate_joinedistart_dateto mieszanka, a nie schemat. 13 z 50 pozostałych zgłoszeń kolumn nie podpowiada już nazwy złożonej tylko ze słów o czasie (date_at,timestamp_at), adate_of_birthdostajebirth_date.
Źródła¶
- #297: ta reguła; #98: reguły treści zależne od roli, z których pochodzi.
- fastapi-best-practices: Set DB naming conventions: lower_case_snake, liczba pojedyncza,
_ati_date. - SQLAlchemy: Configuring Constraint Naming Conventions i Declarative Table:
Mapped[datetime]odpowiadaDateTime,Mapped[date]odpowiadaDate. - Django: Model Meta options,
db_table.