55import json
66import math
77import re
8+ from dataclasses import dataclass
9+ from json .decoder import scanstring
810from typing import Any
911from xml .etree import ElementTree
1012
2325_XML_LIST_CHILDREN = frozenset ({"Item" , "Plan" , "Setting" , "Trigger" , "Worker" })
2426_XML_INTEGER_RE = re .compile (r"^-?\d+$" )
2527_XML_DECIMAL_RE = re .compile (r"^-?(?:\d+\.\d*|\d*\.\d+)(?:[eE][+-]?\d+)?$" )
28+ _JSON_NUMBER_RE = re .compile (r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?" )
29+ _JSON_PLAN_ROLES = frozenset ({"root_plan" , "plan_node" })
30+
31+
32+ @dataclass
33+ class _JsonFrame :
34+ kind : str
35+ role : str
36+ state : str
37+ key : str | None = None
38+ index : int = 0
2639
2740
2841def parse_auto_explain (message : str , * , complete : bool ) -> AutoExplainPlan | None :
@@ -61,7 +74,13 @@ def _parse_plan(plan_text: str) -> tuple[str, str | None, int, bool, str | None]
6174def _parse_json (plan_text : str ) -> tuple [str , str | None , int , bool , str | None ]:
6275 try :
6376 payload : Any = json .loads (plan_text )
64- except (TypeError , ValueError , RecursionError ):
77+ except RecursionError :
78+ metadata = _parse_deep_json_metadata (plan_text )
79+ if metadata is None :
80+ return "json" , None , 0 , False , None
81+ root , node_count , query_text = metadata
82+ return "json" , root , node_count , bool (root and node_count ), query_text
83+ except (TypeError , ValueError ):
6584 return "json" , None , 0 , False , None
6685 if isinstance (payload , list ) and payload :
6786 payload = payload [0 ]
@@ -80,6 +99,148 @@ def _parse_json(plan_text: str) -> tuple[str, str | None, int, bool, str | None]
8099 )
81100
82101
102+ def _parse_deep_json_metadata (plan_text : str ) -> tuple [str | None , int , str | None ] | None :
103+ """Extract bounded plan metadata without recursive JSON object construction."""
104+ frames : list [_JsonFrame ] = []
105+ root_started = False
106+ root_complete = False
107+ root_node_type : str | None = None
108+ node_count = 0
109+ query_text : str | None = None
110+
111+ def start_value (kind : str , value : str | None , role : str ) -> None :
112+ nonlocal node_count , query_text , root_node_type
113+ if kind == "{" :
114+ if role in _JSON_PLAN_ROLES :
115+ node_count += 1
116+ frames .append (_JsonFrame ("object" , role , "key_or_end" ))
117+ return
118+ if kind == "[" :
119+ frames .append (_JsonFrame ("array" , role , "value_or_end" ))
120+ return
121+ if kind not in {"string" , "scalar" }:
122+ raise ValueError ("expected JSON value" )
123+ if kind == "string" and role == "query_text" :
124+ query_text = value
125+ elif kind == "string" and role == "root_node_type" :
126+ root_node_type = value
127+
128+ def object_value_role (frame : _JsonFrame ) -> str :
129+ if frame .role == "payload" :
130+ if frame .key == "Plan" :
131+ return "root_plan"
132+ if frame .key == "Query Text" :
133+ return "query_text"
134+ if frame .role in _JSON_PLAN_ROLES :
135+ if frame .key == "Plans" :
136+ return "plans_array"
137+ if frame .role == "root_plan" and frame .key == "Node Type" :
138+ return "root_node_type"
139+ return "other"
140+
141+ def array_value_role (frame : _JsonFrame ) -> str :
142+ if frame .role == "root_array" and frame .index == 0 :
143+ return "payload"
144+ if frame .role == "plans_array" :
145+ return "plan_node"
146+ return "other"
147+
148+ try :
149+ for kind , value in _iter_json_tokens (plan_text ):
150+ if not frames :
151+ if root_started :
152+ raise ValueError ("trailing JSON content" )
153+ root_started = True
154+ role = "payload" if kind == "{" else "root_array" if kind == "[" else "other"
155+ start_value (kind , value , role )
156+ if not frames :
157+ root_complete = True
158+ continue
159+
160+ frame = frames [- 1 ]
161+ if frame .kind == "object" :
162+ if frame .state in {"key_or_end" , "key" }:
163+ if kind == "}" and frame .state == "key_or_end" :
164+ frames .pop ()
165+ root_complete = not frames
166+ elif kind == "string" :
167+ frame .key = value
168+ frame .state = "colon"
169+ else :
170+ raise ValueError ("expected JSON object key" )
171+ elif frame .state == "colon" :
172+ if kind != ":" :
173+ raise ValueError ("expected colon after JSON object key" )
174+ frame .state = "value"
175+ elif frame .state == "value" :
176+ role = object_value_role (frame )
177+ frame .key = None
178+ frame .state = "comma_or_end"
179+ start_value (kind , value , role )
180+ elif frame .state == "comma_or_end" :
181+ if kind == "}" :
182+ frames .pop ()
183+ root_complete = not frames
184+ elif kind == "," :
185+ frame .state = "key"
186+ else :
187+ raise ValueError ("expected comma or JSON object end" )
188+ continue
189+
190+ if frame .state in {"value_or_end" , "value" }:
191+ if kind == "]" and frame .state == "value_or_end" :
192+ frames .pop ()
193+ root_complete = not frames
194+ continue
195+ role = array_value_role (frame )
196+ frame .index += 1
197+ frame .state = "comma_or_end"
198+ start_value (kind , value , role )
199+ elif frame .state == "comma_or_end" :
200+ if kind == "]" :
201+ frames .pop ()
202+ root_complete = not frames
203+ elif kind == "," :
204+ frame .state = "value"
205+ else :
206+ raise ValueError ("expected comma or JSON array end" )
207+ if frames or not root_started or not root_complete :
208+ raise ValueError ("incomplete JSON value" )
209+ except (TypeError , ValueError ):
210+ return None
211+ return root_node_type , node_count , query_text
212+
213+
214+ def _iter_json_tokens (plan_text : str ):
215+ index = 0
216+ while index < len (plan_text ):
217+ character = plan_text [index ]
218+ if character .isspace ():
219+ index += 1
220+ continue
221+ if character in "{}[]:," :
222+ yield character , None
223+ index += 1
224+ continue
225+ if character == '"' :
226+ value , index = scanstring (plan_text , index + 1 , True )
227+ yield "string" , value
228+ continue
229+ number_match = _JSON_NUMBER_RE .match (plan_text , index )
230+ if number_match is not None :
231+ yield "scalar" , None
232+ index = number_match .end ()
233+ continue
234+ literal = next (
235+ (candidate for candidate in ("true" , "false" , "null" ) if plan_text .startswith (candidate , index )),
236+ None ,
237+ )
238+ if literal is None :
239+ raise ValueError ("invalid JSON token" )
240+ yield "scalar" , None
241+ index += len (literal )
242+
243+
83244def _count_json_nodes (plan : dict [str , Any ]) -> int :
84245 count = 0
85246 pending = [plan ]
0 commit comments