fix(catalog): support relative date filters
This commit is contained in:
@@ -927,6 +927,7 @@ var catalogQueryRuleFields = map[string]bool{
|
||||
"country": true,
|
||||
"original_language": true,
|
||||
"content_rating": true,
|
||||
"added_at": true,
|
||||
"release_date": true,
|
||||
"status": true,
|
||||
"actor": true,
|
||||
@@ -956,6 +957,7 @@ var catalogPersonalRuleFields = map[string]bool{
|
||||
"country": true,
|
||||
"original_language": true,
|
||||
"content_rating": true,
|
||||
"added_at": true,
|
||||
"release_date": true,
|
||||
"status": true,
|
||||
"actor": true,
|
||||
@@ -1713,6 +1715,12 @@ func catalogRuleMatchesItem(item *models.MediaItem, rule QueryRule) bool {
|
||||
return false
|
||||
}
|
||||
return compareCatalogNumeric(*item.RatingIMDB, rule.Op, rule.Value)
|
||||
case "added_at":
|
||||
addedAt := item.CreatedAt
|
||||
if item.AddedAt != nil {
|
||||
addedAt = *item.AddedAt
|
||||
}
|
||||
return compareCatalogTime(addedAt, rule.Op, rule.Value)
|
||||
case "release_date":
|
||||
releaseDate := catalogItemReleaseDate(item)
|
||||
if releaseDate == "" {
|
||||
@@ -1793,11 +1801,24 @@ func compareCatalogStringDate(actual, op string, value any) bool {
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "gt", "gte", "lt", "lte", "between", "is", "is_not":
|
||||
case "gt", "gte", "lt", "lte", "between", "is", "is_not", "in_last":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
if op == "in_last" {
|
||||
duration, ok := catalogStringValue(value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
spec, err := parseDurationSpec(duration)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cutoff := catalogDateOnly(spec.cutoffTime(time.Now().UTC()))
|
||||
return !actualTime.Before(cutoff)
|
||||
}
|
||||
|
||||
if op == "between" {
|
||||
values, ok := catalogStringRange(value)
|
||||
if !ok {
|
||||
@@ -1838,6 +1859,90 @@ func compareCatalogStringDate(actual, op string, value any) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func compareCatalogTime(actual time.Time, op string, value any) bool {
|
||||
if actual.IsZero() {
|
||||
return false
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "gt", "gte", "lt", "lte", "between", "in_last":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
if op == "in_last" {
|
||||
duration, ok := catalogStringValue(value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
spec, err := parseDurationSpec(duration)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !actual.Before(spec.cutoffTime(time.Now()))
|
||||
}
|
||||
|
||||
if op == "between" {
|
||||
values, ok := catalogStringRange(value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
start, ok := catalogTimeValue(values[0])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
end, ok := catalogTimeValue(values[1])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return !actual.Before(start) && !actual.After(end)
|
||||
}
|
||||
|
||||
expected, ok := catalogTimeValue(value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "gt":
|
||||
return actual.After(expected)
|
||||
case "gte":
|
||||
return actual.After(expected) || actual.Equal(expected)
|
||||
case "lt":
|
||||
return actual.Before(expected)
|
||||
case "lte":
|
||||
return actual.Before(expected) || actual.Equal(expected)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func catalogTimeValue(value any) (time.Time, bool) {
|
||||
switch v := value.(type) {
|
||||
case time.Time:
|
||||
return v, !v.IsZero()
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
if parsed, err := time.Parse("2006-01-02", trimmed); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
default:
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func catalogDateOnly(value time.Time) time.Time {
|
||||
utc := value.UTC()
|
||||
return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func catalogFloat(value any) (float64, bool) {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
|
||||
@@ -39,6 +39,24 @@ func TestValidateCatalogQueryRequest_AllowsEpisodeMediaScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogQueryRequest_AllowsAddedAtFilter(t *testing.T) {
|
||||
req := CatalogRequest{
|
||||
Source: CatalogSourceQuery,
|
||||
Query: QueryDefinition{
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{{
|
||||
Match: "all",
|
||||
Rules: []QueryRule{{Field: "added_at", Op: "in_last", Value: "1y"}},
|
||||
}},
|
||||
Sort: QuerySort{Field: "title", Order: "asc"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := validateCatalogQueryRequest(req, true); err != nil {
|
||||
t.Fatalf("expected added_at filter to be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogQueryRequest_RejectsPersonalizedSortWithoutProfileScopedSurface(t *testing.T) {
|
||||
req := CatalogRequest{
|
||||
Source: CatalogSourceQuery,
|
||||
@@ -67,6 +85,32 @@ func TestValidateCatalogQueryRequest_AllowsPersonalizedSortWithProfileScopedSurf
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRuleMatchesItem_InLastDateFields(t *testing.T) {
|
||||
recentRelease := time.Now().UTC().AddDate(0, 0, -10).Format("2006-01-02")
|
||||
oldRelease := time.Now().UTC().AddDate(-2, 0, 0).Format("2006-01-02")
|
||||
recentItem := &models.MediaItem{
|
||||
CreatedAt: time.Now().AddDate(0, 0, -10),
|
||||
ReleaseDate: &recentRelease,
|
||||
}
|
||||
oldItem := &models.MediaItem{
|
||||
CreatedAt: time.Now().AddDate(-2, 0, 0),
|
||||
ReleaseDate: &oldRelease,
|
||||
}
|
||||
|
||||
if !catalogRuleMatchesItem(recentItem, QueryRule{Field: "added_at", Op: "in_last", Value: "1y"}) {
|
||||
t.Fatal("expected recent added_at to match 1y in_last")
|
||||
}
|
||||
if catalogRuleMatchesItem(oldItem, QueryRule{Field: "added_at", Op: "in_last", Value: "1y"}) {
|
||||
t.Fatal("expected old added_at not to match 1y in_last")
|
||||
}
|
||||
if !catalogRuleMatchesItem(recentItem, QueryRule{Field: "release_date", Op: "in_last", Value: "1y"}) {
|
||||
t.Fatal("expected recent release_date to match 1y in_last")
|
||||
}
|
||||
if catalogRuleMatchesItem(oldItem, QueryRule{Field: "release_date", Op: "in_last", Value: "1y"}) {
|
||||
t.Fatal("expected old release_date not to match 1y in_last")
|
||||
}
|
||||
}
|
||||
|
||||
// concurrentCallBarrier records the maximum number of callers observed
|
||||
// in-flight at the same time. Each call into the stubbed facet fetcher invokes
|
||||
// hit, which:
|
||||
|
||||
@@ -935,7 +935,11 @@ func buildEpisodeCatalogComparisonClause(column string, rule QueryRule, argIdx i
|
||||
if err != nil {
|
||||
return "", nil, argIdx, true, err
|
||||
}
|
||||
return fmt.Sprintf("%s >= NOW() - INTERVAL '%s'", column, interval), nil, argIdx, true, nil
|
||||
cutoff := fmt.Sprintf("NOW() - INTERVAL '%s'", interval)
|
||||
if cast == "date" {
|
||||
cutoff = fmt.Sprintf("(CURRENT_DATE - INTERVAL '%s')::date", interval)
|
||||
}
|
||||
return fmt.Sprintf("%s >= %s", column, cutoff), nil, argIdx, true, nil
|
||||
default:
|
||||
return "", nil, argIdx, true, fmt.Errorf("unsupported comparison operator %q", rule.Op)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,32 @@ func TestEpisodeCatalogAddedAtFilterUsesEpisodeCreatedAt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodeCatalogReleaseDateInLastUsesDateCutoff(t *testing.T) {
|
||||
def := QueryDefinition{
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{{
|
||||
Match: "all",
|
||||
Rules: []QueryRule{
|
||||
{Field: "release_date", Op: "in_last", Value: "1y"},
|
||||
},
|
||||
}},
|
||||
}.Normalize()
|
||||
|
||||
where, _, _, ok, err := buildEpisodeCatalogEntryQueryWhere(def, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("buildEpisodeCatalogEntryQueryWhere returned error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("buildEpisodeCatalogEntryQueryWhere unexpectedly fell back")
|
||||
}
|
||||
if !strings.Contains(where, "ece.episode_air_date >= (CURRENT_DATE - INTERVAL '1 years')::date") {
|
||||
t.Fatalf("expected release_date filter to use date cutoff, got %q", where)
|
||||
}
|
||||
if strings.Contains(where, "NOW() - INTERVAL") {
|
||||
t.Fatalf("release_date filter must not compare a date to NOW(), got %q", where)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodeCatalogEntryQueryFallsBackForSameFileTechnicalAnd(t *testing.T) {
|
||||
group := QueryGroup{
|
||||
Match: "all",
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -365,6 +366,10 @@ func (qb *QueryBuilder) buildRule(rule QueryRule) (string, error) {
|
||||
return qb.buildInProgressClause(rule)
|
||||
case "last_watched":
|
||||
return qb.buildLastWatchedClause(rule)
|
||||
case "added_at":
|
||||
return qb.buildAddedAtClause(rule)
|
||||
case "release_date":
|
||||
return qb.buildReleaseDateClause(rule)
|
||||
case "resolution":
|
||||
return qb.buildResolutionClause(rule)
|
||||
case "hdr":
|
||||
@@ -940,6 +945,64 @@ func (qb *QueryBuilder) buildLastWatchedClause(rule QueryRule) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) buildAddedAtClause(rule QueryRule) (string, error) {
|
||||
column := qb.addedAtFilterExpr()
|
||||
|
||||
switch rule.Op {
|
||||
case "gt":
|
||||
return qb.buildTypedComparisonClause(column, ">", rule.Value, "timestamptz"), nil
|
||||
case "gte":
|
||||
return qb.buildTypedComparisonClause(column, ">=", rule.Value, "timestamptz"), nil
|
||||
case "lt":
|
||||
return qb.buildTypedComparisonClause(column, "<", rule.Value, "timestamptz"), nil
|
||||
case "lte":
|
||||
return qb.buildTypedComparisonClause(column, "<=", rule.Value, "timestamptz"), nil
|
||||
case "between":
|
||||
return qb.buildTypedBetweenClause(column, rule.Value, "timestamptz")
|
||||
case "in_last":
|
||||
duration, ok := rule.Value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("in_last requires a duration string like '30d'")
|
||||
}
|
||||
interval, err := parseDuration(duration)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s >= NOW() - INTERVAL '%s'", column, interval), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported operator %q for added_at", rule.Op)
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) buildReleaseDateClause(rule QueryRule) (string, error) {
|
||||
column := qb.releaseDateFilterExpr()
|
||||
|
||||
switch rule.Op {
|
||||
case "gt":
|
||||
return qb.buildTypedComparisonClause(column, ">", rule.Value, "date"), nil
|
||||
case "gte":
|
||||
return qb.buildTypedComparisonClause(column, ">=", rule.Value, "date"), nil
|
||||
case "lt":
|
||||
return qb.buildTypedComparisonClause(column, "<", rule.Value, "date"), nil
|
||||
case "lte":
|
||||
return qb.buildTypedComparisonClause(column, "<=", rule.Value, "date"), nil
|
||||
case "between":
|
||||
return qb.buildTypedBetweenClause(column, rule.Value, "date")
|
||||
case "in_last":
|
||||
duration, ok := rule.Value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("in_last requires a duration string like '30d'")
|
||||
}
|
||||
interval, err := parseDuration(duration)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s >= (CURRENT_DATE - INTERVAL '%s')::date", column, interval), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported operator %q for release_date", rule.Op)
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) userStateCompletionClause() string {
|
||||
qb.args = append(qb.args, qb.userID, qb.profileID)
|
||||
clause := fmt.Sprintf(`(
|
||||
@@ -1015,6 +1078,34 @@ func (qb *QueryBuilder) buildTimestampComparisonClause(column, op string, value
|
||||
return clause
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) buildTypedComparisonClause(column, op string, value any, cast string) string {
|
||||
qb.args = append(qb.args, value)
|
||||
placeholder := fmt.Sprintf("$%d", qb.argIdx)
|
||||
if cast != "" {
|
||||
placeholder += "::" + cast
|
||||
}
|
||||
clause := fmt.Sprintf("%s %s %s", column, op, placeholder)
|
||||
qb.argIdx++
|
||||
return clause
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) buildTypedBetweenClause(column string, value any, cast string) (string, error) {
|
||||
values, err := toBetweenValues(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("between requires [min, max] array: %w", err)
|
||||
}
|
||||
qb.args = append(qb.args, values[0], values[1])
|
||||
left := fmt.Sprintf("$%d", qb.argIdx)
|
||||
right := fmt.Sprintf("$%d", qb.argIdx+1)
|
||||
if cast != "" {
|
||||
left += "::" + cast
|
||||
right += "::" + cast
|
||||
}
|
||||
clause := fmt.Sprintf("%s >= %s AND %s <= %s", column, left, column, right)
|
||||
qb.argIdx += 2
|
||||
return clause, nil
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) normalizedTitleExpr() string {
|
||||
if isEpisodeCatalogScope(qb.mediaScope) {
|
||||
return fmt.Sprintf("%s.sort_key", qb.alias)
|
||||
@@ -1033,6 +1124,26 @@ func (qb *QueryBuilder) releaseDateSortExpr() string {
|
||||
return queryColumnSQL(qb.alias, querySortDefs["release_date"].columnSQL)
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) releaseDateFilterExpr() string {
|
||||
if isEpisodeCatalogScope(qb.mediaScope) {
|
||||
return fmt.Sprintf("%s.episode_air_date", qb.alias)
|
||||
}
|
||||
firstAirDate := fmt.Sprintf("NULLIF(BTRIM(%s.first_air_date), '')", qb.alias)
|
||||
return fmt.Sprintf(
|
||||
`COALESCE(%s.release_date, CASE WHEN %s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN %s::date ELSE NULL END)`,
|
||||
qb.alias,
|
||||
firstAirDate,
|
||||
firstAirDate,
|
||||
)
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) addedAtFilterExpr() string {
|
||||
if isEpisodeCatalogScope(qb.mediaScope) {
|
||||
return fmt.Sprintf("%s.episode_created_at", qb.alias)
|
||||
}
|
||||
return fmt.Sprintf("%s.created_at", qb.alias)
|
||||
}
|
||||
|
||||
func (qb *QueryBuilder) orderByExpr(expr, dir string, nullsLast bool, titleExpr string) string {
|
||||
clause := fmt.Sprintf("ORDER BY %s %s", expr, dir)
|
||||
if nullsLast {
|
||||
@@ -1349,24 +1460,70 @@ func toBetweenValues(v any) ([2]any, error) {
|
||||
}
|
||||
}
|
||||
|
||||
type relativeDuration struct {
|
||||
amount int
|
||||
unit byte
|
||||
}
|
||||
|
||||
func parseDuration(s string) (string, error) {
|
||||
if len(s) < 2 {
|
||||
return "", fmt.Errorf("invalid duration: %q", s)
|
||||
spec, err := parseDurationSpec(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return spec.sqlInterval(), nil
|
||||
}
|
||||
|
||||
func parseDurationSpec(s string) (relativeDuration, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(s))
|
||||
if len(normalized) < 2 {
|
||||
return relativeDuration{}, fmt.Errorf("invalid duration: %q", s)
|
||||
}
|
||||
|
||||
value := s[:len(s)-1]
|
||||
unit := s[len(s)-1]
|
||||
value := strings.TrimSpace(normalized[:len(normalized)-1])
|
||||
unit := normalized[len(normalized)-1]
|
||||
amount, err := strconv.Atoi(value)
|
||||
if err != nil || amount <= 0 {
|
||||
return relativeDuration{}, fmt.Errorf("invalid duration: %q", s)
|
||||
}
|
||||
|
||||
switch unit {
|
||||
case 'h':
|
||||
return value + " hours", nil
|
||||
case 'd':
|
||||
return value + " days", nil
|
||||
case 'w':
|
||||
return value + " weeks", nil
|
||||
case 'm':
|
||||
return value + " months", nil
|
||||
case 'h', 'd', 'w', 'm', 'y':
|
||||
return relativeDuration{amount: amount, unit: unit}, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported duration unit %q", unit)
|
||||
return relativeDuration{}, fmt.Errorf("unsupported duration unit %q", unit)
|
||||
}
|
||||
}
|
||||
|
||||
func (d relativeDuration) sqlInterval() string {
|
||||
switch d.unit {
|
||||
case 'h':
|
||||
return fmt.Sprintf("%d hours", d.amount)
|
||||
case 'd':
|
||||
return fmt.Sprintf("%d days", d.amount)
|
||||
case 'w':
|
||||
return fmt.Sprintf("%d weeks", d.amount)
|
||||
case 'm':
|
||||
return fmt.Sprintf("%d months", d.amount)
|
||||
case 'y':
|
||||
return fmt.Sprintf("%d years", d.amount)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (d relativeDuration) cutoffTime(now time.Time) time.Time {
|
||||
switch d.unit {
|
||||
case 'h':
|
||||
return now.Add(-time.Duration(d.amount) * time.Hour)
|
||||
case 'd':
|
||||
return now.AddDate(0, 0, -d.amount)
|
||||
case 'w':
|
||||
return now.AddDate(0, 0, -7*d.amount)
|
||||
case 'm':
|
||||
return now.AddDate(0, -d.amount, 0)
|
||||
case 'y':
|
||||
return now.AddDate(-d.amount, 0, 0)
|
||||
default:
|
||||
return now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,61 @@ func TestBuildSortClause_AddedAtFallsBackToCreatedAtWithoutLibraryScope(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_AddedAtInLastAcceptsYears(t *testing.T) {
|
||||
clause, args, err := NewQueryBuilder("mi").Build(QueryDefinition{
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{{
|
||||
Match: "all",
|
||||
Rules: []QueryRule{{Field: "added_at", Op: "in_last", Value: "1y"}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build returned error: %v", err)
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Fatalf("expected no args, got %v", args)
|
||||
}
|
||||
if !strings.Contains(clause, "mi.created_at >= NOW() - INTERVAL '1 years'") {
|
||||
t.Fatalf("expected added_at in_last to use created_at and years interval, got %q", clause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_ReleaseDateInLastComparesDateExpression(t *testing.T) {
|
||||
clause, args, err := NewQueryBuilder("mi").Build(QueryDefinition{
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{{
|
||||
Match: "all",
|
||||
Rules: []QueryRule{{Field: "release_date", Op: "in_last", Value: "1y"}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build returned error: %v", err)
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Fatalf("expected no args, got %v", args)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"COALESCE(mi.release_date, CASE WHEN NULLIF(BTRIM(mi.first_air_date), '') ~",
|
||||
">= (CURRENT_DATE - INTERVAL '1 years')::date",
|
||||
} {
|
||||
if !strings.Contains(clause, want) {
|
||||
t.Fatalf("expected release_date in_last clause to contain %q, got %q", want, clause)
|
||||
}
|
||||
}
|
||||
if strings.Contains(clause, "release_date::text") || strings.Contains(clause, "NOW() - INTERVAL") {
|
||||
t.Fatalf("release_date in_last must not compare text to timestamp, got %q", clause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationRejectsMalformedInput(t *testing.T) {
|
||||
if _, err := parseDuration("1'; SELECT 1; --d"); err == nil {
|
||||
t.Fatal("expected malformed duration to be rejected")
|
||||
}
|
||||
if got, err := parseDuration("2Y"); err != nil || got != "2 years" {
|
||||
t.Fatalf("parseDuration(2Y) = %q, %v; want 2 years", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSortClause_ContentRatingUsesRankedOrdering(t *testing.T) {
|
||||
clause, _, err := NewQueryBuilder("mi").BuildSortClause(QuerySort{
|
||||
Field: "content_rating",
|
||||
|
||||
Reference in New Issue
Block a user