@@ -376,87 +376,132 @@ def __getitem__(self, key: Any) -> Any:
376376 return item
377377
378378
379- def get_sqlalchemy_column_metadata ( # noqa: C901 # FIXME CoP
379+ def get_sqlalchemy_column_metadata (
380380 execution_engine : SqlAlchemyExecutionEngine ,
381381 table_selectable : sqlalchemy .Select ,
382382 schema_name : Optional [str ] = None ,
383383) -> Sequence [Mapping [str , Any ]] | None :
384384 try :
385- columns : Sequence [Dict [str , Any ]]
386-
387385 engine = execution_engine .engine
388386 inspector = execution_engine .get_inspector ()
389- try :
390- # if a custom query was passed
391- if sqlalchemy .TextClause and isinstance (table_selectable , sqlalchemy .TextClause ): # type: ignore[truthy-function]
392- if hasattr (table_selectable , "selected_columns" ):
393- # New in version 1.4.
394- columns = table_selectable .selected_columns .columns
395- else :
396- # Implicit subquery for columns().column was deprecated in SQLAlchemy 1.4
397- # We must explicitly create a subquery
398- columns = table_selectable .columns ().subquery ().columns
399- elif sqlalchemy .quoted_name and isinstance (table_selectable , sqlalchemy .quoted_name ): # type: ignore[truthy-function]
400- columns = inspector .get_columns (
401- table_name = table_selectable ,
402- schema = schema_name ,
403- )
404- else :
405- logger .warning ("unexpected table_selectable type" )
406- columns = inspector .get_columns ( # type: ignore[assignment]
407- table_name = str (table_selectable ),
387+
388+ # Determine selectable type once
389+ is_text_clause = sqlalchemy .TextClause and isinstance ( # type: ignore[truthy-function]
390+ table_selectable , sqlalchemy .TextClause
391+ )
392+ is_quoted_name = sqlalchemy .quoted_name and isinstance ( # type: ignore[truthy-function]
393+ table_selectable , sqlalchemy .quoted_name
394+ )
395+ table_name = str (table_selectable )
396+
397+ # Fetch primary key info (skip for custom queries/TextClause)
398+ primary_key_columns : set [str ] = set ()
399+ if not is_text_clause :
400+ try :
401+ pk_constraint = inspector .get_pk_constraint (
402+ table_name = table_name ,
408403 schema = schema_name ,
409404 )
410- except (
411- KeyError ,
412- AttributeError ,
413- sa .exc .NoSuchTableError ,
414- sa .exc .ProgrammingError ,
415- ) as exc :
416- logger .debug (f"{ type (exc ).__name__ } while introspecting columns" , exc_info = exc )
417- logger .info (f"While introspecting columns { exc !r} ; attempting reflection fallback" )
418- # we will get a KeyError for temporary tables, since
419- # reflection will not find the temporary schema
420- columns = column_reflection_fallback (
421- selectable = table_selectable ,
422- dialect = engine .dialect ,
423- sqlalchemy_engine = engine ,
424- )
405+ primary_key_columns = set (pk_constraint .get ("constrained_columns" , []))
406+ except (
407+ sa .exc .NoSuchTableError ,
408+ sa .exc .ProgrammingError ,
409+ NotImplementedError ,
410+ AttributeError ,
411+ ) as e :
412+ logger .debug (f"Could not fetch primary key info for { table_name } : { e !r} " )
413+
414+ # Fetch column metadata
415+ columns = _get_columns_from_selectable (
416+ table_selectable ,
417+ inspector ,
418+ schema_name ,
419+ is_text_clause ,
420+ is_quoted_name ,
421+ )
425422
426- # Use fallback because for mssql and trino reflection mechanisms do not throw an error but return an empty list # noqa: E501 # FIXME CoP
427- if len ( columns ) == 0 :
423+ # Use fallback for mssql/ trino or when primary introspection fails
424+ if not columns :
428425 columns = column_reflection_fallback (
429426 selectable = table_selectable ,
430427 dialect = engine .dialect ,
431428 sqlalchemy_engine = engine ,
432429 )
433430
434- dialect_name = execution_engine .dialect .name
435- if dialect_name in [
436- GXSqlDialect .DATABRICKS ,
437- GXSqlDialect .POSTGRESQL ,
438- GXSqlDialect .SNOWFLAKE ,
439- GXSqlDialect .TRINO ,
440- ]:
441- # WARNING: Do not alter columns in place, as they are cached on the inspector
442- columns_copy = [column .copy () for column in columns ]
443- for column in columns_copy :
444- if column .get ("type" ):
445- # When using column_reflection_fallback, we might not be able to
446- # extract the column type, and only have the column name
447- compiled_type = column ["type" ].compile (dialect = execution_engine .dialect )
448- # Make the type case-insensitive
449- column ["type" ] = CaseInsensitiveString (str (compiled_type ))
450-
451- # Wrap all columns in CaseInsensitiveNameDict for all three dialects
452- return [CaseInsensitiveNameDict (column ) for column in columns_copy ]
453-
454- return columns
431+ # Build result: copy columns, add PK info, apply dialect-specific formatting
432+ return _build_column_metadata_result (columns , primary_key_columns , execution_engine )
455433 except AttributeError as e :
456434 logger .debug (f"Error while introspecting columns: { e !r} " , exc_info = e )
457435 return None
458436
459437
438+ def _get_columns_from_selectable (
439+ table_selectable : sqlalchemy .Select ,
440+ inspector : Any ,
441+ schema_name : Optional [str ],
442+ is_text_clause : bool ,
443+ is_quoted_name : bool ,
444+ ) -> Sequence [Dict [str , Any ]]:
445+ """Extract column metadata from a selectable, using reflection fallback on failure."""
446+ try :
447+ if is_text_clause :
448+ # Custom SQL query - extract columns from the clause itself (SQLAlchemy 1.4+)
449+ if hasattr (table_selectable , "selected_columns" ):
450+ return [
451+ {"name" : col .name , "type" : col .type }
452+ for col in table_selectable .selected_columns .values ()
453+ ]
454+ # Pre-1.4 SQLAlchemy is no longer supported; fall back to reflection
455+ logger .debug ("TextClause without selected_columns; using reflection fallback" )
456+ return []
457+
458+ if not is_quoted_name :
459+ logger .warning ("unexpected table_selectable type" )
460+
461+ return inspector .get_columns (
462+ table_name = table_selectable if is_quoted_name else str (table_selectable ),
463+ schema = schema_name ,
464+ )
465+ except (KeyError , AttributeError , sa .exc .NoSuchTableError , sa .exc .ProgrammingError ) as exc :
466+ logger .debug (f"{ type (exc ).__name__ } while introspecting columns" , exc_info = exc )
467+ logger .info (f"While introspecting columns { exc !r} ; attempting reflection fallback" )
468+ return [] # Caller will use column_reflection_fallback
469+
470+
471+ def _build_column_metadata_result (
472+ columns : Sequence [Dict [str , Any ]],
473+ primary_key_columns : set [str ],
474+ execution_engine : SqlAlchemyExecutionEngine ,
475+ ) -> Sequence [Mapping [str , Any ]]:
476+ """Build final column metadata with PK info and dialect-specific formatting."""
477+ # Copy columns to avoid mutating cached inspector data
478+ pk_columns_lower = {pk .casefold () for pk in primary_key_columns }
479+ result = [
480+ {
481+ ** col ,
482+ "primary_key" : col .get ("name" , "" ).casefold () in pk_columns_lower ,
483+ }
484+ for col in (c .copy () for c in columns )
485+ ]
486+
487+ # Apply case-insensitive formatting for specific dialects
488+ dialect_name = execution_engine .dialect .name
489+ case_insensitive_dialects = {
490+ GXSqlDialect .DATABRICKS ,
491+ GXSqlDialect .POSTGRESQL ,
492+ GXSqlDialect .SNOWFLAKE ,
493+ GXSqlDialect .TRINO ,
494+ }
495+ if dialect_name in case_insensitive_dialects :
496+ for col in result :
497+ if col .get ("type" ):
498+ compiled_type = col ["type" ].compile (dialect = execution_engine .dialect )
499+ col ["type" ] = CaseInsensitiveString (str (compiled_type ))
500+ return [CaseInsensitiveNameDict (col ) for col in result ]
501+
502+ return result
503+
504+
460505def column_reflection_fallback ( # noqa: C901, PLR0912, PLR0915 # FIXME CoP
461506 selectable : sqlalchemy .Select ,
462507 dialect : sqlalchemy .Dialect ,
0 commit comments