55 python -m pytest tests/test_read_only_export.py
66The fixture creates and removes its own database and SELECT-only user.
77"""
8+ import json
89import os
910import sqlite3
1011from uuid import uuid4
@@ -43,6 +44,9 @@ def seeded_catalog(request, tmp_path):
4344 with source_engine .connect () as connection :
4445 dataset = connection .execute (select (logical .dataset )).mappings ().one ()
4546 if request .param == "sqlite" :
47+ with sqlite3 .connect (path ) as connection :
48+ connection .execute ("PRAGMA journal_mode = WAL" )
49+
4650 def snapshot ():
4751 with sqlite3 .connect (path ) as connection :
4852 return tuple (connection .iterdump ())
@@ -119,7 +123,8 @@ def read_catalog(seeded_catalog):
119123 forbidden = []
120124
121125 def check (_connection , _cursor , statement , _parameters , _context , _many ):
122- if statement .lstrip ().split ()[0 ].upper () not in {"SELECT" , "SHOW" , "PRAGMA" , "DESCRIBE" }:
126+ normalized = " " .join (statement .split ()).upper ()
127+ if normalized != "BEGIN" and normalized .split ()[0 ] not in {"SELECT" , "SHOW" , "PRAGMA" , "DESCRIBE" }:
123128 forbidden .append (statement )
124129 raise AssertionError ("Read operation attempted a non-read SQL statement" )
125130
@@ -151,7 +156,7 @@ def test_export_and_reads_leave_no_database_trace(read_catalog, tmp_path, suffix
151156 assert not list (tmp_path .glob (".*.staging.*" ))
152157
153158
154- @pytest .mark .parametrize ("failure" , ["loss" , "writer" , "publish" , "restore" , "staging" , "backup" ])
159+ @pytest .mark .parametrize ("failure" , ["read" , " loss" , "writer" , "publish" , "restore" , "staging" , "backup" ])
155160def test_failed_export_never_writes_database (read_catalog , tmp_path , monkeypatch , failure ):
156161 url , snapshot , _source = read_catalog
157162 before = snapshot ()
@@ -161,7 +166,16 @@ def test_failed_export_never_writes_database(read_catalog, tmp_path, monkeypatch
161166 def fail (* _args , ** _kwargs ):
162167 raise OSError ("injected failure" )
163168
164- if failure == "loss" :
169+ readers = []
170+
171+ def fail_read (connection , _cursor , statement , _parameters , _context , _many ):
172+ if "FROM variable " in " " .join (statement .split ()):
173+ readers .append (connection )
174+ raise OSError ("injected read failure" )
175+
176+ if failure == "read" :
177+ event .listen (Engine , "after_cursor_execute" , fail_read )
178+ elif failure == "loss" :
165179 monkeypatch .setattr (sav , "_export_loss_report" , lambda * _a , ** _k : (
166180 {"code" : "test-loss" , "detail" : "Requires consent" , "details" : {}},
167181 ))
@@ -188,8 +202,14 @@ def fail_backup(self, *args, **kwargs):
188202 return unlink (self , * args , ** kwargs )
189203
190204 monkeypatch .setattr (type (output ), "unlink" , fail_backup )
191- with pytest .raises ((OSError , UnsupportedOperationError )):
192- openstatspec .export_sav (database_url = url , dataset_id = "sample" , destination = output )
205+ try :
206+ with pytest .raises ((OSError , UnsupportedOperationError ), match = "injected read failure" if failure == "read" else None ):
207+ openstatspec .export_sav (database_url = url , dataset_id = "sample" , destination = output )
208+ finally :
209+ if failure == "read" :
210+ event .remove (Engine , "after_cursor_execute" , fail_read )
211+ if failure == "read" :
212+ assert readers and all (connection .closed for connection in readers )
193213 assert snapshot () == before
194214 if failure in {"restore" , "backup" }:
195215 # If filesystem recovery itself fails, preserve the previous bytes in
@@ -205,6 +225,117 @@ def fail_backup(self, *args, **kwargs):
205225 assert snapshot () == before
206226
207227
228+ @pytest .mark .parametrize ("seeded_catalog" , ["sqlite" ], indirect = True )
229+ @pytest .mark .parametrize ("operation" , ["descriptor" , "export" ])
230+ def test_reads_use_one_snapshot (read_catalog , tmp_path , monkeypatch , operation ):
231+ url , snapshot , source = read_catalog
232+ path = make_url (url ).database
233+ readers = []
234+ catalog_transactions = []
235+ committed = False
236+
237+ def observe_catalog (connection , _cursor , statement , _parameters , _context , _many ):
238+ if "catalog_identity" in statement and connection not in readers :
239+ readers .append (connection )
240+ catalog_transactions .append (connection .connection .driver_connection .in_transaction )
241+
242+ def interleave (connection , _cursor , statement , _parameters , _context , _many ):
243+ nonlocal committed
244+ sql = " " .join (statement .split ())
245+ if committed or "FROM variable " not in sql or "ORDER BY variable.source_ordinal" not in sql :
246+ return
247+ assert not connection .closed
248+ # Raw connection deliberately bypasses SQLAlchemy's query-only listener.
249+ with sqlite3 .connect (path , timeout = 1 ) as writer :
250+ table , dataset_id , operation_id = writer .execute (
251+ "SELECT physical_table_name, dataset_id, (SELECT operation_id FROM operation) FROM dataset"
252+ ).fetchone ()
253+ writer .execute (f'UPDATE "{ table } " SET answer = 3 WHERE __case_ordinal = 1' )
254+ writer .execute ("UPDATE variable SET variable_label = 'New answer' WHERE source_name = 'answer'" )
255+ writer .execute ("UPDATE value_label SET label = 'New code' WHERE numeric_code = 1" )
256+ writer .execute ("UPDATE dataset SET dataset_label = 'New dataset'" )
257+ writer .execute (
258+ "INSERT INTO fidelity_event VALUES (?, ?, ?, 'import', 'warning', 'snapshot-loss', NULL, '{}', CURRENT_TIMESTAMP)" ,
259+ (str (uuid4 ()), operation_id , dataset_id ),
260+ )
261+ committed = True
262+ assert not connection .closed # writer committed without waiting for reader release
263+
264+ real_writer = sav ._write_with_dictionary_bridge
265+
266+ def write_after_read (* args , ** kwargs ):
267+ assert readers and all (connection .closed for connection in readers )
268+ return real_writer (* args , ** kwargs )
269+
270+ monkeypatch .setattr (sav , "_write_with_dictionary_bridge" , write_after_read )
271+ event .listen (Engine , "before_cursor_execute" , observe_catalog )
272+ event .listen (Engine , "after_cursor_execute" , interleave )
273+ try :
274+ if operation == "descriptor" :
275+ dataset , variables , rows = wide .read_wide_dataset (database_url = url , dataset_id = "sample" )
276+ assert rows [0 ]["answer" ] == 1
277+ assert variables [0 ]["label" ] == "Answer"
278+ assert json .loads (variables [0 ]["value_labels" ])["1.0" ] == "Yes"
279+ assert dataset ["file_label" ] == ""
280+ else :
281+ output = tmp_path / "snapshot.sav"
282+ result = openstatspec .export_sav (database_url = url , dataset_id = "sample" , destination = output )
283+ assert not result .diagnostics
284+ expected , expected_meta = pyspssio .read_sav (str (source ), include_user_missing = True )
285+ actual , actual_meta = pyspssio .read_sav (str (output ), include_user_missing = True )
286+ pd .testing .assert_frame_equal (actual , expected )
287+ for key in ("var_labels" , "var_value_labels" , "file_label" ):
288+ assert actual_meta [key ] == expected_meta [key ]
289+ finally :
290+ event .remove (Engine , "after_cursor_execute" , interleave )
291+ event .remove (Engine , "before_cursor_execute" , observe_catalog )
292+ assert committed
293+ assert catalog_transactions and all (catalog_transactions ), "first catalog read needs native BEGIN"
294+ assert all (connection .closed for connection in readers )
295+ after_writer = snapshot ()
296+ dataset , variables , rows = wide .read_wide_dataset (database_url = url , dataset_id = "sample" )
297+ assert rows [0 ]["answer" ] == 3
298+ assert variables [0 ]["label" ] == "New answer"
299+ assert json .loads (variables [0 ]["value_labels" ])["1.0" ] == "New code"
300+ assert dataset ["file_label" ] == "New dataset"
301+ if operation == "export" :
302+ with pytest .raises (UnsupportedOperationError , match = "snapshot-loss" ):
303+ openstatspec .export_sav (database_url = url , dataset_id = "sample" , destination = output )
304+ result = openstatspec .export_sav (
305+ database_url = url , dataset_id = "sample" , destination = output , allow_loss = ["snapshot-loss" ],
306+ )
307+ assert {d .code for d in result .diagnostics } == {"snapshot-loss" }
308+ assert snapshot () == after_writer
309+
310+
311+ @pytest .mark .parametrize ("seeded_catalog" , ["sqlite" ], indirect = True )
312+ def test_validation_reflects_the_same_schema_snapshot (read_catalog ):
313+ url , snapshot , _source = read_catalog
314+ with sqlite3 .connect (make_url (url ).database ) as connection :
315+ table = connection .execute ("SELECT physical_table_name FROM dataset" ).fetchone ()[0 ]
316+ readers = []
317+
318+ def rename_after_values (connection , _cursor , statement , _parameters , _context , _many ):
319+ if readers or f"FROM { table } " not in " " .join (statement .split ()):
320+ return
321+ readers .append (connection )
322+ with sqlite3 .connect (make_url (url ).database , timeout = 1 ) as writer :
323+ writer .execute ("BEGIN" )
324+ writer .execute (f'ALTER TABLE "{ table } " RENAME COLUMN answer TO renamed_answer' )
325+ writer .execute ("UPDATE variable SET physical_name = 'renamed_answer' WHERE source_name = 'answer'" )
326+ assert not connection .closed
327+
328+ event .listen (Engine , "after_cursor_execute" , rename_after_values )
329+ try :
330+ assert openstatspec .validate (database_url = url , dataset_id = "sample" )["valid" ]
331+ finally :
332+ event .remove (Engine , "after_cursor_execute" , rename_after_values )
333+ assert readers and all (connection .closed for connection in readers )
334+ after_writer = snapshot ()
335+ assert openstatspec .validate (database_url = url , dataset_id = "sample" )["valid" ]
336+ assert snapshot () == after_writer
337+
338+
208339def test_dolt_reads_do_not_enable_undeclared_writes (monkeypatch ):
209340 url = "mysql+pymysql://reader@host/dataset"
210341 active = {
0 commit comments