Skip to content

Commit 1670e47

Browse files
committed
fix(sync): always bind column value parameters in merge_insert_col
Fix parameter binding bug in merge_insert_col that caused SQLite-to-PostgreSQL sync to fail with "there is no parameter $3" when NULL values were synced before non-NULL values for the same column.
1 parent 58ce9a4 commit 1670e47

4 files changed

Lines changed: 504 additions & 8 deletions

File tree

src/cloudsync.c

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,18 +1208,20 @@ int merge_insert_col (cloudsync_context *data, cloudsync_table_context *table, c
12081208
return rc;
12091209
}
12101210

1211-
// bind value
1211+
// bind value (always bind all expected parameters for correct prepared statement handling)
12121212
if (col_value) {
12131213
rc = databasevm_bind_value(vm, table->npks+1, col_value);
12141214
if (rc == DBRES_OK) rc = databasevm_bind_value(vm, table->npks+2, col_value);
1215-
if (rc != DBRES_OK) {
1216-
cloudsync_set_dberror(data);
1217-
dbvm_reset(vm);
1218-
return rc;
1219-
}
1220-
1215+
} else {
1216+
rc = databasevm_bind_null(vm, table->npks+1);
1217+
if (rc == DBRES_OK) rc = databasevm_bind_null(vm, table->npks+2);
12211218
}
1222-
1219+
if (rc != DBRES_OK) {
1220+
cloudsync_set_dberror(data);
1221+
dbvm_reset(vm);
1222+
return rc;
1223+
}
1224+
12231225
// perform real operation and disable triggers
12241226

