Repository navigation
Infer default relationship keys and fix database scaffolding - #317
claude-tmgbedu wants to merge 10 commits into
Conversation
Relationships declared without explicit keys left local/foreign keys as None when accessed through the model class, which is how eager loading, where_has and attach resolve them. Keys are now lazily inferred from the owner and related models using Laravel conventions.
The stub defaulted DB_CONNECTION to pgsql while only defining a sqlite connection, so a freshly published config raised Connection 'pgsql' not found. Default to sqlite and define mysql and postgres connections that match the installer's DB_CONNECTION values.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
claude-tmgbedu
left a comment
There was a problem hiding this comment.
Review + QA — head ee12ed2
Verdict: Changes requested (one small fix). Everything else is non-blocking.
Blocking
HasOneignores a custom owner primary key.relationships/HasOne.py:11still hasself.local_key = local_key or "id", sodefault_keys()["local_key"]never applies.HasManyon the same owner gets this right.
Repro:class Account(Model): __primary_key__ = "uuid"; setting = HasOne["Setting"](); invoices = HasMany["Invoice"]()givesAccount.setting→("id", "account_uuid")butAccount.invoices→("uuid", "account_uuid"). Eager loading then doesrelation._get_value("id")/owner.__attributes__["id"]on a model that has noid.
Fix:self.local_key = local_key. A test with a custom PK would catch it, since every current test usesid.
Non-blocking / for discussion
- Foreign keys and pivot names come from the table name, not the class name (
BaseRelationship.py:99-105). Laravel usessnake(class) + "_" + pk. Two cases differ:- Irregular plurals:
Media→tableize=media→singularize=medium, soMedia.tagsgives pivotmedium_tagand keymedium_id. Laravel givesmedia_tag/media_id. - Custom
__table__: withTag.__table__ = "blog_tags",Tag.postsuses FKblog_tag_id(Laravel:tag_id).
The ADR states this choice, and old BelongsToMany did the same, so this is not a regression. It should still be a deliberate decision, because changing it after release would break apps.
- Irregular plurals:
- Inherited relationships bind to the base class.
owner_classis set in__set_name__, soSpecialItem(BaseItem)inheritingchildren = HasMany[...]()infersbase_item_id. This is an edge case. Document it or leave it as is. - Fresh app:
provider:publish --provider=databasethendb:migrate(before anymake) still fails: firstsqlite3.OperationalError: unable to open database file(nodatabases/), thenFileNotFoundError .../databases/migrations. TheValueError: Connection 'pgsql'is fixed.publish → db:make:model Post -m → db:migrateworks with no directories created beforehand. Could be a follow-up: have migrate create the directory or treat it as "nothing to migrate". HasManyThroughstill has the oldset_keys/"id"defaults, and nothing calls itsset_keys. It is out of scope, but it has the same class of bug as F6.m_through_keyappears in the distant model's attributes ({'id': 1, 'post_id': 1, 'path': 'a.png', 'm_through_key': 1}). Laravel'slaravel_through_keydoes the same, so this is just FYI.
Verified OK
- Explicit keys still win:
HasMany["Post"](foreign_key="author_id")andHasOneThrough(..., local_owner_key="author_id")both work. Keys are only filled when unset. - F6 in a scratch sqlite app with default tables (
users/profiles/posts/media/tags/post_tag), using deprecated string and generic forms:Post.with_("author","tags","media")works.Media.with_("post")works.User.with_("profile","posts","latest_media")works.- Lazy
await post.load("author","tags","media")anduser.load("profile","latest_media")work. Post.where_has("tags")works.
- Inferred keys:
Post.author→author_id/id;Post.media→id/post_id;Post.tags→post_tag, post_id, tag_id;HasOneThrough→id, author_id, id, post_id. - F7: published
config/database.pydefaults tosqliteand defines sqlite/mysql/postgres. - F8:
db:make:model Post -mwritesapp/models/Post.pyanddatabases/migrations/..._create_posts_table.pywithout pre-existing directories.db:migratesucceeds, andfrom app.models.Post import Postimports (tableposts). - ADR 039 is present and indexed in
docs/adr/AGENTS.md, and does not collide with 035–038. The ADR matches the diff against currentmain. The diff adds no explanatory comments or docstrings. - Suite:
uv run pytest --ignore=tests/masoniteorm/postgres --cov→ 3304 passed, 10 skipped, coverage 91.69%.
Re-review + QA — head d4e544dVerdict: Approve once the docs are fixed. The code fixes all three earlier findings. The class-name defaults are the intended Laravel-parity change, but they rename pivots that work on Earlier findings: all fixed
Blocking: breaking default changes need a migration noteI ran the same DB-backed repros against
The PR's own fixtures hit the first case. Unaffected:
Ask:
Non-blocking
Verified OK
|
…ithout a cache, document upgrade notes
claude-tmgbedu
left a comment
There was a problem hiding this comment.
Round-3 review (d4e544df..f5cf9172): APPROVE, one non-blocking doc nit
Tests
cd fastapi_startkit && uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3314 passed, 10 skipped, coverage 91.71% (needs 80%).example/database-app:uv syncfails on this machine becausepydantic-corewon't build (a local environment problem, not this PR). Instead I ran itsartisan db:migratewith the framework venv,DB_CONNECTION=sqlite DB_DATABASE=databases/smoke.sqlite, from a different working directory. All migrations ran, and the file was created atexample/database-app/databases/smoke.sqlite(the app's base_path), not under the working directory.
(3) Connection.resolve_url / sqlite base_path
Scratch repro: base_path=proj/, working directory=cwd/.
| config | main (e6dee88b) |
PR |
|---|---|---|
database="db/a.sqlite" |
relative to the working directory (fails with no dir: main doesn't mkdir) | proj/db/a.sqlite ✅ |
DB_URL=sqlite+aiosqlite:///db/b.sqlite |
relative to the working directory | proj/db/b.sqlite (explicit URLs are rewritten too) |
:memory: |
:memory: |
:memory: ✅ |
| absolute path | same | unchanged, parent dir created ✅ |
- No new crash paths. On main,
create_enginealready calledapp().is_testing(), so standalone ORM use without an Application already raisedContainer not initialized. The PR readsapp().base_pathin the same place.Application.base_pathdefaults toos.getcwd(), so apps that don't passbase_pathbehave exactly as before. - Postgres and MySQL inherit the identity
resolve_url, so they're unaffected.url.set(database=...)keeps query params. - Published config stub
databases/sqlite.db,example/*(base_path=Path(__file__).parent.parent) andmcp-authentication/.env.testing(databases/testing.db): when these run from the project root, which is the usual case, the result is the same as before. When run from anywhere else, they now land in the right place. test_default_connection_is_definedwas moved fromchdirto patchingbase_path, and the new test checks that nothing is written under the working directory. Good.
Nit (non-blocking): The Upgrade notes / Breaking changes table in ADR 039 and the PR body has no row for this change. It only appears in the design section and in F8. It does change behaviour for any app whose working directory differs from base_path and uses a relative sqlite path, including a relative path inside DB_URL. Suggested row:
(e) relative sqlite path (DB_DATABASE / DB_URL) | relative to the working directory | relative to Application.base_path | use an absolute path.
Optionally, also note that relative file: URIs stay relative to the working directory.
(2) Subclass binding via setattr
Scratch repro (Base(notes, tags) → Mid → Leaf, Multi(Base, Other), Shadow(Base): notes = "user value"):
- Leaf accessed before Mid gives
leaf_id, then Mid givesmid_id, and Base staysbase_id.Base.__dict__["notes"].owner_classis stillBase. Leaf.notes is Leaf.notesisTrue, and class-level access returns theHasManydescriptor.- No clobbering. A subclass that defines its own
notesshadows the parent in the MRO, so the parent's__get__never runs (Shadow.notes == "user value"). - Multiple inheritance follows the MRO:
Multi.notesbindsBase's descriptor (multi_id), andMulti.tags._table == "multi_tag". This is correct Python semantics. - Race: 50 threads read
R.notesconcurrently and all got the same keys. The losingsetattrwrites an equivalent copy, so this is harmless. __init_subclass__computes__fillable__before any binding, andcasteronly collectsFieldDescriptor/ModelField. So the injected attribute doesn't leak into fillable or casts.- The GC test (
weakref+gc.collect) is sound, and the WeakKeyDictionary reasoning in the ADR is correct.
Upgrade table spot-check (main vs PR)
- (c)
Media(__table__="media") ↔Tag: mainget_pivot_table_namegivesmedium_tag, PR givesmedia_tag✅ - (d)
User.posts = HasMany("Post"): main{attribute}_id(posts_id, fromHasMany.py:24), PRuser_id✅. On main, class-level access returned(None, None), which matches the "Not affected" note.
AGENTS.md
No new comments or docstrings. ADR and index updated ✅
c78b800 to
9e7943c
Compare
# Conflicts: # docs/adr/AGENTS.md
Fixes F6, F7 and F8 from task #3547. Design:
docs/adr/039-default-relationship-keys-and-db-scaffolding.md.F6: default relationship keys
Root cause: keys were only inferred in
set_keys, which ran on instance access.with_,where_hasandattachuse class-level access, so they sawNonekeys (attribute name must be string,no such column posts.None). Several of the existing defaults were also wrong.Fix: a lazy
RelationshipKeydata descriptor fills unset keys from Laravel-styledefault_keys()on first read. Explicit keys always win.The defaults follow Laravel and come from class names, never from
__table__or singularization:snake(ClassName)_{pk}, soMediagivesmedia_idandLabelwith__table__="blog_labels"giveslabel_id. A custom__primary_key__is respected.HasOne/HasMany: (owner pk, owner FK).BelongsTo: ({attribute}_{related_pk}, related pk).BelongsToMany: the pivot is the two snake class names sorted and joined (essay_label,label_media). The keys are each side's FK and pk. The alphabetical key swap and the table-basedget_pivot_table_namefallback are removed.HasOneThrough: (owner pk, owner FK on the intermediate, intermediate pk, intermediate FK on the distant model), using the select aliasm_through_key.Inheritance: reading a relationship through a subclass returns a copy bound to that subclass, which infers its keys from the subclass (
KeyInvoice(KeyBaseItem)giveskey_invoice_id). The copy is installed on the subclass withsetattr, with no side cache, so a dropped subclass can still be garbage collected. A test covers this.Upgrade notes / Breaking changes
Some default names change for configurations that worked on
main. In each case, pass the old name explicitly to keep it.BelongsToManywithouttable=between models with a custom__table__cp_item_cp_shopcp_item_model_cp_shop_modeltable="cp_item_cp_shop"BelongsToManycp_person_cp_rolecp_admin_cp_roletable="cp_person_cp_role"on the parentmedium_tagmedia_tagtable="medium_tag"HasOne/HasManydefault FK{attribute}_id(posts_id){owner}_{pk}(user_id)foreign_key="posts_id"DB_DATABASE/DB_URL)Application.base_pathThe pivot FKs in (a)–(c) follow the same class-name rule; pass
local_key=/foreign_key=to keep the old ones.Not affected:
table=,local_key=orforeign_key=;BlogPost/blog_postsgivesblog_post_idboth before and after);BelongsTo,HasOneThroughand implicitBelongsToManyreached through the class. These were broken onmain.F7: published database config
Root cause:
default="pgsql", but onlysqlitewas defined.Fix:
default = env("DB_CONNECTION", "sqlite"), withsqlite,mysqlandpostgresconnections.F8: scaffolding and a fresh-app migrate
db:make:modelnow defaults toapp/models. All three generators (model, migration, seed) create their target directories.base_path, not the working directory. This goes through a newConnection.resolve_urlhook. The connection creates the parent directory, skipping:memory:andfile:URIs.db:migratetreats a missing migrations directory as empty.provider:publish --provider=databasethendb:migrateon a fresh app works. So doesdb:make:model Post -mfollowed bydb:migrate.Tests
test_default_relationship_keys.pycovers: class-name keys, the pivot, a custom table, irregular plurals, a custom PK, HasOneThrough, inheritance and subclass GC, plus eager loads andwhere_hasagainst sqlite.DB_CONNECTIONvalue, a missing directory, and resolving a relative path againstbase_path.uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3314 passed, 10 skipped, coverage 91.71%.Follow-ups (not in scope)
m_through_keyalias for it.where_haswith a callback (pre-existing).has/with_counton the async builder.