diff --git a/src/cloudsync.c b/src/cloudsync.c index 27fc443..ec270a1 100644 --- a/src/cloudsync.c +++ b/src/cloudsync.c @@ -2528,6 +2528,18 @@ char *cloudsync_filter_add_row_prefix (const char *filter, const char *prefix, c return result; } +int cloudsync_reset_metatable (cloudsync_context *data, const char *table_name) { + cloudsync_table_context *table = table_lookup(data, table_name); + if (!table) return DBRES_ERROR; + + char *sql = cloudsync_memory_mprintf(SQL_DELETE_ALL_FROM_CLOUDSYNC_TABLE, table->meta_ref); + int rc = database_exec(data, sql); + cloudsync_memory_free(sql); + if (rc != DBRES_OK) return rc; + + return cloudsync_refill_metatable(data, table_name); +} + int cloudsync_refill_metatable (cloudsync_context *data, const char *table_name) { cloudsync_table_context *table = table_lookup(data, table_name); if (!table) return DBRES_ERROR; diff --git a/src/cloudsync.h b/src/cloudsync.h index ed3b9a0..1a7fdf8 100644 --- a/src/cloudsync.h +++ b/src/cloudsync.h @@ -18,7 +18,7 @@ extern "C" { #endif -#define CLOUDSYNC_VERSION "1.0.9" +#define CLOUDSYNC_VERSION "1.0.10" #define CLOUDSYNC_MAX_TABLENAME_LEN 512 #define CLOUDSYNC_VALUE_NOTSET -1 @@ -95,6 +95,8 @@ int cloudsync_payload_get (cloudsync_context *data, char **blob, int *blob_si int cloudsync_payload_save (cloudsync_context *data, const char *payload_path, int *blob_size); // available only on Desktop OS (no WASM, no mobile) // CloudSync table context +int cloudsync_refill_metatable (cloudsync_context *data, const char *table_name); +int cloudsync_reset_metatable (cloudsync_context *data, const char *table_name); cloudsync_table_context *table_lookup (cloudsync_context *data, const char *table_name); void *table_column_lookup (cloudsync_table_context *table, const char *col_name, bool is_merge, int *index); bool table_enabled (cloudsync_table_context *table); diff --git a/src/postgresql/cloudsync_postgresql.c b/src/postgresql/cloudsync_postgresql.c index e2b376d..9719f43 100644 --- a/src/postgresql/cloudsync_postgresql.c +++ b/src/postgresql/cloudsync_postgresql.c @@ -695,6 +695,13 @@ Datum cloudsync_set_filter (PG_FUNCTION_ARGS) { ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("cloudsync_set_filter: error recreating triggers"))); } + + // Clean and refill metatable with the new filter + rc = cloudsync_reset_metatable(data, tbl); + if (rc != DBRES_OK) { + ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("cloudsync_set_filter: error resetting metatable"))); + } } PG_CATCH(); { @@ -753,6 +760,13 @@ Datum cloudsync_clear_filter (PG_FUNCTION_ARGS) { ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("cloudsync_clear_filter: error recreating triggers"))); } + + // Clean and refill metatable without filter (all rows) + rc = cloudsync_reset_metatable(data, tbl); + if (rc != DBRES_OK) { + ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("cloudsync_clear_filter: error resetting metatable"))); + } } PG_CATCH(); { diff --git a/src/postgresql/sql_postgresql.c b/src/postgresql/sql_postgresql.c index db9c2de..32f92c0 100644 --- a/src/postgresql/sql_postgresql.c +++ b/src/postgresql/sql_postgresql.c @@ -354,6 +354,9 @@ const char * const SQL_PRAGMA_TABLEINFO_LIST_NONPK_NAME_CID = const char * const SQL_DROP_CLOUDSYNC_TABLE = "DROP TABLE IF EXISTS %s CASCADE;"; +const char * const SQL_DELETE_ALL_FROM_CLOUDSYNC_TABLE = + "DELETE FROM %s;"; + const char * const SQL_CLOUDSYNC_DELETE_COLS_NOT_IN_SCHEMA_OR_PKCOL = "DELETE FROM %s WHERE col_name NOT IN (" "SELECT column_name FROM information_schema.columns WHERE table_name = '%s' " diff --git a/src/sql.h b/src/sql.h index dfa394e..0701623 100644 --- a/src/sql.h +++ b/src/sql.h @@ -57,6 +57,7 @@ extern const char * const SQL_CLOUDSYNC_SELECT_COL_VERSION_BY_PK_COL; extern const char * const SQL_CLOUDSYNC_SELECT_SITE_ID_BY_PK_COL; extern const char * const SQL_PRAGMA_TABLEINFO_LIST_NONPK_NAME_CID; extern const char * const SQL_DROP_CLOUDSYNC_TABLE; +extern const char * const SQL_DELETE_ALL_FROM_CLOUDSYNC_TABLE; extern const char * const SQL_CLOUDSYNC_DELETE_COLS_NOT_IN_SCHEMA_OR_PKCOL; extern const char * const SQL_PRAGMA_TABLEINFO_PK_QUALIFIED_COLLIST_FMT; extern const char * const SQL_CLOUDSYNC_GC_DELETE_ORPHANED_PK; diff --git a/src/sqlite/cloudsync_sqlite.c b/src/sqlite/cloudsync_sqlite.c index c236466..b037c54 100644 --- a/src/sqlite/cloudsync_sqlite.c +++ b/src/sqlite/cloudsync_sqlite.c @@ -1259,6 +1259,14 @@ void dbsync_set_filter (sqlite3_context *context, int argc, sqlite3_value **argv return; } + // Clean and refill metatable with the new filter + rc = cloudsync_reset_metatable(data, tbl); + if (rc != DBRES_OK) { + dbsync_set_error(context, "cloudsync_set_filter: error resetting metatable"); + sqlite3_result_error_code(context, rc); + return; + } + sqlite3_result_int(context, 1); } @@ -1289,6 +1297,14 @@ void dbsync_clear_filter (sqlite3_context *context, int argc, sqlite3_value **ar return; } + // Clean and refill metatable without filter (all rows) + rc = cloudsync_reset_metatable(data, tbl); + if (rc != DBRES_OK) { + dbsync_set_error(context, "cloudsync_clear_filter: error resetting metatable"); + sqlite3_result_error_code(context, rc); + return; + } + sqlite3_result_int(context, 1); } diff --git a/src/sqlite/sql_sqlite.c b/src/sqlite/sql_sqlite.c index 7228579..afbf6a5 100644 --- a/src/sqlite/sql_sqlite.c +++ b/src/sqlite/sql_sqlite.c @@ -226,6 +226,9 @@ const char * const SQL_PRAGMA_TABLEINFO_LIST_NONPK_NAME_CID = const char * const SQL_DROP_CLOUDSYNC_TABLE = "DROP TABLE IF EXISTS \"%w\";"; +const char * const SQL_DELETE_ALL_FROM_CLOUDSYNC_TABLE = + "DELETE FROM \"%w\";"; + const char * const SQL_CLOUDSYNC_DELETE_COLS_NOT_IN_SCHEMA_OR_PKCOL = "DELETE FROM \"%w\" WHERE \"col_name\" NOT IN (" "SELECT name FROM pragma_table_info('%q') UNION SELECT '%s'" diff --git a/test/postgresql/47_row_filter_advanced.sql b/test/postgresql/47_row_filter_advanced.sql new file mode 100644 index 0000000..c5c8996 --- /dev/null +++ b/test/postgresql/47_row_filter_advanced.sql @@ -0,0 +1,194 @@ +-- 'Row-level filter advanced tests (clear, complex expressions, row transitions, filter change)' + +\set testid '47' +\ir helper_test_init.sql + +-- Create database +\connect postgres +\ir helper_psql_conn_setup.sql +DROP DATABASE IF EXISTS cloudsync_test_47_a; +CREATE DATABASE cloudsync_test_47_a; + +\connect cloudsync_test_47_a +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; + +-- ============================================================ +-- Test 1: cloudsync_clear_filter lifecycle +-- ============================================================ +CREATE TABLE tasks (id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER); +SELECT cloudsync_init('tasks') AS _init \gset +SELECT cloudsync_set_filter('tasks', 'user_id = 1') AS _sf \gset + +INSERT INTO tasks VALUES ('a', 'Task A', 1); +INSERT INTO tasks VALUES ('b', 'Task B', 2); +INSERT INTO tasks VALUES ('c', 'Task C', 1); + +-- Only matching rows tracked +SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM tasks_cloudsync \gset +SELECT (:meta_pk_count = 2) AS clear_t1a_ok \gset +\if :clear_t1a_ok +\echo [PASS] (:testid) clear_filter: 2 PKs tracked before clear +\else +\echo [FAIL] (:testid) clear_filter: expected 2 tracked PKs before clear, got :meta_pk_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Clear filter +SELECT cloudsync_clear_filter('tasks') AS _cf \gset + +-- Insert non-matching row — should now be tracked (no filter) +-- clear_filter refilled metatable with all 3 existing rows (a, b, c) + insert d = 4 +INSERT INTO tasks VALUES ('d', 'Task D', 2); +SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM tasks_cloudsync \gset +SELECT (:meta_pk_count = 4) AS clear_t1b_ok \gset +\if :clear_t1b_ok +\echo [PASS] (:testid) clear_filter: non-matching row tracked after clear (4 PKs) +\else +\echo [FAIL] (:testid) clear_filter: expected 4 PKs after clear+insert, got :meta_pk_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Update row 'b' — already tracked by clear_filter refill, meta count unchanged +UPDATE tasks SET title = 'Task B Updated' WHERE id = 'b'; +SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM tasks_cloudsync \gset +SELECT (:meta_pk_count = 4) AS clear_t1c_ok \gset +\if :clear_t1c_ok +\echo [PASS] (:testid) clear_filter: update on 'b' still 4 PKs +\else +\echo [FAIL] (:testid) clear_filter: expected 4 PKs after update on 'b', got :meta_pk_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 2: Complex filter — AND + comparison operators +-- ============================================================ +DROP TABLE IF EXISTS items; +CREATE TABLE items (id TEXT PRIMARY KEY NOT NULL, status TEXT, priority INTEGER, category TEXT, user_id INTEGER); +SELECT cloudsync_init('items') AS _init \gset +SELECT cloudsync_set_filter('items', 'user_id = 1 AND priority > 3') AS _sf \gset + +INSERT INTO items VALUES ('a', 'active', 5, 'work', 1); -- matches +INSERT INTO items VALUES ('b', 'active', 2, 'work', 1); -- fails priority +INSERT INTO items VALUES ('c', 'active', 5, 'work', 2); -- fails user_id + +SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM items_cloudsync \gset +SELECT (:meta_pk_count = 1) AS complex_t2_ok \gset +\if :complex_t2_ok +\echo [PASS] (:testid) complex_filter: AND+comparison tracked 1 of 3 rows +\else +\echo [FAIL] (:testid) complex_filter: expected 1 tracked PK, got :meta_pk_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 3: IS NULL filter +-- ============================================================ +SELECT cloudsync_clear_filter('items') AS _cf \gset +SELECT cloudsync_set_filter('items', 'category IS NULL') AS _sf \gset + +SELECT COUNT(DISTINCT pk) AS meta_before FROM items_cloudsync \gset +INSERT INTO items VALUES ('f', 'x', 1, NULL, 1); -- matches +INSERT INTO items VALUES ('g', 'x', 1, 'work', 1); -- fails +SELECT COUNT(DISTINCT pk) AS meta_after FROM items_cloudsync \gset +SELECT ((:meta_after::int - :meta_before::int) = 1) AS null_t3_ok \gset +\if :null_t3_ok +\echo [PASS] (:testid) IS NULL filter: only NULL-category row tracked +\else +\echo [FAIL] (:testid) IS NULL filter: expected 1 new PK, got (:meta_after - :meta_before) +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 4: Row exits filter (matching -> non-matching via UPDATE) +-- ============================================================ +DROP TABLE IF EXISTS trans; +CREATE TABLE trans (id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER); +SELECT cloudsync_init('trans') AS _init \gset +SELECT cloudsync_set_filter('trans', 'user_id = 1') AS _sf \gset + +INSERT INTO trans VALUES ('a', 'Task A', 1); + +SELECT COUNT(*) AS meta_before FROM trans_cloudsync \gset +UPDATE trans SET user_id = 2 WHERE id = 'a'; +SELECT COUNT(*) AS meta_after FROM trans_cloudsync \gset +SELECT (:meta_before = :meta_after) AS exit_t4_ok \gset +\if :exit_t4_ok +\echo [PASS] (:testid) row_exit: UPDATE out of filter did not change metadata +\else +\echo [FAIL] (:testid) row_exit: UPDATE out of filter changed metadata (:meta_before -> :meta_after) +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 5: Row enters filter (non-matching -> matching via UPDATE) +-- ============================================================ +INSERT INTO trans VALUES ('b', 'Task B', 2); + +SELECT COUNT(DISTINCT pk) AS meta_before FROM trans_cloudsync \gset +UPDATE trans SET user_id = 1 WHERE id = 'b'; +SELECT COUNT(DISTINCT pk) AS meta_after FROM trans_cloudsync \gset +SELECT (:meta_after::int > :meta_before::int) AS enter_t5_ok \gset +\if :enter_t5_ok +\echo [PASS] (:testid) row_enter: UPDATE into filter created metadata +\else +\echo [FAIL] (:testid) row_enter: UPDATE into filter did not create metadata (:meta_before -> :meta_after) +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 6: Filter change after data +-- ============================================================ +DROP TABLE IF EXISTS fchange; +CREATE TABLE fchange (id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER); +SELECT cloudsync_init('fchange') AS _init \gset +SELECT cloudsync_set_filter('fchange', 'user_id = 1') AS _sf \gset + +INSERT INTO fchange VALUES ('a', 'A', 1); -- matches +INSERT INTO fchange VALUES ('b', 'B', 2); -- non-matching +INSERT INTO fchange VALUES ('c', 'C', 1); -- matches + +SELECT COUNT(DISTINCT pk) AS meta_count FROM fchange_cloudsync \gset +SELECT (:meta_count = 2) AS change_t6a_ok \gset +\if :change_t6a_ok +\echo [PASS] (:testid) filter_change: 2 PKs under initial filter +\else +\echo [FAIL] (:testid) filter_change: expected 2 PKs under initial filter, got :meta_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Change filter — resets metatable to only rows matching new filter (user_id = 2) +-- Only 'b' (user_id=2) matches new filter → 1 PK from refill, then insert d → 2 +SELECT cloudsync_set_filter('fchange', 'user_id = 2') AS _sf2 \gset + +INSERT INTO fchange VALUES ('d', 'D', 2); -- matches new filter +INSERT INTO fchange VALUES ('e', 'E', 1); -- non-matching under new filter + +SELECT COUNT(DISTINCT pk) AS meta_count FROM fchange_cloudsync \gset +SELECT (:meta_count = 2) AS change_t6b_ok \gset +\if :change_t6b_ok +\echo [PASS] (:testid) filter_change: 2 PKs after filter change (metatable reset) +\else +\echo [FAIL] (:testid) filter_change: expected 2 PKs after filter change, got :meta_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Update 'a' (user_id=1) should NOT generate new metadata under new filter (user_id=2) +SELECT COUNT(*) AS meta_before FROM fchange_cloudsync \gset +UPDATE fchange SET title = 'A Updated' WHERE id = 'a'; +SELECT COUNT(*) AS meta_after FROM fchange_cloudsync \gset +SELECT (:meta_before = :meta_after) AS change_t6c_ok \gset +\if :change_t6c_ok +\echo [PASS] (:testid) filter_change: update on 'a' not tracked under new filter +\else +\echo [FAIL] (:testid) filter_change: update on 'a' changed metadata (:meta_before -> :meta_after) +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Cleanup +\ir helper_test_cleanup.sql +\if :should_cleanup +DROP DATABASE IF EXISTS cloudsync_test_47_a; +\else +\echo [INFO] !!!!! +\endif diff --git a/test/postgresql/48_row_filter_multi_table.sql b/test/postgresql/48_row_filter_multi_table.sql new file mode 100644 index 0000000..c2a0590 --- /dev/null +++ b/test/postgresql/48_row_filter_multi_table.sql @@ -0,0 +1,166 @@ +-- 'Row-level filter: multi-table with different filters and composite primary keys' + +\set testid '48' +\ir helper_test_init.sql + +-- Create databases +\connect postgres +\ir helper_psql_conn_setup.sql +DROP DATABASE IF EXISTS cloudsync_test_48_a; +DROP DATABASE IF EXISTS cloudsync_test_48_b; +CREATE DATABASE cloudsync_test_48_a; +CREATE DATABASE cloudsync_test_48_b; + +-- ============================================================ +-- Setup Database A +-- ============================================================ +\connect cloudsync_test_48_a +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; + +-- Table 1: composite PK with simple filter +CREATE TABLE projects (org_id INTEGER NOT NULL, proj_id INTEGER NOT NULL, name TEXT, PRIMARY KEY(org_id, proj_id)); +SELECT cloudsync_init('projects') AS _init \gset +SELECT cloudsync_set_filter('projects', 'org_id = 1') AS _sf \gset + +-- Table 2: composite PK with multi-column filter including string literal +CREATE TABLE members (org_id INTEGER NOT NULL, user_id INTEGER NOT NULL, role TEXT, PRIMARY KEY(org_id, user_id)); +SELECT cloudsync_init('members') AS _init \gset +SELECT cloudsync_set_filter('members', 'org_id = 1 AND role = ''admin''') AS _sf \gset + +-- ============================================================ +-- Test 1: Composite PK — only matching rows tracked +-- ============================================================ +INSERT INTO projects VALUES (1, 1, 'Proj A'); -- matches +INSERT INTO projects VALUES (2, 1, 'Proj B'); -- fails org_id +INSERT INTO projects VALUES (1, 2, 'Proj C'); -- matches + +SELECT COUNT(DISTINCT pk) AS proj_meta FROM projects_cloudsync \gset +SELECT (:proj_meta = 2) AS t1_proj_ok \gset +\if :t1_proj_ok +\echo [PASS] (:testid) composite_pk: 2 of 3 projects tracked +\else +\echo [FAIL] (:testid) composite_pk: expected 2 project PKs, got :proj_meta +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 2: Multi-column filter with string literal — different table +-- ============================================================ +INSERT INTO members VALUES (1, 10, 'admin'); -- matches both conditions +INSERT INTO members VALUES (1, 20, 'viewer'); -- fails role +INSERT INTO members VALUES (2, 10, 'admin'); -- fails org_id + +SELECT COUNT(DISTINCT pk) AS mem_meta FROM members_cloudsync \gset +SELECT (:mem_meta = 1) AS t2_mem_ok \gset +\if :t2_mem_ok +\echo [PASS] (:testid) multi_filter: 1 of 3 members tracked +\else +\echo [FAIL] (:testid) multi_filter: expected 1 member PK, got :mem_meta +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 3: Roundtrip sync — only matching rows per table transfer +-- ============================================================ +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload_hex +FROM cloudsync_changes +WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_test_48_b +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; + +CREATE TABLE projects (org_id INTEGER NOT NULL, proj_id INTEGER NOT NULL, name TEXT, PRIMARY KEY(org_id, proj_id)); +SELECT cloudsync_init('projects') AS _init \gset +SELECT cloudsync_set_filter('projects', 'org_id = 1') AS _sf \gset + +CREATE TABLE members (org_id INTEGER NOT NULL, user_id INTEGER NOT NULL, role TEXT, PRIMARY KEY(org_id, user_id)); +SELECT cloudsync_init('members') AS _init \gset +SELECT cloudsync_set_filter('members', 'org_id = 1 AND role = ''admin''') AS _sf \gset + +SELECT cloudsync_payload_apply(decode(:'payload_hex', 'hex')) AS _apply \gset + +-- Verify projects +SELECT COUNT(*) AS proj_count FROM projects \gset +SELECT (:proj_count = 2) AS t3_proj_ok \gset +\if :t3_proj_ok +\echo [PASS] (:testid) roundtrip: 2 projects synced to db_b +\else +\echo [FAIL] (:testid) roundtrip: expected 2 projects in db_b, got :proj_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Verify members +SELECT COUNT(*) AS mem_count FROM members \gset +SELECT (:mem_count = 1) AS t3_mem_ok \gset +\if :t3_mem_ok +\echo [PASS] (:testid) roundtrip: 1 member synced to db_b +\else +\echo [FAIL] (:testid) roundtrip: expected 1 member in db_b, got :mem_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Verify correct member identity +SELECT COUNT(*) AS admin_exists FROM members WHERE org_id = 1 AND user_id = 10 AND role = 'admin' \gset +SELECT (:admin_exists = 1) AS t3_admin_ok \gset +\if :t3_admin_ok +\echo [PASS] (:testid) roundtrip: correct admin member (1,10) present +\else +\echo [FAIL] (:testid) roundtrip: admin member (1,10) not found +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Non-matching rows should NOT exist +SELECT COUNT(*) AS bad_proj FROM projects WHERE org_id = 2 \gset +SELECT (:bad_proj = 0) AS t3_no_bad_proj \gset +\if :t3_no_bad_proj +\echo [PASS] (:testid) roundtrip: no org_id=2 projects in db_b +\else +\echo [FAIL] (:testid) roundtrip: unexpected org_id=2 projects found (:bad_proj) +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 4: Update and delete on composite PK, then re-sync +-- ============================================================ +\connect cloudsync_test_48_a +\ir helper_psql_conn_setup.sql + +UPDATE projects SET name = 'Proj A Updated' WHERE org_id = 1 AND proj_id = 1; +DELETE FROM projects WHERE org_id = 1 AND proj_id = 2; + +SELECT encode(cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), 'hex') AS payload2_hex +FROM cloudsync_changes +WHERE site_id = cloudsync_siteid() \gset + +\connect cloudsync_test_48_b +\ir helper_psql_conn_setup.sql +SELECT cloudsync_payload_apply(decode(:'payload2_hex', 'hex')) AS _apply2 \gset + +SELECT COUNT(*) AS proj_count FROM projects \gset +SELECT (:proj_count = 1) AS t4_count_ok \gset +\if :t4_count_ok +\echo [PASS] (:testid) update_delete: 1 project remaining after sync +\else +\echo [FAIL] (:testid) update_delete: expected 1 project, got :proj_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +SELECT COUNT(*) AS updated_exists FROM projects WHERE org_id = 1 AND proj_id = 1 AND name = 'Proj A Updated' \gset +SELECT (:updated_exists = 1) AS t4_updated_ok \gset +\if :t4_updated_ok +\echo [PASS] (:testid) update_delete: 'Proj A Updated' present in db_b +\else +\echo [FAIL] (:testid) update_delete: 'Proj A Updated' not found in db_b +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Cleanup +\ir helper_test_cleanup.sql +\if :should_cleanup +DROP DATABASE IF EXISTS cloudsync_test_48_a; +DROP DATABASE IF EXISTS cloudsync_test_48_b; +\else +\echo [INFO] !!!!! +\endif diff --git a/test/postgresql/49_row_filter_prefill.sql b/test/postgresql/49_row_filter_prefill.sql new file mode 100644 index 0000000..a570856 --- /dev/null +++ b/test/postgresql/49_row_filter_prefill.sql @@ -0,0 +1,192 @@ +-- 'Row-level filter with pre-existing data (prefill tests)' +-- Tests that cloudsync_refill_metatable correctly handles the filter +-- when rows exist before cloudsync_init and cloudsync_set_filter. + +\set testid '49' +\ir helper_test_init.sql + +-- Create databases +\connect postgres +\ir helper_psql_conn_setup.sql +DROP DATABASE IF EXISTS cloudsync_test_49_a; +DROP DATABASE IF EXISTS cloudsync_test_49_b; +CREATE DATABASE cloudsync_test_49_a; +CREATE DATABASE cloudsync_test_49_b; + +-- ============================================================ +-- Setup Database A — insert data BEFORE cloudsync_init +-- ============================================================ + +\connect cloudsync_test_49_a +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; + +CREATE TABLE tasks (id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER); + +-- Pre-existing rows: 3 matching (user_id=1), 2 non-matching +INSERT INTO tasks VALUES ('a', 'Task A', 1); +INSERT INTO tasks VALUES ('b', 'Task B', 2); +INSERT INTO tasks VALUES ('c', 'Task C', 1); +INSERT INTO tasks VALUES ('d', 'Task D', 3); +INSERT INTO tasks VALUES ('e', 'Task E', 1); + +-- Init and set filter AFTER data exists +SELECT cloudsync_init('tasks') AS _init_a \gset +SELECT cloudsync_set_filter('tasks', 'user_id = 1') AS _sf_a \gset + +-- ============================================================ +-- Test 1: set_filter resets metatable to only matching rows +-- cloudsync_init filled all 5, then set_filter cleaned and refilled → 3 matching (a, c, e) +-- ============================================================ + +SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM tasks_cloudsync \gset +SELECT (:meta_pk_count = 3) AS prefill_t1_ok \gset +\if :prefill_t1_ok +\echo [PASS] (:testid) prefill: 3 matching rows have metadata after set_filter +\else +\echo [FAIL] (:testid) prefill: expected 3 tracked PKs after set_filter, got :meta_pk_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 2: New matching insert IS tracked +-- ============================================================ + +INSERT INTO tasks VALUES ('f', 'Task F', 1); +SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM tasks_cloudsync \gset +SELECT (:meta_pk_count = 4) AS prefill_t2_ok \gset +\if :prefill_t2_ok +\echo [PASS] (:testid) prefill: new matching insert tracked (4 PKs) +\else +\echo [FAIL] (:testid) prefill: expected 4 PKs after matching insert, got :meta_pk_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 3: New non-matching insert is NOT tracked +-- ============================================================ + +INSERT INTO tasks VALUES ('g', 'Task G', 2); +SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM tasks_cloudsync \gset +SELECT (:meta_pk_count = 4) AS prefill_t3_ok \gset +\if :prefill_t3_ok +\echo [PASS] (:testid) prefill: new non-matching insert not tracked (still 4 PKs) +\else +\echo [FAIL] (:testid) prefill: expected still 4 PKs after non-matching insert, got :meta_pk_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 4: Sync roundtrip — pre-existing + matching new rows transfer +-- ============================================================ + +SELECT encode( + cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq), + 'hex' +) AS payload_a_hex +FROM cloudsync_changes +WHERE site_id = cloudsync_siteid() \gset + +-- Setup Database B (empty) +\connect cloudsync_test_49_b +\ir helper_psql_conn_setup.sql +CREATE EXTENSION IF NOT EXISTS cloudsync; + +CREATE TABLE tasks (id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER); +SELECT cloudsync_init('tasks') AS _init_b \gset +SELECT cloudsync_set_filter('tasks', 'user_id = 1') AS _sf_b \gset + +-- Apply payload +SELECT cloudsync_payload_apply(decode(:'payload_a_hex', 'hex')) AS _apply \gset + +-- Only matching rows (a, c, e, f) should arrive; non-matching (b, d, g) should not +SELECT COUNT(*) AS row_count FROM tasks \gset +SELECT (:row_count = 4) AS prefill_t4a_ok \gset +\if :prefill_t4a_ok +\echo [PASS] (:testid) prefill_sync: 4 matching rows synced to Database B +\else +\echo [FAIL] (:testid) prefill_sync: expected 4 rows in Database B, got :row_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Verify non-matching post-filter row 'g' did NOT sync +SELECT COUNT(*) AS g_count FROM tasks WHERE id = 'g' \gset +SELECT (:g_count = 0) AS prefill_t4b_ok \gset +\if :prefill_t4b_ok +\echo [PASS] (:testid) prefill_sync: non-matching post-filter row 'g' not synced +\else +\echo [FAIL] (:testid) prefill_sync: row 'g' should not have synced +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Verify pre-existing non-matching row 'b' did NOT sync (metadata removed by set_filter) +SELECT COUNT(*) AS b_count FROM tasks WHERE id = 'b' AND user_id = 2 \gset +SELECT (:b_count = 0) AS prefill_t4c_ok \gset +\if :prefill_t4c_ok +\echo [PASS] (:testid) prefill_sync: pre-existing non-matching row 'b' not synced +\else +\echo [FAIL] (:testid) prefill_sync: pre-existing non-matching row 'b' should not have synced +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- ============================================================ +-- Test 5: Composite PK with pre-existing data +-- ============================================================ + +\connect cloudsync_test_49_a + +CREATE TABLE projects ( + org_id INTEGER NOT NULL, + proj_id INTEGER NOT NULL, + name TEXT, + status TEXT, + PRIMARY KEY(org_id, proj_id) +); + +-- Pre-existing rows: 2 matching (org_id=1), 2 non-matching +INSERT INTO projects VALUES (1, 1, 'Alpha', 'active'); +INSERT INTO projects VALUES (1, 2, 'Beta', 'active'); +INSERT INTO projects VALUES (2, 1, 'Gamma', 'active'); +INSERT INTO projects VALUES (2, 2, 'Delta', 'active'); + +SELECT cloudsync_init('projects') AS _init_proj \gset +SELECT cloudsync_set_filter('projects', 'org_id = 1') AS _sf_proj \gset + +-- set_filter resets metatable: only 2 matching rows (org_id=1) should have metadata +SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM projects_cloudsync \gset +SELECT (:meta_pk_count = 2) AS prefill_t5_ok \gset +\if :prefill_t5_ok +\echo [PASS] (:testid) prefill_composite: 2 matching rows have metadata after set_filter +\else +\echo [FAIL] (:testid) prefill_composite: expected 2 tracked PKs, got :meta_pk_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- New matching insert tracked +INSERT INTO projects VALUES (1, 3, 'Epsilon', 'active'); +SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM projects_cloudsync \gset +SELECT (:meta_pk_count = 3) AS prefill_t5b_ok \gset +\if :prefill_t5b_ok +\echo [PASS] (:testid) prefill_composite: new matching row tracked (3 PKs) +\else +\echo [FAIL] (:testid) prefill_composite: expected 3 PKs after matching insert, got :meta_pk_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- New non-matching insert NOT tracked +INSERT INTO projects VALUES (3, 1, 'Zeta', 'active'); +SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM projects_cloudsync \gset +SELECT (:meta_pk_count = 3) AS prefill_t5c_ok \gset +\if :prefill_t5c_ok +\echo [PASS] (:testid) prefill_composite: new non-matching row not tracked (still 3 PKs) +\else +\echo [FAIL] (:testid) prefill_composite: expected still 3 PKs, got :meta_pk_count +SELECT (:fail::int + 1) AS fail \gset +\endif + +-- Cleanup +\ir helper_test_cleanup.sql +\if :should_cleanup +DROP DATABASE IF EXISTS cloudsync_test_49_a; +DROP DATABASE IF EXISTS cloudsync_test_49_b; +\endif diff --git a/test/postgresql/full_test.sql b/test/postgresql/full_test.sql index 2341687..99cae31 100644 --- a/test/postgresql/full_test.sql +++ b/test/postgresql/full_test.sql @@ -54,6 +54,9 @@ \ir 44_large_composite_pk.sql \ir 45_pg_specific_types.sql \ir 46_schema_hash_mismatch.sql +\ir 47_row_filter_advanced.sql +\ir 48_row_filter_multi_table.sql +\ir 49_row_filter_prefill.sql -- 'Test summary' \echo '\nTest summary:' diff --git a/test/unit.c b/test/unit.c index 31e9e3e..b92cd77 100644 --- a/test/unit.c +++ b/test/unit.c @@ -7996,6 +7996,774 @@ bool do_test_row_filter(int nclients, bool print_result, bool cleanup_databases) return result; } +// MARK: - Row Filter: Clear Filter - + +bool do_test_row_filter_clear(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[MAX_SIMULATED_CLIENTS] = {NULL}; + bool result = false; + int rc = SQLITE_OK; + + memset(db, 0, sizeof(sqlite3 *) * MAX_SIMULATED_CLIENTS); + if (nclients >= MAX_SIMULATED_CLIENTS) nclients = MAX_SIMULATED_CLIENTS; + if (nclients < 2) nclients = 2; + + time_t timestamp = time(NULL); + int saved_counter = test_counter; + for (int i = 0; i < nclients; ++i) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + rc = sqlite3_exec(db[i], "CREATE TABLE tasks(id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('tasks');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_filter('tasks', 'user_id = 1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + } + + // Insert matching and non-matching rows on db[0] + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('a', 'Task A', 1);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('b', 'Task B', 2);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('c', 'Task C', 1);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + // Test 1: Only matching rows tracked + { + int64_t meta_count = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM tasks_cloudsync;"); + if (meta_count != 2) { + printf("do_test_row_filter_clear: expected 2 tracked PKs before clear, got %" PRId64 "\n", meta_count); + goto finalize; + } + } + + // Test 2: Clear filter on db[0] — resets metatable with all rows (a, b, c) + rc = sqlite3_exec(db[0], "SELECT cloudsync_clear_filter('tasks');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + // Test 3: Insert non-matching row — should be tracked (no filter) + // clear_filter refilled with all 3 existing rows + 'd' = 4 + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('d', 'Task D', 2);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + { + int64_t meta_count = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM tasks_cloudsync;"); + if (meta_count != 4) { + printf("do_test_row_filter_clear: expected 4 tracked PKs after clear+insert, got %" PRId64 "\n", meta_count); + goto finalize; + } + } + + // Test 4: Update row 'b' — already tracked by clear_filter refill, meta count unchanged + rc = sqlite3_exec(db[0], "UPDATE tasks SET title='Task B Updated' WHERE id='b';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + { + int64_t meta_count = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM tasks_cloudsync;"); + if (meta_count != 4) { + printf("do_test_row_filter_clear: expected 4 tracked PKs after update on 'b', got %" PRId64 "\n", meta_count); + goto finalize; + } + } + + // Test 5: Sync to db[1] (which still has filter active) + // Payload apply bypasses local triggers, so db[1] should receive all rows + if (do_merge_using_payload(db[0], db[1], true, true) == false) goto finalize; + { + int64_t task_count = test_query_int(db[1], "SELECT COUNT(*) FROM tasks;"); + if (task_count != 4) { + printf("do_test_row_filter_clear: expected 4 rows in db[1] after merge, got %" PRId64 "\n", task_count); + goto finalize; + } + } + + if (print_result) { + printf("\n-> tasks (db[0]) after clear:\n"); + do_query(db[0], "SELECT * FROM tasks ORDER BY id;", NULL); + printf("\n-> tasks (db[1]) after merge:\n"); + do_query(db[1], "SELECT * FROM tasks ORDER BY id;", NULL); + } + + result = true; + +finalize: + for (int i = 0; i < nclients; ++i) { + if (rc != SQLITE_OK && db[i] && (sqlite3_errcode(db[i]) != SQLITE_OK)) + printf("do_test_row_filter_clear error: %s\n", sqlite3_errmsg(db[i])); + if (db[i]) close_db(db[i]); + if (cleanup_databases) { + char buf[256]; + do_build_database_path(buf, i, timestamp, saved_counter++); + file_delete_internal(buf); + } + } + return result; +} + +// MARK: - Row Filter: Complex Expressions - + +bool do_test_row_filter_complex_expressions(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[MAX_SIMULATED_CLIENTS] = {NULL}; + bool result = false; + int rc = SQLITE_OK; + + memset(db, 0, sizeof(sqlite3 *) * MAX_SIMULATED_CLIENTS); + if (nclients >= MAX_SIMULATED_CLIENTS) nclients = MAX_SIMULATED_CLIENTS; + if (nclients < 2) nclients = 2; + + time_t timestamp = time(NULL); + int saved_counter = test_counter; + for (int i = 0; i < nclients; ++i) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + rc = sqlite3_exec(db[i], "CREATE TABLE items(id TEXT PRIMARY KEY NOT NULL, status TEXT, priority INTEGER, category TEXT, user_id INTEGER);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('items');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + } + + // --- Phase A: AND + comparison operators --- + rc = sqlite3_exec(db[0], "SELECT cloudsync_set_filter('items', 'user_id = 1 AND priority > 3');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + rc = sqlite3_exec(db[0], "INSERT INTO items VALUES('a', 'active', 5, 'work', 1);", NULL, NULL, NULL); // matches + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO items VALUES('b', 'active', 2, 'work', 1);", NULL, NULL, NULL); // fails priority + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO items VALUES('c', 'active', 5, 'work', 2);", NULL, NULL, NULL); // fails user_id + if (rc != SQLITE_OK) goto finalize; + + { + int64_t meta_count = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM items_cloudsync;"); + if (meta_count != 1) { + printf("do_test_row_filter_complex: Phase A expected 1 tracked PK, got %" PRId64 "\n", meta_count); + goto finalize; + } + } + + // --- Phase B: String literal with escaped quotes --- + rc = sqlite3_exec(db[0], "SELECT cloudsync_clear_filter('items');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "SELECT cloudsync_set_filter('items', 'status = ''active'' AND user_id = 1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + { + int64_t before = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM items_cloudsync;"); + rc = sqlite3_exec(db[0], "INSERT INTO items VALUES('d', 'active', 1, 'home', 1);", NULL, NULL, NULL); // matches + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO items VALUES('e', 'inactive', 1, 'home', 1);", NULL, NULL, NULL); // fails status + if (rc != SQLITE_OK) goto finalize; + int64_t after = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM items_cloudsync;"); + if (after - before != 1) { + printf("do_test_row_filter_complex: Phase B expected 1 new PK, got %" PRId64 "\n", after - before); + goto finalize; + } + } + + // --- Phase C: IS NULL --- + rc = sqlite3_exec(db[0], "SELECT cloudsync_clear_filter('items');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "SELECT cloudsync_set_filter('items', 'category IS NULL');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + { + int64_t before = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM items_cloudsync;"); + rc = sqlite3_exec(db[0], "INSERT INTO items VALUES('f', 'x', 1, NULL, 1);", NULL, NULL, NULL); // matches + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO items VALUES('g', 'x', 1, 'work', 1);", NULL, NULL, NULL); // fails + if (rc != SQLITE_OK) goto finalize; + int64_t after = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM items_cloudsync;"); + if (after - before != 1) { + printf("do_test_row_filter_complex: Phase C (IS NULL) expected 1 new PK, got %" PRId64 "\n", after - before); + goto finalize; + } + } + + // --- Phase D: IN expression --- + rc = sqlite3_exec(db[0], "SELECT cloudsync_clear_filter('items');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "SELECT cloudsync_set_filter('items', 'priority IN (1, 3, 5)');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + { + int64_t before = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM items_cloudsync;"); + rc = sqlite3_exec(db[0], "INSERT INTO items VALUES('h', 'x', 3, 'y', 1);", NULL, NULL, NULL); // matches + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO items VALUES('i', 'x', 4, 'y', 1);", NULL, NULL, NULL); // fails + if (rc != SQLITE_OK) goto finalize; + int64_t after = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM items_cloudsync;"); + if (after - before != 1) { + printf("do_test_row_filter_complex: Phase D (IN) expected 1 new PK, got %" PRId64 "\n", after - before); + goto finalize; + } + } + + // --- Phase E: Sync roundtrip with AND filter --- + rc = sqlite3_exec(db[1], "SELECT cloudsync_set_filter('items', 'user_id = 1 AND priority > 3');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + // Insert on db[1] matching and non-matching + rc = sqlite3_exec(db[1], "INSERT INTO items VALUES('j', 'test', 5, 'cat', 1);", NULL, NULL, NULL); // matches + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[1], "INSERT INTO items VALUES('k', 'test', 1, 'cat', 1);", NULL, NULL, NULL); // fails priority + if (rc != SQLITE_OK) goto finalize; + + // Sync db[1] -> db[0] + if (do_merge_using_payload(db[1], db[0], true, true) == false) goto finalize; + { + int64_t j_exists = test_query_int(db[0], "SELECT COUNT(*) FROM items WHERE id='j';"); + if (j_exists != 1) { + printf("do_test_row_filter_complex: Phase E expected 'j' in db[0], not found\n"); + goto finalize; + } + int64_t k_exists = test_query_int(db[0], "SELECT COUNT(*) FROM items WHERE id='k';"); + if (k_exists != 0) { + printf("do_test_row_filter_complex: Phase E expected 'k' NOT in db[0], but found\n"); + goto finalize; + } + } + + if (print_result) { + printf("\n-> items (db[0]):\n"); + do_query(db[0], "SELECT * FROM items ORDER BY id;", NULL); + printf("\n-> items (db[1]):\n"); + do_query(db[1], "SELECT * FROM items ORDER BY id;", NULL); + } + + result = true; + +finalize: + for (int i = 0; i < nclients; ++i) { + if (rc != SQLITE_OK && db[i] && (sqlite3_errcode(db[i]) != SQLITE_OK)) + printf("do_test_row_filter_complex error: %s\n", sqlite3_errmsg(db[i])); + if (db[i]) close_db(db[i]); + if (cleanup_databases) { + char buf[256]; + do_build_database_path(buf, i, timestamp, saved_counter++); + file_delete_internal(buf); + } + } + return result; +} + +// MARK: - Row Filter: Row Transition - + +bool do_test_row_filter_row_transition(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[MAX_SIMULATED_CLIENTS] = {NULL}; + bool result = false; + int rc = SQLITE_OK; + + memset(db, 0, sizeof(sqlite3 *) * MAX_SIMULATED_CLIENTS); + if (nclients >= MAX_SIMULATED_CLIENTS) nclients = MAX_SIMULATED_CLIENTS; + if (nclients < 2) nclients = 2; + + time_t timestamp = time(NULL); + int saved_counter = test_counter; + for (int i = 0; i < nclients; ++i) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + rc = sqlite3_exec(db[i], "CREATE TABLE tasks(id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('tasks');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_filter('tasks', 'user_id = 1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + } + + // --- Phase A: Row exits filter (matching -> non-matching) --- + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('a', 'Task A', 1);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + { + int64_t meta_before = test_query_int(db[0], "SELECT COUNT(*) FROM tasks_cloudsync;"); + // Update row to no longer match filter + rc = sqlite3_exec(db[0], "UPDATE tasks SET user_id = 2 WHERE id = 'a';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + int64_t meta_after = test_query_int(db[0], "SELECT COUNT(*) FROM tasks_cloudsync;"); + // UPDATE trigger should NOT fire (NEW.user_id=2 fails WHEN clause) + if (meta_after != meta_before) { + printf("do_test_row_filter_row_transition: Phase A - UPDATE out of filter changed meta (%" PRId64 " -> %" PRId64 ")\n", meta_before, meta_after); + goto finalize; + } + } + + // Sync to db[1]: payload reads current row values, so 'a' arrives with user_id=2 + // (the INSERT metadata triggers the sync, but the payload carries current column values) + if (do_merge_using_payload(db[0], db[1], true, true) == false) goto finalize; + { + int64_t a_uid = test_query_int(db[1], "SELECT user_id FROM tasks WHERE id='a';"); + if (a_uid != 2) { + printf("do_test_row_filter_row_transition: Phase A - db[1] expected user_id=2 for 'a', got %" PRId64 "\n", a_uid); + goto finalize; + } + } + + // --- Phase B: Row enters filter (non-matching -> matching) --- + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('b', 'Task B', 2);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + { + int64_t meta_before = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM tasks_cloudsync;"); + // Update row to now match filter + rc = sqlite3_exec(db[0], "UPDATE tasks SET user_id = 1 WHERE id = 'b';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + int64_t meta_after = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM tasks_cloudsync;"); + // UPDATE trigger should fire (NEW.user_id=1 passes WHEN clause) + if (meta_after <= meta_before) { + printf("do_test_row_filter_row_transition: Phase B - UPDATE into filter did not create metadata (%" PRId64 " -> %" PRId64 ")\n", meta_before, meta_after); + goto finalize; + } + } + + // Sync to db[1]: should now get 'b' with user_id=1 + if (do_merge_using_payload(db[0], db[1], true, true) == false) goto finalize; + { + int64_t b_exists = test_query_int(db[1], "SELECT COUNT(*) FROM tasks WHERE id='b' AND user_id=1;"); + if (b_exists != 1) { + printf("do_test_row_filter_row_transition: Phase B - db[1] expected 'b' with user_id=1, not found\n"); + goto finalize; + } + } + + // --- Phase C: Bidirectional with transition --- + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('c', 'Task C', 1);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + // Sync 'c' to db[1] + if (do_merge_using_payload(db[0], db[1], true, true) == false) goto finalize; + { + int64_t c_exists = test_query_int(db[1], "SELECT COUNT(*) FROM tasks WHERE id='c';"); + if (c_exists != 1) { + printf("do_test_row_filter_row_transition: Phase C - db[1] should have 'c'\n"); + goto finalize; + } + } + + // db[0]: move 'c' out of filter (no metadata generated) + rc = sqlite3_exec(db[0], "UPDATE tasks SET user_id = 2 WHERE id = 'c';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + // db[1]: update 'c' title (still matching on db[1], metadata generated) + rc = sqlite3_exec(db[1], "UPDATE tasks SET title = 'Task C Edited' WHERE id = 'c';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + // Sync db[1] -> db[0] + if (do_merge_using_payload(db[1], db[0], true, true) == false) goto finalize; + { + // db[0] should receive the title update from db[1] + int64_t c_title_match = test_query_int(db[0], "SELECT COUNT(*) FROM tasks WHERE id='c' AND title='Task C Edited';"); + if (c_title_match != 1) { + printf("do_test_row_filter_row_transition: Phase C - db[0] should have updated title for 'c'\n"); + goto finalize; + } + } + + if (print_result) { + printf("\n-> tasks (db[0]):\n"); + do_query(db[0], "SELECT * FROM tasks ORDER BY id;", NULL); + printf("\n-> tasks (db[1]):\n"); + do_query(db[1], "SELECT * FROM tasks ORDER BY id;", NULL); + } + + result = true; + +finalize: + for (int i = 0; i < nclients; ++i) { + if (rc != SQLITE_OK && db[i] && (sqlite3_errcode(db[i]) != SQLITE_OK)) + printf("do_test_row_filter_row_transition error: %s\n", sqlite3_errmsg(db[i])); + if (db[i]) close_db(db[i]); + if (cleanup_databases) { + char buf[256]; + do_build_database_path(buf, i, timestamp, saved_counter++); + file_delete_internal(buf); + } + } + return result; +} + +// MARK: - Row Filter: Filter Change - + +bool do_test_row_filter_change(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[MAX_SIMULATED_CLIENTS] = {NULL}; + bool result = false; + int rc = SQLITE_OK; + + memset(db, 0, sizeof(sqlite3 *) * MAX_SIMULATED_CLIENTS); + if (nclients >= MAX_SIMULATED_CLIENTS) nclients = MAX_SIMULATED_CLIENTS; + if (nclients < 2) nclients = 2; + + time_t timestamp = time(NULL); + int saved_counter = test_counter; + for (int i = 0; i < nclients; ++i) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + rc = sqlite3_exec(db[i], "CREATE TABLE tasks(id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('tasks');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + } + + // Set initial filter on db[0] + rc = sqlite3_exec(db[0], "SELECT cloudsync_set_filter('tasks', 'user_id = 1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + // Insert rows under initial filter + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('a', 'Task A', 1);", NULL, NULL, NULL); // matches + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('b', 'Task B', 2);", NULL, NULL, NULL); // non-matching + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('c', 'Task C', 1);", NULL, NULL, NULL); // matches + if (rc != SQLITE_OK) goto finalize; + + // Test 1: 2 PKs tracked under initial filter + { + int64_t meta_count = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM tasks_cloudsync;"); + if (meta_count != 2) { + printf("do_test_row_filter_change: expected 2 PKs under initial filter, got %" PRId64 "\n", meta_count); + goto finalize; + } + } + + // Change filter to user_id = 2 + rc = sqlite3_exec(db[0], "SELECT cloudsync_set_filter('tasks', 'user_id = 2');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + // Insert rows under new filter + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('d', 'Task D', 2);", NULL, NULL, NULL); // matches new + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('e', 'Task E', 1);", NULL, NULL, NULL); // non-matching under new + if (rc != SQLITE_OK) goto finalize; + + // Test 2: set_filter('user_id = 2') reset metatable — only 'b' (user_id=2) from refill + 'd' from insert = 2 + { + int64_t meta_count = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM tasks_cloudsync;"); + if (meta_count != 2) { + printf("do_test_row_filter_change: expected 2 PKs after filter change, got %" PRId64 "\n", meta_count); + goto finalize; + } + } + + // Test 3: Update row 'a' (user_id=1) — does NOT match new filter (user_id=2) + { + int64_t before = test_query_int(db[0], "SELECT COUNT(*) FROM tasks_cloudsync;"); + rc = sqlite3_exec(db[0], "UPDATE tasks SET title='Task A Updated' WHERE id='a';", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + int64_t after = test_query_int(db[0], "SELECT COUNT(*) FROM tasks_cloudsync;"); + if (after != before) { + printf("do_test_row_filter_change: update on 'a' should not change meta under new filter (%" PRId64 " -> %" PRId64 ")\n", before, after); + goto finalize; + } + } + + // Test 4: Sync to db[1] — db[1] has filter 'user_id = 1' + rc = sqlite3_exec(db[1], "SELECT cloudsync_set_filter('tasks', 'user_id = 1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + if (do_merge_using_payload(db[0], db[1], true, true) == false) goto finalize; + { + // db[1] should receive b, d (metadata from new filter era) via payload apply + // a, c had their metadata removed by filter change, e was never tracked + int64_t task_count = test_query_int(db[1], "SELECT COUNT(*) FROM tasks;"); + if (task_count != 2) { + printf("do_test_row_filter_change: expected 2 rows in db[1] after merge, got %" PRId64 "\n", task_count); + goto finalize; + } + int64_t b_exists = test_query_int(db[1], "SELECT COUNT(*) FROM tasks WHERE id='b';"); + int64_t d_exists = test_query_int(db[1], "SELECT COUNT(*) FROM tasks WHERE id='d';"); + if (b_exists != 1 || d_exists != 1) { + printf("do_test_row_filter_change: expected b, d in db[1] (got b=%" PRId64 " d=%" PRId64 ")\n", b_exists, d_exists); + goto finalize; + } + } + + if (print_result) { + printf("\n-> tasks (db[0]):\n"); + do_query(db[0], "SELECT * FROM tasks ORDER BY id;", NULL); + printf("\n-> tasks_cloudsync (db[0]):\n"); + do_query(db[0], "SELECT hex(pk), col_name, col_version, db_version FROM tasks_cloudsync ORDER BY pk, col_name;", NULL); + printf("\n-> tasks (db[1]):\n"); + do_query(db[1], "SELECT * FROM tasks ORDER BY id;", NULL); + } + + result = true; + +finalize: + for (int i = 0; i < nclients; ++i) { + if (rc != SQLITE_OK && db[i] && (sqlite3_errcode(db[i]) != SQLITE_OK)) + printf("do_test_row_filter_change error: %s\n", sqlite3_errmsg(db[i])); + if (db[i]) close_db(db[i]); + if (cleanup_databases) { + char buf[256]; + do_build_database_path(buf, i, timestamp, saved_counter++); + file_delete_internal(buf); + } + } + return result; +} + +// MARK: - Row Filter: Composite PK + Multi-Table - + +bool do_test_row_filter_composite_pk_multi_table(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[MAX_SIMULATED_CLIENTS] = {NULL}; + bool result = false; + int rc = SQLITE_OK; + + memset(db, 0, sizeof(sqlite3 *) * MAX_SIMULATED_CLIENTS); + if (nclients >= MAX_SIMULATED_CLIENTS) nclients = MAX_SIMULATED_CLIENTS; + if (nclients < 2) nclients = 2; + + time_t timestamp = time(NULL); + int saved_counter = test_counter; + for (int i = 0; i < nclients; ++i) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + // Table 1: composite PK + rc = sqlite3_exec(db[i], "CREATE TABLE projects(org_id INTEGER NOT NULL, proj_id INTEGER NOT NULL, name TEXT, PRIMARY KEY(org_id, proj_id));", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('projects');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_filter('projects', 'org_id = 1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + // Table 2: composite PK with string literal in filter + rc = sqlite3_exec(db[i], "CREATE TABLE members(org_id INTEGER NOT NULL, user_id INTEGER NOT NULL, role TEXT, PRIMARY KEY(org_id, user_id));", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('members');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_filter('members', 'org_id = 1 AND role = ''admin''');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + } + + // --- Test 1: Composite PK insert with filter --- + rc = sqlite3_exec(db[0], "INSERT INTO projects VALUES(1, 1, 'Proj A');", NULL, NULL, NULL); // matches + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO projects VALUES(2, 1, 'Proj B');", NULL, NULL, NULL); // fails + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO projects VALUES(1, 2, 'Proj C');", NULL, NULL, NULL); // matches + if (rc != SQLITE_OK) goto finalize; + + { + int64_t meta_count = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM projects_cloudsync;"); + if (meta_count != 2) { + printf("do_test_row_filter_composite: expected 2 project PKs, got %" PRId64 "\n", meta_count); + goto finalize; + } + } + + // --- Test 2: Multi-column filter with string literal on second table --- + rc = sqlite3_exec(db[0], "INSERT INTO members VALUES(1, 10, 'admin');", NULL, NULL, NULL); // matches + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO members VALUES(1, 20, 'viewer');", NULL, NULL, NULL); // fails role + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO members VALUES(2, 10, 'admin');", NULL, NULL, NULL); // fails org_id + if (rc != SQLITE_OK) goto finalize; + + { + int64_t meta_count = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM members_cloudsync;"); + if (meta_count != 1) { + printf("do_test_row_filter_composite: expected 1 member PK, got %" PRId64 "\n", meta_count); + goto finalize; + } + } + + // --- Test 3: Sync roundtrip --- + if (do_merge_using_payload(db[0], db[1], true, true) == false) goto finalize; + + { + int64_t proj_count = test_query_int(db[1], "SELECT COUNT(*) FROM projects;"); + if (proj_count != 2) { + printf("do_test_row_filter_composite: expected 2 projects in db[1], got %" PRId64 "\n", proj_count); + goto finalize; + } + int64_t member_count = test_query_int(db[1], "SELECT COUNT(*) FROM members;"); + if (member_count != 1) { + printf("do_test_row_filter_composite: expected 1 member in db[1], got %" PRId64 "\n", member_count); + goto finalize; + } + // Verify correct member + int64_t admin_exists = test_query_int(db[1], "SELECT COUNT(*) FROM members WHERE org_id=1 AND user_id=10 AND role='admin';"); + if (admin_exists != 1) { + printf("do_test_row_filter_composite: expected admin member (1,10) in db[1]\n"); + goto finalize; + } + } + + // --- Test 4: Update and delete on composite PK --- + rc = sqlite3_exec(db[0], "UPDATE projects SET name='Proj A Updated' WHERE org_id=1 AND proj_id=1;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "DELETE FROM projects WHERE org_id=1 AND proj_id=2;", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + + // Sync again + if (do_merge_using_payload(db[0], db[1], true, true) == false) goto finalize; + + { + // db[1] should have 1 project (Proj A Updated), Proj C deleted + int64_t proj_count = test_query_int(db[1], "SELECT COUNT(*) FROM projects;"); + if (proj_count != 1) { + printf("do_test_row_filter_composite: expected 1 project in db[1] after update+delete, got %" PRId64 "\n", proj_count); + goto finalize; + } + int64_t updated = test_query_int(db[1], "SELECT COUNT(*) FROM projects WHERE org_id=1 AND proj_id=1 AND name='Proj A Updated';"); + if (updated != 1) { + printf("do_test_row_filter_composite: expected updated 'Proj A Updated' in db[1]\n"); + goto finalize; + } + } + + if (print_result) { + printf("\n-> projects (db[0]):\n"); + do_query(db[0], "SELECT * FROM projects ORDER BY org_id, proj_id;", NULL); + printf("\n-> members (db[0]):\n"); + do_query(db[0], "SELECT * FROM members ORDER BY org_id, user_id;", NULL); + printf("\n-> projects (db[1]):\n"); + do_query(db[1], "SELECT * FROM projects ORDER BY org_id, proj_id;", NULL); + printf("\n-> members (db[1]):\n"); + do_query(db[1], "SELECT * FROM members ORDER BY org_id, user_id;", NULL); + } + + result = true; + +finalize: + for (int i = 0; i < nclients; ++i) { + if (rc != SQLITE_OK && db[i] && (sqlite3_errcode(db[i]) != SQLITE_OK)) + printf("do_test_row_filter_composite error: %s\n", sqlite3_errmsg(db[i])); + if (db[i]) close_db(db[i]); + if (cleanup_databases) { + char buf[256]; + do_build_database_path(buf, i, timestamp, saved_counter++); + file_delete_internal(buf); + } + } + return result; +} + +// MARK: - Row Filter: Pre-existing Data (Prefill) - + +bool do_test_row_filter_prefill(int nclients, bool print_result, bool cleanup_databases) { + sqlite3 *db[MAX_SIMULATED_CLIENTS] = {NULL}; + bool result = false; + int rc = SQLITE_OK; + + memset(db, 0, sizeof(sqlite3 *) * MAX_SIMULATED_CLIENTS); + if (nclients >= MAX_SIMULATED_CLIENTS) nclients = MAX_SIMULATED_CLIENTS; + if (nclients < 2) nclients = 2; + + time_t timestamp = time(NULL); + int saved_counter = test_counter; + for (int i = 0; i < nclients; ++i) { + db[i] = do_create_database_file(i, timestamp, test_counter++); + if (!db[i]) return false; + + // Create table + rc = sqlite3_exec(db[i], "CREATE TABLE tasks(id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + } + + // --- Insert pre-existing data into db[0] BEFORE cloudsync_init --- + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('a', 'Task A', 1);", NULL, NULL, NULL); // matches filter + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('b', 'Task B', 2);", NULL, NULL, NULL); // non-matching + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('c', 'Task C', 1);", NULL, NULL, NULL); // matches filter + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('d', 'Task D', 3);", NULL, NULL, NULL); // non-matching + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('e', 'Task E', 1);", NULL, NULL, NULL); // matches filter + if (rc != SQLITE_OK) goto finalize; + + // --- Now init cloudsync and set filter on both databases --- + for (int i = 0; i < nclients; ++i) { + rc = sqlite3_exec(db[i], "SELECT cloudsync_init('tasks');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + rc = sqlite3_exec(db[i], "SELECT cloudsync_set_filter('tasks', 'user_id = 1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + } + + // --- Test 1: set_filter resets the metatable to only matching rows --- + // cloudsync_init filled all 5, then set_filter cleaned and refilled with filter → 3 matching (a, c, e) + { + int64_t meta_count = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM tasks_cloudsync;"); + if (meta_count != 3) { + printf("do_test_row_filter_prefill: expected 3 tracked PKs after set_filter (matching only), got %" PRId64 "\n", meta_count); + goto finalize; + } + } + + // --- Test 2: New insert of matching row → tracked --- + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('f', 'Task F', 1);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + { + int64_t meta_count = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM tasks_cloudsync;"); + if (meta_count != 4) { + printf("do_test_row_filter_prefill: expected 4 PKs after matching insert, got %" PRId64 "\n", meta_count); + goto finalize; + } + } + + // --- Test 3: New insert of non-matching row → NOT tracked --- + rc = sqlite3_exec(db[0], "INSERT INTO tasks VALUES('g', 'Task G', 2);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto finalize; + { + int64_t meta_count = test_query_int(db[0], "SELECT COUNT(DISTINCT pk) FROM tasks_cloudsync;"); + if (meta_count != 4) { + printf("do_test_row_filter_prefill: expected still 4 PKs after non-matching insert, got %" PRId64 "\n", meta_count); + goto finalize; + } + } + + // --- Test 4: Sync roundtrip — only matching rows transfer --- + if (do_merge_using_payload(db[0], db[1], true, true) == false) goto finalize; + + { + // db[1] should have: a, c, e (matching pre-existing) + f (matching new) = 4 rows + // b, d (non-matching, metadata removed by set_filter) and g (non-matching post-filter) should NOT transfer + int64_t task_count = test_query_int(db[1], "SELECT COUNT(*) FROM tasks;"); + if (task_count != 4) { + printf("do_test_row_filter_prefill: expected 4 rows in db[1] after merge, got %" PRId64 "\n", task_count); + goto finalize; + } + // Verify 'g' (non-matching, inserted after filter) did NOT transfer + int64_t g_exists = test_query_int(db[1], "SELECT COUNT(*) FROM tasks WHERE id='g';"); + if (g_exists != 0) { + printf("do_test_row_filter_prefill: non-matching row 'g' should not have synced\n"); + goto finalize; + } + // Verify pre-existing non-matching row 'b' did NOT transfer (metadata removed by set_filter) + int64_t b_exists = test_query_int(db[1], "SELECT COUNT(*) FROM tasks WHERE id='b' AND user_id=2;"); + if (b_exists != 0) { + printf("do_test_row_filter_prefill: pre-existing non-matching row 'b' should not have synced\n"); + goto finalize; + } + } + + if (print_result) { + printf("\n-> tasks (db[0]):\n"); + do_query(db[0], "SELECT * FROM tasks ORDER BY id;", NULL); + printf("\n-> tasks_cloudsync (db[0]):\n"); + do_query(db[0], "SELECT hex(pk), col_name, col_version, db_version FROM tasks_cloudsync ORDER BY pk, col_name;", NULL); + printf("\n-> tasks (db[1]):\n"); + do_query(db[1], "SELECT * FROM tasks ORDER BY id;", NULL); + } + + result = true; + +finalize: + for (int i = 0; i < nclients; ++i) { + if (rc != SQLITE_OK && db[i] && (sqlite3_errcode(db[i]) != SQLITE_OK)) + printf("do_test_row_filter_prefill error: %s\n", sqlite3_errmsg(db[i])); + if (db[i]) close_db(db[i]); + if (cleanup_databases) { + char buf[256]; + do_build_database_path(buf, i, timestamp, saved_counter++); + file_delete_internal(buf); + } + } + return result; +} + // Test that BEFORE triggers with RAISE(ABORT) simulate RLS denial: // per-PK savepoints isolate failures so allowed rows commit and denied rows roll back. bool do_test_rls_trigger_denial (int nclients, bool print_result, bool cleanup_databases, bool only_locals) { @@ -11186,6 +11954,12 @@ int main (int argc, const char * argv[]) { // test row-level filter result += test_report("Test Row Filter:", do_test_row_filter(2, print_result, cleanup_databases)); + result += test_report("Test Row Filter Clear:", do_test_row_filter_clear(2, print_result, cleanup_databases)); + result += test_report("Test Row Filter Complex:", do_test_row_filter_complex_expressions(2, print_result, cleanup_databases)); + result += test_report("Test Row Filter Transition:", do_test_row_filter_row_transition(2, print_result, cleanup_databases)); + result += test_report("Test Row Filter Change:", do_test_row_filter_change(2, print_result, cleanup_databases)); + result += test_report("Test Row Filter CompPK:", do_test_row_filter_composite_pk_multi_table(2, print_result, cleanup_databases)); + result += test_report("Test Row Filter Prefill:", do_test_row_filter_prefill(2, print_result, cleanup_databases)); // test block-level LWW result += test_report("Test Block LWW Insert:", do_test_block_lww_insert(2, print_result, cleanup_databases));