fix(catalog): project air_timezone in episode catalog subquery

episodeCatalogSelectBody is the derived "mi" relation that episode catalog
hydration and preview read qualifiedListItemColumns("mi") from. The local
episode airtimes feature (808f265f) added air_timezone to the shared column
lists but not to this hand-written subquery, so the outer projection
referenced mi.air_timezone, which the subquery never exposed.

Postgres returns SQLSTATE 42703 (undefined_column), which is not one of the
codes episodeCatalogEntriesUnavailable treats as "fast path unavailable" (it
only catches 42P01/42883), so episode catalog requests failed with HTTP 500
instead of degrading. movie and series scopes query media_items directly, so
the column is present there and only episode scope broke.

Add si.air_timezone to the subquery, and add a regression test asserting that
episodeCatalogSelectBody exposes every column qualifiedListItemColumns reads
off mi, so future additions to the shared column lists cannot silently drift
from the episode read model again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-05-29 11:53:32 -04:00
co-authored by Claude Opus 4.8
parent 3c338e855a
commit aa1a0bd38b
2 changed files with 180 additions and 0 deletions
@@ -0,0 +1,179 @@
package catalog
import (
"regexp"
"sort"
"strings"
"testing"
)
// TestEpisodeCatalogSelectBodyExposesQualifiedColumns guards the invariant that
// the hand-written episode subquery (episodeCatalogSelectBody) exposes every
// column the shared projection reads off the derived "mi" relation.
//
// hydrateEpisodeCatalogEntryPage and the episode base relation both render
//
// SELECT qualifiedListItemColumns("mi") FROM (episodeCatalogSelectBody) mi
//
// so any column qualifiedListItemColumns references as mi.<x> must be projected
// by the subquery. When a new media_items column is added to the shared lists
// (itemColumns / qualifiedListItemColumns) but not to this subquery, the query
// fails at runtime with `column mi.<x> does not exist` (SQLSTATE 42703) — which
// is NOT caught by episodeCatalogEntriesUnavailable, so it surfaces as a 500
// instead of degrading. This happened with air_timezone (added in the local
// episode airtimes feature). This test fails fast at build/test time instead.
func TestEpisodeCatalogSelectBodyExposesQualifiedColumns(t *testing.T) {
required := columnsReferencedOnAlias(qualifiedListItemColumns("mi"), "mi")
if len(required) == 0 {
t.Fatal("expected qualifiedListItemColumns(\"mi\") to reference columns on mi")
}
exposed := episodeCatalogSelectBodyOutputColumns(t)
var missing []string
for col := range required {
if !exposed[col] {
missing = append(missing, col)
}
}
if len(missing) > 0 {
sort.Strings(missing)
t.Fatalf("episodeCatalogSelectBody does not expose columns required by "+
"qualifiedListItemColumns(\"mi\"): %v\nadd them to the subquery in "+
"episode_catalog_source.go, or episode catalog hydration fails with "+
"SQLSTATE 42703 (column mi.<x> does not exist)", missing)
}
}
// columnsReferencedOnAlias returns every distinct column referenced as
// "<alias>.<column>" within projection (e.g. mi.air_timezone -> air_timezone).
func columnsReferencedOnAlias(projection, alias string) map[string]bool {
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(alias) + `\.([a-zA-Z_][a-zA-Z0-9_]*)`)
out := map[string]bool{}
for _, m := range re.FindAllStringSubmatch(projection, -1) {
out[m[1]] = true
}
return out
}
// episodeCatalogSelectBodyOutputColumns parses the SELECT list of the episode
// catalog subquery and returns the set of output column names it exposes (the
// identifier after AS, or the trailing identifier for bare "tbl.col" columns).
func episodeCatalogSelectBodyOutputColumns(t *testing.T) map[string]bool {
t.Helper()
selectList := episodeCatalogSelectListSQL(t)
out := map[string]bool{}
for _, part := range splitTopLevelSQLCommas(selectList) {
if name := sqlOutputColumnName(part); name != "" {
out[name] = true
}
}
return out
}
// episodeCatalogSelectListSQL extracts the projection list (between SELECT and
// its matching top-level FROM) from the rendered episode base relation.
func episodeCatalogSelectListSQL(t *testing.T) string {
t.Helper()
body := episodeCatalogBaseRelation
upper := strings.ToUpper(body)
selIdx := strings.Index(upper, "SELECT")
if selIdx < 0 {
t.Fatal("episodeCatalogSelectBody has no SELECT")
}
// The body is wrapped in an outer "( ... ) mi", so the SELECT list lives at
// the paren depth present at the SELECT keyword. Nested parens (COALESCE,
// EXTRACT(... FROM ...), the WHERE EXISTS subquery) sit deeper and are
// skipped, so the first FROM at the base depth is the real one.
base := strings.Count(body[:selIdx], "(") - strings.Count(body[:selIdx], ")")
start := selIdx + len("SELECT")
depth := base
for j := start; j < len(body); j++ {
switch body[j] {
case '(':
depth++
case ')':
depth--
}
if depth == base && isSQLWordAt(upper, j, "FROM") {
return body[start:j]
}
}
t.Fatal("episodeCatalogSelectBody has no top-level FROM")
return ""
}
// splitTopLevelSQLCommas splits a SELECT list on commas that sit outside any
// parentheses, leaving commas inside COALESCE(...)/EXTRACT(...) intact.
func splitTopLevelSQLCommas(s string) []string {
var parts []string
depth, last := 0, 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '(':
depth++
case ')':
depth--
case ',':
if depth == 0 {
parts = append(parts, s[last:i])
last = i + 1
}
}
}
return append(parts, s[last:])
}
// sqlOutputColumnName returns the output column name of a single projection:
// the identifier after a top-level AS, otherwise the trailing dotted identifier.
func sqlOutputColumnName(part string) string {
part = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(part), ","))
if part == "" {
return ""
}
if idx := strings.LastIndex(strings.ToUpper(part), " AS "); idx >= 0 {
return trailingSQLIdent(part[idx+len(" AS "):])
}
return trailingSQLIdent(part)
}
// trailingSQLIdent returns the last dotted identifier segment of s, e.g.
// "si.air_time" -> "air_time", "type" -> "type".
func trailingSQLIdent(s string) string {
s = strings.TrimSpace(s)
if i := strings.LastIndex(s, "."); i >= 0 {
s = s[i+1:]
}
begin := 0
for begin < len(s) && !isSQLIdentByte(s[begin]) {
begin++
}
end := begin
for end < len(s) && isSQLIdentByte(s[end]) {
end++
}
return s[begin:end]
}
func isSQLWordAt(s string, i int, word string) bool {
if i+len(word) > len(s) || s[i:i+len(word)] != word {
return false
}
if i > 0 && isSQLIdentByte(s[i-1]) {
return false
}
if i+len(word) < len(s) && isSQLIdentByte(s[i+len(word)]) {
return false
}
return true
}
func isSQLIdentByte(b byte) bool {
return b == '_' ||
(b >= 'a' && b <= 'z') ||
(b >= 'A' && b <= 'Z') ||
(b >= '0' && b <= '9')
}
@@ -46,6 +46,7 @@ const episodeCatalogSelectBody = `(
NULL::text AS last_air_date,
e.air_date AS last_air_date_at,
si.air_time,
si.air_timezone,
COALESCE(si.show_status, '') AS show_status,
si.matched_at,
si.last_refreshed,