marqeta-diva-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
1
+ """Local SQLite storage for complete transaction data."""
2
+
3
+ import json
4
+ import sqlite3
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+
10
+ class TransactionStorage:
11
+ """Local SQLite storage for complete transaction data."""
12
+
13
+ def __init__(self, db_path: str = "./transactions.db"):
14
+ """
15
+ Initialize the transaction storage.
16
+
17
+ Args:
18
+ db_path: Path to SQLite database file
19
+ """
20
+ self.db_path = Path(db_path)
21
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
22
+
23
+ print(f"[Storage] Initializing SQLite database at {self.db_path}...", file=sys.stderr)
24
+
25
+ self.conn = sqlite3.connect(str(self.db_path))
26
+ self.conn.row_factory = sqlite3.Row # Return rows as dictionaries
27
+
28
+ self._create_tables()
29
+
30
+ print(f"[Storage] SQLite database ready", file=sys.stderr)
31
+
32
+ def _create_tables(self) -> None:
33
+ """Create database tables if they don't exist."""
34
+ cursor = self.conn.cursor()
35
+
36
+ # Main transactions table
37
+ cursor.execute("""
38
+ CREATE TABLE IF NOT EXISTS transactions (
39
+ transaction_token TEXT PRIMARY KEY,
40
+ view_name TEXT NOT NULL,
41
+ aggregation TEXT NOT NULL,
42
+ merchant_name TEXT,
43
+ transaction_amount REAL,
44
+ transaction_type TEXT,
45
+ state TEXT,
46
+ user_token TEXT,
47
+ card_token TEXT,
48
+ business_user_token TEXT,
49
+ created_time TEXT,
50
+ transaction_timestamp TEXT,
51
+ network TEXT,
52
+ merchant_category_code TEXT,
53
+ currency_code TEXT,
54
+ full_data TEXT NOT NULL,
55
+ indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
56
+ )
57
+ """)
58
+
59
+ # Create indexes for common queries
60
+ cursor.execute("""
61
+ CREATE INDEX IF NOT EXISTS idx_merchant_name
62
+ ON transactions(merchant_name)
63
+ """)
64
+
65
+ cursor.execute("""
66
+ CREATE INDEX IF NOT EXISTS idx_transaction_amount
67
+ ON transactions(transaction_amount)
68
+ """)
69
+
70
+ cursor.execute("""
71
+ CREATE INDEX IF NOT EXISTS idx_user_token
72
+ ON transactions(user_token)
73
+ """)
74
+
75
+ cursor.execute("""
76
+ CREATE INDEX IF NOT EXISTS idx_business_user_token
77
+ ON transactions(business_user_token)
78
+ """)
79
+
80
+ cursor.execute("""
81
+ CREATE INDEX IF NOT EXISTS idx_created_time
82
+ ON transactions(created_time)
83
+ """)
84
+
85
+ cursor.execute("""
86
+ CREATE INDEX IF NOT EXISTS idx_view_name
87
+ ON transactions(view_name)
88
+ """)
89
+
90
+ self.conn.commit()
91
+
92
+ def add_transactions(
93
+ self,
94
+ transactions: List[Dict[str, Any]],
95
+ view_name: str,
96
+ aggregation: str
97
+ ) -> int:
98
+ """
99
+ Add or update transactions in the database.
100
+
101
+ Args:
102
+ transactions: List of transaction dictionaries
103
+ view_name: DiVA view name
104
+ aggregation: Aggregation level
105
+
106
+ Returns:
107
+ Number of transactions added/updated
108
+ """
109
+ cursor = self.conn.cursor()
110
+ count = 0
111
+
112
+ for txn in transactions:
113
+ transaction_token = txn.get("transaction_token")
114
+ if not transaction_token:
115
+ print(f"[Storage] Warning: Transaction missing token, skipping", file=sys.stderr)
116
+ continue
117
+
118
+ # Extract common fields for indexing
119
+ merchant_name = txn.get("merchant_name", txn.get("acquirer_merchant_name"))
120
+ transaction_amount = txn.get("transaction_amount")
121
+ transaction_type = txn.get("transaction_type")
122
+ state = txn.get("state", txn.get("transaction_status"))
123
+ user_token = txn.get("user_token", txn.get("acting_user_token"))
124
+ card_token = txn.get("card_token")
125
+ business_user_token = txn.get("business_user_token")
126
+ created_time = txn.get("created_time", txn.get("transaction_timestamp"))
127
+ transaction_timestamp = txn.get("transaction_timestamp")
128
+ network = txn.get("network")
129
+ merchant_category_code = txn.get("merchant_category_code")
130
+ currency_code = txn.get("currency_code")
131
+
132
+ # Store full transaction as JSON
133
+ full_data = json.dumps(txn)
134
+
135
+ # Upsert (insert or replace)
136
+ cursor.execute("""
137
+ INSERT OR REPLACE INTO transactions (
138
+ transaction_token, view_name, aggregation,
139
+ merchant_name, transaction_amount, transaction_type,
140
+ state, user_token, card_token, business_user_token,
141
+ created_time, transaction_timestamp, network,
142
+ merchant_category_code, currency_code, full_data
143
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
144
+ """, (
145
+ transaction_token, view_name, aggregation,
146
+ merchant_name, transaction_amount, transaction_type,
147
+ state, user_token, card_token, business_user_token,
148
+ created_time, transaction_timestamp, network,
149
+ merchant_category_code, currency_code, full_data
150
+ ))
151
+
152
+ count += 1
153
+
154
+ self.conn.commit()
155
+ print(f"[Storage] Added/updated {count} transactions", file=sys.stderr)
156
+ return count
157
+
158
+ def get_transactions(
159
+ self,
160
+ transaction_tokens: List[str]
161
+ ) -> List[Dict[str, Any]]:
162
+ """
163
+ Get transactions by their tokens.
164
+
165
+ Args:
166
+ transaction_tokens: List of transaction tokens
167
+
168
+ Returns:
169
+ List of complete transaction dictionaries
170
+ """
171
+ if not transaction_tokens:
172
+ return []
173
+
174
+ cursor = self.conn.cursor()
175
+ placeholders = ",".join("?" * len(transaction_tokens))
176
+
177
+ cursor.execute(f"""
178
+ SELECT full_data FROM transactions
179
+ WHERE transaction_token IN ({placeholders})
180
+ """, transaction_tokens)
181
+
182
+ results = []
183
+ for row in cursor.fetchall():
184
+ full_data = json.loads(row["full_data"])
185
+ results.append(full_data)
186
+
187
+ return results
188
+
189
+ def query_transactions(
190
+ self,
191
+ filters: Optional[Dict[str, Any]] = None,
192
+ limit: int = 100,
193
+ offset: int = 0,
194
+ order_by: str = "created_time DESC"
195
+ ) -> Dict[str, Any]:
196
+ """
197
+ Query transactions with filters.
198
+
199
+ Args:
200
+ filters: Dictionary of filters (e.g., {"merchant_name": "Starbucks", "transaction_amount": {">": 10}})
201
+ limit: Maximum number of results
202
+ offset: Offset for pagination
203
+ order_by: SQL ORDER BY clause
204
+
205
+ Returns:
206
+ Dictionary with results and metadata
207
+ """
208
+ cursor = self.conn.cursor()
209
+
210
+ # Build WHERE clause
211
+ where_clauses = []
212
+ params = []
213
+
214
+ if filters:
215
+ for field, value in filters.items():
216
+ if isinstance(value, dict):
217
+ # Handle operators like {"transaction_amount": {">": 10}}
218
+ for op, val in value.items():
219
+ if op in [">", "<", ">=", "<=", "=", "!="]:
220
+ where_clauses.append(f"{field} {op} ?")
221
+ params.append(val)
222
+ elif op == "like":
223
+ where_clauses.append(f"{field} LIKE ?")
224
+ params.append(f"%{val}%")
225
+ else:
226
+ # Simple equality
227
+ where_clauses.append(f"{field} = ?")
228
+ params.append(value)
229
+
230
+ where_clause = " AND ".join(where_clauses) if where_clauses else "1=1"
231
+
232
+ # Get total count
233
+ cursor.execute(f"""
234
+ SELECT COUNT(*) as count FROM transactions WHERE {where_clause}
235
+ """, params)
236
+ total_count = cursor.fetchone()["count"]
237
+
238
+ # Get results
239
+ cursor.execute(f"""
240
+ SELECT full_data FROM transactions
241
+ WHERE {where_clause}
242
+ ORDER BY {order_by}
243
+ LIMIT ? OFFSET ?
244
+ """, params + [limit, offset])
245
+
246
+ results = []
247
+ for row in cursor.fetchall():
248
+ full_data = json.loads(row["full_data"])
249
+ results.append(full_data)
250
+
251
+ return {
252
+ "total": total_count,
253
+ "count": len(results),
254
+ "offset": offset,
255
+ "limit": limit,
256
+ "is_more": offset + len(results) < total_count,
257
+ "data": results
258
+ }
259
+
260
+ def get_stats(self) -> Dict[str, Any]:
261
+ """
262
+ Get statistics about the local storage.
263
+
264
+ Returns:
265
+ Dictionary with storage statistics
266
+ """
267
+ cursor = self.conn.cursor()
268
+
269
+ # Total transactions
270
+ cursor.execute("SELECT COUNT(*) as count FROM transactions")
271
+ total_count = cursor.fetchone()["count"]
272
+
273
+ # By view
274
+ cursor.execute("""
275
+ SELECT view_name, aggregation, COUNT(*) as count
276
+ FROM transactions
277
+ GROUP BY view_name, aggregation
278
+ """)
279
+ by_view = [dict(row) for row in cursor.fetchall()]
280
+
281
+ # Date range
282
+ cursor.execute("""
283
+ SELECT
284
+ MIN(created_time) as earliest,
285
+ MAX(created_time) as latest
286
+ FROM transactions
287
+ """)
288
+ date_range = dict(cursor.fetchone())
289
+
290
+ # Database file size
291
+ db_size = self.db_path.stat().st_size if self.db_path.exists() else 0
292
+
293
+ return {
294
+ "database_path": str(self.db_path.absolute()),
295
+ "total_transactions": total_count,
296
+ "by_view": by_view,
297
+ "date_range": date_range,
298
+ "database_size_bytes": db_size,
299
+ "database_size_mb": round(db_size / (1024 * 1024), 2)
300
+ }
301
+
302
+ def clear(self, view_name: Optional[str] = None) -> int:
303
+ """
304
+ Clear transactions from the database.
305
+
306
+ Args:
307
+ view_name: If specified, only clear transactions from this view
308
+
309
+ Returns:
310
+ Number of transactions deleted
311
+ """
312
+ cursor = self.conn.cursor()
313
+
314
+ if view_name:
315
+ cursor.execute("DELETE FROM transactions WHERE view_name = ?", (view_name,))
316
+ count = cursor.rowcount
317
+ print(f"[Storage] Cleared {count} transactions from view '{view_name}'", file=sys.stderr)
318
+ else:
319
+ cursor.execute("DELETE FROM transactions")
320
+ count = cursor.rowcount
321
+ print(f"[Storage] Cleared all {count} transactions", file=sys.stderr)
322
+
323
+ self.conn.commit()
324
+ return count
325
+
326
+ def close(self) -> None:
327
+ """Close the database connection."""
328
+ self.conn.close()
329
+
330
+ def __enter__(self):
331
+ """Context manager entry."""
332
+ return self
333
+
334
+ def __exit__(self, exc_type, exc_val, exc_tb):
335
+ """Context manager exit."""
336
+ self.close()
337
+
338
+
339
+ # Global storage instance (lazy-loaded)
340
+ _storage: TransactionStorage | None = None
341
+
342
+
343
+ def get_storage(db_path: str = "./transactions.db") -> TransactionStorage:
344
+ """Get or create the global transaction storage instance."""
345
+ global _storage
346
+ if _storage is None:
347
+ _storage = TransactionStorage(db_path=db_path)
348
+ return _storage