# python import os import threading from config.paths import get_database_dir from tinydb import TinyDB from tinydb.queries import QueryInstance from db.mongo_client import get_mongo_client, get_mongo_db_name try: from tinydb.table import Document except ImportError: # pragma: no cover - tinydb version compatibility from tinydb.database import Document def _stable_clause_key(clause: dict) -> str: """Return a stable string key for sorting MongoDB filter clauses.""" import json return json.dumps(clause, sort_keys=True, default=str) try: from pymongo import ASCENDING except ImportError: # pragma: no cover - pymongo is a required dependency ASCENDING = 1 USE_MONGODB = os.environ.get('USE_MONGODB', 'true').lower() == 'true' # Resolve the MongoDB database name once at module load so runtime changes to # DB_ENV/DATA_ENV in tests do not switch databases mid-process. _mongo_db_name = get_mongo_db_name() if USE_MONGODB else None base_dir = get_database_dir() os.makedirs(base_dir, exist_ok=True) # --------------------------------------------------------------------------- # TinyDB-backed table wrapper # --------------------------------------------------------------------------- class LockedTable: """ Thread-safe wrapper around a TinyDB table. All callable attribute access is wrapped to acquire a reentrant lock while calling the underlying method. Non-callable attributes are returned directly. """ def __init__(self, table): self._table = table self._lock = threading.RLock() def __getattr__(self, name): # avoid proxying internal attrs if name in ('_table', '_lock'): return super().__getattribute__(name) attr = getattr(self._table, name) if callable(attr): def locked_call(*args, **kwargs): with self._lock: return attr(*args, **kwargs) return locked_call return attr # convenience explicit methods (ensure these are class methods, not top-level) def insert(self, *args, **kwargs): with self._lock: return self._table.insert(*args, **kwargs) def insert_multiple(self, *args, **kwargs): with self._lock: return self._table.insert_multiple(*args, **kwargs) def search(self, *args, **kwargs): with self._lock: return self._table.search(*args, **kwargs) def get(self, *args, **kwargs): with self._lock: return self._table.get(*args, **kwargs) def all(self, *args, **kwargs): with self._lock: return self._table.all(*args, **kwargs) def remove(self, *args, **kwargs): with self._lock: return self._table.remove(*args, **kwargs) def update(self, *args, **kwargs): with self._lock: return self._table.update(*args, **kwargs) def truncate(self): with self._lock: return self._table.truncate() def close(self): with self._lock: return self._table.close() # --------------------------------------------------------------------------- # TinyDB Query -> MongoDB filter translator # --------------------------------------------------------------------------- _MONGO_OP_MAP = { '==': '$eq', '!=': '$ne', '<': '$lt', '<=': '$lte', '>': '$gt', '>=': '$gte', } _NEGATED_OPS = { '$eq': '$ne', '$ne': '$eq', '$lt': '$gte', '$lte': '$gt', '$gt': '$lte', '$gte': '$lt', } def _field_to_mongo(field_path: tuple) -> str: """Map a TinyDB field path to a MongoDB field name. The model ``id`` field is stored as the MongoDB ``_id`` field, so queries on ``id`` are translated to queries on ``_id``. """ if len(field_path) == 1: return '_id' if field_path[0] == 'id' else field_path[0] return '.'.join('_id' if p == 'id' else p for p in field_path) def _negate_condition(cond: dict) -> dict | None: """Return a MongoDB condition that negates a single-field condition.""" if len(cond) != 1: return None field, inner = next(iter(cond.items())) if not isinstance(inner, dict) or len(inner) != 1: return None op, value = next(iter(inner.items())) if op in _NEGATED_OPS: return {field: {_NEGATED_OPS[op]: value}} return None def _hash_to_mongo_filter(query_hash) -> dict | None: """Translate a TinyDB query hash tuple to a MongoDB filter document. Returns ``None`` when the query construct cannot be expressed as a native MongoDB filter, signalling that the caller should fall back to in-memory TinyDB evaluation. """ if not isinstance(query_hash, tuple) or len(query_hash) == 0: return None op = query_hash[0] if op in _MONGO_OP_MAP: field = _field_to_mongo(query_hash[1]) value = query_hash[2] return {field: {_MONGO_OP_MAP[op]: value}} if op == 'exists': field = _field_to_mongo(query_hash[1]) return {field: {'$exists': True}} if op == 'one_of': field = _field_to_mongo(query_hash[1]) return {field: {'$in': list(query_hash[2])}} if op == 'any': field = _field_to_mongo(query_hash[1]) return {field: {'$in': list(query_hash[2])}} if op == 'all': field = _field_to_mongo(query_hash[1]) return {field: {'$all': list(query_hash[2])}} if op == 'matches': field = _field_to_mongo(query_hash[1]) # TinyDB matches() anchors the regex at the start of the string. return {field: {'$regex': f'^{query_hash[2]}'}} if op == 'search': field = _field_to_mongo(query_hash[1]) return {field: {'$regex': query_hash[2]}} if op == 'and': merged: dict = {} for sub_hash in query_hash[1]: sub = _hash_to_mongo_filter(sub_hash) if sub is None: return None for field, inner in sub.items(): if field in merged: if isinstance(merged[field], dict) and isinstance(inner, dict): merged[field].update(inner) elif isinstance(merged[field], list) and isinstance(inner, list): merged[field].extend(inner) else: return None else: merged[field] = ( dict(inner) if isinstance(inner, dict) else list(inner) if isinstance(inner, list) else inner ) return merged if op == 'or': clauses = [_hash_to_mongo_filter(sub_hash) for sub_hash in query_hash[1]] if any(c is None for c in clauses): return None return {'$or': sorted(clauses, key=_stable_clause_key)} if op == 'not': inner = _hash_to_mongo_filter(query_hash[1]) if inner is None: return None negated = _negate_condition(inner) if negated is not None: return negated return None # Unsupported operation (test, fragment, noop, etc.) -> fall back. return None def _query_to_mongo_filter(query) -> dict | None: """Translate a TinyDB QueryInstance to a MongoDB filter, if possible.""" if isinstance(query, dict): return query if isinstance(query, QueryInstance): return _hash_to_mongo_filter(query._hash) return None def _evaluate_in_memory(docs, query) -> list: """Evaluate a TinyDB query against an in-memory list of documents.""" if callable(query): return [doc for doc in docs if query(doc)] return docs # --------------------------------------------------------------------------- # MongoDB-backed table wrapper # --------------------------------------------------------------------------- class MongoLockedTable: """Drop-in replacement for ``LockedTable`` that delegates to MongoDB. The adapter preserves the existing table API while mapping the model ``id`` field to MongoDB's ``_id`` field on reads and writes. """ def __init__(self, collection_name: str): self.collection_name = collection_name def _collection(self): client = get_mongo_client() return client[_mongo_db_name][self.collection_name] @staticmethod def _doc_to_mongo(doc: dict) -> dict: """Store a copy of ``doc`` with ``id`` promoted to MongoDB ``_id``. This avoids storing both ``_id`` and ``id`` with identical values. The original ``id`` field is removed from the stored document. """ if doc is None: return None d = dict(doc) if 'id' in d: d['_id'] = d.pop('id') return d @staticmethod def _doc_from_mongo(doc: dict): """Return a TinyDB-compatible Document with ``doc_id`` set to ``_id``. Restores the model ``id`` field from MongoDB's ``_id`` and exposes TinyDB's ``doc_id`` attribute so callers that rely on it continue to work. """ if doc is None: return None d = dict(doc) doc_id = d.pop('_id', None) if doc_id is not None: d['id'] = doc_id return Document(d, doc_id=doc_id) def _mongo_filter(self, cond): """Translate a TinyDB query or dict to a MongoDB filter.""" return _query_to_mongo_filter(cond) def all(self): return [self._doc_from_mongo(doc) for doc in self._collection().find({})] def search(self, cond): mongo_filter = self._mongo_filter(cond) if mongo_filter is not None: cursor = self._collection().find(mongo_filter) return [self._doc_from_mongo(doc) for doc in cursor] # Fallback: fetch all and evaluate the TinyDB query in Python. docs = list(self._collection().find({})) matched = _evaluate_in_memory( [self._doc_from_mongo(doc) for doc in docs], cond ) return matched def get(self, cond): mongo_filter = self._mongo_filter(cond) if mongo_filter is not None: doc = self._collection().find_one(mongo_filter) return self._doc_from_mongo(doc) docs = list(self._collection().find({})) for doc in docs: d = self._doc_from_mongo(doc) if callable(cond) and cond(d): return d return None def insert(self, document: dict): doc = self._doc_to_mongo(document) result = self._collection().insert_one(doc) return str(result.inserted_id) def insert_multiple(self, documents: list): if not documents: return [] docs = [self._doc_to_mongo(d) for d in documents] result = self._collection().insert_many(docs) return [str(iid) for iid in result.inserted_ids] def update(self, fields, cond=None, doc_ids=None): is_callable = callable(fields) if doc_ids is not None: mongo_filter = {'_id': {'$in': list(doc_ids)}} target_ids = [str(did) for did in doc_ids] if not target_ids: return [] if is_callable: # Fetch, apply callable in-memory, and replace each document. updated_ids = [] for doc in self._collection().find(mongo_filter): d = self._doc_from_mongo(doc) fields(d) new_doc = self._doc_to_mongo(d) new_doc.pop('_id', None) self._collection().update_one( {'_id': doc['_id']}, {'$set': new_doc} ) updated_ids.append(str(doc['_id'])) return updated_ids update_doc = self._doc_to_mongo(fields) or {} update_doc.pop('_id', None) update_doc.pop('id', None) if update_doc: self._collection().update_many(mongo_filter, {'$set': update_doc}) return target_ids mongo_filter = self._mongo_filter(cond) if mongo_filter is not None and not is_callable: update_doc = self._doc_to_mongo(fields) or {} update_doc.pop('_id', None) update_doc.pop('id', None) target_ids = [ str(doc['_id']) for doc in self._collection().find(mongo_filter, {'_id': 1}) ] if target_ids and update_doc: self._collection().update_many( mongo_filter, {'$set': update_doc} ) return target_ids # Fallback: evaluate the query in-memory and update one at a time. docs = list(self._collection().find({})) updated_ids = [] for doc in docs: d = self._doc_from_mongo(doc) match = cond(d) if callable(cond) else (mongo_filter is not None) if not match: continue if is_callable: fields(d) new_doc = self._doc_to_mongo(d) new_doc.pop('_id', None) self._collection().update_one( {'_id': doc['_id']}, {'$set': new_doc} ) else: update_doc = self._doc_to_mongo(fields) or {} update_doc.pop('_id', None) update_doc.pop('id', None) if update_doc: self._collection().update_one( {'_id': doc['_id']}, {'$set': update_doc} ) updated_ids.append(str(doc['_id'])) return updated_ids def remove(self, cond): mongo_filter = self._mongo_filter(cond) if mongo_filter is not None: target_ids = [ str(doc['_id']) for doc in self._collection().find(mongo_filter, {'_id': 1}) ] if target_ids: self._collection().delete_many(mongo_filter) return target_ids # Fallback: evaluate the query in-memory and delete one at a time. docs = list(self._collection().find({})) removed_ids = [] for doc in docs: d = self._doc_from_mongo(doc) if callable(cond) and cond(d): self._collection().delete_one({'_id': doc['_id']}) removed_ids.append(str(doc['_id'])) return removed_ids def truncate(self): self._collection().delete_many({}) def close(self): # MongoDB clients are shared and long-lived; nothing to close here. pass # --------------------------------------------------------------------------- # Collection factory # --------------------------------------------------------------------------- def _make_table(json_path: str, collection_name: str): if USE_MONGODB: return MongoLockedTable(collection_name) db = TinyDB(json_path, indent=2) return LockedTable(db) # Setup DB files next to this module child_path = os.path.join(base_dir, 'children.json') task_path = os.path.join(base_dir, 'tasks.json') routine_path = os.path.join(base_dir, 'routines.json') routine_items_path = os.path.join(base_dir, 'routine_items.json') routine_schedules_path = os.path.join(base_dir, 'routine_schedules.json') routine_extensions_path = os.path.join(base_dir, 'routine_extensions.json') reward_path = os.path.join(base_dir, 'rewards.json') image_path = os.path.join(base_dir, 'images.json') pending_reward_path = os.path.join(base_dir, 'pending_rewards.json') pending_confirmations_path = os.path.join(base_dir, 'pending_confirmations.json') users_path = os.path.join(base_dir, 'users.json') tracking_events_path = os.path.join(base_dir, 'tracking_events.json') child_overrides_path = os.path.join(base_dir, 'child_overrides.json') chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json') task_extensions_path = os.path.join(base_dir, 'task_extensions.json') refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json') push_subscriptions_path = os.path.join(base_dir, 'push_subscriptions.json') digest_action_tokens_path = os.path.join(base_dir, 'digest_action_tokens.json') # Expose table objects backed by TinyDB or MongoDB based on USE_MONGODB child_db = _make_table(child_path, 'children') task_db = _make_table(task_path, 'tasks') routine_db = _make_table(routine_path, 'routines') routine_items_db = _make_table(routine_items_path, 'routine_items') routine_schedules_db = _make_table(routine_schedules_path, 'routine_schedules') routine_extensions_db = _make_table(routine_extensions_path, 'routine_extensions') reward_db = _make_table(reward_path, 'rewards') image_db = _make_table(image_path, 'images') pending_reward_db = _make_table(pending_reward_path, 'pending_rewards') pending_confirmations_db = _make_table(pending_confirmations_path, 'pending_confirmations') users_db = _make_table(users_path, 'users') tracking_events_db = _make_table(tracking_events_path, 'tracking_events') child_overrides_db = _make_table(child_overrides_path, 'child_overrides') chore_schedules_db = _make_table(chore_schedules_path, 'chore_schedules') task_extensions_db = _make_table(task_extensions_path, 'task_extensions') refresh_tokens_db = _make_table(refresh_tokens_path, 'refresh_tokens') push_subscriptions_db = _make_table(push_subscriptions_path, 'push_subscriptions') digest_action_tokens_db = _make_table(digest_action_tokens_path, 'digest_action_tokens') # --------------------------------------------------------------------------- # Index management # --------------------------------------------------------------------------- COLLECTION_INDEXES = { # NOTE: The model ``id`` field is stored as MongoDB's primary key ``_id``, # so no separate unique index on ``id`` is needed. Only secondary indexes # for frequently queried fields are defined here. 'children': [ {'keys': [('user_id', ASCENDING)]}, ], 'tasks': [ {'keys': [('user_id', ASCENDING)]}, ], 'routines': [ {'keys': [('user_id', ASCENDING)]}, ], 'routine_items': [ {'keys': [('user_id', ASCENDING)]}, ], 'routine_schedules': [], 'routine_extensions': [], 'rewards': [ {'keys': [('user_id', ASCENDING)]}, ], 'images': [ {'keys': [('user_id', ASCENDING)]}, ], 'pending_rewards': [ {'keys': [('child_id', ASCENDING)]}, ], 'pending_confirmations': [ {'keys': [('user_id', ASCENDING)]}, {'keys': [('child_id', ASCENDING)]}, {'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]}, ], 'users': [], 'tracking_events': [ {'keys': [('user_id', ASCENDING)]}, {'keys': [('child_id', ASCENDING)]}, {'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]}, ], 'child_overrides': [ {'keys': [('child_id', ASCENDING)]}, {'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]}, ], 'chore_schedules': [ {'keys': [('user_id', ASCENDING)]}, {'keys': [('child_id', ASCENDING)]}, ], 'task_extensions': [ {'keys': [('user_id', ASCENDING)]}, {'keys': [('child_id', ASCENDING)]}, ], 'refresh_tokens': [ {'keys': [('user_id', ASCENDING)]}, {'keys': [('token', ASCENDING)], 'unique': True, 'sparse': True}, ], 'push_subscriptions': [ {'keys': [('user_id', ASCENDING)]}, ], 'digest_action_tokens': [ {'keys': [('user_id', ASCENDING)]}, {'keys': [('token', ASCENDING)], 'unique': True, 'sparse': True}, ], } def ensure_mongodb_indexes(client=None, db_name=None): """Create required indexes on all MongoDB collections. Safe to call repeatedly: MongoDB treats index creation as idempotent. """ if not USE_MONGODB: return client = client or get_mongo_client() db_name = db_name or _mongo_db_name db = client[db_name] for collection_name, indexes in COLLECTION_INDEXES.items(): coll = db[collection_name] for spec in indexes: keys = spec['keys'] kwargs = {k: v for k, v in spec.items() if k != 'keys'} coll.create_index(keys, **kwargs) # Clear test collections at import time so tests start with a clean slate. if os.environ.get('DB_ENV', 'prod') == 'test': child_db.truncate() task_db.truncate() routine_db.truncate() routine_items_db.truncate() routine_schedules_db.truncate() routine_extensions_db.truncate() reward_db.truncate() image_db.truncate() pending_reward_db.truncate() pending_confirmations_db.truncate() users_db.truncate() tracking_events_db.truncate() child_overrides_db.truncate() chore_schedules_db.truncate() task_extensions_db.truncate() refresh_tokens_db.truncate() push_subscriptions_db.truncate() digest_action_tokens_db.truncate()