@@ -688,6 +688,223 @@ describe('Metadata', () => {
688688 } ) ;
689689 } ) ;
690690
691+ describe ( 'getAllFields' , ( ) => {
692+ beforeEach ( ( ) => {
693+ jest . clearAllMocks ( ) ;
694+ } ) ;
695+
696+ it ( 'should extract LowCardinality(String) type for Map sub-fields when value type is LowCardinality' , async ( ) => {
697+ // Simulate: Map(LowCardinality(String), LowCardinality(String))
698+ // This is the "working" case — sub-fields get type LowCardinality(String)
699+ const realCache = new (
700+ jest . requireActual ( '../core/metadata' ) as any
701+ ) . MetadataCache ( ) ;
702+ const md = new Metadata ( mockClickhouseClient , realCache ) ;
703+
704+ // Mock getColumns → returns one Map column
705+ ( mockClickhouseClient . query as jest . Mock )
706+ . mockResolvedValueOnce ( {
707+ // DESCRIBE TABLE
708+ json : ( ) =>
709+ Promise . resolve ( {
710+ data : [
711+ {
712+ name : 'LogAttributes' ,
713+ type : 'Map(LowCardinality(String), LowCardinality(String))' ,
714+ default_type : '' ,
715+ default_expression : '' ,
716+ comment : '' ,
717+ codec_expression : '' ,
718+ ttl_expression : '' ,
719+ } ,
720+ ] ,
721+ } ) ,
722+ } )
723+ . mockResolvedValueOnce ( {
724+ // lowCardinalityKeys query for LogAttributes
725+ json : ( ) =>
726+ Promise . resolve ( {
727+ data : [ { key : 'http.method' } , { key : 'http.status_code' } ] ,
728+ } ) ,
729+ } ) ;
730+
731+ const fields = await md . getAllFields ( {
732+ databaseName : 'otel' ,
733+ tableName : 'otel_logs' ,
734+ connectionId : 'conn-1' ,
735+ } ) ;
736+
737+ // The Map column itself should be present
738+ const mapField = fields . find (
739+ f => f . path . length === 1 && f . path [ 0 ] === 'LogAttributes' ,
740+ ) ;
741+ expect ( mapField ) . toBeDefined ( ) ;
742+ expect ( mapField ! . type ) . toBe (
743+ 'Map(LowCardinality(String), LowCardinality(String))' ,
744+ ) ;
745+
746+ // Sub-fields should have LowCardinality(String) as their type
747+ const httpMethod = fields . find (
748+ f =>
749+ f . path . length === 2 &&
750+ f . path [ 0 ] === 'LogAttributes' &&
751+ f . path [ 1 ] === 'http.method' ,
752+ ) ;
753+ expect ( httpMethod ) . toBeDefined ( ) ;
754+ expect ( httpMethod ! . type ) . toBe ( 'LowCardinality(String)' ) ;
755+ // This type includes 'LowCardinality', so the UI filter check passes
756+ expect ( httpMethod ! . type . includes ( 'LowCardinality' ) ) . toBe ( true ) ;
757+ } ) ;
758+
759+ it ( 'should extract String type for Map sub-fields when value type is plain String — BUG: fields excluded from default filters' , async ( ) => {
760+ // Simulate: Map(LowCardinality(String), String)
761+ // This is the customer's schema (Constructor.io) — sub-fields get type "String"
762+ // which causes them to be filtered out of the default facet panel
763+ const realCache = new (
764+ jest . requireActual ( '../core/metadata' ) as any
765+ ) . MetadataCache ( ) ;
766+ const md = new Metadata ( mockClickhouseClient , realCache ) ;
767+
768+ ( mockClickhouseClient . query as jest . Mock )
769+ . mockResolvedValueOnce ( {
770+ // DESCRIBE TABLE
771+ json : ( ) =>
772+ Promise . resolve ( {
773+ data : [
774+ {
775+ name : 'LogAttributes' ,
776+ type : 'Map(LowCardinality(String), String)' ,
777+ default_type : '' ,
778+ default_expression : '' ,
779+ comment : '' ,
780+ codec_expression : '' ,
781+ ttl_expression : '' ,
782+ } ,
783+ {
784+ name : 'ResourceAttributes' ,
785+ type : 'Map(LowCardinality(String), String)' ,
786+ default_type : '' ,
787+ default_expression : '' ,
788+ comment : '' ,
789+ codec_expression : '' ,
790+ ttl_expression : '' ,
791+ } ,
792+ ] ,
793+ } ) ,
794+ } )
795+ . mockResolvedValueOnce ( {
796+ // lowCardinalityKeys query for LogAttributes
797+ json : ( ) =>
798+ Promise . resolve ( {
799+ data : [ { key : 'io.constructor.message' } , { key : 'severity' } ] ,
800+ } ) ,
801+ } )
802+ . mockResolvedValueOnce ( {
803+ // lowCardinalityKeys query for ResourceAttributes
804+ json : ( ) =>
805+ Promise . resolve ( {
806+ data : [ { key : 'log.index' } , { key : 'service.name' } ] ,
807+ } ) ,
808+ } ) ;
809+
810+ const fields = await md . getAllFields ( {
811+ databaseName : 'otel' ,
812+ tableName : 'otel_logs' ,
813+ connectionId : 'conn-1' ,
814+ } ) ;
815+
816+ // Sub-fields for LogAttributes
817+ const logAttrField = fields . find (
818+ f =>
819+ f . path [ 0 ] === 'LogAttributes' &&
820+ f . path [ 1 ] === 'io.constructor.message' ,
821+ ) ;
822+ expect ( logAttrField ) . toBeDefined ( ) ;
823+ // BUG: The extracted type is "String" (the Map VALUE type), NOT "LowCardinality(String)"
824+ expect ( logAttrField ! . type ) . toBe ( 'String' ) ;
825+ // This means the UI's LowCardinality check FAILS, hiding the field by default
826+ expect ( logAttrField ! . type . includes ( 'LowCardinality' ) ) . toBe ( false ) ;
827+
828+ // Same issue for ResourceAttributes
829+ const resAttrField = fields . find (
830+ f => f . path [ 0 ] === 'ResourceAttributes' && f . path [ 1 ] === 'log.index' ,
831+ ) ;
832+ expect ( resAttrField ) . toBeDefined ( ) ;
833+ expect ( resAttrField ! . type ) . toBe ( 'String' ) ;
834+ expect ( resAttrField ! . type . includes ( 'LowCardinality' ) ) . toBe ( false ) ;
835+ } ) ;
836+
837+ it ( 'demonstrates that Map sub-fields with plain String type are included via isMapSubField check' , async ( ) => {
838+ // This test simulates the fixed keysToFetch filtering logic from DBSearchPageFilters.tsx
839+ const fields = [
840+ // Regular LowCardinality column — always shown
841+ {
842+ path : [ 'SeverityText' ] ,
843+ type : 'LowCardinality(String)' ,
844+ jsType : 'string' as const ,
845+ } ,
846+ {
847+ path : [ 'ServiceName' ] ,
848+ type : 'LowCardinality(String)' ,
849+ jsType : 'string' as const ,
850+ } ,
851+ // Map(LowCardinality(String), LowCardinality(String)) sub-fields — shown (type has LowCardinality)
852+ {
853+ path : [ 'SpanAttributes' , 'http.method' ] ,
854+ type : 'LowCardinality(String)' ,
855+ jsType : 'string' as const ,
856+ } ,
857+ // Map(LowCardinality(String), String) sub-fields — now shown via isMapSubField
858+ {
859+ path : [ 'LogAttributes' , 'io.constructor.message' ] ,
860+ type : 'String' ,
861+ jsType : 'string' as const ,
862+ } ,
863+ {
864+ path : [ 'ResourceAttributes' , 'log.index' ] ,
865+ type : 'String' ,
866+ jsType : 'string' as const ,
867+ } ,
868+ // Regular String column (not a Map sub-field) — still hidden by default
869+ { path : [ 'Body' ] , type : 'String' , jsType : 'string' as const } ,
870+ ] ;
871+
872+ // Simulate the fixed filter logic from DBSearchPageFilters.tsx
873+ const showMoreFields = false ; // default state
874+ const filterState : Record < string , unknown > = { } ;
875+ const isFieldPinned = ( ) => false ;
876+
877+ const keysToFetch = fields
878+ . filter ( field => field . jsType && [ 'string' ] . includes ( field . jsType ) )
879+ . map ( ( { path, type } ) => ( {
880+ type,
881+ path : path . join ( '.' ) ,
882+ isMapSubField : path . length > 1 ,
883+ } ) )
884+ . filter (
885+ field =>
886+ showMoreFields ||
887+ field . type . includes ( 'LowCardinality' ) ||
888+ field . isMapSubField || // Fix: always include Map/JSON sub-fields
889+ Object . keys ( filterState ) . includes ( field . path ) ||
890+ isFieldPinned ( ) ,
891+ )
892+ . map ( f => f . path ) ;
893+
894+ // LowCardinality columns still shown
895+ expect ( keysToFetch ) . toContain ( 'SeverityText' ) ;
896+ expect ( keysToFetch ) . toContain ( 'ServiceName' ) ;
897+ expect ( keysToFetch ) . toContain ( 'SpanAttributes.http.method' ) ;
898+
899+ // Map(LowCardinality(String), String) sub-fields NOW included
900+ expect ( keysToFetch ) . toContain ( 'LogAttributes.io.constructor.message' ) ;
901+ expect ( keysToFetch ) . toContain ( 'ResourceAttributes.log.index' ) ;
902+
903+ // Regular non-LowCardinality columns still hidden by default
904+ expect ( keysToFetch ) . not . toContain ( 'Body' ) ;
905+ } ) ;
906+ } ) ;
907+
691908 describe ( 'parseTokensExpression' , ( ) => {
692909 it . each ( [
693910 // Test cases without tokens
0 commit comments