Skip to content

Expose series/universe on the quote read path — QuoteResponse fields and filters #192

Description

@DutchJaFO

Background

#179 added the UniverseSeriesSource schema and #180 populated it (75 Sources across 26
Series and 5 Universes, from a curated overlay file). Nothing reads it. QuoteResponse has no
series or universe field, and no endpoint filters on either — so the data #180 populates currently
has no read path from a quote at all.

This is the capability #169's research was originally motivated by: grouping related Sources so a
consumer can ask for "a random Star Wars quote" or display "from the Middle Earth universe" without
knowing which individual films exist. Today a consumer would have to fetch the quote, look up its
Source (#184), then look up that Source's Series (#187), then that Series' Universe (#188) — three
extra round-trips to answer a question the quote itself should be able to answer, and still no way to
filter by either.

The masterdata list endpoints (#184/#187/#188) are a different concern: they expose Series/Universe as
first-class entities to enumerate and (later) manage. This issue is about enriching and filtering the
quote read path, which none of them touch.

Depends on #183, which establishes this project's convention for an entity-scoped filter query
parameter — how it is named, parsed, validated (422 on malformed input rather than letting the
framework's binder throw), and applied to the underlying query. #183 names ?universeId= as its own
worked example, so this issue is a direct consumer of that convention rather than a place to re-decide
it. Sequencing this after #183 is what keeps the quote endpoints' filters and the masterdata
endpoints' filters from drifting into two different shapes.

Found while reviewing #180's T1 (2026-07-16): the developer's first observation on seeing a live quote
response was that it carries neither field.

What needs to be done

  1. Add series and universe to QuoteResponse — populated by joining Sources.SeriesId
    SeriesSeries.UniverseIdUniverse. Both nullable: a standalone Source has no Series, and
    a standalone Series has no Universe (both are the common case today — only 75 of 479 Sources are
    in a Series at all).
  2. Extend Sql.Quotes.SelectBase's projection with the two LEFT JOINs. They must be LEFT — a
    Source with no Series must still return its quote, not drop it from every result.
  3. Add Series and Universe filters to GET /api/v1/quotes, GET /api/v1/quotes/random, and
    GET /api/v1/quotes/search, wired through SqliteQuoteService.BuildFilterWhere alongside the
    existing character/author/source filters. Follow List-endpoint shared infrastructure for the masterdata and Conversations list endpoints #183's documented filter-parameter
    convention
    — naming, parsing, validation, and how the filter reaches the query — rather than
    re-deciding any of it here. List-endpoint shared infrastructure for the masterdata and Conversations list endpoints #183 settles the open question of whether an entity-scoped filter is
    id-valued or name-valued; this issue adopts whatever it lands on, so the quote endpoints' filters
    and the masterdata endpoints' filters stay one pattern. Note the existing
    source/character/author filters on these same endpoints are case-insensitive contains
    matches on a name — if List-endpoint shared infrastructure for the masterdata and Conversations list endpoints #183's convention differs, the mismatch on one endpoint is worth a
    deliberate note rather than a silent inconsistency.
  4. A Universe filter must match quotes across every Series in that Universe, not only Sources
    directly named by it — the join is Quote → Source → Series → Universe, so the filter applies two
    levels up. This is the main reason a Universe filter is worth having at all ("a random Middle Earth
    quote" spans both the LOTR and Hobbit Series).
  5. Update README.md, addon/DOCS.md, and QuoteEndpoints.cs's [Description] attributes in the
    same commit, per this project's "Keeping API documentation in sync" rule.
  6. If List-endpoint shared infrastructure for the masterdata and Conversations list endpoints #183's convention lands on an id-valued filter, it matches case-insensitively from the start per
    CLAUDE.md's "GUID/enum/id comparisons are case-insensitive by default" rule — with a
    lowercase/mixed-case regression test in the same commit, not deferred.

Expected tests

Test class Test method Starts
Quotinator.Engine.Tests.Services.SqliteQuoteServiceTests GetById_SourceInSeriesWithUniverse_ResponseCarriesBoth
Quotinator.Engine.Tests.Services.SqliteQuoteServiceTests GetById_SourceWithNoSeries_ReturnsQuoteWithNullSeriesAndUniverse
Quotinator.Engine.Tests.Services.SqliteQuoteServiceTests GetById_SeriesWithNoUniverse_ReturnsSeriesWithNullUniverse
Quotinator.Api.Tests.Endpoints.QuoteEndpointsTests GetAll_SeriesFilter_ReturnsOnlyThatSeriesQuotes
Quotinator.Api.Tests.Endpoints.QuoteEndpointsTests GetAll_UniverseFilter_ReturnsQuotesAcrossEverySeriesInThatUniverse
Quotinator.Api.Tests.Endpoints.QuoteEndpointsTests GetRandom_UniverseFilter_ReturnsOnlyThatUniverseQuotes
Quotinator.Data.Tests.Security.SqlQueryGuardTests AssembledQuery_PassesAggregateGuard (existing — new filter cases added to AssembledQueryCases)

Definition of done

  • All expected tests listed above start red before implementation
  • All requirements implemented
  • All expected tests pass (green)
  • No regression in related tests
  • Findings summarised in a closing comment

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions