Skip to content

Commit b36ff57

Browse files
authored
fix: clean up metatable on row filter change and add edge-case test coverage (#38)
Previously, cloudsync_set_filter and cloudsync_clear_filter only updated the filter setting and recreated triggers — they did not touch the metatable. This left stale metadata for rows that no longer matched the active filter, causing non-matching rows to sync to other nodes. Now both functions DELETE all rows from the metatable and call cloudsync_refill_metatable after changing the filter. This ensures the metatable only contains entries for rows matching the current filter (or all rows when the filter is cleared). Core changes: Add cloudsync_reset_metatable() which deletes metatable contents and refills from scratch using the current filter setting Add SQL_DELETE_ALL_FROM_CLOUDSYNC_TABLE constant with platform-specific format specifiers (%w for SQLite, %s for PostgreSQL) to match how each platform formats the meta_ref identifier Call cloudsync_reset_metatable from set_filter/clear_filter in both the SQLite and PostgreSQL implementations Test coverage: Add row filter edge-case tests: clear_filter lifecycle, complex filter expressions (AND, IS NULL), row entering/exiting filter via UPDATE, filter change after data, composite PK with multi-column filters, multi-table roundtrip sync, and pre-existing data prefill Add corresponding PostgreSQL test files (47, 48, 49) Update test expectations to validate that the metatable reflects the latest filter after set_filter and clear_filter calls
1 parent 90055b2 commit b36ff57

12 files changed

Lines changed: 1381 additions & 1 deletion

src/cloudsync.c

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2528,6 +2528,18 @@ char *cloudsync_filter_add_row_prefix (const char *filter, const char *prefix, c
25282528
return result;
25292529
}
25302530

2531+
int cloudsync_reset_metatable (cloudsync_context *data, const char *table_name) {
2532+
cloudsync_table_context *table = table_lookup(data, table_name);
2533+
if (!table) return DBRES_ERROR;
2534+
2535+
char *sql = cloudsync_memory_mprintf(SQL_DELETE_ALL_FROM_CLOUDSYNC_TABLE, table->meta_ref);
2536+
int rc = database_exec(data, sql);
2537+
cloudsync_memory_free(sql);
2538+
if (rc != DBRES_OK) return rc;
2539+
2540+
return cloudsync_refill_metatable(data, table_name);
2541+
}
2542+
25312543
int cloudsync_refill_metatable (cloudsync_context *data, const char *table_name) {
25322544
cloudsync_table_context *table = table_lookup(data, table_name);
25332545
if (!table) return DBRES_ERROR;

src/cloudsync.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
extern "C" {
1919
#endif
2020

21-
#define CLOUDSYNC_VERSION "1.0.9"
21+
#define CLOUDSYNC_VERSION "1.0.10"
2222
#define CLOUDSYNC_MAX_TABLENAME_LEN 512
2323

2424
#define CLOUDSYNC_VALUE_NOTSET -1
@@ -95,6 +95,8 @@ int cloudsync_payload_get (cloudsync_context *data, char **blob, int *blob_si
9595
int cloudsync_payload_save (cloudsync_context *data, const char *payload_path, int *blob_size); // available only on Desktop OS (no WASM, no mobile)
9696

9797
// CloudSync table context
98+
int cloudsync_refill_metatable (cloudsync_context *data, const char *table_name);
99+
int cloudsync_reset_metatable (cloudsync_context *data, const char *table_name);
98100
cloudsync_table_context *table_lookup (cloudsync_context *data, const char *table_name);
99101
void *table_column_lookup (cloudsync_table_context *table, const char *col_name, bool is_merge, int *index);
100102
bool table_enabled (cloudsync_table_context *table);

src/postgresql/cloudsync_postgresql.c

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,13 @@ Datum cloudsync_set_filter (PG_FUNCTION_ARGS) {
695695
ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR),
696696
errmsg("cloudsync_set_filter: error recreating triggers")));
697697
}
698+
699+
// Clean and refill metatable with the new filter
700+
rc = cloudsync_reset_metatable(data, tbl);
701+
if (rc != DBRES_OK) {
702+
ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR),
703+
errmsg("cloudsync_set_filter: error resetting metatable")));
704+
}
698705
}
699706
PG_CATCH();
700707
{
@@ -753,6 +760,13 @@ Datum cloudsync_clear_filter (PG_FUNCTION_ARGS) {
753760
ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR),
754761
errmsg("cloudsync_clear_filter: error recreating triggers")));
755762
}
763+
764+
// Clean and refill metatable without filter (all rows)
765+
rc = cloudsync_reset_metatable(data, tbl);
766+
if (rc != DBRES_OK) {
767+
ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR),
768+
errmsg("cloudsync_clear_filter: error resetting metatable")));
769+
}
756770
}
757771
PG_CATCH();
758772
{

src/postgresql/sql_postgresql.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,9 @@ const char * const SQL_PRAGMA_TABLEINFO_LIST_NONPK_NAME_CID =
354354
const char * const SQL_DROP_CLOUDSYNC_TABLE =
355355
"DROP TABLE IF EXISTS %s CASCADE;";
356356

357+
const char * const SQL_DELETE_ALL_FROM_CLOUDSYNC_TABLE =
358+
"DELETE FROM %s;";
359+
357360
const char * const SQL_CLOUDSYNC_DELETE_COLS_NOT_IN_SCHEMA_OR_PKCOL =
358361
"DELETE FROM %s WHERE col_name NOT IN ("
359362
"SELECT column_name FROM information_schema.columns WHERE table_name = '%s' "

src/sql.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ extern const char * const SQL_CLOUDSYNC_SELECT_COL_VERSION_BY_PK_COL;
5757
extern const char * const SQL_CLOUDSYNC_SELECT_SITE_ID_BY_PK_COL;
5858
extern const char * const SQL_PRAGMA_TABLEINFO_LIST_NONPK_NAME_CID;
5959
extern const char * const SQL_DROP_CLOUDSYNC_TABLE;
60+
extern const char * const SQL_DELETE_ALL_FROM_CLOUDSYNC_TABLE;
6061
extern const char * const SQL_CLOUDSYNC_DELETE_COLS_NOT_IN_SCHEMA_OR_PKCOL;
6162
extern const char * const SQL_PRAGMA_TABLEINFO_PK_QUALIFIED_COLLIST_FMT;
6263
extern const char * const SQL_CLOUDSYNC_GC_DELETE_ORPHANED_PK;

src/sqlite/cloudsync_sqlite.c

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,14 @@ void dbsync_set_filter (sqlite3_context *context, int argc, sqlite3_value **argv
12591259
return;
12601260
}
12611261

1262+
// Clean and refill metatable with the new filter
1263+
rc = cloudsync_reset_metatable(data, tbl);
1264+
if (rc != DBRES_OK) {
1265+
dbsync_set_error(context, "cloudsync_set_filter: error resetting metatable");
1266+
sqlite3_result_error_code(context, rc);
1267+
return;
1268+
}
1269+
12621270
sqlite3_result_int(context, 1);
12631271
}
12641272

