@@ -101,7 +101,9 @@ def catalog(metadata: MetaData) -> tuple[Table, Table, Table, Table]:
101101def _now () -> str :
102102 return datetime .now (UTC ).isoformat ()
103103
104- def _migrate_catalog_columns (connection : Any , datasets : Table , variables : Table ) -> None :
104+ def _migrate_catalog_columns (
105+ connection : Any , datasets : Table , variables : Table , multiple_response : Table ,
106+ ) -> None :
105107 """Add pyspssio metadata columns to catalogs created by earlier adapters.
106108
107109 This additive migration is intentionally small and portable: no existing
@@ -119,6 +121,14 @@ def _migrate_catalog_columns(connection: Any, datasets: Table, variables: Table)
119121 "attributes" : "TEXT NOT NULL DEFAULT '{}'" ,
120122 "compat_name" : "VARCHAR(255)" ,
121123 },
124+ multiple_response .name : {
125+ "is_dichotomy" : "BOOLEAN" ,
126+ "use_category_labels" : "BOOLEAN" ,
127+ "use_first_var_label" : "BOOLEAN" ,
128+ "counted_value_type" : "VARCHAR(16)" ,
129+ "counted_numeric" : "DOUBLE" ,
130+ "counted_text" : "TEXT" ,
131+ },
122132 }
123133 preparer = connection .dialect .identifier_preparer
124134 for table_name , columns in additions .items ():
@@ -188,11 +198,36 @@ def multiple_response_set_catalog(metadata: MetaData) -> Table:
188198 Column ("set_name" , String (255 ), primary_key = True ),
189199 Column ("member_ordinal" , Integer , primary_key = True ),
190200 Column ("kind" , String (16 )), Column ("label" , Text ),
191- Column ("counted_value" , Text ), Column ("variable_name" , String (255 )),
201+ Column ("is_dichotomy" , Boolean ),
202+ Column ("use_category_labels" , Boolean ),
203+ Column ("use_first_var_label" , Boolean ),
204+ Column ("counted_value" , Text ),
205+ Column ("counted_value_type" , String (16 )),
206+ Column ("counted_numeric" , binary64_type ()),
207+ Column ("counted_text" , Text ),
208+ Column ("variable_name" , String (255 )),
192209 Column ("definition" , Text , nullable = False ),
193210 )
194211
195212
213+ def source_extension_catalog (metadata : MetaData ) -> Table :
214+ """Namespaced raw source semantics retained even when export is fail-closed."""
215+ return Table (
216+ "source_extension_catalog" , metadata ,
217+ Column ("dataset_id" , String (255 ), primary_key = True ),
218+ Column ("extension_key" , String (255 ), primary_key = True ),
219+ Column ("payload" , Text , nullable = False ),
220+ )
221+
222+
223+ def source_extension_rows (dataset_id : str , extensions : Mapping [str , Any ]) -> list [dict [str , Any ]]:
224+ return [
225+ {"dataset_id" : dataset_id , "extension_key" : str (key ),
226+ "payload" : json .dumps (payload , default = str , ensure_ascii = False , sort_keys = True )}
227+ for key , payload in sorted (extensions .items ())
228+ ]
229+
230+
196231def document_catalog (metadata : MetaData ) -> Table :
197232 """Ordered file documents, normalized independently from the legacy JSON column."""
198233 return Table (
@@ -304,23 +339,90 @@ def missing_rule_rows(dataset_id: str, variables: list[dict[str, Any]]) -> list[
304339def normalized_metadata_tables (metadata : MetaData ) -> tuple [Table , Table , Table ]:
305340 return document_catalog (metadata ), value_label_catalog (metadata ), missing_rule_catalog (metadata )
306341
342+ def _mr_counted_value (definition : Mapping [str , Any ]) -> tuple [str | None , float | None , str | None ]:
343+ value = definition .get ("counted_value" , definition .get ("countedvalue" ))
344+ if value is None :
345+ return None , None , None
346+ if isinstance (value , bool ):
347+ return "text" , None , str (value )
348+ if isinstance (value , (int , float )):
349+ return "numeric" , float (value ), None
350+ return "text" , None , str (value )
351+
352+
307353def multiple_response_set_rows (dataset_id : str , definitions : str ) -> list [dict [str , Any ]]:
308- sets = json .loads (definitions )
309- rows = []
354+ sets = json .loads (definitions or "{}" )
355+ rows : list [ dict [ str , Any ]] = []
310356 for set_name , definition in sets .items ():
357+ if not isinstance (definition , Mapping ):
358+ continue
311359 members = definition .get ("variable_list" , definition .get ("variables" , []))
312360 if isinstance (members , str ):
313361 members = members .split ()
362+ is_dichotomy = definition .get ("is_dichotomy" )
363+ if is_dichotomy is None :
364+ is_dichotomy = definition .get ("set_type" , definition .get ("type" )) in {"MD" , "D" , "E" } or (
365+ definition .get ("counted_value" , definition .get ("countedvalue" )) is not None
366+ )
367+ kind = "MD" if is_dichotomy else "MC"
368+ value_type , numeric_value , text_value = _mr_counted_value (definition )
314369 for ordinal , variable_name in enumerate (members or [None ], start = 1 ):
315370 rows .append ({
316371 "dataset_id" : dataset_id , "set_name" : set_name , "member_ordinal" : ordinal ,
317- "kind" : definition .get ("set_type" , definition .get ("type" )),
318- "label" : definition .get ("label" ),
319- "counted_value" : str (definition .get ("counted_value" , definition .get ("countedvalue" , "" ))),
320- "variable_name" : variable_name , "definition" : json .dumps (definition , default = str , sort_keys = True ),
372+ "kind" : kind , "label" : definition .get ("label" ),
373+ "is_dichotomy" : bool (is_dichotomy ),
374+ "use_category_labels" : bool (definition .get ("use_category_labels" , False )),
375+ "use_first_var_label" : bool (definition .get ("use_first_var_label" , False )),
376+ "counted_value" : "" if value_type is None else str (
377+ numeric_value if value_type == "numeric" else text_value
378+ ),
379+ "counted_value_type" : value_type ,
380+ "counted_numeric" : numeric_value , "counted_text" : text_value ,
381+ "variable_name" : variable_name ,
382+ "definition" : json .dumps (definition , default = str , ensure_ascii = False , sort_keys = True ),
321383 })
322384 return rows
323385
386+
387+ def multiple_response_sets_from_rows (rows : Iterable [Mapping [str , Any ]]) -> dict [str , dict [str , Any ]]:
388+ """Reconstruct pyspssio MR metadata from normalized catalog rows.
389+
390+ The new typed fields are authoritative. ``definition`` only supplies
391+ backwards compatibility for catalogs written before those fields existed.
392+ """
393+ result : dict [str , dict [str , Any ]] = {}
394+ for row in rows :
395+ name = str (row ["set_name" ])
396+ if name not in result :
397+ try :
398+ legacy = json .loads (row .get ("definition" ) or "{}" )
399+ except (TypeError , json .JSONDecodeError ):
400+ legacy = {}
401+ definition = dict (legacy ) if isinstance (legacy , Mapping ) else {}
402+ definition ["variable_list" ] = []
403+ result [name ] = definition
404+ definition = result [name ]
405+ if row .get ("label" ) is not None :
406+ definition ["label" ] = row ["label" ]
407+ if row .get ("is_dichotomy" ) is not None :
408+ definition ["is_dichotomy" ] = bool (row ["is_dichotomy" ])
409+ elif row .get ("kind" ) is not None :
410+ definition ["is_dichotomy" ] = str (row ["kind" ]).upper () == "MD"
411+ if row .get ("use_category_labels" ) is not None :
412+ definition ["use_category_labels" ] = bool (row ["use_category_labels" ])
413+ if row .get ("use_first_var_label" ) is not None :
414+ definition ["use_first_var_label" ] = bool (row ["use_first_var_label" ])
415+ value_type = row .get ("counted_value_type" )
416+ if value_type == "numeric" :
417+ definition ["counted_value" ] = row .get ("counted_numeric" )
418+ elif value_type == "text" :
419+ definition ["counted_value" ] = row .get ("counted_text" )
420+ elif row .get ("counted_value" ) not in (None , "" ):
421+ definition ["counted_value" ] = row ["counted_value" ]
422+ if row .get ("variable_name" ) is not None :
423+ definition ["variable_list" ].append (row ["variable_name" ])
424+ return result
425+
324426def physical_name (source_name : str , used : set [str ]) -> str :
325427 stem = _IDENTIFIER .sub ("_" , source_name ).strip ("_" ).lower () or "variable"
326428 stem = stem [:54 ]
@@ -347,13 +449,15 @@ def create_wide_dataset(
347449 source_created_at : str | None = None , source_modified_at : str | None = None ,
348450 imported_at : str = "" ,
349451 multiple_response_sets : str = "{}" ,
452+ source_extensions : Mapping [str , Any ] | None = None ,
350453 fidelity_events : Iterable [Mapping [str , Any ]] = (),
351454) -> dict [str , Any ]:
352455 profile = validate_connection_url (database_url )
353456 engine = create_engine (database_url )
354457 metadata = MetaData ()
355458 datasets , variable_catalog , fidelity_event_catalog , operation_catalog = catalog (metadata )
356459 multiple_response_catalog = multiple_response_set_catalog (metadata )
460+ source_extensions_catalog = source_extension_catalog (metadata )
357461 documents_catalog , value_labels_catalog , missing_rules_catalog = normalized_metadata_tables (metadata )
358462 operation_id = str (uuid4 ())
359463 try :
@@ -374,8 +478,8 @@ def create_wide_dataset(
374478 nullable = item ["storage_kind" ] == "numeric" ) for item in variables ),
375479 )
376480 with engine .begin () as connection :
377- metadata .create_all (connection , tables = [datasets , variable_catalog , multiple_response_catalog , documents_catalog , value_labels_catalog , missing_rules_catalog , fidelity_event_catalog , operation_catalog ])
378- _migrate_catalog_columns (connection , datasets , variable_catalog )
481+ metadata .create_all (connection , tables = [datasets , variable_catalog , multiple_response_catalog , source_extensions_catalog , documents_catalog , value_labels_catalog , missing_rules_catalog , fidelity_event_catalog , operation_catalog ])
482+ _migrate_catalog_columns (connection , datasets , variable_catalog , multiple_response_catalog )
379483 connection .execute (insert (operation_catalog ).values (
380484 operation_id = operation_id , direction = "import" , status = "running" , dataset_id = dataset_id ,
381485 source = source_name , created_at = _now (), details = json .dumps ({"variable_count" : len (variables )}, sort_keys = True ),
@@ -410,6 +514,9 @@ def create_wide_dataset(
410514 mrset_rows = multiple_response_set_rows (dataset_id , multiple_response_sets )
411515 if mrset_rows :
412516 connection .execute (insert (multiple_response_catalog ), mrset_rows )
517+ extension_rows = source_extension_rows (dataset_id , source_extensions or {})
518+ if extension_rows :
519+ connection .execute (insert (source_extensions_catalog ), extension_rows )
413520 event_rows = _event_rows (
414521 operation_id = operation_id , dataset_id = dataset_id , direction = "import" , fidelity_events = fidelity_events ,
415522 )
@@ -421,6 +528,7 @@ def create_wide_dataset(
421528 except Exception :
422529 data_table .drop (connection , checkfirst = True )
423530 connection .execute (delete (multiple_response_catalog ).where (multiple_response_catalog .c .dataset_id == dataset_id ))
531+ connection .execute (delete (source_extensions_catalog ).where (source_extensions_catalog .c .dataset_id == dataset_id ))
424532 connection .execute (delete (documents_catalog ).where (documents_catalog .c .dataset_id == dataset_id ))
425533 connection .execute (delete (value_labels_catalog ).where (value_labels_catalog .c .dataset_id == dataset_id ))
426534 connection .execute (delete (missing_rules_catalog ).where (missing_rules_catalog .c .dataset_id == dataset_id ))
@@ -449,10 +557,12 @@ def read_wide_dataset(*, database_url: str, dataset_id: str) -> tuple[dict[str,
449557 engine = create_engine (database_url )
450558 metadata = MetaData ()
451559 datasets , variable_catalog , _ , _ = catalog (metadata )
560+ multiple_response_catalog = multiple_response_set_catalog (metadata )
561+ source_extensions_catalog = source_extension_catalog (metadata )
452562 documents_catalog , value_labels_catalog , missing_rules_catalog = normalized_metadata_tables (metadata )
453563 with engine .begin () as connection :
454- metadata .create_all (connection , tables = [datasets , variable_catalog , documents_catalog , value_labels_catalog , missing_rules_catalog ])
455- _migrate_catalog_columns (connection , datasets , variable_catalog )
564+ metadata .create_all (connection , tables = [datasets , variable_catalog , multiple_response_catalog , source_extensions_catalog , documents_catalog , value_labels_catalog , missing_rules_catalog ])
565+ _migrate_catalog_columns (connection , datasets , variable_catalog , multiple_response_catalog )
456566 dataset = dict (connection .execute (select (datasets ).where (datasets .c .dataset_id == dataset_id )).mappings ().one ())
457567 data_table = Table (dataset ["data_table" ], MetaData (), autoload_with = connection )
458568 variables = [dict (item ) for item in connection .execute (
@@ -473,9 +583,26 @@ def read_wide_dataset(*, database_url: str, dataset_id: str) -> tuple[dict[str,
473583 select (missing_rules_catalog ).where (missing_rules_catalog .c .dataset_id == dataset_id )
474584 .order_by (missing_rules_catalog .c .variable_ordinal , missing_rules_catalog .c .ordinal )
475585 ).mappings ().all ()
586+ mrset_rows_result = connection .execute (
587+ select (multiple_response_catalog ).where (multiple_response_catalog .c .dataset_id == dataset_id )
588+ .order_by (multiple_response_catalog .c .set_name , multiple_response_catalog .c .member_ordinal )
589+ ).mappings ().all ()
590+ extension_rows_result = connection .execute (
591+ select (source_extensions_catalog ).where (source_extensions_catalog .c .dataset_id == dataset_id )
592+ .order_by (source_extensions_catalog .c .extension_key )
593+ ).mappings ().all ()
476594
477595 if document_rows_result :
478596 dataset ["documents" ] = json .dumps ([item ["text" ] for item in document_rows_result ], ensure_ascii = False )
597+ if mrset_rows_result :
598+ dataset ["multiple_response_sets" ] = json .dumps (
599+ multiple_response_sets_from_rows (mrset_rows_result ), ensure_ascii = False , default = str ,
600+ )
601+ if extension_rows_result :
602+ dataset ["source_extensions" ] = {
603+ item ["extension_key" ]: json .loads (item ["payload" ])
604+ for item in extension_rows_result
605+ }
479606 variables_by_ordinal = {item ["ordinal" ]: item for item in variables }
480607 labels_by_variable : dict [int , dict [Any , str ]] = {}
481608 for item in label_rows_result :
@@ -494,7 +621,7 @@ def read_wide_dataset(*, database_url: str, dataset_id: str) -> tuple[dict[str,
494621 return dataset , variables , rows
495622
496623
497- def read_fidelity_events (* , database_url : str , dataset_id : str ) -> tuple [dict [str , str ], ...]:
624+ def read_fidelity_events (* , database_url : str , dataset_id : str ) -> tuple [dict [str , Any ], ...]:
498625 """Read import-time fidelity diagnostics for a catalogued dataset."""
499626 engine = create_engine (database_url )
500627 metadata = MetaData ()
@@ -506,7 +633,10 @@ def read_fidelity_events(*, database_url: str, dataset_id: str) -> tuple[dict[st
506633 .where (fidelity_event_catalog .c .dataset_id == dataset_id )
507634 .order_by (fidelity_event_catalog .c .code )
508635 ).mappings ().all ()
509- return tuple ({"code" : item ["code" ], "detail" : item ["detail" ]} for item in events )
636+ return tuple ({
637+ "code" : item ["code" ], "detail" : item ["detail" ],
638+ "details" : json .loads (item ["details" ] or "{}" ),
639+ } for item in events )
510640
511641
512642
0 commit comments