@@ -538,3 +538,98 @@ func TestBuild_NodeMissingOnOneSide(t *testing.T) {
538538 t .Fatalf ("only the primary (present on both sides) should be diffed, got %+v" , res .ActivityDelta )
539539 }
540540}
541+
542+ func qn (name string ) snapshot.QualifiedName {
543+ return snapshot.QualifiedName {Schema : "public" , Name : name }
544+ }
545+
546+ // plannerWithIndex / activityWithIndex go beyond the bare-table helpers above:
547+ // they also carry an index entry (orders_pkey) plus an unrelated second table
548+ // (invoices), which is exactly the shape needed to prove a table filter keeps the
549+ // table's index rows while still dropping a different table.
550+ func plannerWithIndex (schemaRef , hash string , ts time.Time , ordersRel float64 , idxSize int64 , invoicesRel float64 ) * snapshot.PlannerStatsSnapshot {
551+ return & snapshot.PlannerStatsSnapshot {
552+ SchemaRefHash : schemaRef , ContentHash : hash , Database : "appdb" , Timestamp : ts ,
553+ Tables : []snapshot.TableSizingEntry {
554+ {Table : qn ("orders" ), Sizing : snapshot.TableSizing {Reltuples : ordersRel , Relpages : 10 , TableSize : 81920 }},
555+ {Table : qn ("invoices" ), Sizing : snapshot.TableSizing {Reltuples : invoicesRel , Relpages : 2 , TableSize : 16384 }},
556+ },
557+ Indexes : []snapshot.IndexSizingEntry {
558+ {Table : qn ("orders" ), Index : "orders_pkey" , Sizing : snapshot.IndexSizing {Reltuples : ordersRel , Relpages : 5 , Size : idxSize }},
559+ },
560+ }
561+ }
562+
563+ func activityWithIndex (schemaRef , hash , node string , ts time.Time , ordersScan , idxScan , invoicesScan int64 ) * snapshot.ActivityStatsSnapshot {
564+ return & snapshot.ActivityStatsSnapshot {
565+ SchemaRefHash : schemaRef , ContentHash : hash ,
566+ Node : snapshot.NodeIdentity {Source : node , PgVersion : "PostgreSQL 17.0" , Timestamp : ts },
567+ Tables : []snapshot.TableActivityEntry {
568+ {Table : qn ("orders" ), Activity : snapshot.TableActivity {SeqScan : ordersScan , IdxScan : 5 }},
569+ {Table : qn ("invoices" ), Activity : snapshot.TableActivity {SeqScan : invoicesScan , IdxScan : 1 }},
570+ },
571+ Indexes : []snapshot.IndexActivityEntry {
572+ {Table : qn ("orders" ), Index : "orders_pkey" , Activity : snapshot.IndexActivity {IdxScan : idxScan , IdxTupRead : idxScan * 2 }},
573+ },
574+ }
575+ }
576+
577+ // TestBuild_TableFilterKeepsIndexRows is the #8 fix in one test: when you drill
578+ // into a single hot table, you want the *whole* table's story, and an index is
579+ // part of that story. Before, a table= filter matched rows by name and an index
580+ // row's name is the index, not the table — so orders_pkey's bloat and idx_scan
581+ // drift quietly vanished the moment you narrowed to orders, which is the worst
582+ // time to lose it. The fix threads the owning table onto the index identity, so
583+ // here orders_pkey must survive the filter carrying both its sizing and its
584+ // activity drift, while a genuinely-unrelated table (invoices) is correctly
585+ // dropped. The unfiltered control proves invoices was only ever excluded by the
586+ // filter and not by some other accident of the fixture.
587+ func TestBuild_TableFilterKeepsIndexRows (t * testing.T ) {
588+ ctx := context .Background ()
589+ store := openStore (t )
590+ t0 := time .Now ().Truncate (time .Second ).Add (- 3 * time .Hour )
591+ t1 := t0 .Add (2 * time .Hour )
592+
593+ // identical table shapes at both moments (distinct hashes) so the diff is
594+ // driven entirely by stats/activity, not DDL — keeps the index rows the only
595+ // thing under test rather than tangled up with schema changes.
596+ tbls := []snapshot.Table {table ("orders" , "id" ), table ("invoices" , "id" )}
597+ put (t , store , history .WrapSchema (mkSchema ("schema-a" , t0 , tbls ... )))
598+ put (t , store , history .WrapPlanner (plannerWithIndex ("schema-a" , "planner-a" , t0 .Add (time .Minute ), 1000 , 8192 , 100 )))
599+ put (t , store , history .WrapActivity (activityWithIndex ("schema-a" , "activity-a" , "primary" , t0 .Add (time .Minute ), 10 , 3 , 1 )))
600+
601+ put (t , store , history .WrapSchema (mkSchema ("schema-b" , t1 , tbls ... )))
602+ put (t , store , history .WrapPlanner (plannerWithIndex ("schema-b" , "planner-b" , t1 .Add (time .Minute ), 5000 , 81920 , 500 )))
603+ put (t , store , history .WrapActivity (activityWithIndex ("schema-b" , "activity-b" , "primary" , t1 .Add (time .Minute ), 100 , 90 , 50 )))
604+
605+ // control: with no filter, the unrelated invoices table is present
606+ unfiltered , err := Build (ctx , store , key (), Options {From : "latest~1" , To : "latest" , Kind : "schema" })
607+ if err != nil {
608+ t .Fatalf ("Build (unfiltered): %v" , err )
609+ }
610+ if findObject (unfiltered .Objects , "invoices" ) == nil {
611+ t .Fatal ("fixture sanity: invoices should appear without a filter" )
612+ }
613+
614+ res , err := Build (ctx , store , key (), Options {From : "latest~1" , To : "latest" , Kind : "schema" , Table : "orders" })
615+ if err != nil {
616+ t .Fatalf ("Build (filtered): %v" , err )
617+ }
618+
619+ idx := findObject (res .Objects , "orders_pkey" )
620+ if idx == nil || idx .Kind != "index" {
621+ t .Fatalf ("orders_pkey index drift must survive a table=orders filter, got objects %+v" , res .Objects )
622+ }
623+ if len (idx .Sizing ) == 0 {
624+ t .Error ("the index should carry its sizing drift (size grew 10x)" )
625+ }
626+ if len (idx .Activity ) == 0 {
627+ t .Error ("the index should carry its idx_scan drift" )
628+ }
629+ if findObject (res .Objects , "orders" ) == nil {
630+ t .Error ("the table itself should still be present" )
631+ }
632+ if findObject (res .Objects , "invoices" ) != nil {
633+ t .Error ("an unrelated table must be filtered out" )
634+ }
635+ }
0 commit comments