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..dcc51ea0 --- /dev/null +++ b/docs/adr/039-default-relationship-keys-and-db-scaffolding.md @@ -0,0 +1,94 @@ +# 039. Default relationship keys and working database scaffolding + +Date: 2026-10-25 + +## Problem + +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, 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 + +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: + - 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 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, 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`. +- **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. +- **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")` | +| (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. + +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: + - 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 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/041-database-install-command.md b/docs/adr/041-database-install-command.md index 945983c5..ece52fbd 100644 --- a/docs/adr/041-database-install-command.md +++ b/docs/adr/041-database-install-command.md @@ -41,13 +41,13 @@ CacheInstallCommand = ( - The provider counts as already registered when the list names it as a bare name (`DatabaseProvider`) or as an attribute (`orm.DatabaseProvider`, `fastapi_startkit.masoniteorm.DatabaseProvider`). Nothing is changed in that case. Otherwise `(DatabaseProvider, DatabaseConfig)` is appended to the list, following its layout: a new line for multi-line lists, inline for single-line lists. If there is no providers argument, `providers=` is added. - Missing `from fastapi_startkit.masoniteorm import DatabaseProvider` and `from config.database import DatabaseConfig` imports are inserted after the last top-level import. A name that is already imported is compared by where it comes from, not only by name. The import is reused when its full path matches, or when both paths resolve to the same object in modules that are already loaded (for example a re-export). Nothing new is imported to check this. If the name is bound to something else, such as the base `fastapi_startkit.masoniteorm.config.database.DatabaseConfig`, the command stops with `DatabaseConfig is already imported as , not config.database.DatabaseConfig.` and prints the manual steps. Relative imports count as a different path. We considered importing under an alias (`DatabaseConfig as AppDatabaseConfig`), but rejected it: it adds a second name for the same concept to the user's bootstrap file and hides a likely mistake. Stopping is explicit, and the fix is a one-line edit. - If `bootstrap/application.py` or the `Application(...)` call is missing, or the file cannot be edited safely, it prints the reason and the lines to add by hand, and returns 1. The config file is still written. -- After a successful run it prints next steps. The `DB_*` `.env` entries and the extras come from the installer's `define_database(driver, project_name)` section, so they stay the same as `init --database`. If `importlib.util.find_spec("sqlalchemy")` finds nothing, it first prints `uv add "fastapi-startkit[database,]"`, because the generated config and `DatabaseProvider` import SQLAlchemy and every `artisan` call fails without it. It then prints `mkdir -p databases/migrations` if that directory is missing, because `db:make:migration` does not create it, and points to `db:make:migration` and `db:migrate`. The command does not create directories or seeders, and it does not edit `.env`, so that it only writes the config file and the bootstrap edit. +- After a successful run it prints next steps. The `DB_*` `.env` entries and the extras come from the installer's `define_database(driver, project_name)` section, so they stay the same as `init --database`. If `importlib.util.find_spec("sqlalchemy")` finds nothing, it first prints `uv add "fastapi-startkit[database,]"`, because the generated config and `DatabaseProvider` import SQLAlchemy and every `artisan` call fails without it. It then points to `db:make:migration`, which creates the migrations directory (ADR 039), and `db:migrate`. The command does not create directories or seeders, and it does not edit `.env`, so that it only writes the config file and the bootstrap edit. The command lives in `fastapi_startkit/console/` rather than `masoniteorm/commands/`, because importing `masoniteorm.commands` loads SQLAlchemy, which is an optional extra. `install_command.py` imports only `ast`, cleo and `Command`, so packages with optional extras can subclass it without loading anything extra. Text is inserted at AST-reported positions rather than regenerated with `ast.unparse`, so the user's formatting and comments are kept. ## Validation - `tests/console/test_install_command.py` tests commands built with the fluent API: a copied stub with a tuple entry, a provider-only command with a bare entry, several providers with a `Traversable` stub, class references converted to imports, and chain calls that do not mutate the base class. It also tests `register_provider` directly on single-line, multi-line, empty, missing and positional `providers` lists, files with no imports, existing imports, already-registered providers (bare names and attributes), names imported from another module, aliased or relative imports, re-exported classes and unsupported shapes. -- `tests/console/test_db_install_command.py` runs `db:install` against a temporary app directory. It covers a fresh install, a second run that changes nothing, an existing config with and without `--force`, a missing `config/` directory, a missing bootstrap file, an unsupported driver, a conflicting `DatabaseConfig` import, the next steps for each driver and an existing migrations directory, the hint when SQLAlchemy is missing, and loading the edited bootstrap file. +- `tests/console/test_db_install_command.py` runs `db:install` against a temporary app directory. It covers a fresh install, a second run that changes nothing, an existing config with and without `--force`, a missing `config/` directory, a missing bootstrap file, an unsupported driver, a conflicting `DatabaseConfig` import, the next steps for each driver, the hint when SQLAlchemy is missing, and loading the edited bootstrap file. - The full suite passes with coverage of at least 80%. - Manual run, twice, against an app generated by `fastapi-startkit init --database none`. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 451f9357..7c6d663e 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -37,5 +37,6 @@ Read this index first, then open the records relevant to the change. Before impl | [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. | | [037](037-http-exception-status-and-fastapi-title.md) | 2026-10-25 | HTTP exceptions keep their status; FastAPI title from config | `HTTPExceptionHandler` rendered every `HTTPException` and `ModelNotFoundException` as 500. Starlette `HTTPException` now goes through FastAPI's `http_exception_handler`, which keeps the status, the `detail` and the headers. A new dependency-free `fastapi_startkit.exceptions.HttpException` base (`get_status`/`get_response`/`get_headers`) is subclassed by `ModelNotFoundException`, `RouteNotFoundException`, `MethodNotAllowedException`, `AuthorizationException` and `ThrottleRequestsException` (429 with `Retry-After`), and is registered as its own FastAPI handler so it never reaches the re-raising `ServerErrorMiddleware`. `HTTPExceptionHandler.report` skips 4xx HTTP exceptions and still reports 5xx. `FastAPIConfig.title` reads `APP_NAME` (default `FastAPI Startkit`). `Application.__init__` no longer discards the FastAPI instance a provider creates in `register`. | | [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. | +| [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. | | [040](040-deprecate-application-env-argument.md) | 2026-10-25 | Deprecate the `env` argument of `Application` | The deployment environment must come from outside the code: `APP_ENV`, the CLI `--env` option (turned into `APP_ENV` before Cleo parses arguments) or pytest detection (`testing`). `Application(env=...)` now emits a `DeprecationWarning` at the caller (`stacklevel=2`) and will be removed in 1.0; behaviour and precedence are unchanged until then. `set_environment()` stays. Framework tests no longer pass `env=`, and pytest `filterwarnings` turns this warning into an error so the suite cannot reintroduce it. | | [041](041-database-install-command.md) | 2026-10-22 | InstallCommand base class and db:install | `console/install_command.py` adds an `InstallCommand` base class with a fluent class builder: `InstallCommand.register(name).copy(stub, dest).add_provider(Provider, "config.x.XConfig")`. Each call returns a new subclass. Providers and configs can be classes or dotted strings (strings for app-side configs and optional extras). `--force`, `publish_file` (skips existing files unless forced) and `register_provider` are shared. `register_provider` finds `Application(...)` in `bootstrap/application.py` with `ast` and inserts the entry and any missing imports at AST-reported positions, so formatting is kept. It handles `providers` passed by keyword or positionally. It does nothing if the provider is already listed, by bare name or attribute. It stops with manual steps and exit 1 if the bootstrap file is missing, or if a needed name is already imported from a different module (no aliasing). `db:install` (registered by `AppProvider`) subclasses it to add `--driver` and render `config/database.py` from the installer stub. After a successful run it prints next steps from the installer's database section (the `DB_*` env entries, `uv add` when SQLAlchemy is missing, migrations). Both live in `console/` so the optional SQLAlchemy extra is not imported. | diff --git a/fastapi_startkit/src/fastapi_startkit/console/db_install_command.py b/fastapi_startkit/src/fastapi_startkit/console/db_install_command.py index 0e2eaebd..a9311791 100644 --- a/fastapi_startkit/src/fastapi_startkit/console/db_install_command.py +++ b/fastapi_startkit/src/fastapi_startkit/console/db_install_command.py @@ -9,8 +9,6 @@ from fastapi_startkit.console.install_command import InstallCommand, ProviderEntry from fastapi_startkit.installer.plan import DRIVER_CLASSES, define_database, project_name_for -MIGRATIONS_PATH = "databases/migrations" - class DbInstallCommand(InstallCommand): name = "db:install" @@ -59,7 +57,5 @@ def next_steps(self) -> None: self.line(" Add to .env:") for key, value in section.environment.items(): self.line(f" {key}={value}") - if not (self.base_path / MIGRATIONS_PATH).is_dir(): - self.line(f" Create the migrations directory: mkdir -p {MIGRATIONS_PATH}") self.line(" Create a migration: uv run python artisan db:make:migration create_users_table") self.line(" Run migrations: uv run python artisan db:migrate") 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/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/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 7fd23df2..e9257e41 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,35 @@ import datetime +from pathlib import Path from typing import Any import pendulum +from sqlalchemy import URL +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) + 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]: 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 1b014072..e61f4a82 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 underscore from typing_extensions import TypeVar from fastapi_startkit.masoniteorm.models import registry @@ -19,7 +20,32 @@ 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 + 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 + + 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 +94,26 @@ 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 snake_name_of(model) -> str: + return underscore(model.__name__) + + @classmethod + 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 self.attribute is None or owner is self.__dict__.get("owner_class", owner): + return self + 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 + def exists_alias(self, query, parent): if query.get_table_name() == parent._table: return f"{parent.get_table_name()}_self" @@ -84,7 +130,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): """ @@ -102,8 +148,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 399c89bb..0493f9f1 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 0fa024e3..948b6d07 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/BelongsToMany.py @@ -1,7 +1,6 @@ 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.builder import QueryBuilder from ..models.pivot import Pivot @@ -10,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, @@ -27,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 @@ -36,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(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__, + "other_owner_key": related.__primary_key__, + } async def apply_query(self, foreign, owner): """Apply the query and return a dictionary to be hydrated. @@ -53,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( @@ -164,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 = ( @@ -279,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( @@ -340,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() @@ -417,7 +367,7 @@ def query_has(self, current_query_builder, method="where_exists"): def related_exists_query(self, builder): query = self.get_builder() - pivot_table = self._table or self.get_pivot_table_name(query, builder) + pivot_table = self._table related = query.new().table(query.get_table_name(), self.exists_alias(query, builder)) table = related.get_table_name() return related.join( @@ -430,17 +380,8 @@ def related_exists_query(self, builder): f"{builder.get_table_name()}.{self.local_owner_key}", ) - 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("*") @@ -472,8 +413,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( { @@ -490,8 +429,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() @@ -507,8 +444,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( { @@ -525,8 +460,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/HasMany.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasMany.py index 3985f478..430980a4 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 0fc810e0..8f94a562 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/relationships/HasOne.py @@ -8,14 +8,11 @@ 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 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/console/test_db_install_command.py b/fastapi_startkit/tests/console/test_db_install_command.py index 04454502..26a1fd31 100644 --- a/fastapi_startkit/tests/console/test_db_install_command.py +++ b/fastapi_startkit/tests/console/test_db_install_command.py @@ -147,19 +147,12 @@ def test_prints_next_steps_after_install(tmp_path): assert "Next steps:" in output assert "DB_CONNECTION=sqlite\n" in output assert "DB_DATABASE=databases/database.sqlite\n" in output - assert "mkdir -p databases/migrations" in output + assert "mkdir" not in output assert "uv run python artisan db:make:migration create_users_table" in output assert "uv run python artisan db:migrate" in output assert "uv add" not in output -def test_next_steps_skip_existing_migrations_directory(tmp_path): - app = make_app(tmp_path) - (app / "databases/migrations").mkdir(parents=True) - - assert "mkdir" not in run(app).io.fetch_output() - - def test_next_steps_use_driver_environment(tmp_path): app = make_app(tmp_path / "my-shop") 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 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 9f0cbe03..56112111 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 @@ -77,3 +77,46 @@ 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, tmp_path): + monkeypatch.setattr(app(), "base_path", tmp_path) + 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) + + +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() + + +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/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 68d6b54d..3b17b668 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 new file mode 100644 index 00000000..28476b09 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_default_relationship_keys.py @@ -0,0 +1,257 @@ +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 BaseRelationship, HasOneThrough +from fastapi_startkit.masoniteorm.relationships.BaseRelationship import RelationshipKey + +from ..test_case import TestCase + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + class Novelist(Model): + __timestamps__ = False + + memoir = HasOne["Memoir"]() + essays = HasMany["Essay"]() + latest_media = HasOneThrough(["Media", "Essay"]) + + class Memoir(Model): + __timestamps__ = False + + novelist = BelongsTo["Novelist"]() + + class Essay(Model): + __timestamps__ = False + + novelist = BelongsTo["Novelist"]() + media = HasMany("Media") + labels = BelongsToMany["Label"]() + + class Media(Model): + __table__ = "media" + __timestamps__ = False + + essay = BelongsTo("Essay") + labels = BelongsToMany["Label"]() + + class Label(Model): + __table__ = "blog_labels" + __timestamps__ = False + + 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 + + class PlainRelationship(BaseRelationship): + pass + + class KeyPlainOwner(Model): + plain = PlainRelationship("KeyAccountNote") + + +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_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" + + +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") + + +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() + schema = self.schema.on("default") + + 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("memoirs") as table: + table.id() + table.integer("novelist_id") + table.string("body") + + async with await schema.create_table_if_not_exists("essays") as table: + table.id() + table.integer("novelist_id") + table.string("title") + + async with await schema.create_table_if_not_exists("media") as table: + table.id() + table.integer("essay_id") + table.string("path") + + 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("essay_label") as table: + table.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): + essays = await Essay.with_("novelist", "labels", "media").where("title", "Engines").get() + essay = essays.first() + + 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 Media.with_("essay").first_or_fail() + assert media.essay.title == "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): + novelist = await Novelist.with_("memoir", "essays").where("name", "Ada").first_or_fail() + + assert novelist.memoir.body == "Analyst" + assert sorted(essay.title for essay in novelist.essays) == ["Engines", "Notes"] + + memoir = await Memoir.with_("novelist").first_or_fail() + assert memoir.novelist.name == "Ada" + + async def test_eager_loads_default_key_has_one_through(self): + novelist = await Novelist.with_("latest_media").where("name", "Ada").first_or_fail() + + assert novelist.latest_media.path == "engine.png" + assert novelist.latest_media.id is not None + + 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_essays = await Novelist.where_has("essays").get() + assert [novelist.name for novelist in with_essays] == ["Ada"] + + engines = await Novelist.where_has("essays", lambda query: query.where("title", "Engines")).get() + assert [novelist.name for novelist in engines] == ["Ada"] + + labelled = await Label.where_has("essays").get() + assert [label.name for label in labelled] == ["math"] + + with_memoir = await Novelist.where_has("memoir").get() + assert [novelist.name for novelist in with_memoir] == ["Ada"] + + 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"] + + labelled_essays = await Essay.where_has("labels").get() + assert [essay.title for essay in labelled_essays] == ["Engines"] + + 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"] 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, ) diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index 29857452..adb7a72b 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -828,7 +828,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.67.0" +version = "0.70.0" source = { editable = "." } dependencies = [ { name = "cleo" },