feat(calendar): generalize personal filter to an id-set restriction
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,13 +32,17 @@ type CalendarEvent struct {
|
||||
type CalendarFilter struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Filter string // "all" | "favorites" | "watchlist"
|
||||
LibraryID *int
|
||||
AllowedLibraryIDs []int
|
||||
DisabledLibraryIDs []int
|
||||
MaxContentRating string
|
||||
UserID int
|
||||
ProfileID string
|
||||
|
||||
// RestrictByIDs limits results to items whose movie content_id (movies) or
|
||||
// series_id (episodes / season premieres) is in RestrictToIDs. When
|
||||
// RestrictByIDs is true and RestrictToIDs is empty, no rows match. Callers
|
||||
// resolve the id-set (Following / Popular / Trending) before querying.
|
||||
RestrictByIDs bool
|
||||
RestrictToIDs []string
|
||||
}
|
||||
|
||||
// CalendarRepository provides calendar queries across movies, episodes, and seasons.
|
||||
@@ -102,9 +106,18 @@ func (r *CalendarRepository) buildListEventsQuery(f CalendarFilter) (string, []a
|
||||
args = append(args, f.End)
|
||||
argIdx++
|
||||
|
||||
movieBranch := r.buildMovieBranch(startArg, endArg, f, &args, &argIdx)
|
||||
filteredEpisodes := r.buildFilteredEpisodesCTE(startArg, endArg, f, &args, &argIdx)
|
||||
filteredSeasons := r.buildFilteredSeasonsCTE(startArg, endArg, f, &args, &argIdx)
|
||||
// Optional id-set restriction, appended once and shared by all branches.
|
||||
// restrictArg stays 0 when the set is empty so branches short-circuit.
|
||||
restrictArg := 0
|
||||
if f.RestrictByIDs && len(f.RestrictToIDs) > 0 {
|
||||
restrictArg = argIdx
|
||||
args = append(args, f.RestrictToIDs)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
movieBranch := r.buildMovieBranch(startArg, endArg, restrictArg, f, &args, &argIdx)
|
||||
filteredEpisodes := r.buildFilteredEpisodesCTE(startArg, endArg, restrictArg, f, &args, &argIdx)
|
||||
filteredSeasons := r.buildFilteredSeasonsCTE(startArg, endArg, restrictArg, f, &args, &argIdx)
|
||||
episodeBranch := r.buildEpisodeBranch()
|
||||
seasonBranch := r.buildSeasonBranch()
|
||||
|
||||
@@ -139,14 +152,14 @@ FROM (
|
||||
return query, args
|
||||
}
|
||||
|
||||
func (r *CalendarRepository) buildMovieBranch(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
func (r *CalendarRepository) buildMovieBranch(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
conditions := []string{
|
||||
"mi.type = 'movie'",
|
||||
fmt.Sprintf("mi.release_date BETWEEN $%d::date AND $%d::date", startArg, endArg),
|
||||
}
|
||||
r.appendLibraryExistsClauses("mi.content_id", f, &conditions, args, argIdx)
|
||||
r.appendContentRatingClause("mi", f, &conditions, args, argIdx)
|
||||
r.appendPersonalFilterClause("mi.content_id", f, &conditions, args, argIdx)
|
||||
r.appendRestrictClause("mi.content_id", restrictArg, f, &conditions)
|
||||
|
||||
return fmt.Sprintf(`SELECT mi.content_id, 'movie'::text AS type,
|
||||
mi.title, NULL::text AS episode_title, NULL::text AS series_id,
|
||||
@@ -158,14 +171,14 @@ FROM media_items mi
|
||||
WHERE %s`, strings.Join(conditions, " AND "))
|
||||
}
|
||||
|
||||
func (r *CalendarRepository) buildFilteredEpisodesCTE(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
func (r *CalendarRepository) buildFilteredEpisodesCTE(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
conditions := []string{
|
||||
fmt.Sprintf("e.air_date BETWEEN $%d::date AND $%d::date", startArg, endArg),
|
||||
"e.season_number > 0", // exclude specials
|
||||
}
|
||||
r.appendLibraryExistsClauses("e.series_id", f, &conditions, args, argIdx)
|
||||
r.appendContentRatingClause("mi", f, &conditions, args, argIdx)
|
||||
r.appendPersonalFilterClause("e.series_id", f, &conditions, args, argIdx)
|
||||
r.appendRestrictClause("e.series_id", restrictArg, f, &conditions)
|
||||
|
||||
return fmt.Sprintf(`SELECT e.content_id, e.series_id, e.season_number,
|
||||
e.episode_number, e.title AS episode_title, e.air_date,
|
||||
@@ -188,14 +201,14 @@ FROM filtered_episodes fe
|
||||
LEFT JOIN season_finales sf ON sf.series_id = fe.series_id AND sf.season_number = fe.season_number`
|
||||
}
|
||||
|
||||
func (r *CalendarRepository) buildFilteredSeasonsCTE(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
func (r *CalendarRepository) buildFilteredSeasonsCTE(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string {
|
||||
conditions := []string{
|
||||
fmt.Sprintf("s.air_date BETWEEN $%d::date AND $%d::date", startArg, endArg),
|
||||
"s.season_number > 0", // exclude specials
|
||||
}
|
||||
r.appendLibraryExistsClauses("s.series_id", f, &conditions, args, argIdx)
|
||||
r.appendContentRatingClause("mi", f, &conditions, args, argIdx)
|
||||
r.appendPersonalFilterClause("s.series_id", f, &conditions, args, argIdx)
|
||||
r.appendRestrictClause("s.series_id", restrictArg, f, &conditions)
|
||||
|
||||
return fmt.Sprintf(`SELECT s.content_id, s.series_id, s.season_number,
|
||||
s.title AS episode_title, s.air_date, mi.title AS title, mi.air_time, mi.air_timezone,
|
||||
@@ -282,30 +295,16 @@ func (r *CalendarRepository) appendLibraryExistsClauses(contentIDExpr string, f
|
||||
*argIdx++
|
||||
}
|
||||
|
||||
// appendPersonalFilterClause adds favorites/watchlist EXISTS subqueries.
|
||||
func (r *CalendarRepository) appendPersonalFilterClause(itemIDExpr string, f CalendarFilter, conditions *[]string, args *[]any, argIdx *int) {
|
||||
switch f.Filter {
|
||||
case "favorites":
|
||||
userArg := *argIdx
|
||||
*args = append(*args, f.UserID)
|
||||
*argIdx++
|
||||
profileArg := *argIdx
|
||||
*args = append(*args, f.ProfileID)
|
||||
*argIdx++
|
||||
*conditions = append(*conditions, fmt.Sprintf(
|
||||
"EXISTS (SELECT 1 FROM user_favorites uf WHERE uf.user_id = $%d AND uf.profile_id = $%d AND uf.media_item_id = %s)",
|
||||
userArg, profileArg, itemIDExpr,
|
||||
))
|
||||
case "watchlist":
|
||||
userArg := *argIdx
|
||||
*args = append(*args, f.UserID)
|
||||
*argIdx++
|
||||
profileArg := *argIdx
|
||||
*args = append(*args, f.ProfileID)
|
||||
*argIdx++
|
||||
*conditions = append(*conditions, fmt.Sprintf(
|
||||
"EXISTS (SELECT 1 FROM user_watchlist uw WHERE uw.user_id = $%d AND uw.profile_id = $%d AND uw.media_item_id = %s)",
|
||||
userArg, profileArg, itemIDExpr,
|
||||
))
|
||||
// appendRestrictClause limits a branch to the id-set in CalendarFilter. restrictArg
|
||||
// is the positional parameter holding the id array (0 when the set is empty). An
|
||||
// empty restriction matches nothing.
|
||||
func (r *CalendarRepository) appendRestrictClause(itemIDExpr string, restrictArg int, f CalendarFilter, conditions *[]string) {
|
||||
if !f.RestrictByIDs {
|
||||
return
|
||||
}
|
||||
if restrictArg == 0 {
|
||||
*conditions = append(*conditions, "1 = 0")
|
||||
return
|
||||
}
|
||||
*conditions = append(*conditions, fmt.Sprintf("%s = ANY($%d)", itemIDExpr, restrictArg))
|
||||
}
|
||||
|
||||
@@ -102,3 +102,47 @@ func TestBuildListEventsQuery_RejectsExplicitLibraryOutsideAllowedScope(t *testi
|
||||
t.Fatalf("expected season branch to short-circuit inaccessible library selection, got:\n%s", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListEventsQuery_AppliesIDRestriction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := &CalendarRepository{}
|
||||
query, args := repo.buildListEventsQuery(CalendarFilter{
|
||||
Start: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, time.April, 12, 0, 0, 0, 0, time.UTC),
|
||||
RestrictByIDs: true,
|
||||
RestrictToIDs: []string{"series-1", "movie-2"},
|
||||
})
|
||||
|
||||
for _, fragment := range []string{
|
||||
"mi.content_id = ANY($3)",
|
||||
"e.series_id = ANY($3)",
|
||||
"s.series_id = ANY($3)",
|
||||
} {
|
||||
if !strings.Contains(query, fragment) {
|
||||
t.Fatalf("expected query to contain %q, got:\n%s", fragment, query)
|
||||
}
|
||||
}
|
||||
if len(args) != 3 {
|
||||
t.Fatalf("expected start/end/ids args, got %d", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListEventsQuery_EmptyRestrictionMatchesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := &CalendarRepository{}
|
||||
query, args := repo.buildListEventsQuery(CalendarFilter{
|
||||
Start: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, time.April, 12, 0, 0, 0, 0, time.UTC),
|
||||
RestrictByIDs: true,
|
||||
RestrictToIDs: nil,
|
||||
})
|
||||
|
||||
if strings.Count(query, "1 = 0") != 3 {
|
||||
t.Fatalf("expected each branch to short-circuit with 1 = 0, got:\n%s", query)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("expected only start/end args, got %d", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user