fix(compare): stop scripting a view, routine or trigger whose definition could not be read - #3069
Merged
Merged
Conversation
…currently, and gate its structure edits by kind
…ion could not be read
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #3063
Summary
Compare & Sync treated "could not read this definition" as an empty definition. A MySQL account without
SHOW VIEWlists every view and is refused eachSHOW CREATE VIEW. The comparison scored that empty text as Differs against the target's real view, suggested a replace, and the script it built wasDROP VIEWwith nothing after it. Two unreadable reads of one object scored Identical. A failed procedure/function list read as "no routines", so every routine on the target was offered as aDROP.Now a view, procedure, function or trigger whose definition cannot be used lands in Could Not Compare with the reason. Nothing is scripted for it, and a list that cannot be read stops the comparison. Copy To reads the same way and shows the same reasons.
Root cause
CompareMetadataService.viewDefinitionsdidtry? fetchViewDefinitionand then?? "".routineReadsdid the same withfetchRoutineDDL, and with(try? fetchRoutines) ?? []for the whole list.perTableTriggerReadsskipped a table whosefetchTriggersthrew, and a trigger with nodefinitionfell back tostatement, which is the action body.RoutineSourceRead.source: Stringhad no way to say "unreadable".SourceObjectDiffEnginecompared""as text, andSourceObjectSyncBuilderturned.alterintoDROPplussendableStatements(""), which is[]. The DROP ran alone.fetchRoutineDDL. DuckDB lists a macro's parsed expression ((a + b),SELECT 1 AS x) rather than a statement, so a replace ranDROP FUNCTIONand then a bareSELECT..createwith an empty body, and the re-read before the script compared[""]with[""]and passed it.What changed
RoutineSourceRead.failure, likeTableStructureRead.failure. The view, routine and trigger reads move into nonisolated statics inCompareMetadataService+SourceDefinitions.swift(readViewDefinitions,readRoutineDefinitions,readTriggerDefinitions), which are the test seam and are shared by Compare and Copy To.fetchViewDefinition, and a routine always asksfetchRoutineDDL. A trigger uses its listeddefinitionwhen it has content, otherwisefetchTriggerDDL, and neverstatement.failure, with the driver's message, and is logged. ACancellationError, or any error once the task is cancelled, propagates instead.fetchRoutines, or a failed per-tablefetchTriggers(after the existing whole-schema fallback), throwsCompareSyncError.readFailednaming the connection and the table.SourceDefinitionDefectis the one rule, used by the diff engine, the builder backstop and Copy To. The diff engine checks each side in its own engine's grammar, as the design said. The builder and Copy To check in the target's grammar, which is what they send. A structure script is only generated within one engine family (the same type, or MySQL and MariaDB,CompareSyncEngineFamily), and within a family the two grammars agree. It reports.unreadable(reason)for a failed read,.emptywhen the text has no sendable statement (blank or comment only), and.notACreateStatementwhen the first sendable statement's first code word, past comments, is notCREATE.SQLScriptText.leadingKeyword(of:)reads that word with the engine's own comment rules, so a SQL Server module with a header comment above itsCREATEstill counts as a statement.SourceObjectDiffEnginebuilds one result per object, matched or not. A defect on either side setscomparisonError, naming the side ("The source's definition could not be read: …"), and the text is never compared.CompareObjectResultthen gives.skip/[.skip], andCompareReportalready keeps such rows out ofcomparable, the counts, Select All and the script. The readable side's definition still shows in the detail pane. The name-only note is gone, along with its catalog key.SourceObjectSyncBuilder.buildnow throws..createand.alterrefuse a definition that fails the same rule. They log a fault and throwunsupportedOperationnaming the object, so a miss upstream is visible rather than a silent[]..dropneeds no definition.StructureChangeGuard.refusal(expected:actual:unreadable:): an object selected at compare time that is uncomparable on the re-read is refused with "could not be read again" and its reason, rather than "changed after it was compared".CompareRunnerreads definitions only for the ticked view kinds, filters every source-defined result by the ticked kinds (an unticked kind's unreadable object would otherwise appear under Could Not Compare), and passes the re-read's uncomparable reasons to the guard.ObjectCopyPlanner.sourceDefinitionsgoes through the same statics inside onewithMetadataDrivercall.definitionOutcomemaps the shared rule to a skip. An unreadable read keeps the driver's reason, an empty one gets "The source reports no definition for it.", and a body gets the existing not-executable reason. A list that throws mid-plan does not fail the plan: only the selected procedures and functions, or the selected triggers, are skipped, each carrying the list's error, and the tables and views still copy. A cancelled list cancels the plan.ObjectCopyEligibility.isExecutableDefinition(a barehasPrefix("CREATE")) is replaced by the shared rule.docs/features/compare-sync.mdx,docs/features/copy-objects.mdx. The copy-objects page no longer lists Oracle among the drivers that answer a view with itsSELECT:OraclePlugin.fetchViewDefinitionhas prependedCREATE OR REPLACE VIEWfor a while.Measured
lanes/compare_unreadable/measure-mysql84.txt):cu_bare(SELECT only):information_schema.TABLESlistsvas VIEW, andSHOW CREATE VIEWfails withERROR 1142 (42000): SHOW VIEW command denied to user 'cu_bare'@'localhost' for table 'v'.ROUTINESandTRIGGERSreturn 0 rows with no error.cu_select(SELECT, EXECUTE): the same 1142 on the view. Routines: 2 listed, butSHOW CREATE PROCEDUREreturnsCreate Procedure: NULL, whichMySQLPluginDriver.fetchRoutineDDLturns intoinsufficientPrivilege. Triggers: 0 listed.cu_showview(SELECT, EXECUTE, SHOW VIEW, TRIGGER): the view definition is returned, 1 trigger is listed, and the procedure body is still NULL.measure-duckdb.txt):duckdb_functions().macro_definitionis(a + b)for a scalar macro andSELECT 1 AS xfor a table macro, which is whatDuckDBPluginDriver.fetchRoutineslists asdefinition.pg_get_viewdef,pg_get_functiondefandpg_get_triggerdefreturn full text even without schemaUSAGE, so on PG only an error such as an object dropped mid-read reaches this path.definition(only DuckDB) or overridesfetchRoutineDDLwas read. EachfetchRoutineDDLreturns a listed definition that is already a statement, or fetches the DDL by name, so always asking it costs no extra round trip anywhere except DuckDB, which is in-process.Tests
verify.sh teston 406f80c, 18 suites, 230 executed, 230 passed: SourceObjectDiffEngineTests 22, SourceObjectHazardTests 2, CompareSourceDefinitionReadTests 10 (new), SourceObjectSyncBuilderTests 17, StructureChangeGuardTests 23, CompareReportUnreadableTests 2, CompareMetadataReadPlanTests 13, CompareResultGroupingTests 11, CompareRunClaimTests 17, StructureDeclaredTypeCompareTests 11, ObjectCopyEligibilityTests 18, ObjectCopyPlannerOrderingTests 21, ObjectCopyPlannerSourceDefinitionTests 8 (new), ObjectCopySelectQueryTests 11, ObjectCopySequenceStatementTests 5, SQLScriptTextTests 23, SchemaSyncScriptBuilderTests 14, SQLExportScriptTextTests 2.Red without the fix: I mutated each fix point back to the old behaviour, ran again and got 24 failures, every one of them a new or rewritten test:
failure: nil(the old swallow) turns redtestAViewTheDriverRefusesCarriesTheDriversReason,testARoutineWhoseDDLCannotBeReadCarriesTheReason,testATriggerWithoutADefinition…and the three Copy To reason tests.(try? fetchRoutines) ?? []and(try? fetchTriggers) ?? []turn redtestARoutineListingThatFailsStopsTheRead,testAPerTableTriggerListingThatFailsStopsTheRead,testAFailedRoutineListingSkipsTheRoutinesAndKeepsTheRest,testAFailedTriggerListingSkipsTheTriggersandtestACancelledListingCancelsThePlan.fetchRoutineDDLturns redtestARoutineIsReadThroughItsDDLEvenWhenTheListingCarriesABody, and the fallback totrigger.statementturns redtestATriggerWithoutADefinitionIsReadThroughItsDDLAndNeverItsStatement.comparisonErrorturns red the seven unreadable cases in SourceObjectDiffEngineTests, including the one throughCompareReport(0 differences, nothing comparable).unreadableturns redtestAnObjectThatCouldNotBeReadAgainIsRefusedWithItsReason.leadingKeywordthat does not skip comments turns red both SQLScriptText tests andtestACreateStatementIsRunnableBehindALeadingComment.Follow-up c5e000c (review finding 1) adds
testEachSideIsReadInItsOwnEnginesGrammarandtestASQLServerModuleStoredAfterAlterOrCreateOrAlterIsReadable. The first is red with the source checked in the target's grammar (1 of 24 failed). Rerun on c5e000c: SourceObjectDiffEngineTests, SourceObjectHazardTests, SourceObjectSyncBuilderTests, StructureChangeGuardTests, CompareReportUnreadableTests and CompareSourceDefinitionReadTests, 78 executed, 78 passed.testAnObjectWithNoDefinitionCarriesANotepinned the defect and is replaced bytestAnUnreadableObjectOnlyOnTheSourceIsNeverCreated(comparisonError,[.skip]). Existing SourceObjectDiffEngineTests fixtures that used bare bodies (BEGIN END,SELECT 1) as definitions now useCREATE FUNCTION …, because a bare body is now correctly unreadable. Their assertions are unchanged.Build: PASS. Lint:
verify.sh linton all 19 changed Swift files. Its one finding issingle_test_classonSourceObjectDiffEngineTests.swift, which has held two test classes since before this branch. Docs:verify.sh docsPASS. No PluginKit or plugin change, so no ABI check or plugin build applies.Before / After
Screenshots to be added. States to capture, from a MySQL source signed in as an account with
SELECTonly on a schema holding viewv, compared against a target whosevdiffers:vshows an empty source against the target'sCREATE VIEW. The row reads Different and is included by Select All, and Script showsDROP VIEWwith noCREATEafter it.vsits under Could Not Compare with "The source's definition could not be read: SHOW VIEW command denied…", its Include checkbox is dimmed, and Select All leaves it out.Review findings
CompareSyncEngineFamily.swift:6-9), so a MySQL view whose definition opens with a#comment, compared against a PostgreSQL target, was misread as not aCREATEand filed under Could Not Compare. The diff engine now checks the source withsourceScriptText. The builder backstop and Copy To keep the target's grammar, because that is what they send, and scripts are only generated within one engine family.ALTERreads back starting withALTER" (dismissed, no driver change). SQL Server stores the wordALTERasCREATE: afterALTER PROCEDURE Test1A AS SELECT 3;,OBJECT_DEFINITIONreturns/* comment */ CREATE PROCEDURE Test1A AS SELECT 3;.CREATE OR ALTERis stored withCREATEin its original case andOR ALTERremoved (CrEaTe /*Y*/ PROCEDURE Test1B). Both come from Solomon Rutzky's tested write-up, which covers stored procedures, functions, views and triggers: Stored Procedure / Function / View / Trigger Definitions Can Be Wrong, Even If sp_rename Was Never Used. Microsoft's sys.sql_modules describesdefinitiononly as "SQL text that defines this module" (the same textOBJECT_DEFINITIONreturns), and says nothing on this point.MSSQLPluginDriver.fetchTriggerDefinitionalready assumes the stored text saysCREATE TRIGGER. SQL Server could not be measured here. The leading comment in that stored text is why the rule skips comments, and both stored shapes are pinned bytestASQLServerModuleStoredAfterAlterOrCreateOrAlterIsReadable.Critique points not taken
SourceObjectSyncBuilderbackstop "returns []": it throws instead (and logs a fault), per the critique.Deliberately not fixed here
information_schema.ROUTINESandTRIGGERSreturn 0 rows with no error for an account withoutEXECUTEorTRIGGER(measured above), soMySQLPluginDriver.fetchRoutines(Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift:10) andfetchAllTriggers(:66) answer "none". The other side's routines and triggers still show as only on that side, which meansDROPsuggestions on the target. The docs now say so. It needs a driver-side privilege check.DuckDBPluginDriver.fetchRoutineDDL(Plugins/DuckDBDriverPlugin/DuckDBPluginDriver+Routines.swift:63) writesCREATE OR REPLACE MACRO main.t1() AS SELECT 1 AS x;, which DuckDB 1.5.4 refuses withParser Error: syntax error at or near "SELECT". It needsAS TABLEfor atable_macro. DuckDB hassupportsTransactionalDDL == false, so a Compare replace of a differing table macro runsDROP FUNCTIONand then that failing CREATE. Measured: 0 macros left afterwards. The run now reports the failure where it used to report success, but the macro is still gone. This is a registry plugin fix.ObjectCopyCatalog(TablePro/Core/ObjectCopy/ObjectCopyCatalog.swift:86,:96) uses(try? fetchRoutines) ?? []and(try? fetchAllTriggers) ?? [], and:96asksfetchAllTriggerseven whereprovidesBulkTriggerFetchis false (Oracle, Dameng), so those targets never report an existing trigger. Replace then attempts a CREATE over an existing object and fails. Nothing is lost, but the reason is wrong.Sequence(CompareOptionsView.swift:67iteratesCompareObjectKind.allCases), butCompareRunner.sourceDefinedResultshas no read path for it, so ticking it compares nothing.