diff --git a/src/viano_bug_analysis/analysis/entity_resolution.py b/src/viano_bug_analysis/analysis/entity_resolution.py index 34875a1..04f96e7 100644 --- a/src/viano_bug_analysis/analysis/entity_resolution.py +++ b/src/viano_bug_analysis/analysis/entity_resolution.py @@ -436,8 +436,16 @@ class EntityResolver: captures=[] for s,ref in zip(plan['searches'],search['capture_refs'],strict=True): c=read_json(reader,ref,'project-capture',self.owner.limits.max_context_bytes) - if (ref != self.base(context)+'/captures/'+hashlib.sha256(reader.read_bytes(ref)).hexdigest()+'.json' or c['origin'] != initial['binding'] - or c['query'] != query_for(s,initial['policy'])): + query=query_for(s,initial['policy']) + # Reconstruct the query-specific origin from the frozen binding, so + # completed cases remain verifiable without a live database provider. + binding=copy.deepcopy(initial['binding']) + if binding.get('kind') == 'mariadb' and (binding.get('additional_databases') or '.' in query['table']): + parts=query['table'].split('.') + database,table=(binding['database'],parts[0]) if len(parts)==1 else parts + binding.update(selected_database=database,selected_table=table) + if (ref != self.base(context)+'/captures/'+hashlib.sha256(reader.read_bytes(ref)).hexdigest()+'.json' or c['origin'] != binding + or c['query'] != query): raise InterpretationError('ENTITY_RESOLUTION_CAPTURE_CHANGED') captures.append(c) if search['candidates'] != candidates_for(plan,captures,initial['policy']): diff --git a/tests/test_entity_resolution.py b/tests/test_entity_resolution.py index 40a1050..7a6689b 100644 --- a/tests/test_entity_resolution.py +++ b/tests/test_entity_resolution.py @@ -126,6 +126,39 @@ def context_of(store): return build_context(json.loads(reader.read_bytes(ref)),ref,InterpretationLimits()) +@pytest.mark.parametrize('qualified', [False, True]) +@pytest.mark.parametrize('wrong_origin', [False, True]) +def test_multidatabase_capture_origin_and_portable_replay(built_case, tmp_path, qualified, wrong_origin): + class MultiDatabase(Database): + def binding(self): + return {'kind':'mariadb', 'database':'world', 'snapshot_id':self.snapshot, + 'tables':['components'], 'additional_databases':{'characters':['components']}} + + def binding_for(self, query): + database, table = query['table'].split('.') if '.' in query['table'] else ('world', query['table']) + return {**self.binding(), 'selected_database':'wrong' if wrong_origin else database, + 'selected_table':table} + + def validate(self, query): + assert query['table'] in {'components','characters.components'} + + store=built_case[0] + model=Model(); service=service_for(store,MultiDatabase()) + if qualified: + service.entity_resolution['catalogs'][0]['table']='characters.components' + if wrong_origin: + with pytest.raises(InterpretationError, match='ENTITY_RESOLUTION_CAPTURE_CHANGED'): + run(store,model,service) + return + result=run(store,model,service) + assert result.status=='COMPLETED' + assert PackageValidator().validate(result.package).valid + receiving=CheckpointStore(type(store.workspace)(tmp_path/'portable-multidb')) + receiving.intake(result.package) + again=run(receiving,model) + assert again.reused and len(model.requests)==3 + + def test_resolves_unrecorded_subject_preserves_case_and_portable_replay(built_case, tmp_path): store, _, built, _, _ = built_case with PackageReader(built.package) as reader: