Coverage for src/inheritance_calculator_core/database/neo4j_client.py: 0%

1"""Neo4jクライアント

2

3 Neo4jデータベースへの接続と操作を管理するクライアント。

4"""

5 from typing import Any, Dict, List, Optional, Generator

6 from contextlib import contextmanager

7 import logging

8

9 from neo4j import GraphDatabase, Driver, Session, Transaction

10 from neo4j.exceptions import ServiceUnavailable, AuthError, Neo4jError

11

12 from .base import DatabaseClient

13 from ..utils.exceptions import DatabaseException

14 from ..utils.config import Neo4jSettings

15

16

17 class Neo4jClient(DatabaseClient):

18 """

19 Neo4jデータベースクライアント

20

21 接続管理、トランザクション管理、クエリ実行を提供する。

22 """

23

24 def __init__(self, settings: Optional[Neo4jSettings] = None) -> None:

25 """

26 初期化

27

28 Args:

29 settings: Neo4j接続設定（Noneの場合は環境変数から取得）

30 """

31 self.logger = logging.getLogger(__name__)

32

33 if settings is None:

34 try:

35 settings = Neo4jSettings() # type: ignore[call-arg]

36 except Exception:

37 # テスト環境等でパスワードが設定されていない場合のフォールバック

38 raise DatabaseException("Neo4j設定の初期化に失敗しました。NEO4J_PASSWORD環境変数が設定されているか確認してください。")

39

40 self.uri = settings.uri

41 self.user = settings.user

42 self.password = settings.password

43 self.database = settings.database

44

45 self._driver: Optional[Driver] = None

46 self._session: Optional[Session] = None

47 self._transaction: Optional[Transaction] = None

48

49 self.logger.info(f"Neo4jClient initialized for {self.uri}")

50

51 def connect(self) -> None:

52 """

53 データベースに接続

54

55 Raises:

56 DatabaseException: 接続に失敗した場合

57 """

58 try:

59 self._driver = GraphDatabase.driver(

60 self.uri,

61 auth=(self.user, self.password),

62 encrypted=False # ローカル環境のため暗号化なし

63 )

64

65 # 接続テスト

66 self._driver.verify_connectivity()

67

68 self.logger.info("Successfully connected to Neo4j")

69

70 except AuthError as e:

71 self.logger.error(f"Authentication failed: {e}")

72 raise DatabaseException(f"Neo4j認証に失敗しました: {str(e)}")

73 except ServiceUnavailable as e:

74 self.logger.error(f"Neo4j service unavailable: {e}")

75 raise DatabaseException(f"Neo4jサービスに接続できません: {str(e)}")

76 except Exception as e:

77 self.logger.error(f"Failed to connect to Neo4j: {e}", exc_info=True)

78 raise DatabaseException(f"Neo4j接続エラー: {str(e)}")

79

80 def disconnect(self) -> None:

81 """

82 データベースから切断

83 """

84 try:

85 # トランザクションをロールバック

86 if self._transaction is not None:

87 self._transaction.rollback()

88 self._transaction = None

89

90 # セッションを閉じる

91 if self._session is not None:

92 self._session.close()

93 self._session = None

94

95 # ドライバーを閉じる

96 if self._driver is not None:

97 self._driver.close()

98 self._driver = None

99

100 self.logger.info("Disconnected from Neo4j")

101

102 except Exception as e:

103 self.logger.error(f"Error during disconnect: {e}", exc_info=True)

104

105 def is_connected(self) -> bool:

106 """

107 接続状態を確認

108

109 Returns:

110 接続中の場合True

111 """

112 if self._driver is None:

113 return False

114

115 try:

116 self._driver.verify_connectivity()

117 return True

118 except Exception:

119 return False

120

121 def health_check(self) -> bool:

122 """

123 ヘルスチェック

124

125 Returns:

126 データベースが正常に動作している場合True

127 """

128 if self._driver is None:

129 return False

130

131 try:

132 with self._driver.session(database=self.database) as session:

133 result = session.run("RETURN 1 as health")

134 record = result.single()

135 if record is None:

136 return False

137 return bool(record["health"] == 1)

138 except Exception as e:

139 self.logger.error(f"Health check failed: {e}")

140 return False

141

142 def begin_transaction(self) -> Transaction:

143 """

144 トランザクションを開始

145

146 Returns:

147 トランザクションオブジェクト

148

149 Raises:

150 DatabaseException: トランザクション開始に失敗した場合

151 """

152 if self._driver is None:

153 raise DatabaseException("データベースに接続されていません")

154

155 try:

156 if self._session is None:

157 self._session = self._driver.session(database=self.database)

158

159 self._transaction = self._session.begin_transaction()

160 self.logger.debug("Transaction started")

161 return self._transaction

162

163 except Exception as e:

164 self.logger.error(f"Failed to begin transaction: {e}", exc_info=True)

165 raise DatabaseException(f"トランザクション開始エラー: {str(e)}")

166

167 def commit_transaction(self, transaction: Any) -> None:

168 """

169 トランザクションをコミット

170

171 Args:

172 transaction: トランザクションオブジェクト

173

174 Raises:

175 DatabaseException: コミットに失敗した場合

176 """

177 if transaction is None:

178 raise DatabaseException("トランザクションがNullです")

179

180 try:

181 transaction.commit()

182 if self._transaction == transaction:

183 self._transaction = None

184 self.logger.debug("Transaction committed")

185

