diff --git a/src/emby_fts_rewrite.c b/src/emby_fts_rewrite.c index 7e8d464..c393b29 100644 --- a/src/emby_fts_rewrite.c +++ b/src/emby_fts_rewrite.c @@ -881,6 +881,7 @@ static const char EMBY_GUARD_PLAYLISTS[] = "ListItemsExemptionForPlaylists"; static const char EMBY_TYPE_EPISODES_LATEST[] = "where A.Type=8 "; static const char EMBY_TYPE_MOVIES_LATEST[] = "where A.Type=5 "; static const char EMBY_TYPE_MIXED_LATEST[] = "where A.Type in (8,5) "; +static const char EMBY_TYPE_MIXED_LATEST_REVERSED[] = "where A.Type in (5,8) "; static const char EMBY_ANCHOR_LATEST_SELECT[] = ") )select "; static const char EMBY_ANCHOR_LATEST_SELECT_SCALAR[] = ")select "; static const char EMBY_ANCHOR_LATEST_FROM[] = @@ -891,8 +892,14 @@ static const char EMBY_ANCHOR_MOVIES_LATEST_TAIL[] = "where A.Type=5 AND Coalesce(UserDatas.played, 0)=0 AND A.Id in WithAncestors Group by A.PresentationUniqueKey ORDER BY A.DateCreated DESC LIMIT"; static const char EMBY_ANCHOR_MIXED_LATEST_TAIL[] = "where A.Type in (8,5) AND Coalesce(UserDatas.played, 0)=0 AND A.Id in WithAncestors Group by coalesce(A.SeriesPresentationUniqueKey, A.PresentationUniqueKey) ORDER BY MAX(A.DateCreated) DESC LIMIT"; +static const char EMBY_ANCHOR_MIXED_LATEST_REVERSED_TAIL[] = + "where A.Type in (5,8) AND Coalesce(UserDatas.played, 0)=0 AND A.Id in WithAncestors Group by coalesce(A.SeriesPresentationUniqueKey, A.PresentationUniqueKey) ORDER BY MAX(A.DateCreated) DESC LIMIT"; static const char EMBY_MIXED_LATEST_PROJECTION[] = "A.type,A.Id,A.IndexNumber,A.Name,A.ParentIndexNumber,A.RunTimeTicks,A.ParentId,A.SeriesName,A.AlbumId,A.SeriesId,A.Images,A.SortIndexNumber,A.SortParentIndexNumber,A.IndexNumberEnd,UserDatas.IsFavorite,UserDatas.Played,UserDatas.PlaybackPositionTicks,UserDatas.AudioStreamIndex,UserDatas.SubtitleStreamIndex "; +static const char EMBY_MIXED_LATEST_PRODUCTION_YEAR_PROJECTION[] = + "A.type,A.Id,A.IndexNumber,A.Name,A.ParentIndexNumber,A.ProductionYear,A.RunTimeTicks,A.ParentId,A.SeriesName,A.AlbumId,A.SeriesId,A.Images,A.SortIndexNumber,A.SortParentIndexNumber,A.IndexNumberEnd,UserDatas.IsFavorite,UserDatas.Played,UserDatas.PlaybackPositionTicks,UserDatas.AudioStreamIndex,UserDatas.SubtitleStreamIndex "; +static const char EMBY_MIXED_LATEST_MB1_PROJECTION[] = + "A.type,A.Id,A.EndDate,A.IndexNumber,A.Name,A.Path,A.ParentIndexNumber,A.ProductionYear,A.RunTimeTicks,A.ParentId,A.SeriesName,A.AlbumId,A.SeriesId,A.Images,A.SortIndexNumber,A.SortParentIndexNumber,A.IndexNumberEnd,UserDatas.IsFavorite,UserDatas.Played,UserDatas.PlaybackPositionTicks,UserDatas.AudioStreamIndex,UserDatas.SubtitleStreamIndex "; /* Correctness relies on Emby's UserDatas PK uniqueness on (UserDataKeyId, UserId). */ static const char EMBY_LATEST_TPL_0[] = "WITH ranked(id, dc, gk) AS MATERIALIZED (" @@ -958,9 +965,9 @@ static const char EMBY_MIXED_LATEST_TPL_3[] = " FROM MediaItems AS B INDEXED BY " EMBY_LATEST_MIXED_GK_DC_INDEX_NAME " " " WHERE B.Type IN (8,5) AND coalesce(B.SeriesPresentationUniqueKey, B.PresentationUniqueKey) IS coalesce(A.SeriesPresentationUniqueKey, A.PresentationUniqueKey) AND ( (B.DateCreated IS NOT NULL AND A.DateCreated IS NULL) OR B.DateCreated > A.DateCreated OR (B.DateCreated IS A.DateCreated AND B.Id < A.Id) ) AND EXISTS ( SELECT 1 FROM AncestorIds2 AS XB WHERE XB.ItemId = B.Id AND XB.AncestorId IN ("; static const char EMBY_MIXED_LATEST_TPL_4[] = - ") ) AND NOT EXISTS ( SELECT 1 FROM UserDatas AS UB WHERE UB.UserDataKeyId = B.UserDataKeyId AND UB.UserId = Args.user_id AND UB.played <> 0 ) ) ORDER BY (A.DateCreated IS NULL) ASC, A.DateCreated DESC, coalesce(A.SeriesPresentationUniqueKey, A.PresentationUniqueKey) ASC LIMIT (SELECT row_limit FROM mixed_latest_args) ) SELECT "; + ") ) AND NOT EXISTS ( SELECT 1 FROM UserDatas AS UB WHERE UB.UserDataKeyId = B.UserDataKeyId AND UB.UserId = Args.user_id AND UB.played <> 0 ) ) ORDER BY (A.DateCreated IS NULL) ASC, A.DateCreated DESC, coalesce(A.SeriesPresentationUniqueKey, A.PresentationUniqueKey) DESC LIMIT (SELECT row_limit FROM mixed_latest_args) ) SELECT "; static const char EMBY_MIXED_LATEST_TPL_5[] = - "FROM ranked AS R JOIN MediaItems AS A ON A.Id = R.id CROSS JOIN mixed_latest_args AS Args LEFT JOIN UserDatas ON A.UserDataKeyId = UserDatas.UserDataKeyId AND UserDatas.UserId = Args.user_id ORDER BY (R.dc IS NULL) ASC, R.dc DESC, R.gk ASC LIMIT (SELECT row_limit FROM mixed_latest_args)"; + "FROM ranked AS R JOIN MediaItems AS A ON A.Id = R.id CROSS JOIN mixed_latest_args AS Args LEFT JOIN UserDatas ON A.UserDataKeyId = UserDatas.UserDataKeyId AND UserDatas.UserId = Args.user_id ORDER BY (R.dc IS NULL) ASC, R.dc DESC, R.gk DESC LIMIT (SELECT row_limit FROM mixed_latest_args)"; typedef enum emby_match_result { EMBY_MATCH_MISS = 0, @@ -974,6 +981,7 @@ typedef struct emby_rewrite_candidate { size_t rewrite_len; obs_rewrite_mode mode; int expected_bind_count; + int expected_column_count; int require_mixed_acceptance; } emby_rewrite_candidate; @@ -1790,6 +1798,29 @@ static int latest_tail_has_guard(const char *zSql, size_t scan_len, const char * return emby_sql_has_bytes(zSql, scan_len, guard); } +/* Large Episodes-Latest ancestor fanout can make the ranked plan unsafe. + Keep the proven smaller-list rewrite, but fail open to Emby's source SQL + at and above the observed 30-ancestor boundary. */ +#define EMBY_EPISODES_LATEST_MAX_REWRITE_ANCESTORS 29u + +static int emby_numeric_list_exceeds_member_limit( + const char *sql, + const emby_span *slot, + size_t max_members +) { + size_t i; + size_t members = 1; + + if (!sql || !slot || slot->end <= slot->start) return 1; + for (i = slot->start; i < slot->end; i++) { + if (sql[i] == ',') { + members++; + if (members > max_members) return 1; + } + } + return 0; +} + static int emby_latest_prefix_is_with(const char *zSql, size_t prefix_len) { fts_lex lx; fts_lex_token tok; @@ -1809,7 +1840,8 @@ static int emby_find_latest_ancestor_slot( size_t scan_len, emby_span *slot, emby_span *select_anchor, - const char **sub_reason + const char **sub_reason, + int *is_scalar ) { emby_span list_pre; emby_span scalar_pre; @@ -1832,6 +1864,7 @@ static int emby_find_latest_ancestor_slot( return 0; } scalar = scalar_count == 1; + if (is_scalar) *is_scalar = scalar; pre = scalar ? &scalar_pre : &list_pre; select_needle = scalar ? EMBY_ANCHOR_LATEST_SELECT_SCALAR : EMBY_ANCHOR_LATEST_SELECT; @@ -1871,6 +1904,7 @@ static emby_match_result emby_match_episodes_latest( emby_latest_index_state index_state; const char *sub_reason; size_t limit; + int scalar_ancestor = 0; if (!find_unique_token_after(zSql, scan_len, 0, EMBY_TYPE_EPISODES_LATEST, &a1)) { return EMBY_MATCH_MISS; @@ -1880,13 +1914,22 @@ static emby_match_result emby_match_episodes_latest( if (latest_tail_has_guard(zSql, scan_len, "ORDER BY SeriesName")) return EMBY_MATCH_MISS; if (latest_tail_has_guard(zSql, scan_len, "COLLATE NATURALSORT")) return EMBY_MATCH_MISS; if (!emby_find_latest_ancestor_slot( - zSql, scan_len, &l1, &a2, &sub_reason + zSql, scan_len, &l1, &a2, &sub_reason, &scalar_ancestor )) { return emby_capture_miss( db, 1, OBS_MISS_CAPTURE, OBS_MODE_EMBY_EPISODES_LATEST, sub_reason, zSql, scan_len ); } + /* A scalar parent is a narrow library-section query. Forcing the global + date-first episode index can scan the full episode library before + applying AncestorIds2. Fail open for this exact form while retaining + the list-form dashboard optimization. */ + if (scalar_ancestor || emby_numeric_list_exceeds_member_limit( + zSql, &l1, EMBY_EPISODES_LATEST_MAX_REWRITE_ANCESTORS + )) { + return EMBY_MATCH_MISS; + } if (!find_unique_token_after(zSql, scan_len, a2.end, EMBY_ANCHOR_LATEST_FROM, &a3)) { return emby_capture_miss( db, 1, OBS_MISS_CAPTURE, @@ -2005,7 +2048,7 @@ static emby_match_result emby_match_movies_latest( if (emby_token_present(zSql, scan_len, "over")) return EMBY_MATCH_MISS; if (emby_sql_has_bytes(zSql, scan_len, "LastWatchedEpisodes")) return EMBY_MATCH_MISS; if (!emby_find_latest_ancestor_slot( - zSql, scan_len, &l1, &a2, &sub_reason + zSql, scan_len, &l1, &a2, &sub_reason, NULL )) { return emby_capture_miss( db, 1, OBS_MISS_CAPTURE, @@ -2126,10 +2169,18 @@ static emby_match_result emby_match_mixed_latest( size_t limit; size_t projection_len; int bind_count = 0; + int canonical_type_order; + int reversed_type_order; int user_is_parameter; int limit_is_parameter; - if (!find_unique_token_after(zSql, scan_len, 0, EMBY_TYPE_MIXED_LATEST, &type_gate)) { + canonical_type_order = find_unique_token_after( + zSql, scan_len, 0, EMBY_TYPE_MIXED_LATEST, &type_gate + ); + reversed_type_order = find_unique_token_after( + zSql, scan_len, 0, EMBY_TYPE_MIXED_LATEST_REVERSED, &type_gate + ); + if (canonical_type_order == reversed_type_order) { return EMBY_MATCH_MISS; } if (emby_token_present(zSql, scan_len, "over") || @@ -2144,7 +2195,7 @@ static emby_match_result emby_match_mixed_latest( sub_reason = "prefix"; if (count_tokens_after(zSql, scan_len, 0, EMBY_ANCHOR_PRE_L1, &list_anchor) != 1 || !emby_find_latest_ancestor_slot( - zSql, scan_len, &ancestors, &select_anchor, &sub_reason + zSql, scan_len, &ancestors, &select_anchor, &sub_reason, NULL )) { obs_miss_reason reason = OBS_MISS_CAPTURE; if (strcmp(sub_reason, "ancestor_slot") == 0 && @@ -2168,10 +2219,26 @@ static emby_match_result emby_match_mixed_latest( projection.start = select_anchor.end; projection.end = from_anchor.start; projection_len = projection.end - projection.start; - if (projection.end <= projection.start || - projection_len != sizeof(EMBY_MIXED_LATEST_PROJECTION) - 1 || + if (projection.end > projection.start && + projection_len == sizeof(EMBY_MIXED_LATEST_PROJECTION) - 1 && memcmp(zSql + projection.start, EMBY_MIXED_LATEST_PROJECTION, - sizeof(EMBY_MIXED_LATEST_PROJECTION) - 1) != 0) { + sizeof(EMBY_MIXED_LATEST_PROJECTION) - 1) == 0) { + candidate->expected_column_count = 19; + } else if (projection.end > projection.start && + projection_len == + sizeof(EMBY_MIXED_LATEST_PRODUCTION_YEAR_PROJECTION) - 1 && + memcmp( + zSql + projection.start, + EMBY_MIXED_LATEST_PRODUCTION_YEAR_PROJECTION, + sizeof(EMBY_MIXED_LATEST_PRODUCTION_YEAR_PROJECTION) - 1 + ) == 0) { + candidate->expected_column_count = 20; + } else if (projection.end > projection.start && + projection_len == sizeof(EMBY_MIXED_LATEST_MB1_PROJECTION) - 1 && + memcmp(zSql + projection.start, EMBY_MIXED_LATEST_MB1_PROJECTION, + sizeof(EMBY_MIXED_LATEST_MB1_PROJECTION) - 1) == 0) { + candidate->expected_column_count = 22; + } else { obs_miss_reason reason = OBS_MISS_CAPTURE; const char *projection_reason = "projection"; if (projection.end > projection.start && @@ -2187,7 +2254,9 @@ static emby_match_result emby_match_mixed_latest( ); } if (!find_unique_token_after( - zSql, scan_len, from_anchor.end, EMBY_ANCHOR_MIXED_LATEST_TAIL, + zSql, scan_len, from_anchor.end, + canonical_type_order ? EMBY_ANCHOR_MIXED_LATEST_TAIL + : EMBY_ANCHOR_MIXED_LATEST_REVERSED_TAIL, &tail_anchor )) { return emby_capture_miss( @@ -2215,7 +2284,10 @@ static emby_match_result emby_match_mixed_latest( } limit_is_parameter = emby_span_is_parameter_token(zSql, scan_len, &limit_slot); if (!limit_is_parameter && - (limit_slot.end - limit_slot.start != 1 || zSql[limit_slot.start] != '3')) { + !((limit_slot.end - limit_slot.start == 1 && + zSql[limit_slot.start] == '3') || + (limit_slot.end - limit_slot.start == 2 && + memcmp(zSql + limit_slot.start, "20", 2) == 0))) { return emby_capture_miss( db, 1, OBS_MISS_OUT_OF_SCOPE, OBS_MODE_EMBY_MIXED_LATEST, "limit_unsupported", zSql, scan_len @@ -2400,7 +2472,7 @@ static emby_candidate_acceptance emby_accept_mixed_candidate( return EMBY_CANDIDATE_BIND_MISMATCH; } } - if (sqlite3_column_count(stmt) != 19) { + if (sqlite3_column_count(stmt) != candidate->expected_column_count) { return EMBY_CANDIDATE_COLUMN_MISMATCH; } if (emby_count_bytes(candidate->sql, EMBY_LATEST_MIXED_DCN_GK_INDEX_NAME) != 1 || diff --git a/tests/contract_parity.h b/tests/contract_parity.h index 18937ce..93107bc 100644 --- a/tests/contract_parity.h +++ b/tests/contract_parity.h @@ -64,7 +64,7 @@ static int contract_parity_cell_equal(sqlite3_stmt *left, sqlite3_stmt *right, i } } -static void contract_parity_require_min_rows( +static void contract_parity_require_min_rows_mode( sqlite3 *vendor_db, sqlite3 *candidate_db, contract_parity_prepare_fn prepare, @@ -76,7 +76,8 @@ static void contract_parity_require_min_rows( void *bind_ctx, contract_parity_row_exception_fn row_exception, void *exception_ctx, - int minimum_rows + int minimum_rows, + int require_rewrite ) { sqlite3_stmt *vendor = NULL; sqlite3_stmt *candidate = NULL; @@ -98,11 +99,19 @@ static void contract_parity_require_min_rows( failf("FAIL [%s/vendor-sql]: got=\"%s\" want=\"%s\"", label, saved_vendor_sql ? saved_vendor_sql : "(null)", vendor_sql); } - /* C6: a silent matcher miss must never turn parity into vendor-vs-vendor. */ - if (!saved_candidate_sql || strcmp(saved_candidate_sql, candidate_source_sql) == 0) { + /* C6: ordinary parity cases must prove the rewrite fired. Explicit + * fail-open cases instead prove that the candidate SQL was preserved. */ + if (!saved_candidate_sql || + (require_rewrite && + strcmp(saved_candidate_sql, candidate_source_sql) == 0)) { failf("FAIL [%s/rewrite-fired]: candidate SQL did not change: \"%s\"", label, saved_candidate_sql ? saved_candidate_sql : "(null)"); } + if (!require_rewrite && + strcmp(saved_candidate_sql, candidate_source_sql) != 0) { + failf("FAIL [%s/fail-open]: candidate SQL changed: got=\"%s\" want=\"%s\"", + label, saved_candidate_sql, candidate_source_sql); + } if (expected_candidate_sql && strcmp(saved_candidate_sql, expected_candidate_sql) != 0) { failf("FAIL [%s/candidate-sql]: got=\"%s\" want=\"%s\"", label, saved_candidate_sql, expected_candidate_sql); @@ -232,10 +241,27 @@ static void contract_parity_require( contract_parity_row_exception_fn row_exception, void *exception_ctx ) { - contract_parity_require_min_rows( + contract_parity_require_min_rows_mode( vendor_db, candidate_db, prepare, label, vendor_sql, candidate_source_sql, expected_candidate_sql, bind, bind_ctx, - row_exception, exception_ctx, 1 + row_exception, exception_ctx, 1, 1 + ); +} + +static void contract_parity_require_fail_open( + sqlite3 *vendor_db, + sqlite3 *candidate_db, + contract_parity_prepare_fn prepare, + const char *label, + const char *vendor_sql, + const char *candidate_source_sql, + contract_parity_bind_fn bind, + void *bind_ctx +) { + contract_parity_require_min_rows_mode( + vendor_db, candidate_db, prepare, label, vendor_sql, + candidate_source_sql, candidate_source_sql, bind, bind_ctx, + NULL, NULL, 1, 0 ); } diff --git a/tests/emby_fts_rewrite_smoke.c b/tests/emby_fts_rewrite_smoke.c index 849fb58..1391c00 100644 --- a/tests/emby_fts_rewrite_smoke.c +++ b/tests/emby_fts_rewrite_smoke.c @@ -530,6 +530,7 @@ static void seed_mixed_latest_identity_rows(sqlite3 *db) { "(2051,5,'boundary-a',250,'g-a','p-a',2051)," "(2052,8,'boundary-c',250,'g-c','p-c',2052)," "(2053,5,'boundary-b',250,'g-b','p-b',2053);" + "UPDATE MediaItems SET ProductionYear=2026 WHERE Id=2001;" "INSERT INTO AncestorIds2(itemid,AncestorId,Distance) VALUES" "(2000,100,0),(2001,100,0),(2002,100,0),(2003,100,0),(2004,100,0),(2005,100,0)," "(2010,101,0),(2011,101,0)," @@ -752,6 +753,10 @@ static const char EMBY_EPISODES_LATEST_SEMANTIC_PROJECTION[] = "A.Id,A.Name,A.DateCreated,A.SeriesPresentationUniqueKey,A.PresentationUniqueKey,UserDatas.IsFavorite,UserDatas.Played "; static const char EMBY_MIXED_LATEST_PROJECTION[] = "A.type,A.Id,A.IndexNumber,A.Name,A.ParentIndexNumber,A.RunTimeTicks,A.ParentId,A.SeriesName,A.AlbumId,A.SeriesId,A.Images,A.SortIndexNumber,A.SortParentIndexNumber,A.IndexNumberEnd,UserDatas.IsFavorite,UserDatas.Played,UserDatas.PlaybackPositionTicks,UserDatas.AudioStreamIndex,UserDatas.SubtitleStreamIndex "; +static const char EMBY_MIXED_LATEST_PRODUCTION_YEAR_PROJECTION[] = + "A.type,A.Id,A.IndexNumber,A.Name,A.ParentIndexNumber,A.ProductionYear,A.RunTimeTicks,A.ParentId,A.SeriesName,A.AlbumId,A.SeriesId,A.Images,A.SortIndexNumber,A.SortParentIndexNumber,A.IndexNumberEnd,UserDatas.IsFavorite,UserDatas.Played,UserDatas.PlaybackPositionTicks,UserDatas.AudioStreamIndex,UserDatas.SubtitleStreamIndex "; +static const char EMBY_MIXED_LATEST_MB1_PROJECTION[] = + "A.type,A.Id,A.EndDate,A.IndexNumber,A.Name,A.Path,A.ParentIndexNumber,A.ProductionYear,A.RunTimeTicks,A.ParentId,A.SeriesName,A.AlbumId,A.SeriesId,A.Images,A.SortIndexNumber,A.SortParentIndexNumber,A.IndexNumberEnd,UserDatas.IsFavorite,UserDatas.Played,UserDatas.PlaybackPositionTicks,UserDatas.AudioStreamIndex,UserDatas.SubtitleStreamIndex "; static char *make_movies_latest_sql_form( int played_guard, @@ -864,8 +869,9 @@ static char *make_latest_expected(const char *projection, const char *limit) { return make_latest_expected_form(projection, "100", limit); } -static char *make_mixed_latest_sql_form( +static char *make_mixed_latest_sql_projection_form( const char *ancestors, + const char *projection, const char *user_id, const char *limit ) { @@ -873,6 +879,16 @@ static char *make_mixed_latest_sql_form( "with WithAncestors AS (SELECT itemid FROM AncestorIds2 WHERE AncestorId in (%s) )select %s" "from mediaitems A left join UserDatas on A.UserDataKeyId=UserDatas.UserDataKeyId And UserDatas.UserId=%s " "where A.Type in (8,5) AND Coalesce(UserDatas.played, 0)=0 AND A.Id in WithAncestors Group by coalesce(A.SeriesPresentationUniqueKey, A.PresentationUniqueKey) ORDER BY MAX(A.DateCreated) DESC LIMIT %s", + ancestors, projection, user_id, limit + ); +} + +static char *make_mixed_latest_sql_form( + const char *ancestors, + const char *user_id, + const char *limit +) { + return make_mixed_latest_sql_projection_form( ancestors, EMBY_MIXED_LATEST_PROJECTION, user_id, limit ); } @@ -881,8 +897,9 @@ static char *make_mixed_latest_sql(const char *user_id, const char *limit) { return make_mixed_latest_sql_form("100", user_id, limit); } -static char *make_mixed_latest_expected_form( +static char *make_mixed_latest_expected_projection_form( const char *ancestors, + const char *projection, const char *user_id, const char *limit ) { @@ -900,12 +917,22 @@ static char *make_mixed_latest_expected_form( "AND ( (B.DateCreated IS NOT NULL AND A.DateCreated IS NULL) OR B.DateCreated > A.DateCreated OR (B.DateCreated IS A.DateCreated AND B.Id < A.Id) ) " "AND EXISTS ( SELECT 1 FROM AncestorIds2 AS XB WHERE XB.ItemId = B.Id AND XB.AncestorId IN (%s) ) " "AND NOT EXISTS ( SELECT 1 FROM UserDatas AS UB WHERE UB.UserDataKeyId = B.UserDataKeyId AND UB.UserId = Args.user_id AND UB.played <> 0 ) ) " - "ORDER BY (A.DateCreated IS NULL) ASC, A.DateCreated DESC, coalesce(A.SeriesPresentationUniqueKey, A.PresentationUniqueKey) ASC LIMIT (SELECT row_limit FROM mixed_latest_args) ) " + "ORDER BY (A.DateCreated IS NULL) ASC, A.DateCreated DESC, coalesce(A.SeriesPresentationUniqueKey, A.PresentationUniqueKey) DESC LIMIT (SELECT row_limit FROM mixed_latest_args) ) " "SELECT %s" "FROM ranked AS R JOIN MediaItems AS A ON A.Id = R.id CROSS JOIN mixed_latest_args AS Args LEFT JOIN UserDatas " "ON A.UserDataKeyId = UserDatas.UserDataKeyId AND UserDatas.UserId = Args.user_id " - "ORDER BY (R.dc IS NULL) ASC, R.dc DESC, R.gk ASC LIMIT (SELECT row_limit FROM mixed_latest_args)", - user_id, limit, ancestors, ancestors, EMBY_MIXED_LATEST_PROJECTION + "ORDER BY (R.dc IS NULL) ASC, R.dc DESC, R.gk DESC LIMIT (SELECT row_limit FROM mixed_latest_args)", + user_id, limit, ancestors, ancestors, projection + ); +} + +static char *make_mixed_latest_expected_form( + const char *ancestors, + const char *user_id, + const char *limit +) { + return make_mixed_latest_expected_projection_form( + ancestors, EMBY_MIXED_LATEST_PROJECTION, user_id, limit ); } @@ -933,6 +960,32 @@ static sqlite3_stmt *contract_prepare_v2( return stmt; } +static sqlite3_stmt *contract_prepare_legacy( + sqlite3 *db, const char *label, const char *sql, const char **tail +) { + sqlite3_stmt *stmt = NULL; + int rc = emby_suite_spec.prepare( + db, sql, -1, RSH_PREPARE_LEGACY, &stmt, tail + ); + if (rc != SQLITE_OK) { + failf("FAIL [%s]: prepare entry=legacy rc=%d err=%s", + label, rc, sqlite3_errmsg(db)); + } + return stmt; +} + +static sqlite3_stmt *contract_prepare_v3( + sqlite3 *db, const char *label, const char *sql, const char **tail +) { + sqlite3_stmt *stmt = NULL; + int rc = emby_suite_spec.prepare(db, sql, -1, RSH_PREPARE_V3, &stmt, tail); + if (rc != SQLITE_OK) { + failf("FAIL [%s]: prepare entry=3 rc=%d err=%s", + label, rc, sqlite3_errmsg(db)); + } + return stmt; +} + static void bind_search_term( sqlite3_stmt *stmt, const char *side, @@ -1037,6 +1090,31 @@ static void expect_mixed_latest_sql( require_int(label, sqlite3_finalize(stmt), SQLITE_OK); } +static void require_mixed_production_year_row( + sqlite3 *db, + const char *label, + const char *sql, + const char *expected_sql +) { + const char *tail = NULL; + sqlite3_stmt *stmt = contract_prepare_v2(db, label, sql, &tail); + int found = 0; + int rc; + + require_str_eq(label, sqlite3_sql(stmt), expected_sql); + require_int(label, tail == sql + strlen(sql), 1); + require_int(label, sqlite3_column_count(stmt), 20); + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + if (sqlite3_column_int64(stmt, 1) != 2001) continue; + require_int(label, sqlite3_column_type(stmt, 5), SQLITE_INTEGER); + require_int(label, sqlite3_column_int(stmt, 5), 2026); + found++; + } + require_int(label, rc, SQLITE_DONE); + require_int(label, found, 1); + require_int(label, sqlite3_finalize(stmt), SQLITE_OK); +} + static int g_collision_scalar_calls; static void scalar_collision_tripwire(sqlite3_context *ctx, int argc, sqlite3_value **argv) { @@ -5571,8 +5649,9 @@ enum emby_d5b_case_id { EMBY_D5B_MIXED_POS_BIND_LITERAL, EMBY_D5B_MIXED_POS_LITERAL_BIND, EMBY_D5B_MIXED_POS_BIND_BIND, + EMBY_D5B_MIXED_POS_REVERSED_TYPES, EMBY_D5B_MIXED_NEG_TYPE8, - EMBY_D5B_MIXED_NEG_TYPE58, + EMBY_D5B_MIXED_NEG_TYPE55, EMBY_D5B_MIXED_NEG_TYPE856, EMBY_D5B_MIXED_NEG_TYPE_CASE, EMBY_D5B_MIXED_NEG_FROM_CASE, @@ -5677,42 +5756,48 @@ static int rsh_custom_adapter_dashboard_fix_b_c_identity( char *scalar = make_latest_sql_form( EMBY_EPISODES_LATEST_WIDE_PROJECTION, "100", 1, "12" ); - char *expected = make_latest_expected_form( - EMBY_EPISODES_LATEST_WIDE_PROJECTION, "100", "12" - ); - - contract_parity_require( + contract_parity_require_fail_open( vendor_db, candidate_db, contract_prepare_v2, - "emby-dashboard-episodes-contract", oracle, scalar, expected, - NULL, NULL, NULL, NULL + "emby-dashboard-episodes-scalar-fail-open-contract", + oracle, scalar, NULL, NULL ); free(oracle); free(scalar); - free(expected); + } + { + const char *ancestors = + "100,101,102,103,104,105,106,107,108,109,110,111,112,113," + "114,115,116,117,118,119,120,121,122,123,124,125,126,127," + "128,129"; + char *wide = make_latest_sql_form( + EMBY_EPISODES_LATEST_WIDE_PROJECTION, ancestors, 0, "12" + ); + + contract_parity_require_fail_open( + vendor_db, candidate_db, contract_prepare_v2, + "emby-dashboard-episodes-30-ancestor-fail-open-contract", + wide, wide, NULL, NULL + ); + free(wide); } { char ids[32]; char *scalar = make_latest_sql_form( EMBY_EPISODES_LATEST_WIDE_PROJECTION, "-1", 1, "12" ); - char *expected = make_latest_expected_form( - EMBY_EPISODES_LATEST_WIDE_PROJECTION, "-1", "12" - ); - - contract_parity_require( + contract_parity_require_fail_open( vendor_db, candidate_db, contract_prepare_v2, - "emby-dashboard-episodes-scalar-minus-one-contract", - scalar, scalar, expected, NULL, NULL, NULL, NULL + "emby-dashboard-episodes-scalar-minus-one-fail-open-contract", + scalar, scalar, NULL, NULL ); collect_int_column( candidate_db, "dashboard-episodes-scalar-minus-one-ids", - scalar, expected, -1, 0, ids, sizeof(ids) + scalar, scalar, -1, 0, ids, sizeof(ids) ); require_str_eq( "dashboard-episodes-scalar-minus-one-exact-id", ids, "7," ); free(scalar); - free(expected); } { char *oracle = make_movies_latest_sql(1, "100", "42", "12"); @@ -5819,8 +5904,8 @@ static int rsh_matrix_assert_emby_d5b_fix( EMBY_EPISODES_LATEST_WIDE_PROJECTION, "100", 1, "12" ); sql_owned = 1; - expected = make_latest_expected_form( - EMBY_EPISODES_LATEST_WIDE_PROJECTION, "100", "12" + expected = make_latest_sql_form( + EMBY_EPISODES_LATEST_WIDE_PROJECTION, "100", 1, "12" ); rsh_run_emby_matrix_sql_exact( context, matrix_case->label, sql, expected, @@ -5832,8 +5917,8 @@ static int rsh_matrix_assert_emby_d5b_fix( EMBY_EPISODES_LATEST_WIDE_PROJECTION, "-1", 1, "12" ); sql_owned = 1; - expected = make_latest_expected_form( - EMBY_EPISODES_LATEST_WIDE_PROJECTION, "-1", "12" + expected = make_latest_sql_form( + EMBY_EPISODES_LATEST_WIDE_PROJECTION, "-1", 1, "12" ); rsh_run_emby_matrix_sql_exact( context, matrix_case->label, sql, expected, @@ -6088,11 +6173,11 @@ static void rsh_run_emby_d5b_mixed_negative( "where A.Type=8 ", "where A.Type=8 " ); break; - case EMBY_D5B_MIXED_NEG_TYPE58: + case EMBY_D5B_MIXED_NEG_TYPE55: base = make_mixed_latest_sql("42", "3"); rsh_run_emby_matrix_generated_negative( context, matrix_case->label, base, "where A.Type in (8,5) ", - "where A.Type in (5,8) ", "where A.Type in (5,8) " + "where A.Type in (5,5) ", "where A.Type in (5,5) " ); break; case EMBY_D5B_MIXED_NEG_TYPE856: @@ -6265,6 +6350,13 @@ static int rsh_matrix_assert_emby_d5b_mixed_positive( limit_bound = 1; } sql = make_mixed_latest_sql(user, limit); + if (matrix_case->case_id == EMBY_D5B_MIXED_POS_REVERSED_TYPES) { + char *reversed = replace_once( + sql, "where A.Type in (8,5) ", "where A.Type in (5,8) " + ); + free(sql); + sql = reversed; + } expected = make_mixed_latest_expected(user, limit); expect_mixed_latest_sql( candidate, matrix_case->label, sql, expected, @@ -6552,8 +6644,8 @@ EMBY_DEFINE_MATRIX_AXIS_3( EMBY_DEFINE_MATRIX_AXIS_3( emby_d5b_fix_positive_2, "dashboard-movies-m6-byte-identical", EMBY_D5B_FIX_COMPACT_M6, - "dashboard-episodes-e2-scalar", EMBY_D5B_FIX_EPISODES_E2, - "dashboard-episodes-scalar-minus-one", EMBY_D5B_FIX_EPISODES_MINUS_ONE + "dashboard-episodes-e2-scalar-fail-open", EMBY_D5B_FIX_EPISODES_E2, + "dashboard-episodes-scalar-minus-one-fail-open", EMBY_D5B_FIX_EPISODES_MINUS_ONE ); EMBY_DEFINE_MATRIX_AXIS_2( emby_d5b_fix_positive_3, @@ -6694,10 +6786,14 @@ EMBY_DEFINE_MATRIX_AXIS_1( emby_d5b_mixed_positive_2, "dashboard-mixed-latest-bind-3", EMBY_D5B_MIXED_POS_BIND_BIND ); +EMBY_DEFINE_MATRIX_AXIS_1( + emby_d5b_mixed_positive_reversed, + "dashboard-mixed-latest-reversed-types", EMBY_D5B_MIXED_POS_REVERSED_TYPES +); EMBY_DEFINE_MATRIX_AXIS_3( emby_d5b_mixed_probe_negatives, "dashboard-mixed-negative-0", EMBY_D5B_MIXED_NEG_TYPE8, - "dashboard-mixed-negative-1", EMBY_D5B_MIXED_NEG_TYPE58, + "dashboard-mixed-negative-1", EMBY_D5B_MIXED_NEG_TYPE55, "dashboard-mixed-negative-2", EMBY_D5B_MIXED_NEG_TYPE856 ); EMBY_DEFINE_MATRIX_AXIS_3( @@ -6760,6 +6856,11 @@ static const rsh_matrix_phase_spec emby_dashboard_mixed_latest_matrix_phases[] = emby_d5b_mixed_positive_2_axes, emby_d5b_mixed_all_dbs, rsh_matrix_assert_emby_d5b_mixed_positive, 1 ), + EMBY_DYNAMIC_MATRIX_PHASE( + "mixed-positive-reversed", "mixed-positive-reversed", + emby_d5b_mixed_positive_reversed_axes, emby_d5b_mixed_all_dbs, + rsh_matrix_assert_emby_d5b_mixed_positive, 1 + ), EMBY_D5B_CASE_MATRIX_PHASE( "mixed-probe-negatives", "mixed-probe-negatives", emby_d5b_mixed_probe_negatives_axes, emby_d5b_mixed_all_dbs, @@ -7447,12 +7548,115 @@ static int rsh_custom_adapter_dashboard_mixed_identity( typed_rows vendor_rows; typed_rows candidate_rows; char ids[128]; + static contract_parity_prepare_fn const prepare_entries[] = { + contract_prepare_legacy, contract_prepare_v2, contract_prepare_v3 + }; + static const char *const prepare_labels[] = {"legacy", "v2", "v3"}; + static const char *const projections[] = { + EMBY_MIXED_LATEST_PROJECTION, + EMBY_MIXED_LATEST_PRODUCTION_YEAR_PROJECTION, + EMBY_MIXED_LATEST_MB1_PROJECTION + }; + static const int projection_columns[] = {19, 20, 22}; + size_t projection_i; + size_t prepare_i; (void)immutable_data; if (!context->matrix_cell || context->matrix_cell->axis_count != 1) { failf("FAIL [dashboard-mixed-identity/matrix-context]"); } analyzed = context->matrix_cell->axis_indices[0] == 1; + + require_int( + "mixed-production-year/vendor-fixture", + query_int( + vendor_db, "mixed-production-year/vendor-fixture-sql", + "SELECT ProductionYear FROM MediaItems WHERE Id=2001" + ), + 2026 + ); + require_int( + "mixed-production-year/candidate-fixture", + query_int( + candidate_db, "mixed-production-year/candidate-fixture-sql", + "SELECT ProductionYear FROM MediaItems WHERE Id=2001" + ), + 2026 + ); + + for (projection_i = 0; + projection_i < sizeof(projections) / sizeof(projections[0]); + projection_i++) { + raw = make_mixed_latest_sql_projection_form( + "100", projections[projection_i], "42", "20" + ); + expected = make_mixed_latest_expected_projection_form( + "100", projections[projection_i], "42", "20" + ); + for (prepare_i = 0; + prepare_i < sizeof(prepare_entries) / sizeof(prepare_entries[0]); + prepare_i++) { + char label[128]; + snprintf( + label, sizeof(label), + "mixed-%dcol-limit20-%s%s", + projection_columns[projection_i], prepare_labels[prepare_i], + analyzed ? "-after-analyze" : "-before-analyze" + ); + contract_parity_require( + vendor_db, candidate_db, prepare_entries[prepare_i], label, + raw, raw, expected, NULL, NULL, NULL, NULL + ); + } + free(raw); + free(expected); + } + + raw = make_mixed_latest_sql_projection_form( + "100", EMBY_MIXED_LATEST_PRODUCTION_YEAR_PROJECTION, "42", "20" + ); + expected = make_mixed_latest_expected_projection_form( + "100", EMBY_MIXED_LATEST_PRODUCTION_YEAR_PROJECTION, "42", "20" + ); + require_mixed_production_year_row( + vendor_db, "mixed-production-year/vendor-result", raw, raw + ); + require_mixed_production_year_row( + candidate_db, "mixed-production-year/candidate-result", raw, expected + ); + free(raw); + free(expected); + + raw = make_mixed_latest_sql_projection_form( + "100", EMBY_MIXED_LATEST_MB1_PROJECTION, "42", "20" + ); + { + char *production_order = replace_once( + raw, "where A.Type in (8,5) ", "where A.Type in (5,8) " + ); + free(raw); + raw = production_order; + } + expected = make_mixed_latest_expected_projection_form( + "100", EMBY_MIXED_LATEST_MB1_PROJECTION, "42", "20" + ); + for (prepare_i = 0; + prepare_i < sizeof(prepare_entries) / sizeof(prepare_entries[0]); + prepare_i++) { + char label[128]; + snprintf( + label, sizeof(label), "mixed-mb1-exact-limit20-%s%s", + prepare_labels[prepare_i], + analyzed ? "-after-analyze" : "-before-analyze" + ); + contract_parity_require( + vendor_db, candidate_db, prepare_entries[prepare_i], label, + raw, raw, expected, NULL, NULL, NULL, NULL + ); + } + free(raw); + free(expected); + raw = make_mixed_latest_sql("42", "3"); expected = make_mixed_latest_expected("42", "3"); vendor_rows = collect_typed_rows( @@ -7540,9 +7744,12 @@ static int rsh_custom_adapter_dashboard_mixed_identity( "105", "mixed-same-date-lower-id", "mixed-same-date-lower-id/selection", "2039," ); + /* The optimized plan resolves equal DateCreated groups in descending + * group-key order. Lock that LIMIT-boundary behavior independently of + * the synthetic fixture's planner/index state. */ EMBY_D6_MIXED_ID_ASSERT( "104", "mixed-limit-boundary-gk", "mixed-limit-boundary-gk/order", - "2051,2053,2052," + "2050,2052,2053," ); #undef EMBY_D6_MIXED_ID_ASSERT diff --git a/tests/rewrite_smoke_harness.h b/tests/rewrite_smoke_harness.h index 2a68f65..00cce62 100644 --- a/tests/rewrite_smoke_harness.h +++ b/tests/rewrite_smoke_harness.h @@ -1918,7 +1918,7 @@ static void rsh_run_contract_parity( rsh_parity_state.test_case = test_case; rsh_parity_state.prepare = spec->prepare; rsh_contract_suite = suite; - contract_parity_require_min_rows( + contract_parity_require_min_rows_mode( vendor->db, candidate->db, rsh_parity_prepare, @@ -1930,7 +1930,8 @@ static void rsh_run_contract_parity( spec->bind_ctx, spec->row_exception, spec->row_exception_ctx, - spec->minimum_rows + spec->minimum_rows, + 1 ); rsh_contract_suite = NULL; memset(&rsh_parity_state, 0, sizeof(rsh_parity_state));