@@ -145,31 +145,171 @@ def _shared_array_schema(arr: list[dict], field_name: str) -> list[str] | None:
145145 return canonical_fields
146146
147147
148+ # ── Nested object flattening (v3.2) ──────────────────────────────────────
149+
150+
151+ def _analyze_flattenable (
152+ arr : list [dict ], field_name : str , parent_path : str
153+ ) -> list [dict ] | None :
154+ """Analyze whether a field can be flattened. Returns list of leaf descriptors or None."""
155+ canonical_shape : dict [str , str ] | None = None # key -> "scalar" | "nested"
156+
157+ for item in arr :
158+ if field_name not in item or item [field_name ] is None :
159+ continue
160+ v = item [field_name ]
161+ if not isinstance (v , dict ):
162+ return None
163+ if isinstance (v , list ):
164+ return None
165+
166+ keys = list (v .keys ())
167+
168+ if canonical_shape is None :
169+ canonical_shape = {}
170+ for k in keys :
171+ if ">" in k :
172+ return None
173+ val = v [k ]
174+ if isinstance (val , list ):
175+ return None
176+ elif isinstance (val , dict ):
177+ canonical_shape [k ] = "nested"
178+ else :
179+ canonical_shape [k ] = "scalar"
180+ else :
181+ if len (keys ) != len (canonical_shape ):
182+ return None
183+ for k in keys :
184+ if k not in canonical_shape :
185+ return None
186+ val = v [k ]
187+ expected = canonical_shape [k ]
188+ if expected == "scalar" :
189+ if isinstance (val , (dict , list )):
190+ return None
191+ elif expected == "nested" :
192+ if isinstance (val , list ):
193+ return None
194+ if val is not None and not isinstance (val , dict ):
195+ return None
196+
197+ if canonical_shape is None :
198+ return None
199+
200+ current_path = f"{ parent_path } >{ field_name } " if parent_path else field_name
201+ parent_keys = parent_path .split (">" ) + [field_name ] if parent_path else [field_name ]
202+
203+ leaves : list [dict ] = []
204+ for k in canonical_shape :
205+ if canonical_shape [k ] == "scalar" :
206+ leaves .append ({"path" : f"{ current_path } >{ k } " , "keys" : parent_keys + [k ]})
207+ else :
208+ sub_arr = []
209+ for item in arr :
210+ if field_name not in item or item [field_name ] is None :
211+ sub_arr .append ({})
212+ else :
213+ sub_arr .append (item [field_name ])
214+ sub_leaves = _analyze_flattenable (sub_arr , k , current_path )
215+ if sub_leaves is None or len (sub_leaves ) == 0 :
216+ return None
217+ leaves .extend (sub_leaves )
218+
219+ # Guard: reject if any row has non-null object with all-null leaves.
220+ if leaves :
221+ for item in arr :
222+ if field_name not in item or item [field_name ] is None :
223+ continue
224+ all_null = all (
225+ _resolve_key_chain (item , leaf ["keys" ])[0 ] is None
226+ and _resolve_key_chain (item , leaf ["keys" ])[1 ]
227+ for leaf in leaves
228+ )
229+ if all_null :
230+ return None
231+
232+ return leaves
233+
234+
235+ def _resolve_key_chain (item : Any , keys : list [str ]) -> tuple [Any , bool ]:
236+ """Traverse an object by key chain. Returns (value, exists)."""
237+ if not keys or not isinstance (item , dict ):
238+ return None , False
239+ if keys [0 ] not in item :
240+ return None , False
241+ current = item [keys [0 ]]
242+ if current is None :
243+ return None , True
244+ for k in keys [1 :]:
245+ if not isinstance (current , dict ) or k not in current :
246+ return None , False
247+ current = current [k ]
248+ return current , True
249+
250+
148251def _encode_tabular (
149252 header_prefix : str , arr : list [dict ], fields : list [str ], out : list [str ], depth : int
150253) -> None :
151254 prefix = _indent (depth )
152255
153- # Pre-compute inline schemas and shared array schemas.
256+ # Phase 0: Analyze fields for flattening.
257+ flatten_map : dict [str , list [dict ]] = {}
258+ for f in fields :
259+ leaves = _analyze_flattenable (arr , f , "" )
260+ if leaves and len (leaves ) > 0 :
261+ flatten_map [f ] = leaves
262+
263+ # Build expanded column list.
264+ columns : list [dict ] = []
265+ for f in fields :
266+ if f in flatten_map :
267+ for leaf in flatten_map [f ]:
268+ columns .append ({"header" : format_key (leaf ["path" ]), "type" : "flat" , "field" : f , "keys" : leaf ["keys" ]})
269+ else :
270+ columns .append ({"header" : format_key (f ), "type" : "original" , "field" : f , "keys" : []})
271+
272+ # Pre-compute inline schemas and shared array schemas (skip flattened fields).
154273 inline_schemas : dict [str , list [str ]] = {}
155274 shared_arr_schemas : dict [str , list [str ]] = {}
156275 for f in fields :
276+ if f in flatten_map :
277+ continue
157278 ifs = _inline_schema_fields (arr , f )
158279 if ifs is not None :
159280 inline_schemas [f ] = ifs
160281 sas = _shared_array_schema (arr , f )
161282 if sas is not None :
162283 shared_arr_schemas [f ] = sas
163284
164- fmt_fields = "," .join (format_key ( f ) for f in fields )
165- out .append (f"{ header_prefix } [{ len (arr )} ]{{{ fmt_fields } }}" )
285+ header_fields = "," .join (col [ "header" ] for col in columns )
286+ out .append (f"{ header_prefix } [{ len (arr )} ]{{{ header_fields } }}" )
166287
167288 for i , item in enumerate (arr ):
168289 cells : list [str ] = []
169- attachments : list [tuple [str , Any , bool , list [str ] | None ]] = [] # (name, value, inline, inline_fields)
290+ attachments : list [tuple [str , Any , bool , list [str ] | None ]] = []
170291 row_has_attachment = False
171292
172- for f in fields :
293+ for col in columns :
294+ if col ["type" ] == "flat" :
295+ keys = col ["keys" ]
296+ if keys [0 ] not in item :
297+ cells .append ("~" )
298+ else :
299+ top_val = item [keys [0 ]]
300+ if top_val is None :
301+ cells .append ("-" )
302+ else :
303+ val , exists = _resolve_key_chain (item , keys )
304+ if not exists :
305+ cells .append ("~" )
306+ elif val is None :
307+ cells .append ("-" )
308+ else :
309+ cells .append (format_scalar (val , "|" ))
310+ continue
311+
312+ f = col ["field" ]
173313 if f not in item :
174314 cells .append ("~" )
175315 continue
0 commit comments