186 except Exception as e:

187 self.logger.error(f"Failed to commit transaction: {e}", exc_info=True)

188 raise DatabaseException(f"トランザクションコミットエラー: {str(e)}")

189

190 def rollback_transaction(self, transaction: Any) -> None:

191 """

192 トランザクションをロールバック

193

194 Args:

195 transaction: トランザクションオブジェクト

196

197 Raises:

198 DatabaseException: ロールバックに失敗した場合

199 """

200 if transaction is None:

201 raise DatabaseException("トランザクションがNullです")

202

203 try:

204 transaction.rollback()

205 if self._transaction == transaction:

206 self._transaction = None

207 self.logger.debug("Transaction rolled back")

208

209 except Exception as e:

210 self.logger.error(f"Failed to rollback transaction: {e}", exc_info=True)

211 raise DatabaseException(f"トランザクションロールバックエラー: {str(e)}")

212

213 def commit(self) -> None:

214 """

215 現在のトランザクションをコミット（内部メソッド）

216

217 Raises:

218 DatabaseException: コミットに失敗した場合

219 """

220 if self._transaction is None:

221 raise DatabaseException("アクティブなトランザクションがありません")

222 self.commit_transaction(self._transaction)

223

224 def rollback(self) -> None:

225 """

226 現在のトランザクションをロールバック（内部メソッド）

227

228 Raises:

229 DatabaseException: ロールバックに失敗した場合

230 """

231 if self._transaction is None:

232 raise DatabaseException("アクティブなトランザクションがありません")

233 self.rollback_transaction(self._transaction)

234

235 def execute_query(

236 self,

237 query: str,

238 parameters: Optional[Dict[str, Any]] = None

239 ) -> List[Dict[str, Any]]:

240 """

241 Cypherクエリを実行（基底クラスメソッド）

242

243 Args:

244 query: Cypherクエリ文字列

245 parameters: クエリパラメータ

246

247 Returns:

248 クエリ結果のリスト

249

250 Raises:

251 DatabaseException: クエリ実行に失敗した場合

252 """

253 if self._driver is None:

254 raise DatabaseException("データベースに接続されていません")

255

256 try:

257 # トランザクション内の場合

258 if self._transaction is not None:

259 result = self._transaction.run(query, parameters or {})

260 return [dict(record) for record in result]

261

262 # トランザクション外の場合

263 with self._driver.session(database=self.database) as session:

264 result = session.run(query, parameters or {})

265 return [dict(record) for record in result]

266

267 except Neo4jError as e:

268 self.logger.error(f"Neo4j query error: {e}", exc_info=True)

269 raise DatabaseException(f"クエリ実行エラー: {str(e)}")

270 except Exception as e:

271 self.logger.error(f"Unexpected error during query execution: {e}", exc_info=True)

272 raise DatabaseException(f"予期しないエラー: {str(e)}")

273

274 def execute(

275 self,

276 query: str,

277 parameters: Optional[Dict[str, Any]] = None

278 ) -> List[Dict[str, Any]]:

279 """

280 Cypherクエリを実行（互換性メソッド）

281

282 Args:

283 query: Cypherクエリ文字列

284 parameters: クエリパラメータ

285

286 Returns:

287 クエリ結果のリスト

288

289 Raises:

290 DatabaseException: クエリ実行に失敗した場合

291 """

292 return self.execute_query(query, parameters)

293

294 @contextmanager

295 def transaction(self) -> Generator["Neo4jClient", None, None]:

296 """

297 トランザクションコンテキストマネージャー

298

299 Usage:

300 with client.transaction():

301 client.execute("CREATE (n:Person {name: $name})", {"name": "太郎"})

302

303 Yields:

304 Neo4jClient: 自身のインスタンス

305 """

306 self.begin_transaction()

307 try:

308 yield self

309 self.commit()

310 except Exception:

311 self.rollback()

312 raise

313

314 def clear_database(self) -> None:

315 """

316 データベースの全データを削除（テスト用）

317

318 Warning:

319 本番環境では使用しないこと

320 """

321 self.logger.warning("Clearing all data from database")

322 self.execute("MATCH (n) DETACH DELETE n")

323

324 def create_constraints(self) -> None:

325 """

326 制約とインデックスを作成

327 """

328 constraints = [

329 # Personノードのname属性にユニーク制約

330 "CREATE CONSTRAINT person_name_unique IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE",

331

332 # Personノードのnameにインデックス

333 "CREATE INDEX person_name_index IF NOT EXISTS FOR (p:Person) ON (p.name)",

334

335 # is_decedentフラグにインデックス

336 "CREATE INDEX person_decedent_index IF NOT EXISTS FOR (p:Person) ON (p.is_decedent)",

337

338 # is_aliveフラグにインデックス

339 "CREATE INDEX person_alive_index IF NOT EXISTS FOR (p:Person) ON (p.is_alive)",

340 ]

341

342 for constraint in constraints:

343 try:

344 self.execute(constraint)

345 self.logger.debug(f"Constraint/Index created: {constraint[:50]}...")

346 except Exception as e:

347 # 制約が既に存在する場合は無視

348 self.logger.debug(f"Constraint/Index already exists or failed: {e}")

349

350 def __enter__(self) -> "Neo4jClient":

351 """コンテキストマネージャーのエントリー"""

352 self.connect()

353 return self

354

355 def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:

356 """コンテキストマネージャーのイグジット"""

357 self.disconnect()