d365fo-client 0.1.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.
Files changed (51) hide show
  1. d365fo_client/__init__.py +305 -0
  2. d365fo_client/auth.py +93 -0
  3. d365fo_client/cli.py +700 -0
  4. d365fo_client/client.py +1454 -0
  5. d365fo_client/config.py +304 -0
  6. d365fo_client/crud.py +200 -0
  7. d365fo_client/exceptions.py +49 -0
  8. d365fo_client/labels.py +528 -0
  9. d365fo_client/main.py +502 -0
  10. d365fo_client/mcp/__init__.py +16 -0
  11. d365fo_client/mcp/client_manager.py +276 -0
  12. d365fo_client/mcp/main.py +98 -0
  13. d365fo_client/mcp/models.py +371 -0
  14. d365fo_client/mcp/prompts/__init__.py +43 -0
  15. d365fo_client/mcp/prompts/action_execution.py +480 -0
  16. d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
  17. d365fo_client/mcp/resources/__init__.py +15 -0
  18. d365fo_client/mcp/resources/database_handler.py +555 -0
  19. d365fo_client/mcp/resources/entity_handler.py +176 -0
  20. d365fo_client/mcp/resources/environment_handler.py +132 -0
  21. d365fo_client/mcp/resources/metadata_handler.py +283 -0
  22. d365fo_client/mcp/resources/query_handler.py +135 -0
  23. d365fo_client/mcp/server.py +432 -0
  24. d365fo_client/mcp/tools/__init__.py +17 -0
  25. d365fo_client/mcp/tools/connection_tools.py +175 -0
  26. d365fo_client/mcp/tools/crud_tools.py +579 -0
  27. d365fo_client/mcp/tools/database_tools.py +813 -0
  28. d365fo_client/mcp/tools/label_tools.py +189 -0
  29. d365fo_client/mcp/tools/metadata_tools.py +766 -0
  30. d365fo_client/mcp/tools/profile_tools.py +706 -0
  31. d365fo_client/metadata_api.py +793 -0
  32. d365fo_client/metadata_v2/__init__.py +59 -0
  33. d365fo_client/metadata_v2/cache_v2.py +1372 -0
  34. d365fo_client/metadata_v2/database_v2.py +585 -0
  35. d365fo_client/metadata_v2/global_version_manager.py +573 -0
  36. d365fo_client/metadata_v2/search_engine_v2.py +423 -0
  37. d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
  38. d365fo_client/metadata_v2/version_detector.py +439 -0
  39. d365fo_client/models.py +862 -0
  40. d365fo_client/output.py +181 -0
  41. d365fo_client/profile_manager.py +342 -0
  42. d365fo_client/profiles.py +178 -0
  43. d365fo_client/query.py +162 -0
  44. d365fo_client/session.py +60 -0
  45. d365fo_client/utils.py +196 -0
  46. d365fo_client-0.1.0.dist-info/METADATA +1084 -0
  47. d365fo_client-0.1.0.dist-info/RECORD +51 -0
  48. d365fo_client-0.1.0.dist-info/WHEEL +5 -0
  49. d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
  50. d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. d365fo_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,585 @@