12251227
// in case of GOS we reused the table->col_merge_stmt statement
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
-- Init With Existing Data Test
2+
-- Tests cloudsync_init on a table that already contains data.
3+
-- This verifies that cloudsync_refill_metatable correctly creates
4+
-- metadata entries for pre-existing rows.
5+
6+
\set testid '20'
7+
\ir helper_test_init.sql
8+
9+
\connect postgres
10+
\ir helper_psql_conn_setup.sql
11+
12+
-- Cleanup and create test databases
13+
DROP DATABASE IF EXISTS cloudsync_test_20a;
14+
DROP DATABASE IF EXISTS cloudsync_test_20b;
15+
CREATE DATABASE cloudsync_test_20a;
16+
CREATE DATABASE cloudsync_test_20b;
17+
18+
-- ============================================================================
19+
-- Setup Database A - INSERT DATA BEFORE cloudsync_init
20+
-- ============================================================================
21+
22+
\connect cloudsync_test_20a
23+
\ir helper_psql_conn_setup.sql
24+
CREATE EXTENSION IF NOT EXISTS cloudsync;
25+
26+
-- Create table with UUID primary key (required for CRDT replication)
27+
CREATE TABLE items (
28+
id UUID PRIMARY KEY,
29+
name TEXT NOT NULL DEFAULT '',
30+
price DOUBLE PRECISION NOT NULL DEFAULT 0.0,
31+
quantity INTEGER NOT NULL DEFAULT 0,
32+
metadata JSONB
33+
);
34+
35+
-- ============================================================================
36+
-- INSERT DATA BEFORE CALLING cloudsync_init
37+
-- This is the key difference from other tests - data exists before sync setup
38+
-- ============================================================================
39+
40+
INSERT INTO items VALUES ('11111111-1111-1111-1111-111111111111', 'Pre-existing Item 1', 10.99, 100, '{"pre": true}');
41+
INSERT INTO items VALUES ('22222222-2222-2222-2222-222222222222', 'Pre-existing Item 2', 20.50, 200, '{"pre": true, "id": 2}');
42+
INSERT INTO items VALUES ('33333333-3333-3333-3333-333333333333', 'Pre-existing Item 3', 30.00, 300, NULL);
43+
INSERT INTO items VALUES ('44444444-4444-4444-4444-444444444444', 'Pre-existing Item 4', 0.0, 0, '[]');
44+
INSERT INTO items VALUES ('55555555-5555-5555-5555-555555555555', 'Pre-existing Item 5', -5.50, -10, '{"nested": {"key": "value"}}');
45+
46+
-- Verify data exists before init
47+
SELECT COUNT(*) AS pre_init_count FROM items \gset
48+
\echo [INFO] (:testid) Rows before cloudsync_init: :pre_init_count
49+
50+
-- ============================================================================
51+
-- NOW call cloudsync_init - this should trigger cloudsync_refill_metatable
52+
-- ============================================================================
53+
54+
SELECT cloudsync_init('items', 'CLS', false) AS _init_a \gset
55+
56+
-- ============================================================================
57+
-- Verify metadata was created for existing rows
58+
-- ============================================================================
59+
60+
-- Check that metadata table exists and has entries
61+
SELECT COUNT(*) AS metadata_count FROM items_cloudsync \gset
62+
63+
SELECT (:metadata_count > 0) AS metadata_created \gset
64+
\if :metadata_created
65+
\echo [PASS] (:testid) Metadata table populated after init (:metadata_count entries)
66+
\else
67+
\echo [FAIL] (:testid) Metadata table empty after init - cloudsync_refill_metatable may have failed
68+
SELECT (:fail::int + 1) AS fail \gset
69+
\endif
70+
71+
-- ============================================================================
72+
-- Compute hash of Database A data
73+
-- ============================================================================
74+
75+
SELECT md5(
76+
COALESCE(
77+
string_agg(
78+
id::text || ':' ||
79+
COALESCE(name, 'NULL') || ':' ||
80+
COALESCE(price::text, 'NULL') || ':' ||
81+
COALESCE(quantity::text, 'NULL') || ':' ||
82+
COALESCE(metadata::text, 'NULL'),
83+
'|' ORDER BY id
84+
),
85+
''
86+
)
87+
) AS hash_a FROM items \gset
88+
89+
\echo [INFO] (:testid) Database A hash: :hash_a
90+
91+
-- ============================================================================
92+
-- Encode payload from Database A
93+
-- ============================================================================
94+
95+
SELECT encode(
96+
cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq),
97+
'hex'
98+
) AS payload_a_hex
99+
FROM cloudsync_changes
100+
WHERE site_id = cloudsync_siteid() \gset
101+
102+
-- Verify payload was created
103+
SELECT (length(:'payload_a_hex') > 0) AS payload_created \gset
104+
\if :payload_created
105+
\echo [PASS] (:testid) Payload encoded from Database A (pre-existing data)
106+
\else
107+
\echo [FAIL] (:testid) Payload encoded from Database A - Empty payload
108+
SELECT (:fail::int + 1) AS fail \gset
109+
\endif
110+
111+
-- ============================================================================
112+
-- Setup Database B with same schema (empty)
113+
-- ============================================================================
114+
115+
\connect cloudsync_test_20b
116+
\ir helper_psql_conn_setup.sql
117+
CREATE EXTENSION IF NOT EXISTS cloudsync;
118+
119+
CREATE TABLE items (
120+
id UUID PRIMARY KEY,
121+
name TEXT NOT NULL DEFAULT '',
122+
price DOUBLE PRECISION NOT NULL DEFAULT 0.0,
123+
quantity INTEGER NOT NULL DEFAULT 0,
124+
metadata JSONB
125+
);
126+
127+
-- Initialize CloudSync on empty table
128+
SELECT cloudsync_init('items', 'CLS', false) AS _init_b \gset
129+
130+
-- ============================================================================
131+
-- Apply payload to Database B
132+
-- ============================================================================
133+
134+
SELECT cloudsync_payload_apply(decode(:'payload_a_hex', 'hex')) AS apply_result \gset
135+
136+
-- Verify application succeeded
137+
SELECT (:apply_result >= 0) AS payload_applied \gset
138+
\if :payload_applied
139+
\echo [PASS] (:testid) Payload applied to Database B
140+
\else
141+
\echo [FAIL] (:testid) Payload applied to Database B - Apply returned :apply_result
142+
SELECT (:fail::int + 1) AS fail \gset
143+
\endif
144+
145+
-- ============================================================================
146+
-- Verify data integrity after roundtrip
147+
-- ============================================================================
148+
149+
SELECT md5(
150+
COALESCE(
151+
string_agg(
152+
id::text || ':' ||
153+
COALESCE(name, 'NULL') || ':' ||
154+
COALESCE(price::text, 'NULL') || ':' ||
155+
COALESCE(quantity::text, 'NULL') || ':' ||
156+
COALESCE(metadata::text, 'NULL'),
157+
'|' ORDER BY id
158+
),
159+
''
160+
)
161+
) AS hash_b FROM items \gset
162+
163+
\echo [INFO] (:testid) Database B hash: :hash_b
164+
165+
SELECT (:'hash_a' = :'hash_b') AS hashes_match \gset
166+
\if :hashes_match
167+
\echo [PASS] (:testid) Data integrity verified - hashes match
168+
\else
169+
\echo [FAIL] (:testid) Data integrity check failed - Database A hash: :hash_a, Database B hash: :hash_b
170+
SELECT (:fail::int + 1) AS fail \gset
171+
\endif
172+
173+
-- ============================================================================
174+
-- Verify row count
175+
-- ============================================================================
176+
177+
SELECT COUNT(*) AS count_b FROM items \gset
178+
\connect cloudsync_test_20a
179+
SELECT COUNT(*) AS count_a_orig FROM items \gset
180+
181+
\connect cloudsync_test_20b
182+
SELECT (:count_b = :count_a_orig) AS row_counts_match \gset
183+
\if :row_counts_match
184+
\echo [PASS] (:testid) Row counts match (:count_b rows)
185+
\else
186+
\echo [FAIL] (:testid) Row counts mismatch - Database A: :count_a_orig, Database B: :count_b
187+
SELECT (:fail::int + 1) AS fail \gset
188+
\endif
189+
190+
-- ============================================================================
191+
-- Verify specific pre-existing data was synced correctly
192+
-- ============================================================================
193+
194+
SELECT COUNT(*) = 1 AS item1_ok
195+
FROM items
196+
WHERE id = '11111111-1111-1111-1111-111111111111'
197+
AND name = 'Pre-existing Item 1'
198+
AND price = 10.99
199+
AND quantity = 100 \gset
200+
\if :item1_ok
201+
\echo [PASS] (:testid) Pre-existing item 1 synced correctly
202+
\else
203+
\echo [FAIL] (:testid) Pre-existing item 1 not found or incorrect
204+
SELECT (:fail::int + 1) AS fail \gset
205+
\endif
206+
207+
-- Verify JSONB data
208+
SELECT COUNT(*) = 1 AS jsonb_ok
209+
FROM items
210+
WHERE id = '55555555-5555-5555-5555-555555555555' AND metadata = '{"nested": {"key": "value"}}'::jsonb \gset
211+
\if :jsonb_ok
212+
\echo [PASS] (:testid) JSONB data synced correctly
213+
\else
214+
\echo [FAIL] (:testid) JSONB data not synced correctly
215+
SELECT (:fail::int + 1) AS fail \gset
216+
\endif
217+
218+
-- ============================================================================
219+
-- Test: Add new data AFTER init, verify it also syncs
220+
-- ============================================================================
221+
222+
\connect cloudsync_test_20a
223+
224+
-- Add new row after init
225+
INSERT INTO items VALUES ('66666666-6666-6666-6666-666666666666', 'Post-init Item', 66.66, 666, '{"post": true}');
226+
227+
-- Encode new changes
228+
SELECT encode(
229+
cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq),
230+
'hex'
231+
) AS payload_a2_hex
232+
FROM cloudsync_changes
233+
WHERE site_id = cloudsync_siteid() \gset
234+
235+
\connect cloudsync_test_20b
236+
SELECT cloudsync_payload_apply(decode(:'payload_a2_hex', 'hex')) AS apply_result2 \gset
237+
238+
SELECT COUNT(*) = 1 AS post_init_ok
239+
FROM items
240+
WHERE id = '66666666-6666-6666-6666-666666666666' AND name = 'Post-init Item' \gset
241+
\if :post_init_ok
242+
\echo [PASS] (:testid) Post-init data syncs correctly
243+
\else
244+
\echo [FAIL] (:testid) Post-init data failed to sync
245+
SELECT (:fail::int + 1) AS fail \gset
246+
\endif
247+
248+
-- ============================================================================
249+
-- Test bidirectional sync (B -> A)
250+
-- ============================================================================
251+
252+
INSERT INTO items VALUES ('77777777-7777-7777-7777-777777777777', 'From B', 77.77, 777, '{"from": "B"}');
253+
254+
SELECT encode(
255+
cloudsync_payload_encode(tbl, pk, col_name, col_value, col_version, db_version, site_id, cl, seq),
256+
'hex'
257+
) AS payload_b_hex
258+
FROM cloudsync_changes
259+
WHERE site_id = cloudsync_siteid() \gset
260+
261+
\connect cloudsync_test_20a
262+
SELECT cloudsync_payload_apply(decode(:'payload_b_hex', 'hex')) AS apply_b_to_a \gset
263+
264+
SELECT COUNT(*) = 1 AS bidirectional_ok
265+
FROM items
266+
WHERE id = '77777777-7777-7777-7777-777777777777' AND name = 'From B' \gset
267+
\if :bidirectional_ok
268+
\echo [PASS] (:testid) Bidirectional sync works (B to A)
269+
\else
270+
\echo [FAIL] (:testid) Bidirectional sync failed
271+
SELECT (:fail::int + 1) AS fail \gset
272+
\endif
273+
274+
-- ============================================================================
275+
-- Final verification: total row count should be 7 in both databases
276+
-- ============================================================================
277+
278+
SELECT COUNT(*) AS final_count_a FROM items \gset
279+
\connect cloudsync_test_20b
280+
SELECT COUNT(*) AS final_count_b FROM items \gset
281+
282+
SELECT (:final_count_a = 7 AND :final_count_b = 7) AS final_counts_ok \gset
283+
\if :final_counts_ok
284+
\echo [PASS] (:testid) Final row counts correct (7 rows each)
285+
\else
286+
\echo [FAIL] (:testid) Final row counts incorrect - A: :final_count_a, B: :final_count_b
287+
SELECT (:fail::int + 1) AS fail \gset
288+
\endif
289+
290+
-- ============================================================================
291+
-- Cleanup: Drop test databases if not in DEBUG mode and no failures
292+
-- ============================================================================
293+
294+
\ir helper_test_cleanup.sql
295+
\if :should_cleanup
296+
DROP DATABASE IF EXISTS cloudsync_test_20a;
297+
DROP DATABASE IF EXISTS cloudsync_test_20b;
298+
\endif

0 commit comments

Comments
 (0)