query_parser.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. """
  2. Query Parser for Satellite Access
  3. This module implements the query parsing system for advanced satellite access patterns
  4. as specified in CLAUDE.md. It supports query-based satellite selection using expressions like:
  5. - "status=connected"
  6. - "room=kitchen"
  7. - "status=connected,room=kitchen"
  8. - "capability=wakeword_detection"
  9. The query parser supports various operators and can be extended for complex filtering.
  10. """
  11. import re
  12. from typing import List, Dict, Any, Optional, Union, Callable, Set
  13. from enum import Enum
  14. from dataclasses import dataclass
  15. from ..satellites.satellite import Satellite, SatelliteStatus, SatelliteCapability
  16. def pprint(message: str) -> None:
  17. """Query parser logging function."""
  18. print(f"[QUERY_PARSER] {message}")
  19. class QueryOperator(Enum):
  20. """Supported query operators."""
  21. EQUALS = "="
  22. NOT_EQUALS = "!="
  23. CONTAINS = "contains"
  24. NOT_CONTAINS = "!contains"
  25. STARTS_WITH = "starts_with"
  26. ENDS_WITH = "ends_with"
  27. GREATER_THAN = ">"
  28. LESS_THAN = "<"
  29. GREATER_EQUAL = ">="
  30. LESS_EQUAL = "<="
  31. IN = "in"
  32. NOT_IN = "!in"
  33. REGEX = "regex"
  34. EXISTS = "exists"
  35. NOT_EXISTS = "!exists"
  36. @dataclass
  37. class QueryCondition:
  38. """Represents a single query condition."""
  39. field: str
  40. operator: QueryOperator
  41. value: Any
  42. case_sensitive: bool = False
  43. def __str__(self) -> str:
  44. """String representation of the condition."""
  45. return f"{self.field} {self.operator.value} {self.value}"
  46. def __repr__(self) -> str:
  47. """Detailed representation of the condition."""
  48. return f"QueryCondition(field='{self.field}', operator={self.operator}, value={self.value})"
  49. class QueryError(Exception):
  50. """Base exception for query parsing errors."""
  51. pass
  52. class QuerySyntaxError(QueryError):
  53. """Raised when query syntax is invalid."""
  54. pass
  55. class QueryValidationError(QueryError):
  56. """Raised when query validation fails."""
  57. pass
  58. class QueryParser:
  59. """
  60. Advanced query parser for satellite filtering and selection.
  61. Supports complex query expressions with multiple conditions, operators,
  62. and field types. Provides case-insensitive matching by default as
  63. specified in CLAUDE.md.
  64. """
  65. # Supported fields and their types
  66. SUPPORTED_FIELDS = {
  67. "status": str,
  68. "room": str,
  69. "room_id": str,
  70. "alias": str,
  71. "alias_name": str,
  72. "ip": str,
  73. "ip_address": str,
  74. "mac": str,
  75. "mac_address": str,
  76. "version": str,
  77. "capability": str,
  78. "capabilities": list,
  79. "uptime": float,
  80. "connection_count": int,
  81. "message_count": int,
  82. "error_count": int,
  83. "has_errors": bool,
  84. "last_seen": str,
  85. "connected_at": str,
  86. }
  87. # Field aliases for convenience
  88. FIELD_ALIASES = {
  89. "room": "room_id",
  90. "alias": "alias_name",
  91. "ip": "ip_address",
  92. "mac": "mac_address",
  93. }
  94. # Special value processors
  95. VALUE_PROCESSORS = {
  96. "status": lambda x: SatelliteStatus(x.lower()).value if isinstance(x, str) else x,
  97. "capability": lambda x: SatelliteCapability(x.lower()).value if isinstance(x, str) else x,
  98. "has_errors": lambda x: str(x).lower() in ("true", "1", "yes", "on"),
  99. }
  100. def __init__(self, case_sensitive: bool = False):
  101. """
  102. Initialize the query parser.
  103. Args:
  104. case_sensitive: Whether string comparisons are case-sensitive
  105. """
  106. self._case_sensitive = case_sensitive
  107. self._compiled_regex_cache: Dict[str, re.Pattern] = {}
  108. def parse_query(self, query: str) -> List[QueryCondition]:
  109. """
  110. Parse a query string into a list of conditions.
  111. Args:
  112. query: Query string (e.g., "status=connected,room=kitchen")
  113. Returns:
  114. List[QueryCondition]: Parsed query conditions
  115. Raises:
  116. QuerySyntaxError: If query syntax is invalid
  117. """
  118. if not query or not query.strip():
  119. return []
  120. query = query.strip()
  121. pprint(f"Parsing query: '{query}'")
  122. try:
  123. # Split by comma for multiple conditions
  124. condition_strings = [c.strip() for c in query.split(",")]
  125. conditions = []
  126. for condition_str in condition_strings:
  127. if not condition_str:
  128. continue
  129. condition = self._parse_single_condition(condition_str)
  130. conditions.append(condition)
  131. pprint(f"Parsed {len(conditions)} conditions")
  132. return conditions
  133. except Exception as e:
  134. raise QuerySyntaxError(f"Invalid query syntax: {e}") from e
  135. def _parse_single_condition(self, condition_str: str) -> QueryCondition:
  136. """Parse a single condition string."""
  137. # Define operator patterns (order matters - longer operators first)
  138. operator_patterns = [
  139. (r'!=', QueryOperator.NOT_EQUALS),
  140. (r'>=', QueryOperator.GREATER_EQUAL),
  141. (r'<=', QueryOperator.LESS_EQUAL),
  142. (r'>', QueryOperator.GREATER_THAN),
  143. (r'<', QueryOperator.LESS_THAN),
  144. (r'=', QueryOperator.EQUALS),
  145. (r'\bcontains\b', QueryOperator.CONTAINS),
  146. (r'!contains\b', QueryOperator.NOT_CONTAINS),
  147. (r'\bstarts_with\b', QueryOperator.STARTS_WITH),
  148. (r'\bends_with\b', QueryOperator.ENDS_WITH),
  149. (r'\bin\b', QueryOperator.IN),
  150. (r'!in\b', QueryOperator.NOT_IN),
  151. (r'\bregex\b', QueryOperator.REGEX),
  152. (r'\bexists\b', QueryOperator.EXISTS),
  153. (r'!exists\b', QueryOperator.NOT_EXISTS),
  154. ]
  155. # Try to match operators
  156. for pattern, operator in operator_patterns:
  157. match = re.search(pattern, condition_str)
  158. if match:
  159. field = condition_str[:match.start()].strip()
  160. value_str = condition_str[match.end():].strip()
  161. # Handle existence checks (no value needed)
  162. if operator in [QueryOperator.EXISTS, QueryOperator.NOT_EXISTS]:
  163. value_str = "true"
  164. # Validate field
  165. field = self._validate_and_normalize_field(field)
  166. # Parse value
  167. value = self._parse_value(field, value_str, operator)
  168. return QueryCondition(
  169. field=field,
  170. operator=operator,
  171. value=value,
  172. case_sensitive=self._case_sensitive
  173. )
  174. raise QuerySyntaxError(f"No valid operator found in condition: '{condition_str}'")
  175. def _validate_and_normalize_field(self, field: str) -> str:
  176. """Validate and normalize a field name."""
  177. field = field.lower().strip()
  178. # Apply aliases
  179. if field in self.FIELD_ALIASES:
  180. field = self.FIELD_ALIASES[field]
  181. # Check if field is supported
  182. if field not in self.SUPPORTED_FIELDS:
  183. raise QueryValidationError(
  184. f"Unsupported field: '{field}'. "
  185. f"Supported fields: {', '.join(self.SUPPORTED_FIELDS.keys())}"
  186. )
  187. return field
  188. def _parse_value(self, field: str, value_str: str, operator: QueryOperator) -> Any:
  189. """Parse a value string based on the field type and operator."""
  190. if not value_str and operator not in [QueryOperator.EXISTS, QueryOperator.NOT_EXISTS]:
  191. raise QuerySyntaxError(f"Empty value for field '{field}'")
  192. # Remove quotes if present
  193. if value_str.startswith('"') and value_str.endswith('"'):
  194. value_str = value_str[1:-1]
  195. elif value_str.startswith("'") and value_str.endswith("'"):
  196. value_str = value_str[1:-1]
  197. # Apply field-specific processing
  198. if field in self.VALUE_PROCESSORS:
  199. try:
  200. return self.VALUE_PROCESSORS[field](value_str)
  201. except Exception as e:
  202. raise QueryValidationError(f"Invalid value '{value_str}' for field '{field}': {e}")
  203. # Handle list operators (IN, NOT_IN)
  204. if operator in [QueryOperator.IN, QueryOperator.NOT_IN]:
  205. # Parse comma-separated list
  206. if value_str.startswith('[') and value_str.endswith(']'):
  207. value_str = value_str[1:-1]
  208. values = [v.strip().strip('"\'') for v in value_str.split(',')]
  209. return values
  210. # Type conversion based on field
  211. expected_type = self.SUPPORTED_FIELDS.get(field, str)
  212. if expected_type == str:
  213. return value_str if self._case_sensitive else value_str.lower()
  214. elif expected_type == int:
  215. try:
  216. return int(value_str)
  217. except ValueError:
  218. raise QueryValidationError(f"Invalid integer value '{value_str}' for field '{field}'")
  219. elif expected_type == float:
  220. try:
  221. return float(value_str)
  222. except ValueError:
  223. raise QueryValidationError(f"Invalid float value '{value_str}' for field '{field}'")
  224. elif expected_type == bool:
  225. return value_str.lower() in ("true", "1", "yes", "on")
  226. else:
  227. return value_str
  228. def filter_satellites(
  229. self,
  230. satellites: List[Satellite],
  231. query: Union[str, List[QueryCondition]]
  232. ) -> List[Satellite]:
  233. """
  234. Filter satellites based on query conditions.
  235. Args:
  236. satellites: List of satellites to filter
  237. query: Query string or list of conditions
  238. Returns:
  239. List[Satellite]: Filtered satellites
  240. """
  241. if isinstance(query, str):
  242. conditions = self.parse_query(query)
  243. else:
  244. conditions = query
  245. if not conditions:
  246. return satellites.copy()
  247. pprint(f"Filtering {len(satellites)} satellites with {len(conditions)} conditions")
  248. filtered = []
  249. for satellite in satellites:
  250. if self._matches_all_conditions(satellite, conditions):
  251. filtered.append(satellite)
  252. pprint(f"Filter result: {len(filtered)} satellites matched")
  253. return filtered
  254. def _matches_all_conditions(self, satellite: Satellite, conditions: List[QueryCondition]) -> bool:
  255. """Check if a satellite matches all conditions."""
  256. for condition in conditions:
  257. if not self._matches_condition(satellite, condition):
  258. return False
  259. return True
  260. def _matches_condition(self, satellite: Satellite, condition: QueryCondition) -> bool:
  261. """Check if a satellite matches a single condition."""
  262. try:
  263. # Get the field value from the satellite
  264. field_value = self._get_field_value(satellite, condition.field)
  265. # Apply the operator
  266. return self._apply_operator(field_value, condition.operator, condition.value, condition.case_sensitive)
  267. except Exception as e:
  268. pprint(f"Error matching condition {condition} on satellite {satellite.alias_name}: {e}")
  269. return False
  270. def _get_field_value(self, satellite: Satellite, field: str) -> Any:
  271. """Get the value of a field from a satellite."""
  272. # Direct property access
  273. if field == "status":
  274. return satellite.status.value
  275. elif field == "room_id":
  276. return satellite.room_id
  277. elif field == "alias_name":
  278. return satellite.alias_name
  279. elif field == "ip_address":
  280. return satellite.ip_address or ""
  281. elif field == "mac_address":
  282. return satellite.mac_address
  283. elif field == "version":
  284. return satellite.version
  285. elif field == "uptime":
  286. return satellite.uptime
  287. elif field == "capabilities":
  288. return [cap.value for cap in satellite.capabilities]
  289. elif field == "capability":
  290. # Special handling for single capability checks
  291. return [cap.value for cap in satellite.capabilities]
  292. elif field == "has_errors":
  293. status = satellite.get_status()
  294. return status["statistics"]["error_count"] > 0
  295. elif field == "connection_count":
  296. status = satellite.get_status()
  297. return status["statistics"]["connection_count"]
  298. elif field == "message_count":
  299. status = satellite.get_status()
  300. return status["statistics"]["message_count"]
  301. elif field == "error_count":
  302. status = satellite.get_status()
  303. return status["statistics"]["error_count"]
  304. elif field == "last_seen":
  305. return satellite._info.last_seen.isoformat() if satellite._info.last_seen else ""
  306. elif field == "connected_at":
  307. status = satellite.get_status()
  308. return status["connection"]["connected_at"] or ""
  309. # If field not found, return empty string
  310. return ""
  311. def _apply_operator(
  312. self,
  313. field_value: Any,
  314. operator: QueryOperator,
  315. condition_value: Any,
  316. case_sensitive: bool
  317. ) -> bool:
  318. """Apply an operator to compare field value with condition value."""
  319. # Handle None values
  320. if field_value is None:
  321. field_value = ""
  322. # Convert to string for string operations if needed
  323. if operator in [
  324. QueryOperator.CONTAINS, QueryOperator.NOT_CONTAINS,
  325. QueryOperator.STARTS_WITH, QueryOperator.ENDS_WITH,
  326. QueryOperator.REGEX
  327. ]:
  328. field_str = str(field_value)
  329. condition_str = str(condition_value)
  330. if not case_sensitive:
  331. field_str = field_str.lower()
  332. condition_str = condition_str.lower()
  333. # Apply operators
  334. if operator == QueryOperator.EQUALS:
  335. # Special handling for lists (like capabilities)
  336. if isinstance(field_value, list):
  337. if not case_sensitive:
  338. return any(str(item).lower() == str(condition_value).lower() for item in field_value)
  339. return condition_value in field_value
  340. elif isinstance(field_value, str) and not case_sensitive:
  341. return field_value.lower() == str(condition_value).lower()
  342. return field_value == condition_value
  343. elif operator == QueryOperator.NOT_EQUALS:
  344. if isinstance(field_value, str) and not case_sensitive:
  345. return field_value.lower() != str(condition_value).lower()
  346. return field_value != condition_value
  347. elif operator == QueryOperator.CONTAINS:
  348. return condition_str in field_str
  349. elif operator == QueryOperator.NOT_CONTAINS:
  350. return condition_str not in field_str
  351. elif operator == QueryOperator.STARTS_WITH:
  352. return field_str.startswith(condition_str)
  353. elif operator == QueryOperator.ENDS_WITH:
  354. return field_str.endswith(condition_str)
  355. elif operator == QueryOperator.GREATER_THAN:
  356. try:
  357. return float(field_value) > float(condition_value)
  358. except (ValueError, TypeError):
  359. return str(field_value) > str(condition_value)
  360. elif operator == QueryOperator.LESS_THAN:
  361. try:
  362. return float(field_value) < float(condition_value)
  363. except (ValueError, TypeError):
  364. return str(field_value) < str(condition_value)
  365. elif operator == QueryOperator.GREATER_EQUAL:
  366. try:
  367. return float(field_value) >= float(condition_value)
  368. except (ValueError, TypeError):
  369. return str(field_value) >= str(condition_value)
  370. elif operator == QueryOperator.LESS_EQUAL:
  371. try:
  372. return float(field_value) <= float(condition_value)
  373. except (ValueError, TypeError):
  374. return str(field_value) <= str(condition_value)
  375. elif operator == QueryOperator.IN:
  376. if isinstance(field_value, list):
  377. # Check if any item in field_value is in condition_value
  378. return any(item in condition_value for item in field_value)
  379. else:
  380. # Check if field_value is in the list of condition values
  381. if not case_sensitive and isinstance(field_value, str):
  382. return any(field_value.lower() == str(cv).lower() for cv in condition_value)
  383. return field_value in condition_value
  384. elif operator == QueryOperator.NOT_IN:
  385. if isinstance(field_value, list):
  386. return not any(item in condition_value for item in field_value)
  387. else:
  388. if not case_sensitive and isinstance(field_value, str):
  389. return not any(field_value.lower() == str(cv).lower() for cv in condition_value)
  390. return field_value not in condition_value
  391. elif operator == QueryOperator.REGEX:
  392. pattern = condition_value
  393. if not case_sensitive:
  394. pattern = f"(?i){pattern}"
  395. # Use cached compiled regex for performance
  396. if pattern not in self._compiled_regex_cache:
  397. try:
  398. self._compiled_regex_cache[pattern] = re.compile(pattern)
  399. except re.error as e:
  400. raise QueryValidationError(f"Invalid regex pattern '{condition_value}': {e}")
  401. regex = self._compiled_regex_cache[pattern]
  402. return bool(regex.search(str(field_value)))
  403. elif operator == QueryOperator.EXISTS:
  404. return field_value is not None and field_value != ""
  405. elif operator == QueryOperator.NOT_EXISTS:
  406. return field_value is None or field_value == ""
  407. else:
  408. raise QueryError(f"Unsupported operator: {operator}")
  409. def get_supported_fields(self) -> Dict[str, type]:
  410. """Get all supported fields and their types."""
  411. return self.SUPPORTED_FIELDS.copy()
  412. def get_field_aliases(self) -> Dict[str, str]:
  413. """Get field aliases mapping."""
  414. return self.FIELD_ALIASES.copy()
  415. def validate_query(self, query: str) -> bool:
  416. """
  417. Validate a query string without executing it.
  418. Args:
  419. query: Query string to validate
  420. Returns:
  421. bool: True if valid, False otherwise
  422. """
  423. try:
  424. self.parse_query(query)
  425. return True
  426. except QueryError:
  427. return False
  428. # Global parser instance for convenience
  429. _default_parser = QueryParser(case_sensitive=False)
  430. def parse_query(query: str) -> List[QueryCondition]:
  431. """
  432. Parse a query string using the default parser.
  433. Args:
  434. query: Query string to parse
  435. Returns:
  436. List[QueryCondition]: Parsed conditions
  437. """
  438. return _default_parser.parse_query(query)
  439. def validate_query(query: str) -> bool:
  440. """
  441. Validate a query string using the default parser.
  442. Args:
  443. query: Query string to validate
  444. Returns:
  445. bool: True if valid, False otherwise
  446. """
  447. return _default_parser.validate_query(query)
  448. def filter_satellites(satellites: List[Satellite], query: str) -> List[Satellite]:
  449. """
  450. Filter satellites using the default parser.
  451. Args:
  452. satellites: List of satellites to filter
  453. query: Query string
  454. Returns:
  455. List[Satellite]: Filtered satellites
  456. """
  457. return _default_parser.filter_satellites(satellites, query)
  458. def create_query_parser(case_sensitive: bool = False) -> QueryParser:
  459. """
  460. Create a new query parser instance.
  461. Args:
  462. case_sensitive: Whether string comparisons are case-sensitive
  463. Returns:
  464. QueryParser: New parser instance
  465. """
  466. return QueryParser(case_sensitive=case_sensitive)
  467. # Example usage and testing functions
  468. def test_query_examples():
  469. """Test some example queries for validation."""
  470. test_queries = [
  471. "status=connected",
  472. "room=kitchen",
  473. "status=connected,room=kitchen",
  474. "capability=wakeword_detection",
  475. "uptime>3600",
  476. "error_count=0",
  477. "alias contains kitchen",
  478. "ip starts_with 192.168",
  479. "status in [connected,connecting]",
  480. "capability exists",
  481. ]
  482. parser = QueryParser()
  483. for query in test_queries:
  484. try:
  485. conditions = parser.parse_query(query)
  486. pprint(f"✓ '{query}' -> {len(conditions)} conditions")
  487. except Exception as e:
  488. pprint(f"✗ '{query}' -> Error: {e}")
  489. if __name__ == "__main__":
  490. # Run tests if module is executed directly
  491. test_query_examples()
  492. # Module exports
  493. __all__ = [
  494. "QueryParser",
  495. "QueryCondition",
  496. "QueryOperator",
  497. "QueryError",
  498. "QuerySyntaxError",
  499. "QueryValidationError",
  500. "parse_query",
  501. "validate_query",
  502. "filter_satellites",
  503. "create_query_parser",
  504. ]