1
+ """Enhanced database schema with global version management."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Optional
6
+
7
+ import aiosqlite
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class DatabaseSchemaV2:
13
+ """Database schema manager for metadata v2"""
14
+
15
+ @staticmethod
16
+ async def create_schema(db: aiosqlite.Connection):
17
+ """Create complete database schema for metadata v2"""
18
+
19
+ # Core environment tracking (enhanced)
20
+ await db.execute(
21
+ """
22
+ CREATE TABLE IF NOT EXISTS metadata_environments (
23
+ id INTEGER PRIMARY KEY,
24
+ base_url TEXT NOT NULL UNIQUE,
25
+ environment_name TEXT,
26
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
27
+ last_sync_at TIMESTAMP,
28
+ last_version_check TIMESTAMP,
29
+ is_active BOOLEAN DEFAULT 1
30
+ )
31
+ """
32
+ )
33
+
34
+ # Global version registry (NEW)
35
+ await db.execute(
36
+ """
37
+ CREATE TABLE IF NOT EXISTS global_versions (
38
+ id INTEGER PRIMARY KEY,
39
+ version_hash TEXT UNIQUE NOT NULL,
40
+ modules_hash TEXT UNIQUE NOT NULL,
41
+ first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
42
+ last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
43
+ reference_count INTEGER DEFAULT 0,
44
+ metadata_size_bytes INTEGER DEFAULT 0,
45
+ created_by_environment_id INTEGER REFERENCES metadata_environments(id)
46
+ )
47
+ """
48
+ )
49
+
50
+ # Environment to global version mapping (NEW)
51
+ await db.execute(
52
+ """
53
+ CREATE TABLE IF NOT EXISTS environment_versions (
54
+ environment_id INTEGER REFERENCES metadata_environments(id),
55
+ global_version_id INTEGER REFERENCES global_versions(id),
56
+ detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
57
+ is_active BOOLEAN DEFAULT 1,
58
+ sync_status TEXT DEFAULT 'pending', -- pending|syncing|completed|failed
59
+ last_sync_duration_ms INTEGER,
60
+ PRIMARY KEY (environment_id, global_version_id)
61
+ )
62
+ """
63
+ )
64
+
65
+ # Sample modules for global versions (NEW)
66
+ await db.execute(
67
+ """
68
+ CREATE TABLE IF NOT EXISTS global_version_modules (
69
+ id INTEGER PRIMARY KEY,
70
+ global_version_id INTEGER REFERENCES global_versions(id),
71
+ module_id TEXT NOT NULL,
72
+ module_name TEXT,
73
+ version TEXT,
74
+ publisher TEXT,
75
+ display_name TEXT,
76
+ sort_order INTEGER DEFAULT 0
77
+ )
78
+ """
79
+ )
80
+
81
+ # Enhanced metadata versioning
82
+ await db.execute(
83
+ """
84
+ CREATE TABLE IF NOT EXISTS metadata_versions (
85
+ id INTEGER PRIMARY KEY,
86
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
87
+ application_version TEXT,
88
+ platform_version TEXT,
89
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
90
+ sync_completed_at TIMESTAMP,
91
+ entity_count INTEGER DEFAULT 0,
92
+ action_count INTEGER DEFAULT 0,
93
+ enumeration_count INTEGER DEFAULT 0,
94
+ label_count INTEGER DEFAULT 0
95
+ )
96
+ """
97
+ )
98
+
99
+ # Version-aware metadata tables (enhanced with global_version_id)
100
+
101
+ # Data entities
102
+ await db.execute(
103
+ """
104
+ CREATE TABLE IF NOT EXISTS data_entities (
105
+ id INTEGER PRIMARY KEY,
106
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
107
+ name TEXT NOT NULL,
108
+ public_entity_name TEXT,
109
+ public_collection_name TEXT,
110
+ label_id TEXT,
111
+ label_text TEXT,
112
+ entity_category TEXT,
113
+ data_service_enabled BOOLEAN DEFAULT 1,
114
+ data_management_enabled BOOLEAN DEFAULT 1,
115
+ is_read_only BOOLEAN DEFAULT 0,
116
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
117
+ )
118
+ """
119
+ )
120
+
121
+ # Public entities
122
+ await db.execute(
123
+ """
124
+ CREATE TABLE IF NOT EXISTS public_entities (
125
+ id INTEGER PRIMARY KEY,
126
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
127
+ name TEXT NOT NULL,
128
+ entity_set_name TEXT,
129
+ label_id TEXT,
130
+ label_text TEXT,
131
+ is_read_only BOOLEAN DEFAULT 0,
132
+ configuration_enabled BOOLEAN DEFAULT 1,
133
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
134
+ )
135
+ """
136
+ )
137
+
138
+ # Entity properties (version-aware)
139
+ await db.execute(
140
+ """
141
+ CREATE TABLE IF NOT EXISTS entity_properties (
142
+ id INTEGER PRIMARY KEY,
143
+ entity_id INTEGER NOT NULL REFERENCES public_entities(id),
144
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
145
+ name TEXT NOT NULL,
146
+ type_name TEXT,
147
+ data_type TEXT,
148
+ odata_xpp_type TEXT,
149
+ label_id TEXT,
150
+ label_text TEXT,
151
+ is_key BOOLEAN DEFAULT 0,
152
+ is_mandatory BOOLEAN DEFAULT 0,
153
+ configuration_enabled BOOLEAN DEFAULT 1,
154
+ allow_edit BOOLEAN DEFAULT 1,
155
+ allow_edit_on_create BOOLEAN DEFAULT 1,
156
+ is_dimension BOOLEAN DEFAULT 0,
157
+ dimension_relation TEXT,
158
+ is_dynamic_dimension BOOLEAN DEFAULT 0,
159
+ dimension_legal_entity_property TEXT,
160
+ dimension_type_property TEXT,
161
+ property_order INTEGER DEFAULT 0
162
+ )
163
+ """
164
+ )
165
+
166
+ # Navigation properties (version-aware)
167
+ await db.execute(
168
+ """
169
+ CREATE TABLE IF NOT EXISTS navigation_properties (
170
+ id INTEGER PRIMARY KEY,
171
+ entity_id INTEGER NOT NULL REFERENCES public_entities(id),
172
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
173
+ name TEXT NOT NULL,
174
+ related_entity TEXT,
175
+ related_relation_name TEXT,
176
+ cardinality TEXT DEFAULT 'Single'
177
+ )
178
+ """
179
+ )
180
+
181
+ # Relation constraints (version-aware)
182
+ await db.execute(
183
+ """
184
+ CREATE TABLE IF NOT EXISTS relation_constraints (
185
+ id INTEGER PRIMARY KEY,
186
+ navigation_property_id INTEGER NOT NULL REFERENCES navigation_properties(id),
187
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
188
+ constraint_type TEXT NOT NULL,
189
+ property_name TEXT,
190
+ referenced_property TEXT,
191
+ related_property TEXT,
192
+ fixed_value INTEGER,
193
+ fixed_value_str TEXT
194
+ )
195
+ """
196
+ )
197
+
198
+ # Property groups (version-aware)
199
+ await db.execute(
200
+ """
201
+ CREATE TABLE IF NOT EXISTS property_groups (
202
+ id INTEGER PRIMARY KEY,
203
+ entity_id INTEGER NOT NULL REFERENCES public_entities(id),
204
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
205
+ name TEXT NOT NULL
206
+ )
207
+ """
208
+ )
209
+
210
+ # Property group members (version-aware)
211
+ await db.execute(
212
+ """
213
+ CREATE TABLE IF NOT EXISTS property_group_members (
214
+ id INTEGER PRIMARY KEY,
215
+ property_group_id INTEGER NOT NULL REFERENCES property_groups(id),
216
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
217
+ property_name TEXT NOT NULL,
218
+ member_order INTEGER DEFAULT 0
219
+ )
220
+ """
221
+ )
222
+
223
+ # Entity actions (version-aware)
224
+ await db.execute(
225
+ """
226
+ CREATE TABLE IF NOT EXISTS entity_actions (
227
+ id INTEGER PRIMARY KEY,
228
+ entity_id INTEGER NOT NULL REFERENCES public_entities(id),
229
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
230
+ name TEXT NOT NULL,
231
+ binding_kind TEXT DEFAULT 'BoundToEntitySet',
232
+ entity_name TEXT,
233
+ entity_set_name TEXT,
234
+ return_type_name TEXT,
235
+ return_is_collection BOOLEAN DEFAULT 0,
236
+ return_odata_xpp_type TEXT,
237
+ field_lookup TEXT
238
+ )
239
+ """
240
+ )
241
+
242
+ # Action parameters (version-aware)
243
+ await db.execute(
244
+ """
245
+ CREATE TABLE IF NOT EXISTS action_parameters (
246
+ id INTEGER PRIMARY KEY,
247
+ action_id INTEGER NOT NULL REFERENCES entity_actions(id),
248
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
249
+ name TEXT NOT NULL,
250
+ type_name TEXT,
251
+ is_collection BOOLEAN DEFAULT 0,
252
+ odata_xpp_type TEXT,
253
+ parameter_order INTEGER DEFAULT 0
254
+ )
255
+ """
256
+ )
257
+
258
+ # Enumerations (version-aware)
259
+ await db.execute(
260
+ """
261
+ CREATE TABLE IF NOT EXISTS enumerations (
262
+ id INTEGER PRIMARY KEY,
263
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
264
+ name TEXT NOT NULL,
265
+ label_id TEXT,
266
+ label_text TEXT,
267
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
268
+ )
269
+ """
270
+ )
271
+
272
+ # Enumeration members (version-aware)
273
+ await db.execute(
274
+ """
275
+ CREATE TABLE IF NOT EXISTS enumeration_members (
276
+ id INTEGER PRIMARY KEY,
277
+ enumeration_id INTEGER NOT NULL REFERENCES enumerations(id),
278
+ global_version_id INTEGER NOT NULL REFERENCES global_versions(id),
279
+ name TEXT NOT NULL,
280
+ value INTEGER NOT NULL,
281
+ label_id TEXT,
282
+ label_text TEXT,
283
+ configuration_enabled BOOLEAN DEFAULT 1,
284
+ member_order INTEGER DEFAULT 0
285
+ )
286
+ """
287
+ )
288
+
289
+ # Labels cache (version-aware)
290
+ await db.execute(
291
+ """
292
+ CREATE TABLE IF NOT EXISTS labels_cache (
293
+ id INTEGER PRIMARY KEY,
294
+ global_version_id INTEGER REFERENCES global_versions(id),
295
+ label_id TEXT NOT NULL,
296
+ language TEXT NOT NULL DEFAULT 'en-US',
297
+ label_text TEXT,
298
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
299
+ expires_at TIMESTAMP,
300
+ hit_count INTEGER DEFAULT 0,
301
+ last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
302
+ UNIQUE(global_version_id, label_id, language)
303
+ )
304
+ """
305
+ )
306
+
307
+ # FTS5 search index (version-aware)
308
+ await db.execute(
309
+ """
310
+ CREATE VIRTUAL TABLE IF NOT EXISTS metadata_search_v2 USING fts5(
311
+ name,
312
+ entity_type,
313
+ description,
314
+ properties,
315
+ labels,
316
+ global_version_id UNINDEXED,
317
+ entity_id UNINDEXED,
318
+ content='',
319
+ contentless_delete=1
320
+ )
321
+ """
322
+ )
323
+
324
+ await db.commit()
325
+ logger.info("Database schema v2 created successfully")
326
+
327
+ @staticmethod
328
+ async def create_indexes(db: aiosqlite.Connection):
329
+ """Create optimized indexes for version-aware queries"""
330
+
331
+ indexes = [
332
+ # Global version indexes
333
+ "CREATE INDEX IF NOT EXISTS idx_global_versions_hash ON global_versions(version_hash)",
334
+ "CREATE INDEX IF NOT EXISTS idx_global_versions_modules_hash ON global_versions(modules_hash)",
335
+ "CREATE INDEX IF NOT EXISTS idx_global_versions_last_used ON global_versions(last_used_at)",
336
+ # Environment version indexes
337
+ "CREATE INDEX IF NOT EXISTS idx_env_versions_active ON environment_versions(environment_id, is_active)",
338
+ "CREATE INDEX IF NOT EXISTS idx_env_versions_global ON environment_versions(global_version_id, is_active)",
339
+ # Version-aware entity indexes
340
+ "CREATE INDEX IF NOT EXISTS idx_data_entities_version ON data_entities(global_version_id, name)",
341
+ "CREATE INDEX IF NOT EXISTS idx_public_entities_version ON public_entities(global_version_id, name)",
342
+ "CREATE INDEX IF NOT EXISTS idx_entity_properties_version ON entity_properties(global_version_id, entity_id)",
343
+ "CREATE INDEX IF NOT EXISTS idx_navigation_props_version ON navigation_properties(global_version_id, entity_id)",
344
+ "CREATE INDEX IF NOT EXISTS idx_entity_actions_version ON entity_actions(global_version_id, entity_id)",
345
+ "CREATE INDEX IF NOT EXISTS idx_enumerations_version ON enumerations(global_version_id, name)",
346
+ # Labels indexes
347
+ "CREATE INDEX IF NOT EXISTS idx_labels_version_lookup ON labels_cache(global_version_id, label_id, language)",
348
+ "CREATE INDEX IF NOT EXISTS idx_labels_expires ON labels_cache(expires_at)",
349
+ # Search performance indexes
350
+ "CREATE INDEX IF NOT EXISTS idx_data_entities_search ON data_entities(global_version_id, data_service_enabled, entity_category)",
351
+ "CREATE INDEX IF NOT EXISTS idx_public_entities_search ON public_entities(global_version_id, is_read_only)",
352
+ # Global version modules index
353
+ "CREATE INDEX IF NOT EXISTS idx_global_version_modules ON global_version_modules(global_version_id, module_id)",
354
+ ]
355
+
356
+ for index_sql in indexes:
357
+ try:
358
+ await db.execute(index_sql)
359
+ except Exception as e:
360
+ logger.warning(f"Failed to create index: {e}")
361
+
362
+ await db.commit()
363
+ logger.info("Database indexes v2 created successfully")
364
+
365
+
366
+ class MetadataDatabaseV2:
367
+ """Enhanced metadata database with global version support"""
368
+
369
+ def __init__(self, db_path: Path):
370
+ """Initialize database with path
371
+
372
+ Args:
373
+ db_path: Path to SQLite database file
374
+ """
375
+ self.db_path = db_path
376
+ self._ensure_database_directory()
377
+
378
+ def _ensure_database_directory(self):
379
+ """Ensure database directory exists"""
380
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
381
+
382
+ async def initialize(self):
383
+ """Initialize database with v2 schema"""
384
+ async with aiosqlite.connect(self.db_path) as db:
385
+ await DatabaseSchemaV2.create_schema(db)
386
+ await DatabaseSchemaV2.create_indexes(db)
387
+
388
+ # Enable foreign key constraints
389
+ await db.execute("PRAGMA foreign_keys = ON")
390
+ await db.execute("PRAGMA journal_mode = WAL")
391
+ await db.commit()
392
+
393
+ logger.info(f"Metadata database v2 initialized: {self.db_path}")
394
+
395
+ async def get_or_create_environment(self, base_url: str) -> int:
396
+ """Get or create environment ID
397
+
398
+ Args:
399
+ base_url: Environment base URL
400
+
401
+ Returns:
402
+ Environment ID
403
+ """
404
+ async with aiosqlite.connect(self.db_path) as db:
405
+ # Try to find existing environment
406
+ cursor = await db.execute(
407
+ "SELECT id FROM metadata_environments WHERE base_url = ?", (base_url,)
408
+ )
409
+
410
+ row = await cursor.fetchone()
411
+ if row:
412
+ return row[0]
413
+
414
+ # Create new environment
415
+ environment_name = self._extract_environment_name(base_url)
416
+ cursor = await db.execute(
417
+ """INSERT INTO metadata_environments (base_url, environment_name)
418
+ VALUES (?, ?)""",
419
+ (base_url, environment_name),
420
+ )
421
+
422
+ environment_id = cursor.lastrowid
423
+ await db.commit()
424
+
425
+ logger.info(f"Created environment {environment_id}: {environment_name}")
426
+ return environment_id
427
+
428
+ def _extract_environment_name(self, base_url: str) -> str:
429
+ """Extract environment name from URL
430
+
431
+ Args:
432
+ base_url: Full environment URL
433
+
434
+ Returns:
435
+ Extracted environment name
436
+ """
437
+ from urllib.parse import urlparse
438
+
439
+ parsed = urlparse(base_url)
440
+ hostname = parsed.hostname or base_url
441
+ return hostname.split(".")[0] if "." in hostname else hostname
442
+
443
+ async def get_global_version_metadata_counts(
444
+ self, global_version_id: int
445
+ ) -> Dict[str, int]:
446
+ """Get metadata counts for a global version
447
+
448
+ Args:
449
+ global_version_id: Global version ID to get counts for
450
+
451
+ Returns:
452
+ Dictionary with counts for each metadata type
453
+ """
454
+ async with aiosqlite.connect(self.db_path) as db:
455
+ counts = {}
456
+
457
+ tables = [
458
+ ("data_entities", "entities"),
459
+ ("public_entities", "public_entities"),
460
+ ("entity_properties", "properties"),
461
+ ("entity_actions", "actions"),
462
+ ("enumerations", "enumerations"),
463
+ ("labels_cache", "labels"),
464
+ ]
465
+
466
+ for table, key in tables:
467
+ cursor = await db.execute(
468
+ f"SELECT COUNT(*) FROM {table} WHERE global_version_id = ?",
469
+ (global_version_id,),
470
+ )
471
+ counts[key] = (await cursor.fetchone())[0]
472
+
473
+ return counts
474
+
475
+ async def get_database_statistics(self) -> Dict[str, Any]:
476
+ """Get comprehensive database statistics
477
+
478
+ Returns:
479
+ Dictionary with database statistics
480
+ """
481
+ async with aiosqlite.connect(self.db_path) as db:
482
+ stats = {}
483
+
484
+ # Basic table counts
485
+ tables = [
486
+ "metadata_environments",
487
+ "global_versions",
488
+ "environment_versions",
489
+ "global_version_modules",
490
+ "metadata_versions",
491
+ "data_entities",
492
+ "public_entities",
493
+ "entity_properties",
494
+ "navigation_properties",
495
+ "entity_actions",
496
+ "enumerations",
497
+ "labels_cache",
498
+ ]
499
+
500
+ for table in tables:
501
+ cursor = await db.execute(f"SELECT COUNT(*) FROM {table}")
502
+ stats[f"{table}_count"] = (await cursor.fetchone())[0]
503
+
504
+ # Global version statistics
505
+ cursor = await db.execute(
506
+ """SELECT
507
+ COUNT(*) as total_versions,
508
+ SUM(reference_count) as total_references,
509
+ AVG(reference_count) as avg_references,
510
+ MAX(reference_count) as max_references
511
+ FROM global_versions"""
512
+ )
513
+ version_stats = await cursor.fetchone()
514
+ stats["version_statistics"] = {
515
+ "total_versions": version_stats[0],
516
+ "total_references": version_stats[1] or 0,
517
+ "avg_references": round(version_stats[2] or 0, 2),
518
+ "max_references": version_stats[3] or 0,
519
+ }
520
+
521
+ # Environment statistics
522
+ cursor = await db.execute(
523
+ """SELECT
524
+ COUNT(DISTINCT me.id) as total_environments,
525
+ COUNT(DISTINCT ev.global_version_id) as linked_versions
526
+ FROM metadata_environments me
527
+ LEFT JOIN environment_versions ev ON me.id = ev.environment_id
528
+ WHERE ev.is_active = 1"""
529
+ )
530
+ env_stats = await cursor.fetchone()
531
+ stats["environment_statistics"] = {
532
+ "total_environments": env_stats[0],
533
+ "linked_versions": env_stats[1] or 0,
534
+ }
535
+
536
+ # Database file size
537
+ try:
538
+ db_size = self.db_path.stat().st_size
539
+ stats["database_size_bytes"] = db_size
540
+ stats["database_size_mb"] = round(db_size / (1024 * 1024), 2)
541
+ except Exception:
542
+ stats["database_size_bytes"] = None
543
+ stats["database_size_mb"] = None
544
+
545
+ return stats
546
+
547
+ async def vacuum_database(self) -> bool:
548
+ """Vacuum database to reclaim space
549
+
550
+ Returns:
551
+ True if successful, False otherwise
552
+ """
553
+ try:
554
+ async with aiosqlite.connect(self.db_path) as db:
555
+ await db.execute("VACUUM")
556
+ await db.commit()
557
+ logger.info("Database vacuum completed successfully")
558
+ return True
559
+ except Exception as e:
560
+ logger.error(f"Database vacuum failed: {e}")
561
+ return False
562
+
563
+ async def check_database_integrity(self) -> Dict[str, Any]:
564
+ """Check database integrity
565
+
566
+ Returns:
567
+ Dictionary with integrity check results
568
+ """
569
+ async with aiosqlite.connect(self.db_path) as db:
570
+ # Run integrity check
571
+ cursor = await db.execute("PRAGMA integrity_check")
572
+ integrity_result = await cursor.fetchone()
573
+
574
+ # Run foreign key check
575
+ cursor = await db.execute("PRAGMA foreign_key_check")
576
+ foreign_key_issues = await cursor.fetchall()
577
+
578
+ return {
579
+ "integrity_ok": integrity_result[0] == "ok",
580
+ "integrity_message": integrity_result[0],
581
+ "foreign_key_issues": len(foreign_key_issues),
582
+ "foreign_key_details": foreign_key_issues[
583
+ :10
584
+ ], # Limit to first 10 issues
585
+ }