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.
Files changed (186) hide show
  1. {nene2_python-1.0.0 → nene2_python-1.1.0}/PKG-INFO +1 -1
  2. nene2_python-1.1.0/docs/field-trials/2026-05-field-trial-7.md +170 -0
  3. nene2_python-1.1.0/docs/field-trials/2026-05-field-trial-8.md +218 -0
  4. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/how-to/sqlalchemy-repository.md +176 -20
  5. {nene2_python-1.0.0 → nene2_python-1.1.0}/pyproject.toml +1 -1
  6. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/database/__init__.py +2 -0
  7. nene2_python-1.1.0/src/nene2/database/utils.py +28 -0
  8. nene2_python-1.1.0/tests/nene2/database/test_utils.py +55 -0
  9. {nene2_python-1.0.0 → nene2_python-1.1.0}/.env.example +0 -0
  10. {nene2_python-1.0.0 → nene2_python-1.1.0}/.github/workflows/ci.yml +0 -0
  11. {nene2_python-1.0.0 → nene2_python-1.1.0}/.github/workflows/docs.yml +0 -0
  12. {nene2_python-1.0.0 → nene2_python-1.1.0}/.github/workflows/publish.yml +0 -0
  13. {nene2_python-1.0.0 → nene2_python-1.1.0}/.gitignore +0 -0
  14. {nene2_python-1.0.0 → nene2_python-1.1.0}/.vitepress/config.mts +0 -0
  15. {nene2_python-1.0.0 → nene2_python-1.1.0}/.vitepress/theme/custom.css +0 -0
  16. {nene2_python-1.0.0 → nene2_python-1.1.0}/.vitepress/theme/index.ts +0 -0
  17. {nene2_python-1.0.0 → nene2_python-1.1.0}/AGENTS.md +0 -0
  18. {nene2_python-1.0.0 → nene2_python-1.1.0}/CHANGELOG.md +0 -0
  19. {nene2_python-1.0.0 → nene2_python-1.1.0}/CLAUDE.md +0 -0
  20. {nene2_python-1.0.0 → nene2_python-1.1.0}/Dockerfile +0 -0
  21. {nene2_python-1.0.0 → nene2_python-1.1.0}/LICENSE +0 -0
  22. {nene2_python-1.0.0 → nene2_python-1.1.0}/README.md +0 -0
  23. {nene2_python-1.0.0 → nene2_python-1.1.0}/alembic/README +0 -0
  24. {nene2_python-1.0.0 → nene2_python-1.1.0}/alembic/env.py +0 -0
  25. {nene2_python-1.0.0 → nene2_python-1.1.0}/alembic/script.py.mako +0 -0
  26. {nene2_python-1.0.0 → nene2_python-1.1.0}/alembic/versions/001_create_notes_and_tags_tables.py +0 -0
  27. {nene2_python-1.0.0 → nene2_python-1.1.0}/alembic.ini +0 -0
  28. {nene2_python-1.0.0 → nene2_python-1.1.0}/compose.yaml +0 -0
  29. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0001-toolchain.md +0 -0
  30. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0002-clean-architecture.md +0 -0
  31. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0003-security-first.md +0 -0
  32. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0004-ai-first-design.md +0 -0
  33. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0005-logging.md +0 -0
  34. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0006-rate-limiting.md +0 -0
  35. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0009-mcp-design.md +0 -0
  36. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/adr/0010-async-use-case.md +0 -0
  37. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/de/index.md +0 -0
  38. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/de/tutorials/getting-started.md +0 -0
  39. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/explanation/architecture.md +0 -0
  40. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/explanation/design-philosophy.md +0 -0
  41. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-1.md +0 -0
  42. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-2.md +0 -0
  43. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-3.md +0 -0
  44. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-4.md +0 -0
  45. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-5.md +0 -0
  46. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/field-trials/2026-05-field-trial-6.md +0 -0
  47. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/fr/index.md +0 -0
  48. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/fr/tutorials/getting-started.md +0 -0
  49. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/how-to/add-new-domain.md +0 -0
  50. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/how-to/configure-auth.md +0 -0
  51. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/how-to/new-project.md +0 -0
  52. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/how-to/run-tests.md +0 -0
  53. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/howto/mcp-setup.md +0 -0
  54. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/index.md +0 -0
  55. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/explanation/architecture.md +0 -0
  56. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/explanation/design-philosophy.md +0 -0
  57. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/how-to/add-new-domain.md +0 -0
  58. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/how-to/configure-auth.md +0 -0
  59. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/how-to/new-project.md +0 -0
  60. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/how-to/run-tests.md +0 -0
  61. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/how-to/sqlalchemy-repository.md +0 -0
  62. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/howto/mcp-setup.md +0 -0
  63. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/index.md +0 -0
  64. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/reference/api.md +0 -0
  65. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/reference/configuration.md +0 -0
  66. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/reference/framework-modules.md +0 -0
  67. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/tutorials/first-domain.md +0 -0
  68. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/ja/tutorials/getting-started.md +0 -0
  69. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/pt-br/index.md +0 -0
  70. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/pt-br/tutorials/getting-started.md +0 -0
  71. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/reference/api.md +0 -0
  72. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/reference/configuration.md +0 -0
  73. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/reference/framework-modules.md +0 -0
  74. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/roadmap.md +0 -0
  75. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/todo/current.md +0 -0
  76. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/tutorials/first-domain.md +0 -0
  77. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/tutorials/getting-started.md +0 -0
  78. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/zh/index.md +0 -0
  79. {nene2_python-1.0.0 → nene2_python-1.1.0}/docs/zh/tutorials/getting-started.md +0 -0
  80. {nene2_python-1.0.0 → nene2_python-1.1.0}/package-lock.json +0 -0
  81. {nene2_python-1.0.0 → nene2_python-1.1.0}/package.json +0 -0
  82. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/__init__.py +0 -0
  83. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/__main__.py +0 -0
  84. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/app.py +0 -0
  85. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/__init__.py +0 -0
  86. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/entity.py +0 -0
  87. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/exceptions.py +0 -0
  88. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/handler.py +0 -0
  89. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/repository.py +0 -0
  90. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/sqlalchemy_repository.py +0 -0
  91. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/comment/use_case.py +0 -0
  92. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/mcp.py +0 -0
  93. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/__init__.py +0 -0
  94. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/async_use_case.py +0 -0
  95. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/entity.py +0 -0
  96. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/exceptions.py +0 -0
  97. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/handler.py +0 -0
  98. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/repository.py +0 -0
  99. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/sqlalchemy_repository.py +0 -0
  100. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/note/use_case.py +0 -0
  101. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/schema.py +0 -0
  102. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/__init__.py +0 -0
  103. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/entity.py +0 -0
  104. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/exceptions.py +0 -0
  105. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/handler.py +0 -0
  106. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/repository.py +0 -0
  107. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/sqlalchemy_repository.py +0 -0
  108. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/example/tag/use_case.py +0 -0
  109. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/__init__.py +0 -0
  110. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/__init__.py +0 -0
  111. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/api_key.py +0 -0
  112. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/bearer_token.py +0 -0
  113. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/exceptions.py +0 -0
  114. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/interfaces.py +0 -0
  115. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/auth/local_verifier.py +0 -0
  116. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/config/__init__.py +0 -0
  117. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/config/settings.py +0 -0
  118. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/database/exceptions.py +0 -0
  119. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/database/health.py +0 -0
  120. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/database/interfaces.py +0 -0
  121. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/database/sqlalchemy_executor.py +0 -0
  122. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/http/__init__.py +0 -0
  123. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/http/health.py +0 -0
  124. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/http/pagination.py +0 -0
  125. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/http/problem_details.py +0 -0
  126. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/log/__init__.py +0 -0
  127. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/log/setup.py +0 -0
  128. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/mcp/__init__.py +0 -0
  129. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/mcp/http_client.py +0 -0
  130. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/mcp/server.py +0 -0
  131. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/__init__.py +0 -0
  132. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/domain_exception.py +0 -0
  133. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/error_handler.py +0 -0
  134. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/request_id.py +0 -0
  135. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/request_logging.py +0 -0
  136. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/request_size_limit.py +0 -0
  137. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/security_headers.py +0 -0
  138. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/middleware/throttle.py +0 -0
  139. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/py.typed +0 -0
  140. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/use_case/__init__.py +0 -0
  141. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/use_case/protocols.py +0 -0
  142. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/validation/__init__.py +0 -0
  143. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/nene2/validation/exceptions.py +0 -0
  144. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/scripts/__init__.py +0 -0
  145. {nene2_python-1.0.0 → nene2_python-1.1.0}/src/scripts/export_openapi.py +0 -0
  146. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/__init__.py +0 -0
  147. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/__init__.py +0 -0
  148. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/comment/__init__.py +0 -0
  149. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/comment/test_comment_http.py +0 -0
  150. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/comment/test_comment_repository.py +0 -0
  151. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/comment/test_comment_use_case.py +0 -0
  152. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/conftest.py +0 -0
  153. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/note/__init__.py +0 -0
  154. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/note/test_async_note_use_case.py +0 -0
  155. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/note/test_list_notes.py +0 -0
  156. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/note/test_note_repository.py +0 -0
  157. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/tag/__init__.py +0 -0
  158. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/tag/test_tag_repository.py +0 -0
  159. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/tag/test_tags.py +0 -0
  160. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/test_cors.py +0 -0
  161. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/example/test_mcp.py +0 -0
  162. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/__init__.py +0 -0
  163. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/auth/__init__.py +0 -0
  164. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/auth/test_api_key.py +0 -0
  165. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/auth/test_bearer_token.py +0 -0
  166. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/auth/test_token_issuer.py +0 -0
  167. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/database/__init__.py +0 -0
  168. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/database/test_transaction.py +0 -0
  169. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/http/__init__.py +0 -0
  170. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/http/test_pagination.py +0 -0
  171. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/mcp/__init__.py +0 -0
  172. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/mcp/test_http_client.py +0 -0
  173. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/__init__.py +0 -0
  174. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_error_handler.py +0 -0
  175. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_request_id.py +0 -0
  176. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_request_logging.py +0 -0
  177. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_request_size_limit.py +0 -0
  178. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_security_headers.py +0 -0
  179. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/middleware/test_throttle.py +0 -0
  180. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/use_case/__init__.py +0 -0
  181. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/use_case/test_protocols.py +0 -0
  182. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/validation/__init__.py +0 -0
  183. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/nene2/validation/test_exceptions.py +0 -0
  184. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/scripts/__init__.py +0 -0
  185. {nene2_python-1.0.0 → nene2_python-1.1.0}/tests/scripts/test_export_openapi.py +0 -0
  186. {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.0.0
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 cast and keep each query method lean.
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, object]) -> Book:
57
+ def _to_book(row: dict[str, Any]) -> Book:
57
58
  return Book(
58
- id=int(row["id"]), # type: ignore[arg-type]
59
- title=str(row["title"]),
60
- author=str(row["author"]),
61
- isbn=str(row["isbn"]),
62
- published_year=int(row["published_year"]), # type: ignore[arg-type]
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
- > `# type: ignore[arg-type]` is acceptable here: SQLAlchemy returns column values as
67
- > `int | str | float | None | …` depending on the driver, so the cast is correct
68
- > but the static type is `object`. Centralising casts in `_to_entity()` keeps
69
- > `type: ignore` in one place and out of every query method.
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 # type: ignore[arg-type]
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, object]) -> Book:
122
+ def _to_book(row: dict[str, Any]) -> Book:
121
123
  return Book(
122
- id=int(row["id"]), # type: ignore[arg-type]
123
- title=str(row["title"]),
124
- author=str(row["author"]),
125
- isbn=str(row["isbn"]),
126
- published_year=int(row["published_year"]), # type: ignore[arg-type]
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. Use `InMemoryXxxRepository` in tests
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
- ## 5. Atomic multi-write operations with `transactional()`
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nene2-python"
3
- version = "1.0.0"
3
+ version = "1.1.0"
4
4
  description = "NENE2 Python — minimal API framework following NENE2's design philosophy"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -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
  ]