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.
- d365fo_client/__init__.py +305 -0
- d365fo_client/auth.py +93 -0
- d365fo_client/cli.py +700 -0
- d365fo_client/client.py +1454 -0
- d365fo_client/config.py +304 -0
- d365fo_client/crud.py +200 -0
- d365fo_client/exceptions.py +49 -0
- d365fo_client/labels.py +528 -0
- d365fo_client/main.py +502 -0
- d365fo_client/mcp/__init__.py +16 -0
- d365fo_client/mcp/client_manager.py +276 -0
- d365fo_client/mcp/main.py +98 -0
- d365fo_client/mcp/models.py +371 -0
- d365fo_client/mcp/prompts/__init__.py +43 -0
- d365fo_client/mcp/prompts/action_execution.py +480 -0
- d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
- d365fo_client/mcp/resources/__init__.py +15 -0
- d365fo_client/mcp/resources/database_handler.py +555 -0
- d365fo_client/mcp/resources/entity_handler.py +176 -0
- d365fo_client/mcp/resources/environment_handler.py +132 -0
- d365fo_client/mcp/resources/metadata_handler.py +283 -0
- d365fo_client/mcp/resources/query_handler.py +135 -0
- d365fo_client/mcp/server.py +432 -0
- d365fo_client/mcp/tools/__init__.py +17 -0
- d365fo_client/mcp/tools/connection_tools.py +175 -0
- d365fo_client/mcp/tools/crud_tools.py +579 -0
- d365fo_client/mcp/tools/database_tools.py +813 -0
- d365fo_client/mcp/tools/label_tools.py +189 -0
- d365fo_client/mcp/tools/metadata_tools.py +766 -0
- d365fo_client/mcp/tools/profile_tools.py +706 -0
- d365fo_client/metadata_api.py +793 -0
- d365fo_client/metadata_v2/__init__.py +59 -0
- d365fo_client/metadata_v2/cache_v2.py +1372 -0
- d365fo_client/metadata_v2/database_v2.py +585 -0
- d365fo_client/metadata_v2/global_version_manager.py +573 -0
- d365fo_client/metadata_v2/search_engine_v2.py +423 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
- d365fo_client/metadata_v2/version_detector.py +439 -0
- d365fo_client/models.py +862 -0
- d365fo_client/output.py +181 -0
- d365fo_client/profile_manager.py +342 -0
- d365fo_client/profiles.py +178 -0
- d365fo_client/query.py +162 -0
- d365fo_client/session.py +60 -0
- d365fo_client/utils.py +196 -0
- d365fo_client-0.1.0.dist-info/METADATA +1084 -0
- d365fo_client-0.1.0.dist-info/RECORD +51 -0
- d365fo_client-0.1.0.dist-info/WHEEL +5 -0
- d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
- d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- d365fo_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,423 @@
|
|
1
|
+
"""Version-aware metadata search engine for MetadataCacheV2."""
|
2
|
+
|
3
|
+
import hashlib
|
4
|
+
import logging
|
5
|
+
import threading
|
6
|
+
import time
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
8
|
+
|
9
|
+
import aiosqlite
|
10
|
+
|
11
|
+
from ..exceptions import MetadataError
|
12
|
+
from ..models import SearchQuery, SearchResult, SearchResults
|
13
|
+
|
14
|
+
if TYPE_CHECKING:
|
15
|
+
from .cache_v2 import MetadataCacheV2
|
16
|
+
|
17
|
+
logger = logging.getLogger(__name__)
|
18
|
+
|
19
|
+
|
20
|
+
class VersionAwareSearchEngine:
|
21
|
+
"""Advanced metadata search engine with version awareness and FTS5 support.
|
22
|
+
|
23
|
+
This search engine is designed to work with MetadataCacheV2 and provides:
|
24
|
+
- Version-aware search across multiple environments
|
25
|
+
- FTS5 full-text search capabilities
|
26
|
+
- Pattern-based search for simple queries
|
27
|
+
- Multi-tier caching (memory cache)
|
28
|
+
- Cross-environment search support
|
29
|
+
"""
|
30
|
+
|
31
|
+
def __init__(self, metadata_cache: "MetadataCacheV2"):
|
32
|
+
"""Initialize version-aware search engine.
|
33
|
+
|
34
|
+
Args:
|
35
|
+
metadata_cache: MetadataCacheV2 instance
|
36
|
+
"""
|
37
|
+
self.cache = metadata_cache
|
38
|
+
self._search_cache = {}
|
39
|
+
self._search_cache_lock = threading.RLock()
|
40
|
+
self._cache_ttl_seconds = 300 # 5 minutes cache TTL
|
41
|
+
|
42
|
+
async def rebuild_search_index(self, global_version_id: Optional[int] = None):
|
43
|
+
"""Rebuild the FTS5 search index for a specific version.
|
44
|
+
|
45
|
+
Args:
|
46
|
+
global_version_id: Specific version to rebuild index for.
|
47
|
+
If None, rebuilds for current environment version.
|
48
|
+
"""
|
49
|
+
if not self.cache._environment_id:
|
50
|
+
await self.cache.initialize()
|
51
|
+
|
52
|
+
# Get version to rebuild for
|
53
|
+
if global_version_id is None:
|
54
|
+
# Get current environment's active global version
|
55
|
+
async with aiosqlite.connect(self.cache.db_path) as db:
|
56
|
+
cursor = await db.execute(
|
57
|
+
"""SELECT global_version_id FROM environment_versions
|
58
|
+
WHERE environment_id = ? AND is_active = 1
|
59
|
+
ORDER BY detected_at DESC LIMIT 1""",
|
60
|
+
(self.cache._environment_id,)
|
61
|
+
)
|
62
|
+
row = await cursor.fetchone()
|
63
|
+
if not row:
|
64
|
+
logger.warning("No active version found for current environment")
|
65
|
+
return
|
66
|
+
global_version_id = row[0]
|
67
|
+
|
68
|
+
await self._rebuild_fts_index_for_version(global_version_id)
|
69
|
+
|
70
|
+
async def _rebuild_fts_index_for_version(self, global_version_id: int):
|
71
|
+
"""Rebuild FTS5 index for specific global version."""
|
72
|
+
async with aiosqlite.connect(self.cache.db_path) as db:
|
73
|
+
logger.info(f"Rebuilding FTS5 search index for version {global_version_id}")
|
74
|
+
|
75
|
+
# Clear existing entries for this version
|
76
|
+
await db.execute(
|
77
|
+
"DELETE FROM metadata_search_v2 WHERE global_version_id = ?",
|
78
|
+
(global_version_id,)
|
79
|
+
)
|
80
|
+
|
81
|
+
# Index data entities
|
82
|
+
await db.execute(
|
83
|
+
"""INSERT INTO metadata_search_v2
|
84
|
+
(name, entity_type, description, properties, labels, global_version_id, entity_id)
|
85
|
+
SELECT
|
86
|
+
de.name,
|
87
|
+
'data_entity',
|
88
|
+
COALESCE(de.label_text, de.label_id, de.name),
|
89
|
+
de.name || ' ' || COALESCE(de.public_entity_name, '') || ' ' || COALESCE(de.public_collection_name, ''),
|
90
|
+
COALESCE(de.label_text, ''),
|
91
|
+
de.global_version_id,
|
92
|
+
de.id
|
93
|
+
FROM data_entities de
|
94
|
+
WHERE de.global_version_id = ?""",
|
95
|
+
(global_version_id,)
|
96
|
+
)
|
97
|
+
|
98
|
+
# Index public entities
|
99
|
+
await db.execute(
|
100
|
+
"""INSERT INTO metadata_search_v2
|
101
|
+
(name, entity_type, description, properties, labels, global_version_id, entity_id)
|
102
|
+
SELECT
|
103
|
+
pe.name,
|
104
|
+
'public_entity',
|
105
|
+
COALESCE(pe.label_text, pe.label_id, pe.name),
|
106
|
+
pe.name || ' ' || COALESCE(pe.entity_set_name, ''),
|
107
|
+
COALESCE(pe.label_text, ''),
|
108
|
+
pe.global_version_id,
|
109
|
+
pe.id
|
110
|
+
FROM public_entities pe
|
111
|
+
WHERE pe.global_version_id = ?""",
|
112
|
+
(global_version_id,)
|
113
|
+
)
|
114
|
+
|
115
|
+
# Index enumerations
|
116
|
+
await db.execute(
|
117
|
+
"""INSERT INTO metadata_search_v2
|
118
|
+
(name, entity_type, description, properties, labels, global_version_id, entity_id)
|
119
|
+
SELECT
|
120
|
+
e.name,
|
121
|
+
'enumeration',
|
122
|
+
COALESCE(e.label_text, e.label_id, e.name),
|
123
|
+
e.name,
|
124
|
+
COALESCE(e.label_text, ''),
|
125
|
+
e.global_version_id,
|
126
|
+
e.id
|
127
|
+
FROM enumerations e
|
128
|
+
WHERE e.global_version_id = ?""",
|
129
|
+
(global_version_id,)
|
130
|
+
)
|
131
|
+
|
132
|
+
await db.commit()
|
133
|
+
logger.info(f"FTS5 search index rebuilt for version {global_version_id}")
|
134
|
+
|
135
|
+
async def search(self, query: SearchQuery) -> SearchResults:
|
136
|
+
"""Execute version-aware metadata search.
|
137
|
+
|
138
|
+
Args:
|
139
|
+
query: Search query parameters
|
140
|
+
|
141
|
+
Returns:
|
142
|
+
Search results with version awareness
|
143
|
+
"""
|
144
|
+
start_time = time.time()
|
145
|
+
|
146
|
+
# Build cache key
|
147
|
+
cache_key = self._build_search_cache_key(query)
|
148
|
+
|
149
|
+
# Check cache
|
150
|
+
with self._search_cache_lock:
|
151
|
+
cached = self._search_cache.get(cache_key)
|
152
|
+
if cached and time.time() - cached["timestamp"] < self._cache_ttl_seconds:
|
153
|
+
cached["results"].cache_hit = True
|
154
|
+
cached["results"].query_time_ms = (time.time() - start_time) * 1000
|
155
|
+
return cached["results"]
|
156
|
+
|
157
|
+
# Execute search
|
158
|
+
if query.use_fulltext:
|
159
|
+
results = await self._fts_search(query)
|
160
|
+
else:
|
161
|
+
results = await self._pattern_search(query)
|
162
|
+
|
163
|
+
# Calculate timing
|
164
|
+
results.query_time_ms = (time.time() - start_time) * 1000
|
165
|
+
results.cache_hit = False
|
166
|
+
|
167
|
+
# Cache results
|
168
|
+
with self._search_cache_lock:
|
169
|
+
self._search_cache[cache_key] = {
|
170
|
+
"results": results,
|
171
|
+
"timestamp": time.time(),
|
172
|
+
}
|
173
|
+
|
174
|
+
# Limit cache size
|
175
|
+
if len(self._search_cache) > 100:
|
176
|
+
oldest_key = min(
|
177
|
+
self._search_cache.keys(),
|
178
|
+
key=lambda k: self._search_cache[k]["timestamp"],
|
179
|
+
)
|
180
|
+
del self._search_cache[oldest_key]
|
181
|
+
|
182
|
+
return results
|
183
|
+
|
184
|
+
def _build_search_cache_key(self, query: SearchQuery) -> str:
|
185
|
+
"""Build cache key for search query."""
|
186
|
+
key_parts = [
|
187
|
+
str(self.cache._environment_id or ""),
|
188
|
+
query.text,
|
189
|
+
"|".join(query.entity_types or []),
|
190
|
+
str(query.limit),
|
191
|
+
str(query.offset),
|
192
|
+
str(query.use_fulltext),
|
193
|
+
str(query.include_properties),
|
194
|
+
str(query.include_actions),
|
195
|
+
]
|
196
|
+
|
197
|
+
if query.filters:
|
198
|
+
for k, v in sorted(query.filters.items()):
|
199
|
+
key_parts.append(f"{k}:{v}")
|
200
|
+
|
201
|
+
return hashlib.md5("|".join(key_parts).encode()).hexdigest()
|
202
|
+
|
203
|
+
async def _fts_search(self, query: SearchQuery) -> SearchResults:
|
204
|
+
"""Full-text search using FTS5 with version awareness."""
|
205
|
+
if not self.cache._environment_id:
|
206
|
+
return SearchResults(results=[], total_count=0)
|
207
|
+
|
208
|
+
search_query = self._build_fts_query(query.text)
|
209
|
+
|
210
|
+
async with aiosqlite.connect(self.cache.db_path) as db:
|
211
|
+
# Get current environment's active global version
|
212
|
+
cursor = await db.execute(
|
213
|
+
"""SELECT global_version_id FROM environment_versions
|
214
|
+
WHERE environment_id = ? AND is_active = 1
|
215
|
+
ORDER BY detected_at DESC LIMIT 1""",
|
216
|
+
(self.cache._environment_id,)
|
217
|
+
)
|
218
|
+
version_row = await cursor.fetchone()
|
219
|
+
|
220
|
+
if not version_row:
|
221
|
+
logger.warning("No active version found for FTS search")
|
222
|
+
return SearchResults(results=[], total_count=0)
|
223
|
+
|
224
|
+
global_version_id = version_row[0]
|
225
|
+
|
226
|
+
# Execute FTS5 search
|
227
|
+
sql = """
|
228
|
+
SELECT name, entity_type, description, labels,
|
229
|
+
bm25(metadata_search_v2) as relevance,
|
230
|
+
snippet(metadata_search_v2, 0, '<mark>', '</mark>', '...', 32) as snippet
|
231
|
+
FROM metadata_search_v2
|
232
|
+
WHERE metadata_search_v2 MATCH ? AND global_version_id = ?
|
233
|
+
"""
|
234
|
+
|
235
|
+
params = [search_query, global_version_id]
|
236
|
+
|
237
|
+
# Add entity type filter
|
238
|
+
if query.entity_types:
|
239
|
+
placeholders = ",".join("?" * len(query.entity_types))
|
240
|
+
sql += f" AND entity_type IN ({placeholders})"
|
241
|
+
params.extend(query.entity_types)
|
242
|
+
|
243
|
+
sql += " ORDER BY bm25(metadata_search_v2) LIMIT ? OFFSET ?"
|
244
|
+
params.extend([query.limit, query.offset])
|
245
|
+
|
246
|
+
cursor = await db.execute(sql, params)
|
247
|
+
rows = await cursor.fetchall()
|
248
|
+
|
249
|
+
results = []
|
250
|
+
for row in rows:
|
251
|
+
result = SearchResult(
|
252
|
+
name=row[0],
|
253
|
+
entity_type=row[1],
|
254
|
+
entity_set_name="", # Will be populated if needed
|
255
|
+
description=row[2],
|
256
|
+
relevance=row[4],
|
257
|
+
snippet=row[5],
|
258
|
+
)
|
259
|
+
results.append(result)
|
260
|
+
|
261
|
+
# Get total count
|
262
|
+
count_sql = """
|
263
|
+
SELECT COUNT(*) FROM metadata_search_v2
|
264
|
+
WHERE metadata_search_v2 MATCH ? AND global_version_id = ?
|
265
|
+
"""
|
266
|
+
count_params = [search_query, global_version_id]
|
267
|
+
|
268
|
+
if query.entity_types:
|
269
|
+
placeholders = ",".join("?" * len(query.entity_types))
|
270
|
+
count_sql += f" AND entity_type IN ({placeholders})"
|
271
|
+
count_params.extend(query.entity_types)
|
272
|
+
|
273
|
+
count_cursor = await db.execute(count_sql, count_params)
|
274
|
+
total_count = (await count_cursor.fetchone())[0]
|
275
|
+
|
276
|
+
return SearchResults(results=results, total_count=total_count)
|
277
|
+
|
278
|
+
async def _pattern_search(self, query: SearchQuery) -> SearchResults:
|
279
|
+
"""Pattern-based search for simple queries with version awareness."""
|
280
|
+
if not self.cache._environment_id:
|
281
|
+
return SearchResults(results=[], total_count=0)
|
282
|
+
|
283
|
+
pattern = f"%{query.text.lower()}%"
|
284
|
+
|
285
|
+
async with aiosqlite.connect(self.cache.db_path) as db:
|
286
|
+
# Get current environment's active global version
|
287
|
+
cursor = await db.execute(
|
288
|
+
"""SELECT global_version_id FROM environment_versions
|
289
|
+
WHERE environment_id = ? AND is_active = 1
|
290
|
+
ORDER BY detected_at DESC LIMIT 1""",
|
291
|
+
(self.cache._environment_id,)
|
292
|
+
)
|
293
|
+
version_row = await cursor.fetchone()
|
294
|
+
|
295
|
+
if not version_row:
|
296
|
+
logger.warning("No active version found for pattern search")
|
297
|
+
return SearchResults(results=[], total_count=0)
|
298
|
+
|
299
|
+
global_version_id = version_row[0]
|
300
|
+
|
301
|
+
# Search across multiple entity types
|
302
|
+
union_queries = []
|
303
|
+
params = []
|
304
|
+
|
305
|
+
if not query.entity_types or "data_entity" in query.entity_types:
|
306
|
+
union_queries.append(
|
307
|
+
"""
|
308
|
+
SELECT de.name as entity_name, 'data_entity' as entity_type,
|
309
|
+
de.public_collection_name as entity_set_name,
|
310
|
+
COALESCE(de.label_text, de.label_id) as description,
|
311
|
+
0.5 as relevance,
|
312
|
+
de.name as snippet
|
313
|
+
FROM data_entities de
|
314
|
+
WHERE LOWER(de.name) LIKE ? AND de.global_version_id = ?
|
315
|
+
"""
|
316
|
+
)
|
317
|
+
params.extend([pattern, global_version_id])
|
318
|
+
|
319
|
+
if not query.entity_types or "public_entity" in query.entity_types:
|
320
|
+
union_queries.append(
|
321
|
+
"""
|
322
|
+
SELECT pe.name as entity_name, 'public_entity' as entity_type,
|
323
|
+
pe.entity_set_name,
|
324
|
+
COALESCE(pe.label_text, pe.label_id) as description,
|
325
|
+
0.5 as relevance,
|
326
|
+
pe.name as snippet
|
327
|
+
FROM public_entities pe
|
328
|
+
WHERE LOWER(pe.name) LIKE ? AND pe.global_version_id = ?
|
329
|
+
"""
|
330
|
+
)
|
331
|
+
params.extend([pattern, global_version_id])
|
332
|
+
|
333
|
+
if not query.entity_types or "enumeration" in query.entity_types:
|
334
|
+
union_queries.append(
|
335
|
+
"""
|
336
|
+
SELECT e.name as entity_name, 'enumeration' as entity_type,
|
337
|
+
e.name as entity_set_name,
|
338
|
+
COALESCE(e.label_text, e.label_id) as description,
|
339
|
+
0.5 as relevance,
|
340
|
+
e.name as snippet
|
341
|
+
FROM enumerations e
|
342
|
+
WHERE LOWER(e.name) LIKE ? AND e.global_version_id = ?
|
343
|
+
"""
|
344
|
+
)
|
345
|
+
params.extend([pattern, global_version_id])
|
346
|
+
|
347
|
+
if not union_queries:
|
348
|
+
return SearchResults(results=[], total_count=0)
|
349
|
+
|
350
|
+
sql = " UNION ALL ".join(union_queries)
|
351
|
+
sql += " ORDER BY relevance DESC, entity_name LIMIT ? OFFSET ?"
|
352
|
+
params.extend([query.limit, query.offset])
|
353
|
+
|
354
|
+
cursor = await db.execute(sql, params)
|
355
|
+
rows = await cursor.fetchall()
|
356
|
+
|
357
|
+
results = []
|
358
|
+
for row in rows:
|
359
|
+
result = SearchResult(
|
360
|
+
name=row[0],
|
361
|
+
entity_type=row[1],
|
362
|
+
entity_set_name=row[2] or "",
|
363
|
+
description=row[3] or "",
|
364
|
+
relevance=row[4],
|
365
|
+
snippet=row[5],
|
366
|
+
)
|
367
|
+
results.append(result)
|
368
|
+
|
369
|
+
return SearchResults(
|
370
|
+
results=results,
|
371
|
+
total_count=len(results), # Simplified count for pattern search
|
372
|
+
)
|
373
|
+
|
374
|
+
def _build_fts_query(self, text: str) -> str:
|
375
|
+
"""Build FTS5 query from user input."""
|
376
|
+
# Simple FTS query building - can be enhanced with more sophisticated parsing
|
377
|
+
# Handle basic operators and quoted phrases
|
378
|
+
|
379
|
+
# If already quoted or contains operators, use as-is
|
380
|
+
if '"' in text or any(op in text for op in ["AND", "OR", "NOT", "*"]):
|
381
|
+
return text
|
382
|
+
|
383
|
+
# For simple terms, create a phrase query with prefix matching
|
384
|
+
terms = text.strip().split()
|
385
|
+
if len(terms) == 1:
|
386
|
+
return f'"{terms[0]}"*'
|
387
|
+
else:
|
388
|
+
return f'"{" ".join(terms)}"'
|
389
|
+
|
390
|
+
async def search_entities_fts(self, search_text: str, entity_types: Optional[List[str]] = None, limit: int = 10) -> List[Dict[str, Any]]:
|
391
|
+
"""Simplified FTS search for entities (for MCP compatibility).
|
392
|
+
|
393
|
+
Args:
|
394
|
+
search_text: Text to search for
|
395
|
+
entity_types: Optional list of entity types to filter by
|
396
|
+
limit: Maximum number of results
|
397
|
+
|
398
|
+
Returns:
|
399
|
+
List of entity dictionaries
|
400
|
+
"""
|
401
|
+
query = SearchQuery(
|
402
|
+
text=search_text,
|
403
|
+
entity_types=entity_types or ["data_entity"],
|
404
|
+
limit=limit,
|
405
|
+
use_fulltext=True
|
406
|
+
)
|
407
|
+
|
408
|
+
results = await self.search(query)
|
409
|
+
|
410
|
+
# Convert to dictionary format for compatibility
|
411
|
+
entities = []
|
412
|
+
for result in results.results:
|
413
|
+
entity_dict = {
|
414
|
+
"name": result.name,
|
415
|
+
"entity_type": result.entity_type,
|
416
|
+
"entity_set_name": result.entity_set_name,
|
417
|
+
"description": result.description,
|
418
|
+
"relevance": result.relevance,
|
419
|
+
"snippet": result.snippet
|
420
|
+
}
|
421
|
+
entities.append(entity_dict)
|
422
|
+
|
423
|
+
return entities
|