@@ -141,36 +141,188 @@ function sharedArraySchema(arr: unknown[], fieldName: string): string[] | null {
141141 return canonicalFields ;
142142}
143143
144+ // ── Nested object flattening (v3.2) ──────────────────────────────────────
145+
146+ interface FlatLeaf {
147+ path : string ; // ">" separated path (e.g. "customer>name")
148+ keys : string [ ] ; // key chain to traverse from row object
149+ }
150+
151+ function analyzeFlattenable ( arr : unknown [ ] , fieldName : string , parentPath : string ) : FlatLeaf [ ] | null {
152+ let canonicalShape : Record < string , 'scalar' | 'nested' > | null = null ;
153+
154+ for ( const item of arr ) {
155+ const obj = item as Record < string , unknown > ;
156+ if ( ! ( fieldName in obj ) || obj [ fieldName ] === null || obj [ fieldName ] === undefined ) continue ;
157+ const v = obj [ fieldName ] ;
158+ if ( typeof v !== 'object' || Array . isArray ( v ) ) return null ;
159+
160+ const keys = Object . keys ( v as Record < string , unknown > ) ;
161+
162+ if ( ! canonicalShape ) {
163+ canonicalShape = { } ;
164+ for ( const k of keys ) {
165+ if ( k . includes ( '>' ) ) return null ;
166+ const val = ( v as Record < string , unknown > ) [ k ] ;
167+ if ( val !== null && val !== undefined && typeof val === 'object' && ! Array . isArray ( val ) ) {
168+ canonicalShape [ k ] = 'nested' ;
169+ } else if ( Array . isArray ( val ) ) {
170+ return null ;
171+ } else {
172+ canonicalShape [ k ] = 'scalar' ;
173+ }
174+ }
175+ } else {
176+ if ( keys . length !== Object . keys ( canonicalShape ) . length ) return null ;
177+ for ( const k of keys ) {
178+ if ( ! ( k in canonicalShape ) ) return null ;
179+ const val = ( v as Record < string , unknown > ) [ k ] ;
180+ const expected = canonicalShape [ k ] ;
181+ if ( expected === 'scalar' ) {
182+ if ( val !== null && val !== undefined && typeof val === 'object' ) return null ;
183+ } else if ( expected === 'nested' ) {
184+ if ( val !== null && val !== undefined ) {
185+ if ( typeof val !== 'object' || Array . isArray ( val ) ) return null ;
186+ }
187+ }
188+ }
189+ }
190+ }
191+
192+ if ( ! canonicalShape ) return null ;
193+
194+ const currentPath = parentPath ? parentPath + '>' + fieldName : fieldName ;
195+ const parentKeys = parentPath ? [ ...parentPath . split ( '>' ) , fieldName ] : [ fieldName ] ;
196+
197+ const leaves : FlatLeaf [ ] = [ ] ;
198+ for ( const k of Object . keys ( canonicalShape ) ) {
199+ if ( canonicalShape [ k ] === 'scalar' ) {
200+ leaves . push ( { path : currentPath + '>' + k , keys : [ ...parentKeys , k ] } ) ;
201+ } else {
202+ const subArr = arr . map ( item => {
203+ const obj = item as Record < string , unknown > ;
204+ if ( ! ( fieldName in obj ) || obj [ fieldName ] === null || obj [ fieldName ] === undefined ) return { } ;
205+ return obj [ fieldName ] ;
206+ } ) ;
207+ const subLeaves = analyzeFlattenable ( subArr as unknown [ ] , k , currentPath ) ;
208+ if ( ! subLeaves || subLeaves . length === 0 ) return null ;
209+ leaves . push ( ...subLeaves ) ;
210+ }
211+ }
212+
213+ // Guard: reject if any row has non-null object with all-null leaves.
214+ if ( leaves . length > 0 ) {
215+ for ( const item of arr ) {
216+ const obj = item as Record < string , unknown > ;
217+ if ( ! ( fieldName in obj ) || obj [ fieldName ] === null || obj [ fieldName ] === undefined ) continue ;
218+ const allNull = leaves . every ( leaf => {
219+ const val = resolveKeyChain ( item , leaf . keys ) ;
220+ return val . exists && val . value === null ;
221+ } ) ;
222+ if ( allNull ) return null ;
223+ }
224+ }
225+
226+ return leaves ;
227+ }
228+
229+ function resolveKeyChain ( item : unknown , keys : string [ ] ) : { value : unknown ; exists : boolean } {
230+ if ( keys . length === 0 ) return { value : undefined , exists : false } ;
231+ const obj = item as Record < string , unknown > ;
232+ if ( typeof obj !== 'object' || obj === null ) return { value : undefined , exists : false } ;
233+ if ( ! ( keys [ 0 ] in obj ) ) return { value : undefined , exists : false } ;
234+ let current : unknown = obj [ keys [ 0 ] ] ;
235+ if ( current === null || current === undefined ) return { value : current , exists : true } ;
236+ for ( let i = 1 ; i < keys . length ; i ++ ) {
237+ if ( typeof current !== 'object' || current === null ) return { value : undefined , exists : false } ;
238+ const c = current as Record < string , unknown > ;
239+ if ( ! ( keys [ i ] in c ) ) return { value : undefined , exists : false } ;
240+ current = c [ keys [ i ] ] ;
241+ }
242+ return { value : current , exists : true } ;
243+ }
244+
245+ // ── End flattening helpers ───────────────────────────────────────────────
246+
144247function encodeTabular ( headerPrefix : string , arr : unknown [ ] , fields : string [ ] , depth : number ) : string {
145248 const prefix = indent ( depth ) ;
146249
147- // Pre-compute inline schemas and shared array schemas.
250+ // Phase 0: Analyze fields for flattening.
251+ const flattenMap = new Map < string , FlatLeaf [ ] > ( ) ;
252+ for ( const f of fields ) {
253+ const leaves = analyzeFlattenable ( arr , f , '' ) ;
254+ if ( leaves && leaves . length > 0 ) {
255+ flattenMap . set ( f , leaves ) ;
256+ }
257+ }
258+
259+ // Build expanded column list.
260+ type ColType = 'flat' | 'original' ;
261+ interface FlatColumn { headerName : string ; colType : ColType ; field : string ; keys : string [ ] ; }
262+ const columns : FlatColumn [ ] = [ ] ;
263+ for ( const f of fields ) {
264+ const leaves = flattenMap . get ( f ) ;
265+ if ( leaves ) {
266+ for ( const leaf of leaves ) {
267+ columns . push ( { headerName : formatKey ( leaf . path ) , colType : 'flat' , field : f , keys : leaf . keys } ) ;
268+ }
269+ } else {
270+ columns . push ( { headerName : formatKey ( f ) , colType : 'original' , field : f , keys : [ ] } ) ;
271+ }
272+ }
273+
274+ // Pre-compute inline schemas and shared array schemas (skip flattened fields).
148275 const inlineSchemas = new Map < string , string [ ] > ( ) ;
149276 const sharedArrSchemas = new Map < string , string [ ] > ( ) ;
150277 for ( const f of fields ) {
278+ if ( flattenMap . has ( f ) ) continue ;
151279 const ifs = inlineSchemaFields ( arr , f ) ;
152280 if ( ifs ) inlineSchemas . set ( f , ifs ) ;
153281 const sas = sharedArraySchema ( arr , f ) ;
154282 if ( sas ) sharedArrSchemas . set ( f , sas ) ;
155283 }
156284
157- const fmtFields = fields . map ( f => formatKey ( f ) ) ;
158- let out = `${ headerPrefix } [${ arr . length } ]{${ fmtFields . join ( ',' ) } }\n` ;
285+ const headerFields = columns . map ( c => c . headerName ) ;
286+ let out = `${ headerPrefix } [${ arr . length } ]{${ headerFields . join ( ',' ) } }\n` ;
159287
160288 for ( let i = 0 ; i < arr . length ; i ++ ) {
161289 const obj = arr [ i ] as Record < string , unknown > ;
162290 const cells : string [ ] = [ ] ;
163291 const attachments : { name : string ; value : unknown ; inline : boolean ; inlineFields ?: string [ ] } [ ] = [ ] ;
164292 let rowHasAttachment = false ;
165293
166- for ( const f of fields ) {
294+ for ( const col of columns ) {
295+ if ( col . colType === 'flat' ) {
296+ // Resolve value via key chain.
297+ if ( ! ( col . keys [ 0 ] in obj ) ) {
298+ cells . push ( '~' ) ;
299+ } else {
300+ // Check if top-level field is null.
301+ const topVal = obj [ col . keys [ 0 ] ] ;
302+ if ( topVal === null || topVal === undefined ) {
303+ cells . push ( topVal === null ? '-' : '~' ) ;
304+ } else {
305+ const resolved = resolveKeyChain ( obj , col . keys ) ;
306+ if ( ! resolved . exists ) {
307+ cells . push ( '~' ) ;
308+ } else if ( resolved . value === null || resolved . value === undefined ) {
309+ cells . push ( '-' ) ;
310+ } else {
311+ cells . push ( formatScalar ( resolved . value , 0x7c ) ) ;
312+ }
313+ }
314+ }
315+ continue ;
316+ }
317+
318+ // Original (non-flattened) field.
319+ const f = col . field ;
167320 if ( ! ( f in obj ) ) { cells . push ( '~' ) ; continue ; }
168321 const v = obj [ f ] ;
169322 if ( v === null || v === undefined ) { cells . push ( '-' ) ; continue ; }
170323 if ( typeof v === 'object' ) {
171324 const ifs = inlineSchemas . get ( f ) ;
172325 if ( ifs && ! Array . isArray ( v ) ) {
173- // Inline schema: first row declares, subsequent use bare ^.
174326 if ( i === 0 ) {
175327 const fmtIF = ifs . map ( k => formatKey ( k ) ) ;
176328 cells . push ( `^{${ fmtIF . join ( ',' ) } }` ) ;
0 commit comments