|
@@ -0,0 +1,249 @@
|
|
|
|
|
+"""
|
|
|
|
|
+SQLite storage layer for astro-mcp.
|
|
|
|
|
+
|
|
|
|
|
+Persons table for storing birth data.
|
|
|
|
|
+All DB access uses asyncio.to_thread() for non-blocking operation.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import asyncio
|
|
|
|
|
+import logging
|
|
|
|
|
+import sqlite3
|
|
|
|
|
+import uuid
|
|
|
|
|
+from datetime import datetime, timezone
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+from typing import Any
|
|
|
|
|
+
|
|
|
|
|
+from .config import DB_PATH
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger("astro-mcp.storage")
|
|
|
|
|
+
|
|
|
|
|
+_init_lock = asyncio.Lock()
|
|
|
|
|
+_initialized = False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _get_conn() -> sqlite3.Connection:
|
|
|
|
|
+ """Get a thread-local SQLite connection."""
|
|
|
|
|
+ conn = sqlite3.connect(str(DB_PATH))
|
|
|
|
|
+ conn.execute("PRAGMA journal_mode=WAL")
|
|
|
|
|
+ conn.execute("PRAGMA busy_timeout=5000")
|
|
|
|
|
+ conn.row_factory = sqlite3.Row
|
|
|
|
|
+ return conn
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _init_db_sync() -> None:
|
|
|
|
|
+ """Synchronous database initialization."""
|
|
|
|
|
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ conn = _get_conn()
|
|
|
|
|
+ conn.executescript("""
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS persons (
|
|
|
|
|
+ id TEXT PRIMARY KEY,
|
|
|
|
|
+ name TEXT NOT NULL,
|
|
|
|
|
+ nickname TEXT UNIQUE,
|
|
|
|
|
+ birth_datetime TEXT NOT NULL,
|
|
|
|
|
+ latitude REAL NOT NULL,
|
|
|
|
|
+ longitude REAL NOT NULL,
|
|
|
|
|
+ elevation REAL DEFAULT 0.0,
|
|
|
|
|
+ created_at TEXT NOT NULL,
|
|
|
|
|
+ updated_at TEXT
|
|
|
|
|
+ );
|
|
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_persons_nickname ON persons(nickname);
|
|
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_persons_name ON persons(name);
|
|
|
|
|
+ """)
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def init_db() -> None:
|
|
|
|
|
+ """Initialize the database (idempotent, async-safe)."""
|
|
|
|
|
+ global _initialized
|
|
|
|
|
+ if _initialized:
|
|
|
|
|
+ return
|
|
|
|
|
+ async with _init_lock:
|
|
|
|
|
+ if _initialized:
|
|
|
|
|
+ return
|
|
|
|
|
+ await asyncio.to_thread(_init_db_sync)
|
|
|
|
|
+ _initialized = True
|
|
|
|
|
+ logger.info(f"database initialized at {DB_PATH}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
|
|
|
|
|
+ """Convert a sqlite3.Row to a plain dict."""
|
|
|
|
|
+ return dict(row)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── CRUD Operations ─────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+async def add_person(
|
|
|
|
|
+ name: str,
|
|
|
|
|
+ birth_datetime: str,
|
|
|
|
|
+ latitude: float,
|
|
|
|
|
+ longitude: float,
|
|
|
|
|
+ elevation: float = 0.0,
|
|
|
|
|
+ nickname: str | None = None,
|
|
|
|
|
+) -> dict[str, Any]:
|
|
|
|
|
+ """Add a new person to the database.
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ The created person dict.
|
|
|
|
|
+ """
|
|
|
|
|
+ await init_db()
|
|
|
|
|
+ person_id = str(uuid.uuid4())[:8]
|
|
|
|
|
+ now = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
+
|
|
|
|
|
+ def _insert():
|
|
|
|
|
+ conn = _get_conn()
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ """INSERT INTO persons (id, name, nickname, birth_datetime, latitude, longitude, elevation, created_at)
|
|
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
|
|
|
+ (person_id, name, nickname, birth_datetime, latitude, longitude, elevation, now),
|
|
|
|
|
+ )
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ row = conn.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ return _row_to_dict(row)
|
|
|
|
|
+
|
|
|
|
|
+ result = await asyncio.to_thread(_insert)
|
|
|
|
|
+ logger.info(f"added person {result['id']} ({name})")
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def get_person(
|
|
|
|
|
+ person_id: str | None = None,
|
|
|
|
|
+ nickname: str | None = None,
|
|
|
|
|
+) -> dict[str, Any] | None:
|
|
|
|
|
+ """Get a person by ID or nickname.
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ Person dict or None if not found.
|
|
|
|
|
+ """
|
|
|
|
|
+ await init_db()
|
|
|
|
|
+
|
|
|
|
|
+ def _fetch():
|
|
|
|
|
+ conn = _get_conn()
|
|
|
|
|
+ if person_id:
|
|
|
|
|
+ row = conn.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
|
|
|
|
|
+ elif nickname:
|
|
|
|
|
+ row = conn.execute("SELECT * FROM persons WHERE nickname = ?", (nickname,)).fetchone()
|
|
|
|
|
+ else:
|
|
|
|
|
+ row = None
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ return _row_to_dict(row) if row else None
|
|
|
|
|
+
|
|
|
|
|
+ return await asyncio.to_thread(_fetch)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def list_persons(
|
|
|
|
|
+ limit: int = 100,
|
|
|
|
|
+ offset: int = 0,
|
|
|
|
|
+) -> list[dict[str, Any]]:
|
|
|
|
|
+ """List all persons, ordered by creation date (newest first).
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ List of person dicts.
|
|
|
|
|
+ """
|
|
|
|
|
+ await init_db()
|
|
|
|
|
+
|
|
|
|
|
+ def _fetch():
|
|
|
|
|
+ conn = _get_conn()
|
|
|
|
|
+ rows = conn.execute(
|
|
|
|
|
+ "SELECT * FROM persons ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
|
|
|
|
+ (limit, offset),
|
|
|
|
|
+ ).fetchall()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ return [_row_to_dict(r) for r in rows]
|
|
|
|
|
+
|
|
|
|
|
+ return await asyncio.to_thread(_fetch)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def update_person(
|
|
|
|
|
+ person_id: str,
|
|
|
|
|
+ name: str | None = None,
|
|
|
|
|
+ nickname: str | None = None,
|
|
|
|
|
+ birth_datetime: str | None = None,
|
|
|
|
|
+ latitude: float | None = None,
|
|
|
|
|
+ longitude: float | None = None,
|
|
|
|
|
+ elevation: float | None = None,
|
|
|
|
|
+) -> dict[str, Any] | None:
|
|
|
|
|
+ """Update a person's data. Only provided fields are updated.
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ Updated person dict, or None if not found.
|
|
|
|
|
+ """
|
|
|
|
|
+ await init_db()
|
|
|
|
|
+ now = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
+
|
|
|
|
|
+ def _update():
|
|
|
|
|
+ conn = _get_conn()
|
|
|
|
|
+ # Check existence
|
|
|
|
|
+ existing = conn.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
|
|
|
|
|
+ if not existing:
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ # Build dynamic update
|
|
|
|
|
+ fields = []
|
|
|
|
|
+ values = []
|
|
|
|
|
+ if name is not None:
|
|
|
|
|
+ fields.append("name = ?")
|
|
|
|
|
+ values.append(name)
|
|
|
|
|
+ if nickname is not None:
|
|
|
|
|
+ fields.append("nickname = ?")
|
|
|
|
|
+ values.append(nickname)
|
|
|
|
|
+ if birth_datetime is not None:
|
|
|
|
|
+ fields.append("birth_datetime = ?")
|
|
|
|
|
+ values.append(birth_datetime)
|
|
|
|
|
+ if latitude is not None:
|
|
|
|
|
+ fields.append("latitude = ?")
|
|
|
|
|
+ values.append(latitude)
|
|
|
|
|
+ if longitude is not None:
|
|
|
|
|
+ fields.append("longitude = ?")
|
|
|
|
|
+ values.append(longitude)
|
|
|
|
|
+ if elevation is not None:
|
|
|
|
|
+ fields.append("elevation = ?")
|
|
|
|
|
+ values.append(elevation)
|
|
|
|
|
+
|
|
|
|
|
+ if not fields:
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ return _row_to_dict(existing)
|
|
|
|
|
+
|
|
|
|
|
+ fields.append("updated_at = ?")
|
|
|
|
|
+ values.append(now)
|
|
|
|
|
+ values.append(person_id)
|
|
|
|
|
+
|
|
|
|
|
+ conn.execute(
|
|
|
|
|
+ f"UPDATE persons SET {', '.join(fields)} WHERE id = ?",
|
|
|
|
|
+ values,
|
|
|
|
|
+ )
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ row = conn.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ return _row_to_dict(row)
|
|
|
|
|
+
|
|
|
|
|
+ result = await asyncio.to_thread(_update)
|
|
|
|
|
+ if result:
|
|
|
|
|
+ logger.info(f"updated person {person_id}")
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def delete_person(person_id: str) -> bool:
|
|
|
|
|
+ """Delete a person by ID.
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ True if deleted, False if not found.
|
|
|
|
|
+ """
|
|
|
|
|
+ await init_db()
|
|
|
|
|
+
|
|
|
|
|
+ def _delete():
|
|
|
|
|
+ conn = _get_conn()
|
|
|
|
|
+ cursor = conn.execute("DELETE FROM persons WHERE id = ?", (person_id,))
|
|
|
|
|
+ conn.commit()
|
|
|
|
|
+ deleted = cursor.rowcount > 0
|
|
|
|
|
+ conn.close()
|
|
|
|
|
+ return deleted
|
|
|
|
|
+
|
|
|
|
|
+ result = await asyncio.to_thread(_delete)
|
|
|
|
|
+ if result:
|
|
|
|
|
+ logger.info(f"deleted person {person_id}")
|
|
|
|
|
+ return result
|