Skip to content

Commit 953c9a7

Browse files
authored
Merge pull request #49 from kc3hack/feature/issue-45-jsonb-migration
Backend: SkillTree.tree_dataをJSON型からJSONB型に変更
2 parents 48a0f75 + 2cfe2e3 commit 953c9a7

File tree

4 files changed

+179
-11
lines changed

4 files changed

+179
-11
lines changed

.github/decisions/009-database-design-strategy.md

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## ステータス
44

55
- [x] **決定**
6+
- [x] **更新** (2026-02-19): JSON型 → JSONB型に変更(issue #45
67

78
## コンテキスト (課題と背景)
89

@@ -28,12 +29,18 @@ issue #31で6テーブル(Profile, OAuthAccount, Badge, Quest, QuestProgress,
2829
### 2. 型選択基準
2930

3031
#### JSON型 vs JSONB型(PostgreSQL)
31-
- **基本方針**: JSON型を使用(JSONB型は不要)
32-
- **理由**:
33-
- **SkillTree.tree_data**: AI生成のツリー構造を全体として保存・取得するだけ
34-
- **JSON内部の検索が不要**: 全体を読むだけ、個別ノード検索はしない
35-
- **SQLiteとPostgreSQLで互換性が高い**: JSON型はどちらでも使える
36-
- **JSONB型を検討すべきケース**: JSON内部の特定フィールドを検索する必要が出てきたら移行
32+
- **基本方針**: ~~JSON型を使用(JSONB型は不要)~~ **→ JSONB型に変更(2026-02-19、issue #45**
33+
- **変更理由**:
34+
- **Langchain/フロントエンドとの統一的な操作**: JSONB型(バイナリ形式)で一貫した扱いが必要
35+
- **テスト環境の統一**: SQLite(BLOB型)/PostgreSQL(JSONB型)両対応
36+
- **将来の拡張性**: JSON内部検索の可能性を考慮
37+
- **実装方針**:
38+
- PostgreSQL: JSONB型として保存
39+
- SQLite: BLOB型として保存(JSONBのバイナリ表現)
40+
- SQLAlchemyの`JSON().with_variant(JSONB, "postgresql")`で両DB対応
41+
- **旧方針(参考)**:
42+
- ~~JSON型を使用(JSONB型は不要)~~
43+
- ~~理由: AI生成のツリー構造を全体として保存・取得するだけ、JSON内部の検索が不要~~
3744

3845
#### Enum vs 整数
3946
- **基本方針**: Enumは文字列型、整数は数値演算が必要な場合
@@ -102,11 +109,14 @@ issue #31で6テーブル(Profile, OAuthAccount, Badge, Quest, QuestProgress,
102109
### 2. JSONB型を優先(PostgreSQL)
103110

104111
- **Good**: JSON内部の検索が高速、GINインデックスで最適化可能
105-
- **Bad**:
106-
- **MVP段階ではJSON内部検索が不要**(全体を読むだけ)
107-
- SQLiteとの互換性が低い(SQLiteはJSONB型をサポートしない)
112+
- **Bad(旧判断、2026-02-19に方針転換)**:
113+
- ~~MVP段階ではJSON内部検索が不要(全体を読むだけ)~~
114+
- ~~SQLiteとの互換性が低い(SQLiteはJSONB型をサポートしない)~~
108115
- 書き込みパフォーマンスが若干劣る
109-
- **却下理由**: 現時点では過剰な最適化、互換性優先
116+
- ~~**却下理由**: 現時点では過剰な最適化、互換性優先~~
117+
- **方針転換(2026-02-19、issue #45**:
118+
- Langchain/フロントエンドとの統一的な操作のため、JSONB型(BLOB型)を採用
119+
- SQLiteではBLOB型として保存することで互換性を確保
110120

111121
### 3. NoSQL(MongoDB等)
112122

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""change_tree_data_to_jsonb (issue #45)
2+
3+
Revision ID: 0002_change_tree_data_to_jsonb
4+
Revises: 0001_all_tables
5+
Create Date: 2026-02-19 14:07:37.523910
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
from sqlalchemy.dialects.postgresql import JSONB
14+
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "0002_change_tree_data_to_jsonb"
18+
down_revision: Union[str, Sequence[str], None] = "0001_all_tables"
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
"""SkillTree.tree_dataをJSON型からJSONB型(BLOB)に変更"""
25+
bind = op.get_bind()
26+
27+
if bind.dialect.name == "postgresql":
28+
# PostgreSQL: JSON型 → JSONB型に変更
29+
op.alter_column(
30+
"skill_trees",
31+
"tree_data",
32+
type_=JSONB,
33+
existing_type=sa.JSON(),
34+
existing_nullable=False,
35+
postgresql_using="tree_data::jsonb",
36+
)
37+
elif bind.dialect.name == "sqlite":
38+
# SQLite: JSON型(TEXT) → BLOB型に変更
39+
# SQLiteではALTER COLUMNによる型変更が非対応のため、テーブル再作成が必要
40+
op.execute("""
41+
CREATE TABLE skill_trees_new (
42+
id INTEGER NOT NULL,
43+
user_id INTEGER NOT NULL,
44+
category VARCHAR NOT NULL,
45+
tree_data BLOB NOT NULL,
46+
generated_at DATETIME,
47+
PRIMARY KEY (id),
48+
FOREIGN KEY(user_id) REFERENCES users (id),
49+
CONSTRAINT uq_skill_tree_user_category UNIQUE (user_id, category)
50+
)
51+
""")
52+
53+
# データ移行(JSON文字列をBLOBに変換)
54+
op.execute("""
55+
INSERT INTO skill_trees_new (id, user_id, category, tree_data, generated_at)
56+
SELECT id, user_id, category, tree_data, generated_at
57+
FROM skill_trees
58+
""")
59+
60+
# インデックスを削除
61+
op.execute("DROP INDEX IF EXISTS ix_skill_trees_user_id")
62+
63+
# 旧テーブルを削除
64+
op.execute("DROP TABLE skill_trees")
65+
66+
# 新テーブルをリネーム
67+
op.execute("ALTER TABLE skill_trees_new RENAME TO skill_trees")
68+
69+
# インデックスを再作成
70+
op.create_index(
71+
"ix_skill_trees_user_id", "skill_trees", ["user_id"], unique=False
72+
)
73+
74+
75+
def downgrade() -> None:
76+
"""JSONB型(BLOB)からJSON型に戻す"""
77+
bind = op.get_bind()
78+
79+
if bind.dialect.name == "postgresql":
80+
# PostgreSQL: JSONB型 → JSON型に戻す
81+
op.alter_column(
82+
"skill_trees",
83+
"tree_data",
84+
type_=sa.JSON(),
85+
existing_type=JSONB,
86+
existing_nullable=False,
87+
postgresql_using="tree_data::json",
88+
)
89+
elif bind.dialect.name == "sqlite":
90+
# SQLite: BLOB型 → JSON型(TEXT)に戻す
91+
op.execute("""
92+
CREATE TABLE skill_trees_old (
93+
id INTEGER NOT NULL,
94+
user_id INTEGER NOT NULL,
95+
category VARCHAR NOT NULL,
96+
tree_data JSON NOT NULL,
97+
generated_at DATETIME,
98+
PRIMARY KEY (id),
99+
FOREIGN KEY(user_id) REFERENCES users (id),
100+
CONSTRAINT uq_skill_tree_user_category UNIQUE (user_id, category)
101+
)
102+
""")
103+
104+
op.execute("""
105+
INSERT INTO skill_trees_old (id, user_id, category, tree_data, generated_at)
106+
SELECT id, user_id, category, tree_data, generated_at
107+
FROM skill_trees
108+
""")
109+
110+
op.execute("DROP INDEX IF EXISTS ix_skill_trees_user_id")
111+
op.execute("DROP TABLE skill_trees")
112+
op.execute("ALTER TABLE skill_trees_old RENAME TO skill_trees")
113+
op.create_index(
114+
"ix_skill_trees_user_id", "skill_trees", ["user_id"], unique=False
115+
)

backend/app/models/skill_tree.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
String,
1010
UniqueConstraint,
1111
)
12+
from sqlalchemy.dialects.postgresql import JSONB
1213
from sqlalchemy.orm import relationship
1314

1415
from app.db.base_class import Base
@@ -24,7 +25,8 @@ class SkillTree(Base):
2425
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
2526

2627
category = Column(String, nullable=False)
27-
tree_data = Column(JSON, nullable=False)
28+
# SQLiteではBLOB型、PostgreSQLではJSONB型として保存(SQLAlchemyが自動変換)
29+
tree_data = Column(JSON().with_variant(JSONB, "postgresql"), nullable=False)
2830
generated_at = Column(DateTime(timezone=True), nullable=True)
2931

3032
user = relationship("User", back_populates="skill_trees")

backend/tests/test_crud/test_skill_tree.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,44 @@ def test_update_skill_tree(db):
4949

5050
assert updated.tree_data == tree_data
5151
assert updated.generated_at is not None
52+
53+
54+
def test_skill_tree_jsonb_data_persistence(db):
55+
"""JSONB型(PostgreSQL)/BLOB型(SQLite)でのデータ保存・取得テスト (issue #45)"""
56+
user = create_user(db, UserCreate(username="jsonb_test_user"))
57+
58+
# 複雑なJSONデータを挿入
59+
complex_tree_data = {
60+
"nodes": [
61+
{
62+
"id": "react",
63+
"name": "React",
64+
"level": 5,
65+
"skills": ["hooks", "context", "redux"],
66+
"metadata": {"experience_years": 3, "certified": True},
67+
},
68+
{
69+
"id": "typescript",
70+
"name": "TypeScript",
71+
"level": 4,
72+
"skills": ["generics", "decorators"],
73+
"metadata": {"experience_years": 2, "certified": False},
74+
},
75+
],
76+
"edges": [{"from": "typescript", "to": "react", "weight": 0.8}],
77+
"statistics": {"total_nodes": 2, "max_level": 5, "avg_level": 4.5},
78+
}
79+
80+
# データを挿入
81+
updated = update_skill_tree(db, user.id, SkillCategory.WEB, complex_tree_data)
82+
assert updated.tree_data == complex_tree_data
83+
84+
# データベースから再取得して確認(JSONB/BLOBの読み書きが正しく動作するか)
85+
retrieved = get_skill_tree_by_user_category(db, user.id, SkillCategory.WEB)
86+
assert retrieved is not None
87+
assert retrieved.tree_data == complex_tree_data
88+
89+
# ネストされたデータの検証
90+
assert retrieved.tree_data["nodes"][0]["metadata"]["experience_years"] == 3
91+
assert retrieved.tree_data["nodes"][1]["skills"] == ["generics", "decorators"]
92+
assert retrieved.tree_data["statistics"]["avg_level"] == 4.5

0 commit comments

Comments
 (0)