From 337efbd028683fe84b69769f87cffc18ba5959ef Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 25 Oct 2026 01:34:47 -0700 Subject: [PATCH 1/9] Infer default relationship keys on class-level access 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. --- .../relationships/BaseRelationship.py | 38 ++++- .../masoniteorm/relationships/BelongsTo.py | 9 +- .../relationships/BelongsToMany.py | 83 ++------- .../masoniteorm/relationships/HasMany.py | 7 +- .../masoniteorm/relationships/HasOne.py | 7 +- .../relationships/HasOneThrough.py | 36 ++-- .../test_default_relationship_keys.py | 158 ++++++++++++++++++ 7 files changed, 240 insertions(+), 98 deletions(-) create mode 100644 fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py index 0356fae3..3628bf17 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py @@ -3,6 +3,7 @@ import warnings from typing import TYPE_CHECKING, Any, ForwardRef, Generic, Self, get_args, overload +from inflection import singularize, tableize from typing_extensions import TypeVar from fastapi_startkit.masoniteorm.models import registry @@ -19,7 +20,31 @@ type ManyResult[T] = Collection[T] +class RelationshipKey: + def __set_name__(self, owner, name): + self.name = name + + @overload + def __get__(self, relationship: None, owner: Any) -> Self: ... + @overload + def __get__(self, relationship: object, owner: Any) -> str: ... + def __get__(self, relationship, owner=None) -> Any: + if relationship is None: + return self + if relationship.__dict__.get(self.name) is None and "owner_class" in relationship.__dict__: + for name, value in relationship.default_keys().items(): + if relationship.__dict__.get(name) is None: + relationship.__dict__[name] = value + return relationship.__dict__.get(self.name) + + def __set__(self, relationship: object, value: str | None) -> None: + relationship.__dict__[self.name] = value + + class BaseRelationship(Generic[TModel, TResult]): + local_key: RelationshipKey = RelationshipKey() + foreign_key: RelationshipKey = RelationshipKey() + def __init__(self, fn: str | None = None, local_key=None, foreign_key=None): self.bind_model(fn) @@ -68,6 +93,17 @@ def model_reference(self) -> str | type: def get_builder(self): return self.fn()().get_builder() + def default_keys(self) -> dict[str, str]: + return {} + + @staticmethod + def table_of(model) -> str: + return model.__table__ or tableize(model.__name__) + + @classmethod + def foreign_key_of(cls, model) -> str: + return f"{singularize(cls.table_of(model))}_{model.__primary_key__}" + @overload def __get__(self, instance: None, owner: Any) -> Self: ... @overload @@ -93,8 +129,6 @@ def access(self, instance, owner): if instance is None: return self - self.set_keys(instance, self.attribute) - if not instance.is_loaded(): return self diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsTo.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsTo.py index 8d6a6c70..c40c6185 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsTo.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsTo.py @@ -10,13 +10,12 @@ class BelongsTo(BaseRelationship[TModel, SingleResult[TModel]]): def __init__(self, fn: str | None = None, local_key=None, foreign_key=None): self.bind_model(fn) - self.local_key = local_key or "id" + self.local_key = local_key self.foreign_key = foreign_key - def set_keys(self, owner, attribute): - self.local_key = self.local_key or f"{attribute}_id" - self.foreign_key = self.foreign_key or "id" - return self + def default_keys(self): + related_key = self.related_model().__primary_key__ + return {"local_key": f"{self.attribute}_{related_key}", "foreign_key": related_key} def apply_query(self, foreign, owner): """Apply the query and return a dictionary to be hydrated diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py index d4576fb8..210f0381 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py @@ -1,7 +1,7 @@ import pendulum from inflection import singularize -from .BaseRelationship import BaseRelationship, ManyResult, TModel +from .BaseRelationship import BaseRelationship, ManyResult, RelationshipKey, TModel from ..collection import Collection from ..models.pivot import Pivot @@ -9,6 +9,10 @@ class BelongsToMany(BaseRelationship[TModel, ManyResult[TModel]]): """Has Many Relationship Class.""" + local_owner_key: RelationshipKey = RelationshipKey() + other_owner_key: RelationshipKey = RelationshipKey() + _table: RelationshipKey = RelationshipKey() + def __init__( self, fn: str | None = None, @@ -26,8 +30,8 @@ def __init__( self.local_key = local_foreign_key self.foreign_key = other_foreign_key - self.local_owner_key = local_owner_key or "id" - self.other_owner_key = other_owner_key or "id" + self.local_owner_key = local_owner_key + self.other_owner_key = other_owner_key self._table = table self.with_timestamps = with_timestamps @@ -35,10 +39,16 @@ def __init__( self.pivot_id = pivot_id self.with_fields = with_fields - def set_keys(self, owner, attribute): - self.local_key = self.local_key or "id" - self.foreign_key = self.foreign_key or f"{attribute}_id" - return self + def default_keys(self): + owner = self.owner_class + related = self.related_model() + return { + "_table": "_".join(sorted(singularize(self.table_of(model)) for model in (owner, related))), + "local_key": self.foreign_key_of(owner), + "foreign_key": self.foreign_key_of(related), + "local_owner_key": owner.__primary_key__, + "other_owner_key": related.__primary_key__, + } async def apply_query(self, foreign, owner): """Apply the query and return a dictionary to be hydrated. @@ -52,21 +62,6 @@ async def apply_query(self, foreign, owner): dict -- A dictionary of data which will be hydrated. """ - if not self._table: - pivot_tables = [ - singularize(owner.get_table_name()), - singularize(foreign.get_table_name()), - ] - pivot_tables.sort() - pivot_table_1, pivot_table_2 = pivot_tables - self._table = "_".join(pivot_tables) - self.foreign_key = self.foreign_key or f"{pivot_table_1}_id" - self.local_key = self.local_key or f"{pivot_table_2}_id" - elif self.local_key is None or self.foreign_key is None: - pivot_table_1, pivot_table_2 = self._table.split("_", 1) - self.foreign_key = self.foreign_key or f"{pivot_table_1}_id" - self.local_key = self.local_key or f"{pivot_table_2}_id" - table1 = owner.get_table_name() table2 = foreign.get_table_name() result = foreign.select( @@ -163,21 +158,6 @@ async def make_query(self, query, relation, eagers=None, callback=None): eagers = eagers or [] builder = self.get_builder().with_(eagers) - if not self._table: - pivot_tables = [ - singularize(builder.get_table_name()), - singularize(query.get_table_name()), - ] - pivot_tables.sort() - pivot_table_1, pivot_table_2 = pivot_tables - self._table = "_".join(pivot_tables) - self.foreign_key = self.foreign_key or f"{pivot_table_1}_id" - self.local_key = self.local_key or f"{pivot_table_2}_id" - elif self.local_key is None or self.foreign_key is None: - pivot_table_1, pivot_table_2 = self._table.split("_", 1) - self.foreign_key = self.foreign_key or f"{pivot_table_1}_id" - self.local_key = self.local_key or f"{pivot_table_2}_id" - table2 = builder.get_table_name() table1 = query.get_table_name() result = ( @@ -278,21 +258,6 @@ def relate(self, related_record): owner = related_record.get_builder() query = self.get_builder() - if not self._table: - pivot_tables = [ - singularize(owner.get_table_name()), - singularize(query.get_table_name()), - ] - pivot_tables.sort() - pivot_table_1, pivot_table_2 = pivot_tables - self._table = "_".join(pivot_tables) - self.foreign_key = self.foreign_key or f"{pivot_table_1}_id" - self.local_key = self.local_key or f"{pivot_table_2}_id" - elif self.local_key is None or self.foreign_key is None: - pivot_table_1, pivot_table_2 = self._table.split("_", 1) - self.foreign_key = self.foreign_key or f"{pivot_table_1}_id" - self.local_key = self.local_key or f"{pivot_table_2}_id" - table1 = owner.get_table_name() table2 = query.get_table_name() result = query.select( @@ -339,20 +304,6 @@ def register_related(self, key, model, collection): model.add_relation({key: collection.where(f"{self._table}_id", getattr(model, self.local_owner_key))}) def joins(self, builder, clause=None): - if not self._table: - pivot_tables = [ - singularize(self.get_builder().get_table_name()), - singularize(builder.get_table_name()), - ] - pivot_tables.sort() - pivot_table_1, pivot_table_2 = pivot_tables - self._table = "_".join(pivot_tables) - self.foreign_key = self.foreign_key or f"{pivot_table_1}_id" - self.local_key = self.local_key or f"{pivot_table_2}_id" - elif self.local_key is None or self.foreign_key is None: - pivot_table_1, pivot_table_2 = self._table.split("_", 1) - self.foreign_key = self.foreign_key or f"{pivot_table_1}_id" - self.local_key = self.local_key or f"{pivot_table_2}_id" query = self.get_builder() table1 = query.get_table_name() diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasMany.py index 363268f1..00c93f36 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasMany.py @@ -19,11 +19,8 @@ def apply_query(self, foreign, owner): return result - def set_keys(self, owner, attribute): - self.local_key = self.local_key or "id" - self.foreign_key = self.foreign_key or f"{attribute}_id" - - return self + def default_keys(self): + return {"local_key": self.owner_class.__primary_key__, "foreign_key": self.foreign_key_of(self.owner_class)} def register_related(self, key, model, collection): model.add_relation({key: Collection(collection.get(getattr(model, self.local_key)) or [])}) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py index 42b3b1d4..e39ff6f7 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py @@ -11,11 +11,8 @@ def __init__(self, fn: str | None = None, foreign_key=None, local_key=None): self.local_key = local_key or "id" self.foreign_key = foreign_key - def set_keys(self, owner, attribute): - self.local_key = self.local_key or "id" - self.foreign_key = self.foreign_key or f"{attribute}_id" - - return self + def default_keys(self): + return {"local_key": self.owner_class.__primary_key__, "foreign_key": self.foreign_key_of(self.owner_class)} def apply_query(self, foreign, owner): return foreign.where(self.foreign_key, owner.__attributes__[self.local_key]).first() diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py index fc9e7679..0f66cd89 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOneThrough.py @@ -1,12 +1,16 @@ from ..collection import Collection from ..exceptions import LazyLoadingViolation -from .BaseRelationship import BaseRelationship, SingleResult, TModel +from .BaseRelationship import BaseRelationship, RelationshipKey, SingleResult, TModel from fastapi_startkit.masoniteorm.models import registry class HasOneThrough(BaseRelationship[TModel, SingleResult[TModel]]): """HasOneThrough Relationship Class.""" + through_key = "m_through_key" + local_owner_key: RelationshipKey = RelationshipKey() + other_owner_key: RelationshipKey = RelationshipKey() + def __init__( self, fn: list[str], @@ -17,15 +21,16 @@ def __init__( ): self.fn = fn - self.local_key = local_foreign_key or "id" + self.local_key = local_foreign_key self.foreign_key = other_foreign_key - self.local_owner_key = local_owner_key or "id" - self.other_owner_key = other_owner_key or "id" + self.local_owner_key = local_owner_key + self.other_owner_key = other_owner_key self.distant_builder = None self.intermediary_builder = None def __set_name__(self, owner, name): self.attribute = name + self.owner_class = owner def get_distance_builder(self): """Return a fresh distant builder (never cached — builders are stateful).""" @@ -37,16 +42,17 @@ def get_intermediary_builder(self): model = registry.Registry.resolve(self.fn[1]) return model().get_builder() - def set_keys(self, attribute): - self.local_key = self.local_key or "id" - self.foreign_key = self.foreign_key or f"{attribute}_id" - self.local_owner_key = self.local_owner_key or "id" - self.other_owner_key = self.other_owner_key or "id" - return self + def default_keys(self): + owner = self.owner_class + intermediary = registry.Registry.resolve(self.fn[1]) + return { + "local_key": owner.__primary_key__, + "local_owner_key": self.foreign_key_of(owner), + "foreign_key": intermediary.__primary_key__, + "other_owner_key": self.foreign_key_of(intermediary), + } def access(self, instance, owner): - self.set_keys(self.attribute) - if instance is None or not instance.is_loaded(): return self @@ -60,7 +66,7 @@ def apply_relation_query(self, distant_builder, intermediary_builder, owner): int_table = intermediary_builder.get_table_name() return ( - distant_builder.select(f"{dist_table}.*, {int_table}.{self.local_owner_key} as {self.local_key}") + distant_builder.select(f"{dist_table}.*, {int_table}.{self.local_owner_key} as {self.through_key}") .join( f"{int_table}", f"{int_table}.{self.foreign_key}", @@ -134,7 +140,7 @@ async def get_related(self, query, relation, eagers=None, callback=None): if callback: callback(query) - distant_builder.select(f"{dist_table}.*, {int_table}.{self.local_owner_key} as {self.local_key}").join( + distant_builder.select(f"{dist_table}.*, {int_table}.{self.local_owner_key} as {self.through_key}").join( f"{int_table}", f"{int_table}.{self.foreign_key}", "=", @@ -193,4 +199,4 @@ def query_where_exists(self, builder, callback, method="where_exists"): ) def map_related(self, related_result): - return related_result.group_by(self.local_key) + return related_result.group_by(self.through_key) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py new file mode 100644 index 00000000..694509da --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py @@ -0,0 +1,158 @@ +import warnings + +from fastapi_startkit.masoniteorm import BelongsTo, BelongsToMany, HasMany, HasOne, Model +from fastapi_startkit.masoniteorm.relationships import HasOneThrough + +from ..test_case import TestCase + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + class DefaultKeyWriter(Model): + __table__ = "writers" + __timestamps__ = False + + biography = HasOne["DefaultKeyBiography"]() + posts = HasMany["DefaultKeyPost"]() + latest_media = HasOneThrough(["DefaultKeyMedia", "DefaultKeyPost"]) + + class DefaultKeyBiography(Model): + __table__ = "biographies" + __timestamps__ = False + + writer = BelongsTo["DefaultKeyWriter"]() + + class DefaultKeyPost(Model): + __table__ = "posts" + __timestamps__ = False + + writer = BelongsTo["DefaultKeyWriter"]() + media = HasMany("DefaultKeyMedia") + tags = BelongsToMany["DefaultKeyTag"]() + + class DefaultKeyMedia(Model): + __table__ = "media" + __timestamps__ = False + + post = BelongsTo("DefaultKeyPost") + + class DefaultKeyTag(Model): + __table__ = "tags" + __timestamps__ = False + + posts = BelongsToMany["DefaultKeyPost"]() + + +class TestDefaultRelationshipKeys(TestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + schema = self.schema.on("default") + + async with await schema.create_table_if_not_exists("writers") as table: + table.id() + table.string("name") + + async with await schema.create_table_if_not_exists("biographies") as table: + table.id() + table.integer("writer_id") + table.string("body") + + async with await schema.create_table_if_not_exists("posts") as table: + table.id() + table.integer("writer_id") + table.string("title") + + async with await schema.create_table_if_not_exists("media") as table: + table.id() + table.integer("post_id") + table.string("path") + + async with await schema.create_table_if_not_exists("tags") as table: + table.id() + table.string("name") + + async with await schema.create_table_if_not_exists("post_tag") as table: + table.id() + table.integer("post_id") + table.integer("tag_id") + + ada = await DefaultKeyWriter.create({"name": "Ada"}) + await DefaultKeyWriter.create({"name": "Grace"}) + await DefaultKeyBiography.create({"writer_id": ada.id, "body": "Analyst"}) + post = await DefaultKeyPost.create({"writer_id": ada.id, "title": "Engines"}) + await DefaultKeyPost.create({"writer_id": ada.id, "title": "Notes"}) + await DefaultKeyMedia.create({"post_id": post.id, "path": "engine.png"}) + tag = await DefaultKeyTag.create({"name": "math"}) + await DefaultKeyTag.create({"name": "unused"}) + await DefaultKeyPost.tags.attach(post, tag) + + def test_keys_are_inferred_on_class_access(self): + assert (DefaultKeyWriter.posts.local_key, DefaultKeyWriter.posts.foreign_key) == ("id", "writer_id") + assert (DefaultKeyWriter.biography.local_key, DefaultKeyWriter.biography.foreign_key) == ("id", "writer_id") + assert (DefaultKeyPost.writer.local_key, DefaultKeyPost.writer.foreign_key) == ("writer_id", "id") + assert (DefaultKeyMedia.post.local_key, DefaultKeyMedia.post.foreign_key) == ("post_id", "id") + + tags = DefaultKeyPost.tags + assert (tags._table, tags.local_key, tags.foreign_key) == ("post_tag", "post_id", "tag_id") + assert (tags.local_owner_key, tags.other_owner_key) == ("id", "id") + + posts = DefaultKeyTag.posts + assert (posts._table, posts.local_key, posts.foreign_key) == ("post_tag", "tag_id", "post_id") + + through = DefaultKeyWriter.latest_media + assert (through.local_key, through.local_owner_key) == ("id", "writer_id") + assert (through.foreign_key, through.other_owner_key) == ("id", "post_id") + + async def test_eager_loads_default_key_relationships(self): + posts = await DefaultKeyPost.with_("writer", "tags", "media").where("title", "Engines").get() + post = posts.first() + + assert post.writer.name == "Ada" + assert [tag.name for tag in post.tags] == ["math"] + assert [media.path for media in post.media] == ["engine.png"] + + media = await DefaultKeyMedia.with_("post").first_or_fail() + assert media.post.title == "Engines" + + tag = await DefaultKeyTag.with_("posts").where("name", "math").first_or_fail() + assert [item.title for item in tag.posts] == ["Engines"] + + async def test_eager_loads_default_key_has_one_and_has_many(self): + writer = await DefaultKeyWriter.with_("biography", "posts").where("name", "Ada").first_or_fail() + + assert writer.biography.body == "Analyst" + assert sorted(post.title for post in writer.posts) == ["Engines", "Notes"] + + biography = await DefaultKeyBiography.with_("writer").first_or_fail() + assert biography.writer.name == "Ada" + + async def test_eager_loads_default_key_has_one_through(self): + writer = await DefaultKeyWriter.with_("latest_media").where("name", "Ada").first_or_fail() + + assert writer.latest_media.path == "engine.png" + assert writer.latest_media.id is not None + + lonely = await DefaultKeyWriter.with_("latest_media").where("name", "Grace").first_or_fail() + assert lonely.latest_media is None + + async def test_existence_queries_correlate_default_keys(self): + with_posts = await DefaultKeyWriter.where_has("posts").get() + assert [writer.name for writer in with_posts] == ["Ada"] + + engines = await DefaultKeyWriter.where_has("posts", lambda query: query.where("title", "Engines")).get() + assert [writer.name for writer in engines] == ["Ada"] + + tagged = await DefaultKeyTag.where_has("posts").get() + assert [tag.name for tag in tagged] == ["math"] + + with_biography = await DefaultKeyWriter.where_has("biography").get() + assert [writer.name for writer in with_biography] == ["Ada"] + + with_writer = await DefaultKeyPost.where_has("writer", lambda query: query.where("name", "Ada")).get() + assert sorted(post.title for post in with_writer) == ["Engines", "Notes"] + + tagged_posts = await DefaultKeyPost.where_has("tags").get() + assert [post.title for post in tagged_posts] == ["Engines"] + + with_media = await DefaultKeyWriter.where_has("latest_media").get() + assert [writer.name for writer in with_media] == ["Ada"] From e8b21a731f1dbfef626eee6894d73cb2451d8228 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 25 Oct 2026 01:35:54 -0700 Subject: [PATCH 2/9] Default published database config to a defined connection 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. --- .../masoniteorm/config/database.py | 10 +++++++--- .../config/test_database_config.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/database.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/database.py index f79f3d9f..0180704e 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/database.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/database.py @@ -2,15 +2,19 @@ from fastapi_startkit.configuration import Config from fastapi_startkit.environment import env -from fastapi_startkit.masoniteorm.config.config import DBConfig, SQLiteConfig +from fastapi_startkit.masoniteorm.config.config import DBConfig, MySQLConfig, PostgresConfig, SQLiteConfig @dataclass class DatabaseConfig(Config): - default: str = env("DB_CONNECTION", "pgsql") + default: str = env("DB_CONNECTION", "sqlite") connections: dict[str, DBConfig] = field( - default_factory=lambda: {"sqlite": SQLiteConfig(database=env("DB_DATABASE", "databases/sqlite.db"))} + default_factory=lambda: { + "sqlite": SQLiteConfig(database=env("DB_DATABASE", "databases/sqlite.db")), + "mysql": MySQLConfig(), + "postgres": PostgresConfig(), + } ) migrations: dict[str, str] = field( diff --git a/fastapi_startkit/tests/masoniteorm/config/test_database_config.py b/fastapi_startkit/tests/masoniteorm/config/test_database_config.py index 96bd1449..7bbfa717 100644 --- a/fastapi_startkit/tests/masoniteorm/config/test_database_config.py +++ b/fastapi_startkit/tests/masoniteorm/config/test_database_config.py @@ -77,3 +77,23 @@ def test_provider_rejects_dictionary_connection_settings(tmp_path): config = DatabaseConfig(default="sqlite", connections={"sqlite": {"driver": "sqlite"}}) with pytest.raises(TypeError, match="Database connection 'sqlite' requires a database config object"): DatabaseProvider(app, config).register() + + +def test_default_connection_is_defined(monkeypatch): + monkeypatch.delenv("DB_CONNECTION", raising=False) + config = DatabaseConfig() + + assert config.default == "sqlite" + assert isinstance(DatabaseManager(ConnectionFactory(), config).connection(), SQliteConnection) + + +@pytest.mark.parametrize( + "name,config_type", + [("sqlite", SQLiteConfig), ("mysql", MySQLConfig), ("postgres", PostgresConfig)], +) +def test_db_connection_selects_a_defined_connection(monkeypatch, name, config_type): + monkeypatch.setenv("DB_CONNECTION", name) + config = DatabaseConfig() + + assert config.default == name + assert isinstance(config.connections[config.default], config_type) From 49deb80fa108825e478ee7df7b9ceee77008a9b1 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 25 Oct 2026 01:39:13 -0700 Subject: [PATCH 3/9] Generate models into app/models and create generator target directories --- .../commands/MakeMigrationCommand.py | 2 ++ .../masoniteorm/commands/MakeModelCommand.py | 6 ++--- .../masoniteorm/commands/MakeSeedCommand.py | 2 ++ .../commands/test_make_commands.py | 26 ++++++++----------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeMigrationCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeMigrationCommand.py index 1eba02af..0894888f 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeMigrationCommand.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeMigrationCommand.py @@ -56,6 +56,8 @@ def handle(self) -> int: file_name = f"{now.strftime('%Y_%m_%d_%H%M%S')}_{name}.py" + os.makedirs(os.path.join(os.getcwd(), migration_directory), exist_ok=True) + with open(os.path.join(os.getcwd(), migration_directory, file_name), "w") as fp: fp.write(output) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py index ac7bbed1..63330c53 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py @@ -36,7 +36,7 @@ class MakeModelCommand(Command): "directory", "d", flag=False, - default="app", + default="app/models", description="The location of the model directory", ), option( @@ -75,9 +75,9 @@ def handle(self) -> int: self.line(f'Model "{name}" Already Exists ({full_directory_path}/{file_name})') return 0 - os.makedirs(os.path.dirname(os.path.join(full_directory_path)), exist_ok=True) + os.makedirs(full_directory_path, exist_ok=True) - with open(os.path.join(os.getcwd(), model_directory, file_name), "w+") as fp: + with open(os.path.join(full_directory_path, file_name), "w+") as fp: fp.write(output) self.info(f"Model created: {os.path.join(model_directory, file_name)}") diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeSeedCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeSeedCommand.py index dc1dc4b4..44bdc9fb 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeSeedCommand.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeSeedCommand.py @@ -49,6 +49,8 @@ def handle(self) -> int: self.line(f"{path_normalized} already exists.") return 0 + full_path.parent.mkdir(parents=True, exist_ok=True) + with open(full_path, "w") as fp: fp.write(output) diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py index b8b7c6b9..375f8e08 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py @@ -186,30 +186,26 @@ class TestMakeModelCommand(_TempCwdTestCase): # there is nothing to run against a database -- the generated file content # is the only meaningful contract to assert. def test_creates_model_file(self): - os.makedirs("app") tester = self._tester(MakeModelCommand) tester.execute("Post") output = tester.io.fetch_output() self.assertIn("Model created:", output) - content = open(os.path.join("app", "Post.py")).read() + content = open(os.path.join("app/models", "Post.py")).read() self.assertIn("class Post(Model)", content) def test_generated_model_imports(self): - os.makedirs("app") self._tester(MakeModelCommand).execute("Post") - content = open(os.path.join("app", "Post.py")).read() + content = open(os.path.join("app/models", "Post.py")).read() self.assertIn("from fastapi_startkit.masoniteorm.models import Model", content) self.assertNotIn('"""', content) - post_class = self._load_class(os.path.join("app", "Post.py"), "Post") + post_class = self._load_class(os.path.join("app/models", "Post.py"), "Post") self.assertTrue(issubclass(post_class, Model)) def test_migration_option_creates_migration_through_console(self): - os.makedirs("app") - os.makedirs("databases/migrations") app = create_app() self.assertEqual(app.run("db:make:model", "Post --migration"), 0) @@ -220,8 +216,6 @@ def test_migration_option_creates_migration_through_console(self): self.assertIn('self.schema.create("posts")', content) def test_migration_and_table_options_create_update_migration_through_console(self): - os.makedirs("app") - os.makedirs("databases/migrations") app = create_app() self.assertEqual(app.run("db:make:model", "Post --migration --table"), 0) @@ -232,8 +226,6 @@ def test_migration_and_table_options_create_update_migration_through_console(sel self.assertIn('self.schema.table("posts")', content) def test_seeder_option_creates_seeder_through_console(self): - os.makedirs("app") - os.makedirs("databases/seeders") app = create_app() self.assertEqual(app.run("db:make:model", "Post --seeder"), 0) @@ -241,14 +233,12 @@ def test_seeder_option_creates_seeder_through_console(self): self.assertTrue(os.path.exists(os.path.join("databases/seeders", "post_table_seeder.py"))) def test_pep_option_uses_underscore_filename(self): - os.makedirs("app") tester = self._tester(MakeModelCommand) tester.execute("BlogPost --pep") - self.assertTrue(os.path.exists(os.path.join("app", "blog_post.py"))) + self.assertTrue(os.path.exists(os.path.join("app/models", "blog_post.py"))) def test_reports_when_model_already_exists(self): - os.makedirs("app") self._tester(MakeModelCommand).execute("Post") tester = self._tester(MakeModelCommand) @@ -257,12 +247,18 @@ def test_reports_when_model_already_exists(self): self.assertIn('Model "Post" Already Exists', output) def test_custom_directory_option(self): - os.makedirs("models") tester = self._tester(MakeModelCommand) tester.execute("Post --directory models") self.assertTrue(os.path.exists(os.path.join("models", "Post.py"))) + def test_generated_model_resolves_through_the_package_import(self): + self._tester(MakeModelCommand).execute("Post") + + content = open(os.path.join("app/models", "Post.py")).read() + self.assertNotIn("from masoniteorm", content) + self.assertTrue(issubclass(self._load_class(os.path.join("app/models", "Post.py"), "Post"), Model)) + class TestMakeSeedCommand(_TempCwdTestCase): # make:seed emits a Seeder subclass with an empty ``run`` body; it performs From ee12ed2109656b49d1c421991920fa814be2031c Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 25 Oct 2026 01:42:29 -0700 Subject: [PATCH 4/9] Add ADR 039 for default relationship keys and database scaffolding --- ...lt-relationship-keys-and-db-scaffolding.md | 57 +++++++++++++++++++ docs/adr/AGENTS.md | 1 + 2 files changed, 58 insertions(+) create mode 100644 docs/adr/039-default-relationship-keys-and-db-scaffolding.md diff --git a/docs/adr/039-default-relationship-keys-and-db-scaffolding.md b/docs/adr/039-default-relationship-keys-and-db-scaffolding.md new file mode 100644 index 00000000..5c38132d --- /dev/null +++ b/docs/adr/039-default-relationship-keys-and-db-scaffolding.md @@ -0,0 +1,57 @@ +# 039. Default relationship keys and working database scaffolding + +Date: 2026-10-25 + +## Problem + +Three defects stop a fresh application from getting past its first model. + +1. **Default relationship keys.** A relationship declared without keys, such as `HasMany("Media")`, `BelongsTo("Post")` or `BelongsToMany["Tag"]()`, only filled its keys in `set_keys`, and `set_keys` ran only on instance access. Eager loading (`with_`), `where_has` and `attach` all reach the relationship through the class (`getattr(Model, name)`), so they saw `None` keys. The results were `TypeError: attribute name must be string` and `no such column posts.None`. The defaults were also wrong where they existed: + - `HasOne`/`HasMany` used `{attribute}_id` instead of the owner's foreign key. + - `BelongsTo` defaulted `local_key` to `"id"`. + - `BelongsToMany` assigned pivot keys in alphabetical table order, which swapped them for `Post → Tag`. + - `HasOneThrough` defaulted every key to `"id"`, and its select alias overwrote the distant model's `id`. +2. **Published database config.** The `database` stub set `default="pgsql"` but defined only a `sqlite` connection, so the first query raised `ValueError: Connection 'pgsql' not found`. +3. **Generators.** `db:make:model` defaulted to `app/`, not `app/models/`, and created the parent of its target directory instead of the directory itself. `db:make:migration` and the `seed` generator never created their directories either. As a result, `db:make:model Post -m` failed with `FileNotFoundError` in a new application. + +## Alternatives + +For the keys: + +1. Infer keys inside `access` when the owner is `None`. This is not possible. `Model.__init__` reads class attributes while building casts, so inferring there instantiates models while they are being constructed and recurses. +2. Infer keys eagerly in `__set_name__`. This is also not possible: string targets and later-declared classes are not resolvable yet. +3. Make the keys lazy data descriptors that infer on first read. + +For the config, either keep `pgsql` and add a Postgres connection, or read `DB_CONNECTION` and define every connection the installer can write. + +## Decision + +- **Keys.** I chose alternative 3, `RelationshipKey`. + - Each key is a data descriptor on the relationship class. On first read, after `__set_name__` has set `owner_class`, it fills every unset key from `default_keys()`. + - Explicit keys always win. + - The defaults follow Laravel: + - `HasOne`/`HasMany`: the owner's primary key, and `singular(owner_table)_pk` on the related model. + - `BelongsTo`: `{attribute}_{related_pk}`, and the related primary key. + - `BelongsToMany`: the pivot table is the two singular table names sorted and joined with `_`; the keys are each side's foreign key and each side's primary key. + - `HasOneThrough`: the owner pk, `singular(owner_table)_pk` on the intermediate model, the intermediate pk, and `singular(intermediate_table)_pk` on the distant model. + - Table names come from `__table__` or `tableize(class_name)`, so no model is instantiated. + - The four duplicated alphabetical pivot blocks in `BelongsToMany` are removed. + - `HasOneThrough` selects its join key as the fixed alias `m_through_key`. +- **Config.** + - `default` is now `env("DB_CONNECTION", "sqlite")`. + - The stub defines `sqlite`, `mysql` and `postgres`, the values the installer writes. +- **Generators.** + - `db:make:model` defaults to `app/models` and creates that directory. + - `db:make:migration` and `seed` create their target directories. + - `-m` already calls `db:make:migration`, and the model stub already imports `fastapi_startkit.masoniteorm.models`. Both stay as they are. + +## Validation + +- `tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py` declares every relationship type with no keys, in both generic and string form. It checks: + - the inferred keys on class access; + - eager loads in both directions; + - `HasOneThrough` loading and missing rows; + - `where_has` with and without callbacks. +- The database config tests check that the default resolves to a defined connection, and that each `DB_CONNECTION` value selects one. +- The make-command tests no longer pre-create directories. They also import a generated model through the package. +- In a temporary application, publishing the config, then running `db:make:model Post -m` and `db:migrate`, creates `app/models/Post.py` and the migration, and migrates on sqlite. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index c13f7f1b..95f533fc 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,3 +35,4 @@ Read this index first, then open the records relevant to the change. Before impl | [029](029-cache-module.md) | 2026-10-21 | Cache module with remember, memo, locks and tags | New `fastapi_startkit.cache`: async `Cache.get/put/has/forget/flush`, `get` with a callable default, and `remember(key, ttl, factory)` that runs a sync or async factory only on a miss; `remember(key, ttl)` without a factory is a decorator keyed by `key` plus a hash of the call arguments. Stores implement an async contract with a `MISSING` sentinel (cached `None` is a hit): array, file (pickled per-key files), database (masoniteorm table) and redis (named connection from the existing `RedisManager`, `SET PX` TTL, flush by `SCAN` of prefixed keys, never `FLUSHDB`). TTL in seconds or `timedelta`, `None` forever, <=0 forgets. `CacheConfig` via `resolve_config`; `CacheProvider` binds `cache`; facade `remember` resolves lazily. `Cache.memo()` memoizes reads per asyncio task through a `ContextVar` owned by the task, so requests never share memo state. `Cache.lock(name, seconds, owner)` gives atomic, owner-checked, non-re-entrant locks with `get`, `block` (250 ms polling, `LockTimeoutError`), `release`, `force_release` and `async with`, per store: event-loop dict, `fcntl.flock` file, `lock:` row with update-if-expired, Redis `SET NX PX` plus a Lua owner-checked delete. `Cache.tags([...])` uses Laravel's namespaced keys: random tag ids at `:tag::key`, entries at `:sha1(ids):`, flush rewrites the ids; tags are sorted and deduplicated. Shipped in slices into `feature/caches`; the former memo, lock and tags ADRs (030, 031, 034) are folded in. | | [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. | | [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. | +| [039](039-default-relationship-keys-and-db-scaffolding.md) | 2026-10-25 | Default relationship keys and working database scaffolding | Relationship keys are lazy `RelationshipKey` data descriptors that fill unset keys from Laravel-style `default_keys()` on first read, so class-level access from `with_`, `where_has` and `attach` works without explicit keys (HasOne/HasMany/BelongsTo/BelongsToMany/HasOneThrough; alphabetical pivot key swap removed; HasOneThrough alias `m_through_key`). The published database config reads `DB_CONNECTION` (default `sqlite`) and defines sqlite/mysql/postgres. `db:make:model` writes to `app/models`, and the model, migration and seed generators create their directories. | From d4e544dfa827e7c9837cc19484ba4d37577efb81 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 25 Oct 2026 13:25:00 -0700 Subject: [PATCH 5/9] Use class-name relationship keys, honour custom primary keys and migrate fresh apps --- ...lt-relationship-keys-and-db-scaffolding.md | 26 ++- docs/adr/AGENTS.md | 2 +- .../connections/sqlite_connection.py | 9 + .../masoniteorm/migrations/Migrator.py | 22 +- .../relationships/BaseRelationship.py | 33 ++- .../relationships/BelongsToMany.py | 24 +- .../masoniteorm/relationships/HasOne.py | 2 +- .../commands/test_migrate_commands.py | 13 ++ .../config/test_database_config.py | 13 +- .../masoniteorm/fixtures/typed/owners.py | 1 + .../relationships/test_belongs_to_many.py | 9 +- .../test_default_relationship_keys.py | 219 +++++++++++------- .../test_inferred_relationship_model.py | 1 + 13 files changed, 228 insertions(+), 146 deletions(-) diff --git a/docs/adr/039-default-relationship-keys-and-db-scaffolding.md b/docs/adr/039-default-relationship-keys-and-db-scaffolding.md index 5c38132d..96be851f 100644 --- a/docs/adr/039-default-relationship-keys-and-db-scaffolding.md +++ b/docs/adr/039-default-relationship-keys-and-db-scaffolding.md @@ -4,15 +4,18 @@ Date: 2026-10-25 ## Problem -Three defects stop a fresh application from getting past its first model. +Four defects stop a fresh application from getting past its first model. 1. **Default relationship keys.** A relationship declared without keys, such as `HasMany("Media")`, `BelongsTo("Post")` or `BelongsToMany["Tag"]()`, only filled its keys in `set_keys`, and `set_keys` ran only on instance access. Eager loading (`with_`), `where_has` and `attach` all reach the relationship through the class (`getattr(Model, name)`), so they saw `None` keys. The results were `TypeError: attribute name must be string` and `no such column posts.None`. The defaults were also wrong where they existed: - - `HasOne`/`HasMany` used `{attribute}_id` instead of the owner's foreign key. + - `HasOne`/`HasMany` used `{attribute}_id` instead of the owner's foreign key, and `HasOne` defaulted `local_key` to `"id"` even when `__primary_key__` was custom. + - Foreign keys and pivot names came from the singularized table name, so `Media` became `medium_id` and `__table__ = "blog_tags"` became `blog_tag_id`. + - A relationship declared on a base model kept the base class as its owner, so a subclass inferred `base_item_id`. - `BelongsTo` defaulted `local_key` to `"id"`. - `BelongsToMany` assigned pivot keys in alphabetical table order, which swapped them for `Post → Tag`. - `HasOneThrough` defaulted every key to `"id"`, and its select alias overwrote the distant model's `id`. 2. **Published database config.** The `database` stub set `default="pgsql"` but defined only a `sqlite` connection, so the first query raised `ValueError: Connection 'pgsql' not found`. 3. **Generators.** `db:make:model` defaulted to `app/`, not `app/models/`, and created the parent of its target directory instead of the directory itself. `db:make:migration` and the `seed` generator never created their directories either. As a result, `db:make:model Post -m` failed with `FileNotFoundError` in a new application. +4. **Migrating a fresh application.** Running `db:migrate` right after `provider:publish --provider=database` failed with `unable to open database file`, because `databases/` did not exist. Once that directory existed, it failed again with `FileNotFoundError`, because `databases/migrations` did not exist either. ## Alternatives @@ -30,11 +33,13 @@ For the config, either keep `pgsql` and add a Postgres connection, or read `DB_C - Each key is a data descriptor on the relationship class. On first read, after `__set_name__` has set `owner_class`, it fills every unset key from `default_keys()`. - Explicit keys always win. - The defaults follow Laravel: - - `HasOne`/`HasMany`: the owner's primary key, and `singular(owner_table)_pk` on the related model. + - A model's foreign key is `snake(ClassName)_pk`, like Laravel's `getForeignKey()`. It is taken from the class name, so neither `__table__` nor singularization affects it (`Media` gives `media_id`). + - `HasOne`/`HasMany`: the owner's primary key, and the owner's foreign key on the related model. - `BelongsTo`: `{attribute}_{related_pk}`, and the related primary key. - - `BelongsToMany`: the pivot table is the two singular table names sorted and joined with `_`; the keys are each side's foreign key and each side's primary key. - - `HasOneThrough`: the owner pk, `singular(owner_table)_pk` on the intermediate model, the intermediate pk, and `singular(intermediate_table)_pk` on the distant model. - - Table names come from `__table__` or `tableize(class_name)`, so no model is instantiated. + - `BelongsToMany`: the pivot table is the two snake class names sorted and joined with `_` (`media_tag`). The keys are each side's foreign key and each side's primary key. + - `HasOneThrough`: the owner pk, the owner's foreign key on the intermediate model, the intermediate pk, and the intermediate's foreign key on the distant model. + - Inferred keys are cached separately from explicit ones. Reading a relationship through a subclass returns a copy bound to that subclass and cached on the descriptor. The copy keeps the explicit keys and infers the rest from the subclass, matching Laravel, where `$this` is the concrete model. + - The table-based `BelongsToMany.get_pivot_table_name` fallback is removed, because `_table` is always inferred. - The four duplicated alphabetical pivot blocks in `BelongsToMany` are removed. - `HasOneThrough` selects its join key as the fixed alias `m_through_key`. - **Config.** @@ -44,14 +49,23 @@ For the config, either keep `pgsql` and add a Postgres connection, or read `DB_C - `db:make:model` defaults to `app/models` and creates that directory. - `db:make:migration` and `seed` create their target directories. - `-m` already calls `db:make:migration`, and the model stub already imports `fastapi_startkit.masoniteorm.models`. Both stay as they are. +- **Fresh migrate.** + - `SQliteConnection` creates the parent directory of a file database. In-memory and `file:` URIs are skipped. + - `Migrator.migration_files()` returns an empty list when the migrations directory is missing, so `db:migrate` creates the tracking table and reports nothing to migrate. Laravel behaves the same way. +- **Out of scope.** `HasManyThrough` default keys and the `m_through_key` alias are follow-ups. ## Validation - `tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py` declares every relationship type with no keys, in both generic and string form. It checks: - the inferred keys on class access; + - class-name keys for `Media`, for a custom `__table__` and for the `BelongsToMany` pivot; + - a custom `__primary_key__` on `HasOne`, `HasMany` and `BelongsTo`; + - an inherited relationship binding to the subclass; - eager loads in both directions; - `HasOneThrough` loading and missing rows; - `where_has` with and without callbacks. - The database config tests check that the default resolves to a defined connection, and that each `DB_CONNECTION` value selects one. - The make-command tests no longer pre-create directories. They also import a generated model through the package. +- A sqlite connection to a file in a missing directory runs a query and creates the file. A `Migrator` with a missing migrations directory migrates nothing and leaves only the tracking table. +- In a fresh temporary application, `provider:publish --provider=database` followed by `db:migrate` reports nothing to migrate and creates `databases/sqlite.db`. - In a temporary application, publishing the config, then running `db:make:model Post -m` and `db:migrate`, creates `app/models/Post.py` and the migration, and migrates on sqlite. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 95f533fc..e6785a9c 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,4 +35,4 @@ Read this index first, then open the records relevant to the change. Before impl | [029](029-cache-module.md) | 2026-10-21 | Cache module with remember, memo, locks and tags | New `fastapi_startkit.cache`: async `Cache.get/put/has/forget/flush`, `get` with a callable default, and `remember(key, ttl, factory)` that runs a sync or async factory only on a miss; `remember(key, ttl)` without a factory is a decorator keyed by `key` plus a hash of the call arguments. Stores implement an async contract with a `MISSING` sentinel (cached `None` is a hit): array, file (pickled per-key files), database (masoniteorm table) and redis (named connection from the existing `RedisManager`, `SET PX` TTL, flush by `SCAN` of prefixed keys, never `FLUSHDB`). TTL in seconds or `timedelta`, `None` forever, <=0 forgets. `CacheConfig` via `resolve_config`; `CacheProvider` binds `cache`; facade `remember` resolves lazily. `Cache.memo()` memoizes reads per asyncio task through a `ContextVar` owned by the task, so requests never share memo state. `Cache.lock(name, seconds, owner)` gives atomic, owner-checked, non-re-entrant locks with `get`, `block` (250 ms polling, `LockTimeoutError`), `release`, `force_release` and `async with`, per store: event-loop dict, `fcntl.flock` file, `lock:` row with update-if-expired, Redis `SET NX PX` plus a Lua owner-checked delete. `Cache.tags([...])` uses Laravel's namespaced keys: random tag ids at `:tag::key`, entries at `:sha1(ids):`, flush rewrites the ids; tags are sorted and deduplicated. Shipped in slices into `feature/caches`; the former memo, lock and tags ADRs (030, 031, 034) are folded in. | | [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. | | [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. | -| [039](039-default-relationship-keys-and-db-scaffolding.md) | 2026-10-25 | Default relationship keys and working database scaffolding | Relationship keys are lazy `RelationshipKey` data descriptors that fill unset keys from Laravel-style `default_keys()` on first read, so class-level access from `with_`, `where_has` and `attach` works without explicit keys (HasOne/HasMany/BelongsTo/BelongsToMany/HasOneThrough; alphabetical pivot key swap removed; HasOneThrough alias `m_through_key`). The published database config reads `DB_CONNECTION` (default `sqlite`) and defines sqlite/mysql/postgres. `db:make:model` writes to `app/models`, and the model, migration and seed generators create their directories. | +| [039](039-default-relationship-keys-and-db-scaffolding.md) | 2026-10-25 | Default relationship keys and working database scaffolding | Relationship keys are lazy `RelationshipKey` data descriptors that fill unset keys from Laravel-style `default_keys()` on first read, so class-level access from `with_`, `where_has` and `attach` works without explicit keys (HasOne/HasMany/BelongsTo/BelongsToMany/HasOneThrough; the owner's custom `__primary_key__` is respected). Foreign keys are `snake(ClassName)_pk` and pivots are the sorted snake class names, independent of `__table__`. Relationships read through a subclass bind to that subclass. The alphabetical pivot key swap is removed, and HasOneThrough uses the alias `m_through_key`. The published database config reads `DB_CONNECTION` (default `sqlite`) and defines sqlite/mysql/postgres. `db:make:model` writes to `app/models`, and the model, migration and seed generators create their directories. A sqlite connection creates its database directory, and `db:migrate` treats a missing migrations directory as empty. | diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py index 7fd23df2..d2c60c85 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py @@ -1,15 +1,24 @@ import datetime +from pathlib import Path from typing import Any import pendulum +from sqlalchemy.ext.asyncio import AsyncEngine from fastapi_startkit.masoniteorm.query.grammars import SQLiteGrammar from fastapi_startkit.masoniteorm.query.processors import SQLitePostProcessor from fastapi_startkit.masoniteorm.schema.platforms import SQLitePlatform +from fastapi_startkit.masoniteorm.config.config import DBConfig from .connection import Connection class SQliteConnection(Connection): + def __init__(self, engine: AsyncEngine, config: DBConfig): + super().__init__(engine, config) + database = engine.url.database + if database and database != ":memory:" and not database.startswith("file:"): + Path(database).parent.mkdir(parents=True, exist_ok=True) + @classmethod def get_query_grammar(cls) -> type[SQLiteGrammar]: return SQLiteGrammar diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/Migrator.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/Migrator.py index e55d4dd3..223c4215 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/Migrator.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/Migrator.py @@ -1,5 +1,5 @@ from os import listdir -from os.path import isfile, join +from os.path import isdir, isfile, join from pydoc import locate from timeit import default_timer as timer from typing import TYPE_CHECKING @@ -37,14 +37,18 @@ async def create_table_if_not_exists(self): table.string("migration") table.integer("batch") - async def get_unran_migrations(self): + def migration_files(self) -> list[str]: directory_path = self.migration_directory - all_migrations = [ + if not isdir(directory_path): + return [] + return sorted( f.replace(".py", "") for f in listdir(directory_path) if isfile(join(directory_path, f)) and f != "__init__.py" and not f.startswith(".") - ] - all_migrations.sort() + ) + + async def get_unran_migrations(self): + all_migrations = self.migration_files() unran_migrations = [] database_migrations = await self.migration_model.get() for migration in all_migrations: @@ -84,13 +88,7 @@ def locate(self, file_name): return locate(f"{migration_directory}.{file_name}.{migration_name}") async def get_ran_migrations(self): - directory_path = self.migration_directory - all_migrations = [ - f.replace(".py", "") - for f in listdir(directory_path) - if isfile(join(directory_path, f)) and f != "__init__.py" and not f.startswith(".") - ] - all_migrations.sort() + all_migrations = self.migration_files() ran = [] database_migrations = await self.migration_model.all() diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py index 3628bf17..443c7daf 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py @@ -3,7 +3,7 @@ import warnings from typing import TYPE_CHECKING, Any, ForwardRef, Generic, Self, get_args, overload -from inflection import singularize, tableize +from inflection import underscore from typing_extensions import TypeVar from fastapi_startkit.masoniteorm.models import registry @@ -31,11 +31,12 @@ def __get__(self, relationship: object, owner: Any) -> str: ... def __get__(self, relationship, owner=None) -> Any: if relationship is None: return self - if relationship.__dict__.get(self.name) is None and "owner_class" in relationship.__dict__: - for name, value in relationship.default_keys().items(): - if relationship.__dict__.get(name) is None: - relationship.__dict__[name] = value - return relationship.__dict__.get(self.name) + explicit = relationship.__dict__.get(self.name) + if explicit is not None or "owner_class" not in relationship.__dict__: + return explicit + if "_inferred_keys" not in relationship.__dict__: + relationship.__dict__["_inferred_keys"] = relationship.default_keys() + return relationship.__dict__["_inferred_keys"].get(self.name) def __set__(self, relationship: object, value: str | None) -> None: relationship.__dict__[self.name] = value @@ -97,12 +98,24 @@ def default_keys(self) -> dict[str, str]: return {} @staticmethod - def table_of(model) -> str: - return model.__table__ or tableize(model.__name__) + def snake_name_of(model) -> str: + return underscore(model.__name__) @classmethod def foreign_key_of(cls, model) -> str: - return f"{singularize(cls.table_of(model))}_{model.__primary_key__}" + return f"{cls.snake_name_of(model)}_{model.__primary_key__}" + + def bound_to(self, owner) -> Self: + if owner is None or owner is self.__dict__.get("owner_class", owner): + return self + bindings = self.__dict__.setdefault("_bindings", {}) + if owner not in bindings: + bound = object.__new__(type(self)) + excluded = ("_bindings", "_inferred_keys") + bound.__dict__.update({key: value for key, value in self.__dict__.items() if key not in excluded}) + bound.owner_class = owner + bindings[owner] = bound + return bindings[owner] @overload def __get__(self, instance: None, owner: Any) -> Self: ... @@ -111,7 +124,7 @@ def __get__(self: "BaseRelationship[Model, Any]", instance: object, owner: Any) @overload def __get__(self, instance: object, owner: Any) -> TResult: ... def __get__(self, instance, owner) -> Any: - return self.access(instance, owner) + return self.bound_to(owner).access(instance, owner) def access(self, instance, owner): """ diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py index 210f0381..4baaf127 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py @@ -1,5 +1,4 @@ import pendulum -from inflection import singularize from .BaseRelationship import BaseRelationship, ManyResult, RelationshipKey, TModel from ..collection import Collection @@ -43,7 +42,7 @@ def default_keys(self): owner = self.owner_class related = self.related_model() return { - "_table": "_".join(sorted(singularize(self.table_of(model)) for model in (owner, related))), + "_table": "_".join(sorted(self.snake_name_of(model) for model in (owner, related))), "local_key": self.foreign_key_of(owner), "foreign_key": self.foreign_key_of(related), "local_owner_key": owner.__primary_key__, @@ -353,7 +352,7 @@ def joins(self, builder, clause=None): def query_where_exists(self, builder, callback, method="where_exists"): query = self.get_builder() - pivot_table = self._table or self.get_pivot_table_name(query, builder) + pivot_table = self._table table = self.get_builder().get_table_name() getattr(builder, method)( @@ -374,7 +373,7 @@ def query_where_exists(self, builder, callback, method="where_exists"): def query_has(self, current_query_builder, method="where_exists"): query = self.get_builder() - pivot_table = self._table or self.get_pivot_table_name(query, current_query_builder) + pivot_table = self._table table = self.get_builder().get_table_name() return getattr(current_query_builder, method)( query.new() @@ -391,17 +390,8 @@ def query_has(self, current_query_builder, method="where_exists"): ) ) - def get_pivot_table_name(self, query, builder): - pivot_tables = [ - singularize(query.get_table_name()), - singularize(builder.get_table_name()), - ] - pivot_tables.sort() - return "_".join(pivot_tables) - def get_with_count_query(self, builder, callback): query = self.get_builder() - self._table = self._table or self.get_pivot_table_name(query, builder) if not builder._columns: builder = builder.select("*") @@ -433,8 +423,6 @@ def attach(self, current_model, related_record): self.foreign_key: getattr(related_record, self.other_owner_key), } - self._table = self._table or self.get_pivot_table_name(current_model, related_record) - if self.with_timestamps: data.update( { @@ -451,8 +439,6 @@ def detach(self, current_model, related_record): self.foreign_key: getattr(related_record, self.other_owner_key), } - self._table = self._table or self.get_pivot_table_name(current_model, related_record) - return ( current_model.get_builder() .connection.query() @@ -468,8 +454,6 @@ def attach_related(self, current_model, related_record): self.foreign_key: getattr(related_record, self.other_owner_key), } - self._table = self._table or self.get_pivot_table_name(current_model, related_record) - if self.with_timestamps: data.update( { @@ -486,8 +470,6 @@ def detach_related(self, current_model, related_record): self.foreign_key: getattr(related_record, self.other_owner_key), } - self._table = self._table or self.get_pivot_table_name(current_model, related_record) - if self.with_timestamps: data.update( { diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py index e39ff6f7..f6c83d72 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py @@ -8,7 +8,7 @@ class HasOne(BaseRelationship[TModel, SingleResult[TModel]]): def __init__(self, fn: str | None = None, foreign_key=None, local_key=None): self.bind_model(fn) - self.local_key = local_key or "id" + self.local_key = local_key self.foreign_key = foreign_key def default_keys(self): diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py index d8d0ef29..0a197aaa 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py @@ -1,4 +1,6 @@ import asyncio +import os +import tempfile import unittest from cleo.testers.command_tester import CommandTester @@ -86,6 +88,17 @@ def test_migrate_creates_table_and_applies_pending_migrations(self): [(CREATE_POSTS, 1), (ADD_BODY_TO_POSTS, 1)], ) + def test_migrate_without_a_migrations_directory_creates_only_the_tracking_table(self): + with tempfile.TemporaryDirectory() as base: + migrator = Migrator(connection="sqlite", migration_directory=os.path.join(base, "missing")) + + asyncio.run(migrator.create_table_if_not_exists()) + asyncio.run(migrator.migrate()) + + self.assertEqual(asyncio.run(migrator.get_ran_migrations()), []) + self.assertTrue(self._has_table("migrations")) + self.assertEqual(self._ran_migrations(), []) + def test_migrate_is_idempotent_when_nothing_pending(self): cmd = self._make_command(DBMigrateCommand) CommandTester(cmd).execute("--connection sqlite") diff --git a/fastapi_startkit/tests/masoniteorm/config/test_database_config.py b/fastapi_startkit/tests/masoniteorm/config/test_database_config.py index 7bbfa717..7cf8b7a6 100644 --- a/fastapi_startkit/tests/masoniteorm/config/test_database_config.py +++ b/fastapi_startkit/tests/masoniteorm/config/test_database_config.py @@ -79,7 +79,8 @@ def test_provider_rejects_dictionary_connection_settings(tmp_path): DatabaseProvider(app, config).register() -def test_default_connection_is_defined(monkeypatch): +def test_default_connection_is_defined(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) monkeypatch.delenv("DB_CONNECTION", raising=False) config = DatabaseConfig() @@ -97,3 +98,13 @@ def test_db_connection_selects_a_defined_connection(monkeypatch, name, config_ty assert config.default == name assert isinstance(config.connections[config.default], config_type) + + +async def test_sqlite_connection_creates_the_database_directory(tmp_path): + database = tmp_path / "databases" / "sqlite.db" + connection = ConnectionFactory().make(SQLiteConfig(database=str(database), url=None), "sqlite") + try: + assert await connection.select_one("select 1 as one") == {"one": 1} + assert database.exists() + finally: + await connection.engine.dispose() diff --git a/fastapi_startkit/tests/masoniteorm/fixtures/typed/owners.py b/fastapi_startkit/tests/masoniteorm/fixtures/typed/owners.py index b2fbb406..3bcf0ca1 100644 --- a/fastapi_startkit/tests/masoniteorm/fixtures/typed/owners.py +++ b/fastapi_startkit/tests/masoniteorm/fixtures/typed/owners.py @@ -21,5 +21,6 @@ class TypedStore(Model): other_foreign_key="product_id", local_owner_key="id", other_owner_key="id", + table="product_store", with_timestamps=True, ) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py index 075d5415..ce32a649 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_belongs_to_many.py @@ -99,13 +99,8 @@ async def test_detach_related_removes_pivot_record(self): products = store.products self.assertEqual(len(products), 0) - async def test_get_pivot_table_name(self): - # Test the helper method directly using builder proxies - rel = Store.products - # Manually set the pivot table name to test the method - rel._table = None - name = rel.get_pivot_table_name(self.store.get_builder(), self.product1.get_builder()) - self.assertEqual(name, "product_store") + async def test_default_pivot_table_name(self): + self.assertEqual(Store.products._table, "product_store") async def test_map_related_returns_result(self): results = [self.product1, self.product2] diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py index 694509da..1ef592a8 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py @@ -8,39 +8,101 @@ with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) - class DefaultKeyWriter(Model): - __table__ = "writers" + class Novelist(Model): __timestamps__ = False - biography = HasOne["DefaultKeyBiography"]() - posts = HasMany["DefaultKeyPost"]() - latest_media = HasOneThrough(["DefaultKeyMedia", "DefaultKeyPost"]) + memoir = HasOne["Memoir"]() + essays = HasMany["Essay"]() + latest_media = HasOneThrough(["Media", "Essay"]) - class DefaultKeyBiography(Model): - __table__ = "biographies" + class Memoir(Model): __timestamps__ = False - writer = BelongsTo["DefaultKeyWriter"]() + novelist = BelongsTo["Novelist"]() - class DefaultKeyPost(Model): - __table__ = "posts" + class Essay(Model): __timestamps__ = False - writer = BelongsTo["DefaultKeyWriter"]() - media = HasMany("DefaultKeyMedia") - tags = BelongsToMany["DefaultKeyTag"]() + novelist = BelongsTo["Novelist"]() + media = HasMany("Media") + labels = BelongsToMany["Label"]() - class DefaultKeyMedia(Model): + class Media(Model): __table__ = "media" __timestamps__ = False - post = BelongsTo("DefaultKeyPost") + essay = BelongsTo("Essay") + labels = BelongsToMany["Label"]() - class DefaultKeyTag(Model): - __table__ = "tags" + class Label(Model): + __table__ = "blog_labels" __timestamps__ = False - posts = BelongsToMany["DefaultKeyPost"]() + essays = BelongsToMany["Essay"]() + + class KeyAccount(Model): + __primary_key__ = "uuid" + + setting = HasOne["KeyAccountNote"]() + notes = HasMany["KeyAccountNote"]() + + class KeyAccountNote(Model): + account = BelongsTo["KeyAccount"]() + + class KeyBaseItem(Model): + notes = HasMany["KeyAccountNote"]() + explicit_notes = HasMany["KeyAccountNote"](foreign_key="owner_id") + + class KeyInvoice(KeyBaseItem): + pass + + +def keys(relationship): + return relationship.local_key, relationship.foreign_key + + +def test_keys_follow_the_owner_class_name(): + assert keys(Novelist.essays) == ("id", "novelist_id") + assert keys(Novelist.memoir) == ("id", "novelist_id") + assert keys(Essay.novelist) == ("novelist_id", "id") + assert keys(Media.essay) == ("essay_id", "id") + + +def test_pivot_table_and_keys_follow_sorted_class_names(): + labels = Essay.labels + assert (labels._table, *keys(labels)) == ("essay_label", "essay_id", "label_id") + assert (labels.local_owner_key, labels.other_owner_key) == ("id", "id") + + assert (Label.essays._table, *keys(Label.essays)) == ("essay_label", "label_id", "essay_id") + + +def test_irregular_plural_class_names_are_not_singularized(): + assert (Media.labels._table, *keys(Media.labels)) == ("label_media", "media_id", "label_id") + + +def test_custom_table_does_not_change_key_names(): + assert keys(Label.essays)[0] == "label_id" + assert Label.essays._table == "essay_label" + + +def test_has_one_through_keys_follow_class_names(): + through = Novelist.latest_media + assert (through.local_key, through.local_owner_key) == ("id", "novelist_id") + assert (through.foreign_key, through.other_owner_key) == ("id", "essay_id") + + +def test_custom_primary_key_is_used_for_default_keys(): + assert keys(KeyAccount.setting) == ("uuid", "key_account_uuid") + assert keys(KeyAccount.notes) == ("uuid", "key_account_uuid") + assert keys(KeyAccountNote.account) == ("account_uuid", "uuid") + + +def test_inherited_relationships_bind_to_the_accessing_class(): + assert keys(KeyBaseItem.notes) == ("id", "key_base_item_id") + assert keys(KeyInvoice.notes) == ("id", "key_invoice_id") + assert KeyInvoice.notes.owner_class is KeyInvoice + assert KeyInvoice.notes is KeyInvoice.notes + assert keys(KeyInvoice.explicit_notes) == ("id", "owner_id") class TestDefaultRelationshipKeys(TestCase): @@ -48,111 +110,94 @@ async def asyncSetUp(self): await super().asyncSetUp() schema = self.schema.on("default") - async with await schema.create_table_if_not_exists("writers") as table: + async with await schema.create_table_if_not_exists("novelists") as table: table.id() table.string("name") - async with await schema.create_table_if_not_exists("biographies") as table: + async with await schema.create_table_if_not_exists("memoirs") as table: table.id() - table.integer("writer_id") + table.integer("novelist_id") table.string("body") - async with await schema.create_table_if_not_exists("posts") as table: + async with await schema.create_table_if_not_exists("essays") as table: table.id() - table.integer("writer_id") + table.integer("novelist_id") table.string("title") async with await schema.create_table_if_not_exists("media") as table: table.id() - table.integer("post_id") + table.integer("essay_id") table.string("path") - async with await schema.create_table_if_not_exists("tags") as table: + async with await schema.create_table_if_not_exists("blog_labels") as table: table.id() table.string("name") - async with await schema.create_table_if_not_exists("post_tag") as table: + async with await schema.create_table_if_not_exists("essay_label") as table: table.id() - table.integer("post_id") - table.integer("tag_id") - - ada = await DefaultKeyWriter.create({"name": "Ada"}) - await DefaultKeyWriter.create({"name": "Grace"}) - await DefaultKeyBiography.create({"writer_id": ada.id, "body": "Analyst"}) - post = await DefaultKeyPost.create({"writer_id": ada.id, "title": "Engines"}) - await DefaultKeyPost.create({"writer_id": ada.id, "title": "Notes"}) - await DefaultKeyMedia.create({"post_id": post.id, "path": "engine.png"}) - tag = await DefaultKeyTag.create({"name": "math"}) - await DefaultKeyTag.create({"name": "unused"}) - await DefaultKeyPost.tags.attach(post, tag) - - def test_keys_are_inferred_on_class_access(self): - assert (DefaultKeyWriter.posts.local_key, DefaultKeyWriter.posts.foreign_key) == ("id", "writer_id") - assert (DefaultKeyWriter.biography.local_key, DefaultKeyWriter.biography.foreign_key) == ("id", "writer_id") - assert (DefaultKeyPost.writer.local_key, DefaultKeyPost.writer.foreign_key) == ("writer_id", "id") - assert (DefaultKeyMedia.post.local_key, DefaultKeyMedia.post.foreign_key) == ("post_id", "id") - - tags = DefaultKeyPost.tags - assert (tags._table, tags.local_key, tags.foreign_key) == ("post_tag", "post_id", "tag_id") - assert (tags.local_owner_key, tags.other_owner_key) == ("id", "id") - - posts = DefaultKeyTag.posts - assert (posts._table, posts.local_key, posts.foreign_key) == ("post_tag", "tag_id", "post_id") - - through = DefaultKeyWriter.latest_media - assert (through.local_key, through.local_owner_key) == ("id", "writer_id") - assert (through.foreign_key, through.other_owner_key) == ("id", "post_id") + table.integer("essay_id") + table.integer("label_id") + + ada = await Novelist.create({"name": "Ada"}) + await Novelist.create({"name": "Grace"}) + await Memoir.create({"novelist_id": ada.id, "body": "Analyst"}) + essay = await Essay.create({"novelist_id": ada.id, "title": "Engines"}) + await Essay.create({"novelist_id": ada.id, "title": "Notes"}) + await Media.create({"essay_id": essay.id, "path": "engine.png"}) + label = await Label.create({"name": "math"}) + await Label.create({"name": "unused"}) + await Essay.labels.attach(essay, label) async def test_eager_loads_default_key_relationships(self): - posts = await DefaultKeyPost.with_("writer", "tags", "media").where("title", "Engines").get() - post = posts.first() + essays = await Essay.with_("novelist", "labels", "media").where("title", "Engines").get() + essay = essays.first() - assert post.writer.name == "Ada" - assert [tag.name for tag in post.tags] == ["math"] - assert [media.path for media in post.media] == ["engine.png"] + assert essay.novelist.name == "Ada" + assert [label.name for label in essay.labels] == ["math"] + assert [media.path for media in essay.media] == ["engine.png"] - media = await DefaultKeyMedia.with_("post").first_or_fail() - assert media.post.title == "Engines" + media = await Media.with_("essay").first_or_fail() + assert media.essay.title == "Engines" - tag = await DefaultKeyTag.with_("posts").where("name", "math").first_or_fail() - assert [item.title for item in tag.posts] == ["Engines"] + label = await Label.with_("essays").where("name", "math").first_or_fail() + assert [item.title for item in label.essays] == ["Engines"] async def test_eager_loads_default_key_has_one_and_has_many(self): - writer = await DefaultKeyWriter.with_("biography", "posts").where("name", "Ada").first_or_fail() + novelist = await Novelist.with_("memoir", "essays").where("name", "Ada").first_or_fail() - assert writer.biography.body == "Analyst" - assert sorted(post.title for post in writer.posts) == ["Engines", "Notes"] + assert novelist.memoir.body == "Analyst" + assert sorted(essay.title for essay in novelist.essays) == ["Engines", "Notes"] - biography = await DefaultKeyBiography.with_("writer").first_or_fail() - assert biography.writer.name == "Ada" + memoir = await Memoir.with_("novelist").first_or_fail() + assert memoir.novelist.name == "Ada" async def test_eager_loads_default_key_has_one_through(self): - writer = await DefaultKeyWriter.with_("latest_media").where("name", "Ada").first_or_fail() + novelist = await Novelist.with_("latest_media").where("name", "Ada").first_or_fail() - assert writer.latest_media.path == "engine.png" - assert writer.latest_media.id is not None + assert novelist.latest_media.path == "engine.png" + assert novelist.latest_media.id is not None - lonely = await DefaultKeyWriter.with_("latest_media").where("name", "Grace").first_or_fail() + lonely = await Novelist.with_("latest_media").where("name", "Grace").first_or_fail() assert lonely.latest_media is None async def test_existence_queries_correlate_default_keys(self): - with_posts = await DefaultKeyWriter.where_has("posts").get() - assert [writer.name for writer in with_posts] == ["Ada"] + with_essays = await Novelist.where_has("essays").get() + assert [novelist.name for novelist in with_essays] == ["Ada"] - engines = await DefaultKeyWriter.where_has("posts", lambda query: query.where("title", "Engines")).get() - assert [writer.name for writer in engines] == ["Ada"] + engines = await Novelist.where_has("essays", lambda query: query.where("title", "Engines")).get() + assert [novelist.name for novelist in engines] == ["Ada"] - tagged = await DefaultKeyTag.where_has("posts").get() - assert [tag.name for tag in tagged] == ["math"] + labelled = await Label.where_has("essays").get() + assert [label.name for label in labelled] == ["math"] - with_biography = await DefaultKeyWriter.where_has("biography").get() - assert [writer.name for writer in with_biography] == ["Ada"] + with_memoir = await Novelist.where_has("memoir").get() + assert [novelist.name for novelist in with_memoir] == ["Ada"] - with_writer = await DefaultKeyPost.where_has("writer", lambda query: query.where("name", "Ada")).get() - assert sorted(post.title for post in with_writer) == ["Engines", "Notes"] + by_ada = await Essay.where_has("novelist", lambda query: query.where("name", "Ada")).get() + assert sorted(essay.title for essay in by_ada) == ["Engines", "Notes"] - tagged_posts = await DefaultKeyPost.where_has("tags").get() - assert [post.title for post in tagged_posts] == ["Engines"] + labelled_essays = await Essay.where_has("labels").get() + assert [essay.title for essay in labelled_essays] == ["Engines"] - with_media = await DefaultKeyWriter.where_has("latest_media").get() - assert [writer.name for writer in with_media] == ["Ada"] + with_media = await Novelist.where_has("latest_media").get() + assert [novelist.name for novelist in with_media] == ["Ada"] diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_inferred_relationship_model.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_inferred_relationship_model.py index 582b0487..a10da4d9 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_inferred_relationship_model.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_inferred_relationship_model.py @@ -40,6 +40,7 @@ class InferredStore(Model): other_foreign_key="product_id", local_owner_key="id", other_owner_key="id", + table="product_store", with_timestamps=True, ) From f5cf91723e41169fd608b3b665c2e128e3e34f61 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 25 Oct 2026 13:49:45 -0700 Subject: [PATCH 6/9] Resolve sqlite paths against base_path, bind subclass relationships without a cache, document upgrade notes --- ...lt-relationship-keys-and-db-scaffolding.md | 26 +++++++++++++++++-- docs/adr/AGENTS.md | 2 +- .../masoniteorm/connections/connection.py | 7 ++++- .../masoniteorm/connections/factory.py | 7 +++-- .../connections/sqlite_connection.py | 17 +++++++++--- .../relationships/BaseRelationship.py | 15 +++++------ .../config/test_database_config.py | 16 ++++++++++-- .../test_default_relationship_keys.py | 21 +++++++++++++++ 8 files changed, 91 insertions(+), 20 deletions(-) diff --git a/docs/adr/039-default-relationship-keys-and-db-scaffolding.md b/docs/adr/039-default-relationship-keys-and-db-scaffolding.md index 96be851f..5585e778 100644 --- a/docs/adr/039-default-relationship-keys-and-db-scaffolding.md +++ b/docs/adr/039-default-relationship-keys-and-db-scaffolding.md @@ -38,7 +38,8 @@ For the config, either keep `pgsql` and add a Postgres connection, or read `DB_C - `BelongsTo`: `{attribute}_{related_pk}`, and the related primary key. - `BelongsToMany`: the pivot table is the two snake class names sorted and joined with `_` (`media_tag`). The keys are each side's foreign key and each side's primary key. - `HasOneThrough`: the owner pk, the owner's foreign key on the intermediate model, the intermediate pk, and the intermediate's foreign key on the distant model. - - Inferred keys are cached separately from explicit ones. Reading a relationship through a subclass returns a copy bound to that subclass and cached on the descriptor. The copy keeps the explicit keys and infers the rest from the subclass, matching Laravel, where `$this` is the concrete model. + - Inferred keys are cached separately from explicit ones. Reading a relationship through a subclass returns a copy bound to that subclass, matching Laravel, where `$this` is the concrete model. The copy keeps the explicit keys and infers the rest from the subclass. + - The bound copy is installed on the subclass itself with `setattr`. There is no side cache, so the copy lives and dies with the subclass. A `weakref.WeakKeyDictionary` keyed by the subclass was tried and rejected: the cached copy holds the subclass in `owner_class`, so the value keeps its own key alive and the subclass is never collected. - The table-based `BelongsToMany.get_pivot_table_name` fallback is removed, because `_table` is always inferred. - The four duplicated alphabetical pivot blocks in `BelongsToMany` are removed. - `HasOneThrough` selects its join key as the fixed alias `m_through_key`. @@ -50,10 +51,30 @@ For the config, either keep `pgsql` and add a Postgres connection, or read `DB_C - `db:make:migration` and `seed` create their target directories. - `-m` already calls `db:make:migration`, and the model stub already imports `fastapi_startkit.masoniteorm.models`. Both stay as they are. - **Fresh migrate.** + - `Connection.resolve_url` lets a driver rewrite its URL before the engine is created. `SQliteConnection` resolves a relative database path against the application `base_path`, like migrations and other framework paths, so the database location does not depend on the working directory. - `SQliteConnection` creates the parent directory of a file database. In-memory and `file:` URIs are skipped. - `Migrator.migration_files()` returns an empty list when the migrations directory is missing, so `db:migrate` creates the tracking table and reports nothing to migrate. Laravel behaves the same way. - **Out of scope.** `HasManyThrough` default keys and the `m_through_key` alias are follow-ups. +## Upgrade notes / Breaking changes + +Some default names change for configurations that already worked on `main`. Each one is fixed by passing the old name explicitly. + +| Case | Before | After | Fix | +|---|---|---|---| +| (a) `BelongsToMany` without `table=` between models with a custom `__table__` (`CpItemModel.__table__ = "cp_items"`, `CpShopModel.__table__ = "cp_shops"`) | `cp_item_cp_shop` | `cp_item_model_cp_shop_model` | `BelongsToMany["CpShopModel"](table="cp_item_cp_shop")` | +| (b) A subclass that shares its parent's table and inherits a `BelongsToMany` (`CpAdmin(CpPerson)`) | `cp_person_cp_role` | `cp_admin_cp_role` | `BelongsToMany["CpRole"](table="cp_person_cp_role")` on the parent | +| (c) Irregular plurals in the pivot (`Media` ↔ `Tag`) | `medium_tag` | `media_tag` | `BelongsToMany["Tag"](table="medium_tag")` | +| (d) `HasOne`/`HasMany` default foreign key | `{attribute}_id` (`posts = HasMany(...)` on `User` → `posts_id`) | `{owner}_{pk}` (`user_id`) | `HasMany["Post"](foreign_key="posts_id")` | + +The foreign keys of the pivot in (a)–(c) follow the same class-name rule (for example `media_id` instead of `medium_id`). Pass `local_key=` / `foreign_key=` to keep the old names. + +Not affected: + +- any relationship with an explicit `table=`, `local_key=` or `foreign_key=`; +- multi-word classes on their default table (`BlogPost` / `blog_posts` gives `blog_post_id` both before and after); +- `BelongsTo`, `HasOneThrough` and implicit `BelongsToMany` reached through the class (`with_`, `where_has`, `attach`). These raised on `main` with no keys, so no working configuration depends on their old defaults. + ## Validation - `tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py` declares every relationship type with no keys, in both generic and string form. It checks: @@ -66,6 +87,7 @@ For the config, either keep `pgsql` and add a Postgres connection, or read `DB_C - `where_has` with and without callbacks. - The database config tests check that the default resolves to a defined connection, and that each `DB_CONNECTION` value selects one. - The make-command tests no longer pre-create directories. They also import a generated model through the package. -- A sqlite connection to a file in a missing directory runs a query and creates the file. A `Migrator` with a missing migrations directory migrates nothing and leaves only the tracking table. +- A sqlite connection to a file in a missing directory runs a query and creates the file. A relative sqlite path resolves against `base_path`, not the working directory. +- A subclass that bound an inherited relationship is garbage collected once it is dropped. A `Migrator` with a missing migrations directory migrates nothing and leaves only the tracking table. - In a fresh temporary application, `provider:publish --provider=database` followed by `db:migrate` reports nothing to migrate and creates `databases/sqlite.db`. - In a temporary application, publishing the config, then running `db:make:model Post -m` and `db:migrate`, creates `app/models/Post.py` and the migration, and migrates on sqlite. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index e6785a9c..bc5d7cf2 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,4 +35,4 @@ Read this index first, then open the records relevant to the change. Before impl | [029](029-cache-module.md) | 2026-10-21 | Cache module with remember, memo, locks and tags | New `fastapi_startkit.cache`: async `Cache.get/put/has/forget/flush`, `get` with a callable default, and `remember(key, ttl, factory)` that runs a sync or async factory only on a miss; `remember(key, ttl)` without a factory is a decorator keyed by `key` plus a hash of the call arguments. Stores implement an async contract with a `MISSING` sentinel (cached `None` is a hit): array, file (pickled per-key files), database (masoniteorm table) and redis (named connection from the existing `RedisManager`, `SET PX` TTL, flush by `SCAN` of prefixed keys, never `FLUSHDB`). TTL in seconds or `timedelta`, `None` forever, <=0 forgets. `CacheConfig` via `resolve_config`; `CacheProvider` binds `cache`; facade `remember` resolves lazily. `Cache.memo()` memoizes reads per asyncio task through a `ContextVar` owned by the task, so requests never share memo state. `Cache.lock(name, seconds, owner)` gives atomic, owner-checked, non-re-entrant locks with `get`, `block` (250 ms polling, `LockTimeoutError`), `release`, `force_release` and `async with`, per store: event-loop dict, `fcntl.flock` file, `lock:` row with update-if-expired, Redis `SET NX PX` plus a Lua owner-checked delete. `Cache.tags([...])` uses Laravel's namespaced keys: random tag ids at `:tag::key`, entries at `:sha1(ids):`, flush rewrites the ids; tags are sorted and deduplicated. Shipped in slices into `feature/caches`; the former memo, lock and tags ADRs (030, 031, 034) are folded in. | | [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. | | [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. | -| [039](039-default-relationship-keys-and-db-scaffolding.md) | 2026-10-25 | Default relationship keys and working database scaffolding | Relationship keys are lazy `RelationshipKey` data descriptors that fill unset keys from Laravel-style `default_keys()` on first read, so class-level access from `with_`, `where_has` and `attach` works without explicit keys (HasOne/HasMany/BelongsTo/BelongsToMany/HasOneThrough; the owner's custom `__primary_key__` is respected). Foreign keys are `snake(ClassName)_pk` and pivots are the sorted snake class names, independent of `__table__`. Relationships read through a subclass bind to that subclass. The alphabetical pivot key swap is removed, and HasOneThrough uses the alias `m_through_key`. The published database config reads `DB_CONNECTION` (default `sqlite`) and defines sqlite/mysql/postgres. `db:make:model` writes to `app/models`, and the model, migration and seed generators create their directories. A sqlite connection creates its database directory, and `db:migrate` treats a missing migrations directory as empty. | +| [039](039-default-relationship-keys-and-db-scaffolding.md) | 2026-10-25 | Default relationship keys and working database scaffolding | Relationship keys are lazy `RelationshipKey` data descriptors that fill unset keys from Laravel-style `default_keys()` on first read, so class-level access from `with_`, `where_has` and `attach` works without explicit keys (HasOne/HasMany/BelongsTo/BelongsToMany/HasOneThrough; the owner's custom `__primary_key__` is respected). Foreign keys are `snake(ClassName)_pk` and pivots are the sorted snake class names, independent of `__table__`. Relationships read through a subclass bind to that subclass via `setattr` (no cache, so the subclass can be collected). Breaking: default pivot names and HasOne/HasMany foreign keys change (see upgrade notes); pass `table=`/`foreign_key=` to keep old names. The alphabetical pivot key swap is removed, and HasOneThrough uses the alias `m_through_key`. The published database config reads `DB_CONNECTION` (default `sqlite`) and defines sqlite/mysql/postgres. `db:make:model` writes to `app/models`, and the model, migration and seed generators create their directories. A relative sqlite path resolves against `base_path` and the connection creates its database directory, and `db:migrate` treats a missing migrations directory as empty. | diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py index be718159..b04d8d7a 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py @@ -1,11 +1,12 @@ from __future__ import annotations from contextvars import ContextVar +from pathlib import Path from typing import TYPE_CHECKING, Any from fastapi_startkit.masoniteorm.config.config import DBConfig -from sqlalchemy import text +from sqlalchemy import URL, text from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine from fastapi_startkit.masoniteorm.connections.manages_transactions import ManagesTransactions, Transaction @@ -58,6 +59,10 @@ async def close(self) -> None: async def reconnect(self) -> None: await self.close() + @classmethod + def resolve_url(cls, url: URL, base_path: Path) -> URL: + return url + @classmethod def prepare_binding(cls, value: Any) -> Any: return value diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py index c89e49e2..a59ecea5 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/factory.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, cast +from sqlalchemy import make_url from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy.pool import NullPool @@ -41,12 +42,14 @@ def create_engine(cls, config: DBConfig) -> AsyncEngine: from fastapi_startkit.application import app cls._ensure_supported(config.driver) + application = cast("Application[AppConfig]", app()) + url = cls.CONNECTIONS[config.driver].resolve_url(make_url(config.build_url()), application.base_path) options: dict[str, object] = {"echo": config.echo} - if cast("Application[AppConfig]", app()).is_testing(): + if application.is_testing(): options["poolclass"] = NullPool else: options.update(config.engine_options()) - return create_async_engine(config.build_url(), **options) + return create_async_engine(url, **options) @classmethod def create_connection(cls, driver: str, config: DBConfig) -> Connection: diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py index d2c60c85..e9257e41 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/sqlite_connection.py @@ -3,6 +3,7 @@ from typing import Any import pendulum +from sqlalchemy import URL from sqlalchemy.ext.asyncio import AsyncEngine from fastapi_startkit.masoniteorm.query.grammars import SQLiteGrammar @@ -15,9 +16,19 @@ class SQliteConnection(Connection): def __init__(self, engine: AsyncEngine, config: DBConfig): super().__init__(engine, config) - database = engine.url.database - if database and database != ":memory:" and not database.startswith("file:"): - Path(database).parent.mkdir(parents=True, exist_ok=True) + if self.is_file_database(engine.url): + Path(str(engine.url.database)).parent.mkdir(parents=True, exist_ok=True) + + @staticmethod + def is_file_database(url: URL) -> bool: + database = url.database + return bool(database) and database != ":memory:" and not str(database).startswith("file:") + + @classmethod + def resolve_url(cls, url: URL, base_path: Path) -> URL: + if not cls.is_file_database(url) or Path(str(url.database)).is_absolute(): + return url + return url.set(database=str(base_path / str(url.database))) @classmethod def get_query_grammar(cls) -> type[SQLiteGrammar]: diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py index 443c7daf..d52fc387 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BaseRelationship.py @@ -106,16 +106,13 @@ def foreign_key_of(cls, model) -> str: return f"{cls.snake_name_of(model)}_{model.__primary_key__}" def bound_to(self, owner) -> Self: - if owner is None or owner is self.__dict__.get("owner_class", owner): + if owner is None or self.attribute is None or owner is self.__dict__.get("owner_class", owner): return self - bindings = self.__dict__.setdefault("_bindings", {}) - if owner not in bindings: - bound = object.__new__(type(self)) - excluded = ("_bindings", "_inferred_keys") - bound.__dict__.update({key: value for key, value in self.__dict__.items() if key not in excluded}) - bound.owner_class = owner - bindings[owner] = bound - return bindings[owner] + bound = object.__new__(type(self)) + bound.__dict__.update({key: value for key, value in self.__dict__.items() if key != "_inferred_keys"}) + bound.owner_class = owner + setattr(owner, self.attribute, bound) + return bound @overload def __get__(self, instance: None, owner: Any) -> Self: ... diff --git a/fastapi_startkit/tests/masoniteorm/config/test_database_config.py b/fastapi_startkit/tests/masoniteorm/config/test_database_config.py index 7cf8b7a6..d4be5337 100644 --- a/fastapi_startkit/tests/masoniteorm/config/test_database_config.py +++ b/fastapi_startkit/tests/masoniteorm/config/test_database_config.py @@ -1,7 +1,7 @@ import pytest from sqlalchemy import StaticPool, text -from fastapi_startkit.application import Application +from fastapi_startkit.application import Application, app from fastapi_startkit.masoniteorm import DatabaseConfig, MySQLConfig, PostgresConfig, SQLiteConfig from fastapi_startkit.masoniteorm.connections.factory import ConnectionFactory from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager @@ -80,7 +80,7 @@ def test_provider_rejects_dictionary_connection_settings(tmp_path): def test_default_connection_is_defined(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) + monkeypatch.setattr(app(), "base_path", tmp_path) monkeypatch.delenv("DB_CONNECTION", raising=False) config = DatabaseConfig() @@ -108,3 +108,15 @@ async def test_sqlite_connection_creates_the_database_directory(tmp_path): assert database.exists() finally: await connection.engine.dispose() + + +async def test_relative_sqlite_database_resolves_against_the_base_path(monkeypatch, tmp_path): + monkeypatch.setattr(app(), "base_path", tmp_path / "project") + monkeypatch.chdir(tmp_path) + connection = ConnectionFactory().make(SQLiteConfig(database="databases/sqlite.db", url=None), "sqlite") + try: + assert await connection.select_one("select 1 as one") == {"one": 1} + assert (tmp_path / "project" / "databases" / "sqlite.db").exists() + assert not (tmp_path / "databases").exists() + finally: + await connection.engine.dispose() diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py index 1ef592a8..cfe41cc7 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py @@ -1,6 +1,9 @@ +import gc import warnings +import weakref from fastapi_startkit.masoniteorm import BelongsTo, BelongsToMany, HasMany, HasOne, Model +from fastapi_startkit.masoniteorm.models.registry import Registry from fastapi_startkit.masoniteorm.relationships import HasOneThrough from ..test_case import TestCase @@ -105,6 +108,24 @@ def test_inherited_relationships_bind_to_the_accessing_class(): assert keys(KeyInvoice.explicit_notes) == ("id", "owner_id") +def test_bound_relationships_do_not_keep_subclasses_alive(): + def declare(): + class KeyTransientItem(KeyBaseItem): + pass + + assert keys(KeyTransientItem.notes) == ("id", "key_transient_item_id") + Registry._models.pop("KeyTransientItem") + Registry._morph_map.pop("KeyTransientItem") + Registry._reverse_map.pop(KeyTransientItem) + return weakref.ref(KeyTransientItem) + + subclass = declare() + gc.collect() + + assert subclass() is None + assert keys(KeyBaseItem.notes) == ("id", "key_base_item_id") + + class TestDefaultRelationshipKeys(TestCase): async def asyncSetUp(self): await super().asyncSetUp() From 8dab214604b4d1d80dda2296552c2b9c3502286f Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 25 Oct 2026 14:02:47 -0700 Subject: [PATCH 7/9] Document relative sqlite path resolution in upgrade notes --- docs/adr/039-default-relationship-keys-and-db-scaffolding.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/039-default-relationship-keys-and-db-scaffolding.md b/docs/adr/039-default-relationship-keys-and-db-scaffolding.md index 5585e778..dcc51ea0 100644 --- a/docs/adr/039-default-relationship-keys-and-db-scaffolding.md +++ b/docs/adr/039-default-relationship-keys-and-db-scaffolding.md @@ -66,6 +66,7 @@ Some default names change for configurations that already worked on `main`. Each | (b) A subclass that shares its parent's table and inherits a `BelongsToMany` (`CpAdmin(CpPerson)`) | `cp_person_cp_role` | `cp_admin_cp_role` | `BelongsToMany["CpRole"](table="cp_person_cp_role")` on the parent | | (c) Irregular plurals in the pivot (`Media` ↔ `Tag`) | `medium_tag` | `media_tag` | `BelongsToMany["Tag"](table="medium_tag")` | | (d) `HasOne`/`HasMany` default foreign key | `{attribute}_id` (`posts = HasMany(...)` on `User` → `posts_id`) | `{owner}_{pk}` (`user_id`) | `HasMany["Post"](foreign_key="posts_id")` | +| (e) Relative sqlite path (`DB_DATABASE` / `DB_URL`) | relative to the current working directory | relative to `Application.base_path` | use an absolute path | The foreign keys of the pivot in (a)–(c) follow the same class-name rule (for example `media_id` instead of `medium_id`). Pass `local_key=` / `foreign_key=` to keep the old names. From 9e7943c4c4f9c77eae41b55cf36c6aec792d94e0 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 26 Oct 2026 14:49:01 -0700 Subject: [PATCH 8/9] Cover relationship key descriptor, base default keys and BelongsToMany existence callback --- .../test_default_relationship_keys.py | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py index cfe41cc7..11dd1526 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py @@ -4,7 +4,8 @@ from fastapi_startkit.masoniteorm import BelongsTo, BelongsToMany, HasMany, HasOne, Model from fastapi_startkit.masoniteorm.models.registry import Registry -from fastapi_startkit.masoniteorm.relationships import HasOneThrough +from fastapi_startkit.masoniteorm.relationships import BaseRelationship, HasOneThrough +from fastapi_startkit.masoniteorm.relationships.BaseRelationship import RelationshipKey from ..test_case import TestCase @@ -59,6 +60,12 @@ class KeyBaseItem(Model): class KeyInvoice(KeyBaseItem): pass + class PlainRelationship(BaseRelationship): + pass + + class KeyPlainOwner(Model): + plain = PlainRelationship("KeyAccountNote") + def keys(relationship): return relationship.local_key, relationship.foreign_key @@ -83,6 +90,15 @@ def test_irregular_plural_class_names_are_not_singularized(): assert (Media.labels._table, *keys(Media.labels)) == ("label_media", "media_id", "label_id") +def test_relationship_key_is_a_descriptor_at_class_level(): + assert isinstance(BaseRelationship.local_key, RelationshipKey) + assert isinstance(BelongsToMany._table, RelationshipKey) + + +def test_relationships_without_inferred_keys_leave_them_unset(): + assert keys(KeyPlainOwner.plain) == (None, None) + + def test_custom_table_does_not_change_key_names(): assert keys(Label.essays)[0] == "label_id" assert Label.essays._table == "essay_label" @@ -222,3 +238,18 @@ async def test_existence_queries_correlate_default_keys(self): with_media = await Novelist.where_has("latest_media").get() assert [novelist.name for novelist in with_media] == ["Ada"] + + async def test_belongs_to_many_existence_query_with_callback(self): + label_ids = [label.id for label in await Label.where("name", "math").get()] + unused_ids = [label.id for label in await Label.where("name", "unused").get()] + + math = await Essay.where_has("labels", lambda query: label_ids).get() + assert [essay.title for essay in math] == ["Engines"] + + unused = await Essay.where_has("labels", lambda query: unused_ids).get() + assert unused.count() == 0 + + either = await Essay.where_has("labels", lambda query: unused_ids).or_where_has( + "labels", lambda query: label_ids + ).get() + assert [essay.title for essay in either] == ["Engines"] From 1b2ee14c0d6af1e35cef3106f669b26e13d5e708 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 26 Oct 2026 14:49:59 -0700 Subject: [PATCH 9/9] Format relationship key tests --- .../relationships/test_default_relationship_keys.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py index 11dd1526..28476b09 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py @@ -249,7 +249,9 @@ async def test_belongs_to_many_existence_query_with_callback(self): unused = await Essay.where_has("labels", lambda query: unused_ids).get() assert unused.count() == 0 - either = await Essay.where_has("labels", lambda query: unused_ids).or_where_has( - "labels", lambda query: label_ids - ).get() + either = ( + await Essay.where_has("labels", lambda query: unused_ids) + .or_where_has("labels", lambda query: label_ids) + .get() + ) assert [essay.title for essay in either] == ["Engines"]