@@ -1289,6 +1297,14 @@ void dbsync_clear_filter (sqlite3_context *context, int argc, sqlite3_value **ar
12891297
return;
12901298
}
12911299

1300+
// Clean and refill metatable without filter (all rows)
1301+
rc = cloudsync_reset_metatable(data, tbl);
1302+
if (rc != DBRES_OK) {
1303+
dbsync_set_error(context, "cloudsync_clear_filter: error resetting metatable");
1304+
sqlite3_result_error_code(context, rc);
1305+
return;
1306+
}
1307+
12921308
sqlite3_result_int(context, 1);
12931309
}
12941310

src/sqlite/sql_sqlite.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,9 @@ const char * const SQL_PRAGMA_TABLEINFO_LIST_NONPK_NAME_CID =
226226
const char * const SQL_DROP_CLOUDSYNC_TABLE =
227227
"DROP TABLE IF EXISTS \"%w\";";
228228

229+
const char * const SQL_DELETE_ALL_FROM_CLOUDSYNC_TABLE =
230+
"DELETE FROM \"%w\";";
231+
229232
const char * const SQL_CLOUDSYNC_DELETE_COLS_NOT_IN_SCHEMA_OR_PKCOL =
230233
"DELETE FROM \"%w\" WHERE \"col_name\" NOT IN ("
231234
"SELECT name FROM pragma_table_info('%q') UNION SELECT '%s'"
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
-- 'Row-level filter advanced tests (clear, complex expressions, row transitions, filter change)'
2+
3+
\set testid '47'
4+
\ir helper_test_init.sql
5+
6+
-- Create database
7+
\connect postgres
8+
\ir helper_psql_conn_setup.sql
9+
DROP DATABASE IF EXISTS cloudsync_test_47_a;
10+
CREATE DATABASE cloudsync_test_47_a;
11+
12+
\connect cloudsync_test_47_a
13+
\ir helper_psql_conn_setup.sql
14+
CREATE EXTENSION IF NOT EXISTS cloudsync;
15+
16+
-- ============================================================
17+
-- Test 1: cloudsync_clear_filter lifecycle
18+
-- ============================================================
19+
CREATE TABLE tasks (id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER);
20+
SELECT cloudsync_init('tasks') AS _init \gset
21+
SELECT cloudsync_set_filter('tasks', 'user_id = 1') AS _sf \gset
22+
23+
INSERT INTO tasks VALUES ('a', 'Task A', 1);
24+
INSERT INTO tasks VALUES ('b', 'Task B', 2);
25+
INSERT INTO tasks VALUES ('c', 'Task C', 1);
26+
27+
-- Only matching rows tracked
28+
SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM tasks_cloudsync \gset
29+
SELECT (:meta_pk_count = 2) AS clear_t1a_ok \gset
30+
\if :clear_t1a_ok
31+
\echo [PASS] (:testid) clear_filter: 2 PKs tracked before clear
32+
\else
33+
\echo [FAIL] (:testid) clear_filter: expected 2 tracked PKs before clear, got :meta_pk_count
34+
SELECT (:fail::int + 1) AS fail \gset
35+
\endif
36+
37+
-- Clear filter
38+
SELECT cloudsync_clear_filter('tasks') AS _cf \gset
39+
40+
-- Insert non-matching row — should now be tracked (no filter)
41+
-- clear_filter refilled metatable with all 3 existing rows (a, b, c) + insert d = 4
42+
INSERT INTO tasks VALUES ('d', 'Task D', 2);
43+
SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM tasks_cloudsync \gset
44+
SELECT (:meta_pk_count = 4) AS clear_t1b_ok \gset
45+
\if :clear_t1b_ok
46+
\echo [PASS] (:testid) clear_filter: non-matching row tracked after clear (4 PKs)
47+
\else
48+
\echo [FAIL] (:testid) clear_filter: expected 4 PKs after clear+insert, got :meta_pk_count
49+
SELECT (:fail::int + 1) AS fail \gset
50+
\endif
51+
52+
-- Update row 'b' — already tracked by clear_filter refill, meta count unchanged
53+
UPDATE tasks SET title = 'Task B Updated' WHERE id = 'b';
54+
SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM tasks_cloudsync \gset
55+
SELECT (:meta_pk_count = 4) AS clear_t1c_ok \gset
56+
\if :clear_t1c_ok
57+
\echo [PASS] (:testid) clear_filter: update on 'b' still 4 PKs
58+
\else
59+
\echo [FAIL] (:testid) clear_filter: expected 4 PKs after update on 'b', got :meta_pk_count
60+
SELECT (:fail::int + 1) AS fail \gset
61+
\endif
62+
63+
-- ============================================================
64+
-- Test 2: Complex filter — AND + comparison operators
65+
-- ============================================================
66+
DROP TABLE IF EXISTS items;
67+
CREATE TABLE items (id TEXT PRIMARY KEY NOT NULL, status TEXT, priority INTEGER, category TEXT, user_id INTEGER);
68+
SELECT cloudsync_init('items') AS _init \gset
69+
SELECT cloudsync_set_filter('items', 'user_id = 1 AND priority > 3') AS _sf \gset
70+
71+
INSERT INTO items VALUES ('a', 'active', 5, 'work', 1); -- matches
72+
INSERT INTO items VALUES ('b', 'active', 2, 'work', 1); -- fails priority
73+
INSERT INTO items VALUES ('c', 'active', 5, 'work', 2); -- fails user_id
74+
75+
SELECT COUNT(DISTINCT pk) AS meta_pk_count FROM items_cloudsync \gset
76+
SELECT (:meta_pk_count = 1) AS complex_t2_ok \gset
77+
\if :complex_t2_ok
78+
\echo [PASS] (:testid) complex_filter: AND+comparison tracked 1 of 3 rows
79+
\else
80+
\echo [FAIL] (:testid) complex_filter: expected 1 tracked PK, got :meta_pk_count
81+
SELECT (:fail::int + 1) AS fail \gset
82+
\endif
83+
84+
-- ============================================================
85+
-- Test 3: IS NULL filter
86+
-- ============================================================
87+
SELECT cloudsync_clear_filter('items') AS _cf \gset
88+
SELECT cloudsync_set_filter('items', 'category IS NULL') AS _sf \gset
89+
90+
SELECT COUNT(DISTINCT pk) AS meta_before FROM items_cloudsync \gset
91+
INSERT INTO items VALUES ('f', 'x', 1, NULL, 1); -- matches
92+
INSERT INTO items VALUES ('g', 'x', 1, 'work', 1); -- fails
93+
SELECT COUNT(DISTINCT pk) AS meta_after FROM items_cloudsync \gset
94+
SELECT ((:meta_after::int - :meta_before::int) = 1) AS null_t3_ok \gset
95+
\if :null_t3_ok
96+
\echo [PASS] (:testid) IS NULL filter: only NULL-category row tracked
97+
\else
98+
\echo [FAIL] (:testid) IS NULL filter: expected 1 new PK, got (:meta_after - :meta_before)
99+
SELECT (:fail::int + 1) AS fail \gset
100+
\endif
101+
102+
-- ============================================================
103+
-- Test 4: Row exits filter (matching -> non-matching via UPDATE)
104+
-- ============================================================
105+
DROP TABLE IF EXISTS trans;
106+
CREATE TABLE trans (id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER);
107+
SELECT cloudsync_init('trans') AS _init \gset
108+
SELECT cloudsync_set_filter('trans', 'user_id = 1') AS _sf \gset
109+
110+
INSERT INTO trans VALUES ('a', 'Task A', 1);
111+
112+
SELECT COUNT(*) AS meta_before FROM trans_cloudsync \gset
113+
UPDATE trans SET user_id = 2 WHERE id = 'a';
114+
SELECT COUNT(*) AS meta_after FROM trans_cloudsync \gset
115+
SELECT (:meta_before = :meta_after) AS exit_t4_ok \gset
116+
\if :exit_t4_ok
117+
\echo [PASS] (:testid) row_exit: UPDATE out of filter did not change metadata
118+
\else
119+
\echo [FAIL] (:testid) row_exit: UPDATE out of filter changed metadata (:meta_before -> :meta_after)
120+
SELECT (:fail::int + 1) AS fail \gset
121+
\endif
122+
123+
-- ============================================================
124+
-- Test 5: Row enters filter (non-matching -> matching via UPDATE)
125+
-- ============================================================
126+
INSERT INTO trans VALUES ('b', 'Task B', 2);
127+
128+
SELECT COUNT(DISTINCT pk) AS meta_before FROM trans_cloudsync \gset
129+
UPDATE trans SET user_id = 1 WHERE id = 'b';
130+
SELECT COUNT(DISTINCT pk) AS meta_after FROM trans_cloudsync \gset
131+
SELECT (:meta_after::int > :meta_before::int) AS enter_t5_ok \gset
132+
\if :enter_t5_ok
133+
\echo [PASS] (:testid) row_enter: UPDATE into filter created metadata
134+
\else
135+
\echo [FAIL] (:testid) row_enter: UPDATE into filter did not create metadata (:meta_before -> :meta_after)
136+
SELECT (:fail::int + 1) AS fail \gset
137+
\endif
138+
139+
-- ============================================================
140+
-- Test 6: Filter change after data
141+
-- ============================================================
142+
DROP TABLE IF EXISTS fchange;
143+
CREATE TABLE fchange (id TEXT PRIMARY KEY NOT NULL, title TEXT, user_id INTEGER);
144+
SELECT cloudsync_init('fchange') AS _init \gset
145+
SELECT cloudsync_set_filter('fchange', 'user_id = 1') AS _sf \gset
146+
147+
INSERT INTO fchange VALUES ('a', 'A', 1); -- matches
148+
INSERT INTO fchange VALUES ('b', 'B', 2); -- non-matching
149+
INSERT INTO fchange VALUES ('c', 'C', 1); -- matches
150+
151+
SELECT COUNT(DISTINCT pk) AS meta_count FROM fchange_cloudsync \gset
152+
SELECT (:meta_count = 2) AS change_t6a_ok \gset
153+
\if :change_t6a_ok
154+
\echo [PASS] (:testid) filter_change: 2 PKs under initial filter
155+
\else
156+
\echo [FAIL] (:testid) filter_change: expected 2 PKs under initial filter, got :meta_count
157+
SELECT (:fail::int + 1) AS fail \gset
158+
\endif
159+
160+
-- Change filter — resets metatable to only rows matching new filter (user_id = 2)
161+
-- Only 'b' (user_id=2) matches new filter → 1 PK from refill, then insert d → 2
162+
SELECT cloudsync_set_filter('fchange', 'user_id = 2') AS _sf2 \gset
163+
164+
INSERT INTO fchange VALUES ('d', 'D', 2); -- matches new filter
165+
INSERT INTO fchange VALUES ('e', 'E', 1); -- non-matching under new filter
166+
167+
SELECT COUNT(DISTINCT pk) AS meta_count FROM fchange_cloudsync \gset
168+
SELECT (:meta_count = 2) AS change_t6b_ok \gset
169+
\if :change_t6b_ok
170+
\echo [PASS] (:testid) filter_change: 2 PKs after filter change (metatable reset)
171+
\else
172+
\echo [FAIL] (:testid) filter_change: expected 2 PKs after filter change, got :meta_count
173+
SELECT (:fail::int + 1) AS fail \gset
174+
\endif
175+
176+
-- Update 'a' (user_id=1) should NOT generate new metadata under new filter (user_id=2)
177+
SELECT COUNT(*) AS meta_before FROM fchange_cloudsync \gset
178+
UPDATE fchange SET title = 'A Updated' WHERE id = 'a';
179+
SELECT COUNT(*) AS meta_after FROM fchange_cloudsync \gset
180+
SELECT (:meta_before = :meta_after) AS change_t6c_ok \gset
181+
\if :change_t6c_ok
182+
\echo [PASS] (:testid) filter_change: update on 'a' not tracked under new filter
183+
\else
184+
\echo [FAIL] (:testid) filter_change: update on 'a' changed metadata (:meta_before -> :meta_after)
185+
SELECT (:fail::int + 1) AS fail \gset
186+
\endif
187+
188+
-- Cleanup
189+
\ir helper_test_cleanup.sql
190+
\if :should_cleanup
191+
DROP DATABASE IF EXISTS cloudsync_test_47_a;
192+
\else
193+
\echo [INFO] !!!!!
194+
\endif

0 commit comments

Comments
 (0)