_sqlite.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. import logging
  2. import sqlite3
  3. from contextlib import asynccontextmanager
  4. from datetime import datetime
  5. from pathlib import Path
  6. from room_cal.store._backend import EventRecord, register_backend
  7. _log = logging.getLogger(__name__)
  8. _CREATE_TABLE = '''
  9. CREATE TABLE IF NOT EXISTS events (
  10. uid TEXT NOT NULL PRIMARY KEY,
  11. start TEXT NOT NULL,
  12. end TEXT NOT NULL,
  13. all_day INTEGER NOT NULL,
  14. title TEXT NOT NULL,
  15. description TEXT NOT NULL DEFAULT '',
  16. x_data TEXT
  17. )
  18. '''
  19. _CREATE_INDEX = 'CREATE INDEX IF NOT EXISTS idx_events_start_end ON events(start, end)'
  20. # This is not a carefully optimized SQL implementation. It shoves JSON blobs into a column
  21. # But it is very easy to map to CalDAV, which is where the room_cal module was born, and
  22. # it's so much faster than CalDAV that it's hard to care about the simplistic design
  23. _COLS = 'uid, start, end, all_day, title, description, x_data'
  24. _MIGRATE_DROP_CALENDAR_NAME = '''
  25. CREATE TABLE events_new (
  26. uid TEXT NOT NULL PRIMARY KEY,
  27. start TEXT NOT NULL,
  28. end TEXT NOT NULL,
  29. all_day INTEGER NOT NULL,
  30. title TEXT NOT NULL,
  31. description TEXT NOT NULL DEFAULT '',
  32. x_data TEXT
  33. );
  34. INSERT INTO events_new SELECT uid, start, end, all_day, title, description, x_data FROM events;
  35. DROP TABLE events;
  36. ALTER TABLE events_new RENAME TO events;
  37. CREATE INDEX IF NOT EXISTS idx_events_start_end ON events(start, end);
  38. '''
  39. @register_backend('sqlite')
  40. class SQLiteBackend:
  41. def __init__(self, db_path: Path):
  42. self._db_path = db_path
  43. @classmethod
  44. def from_store_config(cls, store_cfg) -> 'SQLiteBackend':
  45. import sqlite3
  46. from room_cal._utils import data_home
  47. db_path = Path(store_cfg.sqlite.db_path)
  48. if not db_path.is_absolute():
  49. db_path = data_home() / db_path
  50. with sqlite3.connect(db_path) as conn:
  51. cols = {row[1] for row in conn.execute('PRAGMA table_info(events)')}
  52. if 'calendar_name' in cols:
  53. conn.executescript(_MIGRATE_DROP_CALENDAR_NAME)
  54. else:
  55. conn.execute(_CREATE_TABLE)
  56. conn.execute(_CREATE_INDEX)
  57. return cls(db_path)
  58. async def health_check(self) -> tuple[bool, str | None]:
  59. try:
  60. async with self._connect() as db:
  61. await db.execute('SELECT 1')
  62. except Exception as e:
  63. return False, str(e)
  64. return True, None
  65. @asynccontextmanager
  66. async def _connect(self):
  67. import aiosqlite
  68. try:
  69. async with aiosqlite.connect(self._db_path) as db:
  70. yield db
  71. except sqlite3.OperationalError as e:
  72. raise sqlite3.OperationalError(f"SQLite error for {self._db_path}: {e}") from None
  73. def _to_record(self, row) -> EventRecord:
  74. uid, start, end, all_day, title, description, x_data = row
  75. return EventRecord(
  76. uid=uid,
  77. start=datetime.fromisoformat(start),
  78. end=datetime.fromisoformat(end),
  79. all_day=bool(all_day),
  80. title=title,
  81. description=description,
  82. x_data=x_data,
  83. )
  84. async def fetch_range(self, start: datetime, end: datetime) -> list[EventRecord]:
  85. async with self._connect() as db:
  86. await db.execute(_CREATE_TABLE)
  87. cursor = await db.execute(
  88. f'SELECT {_COLS} FROM events WHERE start < ? AND end > ? ORDER BY start',
  89. (end.isoformat(), start.isoformat()),
  90. )
  91. rows = await cursor.fetchall()
  92. return [self._to_record(r) for r in rows]
  93. async def create(self, record: EventRecord) -> EventRecord:
  94. async with self._connect() as db:
  95. await db.execute(_CREATE_TABLE)
  96. await db.execute(
  97. f'INSERT INTO events ({_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?)',
  98. (record.uid, record.start.isoformat(), record.end.isoformat(),
  99. int(record.all_day), record.title, record.description, record.x_data),
  100. )
  101. await db.commit()
  102. _log.info('create date=%s title=%r', record.start.date(), record.title)
  103. return record
  104. async def update(self, uid: str, *, title: str | None = None,
  105. description: str | None = None, x_data: str | None = None) -> EventRecord:
  106. async with self._connect() as db:
  107. await db.execute(_CREATE_TABLE)
  108. if title is not None:
  109. await db.execute('UPDATE events SET title = ? WHERE uid = ?', (title, uid))
  110. if description is not None:
  111. await db.execute('UPDATE events SET description = ? WHERE uid = ?', (description, uid))
  112. if x_data is not None:
  113. await db.execute('UPDATE events SET x_data = ? WHERE uid = ?', (x_data, uid))
  114. await db.commit()
  115. cursor = await db.execute(f'SELECT {_COLS} FROM events WHERE uid = ?', (uid,))
  116. row = await cursor.fetchone()
  117. _log.info('update uid=%s', uid)
  118. return self._to_record(row)
  119. async def delete(self, uid: str) -> None:
  120. async with self._connect() as db:
  121. await db.execute(_CREATE_TABLE)
  122. await db.execute('DELETE FROM events WHERE uid = ?', (uid,))
  123. await db.commit()
  124. _log.info('delete uid=%s', uid)