nene2-python 1.0.0__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nene2_python-1.0.0 → nene2_python-1.1.0}/PKG-INFO +1 -1
- nene2_python-1.1.0/docs/field-trials/2026-05-field-trial-7.md +170 -0
- nene2_python-1.1.0/docs/field-trials/2026-05-field-trial-8.md +218 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/how-to/sqlalchemy-repository.md +176 -20
- {nene2_python-1.0.0 → nene2_python-1.1.0}/pyproject.toml +1 -1
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/database/__init__.py +2 -0
- nene2_python-1.1.0/src/nene2/database/utils.py +28 -0
- nene2_python-1.1.0/tests/nene2/database/test_utils.py +55 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/.env.example +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/.github/workflows/ci.yml +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/.github/workflows/docs.yml +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/.github/workflows/publish.yml +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/.gitignore +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/.vitepress/config.mts +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/.vitepress/theme/custom.css +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/.vitepress/theme/index.ts +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/AGENTS.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/CHANGELOG.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/CLAUDE.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/Dockerfile +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/LICENSE +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/README.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/alembic/README +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/alembic/env.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/alembic/script.py.mako +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/alembic/versions/001_create_notes_and_tags_tables.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/alembic.ini +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/compose.yaml +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0001-toolchain.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0002-clean-architecture.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0003-security-first.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0004-ai-first-design.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0005-logging.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0006-rate-limiting.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0009-mcp-design.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0010-async-use-case.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/de/index.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/de/tutorials/getting-started.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/explanation/architecture.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/explanation/design-philosophy.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-1.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-2.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-3.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-4.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-5.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-6.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/fr/index.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/fr/tutorials/getting-started.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/how-to/add-new-domain.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/how-to/configure-auth.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/how-to/new-project.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/how-to/run-tests.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/howto/mcp-setup.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/index.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/explanation/architecture.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/explanation/design-philosophy.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/how-to/add-new-domain.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/how-to/configure-auth.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/how-to/new-project.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/how-to/run-tests.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/how-to/sqlalchemy-repository.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/howto/mcp-setup.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/index.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/reference/api.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/reference/configuration.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/reference/framework-modules.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/tutorials/first-domain.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/tutorials/getting-started.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/pt-br/index.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/pt-br/tutorials/getting-started.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/reference/api.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/reference/configuration.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/reference/framework-modules.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/roadmap.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/todo/current.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/tutorials/first-domain.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/tutorials/getting-started.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/zh/index.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/zh/tutorials/getting-started.md +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/package-lock.json +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/package.json +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/__main__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/app.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/entity.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/exceptions.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/handler.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/repository.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/sqlalchemy_repository.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/use_case.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/mcp.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/async_use_case.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/entity.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/exceptions.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/handler.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/repository.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/sqlalchemy_repository.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/use_case.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/schema.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/entity.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/exceptions.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/handler.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/repository.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/sqlalchemy_repository.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/use_case.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/api_key.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/bearer_token.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/exceptions.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/interfaces.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/local_verifier.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/config/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/config/settings.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/database/exceptions.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/database/health.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/database/interfaces.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/database/sqlalchemy_executor.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/http/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/http/health.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/http/pagination.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/http/problem_details.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/log/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/log/setup.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/mcp/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/mcp/http_client.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/mcp/server.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/domain_exception.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/error_handler.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/request_id.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/request_logging.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/request_size_limit.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/security_headers.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/throttle.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/py.typed +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/use_case/protocols.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/validation/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/validation/exceptions.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/scripts/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/src/scripts/export_openapi.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/comment/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/comment/test_comment_http.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/comment/test_comment_repository.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/comment/test_comment_use_case.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/conftest.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/note/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/note/test_async_note_use_case.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/note/test_list_notes.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/note/test_note_repository.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/tag/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/tag/test_tag_repository.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/tag/test_tags.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/test_cors.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/test_mcp.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/auth/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/auth/test_api_key.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/auth/test_bearer_token.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/auth/test_token_issuer.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/database/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/database/test_transaction.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/http/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/http/test_pagination.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/mcp/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/mcp/test_http_client.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_error_handler.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_request_id.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_request_logging.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_request_size_limit.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_security_headers.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_throttle.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/use_case/test_protocols.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/validation/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/validation/test_exceptions.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/scripts/__init__.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/scripts/test_export_openapi.py +0 -0
- {nene2_python-1.0.0 → nene2_python-1.1.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nene2-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: NENE2 Python — minimal API framework following NENE2's design philosophy
|
|
5
5
|
Project-URL: Homepage, https://github.com/hideyukiMORI/nene2-python
|
|
6
6
|
Project-URL: Repository, https://github.com/hideyukiMORI/nene2-python
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Field Trial 7 — bookmark: PyPI publish フロー DX 検証
|
|
2
|
+
|
|
3
|
+
## Date
|
|
4
|
+
|
|
5
|
+
2026-05-20
|
|
6
|
+
|
|
7
|
+
## Baseline
|
|
8
|
+
|
|
9
|
+
- nene2-python v1.0.0(**PyPI 経由** `uv add nene2-python`)
|
|
10
|
+
- Python 3.14(uv managed)
|
|
11
|
+
- プロジェクト: **bookmark** — ブックマーク管理 JSON API
|
|
12
|
+
- エンティティ: `Bookmark`(id, title, url, description)
|
|
13
|
+
- 5 エンドポイント: CRUD(List / Get / Create / Update / Delete)
|
|
14
|
+
- InMemory リポジトリのみ(SQLite なし)
|
|
15
|
+
- **目標**: `pip install nene2-python` → ゼロ設定で動く API を構築できるか
|
|
16
|
+
|
|
17
|
+
## Goal
|
|
18
|
+
|
|
19
|
+
1. PyPI 公開フローの DX 検証(TestPyPI → PyPI 本番)
|
|
20
|
+
2. `uv add nene2-python`(PyPI 経由)でのインストールが正常に機能するか確認
|
|
21
|
+
3. 公開パッケージだけを使ってブックマーク API を構築する E2E DX 検証
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Steps Taken
|
|
26
|
+
|
|
27
|
+
### 1. PyPI 公開フロー
|
|
28
|
+
|
|
29
|
+
#### TestPyPI
|
|
30
|
+
|
|
31
|
+
- GitHub Actions OIDC(Trusted Publishing)を設定
|
|
32
|
+
- `uv publish --trusted-publishing always` を使用
|
|
33
|
+
- `pypa/gh-action-pypi-publish@release/v1` は Docker コンテナ内で実行されるため、
|
|
34
|
+
GitHub Actions の Docker ネットワークで DNS 解決が失敗する問題が発生
|
|
35
|
+
→ `uv publish` をランナー直接実行に切り替えて解決
|
|
36
|
+
|
|
37
|
+
#### PyPI 本番
|
|
38
|
+
|
|
39
|
+
- TestPyPI 確認後、同一ワークフローで本番 PyPI へ publish
|
|
40
|
+
- GitHub Release を自動生成(`gh release create`)
|
|
41
|
+
|
|
42
|
+
### 2. プロジェクト初期化
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv init --name bookmark --no-workspace
|
|
46
|
+
uv add nene2-python # PyPI から v1.0.0 をインストール
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
追加設定不要。`nene2-python` が FastAPI・Pydantic・SQLAlchemy・structlog をすべて束ねているため、
|
|
50
|
+
依存関係の解決は `uv add nene2-python` 一行で完了。
|
|
51
|
+
|
|
52
|
+
### 3. ドメイン層の実装
|
|
53
|
+
|
|
54
|
+
Clean Architecture の4層(entity / exceptions / repository / use_case)を独立して記述:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
# entity.py
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class Bookmark:
|
|
60
|
+
id: int
|
|
61
|
+
title: str
|
|
62
|
+
url: str
|
|
63
|
+
description: str
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`nene2` からインポートが必要なのは exceptions のみ:
|
|
67
|
+
```python
|
|
68
|
+
from nene2.http import problem_details_response
|
|
69
|
+
from nene2.middleware.domain_exception import DomainExceptionHandlerProtocol
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
UseCase・Repository・Entity は nene2 に依存しないため、フレームワーク非依存のビジネスロジックとして成立。
|
|
73
|
+
|
|
74
|
+
### 4. HTTP 層の実装
|
|
75
|
+
|
|
76
|
+
`handler.py` で `nene2.http`・`nene2.validation` を使用:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from nene2.http import PaginationQueryParser, PaginationResponse
|
|
80
|
+
from nene2.validation.exceptions import ValidationError, ValidationException
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
パターンは example/note/handler.py と完全に同じ。5分で移植可能。
|
|
84
|
+
|
|
85
|
+
### 5. アプリケーションファクトリ
|
|
86
|
+
|
|
87
|
+
`app.py` でミドルウェアをスタックして完成:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from nene2.config import AppSettings
|
|
91
|
+
from nene2.middleware import (
|
|
92
|
+
ErrorHandlerMiddleware, RequestIdMiddleware,
|
|
93
|
+
RequestLoggingMiddleware, RequestSizeLimitMiddleware,
|
|
94
|
+
SecurityHeadersMiddleware,
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
ミドルウェア登録は innermost-first 順で明示的(ドキュメントにコメントあり)。
|
|
99
|
+
|
|
100
|
+
### 6. 動作確認
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
uv run uvicorn app:app --port 8090
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
POST /bookmarks {"title":"GitHub","url":"https://github.com","description":"Code hosting"}
|
|
108
|
+
→ 201 {"id":1,"title":"GitHub","url":"https://github.com","description":"Code hosting"}
|
|
109
|
+
|
|
110
|
+
GET /bookmarks?limit=10&offset=0
|
|
111
|
+
→ 200 {"items":[...],"limit":10,"offset":0,"total":2}
|
|
112
|
+
|
|
113
|
+
GET /bookmarks/1
|
|
114
|
+
→ 200 {"id":1,...}
|
|
115
|
+
|
|
116
|
+
PUT /bookmarks/1 {"title":"GitHub Updated","url":"https://github.com","description":"World's largest code host"}
|
|
117
|
+
→ 200 {"id":1,"title":"GitHub Updated",...}
|
|
118
|
+
|
|
119
|
+
DELETE /bookmarks/2
|
|
120
|
+
→ 204
|
|
121
|
+
|
|
122
|
+
GET /bookmarks/99
|
|
123
|
+
→ 404 {"type":"https://nene2.dev/problems/not-found","title":"Not Found","status":404,"detail":"Bookmark 99 not found."}
|
|
124
|
+
|
|
125
|
+
POST /bookmarks {"title":" ","url":"https://example.com","description":""}
|
|
126
|
+
→ 422 {"type":"https://nene2.dev/problems/validation-failed","errors":[{"field":"title","message":"Title must not be empty.","code":"required"}]}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
全エンドポイントが期待通りに動作。Problem Details(RFC 9457)レスポンスも正常。
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Friction Points
|
|
134
|
+
|
|
135
|
+
### FT7-1: Docker DNS 問題(`pypa/gh-action-pypi-publish`)
|
|
136
|
+
|
|
137
|
+
- **摩擦**: `pypa/gh-action-pypi-publish@release/v1` を使用すると、Docker コンテナ内で
|
|
138
|
+
`upload.test.pypi.org` の DNS 解決に失敗する
|
|
139
|
+
- **深刻度**: HIGH(公開ブロック)
|
|
140
|
+
- **解決策**: `uv publish --trusted-publishing always` をランナー直接実行に切り替え
|
|
141
|
+
- **所要時間**: ワークフロー修正・デバッグで約 1 時間
|
|
142
|
+
|
|
143
|
+
### FT7-2: `[tool.uv] dev-dependencies` → `[dependency-groups]` 変更
|
|
144
|
+
|
|
145
|
+
- **摩擦**: `uv init` が生成した `pyproject.toml` に旧形式の `[tool.uv] dev-dependencies` が含まれる場合がある(uv バージョン依存)
|
|
146
|
+
- **深刻度**: LOW(警告のみ)
|
|
147
|
+
- **解決策**: `[dependency-groups] dev` 形式に更新
|
|
148
|
+
|
|
149
|
+
### FT7-3: `InMemoryRepository` を毎回自前実装
|
|
150
|
+
|
|
151
|
+
- **摩擦**: テスト用 InMemory Repository をドメインごとにゼロから書く必要がある
|
|
152
|
+
- **深刻度**: LOW(パターンが明確なため機械的に書ける)
|
|
153
|
+
- **検討**: `GenericInMemoryRepository[T]` のような汎用実装をフレームワークに含めるか検討余地あり
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Summary
|
|
158
|
+
|
|
159
|
+
| ID | 摩擦 | 深刻度 | 種別 | Follow-up |
|
|
160
|
+
|--------|-------------------------------------------|--------|---------------|-----------|
|
|
161
|
+
| FT7-1 | Docker DNS で pypa action が失敗 | HIGH | インフラ | 解決済み(uv publish に切り替え) |
|
|
162
|
+
| FT7-2 | dev-dependencies フォーマット警告 | LOW | DX | 解決済み(PR #154) |
|
|
163
|
+
| FT7-3 | InMemory Repository を毎回書く | LOW | フレームワーク | 検討余地あり |
|
|
164
|
+
|
|
165
|
+
FT7 の主目的(PyPI 公開 → インストール → 動く API 構築)は達成。
|
|
166
|
+
`uv add nene2-python` の一行でフレームワーク全体が入り、
|
|
167
|
+
Clean Architecture パターンをゼロから構築するのに要した時間は **30分以内**(ドメイン4ファイル + handler + app)。
|
|
168
|
+
|
|
169
|
+
**PyPI publish パイプライン(TestPyPI → PyPI → GitHub Release)は確立済み。**
|
|
170
|
+
FT8 候補:MySQL/PostgreSQL アダプターの実地検証、または親子リソース(nested REST)DX 検証。
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Field Trial 8 — blog: 親子リソース (Nested REST) + datetime + SQLite 複数エンティティ
|
|
2
|
+
|
|
3
|
+
## Date
|
|
4
|
+
|
|
5
|
+
2026-05-20
|
|
6
|
+
|
|
7
|
+
## Baseline
|
|
8
|
+
|
|
9
|
+
- nene2-python v1.0.0(PyPI 経由 `uv add nene2-python`)
|
|
10
|
+
- Python 3.14(uv managed)
|
|
11
|
+
- プロジェクト: **blog** — ブログ API(投稿 + コメント)
|
|
12
|
+
- エンティティ:
|
|
13
|
+
- `Post(id, title, body, created_at: datetime)`
|
|
14
|
+
- `Comment(id, post_id, author, body, created_at: datetime)`
|
|
15
|
+
- SQLite ファイル永続化(`blog.db`)
|
|
16
|
+
- 8 エンドポイント: Post CRUD + コメント List/Create/Delete(Nested REST)
|
|
17
|
+
|
|
18
|
+
## Goal
|
|
19
|
+
|
|
20
|
+
FT1〜FT7 で未探索のパターンを一度に検証:
|
|
21
|
+
|
|
22
|
+
1. **親子リソース(Nested REST)**: `GET /posts/{id}/comments`、`POST /posts/{id}/comments`
|
|
23
|
+
2. **`datetime` フィールドを持つエンティティ**: `created_at` が SQLite → entity → JSON でどう流れるか
|
|
24
|
+
3. **SQLite 外部キー**: 2エンティティ間の参照整合性(`ON DELETE CASCADE`)
|
|
25
|
+
4. **`DatabaseHealthCheck`** の実際の動作確認
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Steps Taken
|
|
30
|
+
|
|
31
|
+
### 1. プロジェクト初期化
|
|
32
|
+
|
|
33
|
+
問題なし。`uv add nene2-python` 一行で完了。
|
|
34
|
+
|
|
35
|
+
### 2. エンティティに `datetime` フィールドを追加
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Post:
|
|
40
|
+
id: int
|
|
41
|
+
title: str
|
|
42
|
+
body: str
|
|
43
|
+
created_at: datetime # ← FT1〜FT7 で一度も使われなかったフィールド
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
FT1〜FT7 の全エンティティ(Note, Book, Task, Snippet, Wallet, City, Bookmark)は
|
|
47
|
+
`created_at` を持っていない。example の Note も schema に `created_at` カラムはあるが
|
|
48
|
+
エンティティ定義には含まれていない。**datetime フィールドの扱いは前例なし。**
|
|
49
|
+
|
|
50
|
+
### 3. SQLite スキーマ(外部キー + `CURRENT_TIMESTAMP`)
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
# PRAGMA foreign_keys=ON で参照整合性を有効化
|
|
54
|
+
@event.listens_for(Engine, "connect")
|
|
55
|
+
def _set_sqlite_pragma(dbapi_conn, _):
|
|
56
|
+
if isinstance(dbapi_conn, sqlite3.Connection):
|
|
57
|
+
dbapi_conn.execute("PRAGMA foreign_keys=ON")
|
|
58
|
+
|
|
59
|
+
# comments テーブルに外部キー
|
|
60
|
+
"post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`PRAGMA foreign_keys=ON` は example の schema.py を参照して発見。ドキュメントには記載なし。
|
|
64
|
+
|
|
65
|
+
### 4. SQLAlchemy Repository での datetime 変換
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
def _to_post(row: dict[str, Any]) -> Post:
|
|
69
|
+
return Post(
|
|
70
|
+
id=row["id"],
|
|
71
|
+
title=row["title"],
|
|
72
|
+
body=row["body"],
|
|
73
|
+
# SQLite は DATETIME を text 型で返す: "2026-05-20 12:34:56"
|
|
74
|
+
created_at=datetime.fromisoformat(str(row["created_at"])).replace(tzinfo=timezone.utc),
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 5. SELECT-after-INSERT パターン(DB生成タイムスタンプを取得)
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
def save(self, title: str, body: str) -> Post:
|
|
82
|
+
new_id = self._executor.write(
|
|
83
|
+
"INSERT INTO posts (title, body) VALUES (:title, :body)",
|
|
84
|
+
{"title": title, "body": body},
|
|
85
|
+
)
|
|
86
|
+
row = self._executor.fetch_one(
|
|
87
|
+
"SELECT id, title, body, created_at FROM posts WHERE id = :id",
|
|
88
|
+
{"id": new_id},
|
|
89
|
+
)
|
|
90
|
+
if row is None:
|
|
91
|
+
raise RuntimeError(f"Row {new_id} not found after INSERT into posts")
|
|
92
|
+
return _to_post(row)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
INSERT → `write()` → `lastrowid` → `fetch_one()` の2往復が必要。
|
|
96
|
+
|
|
97
|
+
### 6. Nested REST ハンドラー
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
@router.get("/posts/{post_id}/comments")
|
|
101
|
+
async def list_comments(post_id: int, request: Request) -> JSONResponse:
|
|
102
|
+
pagination = PaginationQueryParser.parse(request)
|
|
103
|
+
output = list_comments_use_case.execute(
|
|
104
|
+
ListCommentsInput(post_id, pagination.limit, pagination.offset)
|
|
105
|
+
)
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
@router.post("/posts/{post_id}/comments", status_code=201)
|
|
109
|
+
async def create_comment(post_id: int, body: CreateCommentBody) -> JSONResponse:
|
|
110
|
+
...
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
FastAPI がパスパラメータ `post_id` を UseCase に渡すため、ネストの実装自体は自然。
|
|
114
|
+
|
|
115
|
+
### 7. 動作確認
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
GET /health
|
|
119
|
+
→ 200 {"status":"ok","checks":{"database":"ok"}} ← DatabaseHealthCheck 正常
|
|
120
|
+
|
|
121
|
+
POST /posts {"title":"Hello nene2","body":"First post"}
|
|
122
|
+
→ 201 {"id":1,"title":"Hello nene2","body":"First post","created_at":"2026-05-19T18:39:52+00:00"}
|
|
123
|
+
|
|
124
|
+
POST /posts/1/comments {"author":"Alice","body":"Great!"}
|
|
125
|
+
→ 201 {"id":1,"post_id":1,"author":"Alice","body":"Great!","created_at":"2026-05-19T18:39:52+00:00"}
|
|
126
|
+
|
|
127
|
+
GET /posts/1/comments?limit=10
|
|
128
|
+
→ 200 {"items":[...],"limit":10,"offset":0,"total":1}
|
|
129
|
+
|
|
130
|
+
POST /posts/999/comments {...}
|
|
131
|
+
→ 404 {"type":"...not-found","detail":"Post 999 not found."}
|
|
132
|
+
|
|
133
|
+
DELETE /posts/1
|
|
134
|
+
→ 204 (cascade: comments も自動削除)
|
|
135
|
+
|
|
136
|
+
GET /posts/1/comments (削除後)
|
|
137
|
+
→ 404 Post 1 not found.
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Friction Points
|
|
143
|
+
|
|
144
|
+
### FT8-1: SQLite `CURRENT_TIMESTAMP` が naive datetime を返す — タイムゾーン情報が失われる
|
|
145
|
+
|
|
146
|
+
- **摩擦**: `CURRENT_TIMESTAMP` は UTC 時刻だが SQLite は `"2026-05-20 12:34:56"` という文字列で格納・返却する
|
|
147
|
+
- `datetime.fromisoformat("2026-05-20 12:34:56")` は **naive datetime**(tzinfo なし)を返す
|
|
148
|
+
- JSON にシリアライズすると `"2026-05-20T12:34:56"` — タイムゾーンが不明な時刻として API に漏れる
|
|
149
|
+
- **深刻度**: MEDIUM(API 利用者が UTC か JST か判断できない)
|
|
150
|
+
- **解決策**: `.replace(tzinfo=timezone.utc)` を明示的に追加
|
|
151
|
+
- **nene2 の対応**: `fetch_one` / `fetch_all` の戻り値に datetime フィールドのガイダンスがない
|
|
152
|
+
|
|
153
|
+
### FT8-2: `dict[str, object]` vs `dict[str, Any]` — 型注釈の落とし穴
|
|
154
|
+
|
|
155
|
+
- **摩擦**: `_to_post(row: dict[str, object])` と書くと `int(row["id"])` が mypy `call-overload` エラー
|
|
156
|
+
- `# type: ignore[arg-type]` を付けると「wrong error code」で `unused-ignore` が発生(二重エラー)
|
|
157
|
+
- **深刻度**: MEDIUM(mypy --strict を使う場合にブロッキング)
|
|
158
|
+
- **解決策**: `dict[str, Any]` を使う(`fetch_one()` の実際の戻り値型と一致)
|
|
159
|
+
- **根本原因**: nene2 の「row-to-entity 変換」サンプルに datetime フィールドがない
|
|
160
|
+
|
|
161
|
+
### FT8-3: SELECT-after-INSERT のボイラープレート
|
|
162
|
+
|
|
163
|
+
- **摩擦**: DB 生成の `CURRENT_TIMESTAMP` を取得するために INSERT → `lastrowid` → `fetch_one` の2往復が必要
|
|
164
|
+
- 毎 `save()` メソッドに同じパターンを書く(PostRepository と CommentRepository の両方)
|
|
165
|
+
- `fetch_one()` は `dict | None` を返すため、`if row is None: raise RuntimeError(...)` が必要
|
|
166
|
+
- `assert row is not None` は ruff S101(本番コードで assert 禁止)で弾かれる
|
|
167
|
+
- **深刻度**: LOW(機能するが冗長)
|
|
168
|
+
- **検討**: `write_and_return(sql, params, select_sql)` のようなヘルパーをフレームワークに追加するか
|
|
169
|
+
|
|
170
|
+
### FT8-4: if/else で異なる実装を変数に代入すると mypy エラー
|
|
171
|
+
|
|
172
|
+
- **摩擦**:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
if cfg.db_adapter == "sqlite":
|
|
176
|
+
post_repo = SqlAlchemyPostRepository(executor)
|
|
177
|
+
else:
|
|
178
|
+
post_repo = InMemoryPostRepository() # ← mypy: Incompatible types
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- `mypy --strict` が `Incompatible types in assignment` を報告
|
|
182
|
+
- **解決策**: 事前に型注釈を宣言する
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
post_repo: PostRepositoryInterface
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- **深刻度**: LOW(パターンを一度知れば1行で解決)
|
|
189
|
+
- example の `app.py` は `_build_repositories()` 関数で隠蔽しているため、このエラーが見えにくい
|
|
190
|
+
|
|
191
|
+
### FT8-5: Nested REST の `post_id` パスパラメータが DELETE で無視される — 設計の穴
|
|
192
|
+
|
|
193
|
+
- **摩擦**: `DELETE /posts/{post_id}/comments/{comment_id}` の `post_id` を DELETE ハンドラーが無視する
|
|
194
|
+
- `DELETE /posts/1/comments/2` が post 2 のコメント2(別ポストのコメント)を削除できてしまう
|
|
195
|
+
- REST セマンティクス的には `/posts/1/comments/2` は「post 1 に属するコメント2」を指すべき
|
|
196
|
+
- **深刻度**: MEDIUM(アクセス制御がある場合はセキュリティ問題になりうる)
|
|
197
|
+
- **解決策**: DELETE UseCase に `post_id` を含め、コメントの `post_id` と一致するか検証する
|
|
198
|
+
- **nene2 の対応**: ネストリソースの DELETE 設計についてガイダンスなし
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Summary
|
|
203
|
+
|
|
204
|
+
| ID | 摩擦 | 深刻度 | 種別 | 解決策 |
|
|
205
|
+
|--------|-----------------------------------------------|--------|----------------|-------------------------------------|
|
|
206
|
+
| FT8-1 | SQLite naive datetime — TZ情報が失われる | MEDIUM | 設計/DB | `.replace(tzinfo=timezone.utc)` |
|
|
207
|
+
| FT8-2 | `dict[str, object]` vs `Any` — mypy エラー | MEDIUM | 型安全 | `dict[str, Any]` を使う |
|
|
208
|
+
| FT8-3 | SELECT-after-INSERT ボイラープレート | LOW | フレームワーク | `write_and_return()` ヘルパー検討 |
|
|
209
|
+
| FT8-4 | if/else 分岐での型注釈宣言が必要 | LOW | 型安全 | `var: InterfaceType` を先に宣言 |
|
|
210
|
+
| FT8-5 | Nested DELETE で `post_id` が無視される | MEDIUM | 設計 | UseCase に `post_id` を追加して検証 |
|
|
211
|
+
|
|
212
|
+
親子リソース(Nested REST)そのものの実装は **摩擦ゼロ** — FastAPI のパスパラメータが自然に機能した。
|
|
213
|
+
主な摩擦は `datetime` フィールドと `mypy --strict` 周辺に集中。
|
|
214
|
+
|
|
215
|
+
FT9 候補:
|
|
216
|
+
- **FT8-1/FT8-3 の改善**: nene2 フレームワークに `datetime` ユーティリティ / `write_and_return()` を追加して検証
|
|
217
|
+
- **MySQL/PostgreSQL**: SQLite 以外のアダプター(`RETURNING` 句が使えるので FT8-3 が解消されるか検証)
|
|
218
|
+
- **HttpxMcpClient**: HTTP モードの MCP クライアントを実地検証
|
|
@@ -42,9 +42,10 @@ def ensure_schema(executor: DatabaseQueryExecutorInterface) -> None:
|
|
|
42
42
|
### Row-to-entity helper
|
|
43
43
|
|
|
44
44
|
`fetch_one` / `fetch_all` return `dict[str, Any]`.
|
|
45
|
-
Use a private static method to centralise the
|
|
45
|
+
Use a private static method to centralise the mapping and keep each query method lean.
|
|
46
46
|
|
|
47
47
|
```python
|
|
48
|
+
from typing import Any
|
|
48
49
|
from .entity import Book
|
|
49
50
|
from .repository import BookRepositoryInterface
|
|
50
51
|
|
|
@@ -53,20 +54,21 @@ class SqlAlchemyBookRepository(BookRepositoryInterface):
|
|
|
53
54
|
self._executor = executor
|
|
54
55
|
|
|
55
56
|
@staticmethod
|
|
56
|
-
def _to_book(row: dict[str,
|
|
57
|
+
def _to_book(row: dict[str, Any]) -> Book:
|
|
57
58
|
return Book(
|
|
58
|
-
id=
|
|
59
|
-
title=
|
|
60
|
-
author=
|
|
61
|
-
isbn=
|
|
62
|
-
published_year=
|
|
59
|
+
id=row["id"],
|
|
60
|
+
title=row["title"],
|
|
61
|
+
author=row["author"],
|
|
62
|
+
isbn=row["isbn"],
|
|
63
|
+
published_year=row["published_year"],
|
|
63
64
|
)
|
|
64
65
|
```
|
|
65
66
|
|
|
66
|
-
>
|
|
67
|
-
> `
|
|
68
|
-
>
|
|
69
|
-
> `
|
|
67
|
+
> Use `dict[str, Any]` — not `dict[str, object]`.
|
|
68
|
+
> `fetch_one()` / `fetch_all()` return `dict[str, Any]`, so `row["id"]` is `Any`
|
|
69
|
+
> which is assignable to `int` under `mypy --strict` without any casts.
|
|
70
|
+
> Using `dict[str, object]` instead requires `# type: ignore[call-overload]`
|
|
71
|
+
> and triggers follow-up `unused-ignore` errors.
|
|
70
72
|
|
|
71
73
|
### Full implementation
|
|
72
74
|
|
|
@@ -85,7 +87,7 @@ class SqlAlchemyBookRepository(BookRepositoryInterface):
|
|
|
85
87
|
|
|
86
88
|
def count_all(self) -> int:
|
|
87
89
|
row = self._executor.fetch_one("SELECT COUNT(*) AS cnt FROM books")
|
|
88
|
-
return int(row["cnt"]) if row else 0
|
|
90
|
+
return int(row["cnt"]) if row else 0
|
|
89
91
|
|
|
90
92
|
def find_by_id(self, book_id: int) -> Book | None:
|
|
91
93
|
row = self._executor.fetch_one(
|
|
@@ -117,13 +119,13 @@ class SqlAlchemyBookRepository(BookRepositoryInterface):
|
|
|
117
119
|
self._executor.write("DELETE FROM books WHERE id = :id", {"id": book_id})
|
|
118
120
|
|
|
119
121
|
@staticmethod
|
|
120
|
-
def _to_book(row: dict[str,
|
|
122
|
+
def _to_book(row: dict[str, Any]) -> Book:
|
|
121
123
|
return Book(
|
|
122
|
-
id=
|
|
123
|
-
title=
|
|
124
|
-
author=
|
|
125
|
-
isbn=
|
|
126
|
-
published_year=
|
|
124
|
+
id=row["id"],
|
|
125
|
+
title=row["title"],
|
|
126
|
+
author=row["author"],
|
|
127
|
+
isbn=row["isbn"],
|
|
128
|
+
published_year=row["published_year"],
|
|
127
129
|
)
|
|
128
130
|
```
|
|
129
131
|
|
|
@@ -156,6 +158,20 @@ def _build_repository(cfg: AppSettings) -> BookRepositoryInterface:
|
|
|
156
158
|
return InMemoryBookRepository() # fallback for tests / local dev
|
|
157
159
|
```
|
|
158
160
|
|
|
161
|
+
> Wrap the if/else branch in a helper function like `_build_repository()` that
|
|
162
|
+
> returns the interface type. This is cleaner than declaring `repo: BookRepositoryInterface`
|
|
163
|
+
> before an if/else block in `create_app()` — both approaches satisfy `mypy --strict`,
|
|
164
|
+
> but the helper keeps `create_app()` readable.
|
|
165
|
+
>
|
|
166
|
+
> If you prefer inline branching, declare the type first:
|
|
167
|
+
> ```python
|
|
168
|
+
> repo: BookRepositoryInterface
|
|
169
|
+
> if cfg.db_adapter == "sqlite":
|
|
170
|
+
> repo = SqlAlchemyBookRepository(executor)
|
|
171
|
+
> else:
|
|
172
|
+
> repo = InMemoryBookRepository()
|
|
173
|
+
> ```
|
|
174
|
+
|
|
159
175
|
> `StaticPool` is required for SQLite in-memory databases (`DB_NAME=:memory:`) to prevent
|
|
160
176
|
> SQLAlchemy from opening multiple connections — each of which would see an empty database.
|
|
161
177
|
> File-based SQLite and other adapters do not need it.
|
|
@@ -192,7 +208,147 @@ if affected == 0:
|
|
|
192
208
|
|
|
193
209
|
---
|
|
194
210
|
|
|
195
|
-
## 4.
|
|
211
|
+
## 4. Entities with `datetime` fields
|
|
212
|
+
|
|
213
|
+
When your entity has a `created_at: datetime` field backed by a database-generated
|
|
214
|
+
`DEFAULT CURRENT_TIMESTAMP`, use `parse_db_datetime()` from `nene2.database`.
|
|
215
|
+
|
|
216
|
+
### Why it is needed
|
|
217
|
+
|
|
218
|
+
SQLite stores `CURRENT_TIMESTAMP` as a **plain string** (`"2026-05-20 12:34:56"`),
|
|
219
|
+
not as a Python `datetime` object. `datetime.fromisoformat()` parses the string but
|
|
220
|
+
returns a **naive** datetime (no timezone), so the JSON response leaks an ambiguous
|
|
221
|
+
timestamp. `parse_db_datetime()` handles all three cases transparently:
|
|
222
|
+
|
|
223
|
+
| Driver | Raw value | After `parse_db_datetime()` |
|
|
224
|
+
|---|---|---|
|
|
225
|
+
| SQLite | `"2026-05-20 12:34:56"` (str) | `datetime(…, tzinfo=UTC)` |
|
|
226
|
+
| MySQL/PostgreSQL | naive `datetime` object | `datetime(…, tzinfo=UTC)` |
|
|
227
|
+
| MySQL/PostgreSQL | aware `datetime` object | unchanged |
|
|
228
|
+
|
|
229
|
+
### Schema
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP"
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### SELECT-after-INSERT pattern
|
|
236
|
+
|
|
237
|
+
After `write()` you only get back the `lastrowid`, not the DB-generated `created_at`.
|
|
238
|
+
Do a second `fetch_one()` to retrieve the full row:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
from datetime import datetime
|
|
242
|
+
from typing import Any
|
|
243
|
+
|
|
244
|
+
from nene2.database import DatabaseQueryExecutorInterface, parse_db_datetime
|
|
245
|
+
|
|
246
|
+
from .entity import Post
|
|
247
|
+
|
|
248
|
+
def _to_post(row: dict[str, Any]) -> Post:
|
|
249
|
+
return Post(
|
|
250
|
+
id=row["id"],
|
|
251
|
+
title=row["title"],
|
|
252
|
+
body=row["body"],
|
|
253
|
+
created_at=parse_db_datetime(row["created_at"]),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
class SqlAlchemyPostRepository(PostRepositoryInterface):
|
|
257
|
+
def save(self, title: str, body: str) -> Post:
|
|
258
|
+
new_id = self._executor.write(
|
|
259
|
+
"INSERT INTO posts (title, body) VALUES (:title, :body)",
|
|
260
|
+
{"title": title, "body": body},
|
|
261
|
+
)
|
|
262
|
+
row = self._executor.fetch_one(
|
|
263
|
+
"SELECT id, title, body, created_at FROM posts WHERE id = :id",
|
|
264
|
+
{"id": new_id},
|
|
265
|
+
)
|
|
266
|
+
if row is None:
|
|
267
|
+
raise RuntimeError(f"Row {new_id} not found after INSERT into posts")
|
|
268
|
+
return _to_post(row)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
> The `if row is None: raise RuntimeError(...)` guard is needed because `fetch_one()`
|
|
272
|
+
> returns `dict | None`. The row cannot actually be `None` right after INSERT — the guard
|
|
273
|
+
> exists to satisfy the type checker. Prefer `RuntimeError` over `assert`: `assert`
|
|
274
|
+
> is stripped by `python -O` and flagged by ruff's S101 rule in non-test code.
|
|
275
|
+
|
|
276
|
+
### InMemory repository with datetime
|
|
277
|
+
|
|
278
|
+
The `InMemoryXxxRepository` should generate the timestamp in Python:
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
from datetime import datetime, timezone
|
|
282
|
+
|
|
283
|
+
def save(self, title: str, body: str) -> Post:
|
|
284
|
+
now = datetime.now(timezone.utc)
|
|
285
|
+
post = Post(id=self._next_id, title=title, body=body, created_at=now)
|
|
286
|
+
self._store[self._next_id] = post
|
|
287
|
+
self._next_id += 1
|
|
288
|
+
return post
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### JSON serialisation
|
|
292
|
+
|
|
293
|
+
`datetime.isoformat()` on a UTC-aware datetime produces `"2026-05-20T12:34:56+00:00"`.
|
|
294
|
+
Return it as a string in the response dict:
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
def _post_dict(post: Post) -> dict[str, object]:
|
|
298
|
+
return {
|
|
299
|
+
"id": post.id,
|
|
300
|
+
"title": post.title,
|
|
301
|
+
"body": post.body,
|
|
302
|
+
"created_at": post.created_at.isoformat(), # "2026-05-20T12:34:56+00:00"
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## 5. Nested resources — ownership validation in DELETE
|
|
309
|
+
|
|
310
|
+
When a resource is nested under a parent (e.g. `DELETE /posts/{post_id}/comments/{comment_id}`),
|
|
311
|
+
always validate that the child belongs to the parent in the UseCase, not just in the database.
|
|
312
|
+
|
|
313
|
+
### Wrong — ignores `post_id`
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
# handler
|
|
317
|
+
@router.delete("/posts/{post_id}/comments/{comment_id}", status_code=204)
|
|
318
|
+
async def delete_comment(post_id: int, comment_id: int) -> None:
|
|
319
|
+
delete_use_case.execute(DeleteCommentInput(comment_id)) # post_id unused!
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
This allows `DELETE /posts/1/comments/5` to delete comment 5 even when it belongs to post 2.
|
|
323
|
+
|
|
324
|
+
### Correct — validate ownership in the UseCase
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
# use_case.py
|
|
328
|
+
@dataclass(frozen=True, slots=True)
|
|
329
|
+
class DeleteCommentInput:
|
|
330
|
+
post_id: int
|
|
331
|
+
comment_id: int
|
|
332
|
+
|
|
333
|
+
class DeleteCommentUseCase:
|
|
334
|
+
def execute(self, input_: DeleteCommentInput) -> None:
|
|
335
|
+
comment = self._repository.find_by_id(input_.comment_id)
|
|
336
|
+
if comment is None or comment.post_id != input_.post_id:
|
|
337
|
+
raise CommentNotFoundException(input_.comment_id)
|
|
338
|
+
self._repository.delete(input_.comment_id)
|
|
339
|
+
|
|
340
|
+
# handler
|
|
341
|
+
@router.delete("/posts/{post_id}/comments/{comment_id}", status_code=204)
|
|
342
|
+
async def delete_comment(post_id: int, comment_id: int) -> None:
|
|
343
|
+
delete_use_case.execute(DeleteCommentInput(post_id, comment_id))
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
> The same pattern applies to GET and PUT on nested resources:
|
|
347
|
+
> always pass `post_id` into the UseCase and verify `comment.post_id == input_.post_id`.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## 6. Use `InMemoryXxxRepository` in tests
|
|
196
352
|
|
|
197
353
|
Never mock the database. Use the in-memory implementation for unit tests:
|
|
198
354
|
|
|
@@ -232,7 +388,7 @@ def _make_repo() -> SqlAlchemyBookRepository:
|
|
|
232
388
|
|
|
233
389
|
---
|
|
234
390
|
|
|
235
|
-
##
|
|
391
|
+
## 7. Atomic multi-write operations with `transactional()`
|
|
236
392
|
|
|
237
393
|
When a UseCase needs to write to multiple tables atomically, use `SqlAlchemyTransactionManager.transactional()` together with `_in_tx` repository methods.
|
|
238
394
|
|
|
@@ -4,6 +4,7 @@ from .exceptions import DatabaseConnectionException
|
|
|
4
4
|
from .health import DatabaseHealthCheck
|
|
5
5
|
from .interfaces import DatabaseQueryExecutorInterface, DatabaseTransactionManagerInterface
|
|
6
6
|
from .sqlalchemy_executor import SqlAlchemyQueryExecutor, SqlAlchemyTransactionManager
|
|
7
|
+
from .utils import parse_db_datetime
|
|
7
8
|
|
|
8
9
|
__all__ = [
|
|
9
10
|
"DatabaseConnectionException",
|
|
@@ -12,4 +13,5 @@ __all__ = [
|
|
|
12
13
|
"DatabaseTransactionManagerInterface",
|
|
13
14
|
"SqlAlchemyQueryExecutor",
|
|
14
15
|
"SqlAlchemyTransactionManager",
|
|
16
|
+
"parse_db_datetime",
|
|
15
17
|
]
|