From a2a534be6a39bf2d842a2e6f80d628f12e62860a Mon Sep 17 00:00:00 2001 From: Christian Hofstaedtler Date: Mon, 4 Jul 2016 13:49:40 +0200 Subject: Fix bad whatis indexing of man pages By removing the private markers, the generated man pages get their correct format, so whatis indexing works. Forwarded: not-needed Gbp-Pq: Name fix-bad-whatis-man.patch --- lib/sqitchcommands.pod | 7 ------- lib/sqitchguides.pod | 7 ------- lib/sqitchusage.pod | 7 ------- 3 files changed, 21 deletions(-) diff --git a/lib/sqitchcommands.pod b/lib/sqitchcommands.pod index 9fc1bb48..4b19d8b1 100644 --- a/lib/sqitchcommands.pod +++ b/lib/sqitchcommands.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchcommands - List of common sqitch commands -=end private - =head1 Usage sqitch [--etc-path | --help | --man | --version] diff --git a/lib/sqitchguides.pod b/lib/sqitchguides.pod index 8b195388..1d9b87a4 100644 --- a/lib/sqitchguides.pod +++ b/lib/sqitchguides.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchguides - List of common Sqitch guides -=end private - The common Sqitch guides are: changes Specifying changes for Sqitch diff --git a/lib/sqitchusage.pod b/lib/sqitchusage.pod index 4955f113..4655904c 100644 --- a/lib/sqitchusage.pod +++ b/lib/sqitchusage.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchusage - Sqitch usage statement -=end private - =head1 Usage sqitch [options] [command-options] [args] -- cgit v1.2.3 From 2d95027a3c426e133c6612ae1016d408483ec7ef Mon Sep 17 00:00:00 2001 From: gregor herrmann Date: Sun, 23 Feb 2020 11:56:51 +0100 Subject: Import sqitch_1.0.0000.orig.tar.gz [dgit import orig sqitch_1.0.0000.orig.tar.gz] --- Build.PL | 154 ++ Changes | 1979 +++++++++++++++ LICENSE | 32 + LICENSE.md | 21 + MANIFEST | 281 +++ META.json | 305 +++ META.yml | 151 ++ README | 13 + README.md | 160 ++ bin/sqitch | 15 + dist/cpanfile | 164 ++ dist/sqitch.spec | 546 ++++ etc/templates/deploy/exasol.tmpl | 11 + etc/templates/deploy/firebird.tmpl | 11 + etc/templates/deploy/mysql.tmpl | 13 + etc/templates/deploy/oracle.tmpl | 9 + etc/templates/deploy/pg.tmpl | 13 + etc/templates/deploy/snowflake.tmpl | 11 + etc/templates/deploy/sqlite.tmpl | 13 + etc/templates/deploy/vertica.tmpl | 9 + etc/templates/revert/exasol.tmpl | 5 + etc/templates/revert/firebird.tmpl | 5 + etc/templates/revert/mysql.tmpl | 7 + etc/templates/revert/oracle.tmpl | 3 + etc/templates/revert/pg.tmpl | 7 + etc/templates/revert/snowflake.tmpl | 5 + etc/templates/revert/sqlite.tmpl | 7 + etc/templates/revert/vertica.tmpl | 3 + etc/templates/verify/exasol.tmpl | 5 + etc/templates/verify/firebird.tmpl | 5 + etc/templates/verify/mysql.tmpl | 7 + etc/templates/verify/oracle.tmpl | 3 + etc/templates/verify/pg.tmpl | 7 + etc/templates/verify/snowflake.tmpl | 5 + etc/templates/verify/sqlite.tmpl | 7 + etc/templates/verify/vertica.tmpl | 3 + etc/tools/upgrade-registry-to-mysql-5.5.0.sql | 43 + etc/tools/upgrade-registry-to-mysql-5.6.4.sql | 15 + inc/Menlo/Sqitch.pm | 171 ++ inc/Module/Build/Sqitch.pm | 324 +++ lib/App/Sqitch.pm | 927 +++++++ lib/App/Sqitch/Command.pm | 774 ++++++ lib/App/Sqitch/Command/add.pm | 565 +++++ lib/App/Sqitch/Command/bundle.pm | 398 +++ lib/App/Sqitch/Command/checkout.pm | 211 ++ lib/App/Sqitch/Command/config.pm | 666 +++++ lib/App/Sqitch/Command/deploy.pm | 230 ++ lib/App/Sqitch/Command/engine.pm | 457 ++++ lib/App/Sqitch/Command/help.pm | 142 ++ lib/App/Sqitch/Command/init.pm | 292 +++ lib/App/Sqitch/Command/log.pm | 373 +++ lib/App/Sqitch/Command/plan.pm | 354 +++ lib/App/Sqitch/Command/rebase.pm | 183 ++ lib/App/Sqitch/Command/revert.pm | 234 ++ lib/App/Sqitch/Command/rework.pm | 333 +++ lib/App/Sqitch/Command/show.pm | 203 ++ lib/App/Sqitch/Command/status.pm | 432 ++++ lib/App/Sqitch/Command/tag.pm | 206 ++ lib/App/Sqitch/Command/target.pm | 337 +++ lib/App/Sqitch/Command/upgrade.pm | 148 ++ lib/App/Sqitch/Command/verify.pm | 205 ++ lib/App/Sqitch/Config.pm | 231 ++ lib/App/Sqitch/DateTime.pm | 214 ++ lib/App/Sqitch/Engine.pm | 2463 ++++++++++++++++++ lib/App/Sqitch/Engine/Upgrade/exasol-1.0.sql | 21 + lib/App/Sqitch/Engine/Upgrade/exasol-1.1.sql | 3 + lib/App/Sqitch/Engine/Upgrade/firebird-1.0.sql | 45 + lib/App/Sqitch/Engine/Upgrade/firebird-1.1.sql | 49 + lib/App/Sqitch/Engine/Upgrade/mysql-1.0.sql | 20 + lib/App/Sqitch/Engine/Upgrade/mysql-1.1.sql | 2 + lib/App/Sqitch/Engine/Upgrade/oracle-1.0.sql | 44 + lib/App/Sqitch/Engine/Upgrade/oracle-1.1.sql | 31 + lib/App/Sqitch/Engine/Upgrade/pg-1.0.sql | 30 + lib/App/Sqitch/Engine/Upgrade/pg-1.1.sql | 7 + lib/App/Sqitch/Engine/Upgrade/snowflake-1.0.sql | 22 + lib/App/Sqitch/Engine/Upgrade/snowflake-1.1.sql | 3 + lib/App/Sqitch/Engine/Upgrade/sqlite-1.0.sql | 62 + lib/App/Sqitch/Engine/Upgrade/sqlite-1.1.sql | 27 + lib/App/Sqitch/Engine/Upgrade/vertica-1.0.sql | 15 + lib/App/Sqitch/Engine/Upgrade/vertica-1.1.sql | 3 + lib/App/Sqitch/Engine/exasol.pm | 587 +++++ lib/App/Sqitch/Engine/exasol.sql | 142 ++ lib/App/Sqitch/Engine/firebird.pm | 998 ++++++++ lib/App/Sqitch/Engine/firebird.sql | 166 ++ lib/App/Sqitch/Engine/mysql.pm | 551 ++++ lib/App/Sqitch/Engine/mysql.sql | 192 ++ lib/App/Sqitch/Engine/oracle.pm | 832 ++++++ lib/App/Sqitch/Engine/oracle.sql | 142 ++ lib/App/Sqitch/Engine/pg.pm | 505 ++++ lib/App/Sqitch/Engine/pg.sql | 145 ++ lib/App/Sqitch/Engine/snowflake.pm | 724 ++++++ lib/App/Sqitch/Engine/snowflake.sql | 142 ++ lib/App/Sqitch/Engine/sqlite.pm | 305 +++ lib/App/Sqitch/Engine/sqlite.sql | 80 + lib/App/Sqitch/Engine/vertica.pm | 585 +++++ lib/App/Sqitch/Engine/vertica.sql | 85 + lib/App/Sqitch/ItemFormatter.pm | 607 +++++ lib/App/Sqitch/Plan.pm | 1620 ++++++++++++ lib/App/Sqitch/Plan/Blank.pm | 62 + lib/App/Sqitch/Plan/Change.pm | 670 +++++ lib/App/Sqitch/Plan/ChangeList.pm | 433 ++++ lib/App/Sqitch/Plan/Depend.pm | 389 +++ lib/App/Sqitch/Plan/Line.pm | 370 +++ lib/App/Sqitch/Plan/LineList.pm | 133 + lib/App/Sqitch/Plan/Pragma.pm | 125 + lib/App/Sqitch/Plan/Tag.pm | 181 ++ lib/App/Sqitch/Role/ConnectingCommand.pm | 143 ++ lib/App/Sqitch/Role/ContextCommand.pm | 145 ++ lib/App/Sqitch/Role/DBIEngine.pm | 1132 +++++++++ lib/App/Sqitch/Role/RevertDeployCommand.pm | 272 ++ lib/App/Sqitch/Role/TargetConfigCommand.pm | 549 ++++ lib/App/Sqitch/Target.pm | 865 +++++++ lib/App/Sqitch/Types.pm | 191 ++ lib/App/Sqitch/X.pm | 199 ++ lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo | Bin 0 -> 32584 bytes lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo | Bin 0 -> 13122 bytes lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo | Bin 0 -> 30312 bytes lib/sqitch-add-usage.pod | 25 + lib/sqitch-add.pod | 435 ++++ lib/sqitch-authentication.pod | 391 +++ lib/sqitch-bundle-usage.pod | 14 + lib/sqitch-bundle.pod | 133 + lib/sqitch-checkout-usage.pod | 26 + lib/sqitch-checkout.pod | 278 ++ lib/sqitch-config-usage.pod | 37 + lib/sqitch-config.pod | 645 +++++ lib/sqitch-configuration.pod | 1041 ++++++++ lib/sqitch-deploy-usage.pod | 24 + lib/sqitch-deploy.pod | 223 ++ lib/sqitch-engine-usage.pod | 25 + lib/sqitch-engine.pod | 267 ++ lib/sqitch-environment.pod | 343 +++ lib/sqitch-help-usage.pod | 12 + lib/sqitch-help.pod | 35 + lib/sqitch-init-usage.pod | 21 + lib/sqitch-init.pod | 256 ++ lib/sqitch-log-usage.pod | 40 + lib/sqitch-log.pod | 502 ++++ lib/sqitch-passwords.pod | 13 + lib/sqitch-plan-usage.pod | 32 + lib/sqitch-plan.pod | 397 +++ lib/sqitch-rebase-usage.pod | 28 + lib/sqitch-rebase.pod | 305 +++ lib/sqitch-revert-usage.pod | 22 + lib/sqitch-revert.pod | 208 ++ lib/sqitch-rework-usage.pod | 19 + lib/sqitch-rework.pod | 184 ++ lib/sqitch-show-usage.pod | 15 + lib/sqitch-show.pod | 96 + lib/sqitch-status-usage.pod | 22 + lib/sqitch-status.pod | 189 ++ lib/sqitch-tag-usage.pod | 15 + lib/sqitch-tag.pod | 152 ++ lib/sqitch-target-usage.pod | 25 + lib/sqitch-target.pod | 260 ++ lib/sqitch-upgrade-usage.pod | 17 + lib/sqitch-upgrade.pod | 98 + lib/sqitch-verify-usage.pod | 21 + lib/sqitch-verify.pod | 215 ++ lib/sqitch.pod | 490 ++++ lib/sqitchchanges.pod | 197 ++ lib/sqitchcommands.pod | 44 + lib/sqitchguides.pod | 28 + lib/sqitchtutorial-exasol.pod | 1407 +++++++++++ lib/sqitchtutorial-firebird.pod | 1264 ++++++++++ lib/sqitchtutorial-mysql.pod | 1724 +++++++++++++ lib/sqitchtutorial-oracle.pod | 1870 ++++++++++++++ lib/sqitchtutorial-snowflake.pod | 1419 +++++++++++ lib/sqitchtutorial-sqlite.pod | 1240 +++++++++ lib/sqitchtutorial-vertica.pod | 1390 ++++++++++ lib/sqitchtutorial.pod | 1686 +++++++++++++ lib/sqitchusage.pod | 25 + t/add.t | 1010 ++++++++ t/add_change.conf | 10 + t/base.t | 675 +++++ t/blank.t | 135 + t/bundle.t | 565 +++++ t/change.t | 438 ++++ t/changelist.t | 366 +++ t/checkout.t | 660 +++++ t/command.t | 725 ++++++ t/config.t | 1122 ++++++++ t/configuration.t | 90 + t/conn_cmd_role.t | 112 + t/core.conf | 2 + t/core_target.conf | 2 + t/cx_cmd_role.t | 109 + t/datetime.t | 95 + t/depend.t | 224 ++ t/deploy.t | 327 +++ t/die.pl | 5 + t/echo.pl | 3 + t/editor.conf | 3 + t/engine.conf | 20 + t/engine.t | 3089 +++++++++++++++++++++++ t/engine/deploy/func/add_user.sql | 13 + t/engine/deploy/users.sql | 6 + t/engine/deploy/widgets.sql | 7 + t/engine/revert/func/add_user.sql | 7 + t/engine/revert/users.sql | 2 + t/engine/revert/widgets.sql | 2 + t/engine/reworked/deploy/users@alpha.sql | 6 + t/engine/reworked/revert/users@alpha.sql | 2 + t/engine/sqitch.plan | 7 + t/engine_cmd.t | 631 +++++ t/exasol.t | 381 +++ t/firebird.t | 380 +++ t/help.t | 93 + t/init.t | 641 +++++ t/item_formatter.t | 287 +++ t/lib/App/Sqitch/Command/bad.pm | 3 + t/lib/App/Sqitch/Command/good.pm | 20 + t/lib/App/Sqitch/Engine/bad.pm | 3 + t/lib/App/Sqitch/Engine/good.pm | 18 + t/lib/DBIEngineTest.pm | 1807 +++++++++++++ t/lib/LC.pm | 17 + t/lib/MockOutput.pm | 74 + t/lib/TestConfig.pm | 148 ++ t/lib/upgradable_registries/exasol.sql | 139 + t/lib/upgradable_registries/firebird.sql | 327 +++ t/lib/upgradable_registries/mysql.sql | 189 ++ t/lib/upgradable_registries/oracle.sql | 136 + t/lib/upgradable_registries/pg.sql | 140 + t/lib/upgradable_registries/snowflake.sql | 139 + t/lib/upgradable_registries/sqlite.sql | 75 + t/lib/upgradable_registries/vertica.sql | 84 + t/linelist.t | 81 + t/local.conf | 15 + t/log.t | 754 ++++++ t/mooseless.t | 31 + t/multiplan.conf | 13 + t/mysql.t | 521 ++++ t/odbc/odbcinst.ini | 11 + t/odbc/vertica.ini | 4 + t/options.t | 210 ++ t/oracle.t | 571 +++++ t/pg.t | 322 +++ t/plan.t | 2034 +++++++++++++++ t/plan_cmd.t | 675 +++++ t/plans/bad-change.plan | 8 + t/plans/changes-only.plan | 8 + t/plans/dependencies.plan | 12 + t/plans/deploy-and-revert.plan | 11 + t/plans/dos.plan | 8 + t/plans/dupe-change-diff-tag.plan | 10 + t/plans/dupe-change.plan | 10 + t/plans/dupe-tag.plan | 14 + t/plans/multi.plan | 13 + t/plans/pragmas.plan | 9 + t/plans/project_deps.plan | 12 + t/plans/reserved-tag.plan | 10 + t/plans/widgets.plan | 8 + t/pragma.t | 63 + t/read.pl | 3 + t/rebase.t | 674 +++++ t/revert.t | 347 +++ t/rework.conf | 2 + t/rework.t | 978 +++++++ t/show.t | 198 ++ t/snowflake.t | 562 +++++ t/sqitch | 16 + t/sqitch.conf | 24 + t/sql/deploy/roles.sql | 1 + t/sql/deploy/users.sql | 2 + t/sql/deploy/widgets.sql | 2 + t/sql/sqitch.plan | 8 + t/sql/verify/users.sql | 1 + t/sqlite.t | 384 +++ t/status.t | 616 +++++ t/tag.t | 167 ++ t/tag_cmd.t | 370 +++ t/target.conf | 13 + t/target.t | 669 +++++ t/target_cmd.t | 766 ++++++ t/templates.conf | 9 + t/upgrade.t | 118 + t/user.conf | 24 + t/verify.t | 298 +++ t/vertica.t | 320 +++ t/x.t | 78 + 280 files changed, 77326 insertions(+) create mode 100644 Build.PL create mode 100644 Changes create mode 100644 LICENSE create mode 100644 LICENSE.md create mode 100644 MANIFEST create mode 100644 META.json create mode 100644 META.yml create mode 100644 README create mode 100644 README.md create mode 100755 bin/sqitch create mode 100644 dist/cpanfile create mode 100644 dist/sqitch.spec create mode 100644 etc/templates/deploy/exasol.tmpl create mode 100644 etc/templates/deploy/firebird.tmpl create mode 100644 etc/templates/deploy/mysql.tmpl create mode 100644 etc/templates/deploy/oracle.tmpl create mode 100644 etc/templates/deploy/pg.tmpl create mode 100644 etc/templates/deploy/snowflake.tmpl create mode 100644 etc/templates/deploy/sqlite.tmpl create mode 100644 etc/templates/deploy/vertica.tmpl create mode 100644 etc/templates/revert/exasol.tmpl create mode 100644 etc/templates/revert/firebird.tmpl create mode 100644 etc/templates/revert/mysql.tmpl create mode 100644 etc/templates/revert/oracle.tmpl create mode 100644 etc/templates/revert/pg.tmpl create mode 100644 etc/templates/revert/snowflake.tmpl create mode 100644 etc/templates/revert/sqlite.tmpl create mode 100644 etc/templates/revert/vertica.tmpl create mode 100644 etc/templates/verify/exasol.tmpl create mode 100644 etc/templates/verify/firebird.tmpl create mode 100644 etc/templates/verify/mysql.tmpl create mode 100644 etc/templates/verify/oracle.tmpl create mode 100644 etc/templates/verify/pg.tmpl create mode 100644 etc/templates/verify/snowflake.tmpl create mode 100644 etc/templates/verify/sqlite.tmpl create mode 100644 etc/templates/verify/vertica.tmpl create mode 100644 etc/tools/upgrade-registry-to-mysql-5.5.0.sql create mode 100644 etc/tools/upgrade-registry-to-mysql-5.6.4.sql create mode 100644 inc/Menlo/Sqitch.pm create mode 100644 inc/Module/Build/Sqitch.pm create mode 100644 lib/App/Sqitch.pm create mode 100644 lib/App/Sqitch/Command.pm create mode 100644 lib/App/Sqitch/Command/add.pm create mode 100644 lib/App/Sqitch/Command/bundle.pm create mode 100644 lib/App/Sqitch/Command/checkout.pm create mode 100644 lib/App/Sqitch/Command/config.pm create mode 100644 lib/App/Sqitch/Command/deploy.pm create mode 100644 lib/App/Sqitch/Command/engine.pm create mode 100644 lib/App/Sqitch/Command/help.pm create mode 100644 lib/App/Sqitch/Command/init.pm create mode 100644 lib/App/Sqitch/Command/log.pm create mode 100644 lib/App/Sqitch/Command/plan.pm create mode 100644 lib/App/Sqitch/Command/rebase.pm create mode 100644 lib/App/Sqitch/Command/revert.pm create mode 100644 lib/App/Sqitch/Command/rework.pm create mode 100644 lib/App/Sqitch/Command/show.pm create mode 100644 lib/App/Sqitch/Command/status.pm create mode 100644 lib/App/Sqitch/Command/tag.pm create mode 100644 lib/App/Sqitch/Command/target.pm create mode 100644 lib/App/Sqitch/Command/upgrade.pm create mode 100644 lib/App/Sqitch/Command/verify.pm create mode 100644 lib/App/Sqitch/Config.pm create mode 100644 lib/App/Sqitch/DateTime.pm create mode 100644 lib/App/Sqitch/Engine.pm create mode 100644 lib/App/Sqitch/Engine/Upgrade/exasol-1.0.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/exasol-1.1.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/firebird-1.0.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/firebird-1.1.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/mysql-1.0.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/mysql-1.1.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/oracle-1.0.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/oracle-1.1.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/pg-1.0.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/pg-1.1.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/snowflake-1.0.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/snowflake-1.1.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/sqlite-1.0.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/sqlite-1.1.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/vertica-1.0.sql create mode 100644 lib/App/Sqitch/Engine/Upgrade/vertica-1.1.sql create mode 100644 lib/App/Sqitch/Engine/exasol.pm create mode 100644 lib/App/Sqitch/Engine/exasol.sql create mode 100644 lib/App/Sqitch/Engine/firebird.pm create mode 100644 lib/App/Sqitch/Engine/firebird.sql create mode 100644 lib/App/Sqitch/Engine/mysql.pm create mode 100644 lib/App/Sqitch/Engine/mysql.sql create mode 100644 lib/App/Sqitch/Engine/oracle.pm create mode 100644 lib/App/Sqitch/Engine/oracle.sql create mode 100644 lib/App/Sqitch/Engine/pg.pm create mode 100644 lib/App/Sqitch/Engine/pg.sql create mode 100644 lib/App/Sqitch/Engine/snowflake.pm create mode 100644 lib/App/Sqitch/Engine/snowflake.sql create mode 100644 lib/App/Sqitch/Engine/sqlite.pm create mode 100644 lib/App/Sqitch/Engine/sqlite.sql create mode 100644 lib/App/Sqitch/Engine/vertica.pm create mode 100644 lib/App/Sqitch/Engine/vertica.sql create mode 100644 lib/App/Sqitch/ItemFormatter.pm create mode 100644 lib/App/Sqitch/Plan.pm create mode 100644 lib/App/Sqitch/Plan/Blank.pm create mode 100644 lib/App/Sqitch/Plan/Change.pm create mode 100644 lib/App/Sqitch/Plan/ChangeList.pm create mode 100644 lib/App/Sqitch/Plan/Depend.pm create mode 100644 lib/App/Sqitch/Plan/Line.pm create mode 100644 lib/App/Sqitch/Plan/LineList.pm create mode 100644 lib/App/Sqitch/Plan/Pragma.pm create mode 100644 lib/App/Sqitch/Plan/Tag.pm create mode 100644 lib/App/Sqitch/Role/ConnectingCommand.pm create mode 100644 lib/App/Sqitch/Role/ContextCommand.pm create mode 100644 lib/App/Sqitch/Role/DBIEngine.pm create mode 100644 lib/App/Sqitch/Role/RevertDeployCommand.pm create mode 100644 lib/App/Sqitch/Role/TargetConfigCommand.pm create mode 100644 lib/App/Sqitch/Target.pm create mode 100644 lib/App/Sqitch/Types.pm create mode 100644 lib/App/Sqitch/X.pm create mode 100644 lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo create mode 100644 lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo create mode 100644 lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo create mode 100644 lib/sqitch-add-usage.pod create mode 100644 lib/sqitch-add.pod create mode 100644 lib/sqitch-authentication.pod create mode 100644 lib/sqitch-bundle-usage.pod create mode 100644 lib/sqitch-bundle.pod create mode 100644 lib/sqitch-checkout-usage.pod create mode 100644 lib/sqitch-checkout.pod create mode 100644 lib/sqitch-config-usage.pod create mode 100644 lib/sqitch-config.pod create mode 100644 lib/sqitch-configuration.pod create mode 100644 lib/sqitch-deploy-usage.pod create mode 100644 lib/sqitch-deploy.pod create mode 100644 lib/sqitch-engine-usage.pod create mode 100644 lib/sqitch-engine.pod create mode 100644 lib/sqitch-environment.pod create mode 100644 lib/sqitch-help-usage.pod create mode 100644 lib/sqitch-help.pod create mode 100644 lib/sqitch-init-usage.pod create mode 100644 lib/sqitch-init.pod create mode 100644 lib/sqitch-log-usage.pod create mode 100644 lib/sqitch-log.pod create mode 100644 lib/sqitch-passwords.pod create mode 100644 lib/sqitch-plan-usage.pod create mode 100644 lib/sqitch-plan.pod create mode 100644 lib/sqitch-rebase-usage.pod create mode 100644 lib/sqitch-rebase.pod create mode 100644 lib/sqitch-revert-usage.pod create mode 100644 lib/sqitch-revert.pod create mode 100644 lib/sqitch-rework-usage.pod create mode 100644 lib/sqitch-rework.pod create mode 100644 lib/sqitch-show-usage.pod create mode 100644 lib/sqitch-show.pod create mode 100644 lib/sqitch-status-usage.pod create mode 100644 lib/sqitch-status.pod create mode 100644 lib/sqitch-tag-usage.pod create mode 100644 lib/sqitch-tag.pod create mode 100644 lib/sqitch-target-usage.pod create mode 100644 lib/sqitch-target.pod create mode 100644 lib/sqitch-upgrade-usage.pod create mode 100644 lib/sqitch-upgrade.pod create mode 100644 lib/sqitch-verify-usage.pod create mode 100644 lib/sqitch-verify.pod create mode 100644 lib/sqitch.pod create mode 100644 lib/sqitchchanges.pod create mode 100644 lib/sqitchcommands.pod create mode 100644 lib/sqitchguides.pod create mode 100644 lib/sqitchtutorial-exasol.pod create mode 100644 lib/sqitchtutorial-firebird.pod create mode 100644 lib/sqitchtutorial-mysql.pod create mode 100644 lib/sqitchtutorial-oracle.pod create mode 100644 lib/sqitchtutorial-snowflake.pod create mode 100644 lib/sqitchtutorial-sqlite.pod create mode 100644 lib/sqitchtutorial-vertica.pod create mode 100644 lib/sqitchtutorial.pod create mode 100644 lib/sqitchusage.pod create mode 100644 t/add.t create mode 100644 t/add_change.conf create mode 100644 t/base.t create mode 100644 t/blank.t create mode 100644 t/bundle.t create mode 100644 t/change.t create mode 100644 t/changelist.t create mode 100644 t/checkout.t create mode 100644 t/command.t create mode 100644 t/config.t create mode 100644 t/configuration.t create mode 100644 t/conn_cmd_role.t create mode 100644 t/core.conf create mode 100644 t/core_target.conf create mode 100644 t/cx_cmd_role.t create mode 100644 t/datetime.t create mode 100644 t/depend.t create mode 100644 t/deploy.t create mode 100644 t/die.pl create mode 100644 t/echo.pl create mode 100644 t/editor.conf create mode 100644 t/engine.conf create mode 100644 t/engine.t create mode 100644 t/engine/deploy/func/add_user.sql create mode 100644 t/engine/deploy/users.sql create mode 100644 t/engine/deploy/widgets.sql create mode 100644 t/engine/revert/func/add_user.sql create mode 100644 t/engine/revert/users.sql create mode 100644 t/engine/revert/widgets.sql create mode 100644 t/engine/reworked/deploy/users@alpha.sql create mode 100644 t/engine/reworked/revert/users@alpha.sql create mode 100644 t/engine/sqitch.plan create mode 100644 t/engine_cmd.t create mode 100644 t/exasol.t create mode 100644 t/firebird.t create mode 100644 t/help.t create mode 100644 t/init.t create mode 100644 t/item_formatter.t create mode 100644 t/lib/App/Sqitch/Command/bad.pm create mode 100644 t/lib/App/Sqitch/Command/good.pm create mode 100644 t/lib/App/Sqitch/Engine/bad.pm create mode 100644 t/lib/App/Sqitch/Engine/good.pm create mode 100644 t/lib/DBIEngineTest.pm create mode 100644 t/lib/LC.pm create mode 100644 t/lib/MockOutput.pm create mode 100644 t/lib/TestConfig.pm create mode 100644 t/lib/upgradable_registries/exasol.sql create mode 100644 t/lib/upgradable_registries/firebird.sql create mode 100644 t/lib/upgradable_registries/mysql.sql create mode 100644 t/lib/upgradable_registries/oracle.sql create mode 100644 t/lib/upgradable_registries/pg.sql create mode 100644 t/lib/upgradable_registries/snowflake.sql create mode 100644 t/lib/upgradable_registries/sqlite.sql create mode 100644 t/lib/upgradable_registries/vertica.sql create mode 100644 t/linelist.t create mode 100644 t/local.conf create mode 100644 t/log.t create mode 100644 t/mooseless.t create mode 100644 t/multiplan.conf create mode 100644 t/mysql.t create mode 100644 t/odbc/odbcinst.ini create mode 100644 t/odbc/vertica.ini create mode 100644 t/options.t create mode 100644 t/oracle.t create mode 100644 t/pg.t create mode 100644 t/plan.t create mode 100644 t/plan_cmd.t create mode 100644 t/plans/bad-change.plan create mode 100644 t/plans/changes-only.plan create mode 100644 t/plans/dependencies.plan create mode 100644 t/plans/deploy-and-revert.plan create mode 100644 t/plans/dos.plan create mode 100644 t/plans/dupe-change-diff-tag.plan create mode 100644 t/plans/dupe-change.plan create mode 100644 t/plans/dupe-tag.plan create mode 100644 t/plans/multi.plan create mode 100644 t/plans/pragmas.plan create mode 100644 t/plans/project_deps.plan create mode 100644 t/plans/reserved-tag.plan create mode 100644 t/plans/widgets.plan create mode 100644 t/pragma.t create mode 100644 t/read.pl create mode 100644 t/rebase.t create mode 100644 t/revert.t create mode 100644 t/rework.conf create mode 100644 t/rework.t create mode 100644 t/show.t create mode 100644 t/snowflake.t create mode 100755 t/sqitch create mode 100644 t/sqitch.conf create mode 100644 t/sql/deploy/roles.sql create mode 100644 t/sql/deploy/users.sql create mode 100644 t/sql/deploy/widgets.sql create mode 100644 t/sql/sqitch.plan create mode 100644 t/sql/verify/users.sql create mode 100644 t/sqlite.t create mode 100644 t/status.t create mode 100644 t/tag.t create mode 100644 t/tag_cmd.t create mode 100644 t/target.conf create mode 100644 t/target.t create mode 100644 t/target_cmd.t create mode 100644 t/templates.conf create mode 100644 t/upgrade.t create mode 100644 t/user.conf create mode 100644 t/verify.t create mode 100644 t/vertica.t create mode 100644 t/x.t diff --git a/Build.PL b/Build.PL new file mode 100644 index 00000000..96628bb4 --- /dev/null +++ b/Build.PL @@ -0,0 +1,154 @@ + +# This file was automatically generated by Dist::Zilla::Plugin::ModuleBuild v6.012. +use strict; +use warnings; + +use Module::Build 0.35; +use lib qw{inc}; use Module::Build::Sqitch; + +my %module_build_args = ( + "build_requires" => { + "Module::Build" => "0.35" + }, + "configure_requires" => { + "Module::Build" => "0.35" + }, + "dist_abstract" => "Sensible database change management", + "dist_author" => [ + "\"iovation Inc.\"" + ], + "dist_name" => "App-Sqitch", + "dist_version" => "v1.0.0", + "license" => "mit", + "module_name" => "App::Sqitch", + "recommends" => { + "Class::XSAccessor" => "1.18", + "Pod::Simple" => "1.41", + "Template" => 0, + "Type::Tiny::XS" => "0.010" + }, + "recursive_test_files" => 1, + "requires" => { + "Clone" => 0, + "Config::GitLike" => "1.15", + "DBI" => 0, + "DateTime" => "1.04", + "DateTime::TimeZone" => 0, + "Devel::StackTrace" => "1.30", + "Digest::SHA" => 0, + "Encode" => 0, + "Encode::Locale" => 0, + "File::Basename" => 0, + "File::Copy" => 0, + "File::Path" => 0, + "File::Temp" => 0, + "Getopt::Long" => 0, + "Hash::Merge" => 0, + "IO::Handle" => 0, + "IO::Pager" => "0.34", + "IPC::Run3" => 0, + "IPC::System::Simple" => "1.17", + "List::MoreUtils" => 0, + "List::Util" => 0, + "Locale::Messages" => 0, + "Locale::TextDomain" => "1.20", + "Moo" => "1.002000", + "Moo::Role" => 0, + "POSIX" => 0, + "Path::Class" => "0.33", + "PerlIO::utf8_strict" => 0, + "Pod::Escapes" => "1.04", + "Pod::Find" => 0, + "Pod::Usage" => 0, + "Scalar::Util" => 0, + "StackTrace::Auto" => 0, + "String::Formatter" => 0, + "String::ShellQuote" => 0, + "Sub::Exporter" => 0, + "Sub::Exporter::Util" => 0, + "Sys::Hostname" => 0, + "Template::Tiny" => "0.11", + "Term::ANSIColor" => "2.02", + "Throwable" => "0.200009", + "Time::HiRes" => 0, + "Time::Local" => 0, + "Try::Tiny" => 0, + "Type::Library" => "0.040", + "Type::Utils" => 0, + "Types::Standard" => 0, + "URI" => 0, + "URI::QueryParam" => 0, + "URI::db" => "0.19", + "User::pwent" => 0, + "constant" => 0, + "if" => 0, + "namespace::autoclean" => "0.16", + "overload" => 0, + "parent" => 0, + "perl" => "5.010", + "strict" => 0, + "utf8" => 0, + "warnings" => 0 + }, + "script_files" => [ + "bin/sqitch" + ], + "test_requires" => { + "Capture::Tiny" => "0.12", + "Carp" => 0, + "File::Find" => 0, + "File::Spec" => 0, + "File::Spec::Functions" => 0, + "FindBin" => 0, + "IO::Pager" => "0.34", + "Module::Runtime" => 0, + "Path::Class" => "0.33", + "Test::Deep" => 0, + "Test::Dir" => 0, + "Test::Exception" => 0, + "Test::File" => 0, + "Test::File::Contents" => "0.20", + "Test::MockModule" => "0.17", + "Test::More" => "0.94", + "Test::NoWarnings" => "0.083", + "Test::Warn" => 0, + "base" => 0, + "lib" => 0 + } +); + + +my %fallback_build_requires = ( + "Capture::Tiny" => "0.12", + "Carp" => 0, + "File::Find" => 0, + "File::Spec" => 0, + "File::Spec::Functions" => 0, + "FindBin" => 0, + "IO::Pager" => "0.34", + "Module::Build" => "0.35", + "Module::Runtime" => 0, + "Path::Class" => "0.33", + "Test::Deep" => 0, + "Test::Dir" => 0, + "Test::Exception" => 0, + "Test::File" => 0, + "Test::File::Contents" => "0.20", + "Test::MockModule" => "0.17", + "Test::More" => "0.94", + "Test::NoWarnings" => "0.083", + "Test::Warn" => 0, + "base" => 0, + "lib" => 0 +); + + +unless ( eval { Module::Build->VERSION(0.4004) } ) { + delete $module_build_args{test_requires}; + $module_build_args{build_requires} = \%fallback_build_requires; +} + +my $build = Module::Build::Sqitch->new(%module_build_args); + + +$build->create_build_script; diff --git a/Changes b/Changes new file mode 100644 index 00000000..28661173 --- /dev/null +++ b/Changes @@ -0,0 +1,1979 @@ +Revision history for Perl extension App::Sqitch + +1.0.0 2019-06-04T12:56:22Z + - Fixed test failure due to a hard-coded system error that may be + localized on non-en-US hosts. Thanks to Slaven Rezić for the catch + (#427). + - Now require Test::MockModule 0.17 to silence a warning during testing. + Thanks to Slaven Rezić for the suggestion. + - Fixed an error when Sqitch is run with no arguments. Thanks to Henrik + Tudborg for the report (#428). + - Fixed missing dependency on IO::Pager in the distribution metadata. + - Removed use of File::HomeDir, thanks to a PR from Karen Etheridge + (#433). + - Updated the tagline from "Sane database change management" to "Sensible + database change management" out of sensitivity to those subject to + mental illness (#435). + - Removed double-quoting of SQLite commands on Windows, inadvertently + added by the workaround for Windows quoting in v0.9999. + - Fixed a Snowflake issue where Sqitch failed to recognize the proper + error code for a missing table and therefore an uninitialized registry. + Thanks to @lerouxt and @kulmam92 for the report and fix (#439). + - Added check for project initialization when no engine config can be + found. When run from a directory with no configuration, Sqitch now + reports that the project is not initialized instead of complaining + about a lack of engine config (#437). + - Documented Snowflake key pair authentication in + `sqitch-authentication`, as well as `$SNOWSQL_PRIVATE_KEY_PASSPHRASE` + in `sqitch-environment`. Thanks to Casey Largent for figuring it out + (#441). + - Added the German localization. Thanks to Thomas Iguchi for the pull + request (#451). + - Renamed the French localization from "fr" to "fr_FR", so that systems + will actually find it. + - Added the `ask_yes_no()` method as a replacement for `ask_y_n()`, which + is now deprecated. The new method expects localized responses from the + user when translations are provided. Defaults to the English "yes" and + "no" when no translation is available. Suggested by German translator + Thomas Iguchi (#449). + - Fixed a bug where only project without a URI was allowed in the + registry. Thanks to Conding-Brunna for the report (#450). + - Clarified the role of project URIs for uniqueness: They don't allow + multiple projects with the same name, but do prevent the deployment of + a project with the same name but different URI. + - Fixed an issue where target variables could not be found when a target + name was not lowercase. Thanks to @maximejanssens for the report + (#454). + - Now require Config::GitLike 1.15 or higher. + - Fixed the indentation of variables emitted by the `show` actions of the + `target` and `engine` commands, fixing a "Negative repeat count does + nothing" warning in the process. Thanks to @maximejanssens for the + report (#454). + - Fixed a Snowflake test failure when the current system username has a + space or other character requiring URI escaping. Thanks to Ralph + Andrade for the report (#463). + - Fixed an issue where a wayward newline in some versions of SQLite + prevented Sqitch from parsing the version. Thanks to Kivanc Yazan + for the report (#465) and the fix (#465)! + - Fixed an error when Sqitch was run on a system without a valid + username, such as some Docker environments. Thanks to Ferdinand Salis + for the report (#459)! + - When Sqitch finds the registry does not exist on PostgreSQL, it now + sends a warning to the PostgreSQL log reporting that it will initialize + the database. This is to reduce confusion for folks watching the + PostgreSQL error log while Sqitch runs (#314). + +0.9999 2019-02-01T15:29:40Z + [Bug Fixes] + - Fixed a test failure with the MySQL max limit value, mostly exhibited + on BSD platforms. + - Removed fallback in the PostgreSQL engine on the `$PGUSER` and + `$PGPASSWORD` environnement variables, as well as the system username, + since libpq does all that automatically, and collects data from other + sources that we did not (e.g., the password and connection service + files). Thanks to Tom Bloor for the report (issue #410). + - Changed dependency validation to prevent an error when a change required + from a different project has been reworked. Previously, when requiring a + change such as `foo:greeble`, Sqitch would raise an error if + `foo:greeble` was reworked, suggesting that the dependency be + tag-qualified to eliminate ambiguity. Now reworked dependencies may be + required without tag-qualification, though tag-qualification should still + be specified if functionality as of a particular tag is required. + - Added a workaround for the shell quoting issue on Windows. Applies to + IPC::System::Simple 1.29 and lower. See + [pjf/ipc-system-simple#29](https://github.com/pjf/ipc-system-simple/pull/29) + for details (#413). + - Fixed an issue with the MariaDB client where a deploy, revert, or + verify failure was not properly propagated to Sqitch. Sqitch now passes + `--abort-source-on-error` to the Maria `mysql` client to ensure that + SQL errors cause the client to abort with an error so that Sqitch can + properly handle it. Thanks to @mvgrimes for the original report and, + years later, the fix (#209). + - Fixed an issue with command argument parsing so that it truly never + returns a target without an engine specified, as documented. + - Removed documentation for methods that don't exist. + - Fixed test failures due to a change in Encode v2.99 that's stricter + about `undef` arguments that should be defined. + + [Improvements] + - The Snowflake engine now consults the `connections.warehousename`, + `connections.dbname`, and `connections.rolename` variables in the + SnowSQL configuration file (`~/.snowsql/config`) before falling back on + the hard-coded warehouse name "sqitch" and using the system username as + the database name and no default for the role. + - Switched to using a constant internally to optimize windows-specific + code paths at compile time. + - When `deploy` detects undeployed dependencies, it now eliminates + duplicates before listing them in the error message. + - Now requiring IO::Pager v0.34 or later for its more consistent + interface. + - Added notes about creating databases to the tutorials. Thanks to Dave + Rolsky for the prompt (#315). + - Added a status message to tell the user when the registry is being + updated, rather than just show each individual update. Thanks to Ben + Hutton for the suggestion (#276). + - Added support for a `$SQITCH_TARGET` environment variable, which takes + precedence over all other target specifications except for command-line + options and arguments. Thanks to @mvgrimes for the suggestion (#203). + - Fixed target/engine/change argument parsing so it won't automatically + fail when `core.engine` isn't set unless no targets are found. This + lets engines be determined strictly from command-line arguments -- + derived from targets, or just listed on their own -- whether or not + `core.engine` is set. This change eliminates the need for the + `no_default` parameter to the `parse_args()` method of App::Sqitch + Command. It also greatly reduces the need for the core `--engine` + option, which was previously required to work around this issue (see + below for its removal). + - Refactored config handling in tests to use a custom subclass of + App::Sqitch::Config instead of various mocks, temporary files, and the + like. + - Added advice to use the PL/pgSQL `ASSERT()` function for verify scripts + to the Postgres tutorial. Thanks to Sergii Tkachenko for the PR (#425). + + [Target Variables] + - The `verify` command now reads `deploy.variables`, and individual + `verify.variables override `deploy.variables`, on the assumption that + the verify variables in general ought to be the same as the deploy + variables. This makes `verify` variable configuration consistent with + `revert` variable configuration. + - Variables set via the `--set-deploy` option on the `rebase` and + `checkout` commands no longer apply to both reverts and deploys, but + only deploys. Use the `--set` option to apply a variable to both + reverts and deploys. + - Added support for core, engine, and target variable configuration. The + simplest way to use them is via the `--set` option on the `init`, + `engine`, and `target` commands. These commands allow the configuration + of database client variables for specific engines and targets, as well + as defaults that apply to all change execution commands (`deploy`, + `revert`, `verify`, `rebase`, and `checkout`). The commands merge the + variables from each level in this priority order: + * `--set-deploy` and `--set-revert` options on `rebase` and `checkout` + * `--set` option + * `target.$target.variables` + * `engine.$engine.variables` + * `deploy.variables`, `revert.variables`, and `verify.variables` + * `core.variables` + See `sqitch-configuration` for general documentation of of the + hierarchy for merging variables and the documentation for each command + for specifics. + + [Options Unification] + - Added the `--chdir`/`--cd`/`-C` option to specify a directory to change + to before executing any Sqitch commands. Thanks to Thomas Sibley for + the suggestion (#411). + - Added the `--no-pager` option to disable the pager (#414). + - Changed command-line parsing to allow core and command options to + appear anywhere on the line. Previously, core options had to come + before the command name, and command options after. No more. The caveat + is that command options that take arguments should either appear after + the command or use the `--opt=val` syntax instead of `--opt val`, so + that Sqitch doesn't think `val` is the command. Even in that case, it + will search the rest of the arguments to find a valid command. + However, to minimize this challenge, the documentation now suggests + and demonstrates putting all options after the command, like so: + `sqitch [command] [options]`. + - Simplified and clarified the distinction between core and command + options by removing all options from the core except those that affect + output and runtime context. The core options are: + * -C --chdir --cd Change to directory before performing any actions + * --etc-path Print the path to the etc directory and exit + * --no-pager Do not pipe output into a pager + * --quiet Quiet mode with non-error output suppressed + * -V --verbose Increment verbosity + * --version Print the version number and exit + * --help Show a list of commands and exit + * --man Print the introductory documentation and exit + - Relatedly, single-letter core options will now always be uppercase, + while single-letter command options will be lowercase. As such, `-V` + has been added as an alias for `--version`, although `-v` remains for + now, undocumented. It may be removed in the future should a compelling + use for `-v` in a command be discovered. + - All other options have been moved to the commands they affect. Their + use should remain mostly unchanged now that command options are parsed + from anywhere on the command-line, although we recommend that all + options come after commands. The options were moved as follows: + * `--registry`, `--client`, `--db-name`, `--db-user`, `--db-host`, and + `--db-port` (and their aliases) have been moved to the `checkout`, + `deploy`, `log`, `rebase`, `revert`, `status`, `upgrade`, and + `verify` commands. + * `--plan-file` and `--top-dir` (deprecated; see below) have been moved + to the `add`, `bundle`, `checkout`, `deploy`, `rebase`, `revert`, + `rework`, `show`, `status`, `tag`, and `verify` commands. They were + already supported by the `init`, `engine`, and `target` commands + (where `--top-dir` is not deprecated). + - Because some command options conflicted with core options, a few + options have been removed altogether, including: + * The `--verbose` option on the `--engine` and `--target` commands has + been removed, but no visible change should be apparent, since those + commands now read the core `--verbose` option. + * The undocumented `--dir` alias for `--top-dir` has been removed, as + it conflicted with the option of the same name but different meaning + in the `init`, `engine`, and `target` commands. + * The `-d` alias for `--set-deploy` in the `rebase` and `checkout` + commands has been changed to `-e` so as not to conflict with the `-d` + alias for `--db-name`. + * Added tests for all commands to ensure none of their options conflict + with core options. Will help prevent conflicts in the future. + + [Deprecations & Removals] + - Deprecated the `--top-dir` option in favor of `--chdir` with a warning + except when used for configuration in the `init`, `engine`, and + `target` commands. + - Removed the core `--deploy-dir`, `--revert-dir`, and `--verify-dir` + options, which have been deprecated and triggering warnings since + v0.9993 (August 2015). The `--dir` option to the `init`, `engine`, and + `target` commands remains the favored interface for specifying script + directories. + - Removed the deprecated core `--engine` option. The `init` command still + supports it, while other commands are able to parse the engine name as + an argument --- e.g., `sqitch deploy mysql` --- or implicitly as part + of a target, as in `sqitch revert db:pg:tryme`. When Sqitch is unable + to determine the engine for a command, the error message no longer + mentions `--engine` and instead suggests specifying the engine via the + target. This option never triggered an error, but demonstration of its + use has been limited to `init` examples. + - Removed support for reading the `core.$engine` configuration, which has + been deprecated with warnings in favor of `engine.$engine` since 0.997 + (November 2014). The `sqitch engine update-config` action remains + available to update old configurations, but may be removed in the + future. + - Removed the `--deploy`, `--revert`, and `--verify` options on the `add` + command, as well as their `--no-*` variants. They have been deprecated + with warnings in favor of the `--with` and `--without` options since + v0.990 (January 2014). + - Removed the `--deploy-template`, `--revert-template`, and + `--verify-template` options to the `add` command. They have been + deprecated with warnings in favor of the `--use` option since v0.990 + (January 2014). + - Removed the `add.deploy_template`, `add.revert_template`, and + `add.verify_template` configuration settings. They have been deprecated + with warnings in favor of the `add.templates` configuration section + since v0.990 (January 2014). + - Removed the `@FIRST` and `@LAST` symbolic tags, which have been + deprecated with warnings in favor of `@ROOT` and `@HEAD`, respectively, + since 0.997 (November 2014). + - Removed the command-specific options with the string "target" in them, + such as `--to-target`, `--upto-target`, which have been deprecated with + warnings in in favor of options containing the string "change", such as + `--to-change` and `--upto-change`, since v0.997 (November 2014). + - Remove the `engine` and `target` command `set-*` actions and their + corresponding methods, which have been deprecated in favor of the + `alter` action since v0.9993 (August 2015). + - Removed the automatic updating of change and tag IDs in the Postgres + engine. This functionality was added in v0.940 (December 2012), when + Postgres was the only engine, and the SHA-1 hash for change and tag IDs + was changed. There were very few deployments at the time, and all + should long since have been updated. + + [API Changes] + - Added the URI-overriding parameters `user`, `host`, `port`, and + `dbname` to App::Sqitch::Target so that command options can be used to + easily set them. + - Added support for passing attribute parameters to the `all_targets` + group constructor on App::Sqitch::Target, so that command-line options + can be used to assign attributes to all targets read from the + configuration. + - Aded the `target_params` method to App::Sqitch::Command and updated all + commands to use it when constructing targets. This allows commands to + define options for Target parameters, as required for moving options to + commands as described above. + - Added the `class_for` method to App::Sqitch::Command so that the new + options parser described above can load a command class without + instantiating an instance. Useful for searching command-line arguments + for a command name. + - Added the `create` constructor to App::Sqitch::Command to let Sqitch + instantiate an instance of a command once it finds one via `class_for`. + Previously, Sqitch used the `load` method, which handled the + functionality of both `class_for` and `create`. That method still + exists but is used only in tests. + - Added the ConnectingCommand role to define database connection options + for the commands that need them. + - Added the ContextCommand role to define command options for the + location of the plan file and top directory. This is also where use of + the deprecated form of `--top-dir` triggers a warning. + - Removed the `verbosity` attribute from App::Sqitch::Command::engine and + App::Sqitch::Command::target, since the `--verbose` option is no longer + needed. These commands now rely on the core `--verbose` option. + - Removed the copying of core options from the target class and + TargetConfigCommand role, since the attributes fetched from there are + no longer core options, but provided as attribute parameters to the + constructors by commands. + - Removed documentation for the optional `config` parameter to the + `all_targets` constructor of App::Sqitch::Target, since it was never + used by Sqitch. It always fetched the config from the required `sqitch` + parameter. Support for the `config` parameter has not been removed, + since third-parties might use it. + - Removed the `set_*` methods in the `engine` and `target` commands, + which have been deprecated in favor of the new `alter` method since + v0.9993 (August 2015). + - Removed the `old_id` and `old_info` methods from Change and Tag, which + date from v0.940 (December 2012), and were provided only to allow + existing Postgres databases to be updated from the old to new ID + format, now removed. There should be no other use case for these + methods. + +0.9998 2018-10-03T20:53:58Z + - Fixed an issue where Sqitch would sometimes truncate the registry + version number fetched from MySQL, most likely because the Perl runtime + was using 32-bit integers. Fixed by casting the version to CHAR in the + query, before Perl ever see it. Thanks to Allen Godfrey David for the + report. + - Added the Snowflake engine. + - Now require URI::db v0.19 for Snowflake URI support. + - The Vertica and Exasol engines now require DBD::ODBC 1.59, which fixes + a Unicode issue. Thanks to Martin J. Evans for the quick fix + (perl5-dbi/DBD-ODBC#8)! + - Added the `bundle` command to `./Build`. This command installs only the + runtime dependencies into the `--install_base` directory. This should + simplify building distribution packages, binary installs, Docker images, + and the like. + - Added the `--with` option to `./Build`, to require that Sqitch be build + with the specified engine. Pass once for each engine. See the README + for the list of supported engines. + - Added a check for Hash::Merge 0.298 during installation, since that + release has a fatal bug that breaks Sqitch. If it's installed, the + installer will issue a warning and added v0.299 to its list of + dependencies. Thanks to Slaven Rezić for the suggestion (#377). + - Fixed the PostgreSQL engine so it properly checks the `psql` client + version to determine whether or not the `:registry` variable is + supported. Previously it relied on the server version, which would fail + if the server version was greater than 8.4 but the `psql` client was + not. Thanks to multiple folks reporting issues with registry names and + search paths (#314). + - The plan parser will now complain if a change specifies a duplicate + dependency. This should be less confusing than a database unique + violation. Thanks to Eric Bréchemier for the suggestion (#344). + - Moved the project to its own GitHub organization, + [Sqitchers](https://github.com/sqitchers). + - Fixed likely cause of Oracle buffer allocation bug when selecting + timestamp strings. Thanks to @johannwilfling for the bug report and to + @nmaqsudov for the analysis and solution (#316). + - Changed the way the conninfo string is passed to `psql` to eliminate + argument ordering problems on Windows. Thanks to @highlowhighlow for + the report (#384). + - Added `$SQITCH_USERNAME` environment variable to complement + `$SQITCH_PASSWORD`. It can be used to override the username set in + for a target. + - Added the `$SQITCH_FULLNAME` and `$SQITCH_EMAIL` environment + variables, which take precedence over the values of the `user.name` and + `user.email` config variables. + - Added the `$SQITCH_ORIG_SYSUSER`, `$SQITCH_ORIG_FULLNAME` and + `$SQITCH_ORIG_EMAIL` environment variables. For those situations when + Sqitch attempts to read OS data for user information, These new + environment variables override these system-derived values. The + intention is to allow an originating host to set these values on + another host where Sqitch will actually execute. + - Fixed an error triggered by whitespace trailing an engine name in the + configuration. Thanks to Jeremy Simkins for the report (#400). + - Refactored the engine-specific username and password attributes to + support a consistent search for values. Sqitch searches first for one + of its own environment variables (`$SQITCH_USERNAME` and + `$SQITCH_PASSSWORD`), then the target URI, and finally any engine- + specific values, which might include additional environment variables, + configuration files, the system user, or none at all. + - Database engines that implicitly relied on username and/or password + environment variables or on the system username now explicitly rely on + them. These include the Firebird, MySQL, Postgres, and Vertical + engines. This change should exhibit no change in the behavior of these + engines. + - Added support for the `$MYSQL_HOST` and `$MYSQL_TCP_PORT` environment + variables to the MySQL engine. + - Documented all supported engine-specific environment variables in the + sqitch-environment guide. + - Renamed the sqitch-passwords guide to sqitch-authentication and added a + section on username specification. + - Updated all URLs to use the https scheme. Only exceptions are tt2.org, + which doesn't support TLS, and conferences.embarcadero.com, which + appears to be down. + +0.9997 2018-03-15T21:13:52Z + - Fixed the Firebird engine to properly detect multiple instances of a + change specified to `revert` and `verify`, matching the behavior of + displaying tag-qualified alternates added to the other engines in + v0.9996. + - Fixed test failure on Windows. + - Updated the MySQL and PostgreSQL tests to use process-specific database + names, to try to avoid conflicts when tests are being run by multiple + processes on the same box, as happens with CPAN smoke testing boxes. + - Fixed an issue where Sqitch would sometimes truncate the registry + version number fetched from Postgres, most likely because the Perl + runtime was using 32-bit integers. Fixed by casting the version to text + in the query, before Perl ever see it. Thanks to Malte Legenhausen for + the report (#343). + - The MySQL engine will now read the username from MySQL configuration + files. Thanks to Eliot Alter for the bug report (#353). + - Added Italian translation, with thanks to Luca Ferrari and @BeaData! + - Improved multi-value config examples in the `sqitch-config` + documentation to be a bit less confusing. Thanks to Emil for reporting + where he got confused! + - Added the Exasol engine. Thanks to Johan Wärlander for the PR (#362)! + - Fixed an issue where URI::db needed to be explicitly loaded. Thanks to + Hugh Esco for the report (#370)! + - Changed the exit value for `rebase` and `revert` from 1 to 0 when there + is no work to do. This is to match the expectation of non-zero exit + statuses only when a command is unsuccessful, as well as the behavior + of `deploy` as of v0.995. Nothing to do is considered successful. + Thanks to Paul Williams for the PR (#374)! + - Update `psql` options to use a conninfo string to honor connection + parameter key words for PostgreSQL targets. It can now take advantage + of the connection service file using `db:pg:///?service=$PGSERVICE` as + well as other connection parameters. Thanks to Paul Williams for the PR + (#375)! + +0.9996 2017-07-17T18:33:12Z + - Fixed an error where Oracle sometimes truncated timestamp formats so + that date parsing failed. Thanks to Johann Wilfling for the report and + @nmaqsudov for the solution (#316). + - Added pager configuration, prioritizing the new `core.pager` + configuration variable over the `$PAGER` environment variable. The new + `$SQITCH_PAGER` environment variable trumps all. Thanks to Yati Sagade + for the pull request (#329). + - Documented the `core.editor` configuration variable. + - Updated PostgreSQL registry detection to avoid errors when not running + Sqitch as a superuser and the registry schema already exists. Done by + looking for the `changes` table in the `pg_tables` view instead of + looking for the registry schema in the `pg_namespace` catalog table, + and by using `CREATE SCHEMA IF NOT EXISTS` on PostgreSQL 9.3 and + higher. Thanks to @djk447 for the pull request (#307). + - Updated PostgreSQL registry detection to avoid errors when the `psql` + client is newer than the server version. Sqitch now fetches the version + from the server instead of parsing it from the client. + - Removed `no Moo::sification`, to allow modules to be used by Moose + applications. Replaced with tests to make sure Sqitch itself never uses + Moose. Thanks to @perigrin for the PR (#332). + - Specifying a change before a target name on the command-line no longer + ignores the target (#281). + - The `--db-*` options are now more consistently applied to a target, + including when the target is specified as a URI (#293). + - `HEAD` and `ROOT` are now properly recognized as aliases for `@HEAD` + and `@ROOT`, when querying the database. This was supposedly done in + v0.991, but due to a bug, it wasn't really. Sorry about that. + - The `revert` and `verify` commands will now fail if a change is + specified and matches multiple changes. This happens when referencing a + reworked change only by its name. In this case, Sqitch will emit an + error listing properly tag-qualified changes to use. Suggested by Jay + Hannah (#312). + - Sqitch no longer returns an error when a target name is passed to a + command and the default target's plan file does not exist (#324). + - Added missing options to the `rework` usage statement. Thanks to Jay + Hannah for the PR (#342). + - Passing an engine name or plan file as the `` parameter to + the `log`, `status`, and `upgrade` commands now works correctly, + matching what the documentation has said for some time (#324). + - Added the `--target` option to the `plan` and `show` commands. + - Added the `` parameter to the `plan` command. + - Sqitch now loads targets from all config files, not just the local + file, when trying to determine if a `` parameter is a plan + file name. + - Improved the error message when a change is found more than once in a + plan, typically a reworked changed referenced only by name. The error + will no longer be "Key at multiple indexes", but "Change is ambiguous. + Please specify a tag-qualified change:", followed by a list of + tag-qualified variants of the change. + - Fixed a bug where the verify command would return a database error when + it finds no registry. Now it reports that the registry wasn't found in + the database. + +0.9995 2016-07-27T09:23:55Z + - Taught the `add` command not to ignore the `--change` option. + - The `add` command now emits a usage statement when no change name is + passed to it. + - The `add` command now helpfully suggests using the --change option when + attempting to add a change with the same name as a target. Thanks to + Ivan Nunes for the report! + - The `tag` command now helpfully suggests using the --tag option when + attempting to add a tag with the same name as a target. + - Added `--global` as an alias for `--user` to the `config` command. This + alias benefits the muscle memory of Git users. + - Added a note for Git users to the `sqitch-revert` documentation, to + head off potential confusion with `git revert`. Thanks to Eric + Bréchemier for the "time travel" analogy and wording. + - Fixed an "uninitialized value" error when creating a registry database + on Windows. Thanks to Steven C. Buttgereit for the report (Issue #289). + - Fixed editor selection to prioritize the `core.editor` configuration + variable over the `$EDITOR` environment variable. The `$SQITCH_EDITOR` + environment variable still trumps all. Thanks to Jim Nasby for the pull + request (#296). + - Added detection of the `$VISUAL` environment variable to Editor + selection, prioritized after the `core.editor` configuration variable + and before the `$EDITOR` environment variable. Thanks to Jim Nasby for + the pull request (#296). + - Updated the DateTime code to set the locale via `set_locale()` instead + of `set()`, as the latter may actually change the local time + unintentionally, and has been deprecated since DateTime v1.04. Thanks + to Dave Rolsky for the pull request (#304). + +0.9994 2016-01-08T19:46:43Z + - Reduced minimum required MySQL engine from 5.1.0 to 5.0.0. Thanks to + @dgc-wh for testing it (Issue #251). + - Fixed floating-point rounding issue with SQLite registry versions on + Perls with 16-byte doubles. Thanks to H. Merijn Brand for the report + and testing. + - Fixed an error when adding an engine with the `engine` command. Thanks + to Victor Mours for the report and fix! + - Updated the Oracle engine to support Oracle Wallet connection strings, + where no username or host is in the connection URI. Thanks to Timothy + Procter for the patch! + - Improved the installer's selection of the prefix in which to install + `etc` files to better match the `--installdirs` option, which defaults + to the "site" directories. Thanks to @carragom for the pull request + (#265). + - Added missing dash to `-engine` in sample calls to `sqitch init` in the + tutorials. Thanks to Andrew Dunstan for the spot (Issue #268). + - Fixed broken Vertica documentation links. + - Attempting to revert a database with no associated registry no longer + reports the registry as version 0, but correctly reports that no + registry can be found. Thanks to Arnaldo Piccinelli for the spot (Issue + #271). + - Fixed the search for change IDs in engines to match the search for + changes. Specifically, change ID search now properly handles the + offset characters `~` and `^`. This bug mainly affected the `verify` + command, but it's good to address the inconsistency, done mainly by + adding the `find_change_id` and `change_id_offset_from_id` methods to + complement the `find_change` and `change_offset_from_id` methods. + Thanks to Andrew Dunstan for the spot (Issue #272). + - Fixed the `flips` table example in the MySQL tutorial. It was + inappropriately copied from the PostgreSQL tutorial at some point. + Thanks to Jeff Carpenter for the spot (Issue #254)! + +0.9993 2015-08-17T17:55:26Z + [Bug Fixes] + - Eliminated test failures due to warnings from DateTime::Locale when + `LC_TIME` is set to C.UTF-8. Thanks to Shantanu Bhadoria for the report + and Dave Rolsky for the workaround. + - Fixed an error checking the registry version when the local uses a + comma for decimal values. Thanks to Steffen Müller for the report + (Issue #234). + - Worked around an error setting the MySQL storage engine using versions + of DBI prior to 1.631. Thanks to melon-babak for the report! + - Fixed an error from the Oracle engine when deploying more than 1000 + changes. Thanks to Timothy Procter and Minh Hoang for the report and + testing the fix. + - Fixed a bunch of typos in error messages, comments, and documentation. + Thanks to Dmitriy for the pull request! + - Fixed test failures due to new warnings from File::Path on Perl + 5.23.1. + - On Firebird, Looking up a change and tag in the database (via the + `--onto` option to `rebase` or the `--to` option to `revert`, among + others) would sometimes return the incorrect change if the change has + been reworked two or more times. Was fixed for the other engines in + v0.9991. + - Fixed the `--all` option used to apply a command to all known targets + so that it loads only targets specified by the local configuration. + Otherwise, user and system configuration can get in the way when they + specify engines and targets not used by the current project. + [Improvements] + - Added support for the `--set` option when deploying to MySQL. Thanks to + Chris Bandy for figuring out how to do it! + - Added support for a "reworked directory". By default, reworked change + scripts live in the deploy, revert, and verify directories along with + all the other change scripts. But if that starts to get too messy, or + you simply don't want to see them, add a `reworked_dir` setting to the + core, engine, or target config and reworked scripts will be stored + there, instead. Also supported are `reworked_deploy_dir`, + `reworked_revert_dir`, and `reworked_verify_dir`. + - Added the `--dir` option to the `init`, `engine`, and `target` + commands. + - Copied the core configuration options (`--engine`, `--target`, + `--plan-file`, `--registry`, etc.) to the `init`, `engine`, and + `target` commands. This means that they can be specified after the + command, which is a bit more natural. It also means that the + `--registry` and `--client` options of the `target` are no longer + deprecated. + - The `init` command no longer writes out commented values for the + `deploy_dir`, `revert_dir`, or `verify_dir` settings. I think these + settings are not commonly used, and it would start to get crowded if we + also added their "reworked" variants, which will be used still less. + - Added the `alter` action to the `engine` and `target` commands to set + engine and target properties. + - Added support for setting reworked directories to the `engine` and + `target` commands. + - Reformatted the output of the `engine` and `target` command `show` + actions to include reworked directories, and to bit a bit less flat. + - Attempting to add or alter an engine with a target URI that connects to + a different engine now triggers an error. For example, you can't set + the target for engine `pg` to `db:sqlite:`. + - The `add` and `alter` actions of the `engine` and `target` commands + now create script directories if they don't already exist. + - The `add` action of the `engine` and `target` commands now creates a + plan file if one does not exist in the specified location for the + engine or target. + - Added the `deploy_dir`, `revert_dir`, and `verify_dir` methods to + App::Sqitch::Plan::Change. Each points to the proper directory for the + target depending on whether or not the change has been reworked. + - In the MySQL engine, the following URI query params will be converted + to options passed to the command-line client, if they're present: + * mysql_compression=1 => --compress + * mysql_ssl=1 => --ssl + * mysql_connect_timeout => --connect_timeout + * mysql_init_command => --init-command + * mysql_socket => --socket + * mysql_ssl_client_key => --ssl-key + * mysql_ssl_client_cert => --ssl-cert + * mysql_ssl_ca_file => --ssl-ca + * mysql_ssl_ca_path => --ssl-capath + * mysql_ssl_cipher => --ssl-cipher + [Documentation] + - Added the "Overworked" section to sqitch-configuration guide with an + example of how to move reworked change scripts into a `reworked_dir`. + [Deprecations] + - Deprecated the `set-*` actions in the `engine` and `target` commands in + favor of the new `alter` action. + - The core `--deployed-dir`, `--revert-dir`, and `--verify-dir` options + are deprecated in favor of the `--dir` option on the `init`, `engine`, + and `target` command. + +0.9992 2015-05-20T23:51:41Z + - On PostgreSQL, Sqitch now sets the `client_encoding` parameter to + `UTF8` for its own connection to the database. This ensures that data + sent to and from the database should always be properly encoded and + decoded. Users should still set the proper encodings for change scripts + as appropriate. + - Fixed test failures due to path differences on Windows. + - DateTime::TimeZone is now explicitly required in an attempt to head off + "Cannot determine local time zone" errors. + - Corrected some typos and thinkos in `sqitchtutorial-oracle`, thanks to + George Hartzell. + - Improved the script to upgrade an Oracle registry to v1.0 to support + versions prior to Oracle 12, thanks to Timothy Procter. + - Added missing closing parenthesis to the "Nothing to deploy" message. + Thanks to George Hartzell for the pull request (Issue #226). + - Replaced the unique constraint on the `script_hash` column in the + `changes` registry table with a unique constraint on `project` and + `script_hash`. This is to allow a deploy script to be used in more than + one project in a single database. This change increments the registry + version to v1.1. Thanks to Timothy Procter for the report. + - Updated the registry check constraints to have consistent names on the + engines that support them. This will make it easier to modify the + constraints in the future. + - Fixed precision issues with the registry version on MySQL and Firebird. + - Added comment to sqitch-passwords guide that MySQL::Config is required + to read passwords from the MySQL configuration files. Thanks to + Sterling Hanenkamp for the patch! + +0.9991 2015-04-03T23:14:39Z + [Improvements] + - Reduced minimum required MySQL engine from 5.6.4 to 5.1.0. Versions + prior to 5.6.4 lose the following features: + * Versions earlier than 5.6.4 is fractional second precision on + registry `DATETIME` columns. Since the ordering of those timestamps + is so important to the functioning of Sqitch, it will sleep in 100 ms + increments between logging changes to the registry until the time has + ticked over to the next second. Naturally, reverts and deploys will + be a little slower on versions of MySQL before 5.6.4, but accurate. + * Versions earlier than 5.5.0 lose the `checkit()` functions, which + would otherwise be used to emulate CHECK constraints in the registry, + as well as in user-created verify scripts, as recommended in the + MySQL tutorial, `sqitchtutorial-mysql`. + - Added a script to update the `DATETIME` columns in a MySQL Sqitch + registry that was upgraded to MySQL 5.6.4 or higher. It will be + installed as `tools/upgrade-registry-to-mysql-5.6.4.sql` in the + directory returned by `sqitch --etc`. + - Added a script to add the `checkit()` function and registry triggers to + emulate CHECK constraints to a MySQL Sqitch registry that was upgraded + to MySQL 5.5.0 or higher. It will be installed as + `tools/upgrade-registry-to-mysql-5.5.0.sql` in the directory returned + by `sqitch --etc`. + - The `init` command now throws an error when the plan file already + exists and is invalid or defined for a different project. Thanks to + Gabriel Potkány for the suggestion (Issue #214). + - All commands that take target arguments can now specify them as engine + names or plan file paths as well as target names and URIs. + - Added the `--all` option and the `$command.all` configuration variable + to the `add`, `rework`, `tag`, and `bundle` commands. This option tells + the commands to do their thing for all plans known from the + configuration, not just the default plan. + - Pass engine, target, or plan file names to the `add`, `rework`, `tag`, + and `bundle` commands` commands to specify specify one or more targets, + engines, and plans to act on. + - Added the `--change` option to the `add`, `rework`, and `tag` commands + to distinguish the change to be added, reworked, or tagged from + plan-specifying arguments, if necessary. + - Added the `--tag` option to the `tag` command to distinguish the tag to + be added from plan-specifying arguments, if necessary. + - Changed the short variant of the `--conflicts` option to the `add` and + `rework` commands from `-c` to `-x`. The `-c` option is now used as the + short variant for `--change` (and `--conflicts` has almost certainly + never been used, anyway). + - Added the `engine` and `project` variables to the execution of script + templates by the `add` command. The default templates now use it to + make their first lines one of: + * -- Deploy [% project %]:[% change %] to [% engine] + * -- Revert [% project %]:[% change %] from [% engine] + * -- Verify [% project %]:[% change %] on [% engine] + [Bug Fixes] + - DateTime::TimeZone::Local::Win32 is now required on Windows. + - The MySQL engine no longer passes `--skip-pager` on Windows, since + it is not supported there. Thanks to Gabriel Potkány for the report + (Issue #213). + - Fixed "no such table: changes" error when upgrading the SQLite + registry. + - Fixed upgrade failure on PostgreSQL 8.4. Thanks to Phillip Smith for + the report! + - Fixed an error when the `status` command `show_changes` and `show_tags` + configuration variables were set. Thanks to Adrian Klaver for the + report (Issue #219). + - Fixed `log` and `plan` usage statements to properly spell `--abbrev`. + Thanks to Adrian Klaver for the report (Issue #220). + - Fixed the formatting of change notes so that a space precedes the `#` + character whether the note was added by the `--note` option or via an + editor. + - Fixed a bug when parsing plan files with DOS/Windows line endings. + Thanks to Timothy Procter for the report (Issue #212). + - Looking up a change and tag in the database (via the `--onto` option to + `rebase` or the `--to` option to `revert`, among others) would + sometimes return the incorrect change if the change has been reworked + two or more times. Thanks to BryLo for the report! + [Documentation] + - Updated docs to be consistent in referring to the location of the system + configuration and template location as `$(prefix)/etc/sqitch`. Also + added notes pointing to the `--etc-dir` to find out exactly what that + resolves to. Suggested by Joseph Anthony Pasquale Holsten (Issue #167). + [Deprecations] + - Reverted deprecation of the database connection options. Target URIs + are still generally preferred, but sometimes you want to use a target + but just change the user name or database name. Retaining the options + is the easiest way to do this. Plus, a fair number of people have + scripts that use these options, and it seems petty to break them. Sorry + for the double-take here! The list of un-deprecated options is: + * `--db-client` + * `--db-host` + * `--db-port` + * `--db-username` + * `--db-password` + * `--db-name` + +0.999 2015-02-12T19:43:45Z + - Improved MySQL missing table error detection by relying on error codes + instead of matching a (possibly localized) error string. + - Made the registry upgrade more transparent when deploying. Sqitch is + now is a little more vigilant in checking for things being out-of-date + and updating them. + - Fixed an issue where the `status` command would return an error when + run against a an older version of the registry. + - Fixed a Postgres test failure when DBD::Pg is installed but psql is not + in the path. + - Now require Config::GitLike 1.15 to build on Windows in order to avoid + test failures when Cwd::abs_path dies on non-existent paths. + - Clarified the behavior of each `deploy` reversion mode with regard to + deploy script vs. verify script failures, and with the expectation that + deploy scripts are atomic. + - Target passwords can now be set via a single environment variable, + `$SQITCH_PASSWORD`. Its value will override URI-specified password. + - Added the sqitch-passwords and sqitch-environment guides. + +0.998 2015-01-15T22:17:44Z + - Fixed a bug in `sqitch engine update-config` where it would add data to + config files that did not previously have them, or report that data was + present in nonexistent config files. + - Added the `releases` table to the databases. This table will keep track + of releases of the Sqitch registry schema. + - The Oracle `registry` variable is now always `DEFINE`d when Oracle + scripts run. + - Added the `upgrade` command, which upgrades the schema for the Sqitch + registry for a target database. + - Added the `script_hash` column to the `changes` registry table. This + column contains a SHA-1 hash of the deploy script for the change at the + time it was deployed. For existing registries, the upgrade script sets + its value to be the same as the change ID. This value is update the + next time a project is deployed to the database. + - The error message when `deploy` cannot find the currently-deployed + change ID in the plan now includes more contextual information, + including the change name, associated tags, and the plan file name. + Suggested by Curtis Poe (Issue #205). + - Comments on Firebird registry objects are now created with the + `COMMENT` command, rather than INSERTs into catalog tables. + - Added support for "merge" events, though none are logged, yet. + +0.997 2014-11-04T22:52:23Z + [New Features] + - Added support for new target properties. In addition to the existing + `uri`, `client`, and `registry` properties, targets may also configure + these properties via the new `--set` option to and `set-*` actions on + the `target` command: + * `top_dir` + * `plan_file` + * `extension` + * `deploy_dir` + * `revert_dir` + * `verify_dir` + - Added support for new engine configuration variables. In addition to + the existing `target`, `client`, and `registry` variables, engine + configuration may also include these variables: + * `top_dir` + * `plan_file` + * `extension` + * `deploy_dir` + * `revert_dir` + * `verify_dir` + - Rationalized the hierarchical configuration of deployment targets. The + properties of any given target will now be determined by examining + values in the following order: + * Command-line options + * Target configuration + * Engine configuration + * Core configuration + * Reasonable engine-specific defaults + - Added the `engine` command to simplify engine configuration. This + complements the newly-improved `target` command. Run `sqitch engine + update-config` to update deprecated engine configurations and start + using it. + - Added the sqitch-configuration guide to provide an overview of core, + engine, and target configuration. Includes some use-case examples and + best suggested practices. + [Improvements] + - Simplified the output of `sqitch help`, and added the more important + options to it. + - Added the `--guide` option to `sqitch help` to list Sqitch guides. + - Renamed the `--db-client` option to `--client`. `--db-client` still + works, but is deprecated. + - Added the `--registry` core option for parity with `--client`, + `--top-dir`, `--plan-file`, and the rest of the hierarchical + configuration properties. + - Updated the `init` documentation to better cover all the options + processed. + - Incremented the version plan file format version to v1.0.0. No changes; + it has been stable for at least a year, so it's time. + [Bug Fixes] + - At runtime, the Vertica engine now properly requires DBD::ODBC + instead of DBD::Pg. + - The Vertica engine now supports Vertica 6, as documented. + - Fixed a warning from Type::Utils, thanks to a report from Géraud + CONTINSOUZAS. + - The `status` command once again notices if the specified database is + uninitialized and says as much, rather than dying with an SQL error. + - The `--etc-path` option works again. + [Deprecations] + - Deprecated `core.$engine` configuration in favor of `engine.$engine`. A + warning will be emitted if Sqitch sees the former. Run `sqitch engine + update-config` to update your configurations. Existing `core.$engine` + configurations will be left in place for compatibility with older + versions of Sqitch, but the `sqitch engine` command will not modify + them, so they can get out-of-sync. Run `sqitch config --remove-section + core.$engine` to remove them. + - Formally deprecated the database connection options in favor of target + URIs. If any of these options is used, a warning will be issued. They + will be dropped in v1.0: + * `--db-host` + * `--db-port` + * `--db-username` + * `--db-password` + * `--db-name` + - Formally deprecated the database connection configuration variables in + favor of target URIs. If any of these variables is used, a warning will + be issued. Run `sqitch engine update-config` to update your + configurations. Existing `core.$engine` configurations will be left in + place for compatibility with older versions of Sqitch, but the `sqitch + engine` command will not modify them, so they can get out-of-sync. Run + `sqitch config --remove-section core.$engine` to remove them. Sqitch + will cease to support them in v1.0: + * `core.$engine.host` + * `core.$engine.port` + * `core.$engine.username` + * `core.$engine.password` + * `core.$engine.db_name` + - Deprecated the `--registry` and `--client` options of the `target` + command. All target properties should now be set via the new `--set` + option, such as `--set registry=reg`. + - Formally deprecated the following options of the `add` command. They + have been replaced with the `--with`, `--without`, and `--use` options + since v0.991. Their use will emit a warning, and they will be removed + in v1.0: + * `--deploy-template` + * `--revert-template` + * `--verify-template` + * `--deploy` + * `--no-deploy` + * `--revert` + * `--no-revert` + * `--verify` + * `--no-verify` + - Dropped support for the long-deprecated (and likely never used outside + ancient tests long deleted) engine configuration variables + `core.sqlite.sqitch_db` and `core.pg.sqitch_schema`. Both have been + replaced with `engine.$engine.registry`, which applies to all engines. + - Formally deprecated the `@FIRST` and `@LAST` symbolic tags. Their use + will trigger a warning to use `@ROOT` and `@HEAD`, instead. They will + be removed in v1.0. + [Internals] + - Moved target and engine configuration from App::Sqitch and + App::Sqitch::Engine to a new class, App::Sqitch::Target. This class is + solely responsible for finding the appropriate values for attributes on + every run. The target knows what plan and engine to use, based on those + properties. App::Sqitch is now responsible solely for encapsulating + command-line options, configuration, and utilities. Classes are now + responsible for instantiating both an App::Sqitch and + App::Sqitch::Target options as appropriate. + - Updated all classes to create both Sqitch and Target objects as + appropriate. This change touched almost every class. + - Replaced attributes in App::Sqitch that were previously set from + command-line options or configuration with a single attribute, + `options`, which is a hash only of the command-line options. Classes + are now responsible for finding the proper values in config or options. + Mostly this requirement is encapsulated by the new App::Sqitch::Target + class. + - Updated the command classes to use either a "default target" derived + from command-line options, engine configuration, and core + configuration, or a target looked up by name in the configuration + maintained by the `target` command. + +0.996 2014-09-05T21:11:00Z + - Fixed one more test failure due to the introduction of "Negative repeat + count does nothing" warning in Perl 5.21.1. + - Fixed "Redundant argument in printf" warning on Perl 5.21.2. + - Switched from Digest::SHA1, which is deprecated, to Digest::SHA for + generating SHA-1 IDs. + - Switched from Mouse and Moose to Moo. Sqitch no longer depends on any + modules that use Moose, either. This results in an approximately 40% + startup time speedup. + - Loading of App::Sqitch::DateTime is now deferred until it's needed. + This is because DateTime is rather expensive to load. Since a number of + commands don't need it, it seems silly to load it in those cases. + - Now recommend Type::Tiny::XS and Class::XSAccessor for improved + performance. + - The `check` command now properly fails on a plan parse error, instead + of blindly continuing on. + - Fixed a failing test on PostgreSQL due to localization issues. Thanks + to Sven Schoberf for the report (Issue #171). + - Added the `revert.prompt_accept`, `rebase.prompt_accept`, and + `checkout.prompt_accept` boolean configuration variables. Set these + variables to false to change the default answer to the revert prompt to + "No". When rebasing or checking out, if the variables specific to those + commands are not set, Sqitch will fall back on the value of + `revert.prompt_accept`. Suggested by Graeme Lawton (Issue #164). + - The MySQL engine now sets the `$MYSQL_PWD` environment variable if a + password is provided in a target. This should simplify authentication + when running MySQL change scripts through the `mysql` client client + (Issue #150). + - The MySQL engine now reads `client` and `mysql` groups in the MySQL + configuration files for a password when connecting to the registry + database, and when the target URI includes no password. The MySQL + client already read those files, of course, but now the internal + database connection does as well (Issue #150). + - The Firebird engine now sets the `$ISC_PASSWORD` environment variable + if a password is provided in a target. This should simplify + authentication when running Firebird change scripts through the `isql` + client client. Patch from Ștefan Suciu. + - No longer passing URI query params as DBI params, because they are + already included in the DSN provided by URI::db. + - Added the Vertica engine. + +0.995 2014-07-13T22:24:53Z + - Fixed test failures due to the introduction of "Negative repeat count + does nothing" warning in Perl 5.21.1. + - Fixed more test failures when DBD::Firebird is installed but Firebird + isql cannot be found. + - Fixed registry file naming issues on Win32 for the SQLite engine, and + as well as the tests that failed because of it. + - Worked around Config::GitLike bug on Windows in the target test. + - Changed the exit value for an attempt to deploy to an up-to-date + database from 1 to 0. In other words, it no longer looks like an error + (Issue #147). + +0.994 2014-06-20T02:58:10Z + - Fixed installation failure due to missing IO::File module on Windows. + - Fixed file test failure for the Oracle engine on Windows. + - Fixed bug where namespace-autoclean: 0.16 caused errors such as + "Invalid object instance: 'yellow'". + - Fixed Oracle SQL*Plus capture test failure on Windows. + +0.993 2014-06-04T20:14:34Z + - Fixed engine loading to prefer the engine implied by the target URI + over the `core.engine` configuration variable. This means that you no + longer have to pass `--engine` when using commands that accept a target + option or argument, such as `deploy`. + - Fixed test failure when DBD::Firebird is installed but Firebird isql + cannot be found. + - Fixed issue where the revert command fails to execute the proper revert + script. This can occur when a change has been reworked in the plan, but + the reworked version of the change has not been deployed to the + database. Thanks to Timothy Procter for the report (Issue #166). + - Fixed issue with aggregating text values with `COLLECT()` on Oracle. + Thanks to Timothy Procter for the digging and invocation of an Oracle + support request (Issue #91). + - Fixed issue where SQL*Plus could not run rework scripts because of the + `@` in the file name. It now uses a symlink (or copied file on Windows) + to circumvent the problem. Thanks to Timothy Procter for the report + (Issue #165). + - Fix issue where, on first deploy, the MySQL engine would fail to notice + that the server was not the right version of MySQL. Thanks to Luke + Young for the report (Issue #158). + - Made the `checkit()` MySQL function DETERMINISTIC, to improve + compatibility with MariaDB. Thanks to Jesse Luehrs for the report + (Issue #158). + - Fixed deployment to PostgreSQL 8.4 so that it no longer chokes on the + `:tableopts`. Thanks to Justin Hawkins for the report! + +0.992 2014-03-05T00:34:49Z + - Fixed target test failures on Windows. + - Added support for Postgres-XC to the PostgreSQL engine. Sqitch registry + tables are distributed by replication to all data nodes. + - Added support to MariaDB 5.3 and higher to the MySQL engine, thanks to + Ed Silva. + +0.991 2014-01-16T23:24:33Z + - Greatly simplified determining the Firebird ISQL client. It no longer + tries so hard to find a full path, but does search through the path list + for a likely candidate between fbsql, isql-fb, and isql (or equivalents + ending in .exe on Windows). + - Removed a bunch of inappropriately pasted stuff from the Firebird + tutorial, and updated it a bit. + - `HEAD` and `ROOT` are now recognized as aliases for `@HEAD` and + `@ROOT`, when querying the database, too. That means that `revert --to + HEAD` now works the same as `revert --to @HEAD`, as had been expected + in v0.990. + - Eliminated "use of uninitialized value" warnings when database + connections fail. + - Reduced the minimum required DBD::Firebird to v1.11. + - Fixed the `--verbose` option to the `target` command. + - Eliminated more user-configuration issues in tests, thanks to + chromatic. + - Fixed test failures when the `$PGPASSWORD` environment variable is set, + thanks to Ioan Rogers's test smoker. + +0.990 2014-01-04T01:14:24Z + [New Features] + - Added new command and feature: `target`. Use it to manage multiple + database targets, each with an associated URI and, optionally, a + registry name and command-line client. Inspired by Git remotes. + - Added Firebird engine. Three cheers to Ștefan Suciu for this + contribution! + - Added support for the generation of arbitrary scripts from templates to + the `add` command. Just add template files to subdirectories of the + `templates` directory, and scripts will be created in a directory of + the same name based on those templates. + - Added `--open-editor` option (and aliases) to the `add` and `rework` + commands. This option will open the newly-added change scripts in the + preferred editor. Thanks to Thomas Sibley for the patch! + + [Improvements] + - Improved database driver loading to ensure the proper version of the + driver is required. + - Non-fatal but possibly unexpected messages -- which correspond to exit + value 1 -- now send their messages to STDOUT instead of STDERR, and + respect the `--quiet` option. Thanks to @giorgio-v for the report! + - Added or replaced the `--target` option to commands that connect to a + database to specify the name of target managed by the new `target` + command or a database URI. + - `HEAD` and `ROOT` are now recognized as aliases for `@HEAD` and + `@ROOT`, respectively, since they are disallowed as change names, + anyway, and folks often use them out of habit from Git. + + [Internals] + - Replaced the engine-specific connection attributes with three + attributes use by every engine: + * `target`: The name of a target managed by the new `target` command. + Defaults to a value stored for the `core.$engine.target` + configuration variable. If that variable does not exist, the target + falls back on the stringification of `uri`. + * `uri`: a database URI with the format `db:{engine}:{dbname}` or + `db:{engine}://{user}:{password}@{host}:{port}/{dbname}`. If its + value is not passed to the constructor, a `uri` value is looked up + for the associated `target`. If `target` is not passed or configured, + or if it has no URI associated with it, the `config.$engine.uri` + configuration variable is used. If that value does not exist, the URI + defaults to `db:$engine:`. In any of these cases, if any of the + `--db-*` options are passed, they will be merged into the URI. + * `registry`: the name to use for the Sqitch registry schema or + database, where Sqitch's own data will be stored, as appropriate to + each engine. If its value is not passed to the constructor, a + `registry` value is looked up for the associated `target`. If + `target` is not passed or configured, or if it has no registry + associated with it, the `config.$engine.registry` configuration + variable is used. If no value is found there, it defaults to an + engine-specific value, usually "sqitch". + + [Bug Fixes] + - Fixed a bug when installing under local::lib. Thanks to Thomas Sibley + for the pull request! + - Eliminated "Wide character in print" warnings when piping the `log` + command. + - Documented that reworked changes do not have their verify tests run by + the `verify` command. They do run when using the `--verify` deploy + option. + - Removed the documentation for the `add.with_deploy`, `add.with_revert`, + and `add.with_verify` configuration variables, which were never + implemented. + + [Deprecations] + - Deprecated engine-specific connection attributes and configuration + variables. See the "Internals" section for their replacements. The + deprecated options are: + * `core.$engine.username` + * `core.$engine.password` + * `core.$engine.db_name` + * `core.$engine.host` + * `core.$engine.port` + * `core.$engine.sqitch_schema` + * `core.$engine.sqitch_db` + - Deprecated all command-specific options with the string "target" in + them, such as `--to-target`, `--upto-target`, etc. They have been + replaced with options containing the string "change", instead, such as + `--to-change` and `--upto-change`. Few people used these options, + preferring their shorter aliases (`--to`, `--upto`, etc.). + - Deprecated the `--deploy-template`, `--revert-template`, and + `--verify-template` options to the `add` command. They are replaced + with a single option, `--use` which takes a key/value pair for the + script name and template path. This makes it useful for arbitrary + script generation, as well. + - Deprecated the `--deploy`, `--revert`, and `--verify` options to the + `add` command, as well as their `--no-*` variants. They are replaced + with two new options, `--with` and `--without`, to which a script name + is passed. These are useful for arbitrary script generation, as well. + - Deprecated the `add.deploy_template`, `add.revert_template`, and + `add.verify_template` configuration settings. They have been replaced + with a section, `add.templates`, which is more general, and supports + arbitrary script generation, as well. + + [Incompatibilities] + - Removed the undocumented `--test` option to the `add` command. + - Changed the meaning of `--target` from specifying a change to + specifying a deployment target. Use the new `--change` option to + specify a change. + +0.983 2013-11-21T21:50:12Z + - Fixed "Use of uninitialized value" in the MySQL engine. Thanks to + Jean-Michel REY for the report. + - All tests now protect against failures due to the presence of the + `$SQITCH_CONFIG` environment variable (issue #114). + - The installer now respects the `distdir` option to `Build.PL` when + searching for existing templates. Important for packaging. + - Fixed the error "Table 'sqitch.changes' doesn't exist" when deploying + to a MySQL database that exists but has not been initialized. Thanks to + Jean-Michel REY for the report! + - Refactored the handling of the C<--log-only> option so it sets an + engine attribute, rather than passing the flag to a whole stack of + method calls. + - Fixed "Argument "en_us" isn't numeric" error on Windows. + - Now using `LC_ALL` instead of `LC_MESSAGES` when setting the locale, as + the latter is not present on Windows. + - The sqitch-pg RPM now requires DBD::Pg 2.0.0 or higher. + - Improved handling of invalid command names so that the error message is + less ambiguous when triggered by a Perl parse error. + - Added `-m` as an alias for `--note`, for you Git folks out there. + - Added exception handling to the Postgres and Oracle engines to avoid + unexpected errors when deploying to a database that has not been + deployed to before. + - Updated detection of an uninitialized database to double-check with the + engine that it really thinks it's uninitialized, not just that the + "changes" table is missing. This should catch the case where the + database has its own "changes" table unrelated to Sqitch. + +0.982 2013-09-11T18:26:07Z + - Errors thrown by Template toolkit are no longer silently ignored. + - Variables passed to change templates are now cloned before the + execution of each template. This prevents one template from deleting + variable values another template might also need. + - Fixed "The getpwnam function is unimplemented" errors on Win32. + - No longer runs revert scripts when deploying with `--log-only` and a + verify script fails, as that could lead to data loss (yikes!). Thanks + to BryLo for the report (issue #112). + +0.981 2013-09-06T00:22:26Z + - Now use Encode::Locale to try to decode the user's full name from the + system encoding when fetched from the system on all OSes. Note that + this is not necessary if the `user.name` config is explicitly set, as + recommended. Issue #107. + - Removed the special-case handling of the user's full name fetched from + the system on OS X. + - Added call to `sleep` to test in an attempt to fix SQLite failures. + - The SQLite engine now requires that the SQLite client be 3.3.9 or + later, for support of the `-bail` option. + - Bug fix: The MySQL engine now properly uses the host, port, and + password options when connecting to the database. Thanks to vreb87 for + the report! + +0.980 2013-08-28T21:40:00Z + - Changed the default SQLite Sqitch database name from + `$dbname-sqitch.$suffix` to `sqitch.$suffix`. The `$suffix` still + comes from the destination database name. This breaks compatibility + with previous releases. If you need the old name, set it with + `sqitch config core.sqlite.sqitch_db $dbname`. + - Fixed encoding of the user's full name when fetched from the system on + OS X. Thanks to Tomohiro Hosaka for the pull request! + - Fixed test failures when DBD::SQLite is installed but compiled with + SQLite 3.7.10 or lower. + - Fixed a bug where declaring a dependency on a reworked change would + incorrectly result in the error "Key "foo" matches multiple changes". + Thanks to BryLo for the report (issue #103). + - Modified tests to allow them to run in parallel without stomping on + each other. + - Bundling of options, such as `-vvv`, now works properly (issue #108). + - Added alias `--get-regexp` for `--get-regex` to the `config` command. + This brings it in line with the documentation for the `config` command + (Issue #110). + - Fixed all of the `config` command actions that contain a dash so that + they actually work. Thanks to Ștefan Suciu for the report (issue #110). + - All leading and trailing white space is now trimmed from plan notes, + rather than just vertical white space. Thanks to Ronan Dunklau for the + report (issue #106). + - The `status` command now notices if the specified database is + uninitialized and says as much, rather than dying with an SQL error + (issue #109). + - When reading the user's username from the system Sqitch now uses + Encode::Locale to try to decode the value from the system encoding. + Issue #107. + - Compatibility change: Changed the location and name of script template + files. Previously they were called `deploy.tmpl`, `revert.tmpl`, and + `verify.tmpl`, and they lived in the `templates` subdirectory of the + system-wide and user-specific configuration directories. They now live + in subdirectories of the `templates` directory named for each action + (deploy, revert, and verify), and with file names matching engine names + (`pg.tmpl`, `sqlite.tmpl`, `oracle.tmpl`, and `mysql.tmpl`). The + installer will move old files from the system-wide config directory + (`sqitch --etc-path`) to their new homes, named `pg.tmpl` and + `sqlite.tmpl`. It assumes no customizations exist for Oracle. If that's + not true in your case, simply copy the `pg.tmpl` files to + `oracle.tmpl`. + - Added the `--template-name` option to the `add` command. By default, it + looks for templates named for the current engine. The option allows for + the user of task-specific templates. For example, if you create + templates named `createtable.tmpl` in the `deploy`, `revert`, and + `verify` subdirectories of `~/.sqitch/templates`, You can specify + `--template-name createtable` to use those templates when adding a + change. + - Added the `--exists` option to the `show` command. + - Fixed the `--set` option to the `add` command so that duplicate keys + have their values passed to the template as an array, as documented. + - If Template::Toolkit is installed, the `add` command will use it for + processing templates instead of Template::Tiny. This makes it easy to + upgrade the templating environment just by installing a module. + +0.973 2013-07-03T13:47:22Z + - Now Require DBD::SQLite compiled with SQLite 3.7.11 or higher. It + always has, but now it throws a meaningful exception if an older + version is compiled into DBD::SQLite. Thanks to Damon Buckwalter for + the report. + - When a deploy fails because of missing dependencies, the list of + missing dependencies no longer contains duplicates. Thanks to Damon + Buckwalter for the report. + +0.972 2013-05-31T23:26:52Z + - Fixed test failures on Windows. + - Fixed locale configuration on Windows so that `sqitch` will actually + run, rather than exiting with an error about `LC_MESSAGES` not being + set. + - Fixed a test hang on Windows when DBD::Oracle is installed but the + Oracle libraries (`OCI.dll`) are not or cannot be found. This was + triggering a UI dialog that did not dismiss itself. Using Win32::API + to work around this issue. Thanks to Jan Dubois for the fix. + +0.971 2013-05-18T21:08:51Z + - Removed most uses of the smartmatch operator, since as of Perl 5.17.11 + it is marked as experimental, and silenced the warning where it is + still used. + - Added 0.1s sleep between logging changes back-to-back in the engine + tests, mostly to try to get SQLite to generate different timestamps. + Pretty sure the recent test failures have been due to the passage of + less than a millisecond between the two inserts. + - Added the `shell` and `quote_shell` methods to Sqitch.pm for shelling + out a command. + - Sqitch now shells out to an editor when opening a file for the user to + edit. For example, if the `$EDITOR` environment variable is set to + `"emacs -nw"`, it will now work. Thanks to Florian Ragwitz for the + report (issue #80). + - Removed the pod-checking tests from the distribution. + +0.970 2013-05-09T00:21:06Z + - Fixed the default ordering of changes displayed by the `plan` command. + They are now ascending by default. + - Switched to PerlIO::utf8_strict for fast character encoding and + decoding. + - The help emitted when an unknown option is passed to `sqitch` now + consists of a usage statement and brief table of options, rather than + the entire man page. + - Added the project name in a header to the output of the `plan` command. + - Added the Oracle engine. + - Added `sqitchtutorial-oracle.pod`, a Oracle-specific variant of + `sqitchtutorial.pod`. + - Added missing version declaration to the App::Sqitch::Plan::* modules. + - Devel::StackTrace 1.30 is now properly required (it was previously + recommended). + - The `--show-tags` and `--show-changes` options to the `status` command + now show the changes when the project plan cannot be found (issue #90). + +0.965 2013-04-23T16:25:59Z + - Fixed failing test due to line-ending character variations on Windows. + Many thanks to Jan Dubois for the testing help. + - Replaced all uses of `$/` in output to `"\n"`. Thanks to Jan Dubois for + pointing out the incorrect use of `$/`. + - Fixed build error that prevented installation on Perl 5.10 when the + parent module was not installed. + +0.964 2013-04-15T18:47:30Z + - Fixed test failures on Perl versions lower than 5.14 when DBD::SQLite + or DBD::Pg is not installed. + - Removed DBD::SQLite from the list of build dependencies. + - Fixed test failures due to encoded (wide-character) warnings on + triggered on systems with non-english locales. Thanks to Alexandr + Ciornii for the smoke testing that revealed this issue. + - Removed overriding of Throwable's `previous_exception` in + App::Sqitch::X on Throwable 0.200007 and higher, where it is no longer + needed. + - Changed test comparing file contents that fails on Windows to do a + looser comparison and hopefully fix the test failure. + +0.963 2013-04-12T19:11:29Z + - Fixed a test failure when Git is in the execution path and the test is + not run from a Git checkout. + - Added `plan` to `sqitchchanges`, the contents of which are shown when + Sqitch is run with no command. + - Removed the unique constraint on tag names in the database, as it + prevented two projects from having the same tag name. Replaced it with + a unique constraint on the project and tag names. Folks with production + PostgreSQL installs should run these queries: + ALTER TABLE sqitch.tags DROP CONSTRAINT tags_tag_key, ADD UNIQUE(project, tag); + COMMENT ON COLUMN sqitch.tags.tag IS 'Project-unique tag name.'; + - Fixed failing tests when DBD::SQLite is not installed. + - Removed dependency on Git::Wrapper. The `checkout` command does things + very simply, and we already have tools for running command-line + applications. So we just take advantage of that. The code is no more + complicated than it was before. + - Added the `core.vcs.client` configuration setting. Defaults to `git` + (or `git.exe` on Windows). + +0.962 2013-04-10T17:10:05Z + - Fixed failing test on Perl 5.12 and lower. + - Fixed the French translation by re-encoding it in UTF-8 (Ronan + Dunklau). + - Fixed the loading of the editor with placeholder text to properly + encode that text as UTF-8 (Ronan Dunklau). + +0.961 2013-04-09T19:21:15Z + - Fixed error when running on PostgreSQL 9.0. + - Added support for PostgreSQL 8.4. + - Fixed the SQLite tests to skip the live tests when `sqlite3` cannot be + found. + - Fixed the Postgres tests to skip the live tests if `psql` cannot be + found or cannot connect to the database. + - Fixed the `checkout` test to skip tests that depend on Git and Git is + not found in the path. + - Fixed test failures on Windows (hopefully). + - Made the order of commented configuration variables in the project + configuration file deterministic. It will now always be the same order + as specified by the engine class. This fixes test failures on Perl + 5.17. + - Fixed encoding issue that caused test failures on Perl 5.17. + - Requiring Devel::StackTrace 1.30, as earlier versions can + intermittently suppress errors. + - Added hack to `App::Sqitch::X::hurl()` to work around a bug in + Throwable that prevents `previous_exception` from being set half the + time on v5.17. + +0.960 2013-04-05T23:04:35Z + - Removed `-CAS` from the shebang line on Perl 5.10.0. This is to + eliminate `Too late for "-CAS" option` errors. This means that UTF-8 + semantics will be suboptimal on Perl 5.10.0. Consider upgrading to 5.12 + or higher. + - Added the `checkout` command. Pass it the name of a VCS branch, and it + will compare the plans between that branch and the current branch, + revert to the last common change, check out the branch, and then + redeploy. This makes it easy to switch between working branches that + have different sets of commits. Git-only for now. Idea and code by + Ronan Dunklau. + - The `rebase` command no longer fails if the database is already + reverted, but just makes a note of it and goes on to the deploy. + - Added the `plan` command. It's like `log`, but shows a list of changes + in the plan, rather than events recorded in the database. + - Added `search_changes()` to Plan. Used by the `plan` command. + - Added the `--oneline` option to the `log` command. + - Allow tagging of an arbitrary change, not just the last change in the + plan, by passing a change specification (name, ID, or tag) as the + second argument to the `tag` command. + - Updated error messages to note that blank characters are not allowed in + project, change, or tag names. + - Factored most of the engine-specific code into + App::Sqitch::Role::DBIEngine. Future DBI-based engines should be able + to use this role to handle most of the work. + - Factored the live engine tests int `t/lib/DBIEngineTest`. Future + DBI-based engines can use this module to do all or most of the live + testing. + - Added the SQLite engine. The Sqitch metadata is stored in a separate + file from a database, by default in the same directory as the database + file. + - Added `sqitchtutorial-sqlite.pod`, a SQLite-specific variant of + `sqitchtutorial.pod`. + +0.953 2013-02-21T23:37:57Z + - Fixed test failure in `t/engine.t` triggered by a clock tick. + - Changed the verify template to end with `ROLLBACK` rather than + `COMMIT`. This it to encourage folks to make no lasting changes in + verify tests. + - Fixed exception triggered on an attempt to revert or rebase `--to` a + change that does not exist in the database. + - Added recommendation for Pod::Simple to the build process. + - Added the `--etcdir` build option to specify the directory in which + configuration and template files should be installed. Defaults to the + `etc/sqitch` subdirectory of the `--prefix`, `--install_base`, or + Perl's prefix. + - Added the `--installed_etcdir` build option. This is used to set + the location of the system etc directory. Defaults to the value of + `--etcdir`. + - When building with `--prefix` or `--install_base`, and without + `--etcdir`, the configuration files and tmeplates are now installed + into `etc/sqitch` in that directory, rather than just `etc`. This is to + enable packaging systems to move the directory to the proper location. + +0.952 2013-01-12T00:02:54Z + - Switched from Moose to Mouse whever possible. Speeds load and runtime + 20-30%. Thanks to Michael Schwern for the pull request! + +0.951 2013-01-08T00:21:58Z + - Fixed double "@" displayed for tags in the output of `revert`. + - Fixed reversion of reworked changes to run the original revert script, + rather than the reworked script. + - Added `is_reworked` accessor to App::Sqitch::Plan::Change. + - Changed the behavior determining the file name to use for reworked + change scripts. It now looks for a deploy script using the name of any + tag between the reworked instances of a change and selects the first + one it finds that exists. This will allow Sqitch to find the proper + script name even if new tags have been added to the plan (issue #70). + +0.950 2013-01-03T23:09:42Z + - Fixed the "Name" header in `sqitch-rebase` so that it will actually + show up on the CPAN search sites. + - Fixed test failure triggered by the passage of time in `t/engine.t`. + - At the start of a `deploy`, if the most recently deployed change has + any unlogged tags (that is, tags added since the last `deploy`), they + will be logged before the `deploy` continues (issue #60). + - Added the `--no-log` option to `deploy`, `revert`, and `rebase`. This + causes the changes to be logged as deployed without actually running + the deploy scripts. Useful for an existing database that is being + converted to Sqitch, and you need to log changes as deployed because + they have been deployed by other means in the past. + - Now check that dependencies are required for all changes to be deployed + or reverted before deploying or reverting anything, rather than + checking dependencies for each change just before deploying or reverting + it. This allows a or revert deploy to fail sooner, with no database + changes, when dependencies are not met. + - The `deploy` command now checks that no changes its about to deploy are + already deployed. + - Added `--mode` to the `rebase` command. + - Added the `--verify` option to `deploy` and `rebase`. Specify this + option to run the verify script, if it exists, for each change after it + is deployed. If the verify script dies, the deploy will be considered a + failure and the requisite reversion (as specified for `--mode`) will + begin. + - Added the `verify` command, which verifies that a database is valid + relative to the plan and each deployed change's verification scripts. + - Changed the format of the list of changes output by `deploy` and + `revert` so that each now gets "ok" or "not ok" printed on success or + failure. + - Added short aliases for commonly-used core options: + * -f for --plan-file + * -v for --verbose + * -h for --db-host + * -p for --db-port + +0.940 2012-12-04T05:49:45Z + - Fixed tests that failed due to I18N issues, with thanks to Arnaud + (Arhuman) ASSAD! + - Localized messages are now properly encoded in UTF-8. Thanks to Ronan + Dunklau for the report (issue #46) and to Guido Flohr for details on + how to address the issue. + - The variables defined for the `add`, `deploy`, and `revert` commands + now have the case of there names preserved if Config::GitLike 1.10 or + later is installed. Thanks to Ronan Dunklau for the report (issue #48) + and to Alex Vandiver for the case-preserving update to Config::GitLike. + - Attempting to run `sqitch` with no command now outputs the list of + supported commands (`sqitchcommands`), rather than the list of core + options. Thanks to BryLo for the suggestion. + - Changed the plan parser so that it no longer changes the order of + changes based on the dependency graph. Unfortunately, this meant that + the order could change from one run to another, especially if new + changes were added since the last deploy. The planner now throws an + exception if the order in the plan is wrong, and suggests that the user + move changes in the plan file to get it to work properly. + - Fixed bug where the `core.plan_file` configuration variable was + ignored. + - Improved error handling when deploying and reverting a change. If the + change successfully deployed but the logging of the deployment to the + database failed, there was just a rollback message. Sqitch will now + emit the underlying error *and* run the revert script for the + just-deployed change. + - Modified the text hashed for change and tag IDs. Both now include the + note, if present, the ID of the preceding change, and the list of + dependencies. The result is that, when a change is modified or moved in + the plan, it gets a new ID ID. The upshot is that things *must* be in + order for a deploy to succeed. Existing deployments will automatically + have their IDs updated by the `deploy` command. + - Changed the `revert` command so that it *only* fetches information about + changes to be reverted from the database, rather than the plan. + - Deprecated the `@LAST` and `@FIRST` symbolic tags. With `revert` now + fetching change information from the database, there is no longer a + need to specify that changes be found in the database. It's possible + some other way to search database changes will be added in the future, + but if so, it will be less limiting than `@LAST` and `@FIRST`, because + it will likely allow searches by literal tags. + - Added the `rebase` command. This command combines a `revert` and a + `deploy` into a single command, which should allow for more natural + deployment testing during development. `sqitch rebase @HEAD^` should + become a common command for database developers. + - Duplicate values passed via `--requires` and `--conflicts` in the `add` + and `rework` actions are now ignored. + - The `add` command now throws an exception if `--template-directory` is + passed or specified in the configuration file, and the specified + directory does not exist or is not a directory. Thanks to Ronan Dunklau + for the report! (Issue #52). + - The `revert` command now prompts for confirmation before reverting + anything. The prompt can be skipped via the `-y` option or setting the + `revert.no_prompt` configuration variable. Works for rebase, too, which + reads `rebase.no_prompt` before `revert.no_prompt`.' (Issue #49.) + - Added the `show` command, which show information about changes or tags, + or the contents of change script files. (Issue #57.) + - Renamed the `test` scripts and planned command to `verify`. + +0.938 2012-10-12T19:16:57Z + - Added a primary key to the PostgreSQL `events` table, which should make + it easier to support replication. + +0.937 2012-10-09T21:54:36Z + - Fixed the `--to` option to `deploy` and `revert`, which was ignored + starting in v0.936. + +0.936 2012-10-09T19:11:5Z2 + - Added `--set` option to the `deploy` and `revert` commands. Useful for + setting database client variables for use in scripts. Used by the + PostgreSQL engine. + - Merged the contents of `dist/sqitch-pg.spec` into a subpackage in + `sqitch.spec`. This allows both RPMs are created from a single build + process. Simplifies things quite a bit and improves the flexibility for + adding other engines in the future. + - Reduced required Perl version from 5.10.1 to 5.10.0. + - Fixed inconsistent handling of command options with dashes where some + were ignored. + - The bundle command now properly copies scripts for changes with slashes + in their names -- that is, where the scripts are in subdirectories. + +0.935 2012-10-02T19:21:05Z + - Updated `dist/sqitch-pg.spec` to require `postgresql` rather than + "postgresql91". The version doesn't matter so much. + - All known Windows issues and failures fixed, with many thanks to Randy + Stauner for repeatedly running tests and supplying patches: + - Fixed "'2' is not recognized as an internal or external command, + operable program or batch file" error on Windows. + - Fixed multiple errors detecting Windows. The OS name is "MSWin32", + not "Win32". The test failure thus addressed was the setting of the + DateTime locale. + - Fixed failing tests that were incorrectly comparing subprocess errors + messages on Windows + - Fixed bug in `bundle` where a file would be re-copied even if the + source and destination had the same timestamps, as they seem to do + during tests on Windows. Patch from Randy Stauner. + - Fixed failing test that failed to include `.exe` in a file name on + Windows. Patch from Randy Stauner. + - Added French translation, with thanks to Arnaud (Arhuman) ASSAD! + +0.934 2012-09-28T16:43:43Z + - Fixed typo in error handling that prevented an I/O error message from + being properly emitted. + +0.933 2012-09-27T18:04:53Z + - The `init` command no longer fails if `--top-dir` does not exist. It + creates it. + - Yet another attempt to fix "List form of pipe open not implemented" bug + on Windows. + +0.932 2012-09-26T21:32:48Z + - One more attempt to fix "List form of pipe open not implemented" bug on + Windows. + +0.931 2012-09-25T19:09:14Z + - Now properly require Text::LocaleDomain 1.20. + - Stubbed out French and German localization files. Translators wanted! + - Added LocaleTextDomain dzil support (no impact on distribution). + - Fix "List form of pipe open not implemented" bug on Windows by using + Win32::ShellQuote to quote commands. + +0.93 2012-08-31T22:29:41Z + - Added forward and reverse change references. Append ^ to a change + reference to mean the change before, or ~ to mean the change following. + Use ~~ and ^^ to select two changes forward and back, and ~n and ^n, + where n is an integer, to select that number of changes forward or + back. Idea stolen from Git, though the meanings of the characters are + different. + - Added the @FIRST and @LAST symbolic references to refer to the first + and last changes deployed to the database, respectively. These vary + from the existing @ROOT and @HEAD symbolic references, which refer to + the first and last changes listed in the plan. + - Updated the tutorial to use the new symbolic references and ^ and ~ + qualifiers where appropriate. + - The messages output by the `deploy` and `revert` commands now show the + resolved name of the `--to` target, rather than the value passed to + `--to`. This is most useful when using a symbolic reference, so you + can see what you're actually deploying or reverting to. + +0.922 2012-08-30T17:41:59Z + - Loosened constraint to disallow only `/[~^/=%]/` before digits at the + end of name. This allows, for example, a tag to be named "v1.2-1". + - Added the `bundle` command to the documentation displayed by `sqitch + help`. + - Updated the mention of the `bundle` command in the main `sqitch` + documentation. + +0.921 2012-08-30T00:09:56Z + - Made Win32::Locale required only on Windows. + - Fixed some module minimum version requirements so that dependencies + will be properly listed in `Build.PL`. + +0.92 2012-08-28T23:14:37Z + - Added the `bundle` command. + - Attempts to deploy a project with a different name or URI than + previously registered now throws an exception. + - Added UNIQUE constraint to `projects.uri` in the PostgreSQL Sqitch + schema. + - Added ON UPDATE actions to foreign key constraints in the PostgreSQL + Sqitch schema. + +0.913 2012-08-28T17:31:29Z + - Fixed oversight in test that still relied on `$ENV{USER}` instead of + `Sqitch->sysuser`, + +0.912 2012-08-27T21:23:19Z + - Fall back on `Sqitch->sysuser` when looking for the PostgreSQL user, + rather than just `$ENV{USER}`. The method does a lot more work to find + the system user name. This will hopefully also fix test failures on + systems where `$ENV{USER}` is not set. + - Use Win32::Locale to set the locale on DateTime objects on Windows. + +0.911 2012-08-23T19:19:17Z + - Fixed more platform-specific test failures in `t/base.t`. + - Increased liklihood of finding a user's full name on Windows. Thanks to + H. Merijn Brand for testing. + +0.91 2012-08-23T00:37:36Z + - Moved `requires` and `conflicts` array columns from the `changes` table + to an new table, `dependencies`, where there is just one per row. + - Requirements are now checked before reverting a change. If the change + is depended on by other changes, it will not be reverted (Issue #36). + - Fixed bug where the `status` command would show changes and/or tags + from other projects when `--show-tags` or `--show-changes` were used. + - Fixed test failures on Windows. + - Added more ways to look up the current username to minimize the chances + that none is found. + - Added Windows-specific way of finding the current user's full name, + since the existing approach died on Windows. + - Windows-specific modules are no longer required, but are recommended on + Windows. They will be listed by `./Build` and added to the "recommends" + section of the the generated `MYMETA.*` files on Windows. + - Fixed a bug where dependencies on other projects would be rejected + in calls to `add` and `rework`. + +0.902 2012-08-20T21:14:08Z + - Fixed another occasional test failure due to a clock tick in `t/pg.t.` + - Fixed test failures in `t/status.t` on systems without DBD::Pg. + +0.901 2012-08-20T19:31:03Z + - Fix test failure in `t/status.t` caused by failing to ignore a + pre-existing `~/.sqitch/sqitch.conf` configuration file. + - Eliminated "Use of uninitialized value in length" warnings. + +0.90 2012-08-18T00:05:41Z + - Added `dist/sqitch.spec`. This file was created to generate an RPM for + CentOS 6.1. + - Added `dist/sqitch-pg.spec` to use for creating RPMs for Sqitch with + PostgreSQL support. + - Fixed an occasional test failure due to a clock tick in `t/pg.t.` + - Switched to Dist::Zilla for creating the distribution. For end-users, + this just means that `Build.PL` is now a generated file. + - Required module versions are now declared in code. This is so that they + are enforced at runtime, and also so that they will be picked up by + Dist::Zilla for inclusion in the generated `Build.PL` and `META` files.x + - Added support for declaring dependencies (required and conflicting + changes) from other Sqitch projects. This allows one project to depend + on changes from another. The syntax is `--requires $projname:$change`. + This use of the colon required a few changes to the Plan syntax: + + Pragmas may now appear only in the first "header" section of the + plan, separated from the changes in the "body" of the plan by a blank + line. + + Required dependencies no longer begin with ":". Conflicts still must + begin with "!". + + Object names may no longer contain ":", as it is used for project + specification. + + Project-qualified dependencies are supported by the project name + appearing before the change name, separated by a colon. + - Added App::Sqitch::Plan::Depend, an object to parse, represent, and + serialize dependencies. + - The plan parser does not validate changes required from other projects, + as it has no access to the plans from those projects. + - The engine interface validates cross-project dependencies before + deploying changes. + - Project data is not included in the Sqitch metadata tables in the + database. There is a table for all known projects, as well as foreign + key references in the `changes`, `tags`, and `events` tables. + - Project information is now displayed in the output of `sqitch status` + and `sqitch log` (in some formats). + - Added `--project` option to `sqitch status` to identify the project for + which to display the status. Defaults to the current project, if there + is one, or to the project in the database, if there is only one + registered project. + - Added `--project` option to `sqitch log` to allow searching for events + from projects matching a regular expression. + - Now require Config::GitLike 1.09 for its improved character encoding + support. + - Dependencies can now be declared as SHA1 hash IDs, including for IDs + from other projects. + - Fixed change and tag name validation to count "_" as a non-punctuation + character, and therefore able to be used at the beginning or end of + names. + - Replaced the `appuser` change in `sqitchtutorial` with `appschema`. + This simplifies things, since users are global objects in PostgreSQL, + while schemas are not. As a result, a bunch of irrelevant code was + removed from the tutorial. + +0.82 2012-08-03T21:25:27Z + - Now require Moose 2.0300, since MooseX::Role::Parameterized, which + requires Role::HasMessage, requires it, anyway, + - Fixed test failure in `t/pg.t` when running on Test::More 0.94. + - Require POSIX in `t/datetime.t` to fix test failure with CentOS 6 + Perl. Not sure why it did not fail anywhere else, but it's harmless + enough to make sure it's loaded early. + +0.81 2012-08-03T11:34:46Z + - Removed wayward `/l` from a regular expression, which breaks Perls + earlier than 5.14, and is not needed anyway. + - Fixed error in `log` that caused invalid output on Perls earlier than + 5.14. Seems that `return` is required for `when` statements meant to + return a value, and postfix `when` is not supported in Perl 5.10. + +0.80 2012-08-01T21:54:00Z + - Added the `log` command to `sqitchcommands.pod`, which is shown as the + output of `sqitch help`. + - Added `user.name` and `user.email` configuration variables. + - Now using `user.name` and `user.email`, rather than the system or + database user name, to log the user committing changes to a database. + - Database-specific options are now prefixed with `--db-`. + - Added "raw" format to App::Sqitch::DateTime. It is ISO-8601 format in + UTC. + - Modified the "raw" log format to use the raw DateTime format. + - Added timestamp and planner info to the plan. This is additional + metadata included in every change and tag: The planner's name and email + address and the current timestamp. This makes it easier to audit who + added changes to a plan and when. + - Added the `--note` option to the `add`, `rework`, and `tag` commands. + - For consistency throughout, renamed all attributes and options from + "message" and "comment" to "note", which is shorter and better reflects + their purpose. + - The planner's name and email address, as well as the plan time and + note, are now stored in the database whenever changes or tags are + committed and logged. + - Renamed various database columns to be more consistent, with the terms + "commit", "plan", and "note". + - Added `requires` and `conflicts` columns to the events table, so that + they can become available to the `log` command. + - Various `log` format changes: + * Renamed %n (newline) to %v (vertical space) + * Renamed %c to %n (change name) + * Replaced %a (committer name) with %c (committer info). It takes an + optional argument: + + "name" or "n" for committer name + + "email" or "e" for committer email + + "d" or "date" for commit date + + "d:$format" or "date:$format" for formatted commit date + * Added %p (planner info). It takes an optional argument just like + "%c" does: + + "name" or "n" for planner name + + "email" or "e" for planner email + + "d" or "date" for plan date + + "d:$format" or "date:$format" for formatted plan date + * Added special argument to "%C", `:event", which returns a color based + on the value of the event type: + + Green for "deploy" + + Blue for "revert" + + Red for "fail" + * Added "%r" and "%R" for lists of required changes. + * Added "%x" and "%X" for lists of conflicting changes. + * Added "%a" to display an unlocalized attribute name and value. + * Added "planner", "committer", "planned", and "email" arguments to %_. + * Documented that the dates can take CLDR or strftime formats, too. + * Added the %s, %b, and %B format for "subject", "body", and raw body + akin to Git. The values are taken from the note value, if available. + * Added committer email addresses to default formats. + * Added plan data to default formats. + * Added note data to default formats. + * Added lists of required and conflicting changes to the "raw" and + "full" formats. + * Switched to event-driven colors for event types and change IDs in + default formats. + * Added color to the event type and change ID output in the "raw" + format. + - Added detailed descriptions of the default formats to `sqitch-log.pod`. + - Updated the Change object to encode and decode vertical whitespace in a + note, so that all data remains on a single line for each object in the + plan file. + - Now require a note when adding, reworking, or tagging a change. If + `--note` is not specified, an editor will be launched and the user + prompted to write a note. This is similar to how `git commit` behaves, + and encourages documentation of changes. + - Added required "project" and optional "uri" pragmas to the plan. + - Added `--project` and `--uri` attributes to the `init` command. + - Removed the `core.uri` configuration variable and corresponding core + `--uri` option (since it has been replaced with the `init` command's + `--uri` option. + - Command-line arguments are now all assumed to be UTF-8, and are parsed + as such. + - Added workaround to force the configuration file to be written and read + as UTF-8. Requires an unreleased version of Config::GitLike to actually + work properly. + - Text passed to a pager (as when running `sqitch log`) is now encoded in + UTF-8. + - Fixed `--quiet` option so that it properly trumps `--verbose`. + +0.71 2012-07-12T15:30:27Z + - Updated the example `sqitch log` output in `sqitchtutorial`. + - Changed the terms "actor", "agent" to "committer" throughout the API + and output. + - Renamed the `events` table columns from `logged_at` and `logged_by` to + `committed_at` and `committed_by`. + +0.70 2012-07-12T13:24:13Z + - Changed the `current_changes()` and `current_tags()` Engine methods so + that they return iterator code references instead of lists. + - Added the `search_events()` Engine method, to search the event log. + - Added the `pager` attribute and `page()` methods to App::Sqitch. + - Added support for `strftime:` and `cldr:` options to the `status` + command's `--date-format` option. + - Added the `log` command. + - Added the `strftime:$string` and `cldr:$string` options to + `--date-format` in the `status` and `log` commands. + +0.60 2012-07-07T11:12:26Z + - Removed some discussion of VCS integration, since it is not yet + implemented, and it may be a while before it is. + - Added `sqitchcommands`, documentation of the most common Sqitch + commands, and fixed `--help` to show it. + - Fixed `--man` to show the sqitch command documentation. + - Fixed error handling for unknown commands, so that it displays a + message saying the command is unknown, rather than a stack trace. + - Adding a change after a tag now also inserts a blank line into the plan + between the tag and the new change, for nicer plan file formatting. + - Added the `status` command. + - Added App::Sqitch::DateTime, a DateTime subclass with named formats. + +0.51 2012-07-04T18:34:07Z + - Added Role::HasMessage to the list or requirements in `Build.PL`. Was + an oversight that it was omitted in v0.50. + - Removed the `--dry-run` option. It was completely ignored. Maybe it + will return someday. + - Removed `fail()`, `bail()`, `unfound()`, and `help()`. It's better for + commands not to exit, so have them throw exceptions in the appropriate + places, instead. + - Replaced all uses of Carp and non-exception handling uses of `die` with + our own localized exceptions. + - Localized all output and exception messages. + +0.50 2012-07-03T19:55:20Z + - Require a plan file. + - Renamed "steps" to "changes". + - New plan file spec. + + Tags are just labels on a particular change, no longer a list of + changes. + + Dependencies now specified in the plan file, not in the deploy + script. + + Changes can be specified as deploys or reverts, though reverts + are not currently supported. + + Changes can be specified with an optional leading `+` for deploy or + `-` for revert, which will eventually be important for conflict + management. + + Dependencies can be specified as other change names, tags, or a + change as of a tag (e.g., `foo@beta`). + + Pragmas can be specified with a leading `%`. Only `%syntax-version` + is currently recognized; all others are ignored. + - Renamed the `add-step` command to just `add`. + - Added the `tag` command. + - Added the `revert` command. + - Added the `rework` command. + - Added exception objects and started using them. + - Added localization support and started using it. + - Added IDs to changes and tags. These are SHA1s generated from the return + value of the new `info` method, which describes the change or tag. + - Updated the PostgreSQL engine to comply with the new Engine API. + - Updated the PostgreSQL engine to use IDs for tracking changes and tags. + - Eliminated the term "node" from the plan implementation and docs. + - Updated the engine base class for the new plan API, and to just deploy + changes one-at-a-time. + - Added many new ways to look for changes in the plan, including: + + `change_name` + + `@tag_name` + + `change_name@tag_name` + + `change_id` + + `tag_id` + - The plan file can now be written out with nearly all white space and + comments preserved. + - Changed the `add` command to write out the plan file after a new change + is added. + - Change names can now be duplicated, as long as a tag name appears + between them. + - Renamed `target` to destination in Engine. + - Started referring to the change to deploy or revert to in docs as the + "target". + - PostgreSQL errors will now be thrown as Sqitch exceptions, for proper + handling during command execution. + - Added required `core.uri` configuration setting. Used to keep change + IDs unique across projects. + - Added `--mode` option to `deploy`, to trigger reverts on failure to + either: + + Not at all: keep the latest successful change. + + To the last deployed tag + + To the point at which the current deploy started + - Added the implicit tags `@ROOT` and `@HEAD` for looking up changes in + the plan. + - Renamed `sql_dir` to `top_dir` and made it default to the current + directory. + - Changed the location of the plan file to the top directory. This will + make it easier to have plans and scripts for multiple database + platforms in a single project. + - Fixed a bug in the build process so that template files will be + properly written to the `etc` directory. + - Rewrote `sqitchtutorial` to reflect the new realities. + - Updated `sqitch` documentation, and moved the plan file information to + App::Sqitch::Plan. + +0.31 2012-05-21T22:29:42Z + - Fixed some typos and failing tests. + +0.30 2012-05-18T15:43:12Z + - The `init` command now properly writes out the `[core]` section header + when there are only commented core settings. + - The `--requires` and `--conflicts` options to `add` now work + properly. + - Fixed anticipated Win32 test failures in `t/init.t`.' + - Fixed the `--plan-file`, `--top-dir`, and other directory options so + that they no longer throw errors, but actually work. + - Implemented the plan parser. It's designed to later be subclassed to + support VCS integration. Includes dependency parsing and sorting. + - Switched to IPC::System::Simple instead for system/capture code. + - Implemented Engine interface for deploying and reverting tags. + - Implemented PostgreSQL engine. It uses a lock to ensure that only one + deployment can run at any time. + - Added the `deploy` command. it is now possible to deploy to a + PostgreSQL database. + +0.20 2012-05-01T02:48:47Z + - Added `--local` option to `sqitch config`. + - Renamed `project_file()` to `--local_file()` in App::Sqitch::Config. + - `sqitch init` now writes core and engine config settings with default + values to the configuration file. This makes it easier for folks to get + started editing it. + - Implemented `add` command. Includes support for system-wide or + use-specific templates using Template::Tiny. + - Added `etc` directory with default templates. This is installed into + `$Config{prefix}/etc/skitch`, unless built with `--prefix` or + `--install_base`, in which case it will simply be installed into `etc` + in that directory. + - Added `--etc-path`, so that one can know where the system-wide + configuration and templates are to be found. + +0.11 2012-04-27T06:44:54Z + - Implemented `init` command. + - Started sketching out the engine interface, with preliminary PostgreSQL + and SQLite implementations. + - Require Perl v5.10.1 (did before, but in the wrong place, so it was + ignored). + - Fixed test failures on different verions of Moose. + - Fixed test failure on Perl 5.12. + +0.10 2012-04-25T20:46:59Z + - Initial unstable release. + - Implemented `help` command. + - Implemented `config` command, very similar to `git-config`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..bac1a2e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,32 @@ +This software is Copyright (c) 2019 by "iovation Inc.". + +This is free software, licensed under: + + The MIT (X11) License + +The MIT License + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to +whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall +be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT +WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..537a967c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2012-2018 iovation, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 00000000..a17e99e0 --- /dev/null +++ b/MANIFEST @@ -0,0 +1,281 @@ +# This file was automatically generated by Dist::Zilla::Plugin::Manifest v6.012. +Build.PL +Changes +LICENSE +LICENSE.md +MANIFEST +META.json +META.yml +README +README.md +bin/sqitch +dist/cpanfile +dist/sqitch.spec +etc/templates/deploy/exasol.tmpl +etc/templates/deploy/firebird.tmpl +etc/templates/deploy/mysql.tmpl +etc/templates/deploy/oracle.tmpl +etc/templates/deploy/pg.tmpl +etc/templates/deploy/snowflake.tmpl +etc/templates/deploy/sqlite.tmpl +etc/templates/deploy/vertica.tmpl +etc/templates/revert/exasol.tmpl +etc/templates/revert/firebird.tmpl +etc/templates/revert/mysql.tmpl +etc/templates/revert/oracle.tmpl +etc/templates/revert/pg.tmpl +etc/templates/revert/snowflake.tmpl +etc/templates/revert/sqlite.tmpl +etc/templates/revert/vertica.tmpl +etc/templates/verify/exasol.tmpl +etc/templates/verify/firebird.tmpl +etc/templates/verify/mysql.tmpl +etc/templates/verify/oracle.tmpl +etc/templates/verify/pg.tmpl +etc/templates/verify/snowflake.tmpl +etc/templates/verify/sqlite.tmpl +etc/templates/verify/vertica.tmpl +etc/tools/upgrade-registry-to-mysql-5.5.0.sql +etc/tools/upgrade-registry-to-mysql-5.6.4.sql +inc/Menlo/Sqitch.pm +inc/Module/Build/Sqitch.pm +lib/App/Sqitch.pm +lib/App/Sqitch/Command.pm +lib/App/Sqitch/Command/add.pm +lib/App/Sqitch/Command/bundle.pm +lib/App/Sqitch/Command/checkout.pm +lib/App/Sqitch/Command/config.pm +lib/App/Sqitch/Command/deploy.pm +lib/App/Sqitch/Command/engine.pm +lib/App/Sqitch/Command/help.pm +lib/App/Sqitch/Command/init.pm +lib/App/Sqitch/Command/log.pm +lib/App/Sqitch/Command/plan.pm +lib/App/Sqitch/Command/rebase.pm +lib/App/Sqitch/Command/revert.pm +lib/App/Sqitch/Command/rework.pm +lib/App/Sqitch/Command/show.pm +lib/App/Sqitch/Command/status.pm +lib/App/Sqitch/Command/tag.pm +lib/App/Sqitch/Command/target.pm +lib/App/Sqitch/Command/upgrade.pm +lib/App/Sqitch/Command/verify.pm +lib/App/Sqitch/Config.pm +lib/App/Sqitch/DateTime.pm +lib/App/Sqitch/Engine.pm +lib/App/Sqitch/Engine/Upgrade/exasol-1.0.sql +lib/App/Sqitch/Engine/Upgrade/exasol-1.1.sql +lib/App/Sqitch/Engine/Upgrade/firebird-1.0.sql +lib/App/Sqitch/Engine/Upgrade/firebird-1.1.sql +lib/App/Sqitch/Engine/Upgrade/mysql-1.0.sql +lib/App/Sqitch/Engine/Upgrade/mysql-1.1.sql +lib/App/Sqitch/Engine/Upgrade/oracle-1.0.sql +lib/App/Sqitch/Engine/Upgrade/oracle-1.1.sql +lib/App/Sqitch/Engine/Upgrade/pg-1.0.sql +lib/App/Sqitch/Engine/Upgrade/pg-1.1.sql +lib/App/Sqitch/Engine/Upgrade/snowflake-1.0.sql +lib/App/Sqitch/Engine/Upgrade/snowflake-1.1.sql +lib/App/Sqitch/Engine/Upgrade/sqlite-1.0.sql +lib/App/Sqitch/Engine/Upgrade/sqlite-1.1.sql +lib/App/Sqitch/Engine/Upgrade/vertica-1.0.sql +lib/App/Sqitch/Engine/Upgrade/vertica-1.1.sql +lib/App/Sqitch/Engine/exasol.pm +lib/App/Sqitch/Engine/exasol.sql +lib/App/Sqitch/Engine/firebird.pm +lib/App/Sqitch/Engine/firebird.sql +lib/App/Sqitch/Engine/mysql.pm +lib/App/Sqitch/Engine/mysql.sql +lib/App/Sqitch/Engine/oracle.pm +lib/App/Sqitch/Engine/oracle.sql +lib/App/Sqitch/Engine/pg.pm +lib/App/Sqitch/Engine/pg.sql +lib/App/Sqitch/Engine/snowflake.pm +lib/App/Sqitch/Engine/snowflake.sql +lib/App/Sqitch/Engine/sqlite.pm +lib/App/Sqitch/Engine/sqlite.sql +lib/App/Sqitch/Engine/vertica.pm +lib/App/Sqitch/Engine/vertica.sql +lib/App/Sqitch/ItemFormatter.pm +lib/App/Sqitch/Plan.pm +lib/App/Sqitch/Plan/Blank.pm +lib/App/Sqitch/Plan/Change.pm +lib/App/Sqitch/Plan/ChangeList.pm +lib/App/Sqitch/Plan/Depend.pm +lib/App/Sqitch/Plan/Line.pm +lib/App/Sqitch/Plan/LineList.pm +lib/App/Sqitch/Plan/Pragma.pm +lib/App/Sqitch/Plan/Tag.pm +lib/App/Sqitch/Role/ConnectingCommand.pm +lib/App/Sqitch/Role/ContextCommand.pm +lib/App/Sqitch/Role/DBIEngine.pm +lib/App/Sqitch/Role/RevertDeployCommand.pm +lib/App/Sqitch/Role/TargetConfigCommand.pm +lib/App/Sqitch/Target.pm +lib/App/Sqitch/Types.pm +lib/App/Sqitch/X.pm +lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo +lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo +lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo +lib/sqitch-add-usage.pod +lib/sqitch-add.pod +lib/sqitch-authentication.pod +lib/sqitch-bundle-usage.pod +lib/sqitch-bundle.pod +lib/sqitch-checkout-usage.pod +lib/sqitch-checkout.pod +lib/sqitch-config-usage.pod +lib/sqitch-config.pod +lib/sqitch-configuration.pod +lib/sqitch-deploy-usage.pod +lib/sqitch-deploy.pod +lib/sqitch-engine-usage.pod +lib/sqitch-engine.pod +lib/sqitch-environment.pod +lib/sqitch-help-usage.pod +lib/sqitch-help.pod +lib/sqitch-init-usage.pod +lib/sqitch-init.pod +lib/sqitch-log-usage.pod +lib/sqitch-log.pod +lib/sqitch-passwords.pod +lib/sqitch-plan-usage.pod +lib/sqitch-plan.pod +lib/sqitch-rebase-usage.pod +lib/sqitch-rebase.pod +lib/sqitch-revert-usage.pod +lib/sqitch-revert.pod +lib/sqitch-rework-usage.pod +lib/sqitch-rework.pod +lib/sqitch-show-usage.pod +lib/sqitch-show.pod +lib/sqitch-status-usage.pod +lib/sqitch-status.pod +lib/sqitch-tag-usage.pod +lib/sqitch-tag.pod +lib/sqitch-target-usage.pod +lib/sqitch-target.pod +lib/sqitch-upgrade-usage.pod +lib/sqitch-upgrade.pod +lib/sqitch-verify-usage.pod +lib/sqitch-verify.pod +lib/sqitch.pod +lib/sqitchchanges.pod +lib/sqitchcommands.pod +lib/sqitchguides.pod +lib/sqitchtutorial-exasol.pod +lib/sqitchtutorial-firebird.pod +lib/sqitchtutorial-mysql.pod +lib/sqitchtutorial-oracle.pod +lib/sqitchtutorial-snowflake.pod +lib/sqitchtutorial-sqlite.pod +lib/sqitchtutorial-vertica.pod +lib/sqitchtutorial.pod +lib/sqitchusage.pod +t/add.t +t/add_change.conf +t/base.t +t/blank.t +t/bundle.t +t/change.t +t/changelist.t +t/checkout.t +t/command.t +t/config.t +t/configuration.t +t/conn_cmd_role.t +t/core.conf +t/core_target.conf +t/cx_cmd_role.t +t/datetime.t +t/depend.t +t/deploy.t +t/die.pl +t/echo.pl +t/editor.conf +t/engine.conf +t/engine.t +t/engine/deploy/func/add_user.sql +t/engine/deploy/users.sql +t/engine/deploy/widgets.sql +t/engine/revert/func/add_user.sql +t/engine/revert/users.sql +t/engine/revert/widgets.sql +t/engine/reworked/deploy/users@alpha.sql +t/engine/reworked/revert/users@alpha.sql +t/engine/sqitch.plan +t/engine_cmd.t +t/exasol.t +t/firebird.t +t/help.t +t/init.t +t/item_formatter.t +t/lib/App/Sqitch/Command/bad.pm +t/lib/App/Sqitch/Command/good.pm +t/lib/App/Sqitch/Engine/bad.pm +t/lib/App/Sqitch/Engine/good.pm +t/lib/DBIEngineTest.pm +t/lib/LC.pm +t/lib/MockOutput.pm +t/lib/TestConfig.pm +t/lib/upgradable_registries/exasol.sql +t/lib/upgradable_registries/firebird.sql +t/lib/upgradable_registries/mysql.sql +t/lib/upgradable_registries/oracle.sql +t/lib/upgradable_registries/pg.sql +t/lib/upgradable_registries/snowflake.sql +t/lib/upgradable_registries/sqlite.sql +t/lib/upgradable_registries/vertica.sql +t/linelist.t +t/local.conf +t/log.t +t/mooseless.t +t/multiplan.conf +t/mysql.t +t/odbc/odbcinst.ini +t/odbc/vertica.ini +t/options.t +t/oracle.t +t/pg.t +t/plan.t +t/plan_cmd.t +t/plans/bad-change.plan +t/plans/changes-only.plan +t/plans/dependencies.plan +t/plans/deploy-and-revert.plan +t/plans/dos.plan +t/plans/dupe-change-diff-tag.plan +t/plans/dupe-change.plan +t/plans/dupe-tag.plan +t/plans/multi.plan +t/plans/pragmas.plan +t/plans/project_deps.plan +t/plans/reserved-tag.plan +t/plans/widgets.plan +t/pragma.t +t/read.pl +t/rebase.t +t/revert.t +t/rework.conf +t/rework.t +t/show.t +t/snowflake.t +t/sqitch +t/sqitch.conf +t/sql/deploy/roles.sql +t/sql/deploy/users.sql +t/sql/deploy/widgets.sql +t/sql/sqitch.plan +t/sql/verify/users.sql +t/sqlite.t +t/status.t +t/tag.t +t/tag_cmd.t +t/target.conf +t/target.t +t/target_cmd.t +t/templates.conf +t/upgrade.t +t/user.conf +t/verify.t +t/vertica.t +t/x.t diff --git a/META.json b/META.json new file mode 100644 index 00000000..72d974f6 --- /dev/null +++ b/META.json @@ -0,0 +1,305 @@ +{ + "abstract" : "Sensible database change management", + "author" : [ + "\"iovation Inc.\"" + ], + "dynamic_config" : 0, + "generated_by" : "Dist::Zilla version 6.012, CPAN::Meta::Converter version 2.150010", + "license" : [ + "mit" + ], + "meta-spec" : { + "url" : "http://search.cpan.org/perldoc?CPAN::Meta::Spec", + "version" : 2 + }, + "name" : "App-Sqitch", + "no_index" : { + "directory" : [ + "priv" + ] + }, + "optional_features" : { + "exasol" : { + "description" : "Support for managing Exasol databases", + "prereqs" : { + "runtime" : { + "requires" : { + "DBD::ODBC" : "1.59" + } + } + } + }, + "firebird" : { + "description" : "Support for managing Firebird databases", + "prereqs" : { + "runtime" : { + "requires" : { + "DBD::Firebird" : "1.11", + "Time::HiRes" : "0", + "Time::Local" : "0" + } + } + } + }, + "mysql" : { + "description" : "Support for managing MySQL databases", + "prereqs" : { + "runtime" : { + "requires" : { + "DBD::mysql" : "4.018", + "MySQL::Config" : "0" + } + } + } + }, + "odbc" : { + "description" : "Include the ODBC driver.", + "prereqs" : { + "runtime" : { + "requires" : { + "DBD::ODBC" : "1.59" + } + } + } + }, + "oracle" : { + "description" : "Support for managing Oracle databases", + "prereqs" : { + "runtime" : { + "requires" : { + "DBD::Oracle" : "1.23" + } + } + } + }, + "postgres" : { + "description" : "Support for managing PostgreSQL databases", + "prereqs" : { + "runtime" : { + "requires" : { + "DBD::Pg" : "2.0" + } + } + } + }, + "snowflake" : { + "description" : "Support for managing Snowflake databases", + "prereqs" : { + "runtime" : { + "requires" : { + "DBD::ODBC" : "1.59" + } + } + } + }, + "sqlite" : { + "description" : "Support for managing SQLite databases", + "prereqs" : { + "runtime" : { + "requires" : { + "DBD::SQLite" : "1.37" + } + } + } + }, + "vertica" : { + "description" : "Support for managing Vertica databases", + "prereqs" : { + "runtime" : { + "requires" : { + "DBD::ODBC" : "1.59" + } + } + } + } + }, + "prereqs" : { + "build" : { + "recommends" : { + "Menlo::CLI::Compat" : "0" + }, + "requires" : { + "Module::Build" : "0.35" + } + }, + "configure" : { + "requires" : { + "Module::Build" : "0.35" + } + }, + "develop" : { + "recommends" : { + "DBD::Firebird" : "1.11", + "DBD::ODBC" : "1.59", + "DBD::Pg" : "2.0", + "DBD::SQLite" : "1.37", + "DBD::mysql" : "4.018", + "Dist::Zilla" : "5", + "Dist::Zilla::Plugin::AutoPrereqs" : "0", + "Dist::Zilla::Plugin::CPANFile" : "0", + "Dist::Zilla::Plugin::ConfirmRelease" : "0", + "Dist::Zilla::Plugin::ExecDir" : "0", + "Dist::Zilla::Plugin::GatherDir" : "0", + "Dist::Zilla::Plugin::License" : "0", + "Dist::Zilla::Plugin::LocaleTextDomain" : "0", + "Dist::Zilla::Plugin::Manifest" : "0", + "Dist::Zilla::Plugin::ManifestSkip" : "0", + "Dist::Zilla::Plugin::MetaJSON" : "0", + "Dist::Zilla::Plugin::MetaNoIndex" : "0", + "Dist::Zilla::Plugin::MetaResources" : "0", + "Dist::Zilla::Plugin::MetaYAML" : "0", + "Dist::Zilla::Plugin::ModuleBuild" : "0", + "Dist::Zilla::Plugin::OptionalFeature" : "0", + "Dist::Zilla::Plugin::OurPkgVersion" : "0", + "Dist::Zilla::Plugin::Prereqs" : "0", + "Dist::Zilla::Plugin::Prereqs::AuthorDeps" : "0", + "Dist::Zilla::Plugin::PruneCruft" : "0", + "Dist::Zilla::Plugin::Readme" : "0", + "Dist::Zilla::Plugin::RunExtraTests" : "0", + "Dist::Zilla::Plugin::ShareDir" : "0", + "Dist::Zilla::Plugin::TestRelease" : "0", + "Dist::Zilla::Plugin::UploadToCPAN" : "0", + "MySQL::Config" : "0", + "Software::License::MIT" : "0", + "Test::Pod" : "1.41", + "Test::Pod::Coverage" : "1.08", + "Test::Spelling" : "0", + "Time::HiRes" : "0", + "Time::Local" : "0" + }, + "requires" : { + "DBD::Firebird" : "1.11", + "DBD::ODBC" : "1.59", + "DBD::Oracle" : "1.23", + "DBD::Pg" : "2.0", + "DBD::SQLite" : "1.37", + "DBD::mysql" : "4.018", + "MySQL::Config" : "0", + "Time::HiRes" : "0", + "Time::Local" : "0" + }, + "suggests" : { + "DBD::Oracle" : "1.23" + } + }, + "runtime" : { + "recommends" : { + "Class::XSAccessor" : "1.18", + "Pod::Simple" : "1.41", + "Template" : "0", + "Type::Tiny::XS" : "0.010" + }, + "requires" : { + "Clone" : "0", + "Config::GitLike" : "1.15", + "DBI" : "0", + "DateTime" : "1.04", + "DateTime::TimeZone" : "0", + "Devel::StackTrace" : "1.30", + "Digest::SHA" : "0", + "Encode" : "0", + "Encode::Locale" : "0", + "File::Basename" : "0", + "File::Copy" : "0", + "File::Path" : "0", + "File::Temp" : "0", + "Getopt::Long" : "0", + "Hash::Merge" : "0", + "IO::Handle" : "0", + "IO::Pager" : "0.34", + "IPC::Run3" : "0", + "IPC::System::Simple" : "1.17", + "List::MoreUtils" : "0", + "List::Util" : "0", + "Locale::Messages" : "0", + "Locale::TextDomain" : "1.20", + "Moo" : "1.002000", + "Moo::Role" : "0", + "POSIX" : "0", + "Path::Class" : "0.33", + "PerlIO::utf8_strict" : "0", + "Pod::Escapes" : "1.04", + "Pod::Find" : "0", + "Pod::Usage" : "0", + "Scalar::Util" : "0", + "StackTrace::Auto" : "0", + "String::Formatter" : "0", + "String::ShellQuote" : "0", + "Sub::Exporter" : "0", + "Sub::Exporter::Util" : "0", + "Sys::Hostname" : "0", + "Template::Tiny" : "0.11", + "Term::ANSIColor" : "2.02", + "Throwable" : "0.200009", + "Time::HiRes" : "0", + "Time::Local" : "0", + "Try::Tiny" : "0", + "Type::Library" : "0.040", + "Type::Utils" : "0", + "Types::Standard" : "0", + "URI" : "0", + "URI::QueryParam" : "0", + "URI::db" : "0.19", + "User::pwent" : "0", + "constant" : "0", + "if" : "0", + "namespace::autoclean" : "0.16", + "overload" : "0", + "parent" : "0", + "perl" : "5.010", + "strict" : "0", + "utf8" : "0", + "warnings" : "0" + }, + "suggests" : { + "DBD::Firebird" : "1.11", + "DBD::ODBC" : "1.59", + "DBD::Oracle" : "1.23", + "DBD::Pg" : "2.0", + "DBD::SQLite" : "1.37", + "DBD::mysql" : "4.018", + "MySQL::Config" : "0", + "Time::HiRes" : "0", + "Time::Local" : "0" + } + }, + "test" : { + "requires" : { + "Capture::Tiny" : "0.12", + "Carp" : "0", + "File::Find" : "0", + "File::Spec" : "0", + "File::Spec::Functions" : "0", + "FindBin" : "0", + "IO::Pager" : "0.34", + "Module::Runtime" : "0", + "Path::Class" : "0.33", + "Test::Deep" : "0", + "Test::Dir" : "0", + "Test::Exception" : "0", + "Test::File" : "0", + "Test::File::Contents" : "0.20", + "Test::MockModule" : "0.17", + "Test::More" : "0.94", + "Test::NoWarnings" : "0.083", + "Test::Warn" : "0", + "base" : "0", + "lib" : "0" + } + } + }, + "release_status" : "stable", + "resources" : { + "bugtracker" : { + "web" : "https://github.com/sqitchers/sqitch/issues/" + }, + "homepage" : "https://sqitch.org/", + "repository" : { + "url" : "https://github.com/sqitchers/sqitch/" + } + }, + "version" : "v1.0.0", + "x_generated_by_perl" : "v5.30.0", + "x_serialization_backend" : "Cpanel::JSON::XS version 4.11" +} + diff --git a/META.yml b/META.yml new file mode 100644 index 00000000..fd856473 --- /dev/null +++ b/META.yml @@ -0,0 +1,151 @@ +--- +abstract: 'Sensible database change management' +author: + - '"iovation Inc."' +build_requires: + Capture::Tiny: '0.12' + Carp: '0' + File::Find: '0' + File::Spec: '0' + File::Spec::Functions: '0' + FindBin: '0' + IO::Pager: '0.34' + Module::Build: '0.35' + Module::Runtime: '0' + Path::Class: '0.33' + Test::Deep: '0' + Test::Dir: '0' + Test::Exception: '0' + Test::File: '0' + Test::File::Contents: '0.20' + Test::MockModule: '0.17' + Test::More: '0.94' + Test::NoWarnings: '0.083' + Test::Warn: '0' + base: '0' + lib: '0' +configure_requires: + Module::Build: '0.35' +dynamic_config: 0 +generated_by: 'Dist::Zilla version 6.012, CPAN::Meta::Converter version 2.150010' +license: mit +meta-spec: + url: http://module-build.sourceforge.net/META-spec-v1.4.html + version: '1.4' +name: App-Sqitch +no_index: + directory: + - priv +optional_features: + exasol: + description: 'Support for managing Exasol databases' + requires: + DBD::ODBC: '1.59' + firebird: + description: 'Support for managing Firebird databases' + requires: + DBD::Firebird: '1.11' + Time::HiRes: '0' + Time::Local: '0' + mysql: + description: 'Support for managing MySQL databases' + requires: + DBD::mysql: '4.018' + MySQL::Config: '0' + odbc: + description: 'Include the ODBC driver.' + requires: + DBD::ODBC: '1.59' + oracle: + description: 'Support for managing Oracle databases' + requires: + DBD::Oracle: '1.23' + postgres: + description: 'Support for managing PostgreSQL databases' + requires: + DBD::Pg: '2.0' + snowflake: + description: 'Support for managing Snowflake databases' + requires: + DBD::ODBC: '1.59' + sqlite: + description: 'Support for managing SQLite databases' + requires: + DBD::SQLite: '1.37' + vertica: + description: 'Support for managing Vertica databases' + requires: + DBD::ODBC: '1.59' +recommends: + Class::XSAccessor: '1.18' + Pod::Simple: '1.41' + Template: '0' + Type::Tiny::XS: '0.010' +requires: + Clone: '0' + Config::GitLike: '1.15' + DBI: '0' + DateTime: '1.04' + DateTime::TimeZone: '0' + Devel::StackTrace: '1.30' + Digest::SHA: '0' + Encode: '0' + Encode::Locale: '0' + File::Basename: '0' + File::Copy: '0' + File::Path: '0' + File::Temp: '0' + Getopt::Long: '0' + Hash::Merge: '0' + IO::Handle: '0' + IO::Pager: '0.34' + IPC::Run3: '0' + IPC::System::Simple: '1.17' + List::MoreUtils: '0' + List::Util: '0' + Locale::Messages: '0' + Locale::TextDomain: '1.20' + Moo: '1.002000' + Moo::Role: '0' + POSIX: '0' + Path::Class: '0.33' + PerlIO::utf8_strict: '0' + Pod::Escapes: '1.04' + Pod::Find: '0' + Pod::Usage: '0' + Scalar::Util: '0' + StackTrace::Auto: '0' + String::Formatter: '0' + String::ShellQuote: '0' + Sub::Exporter: '0' + Sub::Exporter::Util: '0' + Sys::Hostname: '0' + Template::Tiny: '0.11' + Term::ANSIColor: '2.02' + Throwable: '0.200009' + Time::HiRes: '0' + Time::Local: '0' + Try::Tiny: '0' + Type::Library: '0.040' + Type::Utils: '0' + Types::Standard: '0' + URI: '0' + URI::QueryParam: '0' + URI::db: '0.19' + User::pwent: '0' + constant: '0' + if: '0' + namespace::autoclean: '0.16' + overload: '0' + parent: '0' + perl: '5.010' + strict: '0' + utf8: '0' + warnings: '0' +resources: + bugtracker: https://github.com/sqitchers/sqitch/issues/ + homepage: https://sqitch.org/ + repository: https://github.com/sqitchers/sqitch/ +version: v1.0.0 +x_generated_by_perl: v5.30.0 +x_serialization_backend: 'YAML::Tiny version 1.73' diff --git a/README b/README new file mode 100644 index 00000000..286c9317 --- /dev/null +++ b/README @@ -0,0 +1,13 @@ +This archive contains the distribution App-Sqitch, +version v1.0.0: + + Sensible database change management + +This software is Copyright (c) 2019 by "iovation Inc.". + +This is free software, licensed under: + + The MIT (X11) License + + +This README file was generated by Dist::Zilla::Plugin::Readme v6.012. diff --git a/README.md b/README.md new file mode 100644 index 00000000..b528d1ab --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +App/Sqitch version 0.9999 +========================= + +[![CPAN version](https://badge.fury.io/pl/App-Sqitch.svg)](https://badge.fury.io/pl/App-Sqitch) +[![Build Status](https://travis-ci.com/sqitchers/sqitch.svg)](https://travis-ci.com/sqitchers/sqitch) +[![Coverage Status](https://coveralls.io/repos/sqitchers/sqitch/badge.svg)](https://coveralls.io/r/sqitchers/sqitch) + +[Sqitch](https://sqitch.org/) is a database change management application. It +currently supports PostgreSQL 8.4+, SQLite 3.7.11+, MySQL 5.0+, Oracle 10g+, +Firebird 2.0+, Vertica 6.0+, Exasol 6.0+ and Snowflake. + +What makes it different from your typical +[migration](https://guides.rubyonrails.org/migrations.html) approaches? A few +things: + +* No opinions + + Sqitch is not tied to any framework, ORM, or platform. Rather, it is a + standalone change management system with no opinions about your database + engine, application framework, or development environment. + +* Native scripting + + Changes are implemented as scripts native to your selected database + engine. Writing a [PostgreSQL](https://postgresql.org/) application? Write + SQL scripts for + [`psql`](https://www.postgresql.org/docs/current/static/app-psql.html). + Writing an [Oracle](https://www.oracle.com/database/)-backed app? + Write SQL scripts for [SQL\*Plus](https://www.orafaq.com/wiki/SQL*Plus). + +* Dependency resolution + + Database changes may declare dependencies on other changes -- even on + changes from other Sqitch projects. This ensures proper order of + execution, even when you've committed changes to your VCS out-of-order. + +* Deployment integrity + + Sqitch manages changes and dependencies via a plan file, and employs a + [Merkle tree](https://en.wikipedia.org/wiki/Merkle_tree "Wikipedia: “Merkle tree”") + pattern similar to + [Git](https://stackoverflow.com/a/18589734/ "Stack Overflow: “What is the mathematical structure that represents a Git repo”") + and [Blockchain](https://medium.com/byzantine-studio/blockchain-fundamentals-what-is-a-merkle-tree-d44c529391d7 "Medium: “Blockchain Fundamentals #1: What is a Merkle Tree?”") + to ensure deployment integrity. + As such, there is no need to number your changes, although you can if you + want. Sqitch doesn't much care how you name your changes. + +* Iterative Development + + Up until you [tag](https://sqitch.org/docs/manual/sqitch-tag/) and + [release](https://sqitch.org/docs/manual/sqitch-tag/) your project, you + can modify your change deployment scripts as often as you like. They're + not locked in just because they've been committed to your VCS. This allows + you to take an iterative approach to developing your database schema. Or, + better, you can do test-driven database development. + +Want to learn more? The best place to start is in the tutorials: + +* [Introduction to Sqitch on PostgreSQL](lib/sqitchtutorial.pod) +* [Introduction to Sqitch on SQLite](lib/sqitchtutorial-sqlite.pod) +* [Introduction to Sqitch on Oracle](lib/sqitchtutorial-oracle.pod) +* [Introduction to Sqitch on MySQL](lib/sqitchtutorial-mysql.pod) +* [Introduction to Sqitch on Firebird](lib/sqitchtutorial-firebird.pod) +* [Introduction to Sqitch on Vertica](lib/sqitchtutorial-vertica.pod) +* [Introduction to Sqitch on Exasol](lib/sqitchtutorial-exasol.pod) +* [Introduction to Sqitch on Snowflake](lib/sqitchtutorial-snowflake.pod) + +There have also been a number of presentations on Sqitch: + +* [PDX.pm Presentation](https://speakerdeck.com/theory/sane-database-change-management-with-sqitch): + Slides from "Sane Database Management with Sqitch", presented to the + Portland Perl Mongers in January, 2013. + +* [PDXPUG Presentation](https://vimeo.com/50104469): Movie of "Sane Database + Management with Sqitch", presented to the Portland PostgreSQL Users Group in + September, 2012. + +* [Agile Database Development](https://speakerdeck.com/theory/agile-database-development-2ed): + Slides from a three-hour tutorial session on using [Git](https://git-scm.org), + test-driven development with [pgTAP](https://pgtap.org), and change + management with Sqitch, updated in January, 2014. + +Installation +------------ + +To install Sqitch from a distribution download, type the following: + + perl Build.PL + ./Build installdeps + ./Build + ./Build test + ./Build install + +To install Sqitch and all of its dependencies into a single directory named +`sqitch_bundle`, install the Menlo CPAN client and build the bundle: + + cpanm Menlo::CLI::Compat + ./Build bundle --install_base sqitch_bundle + +After which, Sqitch can be run from `./sqitch_bundle/bin/sqitch`. By default, +no modules that are included in the core Perl distrituion are included. To +require that dual-life modules also be bundled, pass `--dual_life 1`: + + ./Build bundle --install_base sqitch_bundle --dual_life 1 + +To include support for a feature in the bundle, pass the `--with` option +naming the feature: + + ./Build bundle --install_base sqitch_bundle --with postgres --with sqlite + +The feature names generally correspond to the supported engines. The currently +supported features are: + +* `--with postgres`: Support for managing PostgreSQL databases +* `--with sqlite`: Support for managing SQLite databases +* `--with mysql`: Support for managing MySQL databases +* `--with firebird`: Support for managing Firebird databases +* `--with oracle`: Support for managing Oracle databases +* `--with vertica`: Support for managing Vertica databases +* `--with exasol`: Support for managing Exasol databases +* `--with snowflake`: Support for managing Snowflake databases +* `--with odbc`: Include the ODBC driver + +To build from a Git clone, first install +[Dist::Zilla](https://metacpan.org/module/Dist::Zilla), then use it to install +Sqitch and all dependencies: + + cpanm Dist::Zilla + dzil authordeps --missing | cpanm + dzil listdeps --missing | cpanm + dzil install + +To run Sqitch directly from the Git clone, execute `t/sqitch`. + +To install Sqitch on a specific platform, including Debian- and RedHat-derived +Linux distributions and Windows, see the +[Installation documentation](https://sqitch.org/#installation). + +Licence +------- + +Copyright © 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bin/sqitch b/bin/sqitch new file mode 100755 index 00000000..f24e726f --- /dev/null +++ b/bin/sqitch @@ -0,0 +1,15 @@ +#!perl -w -CAS + +our $VERSION = 'v1.0.0'; # VERSION +use POSIX qw(setlocale); +BEGIN { + if ($^O eq 'MSWin32') { + require Win32::Locale; + setlocale POSIX::LC_ALL, Win32::Locale::get_locale(); + } else { + setlocale POSIX::LC_ALL, ''; + } +} +use App::Sqitch; + +exit App::Sqitch->go; diff --git a/dist/cpanfile b/dist/cpanfile new file mode 100644 index 00000000..f107b799 --- /dev/null +++ b/dist/cpanfile @@ -0,0 +1,164 @@ +requires "Clone" => "0"; +requires "Config::GitLike" => "1.15"; +requires "DBI" => "0"; +requires "DateTime" => "1.04"; +requires "DateTime::TimeZone" => "0"; +requires "Devel::StackTrace" => "1.30"; +requires "Digest::SHA" => "0"; +requires "Encode" => "0"; +requires "Encode::Locale" => "0"; +requires "File::Basename" => "0"; +requires "File::Copy" => "0"; +requires "File::Path" => "0"; +requires "File::Temp" => "0"; +requires "Getopt::Long" => "0"; +requires "Hash::Merge" => "0"; +requires "IO::Handle" => "0"; +requires "IO::Pager" => "0.34"; +requires "IPC::Run3" => "0"; +requires "IPC::System::Simple" => "1.17"; +requires "List::MoreUtils" => "0"; +requires "List::Util" => "0"; +requires "Locale::Messages" => "0"; +requires "Locale::TextDomain" => "1.20"; +requires "Moo" => "1.002000"; +requires "Moo::Role" => "0"; +requires "POSIX" => "0"; +requires "Path::Class" => "0.33"; +requires "PerlIO::utf8_strict" => "0"; +requires "Pod::Escapes" => "1.04"; +requires "Pod::Find" => "0"; +requires "Pod::Usage" => "0"; +requires "Scalar::Util" => "0"; +requires "StackTrace::Auto" => "0"; +requires "String::Formatter" => "0"; +requires "String::ShellQuote" => "0"; +requires "Sub::Exporter" => "0"; +requires "Sub::Exporter::Util" => "0"; +requires "Sys::Hostname" => "0"; +requires "Template::Tiny" => "0.11"; +requires "Term::ANSIColor" => "2.02"; +requires "Throwable" => "0.200009"; +requires "Time::HiRes" => "0"; +requires "Time::Local" => "0"; +requires "Try::Tiny" => "0"; +requires "Type::Library" => "0.040"; +requires "Type::Utils" => "0"; +requires "Types::Standard" => "0"; +requires "URI" => "0"; +requires "URI::QueryParam" => "0"; +requires "URI::db" => "0.19"; +requires "User::pwent" => "0"; +requires "constant" => "0"; +requires "if" => "0"; +requires "namespace::autoclean" => "0.16"; +requires "overload" => "0"; +requires "parent" => "0"; +requires "perl" => "5.010"; +requires "strict" => "0"; +requires "utf8" => "0"; +requires "warnings" => "0"; +recommends "Class::XSAccessor" => "1.18"; +recommends "Pod::Simple" => "1.41"; +recommends "Template" => "0"; +recommends "Type::Tiny::XS" => "0.010"; +suggests "DBD::Firebird" => "1.11"; +suggests "DBD::ODBC" => "1.59"; +suggests "DBD::Oracle" => "1.23"; +suggests "DBD::Pg" => "2.0"; +suggests "DBD::SQLite" => "1.37"; +suggests "DBD::mysql" => "4.018"; +suggests "MySQL::Config" => "0"; +suggests "Time::HiRes" => "0"; +suggests "Time::Local" => "0"; + +on 'build' => sub { + requires "Module::Build" => "0.35"; +}; + +on 'build' => sub { + recommends "Menlo::CLI::Compat" => "0"; +}; + +on 'test' => sub { + requires "Capture::Tiny" => "0.12"; + requires "Carp" => "0"; + requires "File::Find" => "0"; + requires "File::Spec" => "0"; + requires "File::Spec::Functions" => "0"; + requires "FindBin" => "0"; + requires "IO::Pager" => "0.34"; + requires "Module::Runtime" => "0"; + requires "Path::Class" => "0.33"; + requires "Test::Deep" => "0"; + requires "Test::Dir" => "0"; + requires "Test::Exception" => "0"; + requires "Test::File" => "0"; + requires "Test::File::Contents" => "0.20"; + requires "Test::MockModule" => "0.17"; + requires "Test::More" => "0.94"; + requires "Test::NoWarnings" => "0.083"; + requires "Test::Warn" => "0"; + requires "base" => "0"; + requires "lib" => "0"; +}; + +on 'configure' => sub { + requires "Module::Build" => "0.35"; +}; + +on 'develop' => sub { + requires "DBD::Firebird" => "1.11"; + requires "DBD::ODBC" => "1.59"; + requires "DBD::Oracle" => "1.23"; + requires "DBD::Pg" => "2.0"; + requires "DBD::SQLite" => "1.37"; + requires "DBD::mysql" => "4.018"; + requires "MySQL::Config" => "0"; + requires "Time::HiRes" => "0"; + requires "Time::Local" => "0"; +}; + +on 'develop' => sub { + recommends "DBD::Firebird" => "1.11"; + recommends "DBD::ODBC" => "1.59"; + recommends "DBD::Pg" => "2.0"; + recommends "DBD::SQLite" => "1.37"; + recommends "DBD::mysql" => "4.018"; + recommends "Dist::Zilla" => "5"; + recommends "Dist::Zilla::Plugin::AutoPrereqs" => "0"; + recommends "Dist::Zilla::Plugin::CPANFile" => "0"; + recommends "Dist::Zilla::Plugin::ConfirmRelease" => "0"; + recommends "Dist::Zilla::Plugin::ExecDir" => "0"; + recommends "Dist::Zilla::Plugin::GatherDir" => "0"; + recommends "Dist::Zilla::Plugin::License" => "0"; + recommends "Dist::Zilla::Plugin::LocaleTextDomain" => "0"; + recommends "Dist::Zilla::Plugin::Manifest" => "0"; + recommends "Dist::Zilla::Plugin::ManifestSkip" => "0"; + recommends "Dist::Zilla::Plugin::MetaJSON" => "0"; + recommends "Dist::Zilla::Plugin::MetaNoIndex" => "0"; + recommends "Dist::Zilla::Plugin::MetaResources" => "0"; + recommends "Dist::Zilla::Plugin::MetaYAML" => "0"; + recommends "Dist::Zilla::Plugin::ModuleBuild" => "0"; + recommends "Dist::Zilla::Plugin::OptionalFeature" => "0"; + recommends "Dist::Zilla::Plugin::OurPkgVersion" => "0"; + recommends "Dist::Zilla::Plugin::Prereqs" => "0"; + recommends "Dist::Zilla::Plugin::Prereqs::AuthorDeps" => "0"; + recommends "Dist::Zilla::Plugin::PruneCruft" => "0"; + recommends "Dist::Zilla::Plugin::Readme" => "0"; + recommends "Dist::Zilla::Plugin::RunExtraTests" => "0"; + recommends "Dist::Zilla::Plugin::ShareDir" => "0"; + recommends "Dist::Zilla::Plugin::TestRelease" => "0"; + recommends "Dist::Zilla::Plugin::UploadToCPAN" => "0"; + recommends "MySQL::Config" => "0"; + recommends "Software::License::MIT" => "0"; + recommends "Test::Pod" => "1.41"; + recommends "Test::Pod::Coverage" => "1.08"; + recommends "Test::Spelling" => "0"; + recommends "Time::HiRes" => "0"; + recommends "Time::Local" => "0"; +}; + +on 'develop' => sub { + suggests "DBD::Oracle" => "1.23"; +}; diff --git a/dist/sqitch.spec b/dist/sqitch.spec new file mode 100644 index 00000000..8cfa9e68 --- /dev/null +++ b/dist/sqitch.spec @@ -0,0 +1,546 @@ +Name: sqitch +Version: 1.0.0 +Release: 1%{?dist} +Summary: Sensible database change management +License: MIT +Group: Development/Libraries +URL: https://sqitch.org/ +Source0: https://www.cpan.org/modules/by-module/App/App-Sqitch-%{version}.tar.gz +BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) +BuildArch: noarch +BuildRequires: perl >= 1:v5.10.0 +BuildRequires: perl(Capture::Tiny) >= 0.12 +BuildRequires: perl(Carp) +BuildRequires: perl(Class::XSAccessor) >= 1.18 +BuildRequires: perl(Clone) +BuildRequires: perl(Config) +BuildRequires: perl(Config::GitLike) >= 1.15 +BuildRequires: perl(constant) +BuildRequires: perl(DateTime) >= 1.04 +BuildRequires: perl(DateTime::TimeZone) +BuildRequires: perl(DBI) +BuildRequires: perl(Devel::StackTrace) >= 1.30 +BuildRequires: perl(Digest::SHA) +BuildRequires: perl(Encode) +BuildRequires: perl(Encode::Locale) +BuildRequires: perl(File::Basename) +BuildRequires: perl(File::Copy) +BuildRequires: perl(File::Find) +BuildRequires: perl(File::Path) +BuildRequires: perl(File::Spec) +BuildRequires: perl(File::Temp) +BuildRequires: perl(Getopt::Long) +BuildRequires: perl(Hash::Merge) +BuildRequires: perl(IO::Pager) >= 0.34 +BuildRequires: perl(IPC::Run3) +BuildRequires: perl(IPC::System::Simple) >= 1.17 +BuildRequires: perl(List::Util) +BuildRequires: perl(List::MoreUtils) +BuildRequires: perl(Locale::Messages) +BuildRequires: perl(Locale::TextDomain) >= 1.20 +BuildRequires: perl(Module::Build) >= 0.35 +BuildRequires: perl(Module::Runtime) +BuildRequires: perl(Moo) >= 1.002000 +BuildRequires: perl(Moo::Role) +BuildRequires: perl(namespace::autoclean) >= 0.16 +BuildRequires: perl(parent) +BuildRequires: perl(overload) +BuildRequires: perl(Path::Class) >= 0.33 +BuildRequires: perl(PerlIO::utf8_strict) +BuildRequires: perl(Pod::Escapes) +BuildRequires: perl(Pod::Find) +BuildRequires: perl(Pod::Usage) +BuildRequires: perl(POSIX) +BuildRequires: perl(Scalar::Util) +BuildRequires: perl(StackTrace::Auto) +BuildRequires: perl(strict) +BuildRequires: perl(String::Formatter) +BuildRequires: perl(String::ShellQuote) +BuildRequires: perl(Sub::Exporter) +BuildRequires: perl(Sub::Exporter::Util) +BuildRequires: perl(Sys::Hostname) +BuildRequires: perl(Template::Tiny) >= 0.11 +BuildRequires: perl(Term::ANSIColor) >= 2.02 +BuildRequires: perl(Test::Deep) +BuildRequires: perl(Test::Dir) +BuildRequires: perl(Test::Exception) +BuildRequires: perl(Test::File) +BuildRequires: perl(Test::File::Contents) >= 0.20 +BuildRequires: perl(Test::MockModule) >= 0.17 +BuildRequires: perl(Test::More) >= 0.94 +BuildRequires: perl(Test::NoWarnings) >= 0.083 +BuildRequires: perl(Test::Warn) +BuildRequires: perl(Throwable) >= 0.200009 +BuildRequires: perl(Time::HiRes) +BuildRequires: perl(Try::Tiny) +BuildRequires: perl(Type::Library) >= 0.040 +BuildRequires: perl(Type::Tiny::XS) >= 0.010 +BuildRequires: perl(Type::Utils) +BuildRequires: perl(Types::Standard) +BuildRequires: perl(URI) +BuildRequires: perl(URI::db) >= 0.19 +BuildRequires: perl(User::pwent) +BuildRequires: perl(utf8) +BuildRequires: perl(warnings) +Requires: perl(Class::XSAccessor) >= 1.18 +Requires: perl(Clone) +Requires: perl(Config) +Requires: perl(Config::GitLike) >= 1.15 +Requires: perl(constant) +Requires: perl(DateTime) >= 1.04 +Requires: perl(DateTime::TimeZone) +Requires: perl(Devel::StackTrace) >= 1.30 +Requires: perl(Digest::SHA) +Requires: perl(Encode) +Requires: perl(Encode::Locale) +Requires: perl(File::Basename) +Requires: perl(File::Copy) +Requires: perl(File::Path) +Requires: perl(File::Temp) +Requires: perl(Getopt::Long) +Requires: perl(Hash::Merge) +Requires: perl(IO::Pager) >= 0.34 +Requires: perl(IPC::Run3) +Requires: perl(IPC::System::Simple) >= 1.17 +Requires: perl(List::Util) +Requires: perl(List::MoreUtils) +Requires: perl(Locale::Messages) +Requires: perl(Locale::TextDomain) >= 1.20 +Requires: perl(Moo) => 1.002000 +Requires: perl(Moo::Role) +Requires: perl(namespace::autoclean) >= 0.16 +Requires: perl(parent) +Requires: perl(overload) +Requires: perl(Path::Class) +Requires: perl(PerlIO::utf8_strict) +Requires: perl(Pod::Escapes) +Requires: perl(Pod::Find) +Requires: perl(Pod::Usage) +Requires: perl(POSIX) +Requires: perl(Scalar::Util) +Requires: perl(StackTrace::Auto) +Requires: perl(strict) +Requires: perl(String::Formatter) +Requires: perl(String::ShellQuote) +Requires: perl(Sub::Exporter) +Requires: perl(Sub::Exporter::Util) +Requires: perl(Sys::Hostname) +Requires: perl(Template::Tiny) >= 0.11 +Requires: perl(Term::ANSIColor) >= 2.02 +Requires: perl(Throwable) >= 0.200009 +Requires: perl(Try::Tiny) +Requires: perl(Type::Library) >= 0.040 +Requires: perl(Type::Tiny::XS) >= 0.010 +Requires: perl(Type::Utils) +Requires: perl(Types::Standard) +Requires: perl(URI) +Requires: perl(URI::db) >= 0.19 +Requires: perl(User::pwent) +Requires: perl(utf8) +Requires: perl(warnings) +Requires: perl(:MODULE_COMPAT_%(eval "`%{__perl} -V:version`"; echo $version)) +Provides: sqitch + +%define etcdir %(%{__perl} -MConfig -E 'say "$Config{prefix}/etc"') + +%description +This application, `sqitch`, provides a simple yet robust interface for +database change management. The philosophy and functionality is inspired by +Git. + +%prep +%setup -q -n App-Sqitch-%{version} + +%build +%{__perl} Build.PL installdirs=vendor destdir=$RPM_BUILD_ROOT +./Build + +%install +rm -rf $RPM_BUILD_ROOT + +./Build install +find $RPM_BUILD_ROOT -depth -type d -exec rmdir {} 2>/dev/null \; + +# Grab and tweak the .packlist file. +find $RPM_BUILD_ROOT -type f -name .packlist -exec mv {} . \; +perl -i -pe 's/[.]([13](?:pm)?)$/.$1*/g' .packlist +perl -i -pe "s{^\Q$RPM_BUILD_ROOT}{}g" .packlist + +%{_fixperms} $RPM_BUILD_ROOT/* + +%check +./Build test + +%clean +rm -rf $RPM_BUILD_ROOT + +%files -f .packlist +%defattr(-,root,root,-) +%doc Changes META.json README.md +%config %{etcdir}/* + +%package pg +Summary: Sensible database change management for PostgreSQL +Group: Development/Libraries +Requires: sqitch >= %{version} +Requires: postgresql >= 8.4.0 +Requires: perl(DBI) +Requires: perl(DBD::Pg) >= 2.0.0 +Provides: sqitch-pg + +%description pg +Sqitch provides a simple yet robust interface for database change +management. The philosophy and functionality is inspired by Git. This +package bundles the Sqitch PostgreSQL support. + +%files pg +# No additional files required. + +%package sqlite +Summary: Sensible database change management for SQLite +Group: Development/Libraries +Requires: sqitch >= %{version} +Requires: sqlite +Requires: perl(DBI) +Requires: perl(DBD::SQLite) >= 1.37 +Provides: sqitch-sqlite + +%description sqlite +Sqitch provides a simple yet robust interface for database change +management. The philosophy and functionality is inspired by Git. This +package bundles the Sqitch SQLite support. + +%files sqlite +# No additional files required. + +%package oracle +Summary: Sensible database change management for Oracle +Group: Development/Libraries +Requires: sqitch >= %{version} +Requires: oracle-instantclient11.2-sqlplus +Requires: perl(DBI) +Requires: perl(DBD::Oracle) >= 1.23 +Provides: sqitch-oracle + +%description oracle +Sqitch provides a simple yet robust interface for database change +management. The philosophy and functionality is inspired by Git. This +package bundles the Sqitch Oracle support. + +%files oracle +# No additional files required. + +%package mysql +Summary: Sensible database change management for MySQL +Group: Development/Libraries +Requires: sqitch >= %{version} +Requires: mysql >= 5.0.0 +Requires: perl(DBI) +Requires: perl(DBD::mysql) >= 4.018 +Requires: perl(MySQL::Config) +Provides: sqitch-mysql + +%description mysql +Sqitch provides a simple yet robust interface for database change +management. The philosophy and functionality is inspired by Git. This +package bundles the Sqitch MySQL support. + +%files mysql +# No additional files required. + +%package firebird +Summary: Sensible database change management for Firebird +Group: Development/Libraries +Requires: sqitch >= %{version} +Requires: firebird >= 2.5.0 +Requires: perl(DBI) +Requires: perl(DBD::Firebird) >= 1.11 +Requires: perl(Time::HiRes) +Requires: perl(Time::Local) +BuildRequires: firebird >= 2.5.0 +Provides: sqitch-firebird + +%description firebird +Sqitch provides a simple yet robust interface for database change +management. The philosophy and functionality is inspired by Git. This +package bundles the Sqitch Firebird support. + +%files firebird +# No additional files required. + +%package vertica +Summary: Sensible database change management for Vertica +Group: Development/Libraries +Requires: sqitch >= %{version} +Requires: libverticaodbc.so +Requires: /opt/vertica/bin/vsql +Requires: perl(DBI) +Requires: perl(DBD::ODBC) >= 1.59 +Provides: sqitch-vertica + +%description vertica +Sqitch provides a simple yet robust interface for database change management. +The philosophy and functionality is inspired by Git. This package bundles the +Sqitch Vertica support. + +%files vertica +# No additional files required. + +%package snowflake +Summary: Sensible database change management for Snowflake +Group: Development/Libraries +Requires: sqitch >= %{version} +Requires: snowflake-odbc +Requires: perl(DBI) +Requires: perl(DBD::ODBC) >= 1.59 +Provides: sqitch-snowflake + +%description snowflake +Sqitch provides a simple yet robust interface for database change management. +The philosophy and functionality is inspired by Git. This package bundles the +Sqitch Snowflake support. It requires that the SnowSQL client and ODBC driver +also be installed. + +%files snowflake +# No additional files required. + +%changelog +* Tue Jun 4 2019 David E. Wheeler 1.0.0-1 +- Upgrade to v1.0.0. +- Config::GitLike now requires v1.15. +- Test::MockModule now requires v0.17. +- Removed File::HomeDir. +- Changed "sane" to "sensible" in the summary. + +* Fri Feb 1 2019 David E. Wheeler 0.9999-1 +- Upgrade to v0.9999. +- Added requirement for IO::Pager 0.34 or higher. +- Added Test::Warn build requirement. +- Removed cross-project dependency patch, since it's part of v0.99999. + +* Wed Oct 3 2018 David E. Wheeler 0.9998-1 +- Upgrade to v0.9998. +- Added sqitch-snowflake package. +- Added Locale::Messages requirement. +- URI::db now requires v0.19. +- DBD::ODBC now requires v1.59. +- Files for installation are now read from the .packlist generated by the Perl + installer. + +* Thu Mar 15 2018 David E. Wheeler 0.9997-1 +- Upgrade to v0.9997. + +* Wed Jul 19 2017 David E. Wheeler 0.9996-2 +- Require File::Find and Module::Runtime at build time. +- Remove Moo::sification. + +* Mon Jul 17 2017 David E. Wheeler 0.9996-1 +- Upgrade to v0.9996. + +* Wed Jul 27 2016 David E. Wheeler 0.9995-1 +- Require DateTime v1.04. +- Upgrade to v0.9995. + +* Thu Feb 11 2016 David E. Wheeler 0.9994-2 +- Add perl(Pod::Escapes) to work around missing dependencies in Pod::Simple. + https://github.com/perl-pod/pod-simple/issues/84. + +* Fri Jan 8 2016 David E. Wheeler 0.9994-1 +- Reduced required MySQL version to 5.0. +- Upgrade to v0.9994. + +* Mon Aug 17 2015 David E. Wheeler 0.9993-1 +- Upgrade to v0.9993. + +* Wed May 20 2015 David E. Wheeler 0.9992-1 +- Upgrade to v0.9992. +- Add perl(DateTime::TimeZone). +- Add Provides. +- Replace requirement for firebird-classic with firebird. +- Replace requirement for vertica-client with /opt/vertica/bin/vsql and + libverticaodbc.so. + +* Tue Mar 3 2015 David E. Wheeler 0.9991-1 +- Upgrade to v0.9991. +- Reduced required MySQL version to 5.1. + +* Thu Feb 12 2015 David E. Wheeler 0.999-1 +- Upgrade to v0.999. + +* Thu Jan 15 2015 David E. Wheeler 0.998-1 +- Upgrade to v0.998. +- Require Path::Class v0.33 when building. + +* Tue Nov 4 2014 David E. Wheeler 0.997-1 +- Upgrade to v0.997. + +* Fri Sep 5 2014 David E. Wheeler 0.996-1 +- Upgrade to v0.996. +- Remove Moose and Mouse dependencies. +- Add Moo dependencies. +- Add Type::Library and related module dependencies. +- Switch from Digest::SHA1 to Digest::SHA. +- Require the Moo-backed version of Config::GitLike. +- Remove Role module dependencies. +- Require URI::db v0.15. +- Add sqitch-vertica. + +* Sun Jul 13 2014 David E. Wheeler 0.995-1 +- Upgrade to v0.995. + +* Thu Jun 19 2014 David E. Wheeler 0.994-1 +- Upgrade to v0.994. + +* Wed Jun 4 2014 David E. Wheeler 0.993-1 +- Upgrade to v0.993. + +* Tue Mar 4 2014 David E. Wheeler 0.992-1 +- Upgrade to v0.992. + +* Thu Jan 16 2014 David E. Wheeler 0.991-1 +- Upgrade to v0.991. +- Remove File::Which from sqitch-firebird. + +* Fri Jan 3 2014 David E. Wheeler 0.990-1 +- Upgrade to v0.990. +- Add sqitch-firebird. +- Add target command and arguments. +- Add support for arbitrary change script templating. +- Add --open-editor option. + +* Thu Nov 21 2013 David E. Wheeler 0.983-1 +- Upgrade to v0.983. +- Require DBD::Pg 2.0.0 or higher. + +* Wed Sep 18 2013 David E. Wheeler 0.982-2 +- No longer include template files ending in .default in the RPM. +- All files in the etc dir now treated as configuration files. +- The etc and inc files are no longer treated as documentation. + +* Wed Sep 11 2013 David E. Wheeler 0.982-1 +- Upgrade to v0.982. +- Require Clone. + +* Thu Sep 5 2013 David E. Wheeler 0.981-1 +- Upgrade to v0.981. + +* Wed Aug 28 2013 David E. Wheeler 0.980-1 +- Upgrade to v0.980. +- Require Encode::Locale. +- Require DBD::SQLite 1.37. +- Require PostgreSQL 8.4.0. +- Remove FindBin requirement. +- Add sqitch-mysql. + +* Wed Jul 3 2013 David E. Wheeler 0.973-1 +- Upgrade to v0.973. + +* Fri May 31 2013 David E. Wheeler 0.972-1 +- Upgrade to v0.972. + +* Sat May 18 2013 David E. Wheeler 0.971-1 +- Upgrade to v0.971. + +* Wed May 8 2013 David E. Wheeler 0.970-1 +- Upgrade to v0.970. +- Add sqitch-oracle. + +* Tue Apr 23 2013 David E. Wheeler 0.965-1 +- Upgrade to v0.965. + +* Mon Apr 15 2013 David E. Wheeler 0.964-1 +- Upgrade to v0.964. + +* Fri Apr 12 2013 David E. Wheeler 0.963-1 +- Upgrade to v0.963. +- Add missing dependency on Devel::StackTrace 1.30. +- Remove dependency on Git::Wrapper. + +* Wed Apr 10 2013 David E. Wheeler 0.962-1 +- Upgrade to v0.962. + +* Tue Apr 9 2013 David E. Wheeler 0.961-1 +- Upgrade to v0.961. + +* Mon Apr 8 2013 David E. Wheeler 0.960-2 +- Add missing dependency on Git::Wrapper. + +* Fri Apr 5 2013 David E. Wheeler 0.960-1 +- Upgrade to v0.960. +- Add sqitch-sqlite. + +* Thu Feb 21 2013 David E. Wheeler 0.953-1 +- Upgrade to v0.953. + +* Fri Jan 11 2013 David E. Wheeler 0.952-1 +- Upgrade to v0.952. + +* Mon Jan 7 2013 David E. Wheeler 0.951-1 +- Upgrade to v0.951. + +* Thu Jan 3 2013 David E. Wheeler 0.950-1 +- Upgrade to v0.950. + +* Mon Dec 3 2012 David E. Wheeler 0.940-1 +- Upgrade to v0.940. + +* Fri Oct 12 2012 David E. Wheeler 0.938-1 +- Upgrade to v0.938. + +* Tue Oct 9 2012 David E. Wheeler 0.937-1 +- Upgrade to v0.937. + +* Tue Oct 9 2012 David E. Wheeler 0.936-1 +- Upgrade to v0.936. + +* Tue Oct 2 2012 David E. Wheeler 0.935-1 +- Upgrade to v0.935. + +* Fri Sep 28 2012 David E. Wheeler 0.934-1 +- Upgrade to v0.934. + +* Thu Sep 27 2012 David E. Wheeler 0.933-1 +- Upgrade to v0.933. + +* Wed Sep 26 2012 David E. Wheeler 0.932-1 +- Upgrade to v0.932. + +* Tue Sep 25 2012 David E. Wheeler 0.931-1 +- Upgrade to v0.931. + +* Fri Aug 31 2012 David E. Wheeler 0.930-1 +- Upgrade to v0.93. + +* Thu Aug 30 2012 David E. Wheeler 0.922-1 +- Upgrade to v0.922. + +* Wed Aug 29 2012 David E. Wheeler 0.921-1 +- Upgrade to v0.921. + +* Tue Aug 28 2012 David E. Wheeler 0.920-1 +- Upgrade to v0.92. + +* Tue Aug 28 2012 David E. Wheeler 0.913-1 +- Upgrade to v0.913. + +* Mon Aug 27 2012 David E. Wheeler 0.912-1 +- Upgrade to v0.912. + +* Thu Aug 23 2012 David E. Wheeler 0.911-1 +- Upgrade to v0.911. + +* Wed Aug 22 2012 David E. Wheeler 0.91-1 +- Upgrade to v0.91. + +* Mon Aug 20 2012 David E. Wheeler 0.902-1 +- Upgrade to v0.902. + +* Mon Aug 20 2012 David E. Wheeler 0.901-1 +- Upgrade to v0.901. + +* Mon Aug 13 2012 David E. Wheeler 0.82-2 +- Require Config::GitLike 1.09, which offers better encoding support an other + bug fixes. + +* Fri Aug 03 2012 David E. Wheeler 0.82-2 +- Specfile autogenerated by cpanspec 1.78. diff --git a/etc/templates/deploy/exasol.tmpl b/etc/templates/deploy/exasol.tmpl new file mode 100644 index 00000000..369d2236 --- /dev/null +++ b/etc/templates/deploy/exasol.tmpl @@ -0,0 +1,11 @@ +-- Deploy [% project %]:[% change %] to [% engine %] +[% FOREACH item IN requires -%] +-- requires: [% item %] +[% END -%] +[% FOREACH item IN conflicts -%] +-- conflicts: [% item %] +[% END -%] + +-- XXX Add DDLs here. + +COMMIT; diff --git a/etc/templates/deploy/firebird.tmpl b/etc/templates/deploy/firebird.tmpl new file mode 100644 index 00000000..369d2236 --- /dev/null +++ b/etc/templates/deploy/firebird.tmpl @@ -0,0 +1,11 @@ +-- Deploy [% project %]:[% change %] to [% engine %] +[% FOREACH item IN requires -%] +-- requires: [% item %] +[% END -%] +[% FOREACH item IN conflicts -%] +-- conflicts: [% item %] +[% END -%] + +-- XXX Add DDLs here. + +COMMIT; diff --git a/etc/templates/deploy/mysql.tmpl b/etc/templates/deploy/mysql.tmpl new file mode 100644 index 00000000..ef39d2bb --- /dev/null +++ b/etc/templates/deploy/mysql.tmpl @@ -0,0 +1,13 @@ +-- Deploy [% project %]:[% change %] to [% engine %] +[% FOREACH item IN requires -%] +-- requires: [% item %] +[% END -%] +[% FOREACH item IN conflicts -%] +-- conflicts: [% item %] +[% END -%] + +BEGIN; + +-- XXX Add DDLs here. + +COMMIT; diff --git a/etc/templates/deploy/oracle.tmpl b/etc/templates/deploy/oracle.tmpl new file mode 100644 index 00000000..72adefc8 --- /dev/null +++ b/etc/templates/deploy/oracle.tmpl @@ -0,0 +1,9 @@ +-- Deploy [% project %]:[% change %] to [% engine %] +[% FOREACH item IN requires -%] +-- requires: [% item %] +[% END -%] +[% FOREACH item IN conflicts -%] +-- conflicts: [% item %] +[% END -%] + +-- XXX Add DDLs here. diff --git a/etc/templates/deploy/pg.tmpl b/etc/templates/deploy/pg.tmpl new file mode 100644 index 00000000..ef39d2bb --- /dev/null +++ b/etc/templates/deploy/pg.tmpl @@ -0,0 +1,13 @@ +-- Deploy [% project %]:[% change %] to [% engine %] +[% FOREACH item IN requires -%] +-- requires: [% item %] +[% END -%] +[% FOREACH item IN conflicts -%] +-- conflicts: [% item %] +[% END -%] + +BEGIN; + +-- XXX Add DDLs here. + +COMMIT; diff --git a/etc/templates/deploy/snowflake.tmpl b/etc/templates/deploy/snowflake.tmpl new file mode 100644 index 00000000..ebb50541 --- /dev/null +++ b/etc/templates/deploy/snowflake.tmpl @@ -0,0 +1,11 @@ +-- Deploy [% project %]:[% change %] to [% engine %] +[% FOREACH item IN requires -%] +-- requires: [% item %] +[% END -%] +[% FOREACH item IN conflicts -%] +-- conflicts: [% item %] +[% END -%] + +USE WAREHOUSE &warehouse; + +-- XXX Add DDLs here. diff --git a/etc/templates/deploy/sqlite.tmpl b/etc/templates/deploy/sqlite.tmpl new file mode 100644 index 00000000..ef39d2bb --- /dev/null +++ b/etc/templates/deploy/sqlite.tmpl @@ -0,0 +1,13 @@ +-- Deploy [% project %]:[% change %] to [% engine %] +[% FOREACH item IN requires -%] +-- requires: [% item %] +[% END -%] +[% FOREACH item IN conflicts -%] +-- conflicts: [% item %] +[% END -%] + +BEGIN; + +-- XXX Add DDLs here. + +COMMIT; diff --git a/etc/templates/deploy/vertica.tmpl b/etc/templates/deploy/vertica.tmpl new file mode 100644 index 00000000..72adefc8 --- /dev/null +++ b/etc/templates/deploy/vertica.tmpl @@ -0,0 +1,9 @@ +-- Deploy [% project %]:[% change %] to [% engine %] +[% FOREACH item IN requires -%] +-- requires: [% item %] +[% END -%] +[% FOREACH item IN conflicts -%] +-- conflicts: [% item %] +[% END -%] + +-- XXX Add DDLs here. diff --git a/etc/templates/revert/exasol.tmpl b/etc/templates/revert/exasol.tmpl new file mode 100644 index 00000000..c2d48048 --- /dev/null +++ b/etc/templates/revert/exasol.tmpl @@ -0,0 +1,5 @@ +-- Revert [% project %]:[% change %] from [% engine %] + +-- XXX Add DDLs here. + +COMMIT; diff --git a/etc/templates/revert/firebird.tmpl b/etc/templates/revert/firebird.tmpl new file mode 100644 index 00000000..c2d48048 --- /dev/null +++ b/etc/templates/revert/firebird.tmpl @@ -0,0 +1,5 @@ +-- Revert [% project %]:[% change %] from [% engine %] + +-- XXX Add DDLs here. + +COMMIT; diff --git a/etc/templates/revert/mysql.tmpl b/etc/templates/revert/mysql.tmpl new file mode 100644 index 00000000..c17751d8 --- /dev/null +++ b/etc/templates/revert/mysql.tmpl @@ -0,0 +1,7 @@ +-- Revert [% project %]:[% change %] from [% engine %] + +BEGIN; + +-- XXX Add DDLs here. + +COMMIT; diff --git a/etc/templates/revert/oracle.tmpl b/etc/templates/revert/oracle.tmpl new file mode 100644 index 00000000..90ccdeb9 --- /dev/null +++ b/etc/templates/revert/oracle.tmpl @@ -0,0 +1,3 @@ +-- Revert [% project %]:[% change %] from [% engine %] + +-- XXX Add DDLs here. diff --git a/etc/templates/revert/pg.tmpl b/etc/templates/revert/pg.tmpl new file mode 100644 index 00000000..c17751d8 --- /dev/null +++ b/etc/templates/revert/pg.tmpl @@ -0,0 +1,7 @@ +-- Revert [% project %]:[% change %] from [% engine %] + +BEGIN; + +-- XXX Add DDLs here. + +COMMIT; diff --git a/etc/templates/revert/snowflake.tmpl b/etc/templates/revert/snowflake.tmpl new file mode 100644 index 00000000..77034b3b --- /dev/null +++ b/etc/templates/revert/snowflake.tmpl @@ -0,0 +1,5 @@ +-- Revert [% project %]:[% change %] from [% engine %] + +USE WAREHOUSE &warehouse; + +-- XXX Add DDLs here. diff --git a/etc/templates/revert/sqlite.tmpl b/etc/templates/revert/sqlite.tmpl new file mode 100644 index 00000000..c17751d8 --- /dev/null +++ b/etc/templates/revert/sqlite.tmpl @@ -0,0 +1,7 @@ +-- Revert [% project %]:[% change %] from [% engine %] + +BEGIN; + +-- XXX Add DDLs here. + +COMMIT; diff --git a/etc/templates/revert/vertica.tmpl b/etc/templates/revert/vertica.tmpl new file mode 100644 index 00000000..90ccdeb9 --- /dev/null +++ b/etc/templates/revert/vertica.tmpl @@ -0,0 +1,3 @@ +-- Revert [% project %]:[% change %] from [% engine %] + +-- XXX Add DDLs here. diff --git a/etc/templates/verify/exasol.tmpl b/etc/templates/verify/exasol.tmpl new file mode 100644 index 00000000..7dc84652 --- /dev/null +++ b/etc/templates/verify/exasol.tmpl @@ -0,0 +1,5 @@ +-- Verify [% project %]:[% change %] on [% engine %] + +-- XXX Add verifications here. + +ROLLBACK; diff --git a/etc/templates/verify/firebird.tmpl b/etc/templates/verify/firebird.tmpl new file mode 100644 index 00000000..7dc84652 --- /dev/null +++ b/etc/templates/verify/firebird.tmpl @@ -0,0 +1,5 @@ +-- Verify [% project %]:[% change %] on [% engine %] + +-- XXX Add verifications here. + +ROLLBACK; diff --git a/etc/templates/verify/mysql.tmpl b/etc/templates/verify/mysql.tmpl new file mode 100644 index 00000000..0d002b25 --- /dev/null +++ b/etc/templates/verify/mysql.tmpl @@ -0,0 +1,7 @@ +-- Verify [% project %]:[% change %] on [% engine %] + +BEGIN; + +-- XXX Add verifications here. + +ROLLBACK; diff --git a/etc/templates/verify/oracle.tmpl b/etc/templates/verify/oracle.tmpl new file mode 100644 index 00000000..ee2496fb --- /dev/null +++ b/etc/templates/verify/oracle.tmpl @@ -0,0 +1,3 @@ +-- Verify [% project %]:[% change %] on [% engine %] + +-- XXX Add verifications here. diff --git a/etc/templates/verify/pg.tmpl b/etc/templates/verify/pg.tmpl new file mode 100644 index 00000000..0d002b25 --- /dev/null +++ b/etc/templates/verify/pg.tmpl @@ -0,0 +1,7 @@ +-- Verify [% project %]:[% change %] on [% engine %] + +BEGIN; + +-- XXX Add verifications here. + +ROLLBACK; diff --git a/etc/templates/verify/snowflake.tmpl b/etc/templates/verify/snowflake.tmpl new file mode 100644 index 00000000..066d6248 --- /dev/null +++ b/etc/templates/verify/snowflake.tmpl @@ -0,0 +1,5 @@ +-- Verify [% project %]:[% change %] on [% engine %] + +USE WAREHOUSE &warehouse; + +-- XXX Add verifications here. diff --git a/etc/templates/verify/sqlite.tmpl b/etc/templates/verify/sqlite.tmpl new file mode 100644 index 00000000..0d002b25 --- /dev/null +++ b/etc/templates/verify/sqlite.tmpl @@ -0,0 +1,7 @@ +-- Verify [% project %]:[% change %] on [% engine %] + +BEGIN; + +-- XXX Add verifications here. + +ROLLBACK; diff --git a/etc/templates/verify/vertica.tmpl b/etc/templates/verify/vertica.tmpl new file mode 100644 index 00000000..ee2496fb --- /dev/null +++ b/etc/templates/verify/vertica.tmpl @@ -0,0 +1,3 @@ +-- Verify [% project %]:[% change %] on [% engine %] + +-- XXX Add verifications here. diff --git a/etc/tools/upgrade-registry-to-mysql-5.5.0.sql b/etc/tools/upgrade-registry-to-mysql-5.5.0.sql new file mode 100644 index 00000000..ce65d09f --- /dev/null +++ b/etc/tools/upgrade-registry-to-mysql-5.5.0.sql @@ -0,0 +1,43 @@ +-- This script upgrades the Sqitch registry for MySQL 5.5.0 and higher. It +-- creates the checkit() function and sets up triggers in the registry to use +-- it to emulate CHECK constraints. It will then also be available for use in +-- verify scripts, as described in sqitchtutorial-mysql. If you have an +-- existing Sqitch registry that was upgraded from an earlier version of MySQL +-- to 5.5.0 or highher, you'll need to run this script to update it, like so: + +-- mysql -u root sqitch --execute "source `sqitch --etc`/tools/upgrade-registry-to-mysql-5.5.0.sql' + +DELIMITER | + +CREATE FUNCTION checkit(doit INTEGER, message VARCHAR(256)) RETURNS INTEGER DETERMINISTIC +BEGIN + IF doit IS NULL OR doit = 0 THEN + SIGNAL SQLSTATE 'ERR0R' SET MESSAGE_TEXT = message; + END IF; + RETURN doit; +END; +| + +CREATE TRIGGER ck_insert_dependency BEFORE INSERT ON dependencies +FOR EACH ROW BEGIN + -- DO does not work. https://bugs.mysql.com/bug.php?id=69647 + SET @dummy := checkit( + (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL) + OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL), + 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set' + ); +END; +| + +CREATE TRIGGER ck_update_dependency BEFORE UPDATE ON dependencies +FOR EACH ROW BEGIN + -- DO does not work. https://bugs.mysql.com/bug.php?id=69647 + SET @dummy := checkit( + (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL) + OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL), + 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set' + ); +END; +| + +DELIMITER ; diff --git a/etc/tools/upgrade-registry-to-mysql-5.6.4.sql b/etc/tools/upgrade-registry-to-mysql-5.6.4.sql new file mode 100644 index 00000000..ecc68fea --- /dev/null +++ b/etc/tools/upgrade-registry-to-mysql-5.6.4.sql @@ -0,0 +1,15 @@ +-- This script upgrades the Sqitch registry for MySQL 5.6.4 and higher. Sqitch +-- expects datetime columns to have a precision on 5.6.4 and higher. If you have +-- an existing Sqitch registry that was upgraded from an earlier version of MySQL +-- to 5.6.4 or highher, you'll need to run this script to update it, like so: + +-- mysql -u root sqitch --execute "source `sqitch --etc`/tools/upgrade-registry-to-mysql-5.6.4.sql' + +ALTER TABLE releases CHANGE installed_at installed_at DATETIME(6) NOT NULL; +ALTER TABLE projects CHANGE created_at created_at DATETIME(6) NOT NULL; +ALTER TABLE changes CHANGE committed_at committed_at DATETIME(6) NOT NULL, + CHANGE planned_at planned_at DATETIME(6) NOT NULL; +ALTER TABLE tags CHANGE committed_at committed_at DATETIME(6) NOT NULL, + CHANGE planned_at planned_at DATETIME(6) NOT NULL; +ALTER TABLE events CHANGE committed_at committed_at DATETIME(6) NOT NULL, + CHANGE planned_at planned_at DATETIME(6) NOT NULL; diff --git a/inc/Menlo/Sqitch.pm b/inc/Menlo/Sqitch.pm new file mode 100644 index 00000000..4fc98f25 --- /dev/null +++ b/inc/Menlo/Sqitch.pm @@ -0,0 +1,171 @@ +package Menlo::Sqitch; + +use strict; +use warnings; +use base 'Menlo::CLI::Compat'; + +sub new { + shift->SUPER::new( + @_, + _remove => [], + _bld_deps => { map { chomp; $_ => 1 } }, + ); +} + +sub find_prereqs { + my ($self, $dist) = @_; + # Menlo defaults to config, test, runtime. We just want to bundle runtime. + $dist->{want_phases} = ['runtime']; + return $self->SUPER::find_prereqs($dist); +} + +sub configure { + my $self = shift; + my $cmd = $_[0]; + return $self->SUPER::configure(@_) if ref $cmd ne 'ARRAY'; + # Always use vendor install dirs. Hack for + # https://github.com/miyagawa/cpanminus/issues/581. + if ($cmd->[1] eq 'Makefile.PL') { + push @{ $cmd } => 'INSTALLDIRS=vendor'; + } elsif ($cmd->[1] eq 'Build.PL') { + push @{ $cmd } => '--installdirs', 'vendor'; + } + return $self->SUPER::configure(@_); +} + +sub save_meta { + my $self = shift; + my ($module, $dist) = @_; + # Record if we've installed a build-only dependency. + my $dname = $dist->{meta}{name}; + push @{ $self->{_remove} } => $module if $self->{_bld_deps}{$dname}; + $self->SUPER::save_meta(@_); +} + +sub remove_build_dependencies { + # Uninstall modules for distributions not actually needed to run Sqitch. + my $self = shift; + local $self->{force} = 1; + my @fail; + for my $mod (reverse @{ $self->{_remove} }) { + $self->uninstall_module($mod) or push @fail, $mod; + } + return !@fail; +} + +1; + +# List of distirbutions that might be installed but are not actually needed to +# run Sqitch. Used to track unneeded installs so they can be removed by +# remove_build_dependencies(). +# +# Data pasted from the report of build-only dependencies by +# dev/dependency_report. +__DATA__ +AppConfig +Archive-Tar +CGI +CPAN +CPAN-Common-Index +CPAN-DistnameInfo +CPAN-Meta +CPAN-Meta-Check +CPAN-Meta-Requirements +CPAN-Meta-YAML +Capture-Tiny +Class-Tiny +Compress-Raw-Bzip2 +Compress-Raw-Zlib +Config-AutoConf +Devel-CheckLib +Devel-PPPort +Devel-Symdump +Digest +Digest-MD5 +Dist-CheckConflicts +Dumpvalue +Expect +ExtUtils-CBuilder +ExtUtils-Config +ExtUtils-Constant +ExtUtils-Helpers +ExtUtils-Install +ExtUtils-InstallPaths +ExtUtils-MakeMaker +ExtUtils-MakeMaker-CPANfile +ExtUtils-ParseXS +File-Fetch +File-Find-Rule +File-Find-Rule-Perl +File-Listing +File-ShareDir-Install +File-Slurp-Tiny +File-pushd +HTML-Parser +HTML-Tagset +HTTP-CookieJar +HTTP-Cookies +HTTP-Daemon +HTTP-Date +HTTP-Message +HTTP-Negotiate +HTTP-Tiny +HTTP-Tinyish +IO-CaptureOutput +IO-Compress +IO-HTML +IO-Socket-IP +IO-Socket-SSL +IO-Tty +IO-Zlib +IPC-Cmd +IPC-Run +JSON-PP +LWP-MediaTypes +Locale-Maketext-Simple +Menlo +Menlo-Legacy +Mock-Config +Module-Build +Module-Build-Tiny +Module-CPANfile +Module-CoreList +Module-Load +Module-Load-Conditional +Module-Metadata +Module-Signature +Mozilla-CA +Mozilla-PublicSuffix +Net-HTTP +Net-Ping +Net-SSLeay +Number-Compare +Params-Check +Parse-PMFile +Perl-Tidy +Pod-Coverage +Readonly +Safe +Search-Dict +Sub-Uplevel +TermReadKey +Test +Test-Exception +Test-Fatal +Test-Harness +Test-LeakTrace +Test-Pod +Test-Pod-Coverage +Test-Simple +Test-Version +Text-Glob +Text-ParseWords +Tie-File +Tie-Handle-Offset +TimeDate +WWW-RobotRules +Win32-ShellQuote +YAML +inc-latest +libwww-perl +local-lib diff --git a/inc/Module/Build/Sqitch.pm b/inc/Module/Build/Sqitch.pm new file mode 100644 index 00000000..bfe73636 --- /dev/null +++ b/inc/Module/Build/Sqitch.pm @@ -0,0 +1,324 @@ +package Module::Build::Sqitch; + +use strict; +use warnings; +use Module::Build 0.35; +use base 'Module::Build'; +use IO::File (); +use File::Spec (); +use Config (); +use File::Path (); +use File::Copy (); + +__PACKAGE__->add_property($_) for qw(etcdir installed_etcdir); + +# List one more more engines to include in a bundle install. +# --with postgres --with msyql +__PACKAGE__->add_property(with => []); + +# Set dual_life to true to force dual-life modules such as Pod::Simple to be +# incliuded in the bundle directory. +# --dual_life 1 +__PACKAGE__->add_property(dual_life => 0); + +sub new { + my ( $class, %p ) = @_; + if ($^O eq 'MSWin32') { + my $recs = $p{recommends} ||= {}; + $recs->{$_} = 0 for qw( + Win32 + Win32::Console::ANSI + Win32API::Net + ); + $p{requires}{'Win32::Locale'} = 0; + $p{requires}{'Win32::ShellQuote'} = 0; + $p{requires}{'DateTime::TimeZone::Local::Win32'} = 0; + } + if (eval { require Hash::Merge; 1 } && $Hash::Merge::VERSION eq '0.298') { + warn join "\n", ( + '**************************************************************', + '* You have Hash::Merge $Hash::Merge::VERSION, which is broken.', + "**************************************************************\n", + ); + $p{requires}{'Hash::Merge'} = '0.299'; + } + my $self = $class->SUPER::new(%p); + $self->add_build_element('etc'); + $self->add_build_element('mo'); + $self->add_build_element('sql'); + return $self; +} + +sub _getetc { + my $self = shift; + my $prefix; + + if ($self->installdirs eq 'site') { + $prefix = $Config::Config{siteprefix} // $Config::Config{prefix}; + } elsif ($self->installdirs eq 'vendor') { + $prefix = $Config::Config{vendorprefix} // $Config::Config{siteprefix} // $Config::Config{prefix}; + } else { + $prefix = $Config::Config{prefix}; + } + + # Prefer the user-specified directory. + if (my $etc = $self->etcdir) { + return $etc; + } + + # Use a directory under the install base (or prefix). + my @subdirs = qw(etc sqitch); + if ( my $dir = $self->install_base || $self->prefix ) { + return File::Spec->catdir( $dir, @subdirs ); + } + + # Go under Perl's prefix. + return File::Spec->catdir( $prefix, @subdirs ); +} + +sub ACTION_move_old_templates { + my $self = shift; + $self->depends_on('build'); + + # First, rename existing etc dir templates; They were moved in v0.980. + my $notify = 0; + my $tmpl_dir = File::Spec->catdir( + ( $self->destdir ? $self->destdir : ()), + $self->_getetc, + 'templates' + ); + if (-e $tmpl_dir && -d _) { + # Scan for old templates, but only if we can read the directory. + if (opendir my $dh, $tmpl_dir) { + while (my $bn = readdir $dh) { + next unless $bn =~ /^(deploy|verify|revert)[.]tmpl([.]default)?$/; + my ($action, $default) = ($1, $2); + my $file = File::Spec->catfile($tmpl_dir, $bn); + if ($default) { + $self->log_verbose("Unlinking $file\n"); + # Just unlink default files. + unlink $file; + next; + } + # Move action templates to $action/pg.tmpl and $action/sqlite.tmpl. + my $action_dir = File::Spec->catdir($tmpl_dir, $action); + File::Path::mkpath($action_dir) or die; + for my $engine (qw(pg sqlite)) { + my $dest = File::Spec->catdir($action_dir, "$engine.tmpl"); + $self->log_info("Copying old $bn to $dest\n"); + File::Copy::copy($file, $dest) + or die "Cannot copy('$file', '$dest'): $!\n"; + } + + $self->log_verbose("Unlinking $file\n"); + unlink $file; + $notify = 1; + } + } + } + + # If we moved any files, nofify the user that custom templates will need + # to be updated, too. + if ($notify) { + $self->log_warn(q{ + ################################################################# + # WARNING # + # # + # As of v0.980, the location of script templates has changed. # + # The system-wide templates have been moved to their new # + # locations as described above. However, user-specific # + # templates have not been moved. # + # # + # Please inform all users that any custom Sqitch templates in # + # their ~/.sqitch/templates directories must be moved into # + # subdirectories using the appropriate engine name (pg, sqlite, # + # or oracle) as follows: # + # # + # deploy.tmpl -> deploy/$engine.tmpl # + # revert.tmpl -> revert/$engine.tmpl # + # verify.tmpl -> verify/$engine.tmpl # + # # + ################################################################# + } . "\n"); + } +} + +sub ACTION_install { + my ($self, @params) = @_; + $self->depends_on('move_old_templates'); + $self->SUPER::ACTION_install(@_); +} + +sub process_etc_files { + my $self = shift; + my $etc = $self->_getetc; + $self->install_path( etc => $etc ); + + if (my $ddir = $self->destdir) { + # Need to search the final destination directory. + $etc = File::Spec->catdir($ddir, $etc); + } + + for my $file ( @{ $self->rscan_dir( 'etc', sub { -f && !/\.\#/ } ) } ) { + $file = $self->localize_file_path($file); + + # Remove leading `etc/` to get path relative to $etc. + my ($vol, $dirs, $fn) = File::Spec->splitpath($file); + my (undef, @segs) = File::Spec->splitdir($dirs); + my $rel = File::Spec->catpath($vol, File::Spec->catdir(@segs), $fn); + + my $dest = $file; + + # Append .default if file already exists at its ultimate destination + # or if it exists with an old name (to be moved by move_old_templates). + if ( -e File::Spec->catfile($etc, $rel) || ( + $segs[0] eq 'templates' + && $fn =~ /^(?:pg|sqlite)[.]tmpl$/ + && -e File::Spec->catfile($etc, 'templates', "$segs[1].tmpl") + ) ) { + $dest .= '.default'; + } + + $self->copy_if_modified( + from => $file, + to => File::Spec->catfile( $self->blib, $dest ) + ); + } +} + +sub process_pm_files { + my $self = shift; + my $ret = $self->SUPER::process_pm_files(@_); + my $pm = File::Spec->catfile(qw(blib lib App Sqitch Config.pm)); + my $etc = $self->installed_etcdir || $self->_getetc; + + $self->do_system( + $self->perl, '-i.bak', '-pe', + qq{s{my \\\$SYSTEM_DIR = undef}{my \\\$SYSTEM_DIR = q{\Q$etc\E}}}, + $pm, + ); + unlink "$pm.bak"; + + return $ret; +} + +sub fix_shebang_line { + my $self = shift; + # Noting to do after 5.10.0. + return $self->SUPER::fix_shebang_line(@_) if $] > 5.010000; + + # Remove -C from the shebang line. + for my $file (@_) { + my $FIXIN = IO::File->new($file) or die "Can't process '$file': $!"; + local $/ = "\n"; + chomp(my $line = <$FIXIN>); + next unless $line =~ s/^\s*\#!\s*//; # Not a shebang file. + + my ($cmd, $arg) = (split(' ', $line, 2), ''); + next unless $cmd =~ /perl/i && $arg =~ s/ -C\w+//; + + # We removed -C; write the file out. + my $FIXOUT = IO::File->new(">$file.new") + or die "Can't create new $file: $!\n"; + local $\; + undef $/; # Was localized above + print $FIXOUT "#!$cmd $arg", <$FIXIN>; + close $FIXIN; + close $FIXOUT; + + rename($file, "$file.bak") + or die "Can't rename $file to $file.bak: $!"; + + rename("$file.new", $file) + or die "Can't rename $file.new to $file: $!"; + + $self->delete_filetree("$file.bak") + or $self->log_warn("Couldn't clean up $file.bak, leaving it there"); + } + + # Back at it now. + return $self->SUPER::fix_shebang_line(@_); +} + +sub ACTION_bundle { + my ($self, @params) = @_; + my $base = $self->install_base or die "No --install_base specified\n"; + + # XXX Consider replacing with a Carton or Carmel-based solution? + SHHH: { + local $SIG{__WARN__} = sub {}; # Menlo has noisy warnings. + local $ENV{PERL_CPANM_OPT}; # Override cpanm options. + require Menlo::Sqitch; + my $feat = $self->with || []; + $feat = [$feat] unless ref $feat; + my $app = Menlo::Sqitch->new( + quiet => $self->quiet, + verbose => $self->verbose, + notest => 1, + self_contained => 1, + skip_installed => 0, + install_types => [qw(requires recommends)], + local_lib => File::Spec->rel2abs($base), + pod2man => undef, + features => { map { $_ => 1 } @{ $feat } }, + ); + + if ($self->dual_life) { + # Force Install dual-life modules. + $app->{argv} = [qw( + File::Temp Scalar::Util Pod::Usage Digest::SHA Pod::Escapes + Pod::Find Getopt::Long Time::HiRes File::Path List::Util + Encode Pod::Simple Time::Local parent IO::File if + Term::ANSIColor + )]; + die "Error installing modules: $@\n" if $app->run; + } + + # Install required modules, but not Sqitch itself. + $app->{argv} = ['.']; + $app->{installdeps} = 1; + die "Error installing modules: $@\n" if $app->run; + + # Remove unneeded build-time dependencies. + die "Error removing build modules: $@\n" + unless $app->remove_build_dependencies; + } + + # Install Sqitch. Required to intall man pages. + $self->depends_on('install'); + + # Delete unneeded files. + $self->delete_filetree(File::Spec->catdir($base, qw(lib perl5 Test))); + $self->delete_filetree(File::Spec->catdir($base, qw(bin))); + for my $file (@{ $self->rscan_dir($base, qr/[.](?:meta|packlist)$/) }) { + $self->delete_filetree($file); + } + + # Install sqitch script using FindBin. + $self->_copy_findbin_script; + + # Delete empty directories. + File::Find::finddepth(sub{rmdir},$base); +} + +sub _copy_findbin_script { + my $self = shift; + # XXX Switch to lib/perl5. + my $bin = $self->install_destination('script'); + my $script = File::Spec->catfile(qw(bin sqitch)); + my $dest = File::Spec->catfile($bin, 'sqitch'); + my $result = $self->copy_if_modified($script, $bin, 'flatten') or return; + $self->fix_shebang_line($result) unless $self->is_vmsish; + $self->_set_findbin($result); + $self->make_executable($result); +} + +sub _set_findbin { + my ($self, $file) = @_; + local $^I = ''; + local @ARGV = ($file); + while (<>) { + s{^BEGIN}{use FindBin;\nuse lib "\$FindBin::RealBin/../lib/perl5";\nBEGIN}; + print; + } +} diff --git a/lib/App/Sqitch.pm b/lib/App/Sqitch.pm new file mode 100644 index 00000000..139d7837 --- /dev/null +++ b/lib/App/Sqitch.pm @@ -0,0 +1,927 @@ +package App::Sqitch; + +# ABSTRACT: Sensible database change management + +use 5.010; +use strict; +use warnings; +use utf8; +use Getopt::Long; +use Hash::Merge qw(merge); +use Path::Class; +use Config; +use Locale::TextDomain 1.20 qw(App-Sqitch); +use Locale::Messages qw(bind_textdomain_filter); +use App::Sqitch::X qw(hurl); +use Moo 1.002000; +use Type::Utils qw(where declare); +use App::Sqitch::Types qw(Str UserName UserEmail Maybe Config HashRef); +use Encode (); +use Try::Tiny; +use List::Util qw(first); +use IPC::System::Simple 1.17 qw(runx capturex $EXITVAL); +use namespace::autoclean 0.16; +use constant ISWIN => $^O eq 'MSWin32'; + +our $VERSION = 'v1.0.0'; # VERSION + +BEGIN { + # Force Locale::TextDomain to encode in UTF-8 and to decode all messages. + $ENV{OUTPUT_CHARSET} = 'UTF-8'; + bind_textdomain_filter 'App-Sqitch' => \&Encode::decode_utf8, Encode::FB_DEFAULT; +} + +# Okay to load Sqitch classes now that types are created. +use App::Sqitch::Config; +use App::Sqitch::Command; +use App::Sqitch::Plan; + +has options => ( + is => 'ro', + isa => HashRef, + default => sub { {} }, +); + +has verbosity => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + $self->options->{verbosity} // $self->config->get( key => 'core.verbosity' ) // 1; + } +); + +has sysuser => ( + is => 'ro', + isa => Maybe[Str], + lazy => 1, + default => sub { + $ENV{ SQITCH_ORIG_SYSUSER } || do { + # Adapted from User.pm. + require Encode::Locale; + return Encode::decode( locale => getlogin ) + || Encode::decode( locale => scalar getpwuid( $< ) ) + || $ENV{ LOGNAME } + || $ENV{ USER } + || $ENV{ USERNAME } + || try { + require Win32; + Encode::decode( locale => Win32::LoginName() ) + }; + }; + }, +); + +has user_name => ( + is => 'ro', + lazy => 1, + isa => UserName, + default => sub { + my $self = shift; + $ENV{ SQITCH_FULLNAME } + || $self->config->get( key => 'user.name' ) + || $ENV{ SQITCH_ORIG_FULLNAME } + || do { + my $sysname = $self->sysuser || hurl user => __( + 'Cannot find your name; run sqitch config --user user.name "YOUR NAME"' + ); + if (ISWIN) { + try { require Win32API::Net } || return $sysname; + Win32API::Net::UserGetInfo( "", $sysname, 10, my $info = {} ); + return $sysname unless $info->{fullName}; + require Encode::Locale; + return Encode::decode( locale => $info->{fullName} ); + } + require User::pwent; + my $name = User::pwent::getpwnam($sysname) || return $sysname; + require Encode::Locale; + return Encode::decode( locale => ($name->gecos)[0] ); + }; + } +); + +has user_email => ( + is => 'ro', + lazy => 1, + isa => UserEmail, + default => sub { + my $self = shift; + $ENV{ SQITCH_EMAIL } + || $self->config->get( key => 'user.email' ) + || $ENV{ SQITCH_ORIG_EMAIL } + || do { + my $sysname = $self->sysuser || hurl user => __( + 'Cannot infer your email address; run sqitch config --user user.email you@host.com' + ); + require Sys::Hostname; + "$sysname@" . Sys::Hostname::hostname(); + }; + } +); + +has config => ( + is => 'ro', + isa => Config, + lazy => 1, + default => sub { + App::Sqitch::Config->new; + } +); + +has editor => ( + is => 'ro', + lazy => 1, + default => sub { + return + $ENV{SQITCH_EDITOR} + || shift->config->get( key => 'core.editor' ) + || $ENV{VISUAL} + || $ENV{EDITOR} + || ( ISWIN ? 'notepad.exe' : 'vi' ); + } +); + +has pager_program => ( + is => "ro", + lazy => 1, + default => sub { + my $self = shift; + return + $ENV{SQITCH_PAGER} + || $self->config->get(key => "core.pager") + || $ENV{PAGER}; + }, +); + +has pager => ( + is => 'ro', + lazy => 1, + isa => declare('Pager', where { + eval { $_->isa('IO::Pager') || $_->isa('IO::Handle') } + }), + default => sub { + # Dupe and configure STDOUT. + require IO::Handle; + my $fh = IO::Handle->new_from_fd(*STDOUT, 'w'); + binmode $fh, ':utf8_strict'; + + # Just return if no pager is wanted or there is no TTY. + return $fh if shift->options->{no_pager} || !(-t *STDOUT); + + # Load IO::Pager and tie the handle to it. + eval "use IO::Pager 0.34"; die $@ if $@; + return IO::Pager->new($fh, ':utf8_strict'); + }, +); + +sub go { + my $class = shift; + my @args = @ARGV; + + # 1. Parse core options. + my $opts = $class->_parse_core_opts(\@args); + + # 2. Load config. + my $config = App::Sqitch::Config->new; + + # 3. Instantiate Sqitch. + my $sqitch = $class->new({ options => $opts, config => $config }); + + # 4. Find the command. + my $cmd = $class->_find_cmd(\@args); + + # 5. Instantiate the command object. + my $command = $cmd->create({ + sqitch => $sqitch, + config => $config, + args => \@args, + }); + + # IO::Pager respects the PAGER environment variable. + local $ENV{PAGER} = $sqitch->pager_program; + + # 6. Execute command. + return try { + $command->execute( @args ) ? 0 : 2; + } catch { + # Just bail for unknown exceptions. + $sqitch->vent($_) && return 2 unless eval { $_->isa('App::Sqitch::X') }; + + # It's one of ours. + if ($_->exitval == 1) { + # Non-fatal exception; just send the message to info. + $sqitch->info($_->message); + } else { + # Fatal exception; vent. + $sqitch->vent($_->message); + + # Emit the stack trace. DEV errors should be vented; otherwise trace. + my $meth = $_->ident eq 'DEV' ? 'vent' : 'trace'; + $sqitch->$meth($_->stack_trace->as_string); + } + + # Bail. + return $_->exitval; + }; +} + +sub _core_opts { + return qw( + chdir|cd|C=s + etc-path + no-pager + quiet + verbose|V|v+ + help + man + version + ); +} + +sub _parse_core_opts { + my ( $self, $args ) = @_; + my %opts; + Getopt::Long::Configure(qw(bundling pass_through)); + Getopt::Long::GetOptionsFromArray( + $args, + map { + ( my $k = $_ ) =~ s/[|=+:!].*//; + $k =~ s/-/_/g; + $_ => \$opts{$k}; + } $self->_core_opts + ) or $self->_pod2usage('sqitchusage', '-verbose' => 99 ); + + # Handle documentation requests. + if ($opts{help} || $opts{man}) { + $self->_pod2usage( + $opts{help} ? 'sqitchcommands' : 'sqitch', + '-exitval' => 0, + '-verbose' => 2, + ); + } + + # Handle version request. + if ( delete $opts{version} ) { + require File::Basename; + my $fn = File::Basename::basename($0); + print $fn, ' (', __PACKAGE__, ') ', __PACKAGE__->VERSION, "\n"; + exit; + } + + # Handle --etc-path. + if ( $opts{etc_path} ) { + say App::Sqitch::Config->class->system_dir; + exit; + } + + # Handle --chdir + if ( my $dir = delete $opts{chdir} ) { + chdir $dir or hurl fs => __x( + 'Cannot change to directory {directory}: {error}', + directory => $dir, + error => $!, + ); + } + + # Normalize the options (remove undefs) and return. + $opts{verbosity} = delete $opts{verbose}; + $opts{verbosity} = 0 if delete $opts{quiet}; + delete $opts{$_} for grep { !defined $opts{$_} } keys %opts; + return \%opts; +} + +sub _find_cmd { + my ( $class, $args ) = @_; + my (@tried, $prev); + for (my $i = 0; $i <= $#$args; $i++) { + my $arg = $args->[$i] or next; + if ($arg =~ /^-/) { + last if $arg eq '--'; + # Skip the next argument if this looks like a pre-0.9999 option. + # There shouldn't be many since we now recommend putting options + # after the command. XXX Remove at some future date. + $i++ if $arg =~ /^(?:-[duhp])|(?:--(?:db-\w+|client|engine|extension|plan-file|registry|top-dir))$/; + next; + } + push @tried => $arg; + my $cmd = try { App::Sqitch::Command->class_for($class, $arg) } or next; + splice @{ $args }, $i, 1; + return $cmd; + } + + # No valid command found. Report those we tried. + $class->vent(__x( + '"{command}" is not a valid command', + command => $_, + )) for @tried; + $class->_pod2usage('sqitchcommands'); +} + +sub _pod2usage { + my ( $self, $doc ) = ( shift, shift ); + require App::Sqitch::Command::help; + # Help does not need the Sqitch command; since it's required, fake it. + my $help = App::Sqitch::Command::help->new( sqitch => bless {}, $self ); + $help->find_and_show( $doc || 'sqitch', '-exitval' => 2, @_ ); +} + +sub run { + my $self = shift; + local $SIG{__DIE__} = sub { + ( my $msg = shift ) =~ s/\s+at\s+.+/\n/ms; + die $msg; + }; + if (ISWIN && IPC::System::Simple->VERSION <= 1.25) { + runx ( shift, $self->quote_shell(@_) ); + return $self; + } + runx @_; + return $self; +} + +sub shell { + my ($self, $cmd) = @_; + local $SIG{__DIE__} = sub { + ( my $msg = shift ) =~ s/\s+at\s+.+/\n/ms; + die $msg; + }; + IPC::System::Simple::run $cmd; + return $self; +} + +sub quote_shell { + my $self = shift; + if (ISWIN) { + require Win32::ShellQuote; + return Win32::ShellQuote::quote_native(@_); + } + require String::ShellQuote; + return String::ShellQuote::shell_quote(@_); +} + +sub capture { + my $self = shift; + local $SIG{__DIE__} = sub { + ( my $msg = shift ) =~ s/\s+at\s+.+/\n/ms; + die $msg; + }; + return capturex ( shift, $self->quote_shell(@_) ) + if ISWIN && IPC::System::Simple->VERSION <= 1.25; + capturex @_; +} + +sub _is_interactive { + return -t STDIN && (-t STDOUT || !(-f STDOUT || -c STDOUT)) ; # Pipe? +} + +sub _is_unattended { + my $self = shift; + return !$self->_is_interactive && eof STDIN; +} + +sub _readline { + my $self = shift; + return undef if $self->_is_unattended; + my $answer = ; + chomp $answer if defined $answer; + return $answer; +} + +sub prompt { + my $self = shift; + my $msg = shift or hurl 'prompt() called without a prompt message'; + + # use a list to distinguish a default of undef() from no default + my @def; + @def = (shift) if @_; + # use dispdef for output + my @dispdef = scalar(@def) + ? ('[', (defined($def[0]) ? $def[0] : ''), '] ') + : ('', ''); + + # Don't use emit because it adds a newline. + local $|=1; + print $msg, ' ', @dispdef; + + if ($self->_is_unattended) { + hurl io => __( + 'Sqitch seems to be unattended and there is no default value for this question' + ) unless @def; + print "$dispdef[1]\n"; + } + + my $ans = $self->_readline; + + if ( !defined $ans or !length $ans ) { + # Ctrl-D or user hit return; + $ans = @def ? $def[0] : ''; + } + + return $ans; +} + +sub ask_yes_no { + my ($self, @msg) = (shift, shift); + hurl 'ask_yes_no() called without a prompt message' unless $msg[0]; + + my $y = __p 'Confirm prompt answer yes', 'Yes'; + my $n = __p 'Confirm prompt answer no', 'No'; + push @msg => $_[0] ? $y : $n if @_; + + my $answer; + my $i = 3; + while ($i--) { + $answer = $self->prompt(@msg); + return 1 if $y =~ /^\Q$answer/i; + return 0 if $n =~ /^\Q$answer/i; + $self->emit(__ 'Please answer "y" or "n".'); + } + + hurl io => __ 'No valid answer after 3 attempts; aborting'; +} + +sub ask_y_n { + my $self = shift; + $self->warn('The ask_y_n() method has been deprecated. Use ask_yes_no() instead.'); + return $self->ask_yes_no(@_) unless @_ > 1; + + my ($msg, $def) = @_; + hurl 'Invalid default value: ask_y_n() default must be "y" or "n"' + if $def && $def !~ /^[yn]/i; + return $self->ask_yes_no($msg, $def =~ /^y/i ? 1 : 0); +} + +sub spool { + my ($self, $fh) = (shift, shift); + local $SIG{__WARN__} = sub { }; # Silence warning. + my $pipe; + if (ISWIN) { + no warnings; + open $pipe, '|' . $self->quote_shell(@_) or hurl io => __x( + 'Cannot exec {command}: {error}', + command => $_[0], + error => $!, + ); + } else { + no warnings; + open $pipe, '|-', @_ or hurl io => __x( + 'Cannot exec {command}: {error}', + command => $_[0], + error => $!, + ); + } + + local $SIG{PIPE} = sub { die 'spooler pipe broke' }; + if (ref $fh eq 'ARRAY') { + for my $h (@{ $fh }) { + print $pipe $_ while <$h>; + } + } else { + print $pipe $_ while <$fh>; + } + + close $pipe or hurl io => $! ? __x( + 'Error closing pipe to {command}: {error}', + command => $_[0], + error => $!, + ) : __x( + '{command} unexpectedly returned exit value {exitval}', + command => $_[0], + exitval => ($? >> 8), + ); + return $self; +} + +sub probe { + my ($ret) = shift->capture(@_); + chomp $ret if $ret; + return $ret; +} + +sub _bn { + require File::Basename; + File::Basename::basename($0); +} + +sub _prepend { + my $prefix = shift; + my $msg = join '', map { $_ // '' } @_; + $msg =~ s/^/$prefix /gms; + return $msg; +} + +sub page { + my $pager = shift->pager; + return $pager->say(@_); +} + +sub page_literal { + my $pager = shift->pager; + return $pager->print(@_); +} + +sub trace { + my $self = shift; + $self->emit( _prepend 'trace:', @_ ) if $self->verbosity > 2; +} + +sub trace_literal { + my $self = shift; + $self->emit_literal( _prepend 'trace:', @_ ) if $self->verbosity > 2; +} + +sub debug { + my $self = shift; + $self->emit( _prepend 'debug:', @_ ) if $self->verbosity > 1; +} + +sub debug_literal { + my $self = shift; + $self->emit_literal( _prepend 'debug:', @_ ) if $self->verbosity > 1; +} + +sub info { + my $self = shift; + $self->emit(@_) if $self->verbosity; +} + +sub info_literal { + my $self = shift; + $self->emit_literal(@_) if $self->verbosity; +} + +sub comment { + my $self = shift; + $self->emit( _prepend '#', @_ ); +} + +sub comment_literal { + my $self = shift; + $self->emit_literal( _prepend '#', @_ ); +} + +sub emit { + shift; + local $|=1; + say @_; +} + +sub emit_literal { + shift; + local $|=1; + print @_; +} + +sub vent { + shift; + my $fh = select; + select STDERR; + local $|=1; + say STDERR @_; + select $fh; +} + +sub vent_literal { + shift; + my $fh = select; + select STDERR; + local $|=1; + print STDERR @_; + select $fh; +} + +sub warn { + my $self = shift; + $self->vent(_prepend 'warning:', @_); +} + +sub warn_literal { + my $self = shift; + $self->vent_literal(_prepend 'warning:', @_); +} + +1; + +__END__ + +=head1 Name + +App::Sqitch - Sensible database change management + +=head1 Synopsis + + use App::Sqitch; + exit App::Sqitch->go; + +=head1 Description + +This module provides the implementation for L. You probably want to +read L, or L. Unless +you want to hack on Sqitch itself, or provide support for a new engine or +L. In which case, you will find this API +documentation useful. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + App::Sqitch->go; + +Called from C, this class method parses command-line options and +arguments in C<@ARGV>, parses the configuration file, constructs an +App::Sqitch object, constructs a command object, and runs it. + +=head2 Constructor + +=head3 C + + my $sqitch = App::Sqitch->new(\%params); + +Constructs and returns a new Sqitch object. The supported parameters include: + +=over + +=item C + +=item C + +=item C + +=item C + +=item C + +=back + +=head2 Accessors + +=head3 C + +=head3 C + +=head3 C + +=head3 C + + my $options = $sqitch->options; + +Returns a hashref of the core command-line options. + +=head3 C + + my $config = $sqitch->config; + +Returns the full configuration, combined from the project, user, and system +configuration files. + +=head3 C + +=head2 Instance Methods + +=head3 C + + $sqitch->run('echo', '-n', 'hello'); + +Runs a system command and waits for it to finish. Throws an exception on +error. Does not use the shell, so arguments must be passed as a list. Use +C to run a command and its arguments as a single string. + +=over + +=item C + +The name of the target, as passed. + +=item C + +A L object, to be used to connect to the target +database. + + +=item C + +The name of the Sqitch registry in the target database. + +=back + +If the C<$target> argument looks like a database URI, it will simply returned +in the hash reference. If the C<$target> argument corresponds to a target +configuration key, the target configuration will be returned, with the C +value a upgraded to a L object. Otherwise returns C. + +=head3 C + + $sqitch->shell('echo -n hello'); + +Shells out a system command and waits for it to finish. Throws an exception on +error. Always uses the shell, so a single string must be passed encapsulating +the entire command and its arguments. Use C to assemble strings +into a single shell command. Use C to execute a list without a shell. + +=head3 C + + my $cmd = $sqitch->quote_shell('echo', '-n', 'hello'); + +Assemble a list into a single string quoted for execution by C. Useful +for combining a specified command, such as C, which might include +the options in the string, for example: + + $sqitch->shell( $sqitch->editor, $sqitch->quote_shell($file) ); + +=head3 C + + my @files = $sqitch->capture(qw(ls -lah)); + +Runs a system command and captures its output to C. Returns the output +lines in list context and the concatenation of the lines in scalar context. +Throws an exception on error. + +=head3 C + + my $git_version = $sqitch->capture(qw(git --version)); + +Like C, but returns just the Ced first line of output. + +=head3 C + + $sqitch->spool($sql_file_handle, 'sqlite3', 'my.db'); + $sqitch->spool(\@file_handles, 'sqlite3', 'my.db'); + +Like run, but spools the contents of one or ore file handle to the standard +input the system command. Returns true on success and throws an exception on +failure. + +=head3 C + +=head3 C + + $sqitch->trace_literal('About to fuzzle the wuzzle.'); + $sqitch->trace('Done.'); + +Send trace information to C if the verbosity level is 3 or higher. +Trace messages will have C prefixed to every line. If it's lower than +3, nothing will be output. C appends a newline to the end of the +message while C does not. + +=head3 C + +=head3 C + + $sqitch->debug('Found snuggle in the crib.'); + $sqitch->debug_literal('ITYM "snuggie".'); + +Send debug information to C if the verbosity level is 2 or higher. +Debug messages will have C prefixed to every line. If it's lower than +2, nothing will be output. C appends a newline to the end of the +message while C does not. + +=head3 C + +=head3 C + + $sqitch->info('Nothing to deploy (up-to-date)'); + $sqitch->info_literal('Going to frobble the shiznet.'); + +Send informational message to C if the verbosity level is 1 or higher, +which, by default, it is. Should be used for normal messages the user would +normally want to see. If verbosity is lower than 1, nothing will be output. +C appends a newline to the end of the message while C does +not. + +=head3 C + +=head3 C + + $sqitch->comment('On database flipr_test'); + $sqitch->comment_literal('Uh-oh...'); + +Send comments to C if the verbosity level is 1 or higher, which, by +default, it is. Comments have C<# > prefixed to every line. If verbosity is +lower than 1, nothing will be output. C appends a newline to the end +of the message while C does not. + +=head3 C + +=head3 C + + $sqitch->emit('core.editor=emacs'); + $sqitch->emit_literal('Getting ready...'); + +Send a message to C, without regard to the verbosity. Should be used +only if the user explicitly asks for output, such as for C. C appends a newline to the end of the message while +C does not. + +=head3 C + +=head3 C + + $sqitch->vent('That was a misage.'); + $sqitch->vent_literal('This is going to be bad...'); + +Send a message to C, without regard to the verbosity. Should be used +only for error messages to be printed before exiting with an error, such as +when reverting failed changes. C appends a newline to the end of the +message while C does not. + +=head3 C + +=head3 C + + $sqitch->page('Search results:'); + $sqitch->page("Here we go\n"); + +Like C, but sends the output to a pager handle rather than C. +Unless there is no TTY (such as when output is being piped elsewhere), in +which case it I sent to C. C appends a newline to the end of +the message while C does not. Meant to be used to send a lot of +data to the user at once, such as when display the results of searching the +event log: + + $iter = $engine->search_events; + while ( my $change = $iter->() ) { + $sqitch->page(join ' - ', @{ $change }{ qw(change_id event change) }); + } + +=head3 C + +=head3 C + + $sqitch->warn('Could not find nerble; using nobble instead.'); + $sqitch->warn_literal("Cannot read file: $!\n"); + +Send a warning messages to C. Warnings will have C prefixed +to every line. Use if something unexpected happened but you can recover from +it. C appends a newline to the end of the message while C +does not. + +=head3 C + + my $ans = $sqitch->('Why would you want to do this?', 'because'); + +Prompts the user for input and returns that input. Pass in an optional default +value for the user to accept or to be used if Sqitch is running unattended. An +exception will be thrown if there is no prompt message or if Sqitch is +unattended and there is no default value. + +=head3 C + + if ( $sqitch->ask_yes_no('Are you sure?', 1) ) { # do it! } + +Prompts the user with a "yes" or "no" question. Returns true if the user +replies in the affirmative and false if the reply is in the negative. If the +optional second argument is passed and true, the answer will default to the +affirmative. If the second argument is passed but false, the answer will +default to the negative. When a translation library is in use, the affirmative +and negative replies from the user should be localized variants of "yes" and +"no", and will be matched as such. If no translation library is in use, the +answers will default to the English "yes" and "no". + +If the user inputs an invalid value three times, an exception will be thrown. +An exception will also be thrown if there is no message. As with C, +an exception will be thrown if Sqitch is running unattended and there is no +default. + +=head3 C + +This method has been deprecated in favor of C and will be +removed in a future version of Sqitch. + + +=head2 Constants + +=head3 C + + my $app = 'sqitch' . ( ISWIN ? '.bat' : '' ); + +True when Sqitch is running on Windows, and false when it's not. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command.pm b/lib/App/Sqitch/Command.pm new file mode 100644 index 00000000..a80fc553 --- /dev/null +++ b/lib/App/Sqitch/Command.pm @@ -0,0 +1,774 @@ +package App::Sqitch::Command; + +use 5.010; +use strict; +use warnings; +use utf8; +use Try::Tiny; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use Hash::Merge 'merge'; +use Moo; +use App::Sqitch::Types qw(Sqitch Target); + +our $VERSION = 'v1.0.0'; # VERSION + +use constant ENGINES => qw( + pg + sqlite + mysql + oracle + firebird + vertica + exasol + snowflake +); + +has sqitch => ( + is => 'ro', + isa => Sqitch, + required => 1, + handles => [qw( + run + shell + quote_shell + capture + probe + verbosity + trace + trace_literal + debug + debug_literal + info + info_literal + comment + comment_literal + emit + emit_literal + vent + vent_literal + warn + warn_literal + page + page_literal + prompt + ask_y_n + )], +); + +has default_target => ( + is => 'ro', + isa => Target, + lazy => 1, + default => sub { + my $self = shift; + my $sqitch = $self->sqitch; + my @params = $self->target_params; + unless ( + $sqitch->config->get(key => 'core.engine') + || $sqitch->config->get(key => 'core.target') + ) { + # No specified engine, so specify an engineless URI. + require URI::db; + unshift @params, uri => URI::db->new('db:'); + } + require App::Sqitch::Target; + return App::Sqitch::Target->new(@params); + }, +); + +sub command { + my $class = ref $_[0] || shift; + return '' if $class eq __PACKAGE__; + my $pkg = quotemeta __PACKAGE__; + $class =~ s/^$pkg\:://; + $class =~ s/_/-/g; + return $class; +} + +sub class_for { + my ( $class, $sqitch, $cmd ) = @_; + + $cmd =~ s/-/_/g; + + # Load the command class. + my $pkg = __PACKAGE__ . "::$cmd"; + eval "require $pkg; 1" or do { + # Emit the original error for debugging. + $sqitch->debug($@); + return undef; + }; + return $pkg; +} + +sub load { + my ( $class, $p ) = @_; + # We should have a command. + my $cmd = delete $p->{command} or $class->usage; + my $pkg = $class->class_for($p->{sqitch}, $cmd) or hurl { + ident => 'command', + exitval => 1, + message => __x( + '"{command}" is not a valid command', + command => $cmd, + ), + }; + $pkg->create($p); +} + +sub create { + my ( $class, $p ) = @_; + + # Merge the command-line options and configuration parameters + my $params = $class->configure( + $p->{config}, + $class->_parse_opts( $p->{args} ) + ); + + # Instantiate and return the command. + $params->{sqitch} = $p->{sqitch}; + return $class->new($params); +} + +sub configure { + my ( $class, $config, $options ) = @_; + + return Hash::Merge->new->merge( + $options, + $config->get_section( section => $class->command ), + ); +} + +sub options { + return; +} + +sub _parse_opts { + my ( $class, $args ) = @_; + return {} unless $args && @{$args}; + + my %opts; + Getopt::Long::Configure(qw(bundling no_pass_through)); + Getopt::Long::GetOptionsFromArray( $args, \%opts, $class->options ) + or $class->usage; + + # Convert dashes to underscores. + for my $k (keys %opts) { + next unless ( my $nk = $k ) =~ s/-/_/g; + $opts{$nk} = delete $opts{$k}; + } + + return \%opts; +} + +sub _bn { + require File::Basename; + File::Basename::basename($0); +} + +sub _pod2usage { + my ( $self, %params ) = @_; + my $command = $self->command; + require Pod::Find; + require Pod::Usage; + my $bn = _bn; + my $find_pod = sub { + Pod::Find::pod_where({ '-inc' => 1, '-script' => 1 }, shift ); + }; + $params{'-input'} ||= $find_pod->("$bn-$command") + || $find_pod->("sqitch-$command") + || $find_pod->($bn) + || $find_pod->('sqitch') + || $find_pod->(ref $self || $self) + || $find_pod->(__PACKAGE__); + Pod::Usage::pod2usage( + '-verbose' => 99, + '-sections' => '(?i:(Usage|Synopsis|Options))', + '-exitval' => 2, + %params, + ); +} + +sub execute { + my $self = shift; + hurl( + 'The execute() method must be called from a subclass of ' + . __PACKAGE__ + ) if ref $self eq __PACKAGE__; + + hurl 'The execute() method has not been overridden in ' . ref $self; +} + +sub usage { + my $self = shift; + require Pod::Find; + my $upod = _bn . '-' . $self->command . '-usage'; + $self->_pod2usage( + '-input' => Pod::Find::pod_where( { '-inc' => 1 }, $upod ) || undef, + '-message' => join '', @_ + ); +} + +sub target_params { + return (sqitch => shift->sqitch); +} + +sub parse_args { + my ($self, %p) = @_; + my $config = $self->sqitch->config; + my @params = $self->target_params; + + # Load the specified or default target. + require App::Sqitch::Target; + my $deftarget_err; + my $target = try { + App::Sqitch::Target->new( @params, name => $p{target} ) + } catch { + # Die if a target was specified; otherwise keep the error for later. + die $_ if $p{target}; + $deftarget_err = $_; + undef; + }; + + # Set up the default results. + my (%seen, %target_for); + my %rec = map { $_ => [] } qw(targets unknown); + $rec{changes} = [] unless $p{no_changes}; + if ($p{target}) { + push @{ $rec{targets} } => $target; + $seen{$target->name}++; + } + + # Iterate over the argsx to look for changes, engines, plans, or targets. + my %engines = map { $_ => 1 } ENGINES; + for my $arg (@{ $p{args} }) { + if ( !$p{no_changes} && $target && -e $target->plan_file && $target->plan->contains($arg) ) { + # A change. + push @{ $rec{changes} } => $arg; + } elsif ($config->get( key => "target.$arg.uri") || URI->new($arg)->isa('URI::db')) { + # A target. Instantiate and keep for subsequente change searches. + $target = App::Sqitch::Target->new( @params, name => $arg ); + push @{ $rec{targets} } => $target unless $seen{$target->name}++; + } elsif ($engines{$arg}) { + # An engine. Add its target. + my $name = $config->get(key => "engine.$arg.target") || "db:$arg:"; + $target = App::Sqitch::Target->new( @params, name => $name ); + push @{ $rec{targets} } => $target unless $seen{$target->name}++; + } elsif (-e $arg) { + # Maybe it's a plan file? + %target_for = map { + $_->plan_file => $_ + } reverse App::Sqitch::Target->all_targets(@params) unless %target_for; + if ($target_for{$arg}) { + # It *is* a plan file. + $target = $target_for{$arg}; + push @{ $rec{targets} } => $target unless $seen{$target->name}++; + } else { + # Nah, who knows. + push @{ $rec{unknown} } => $arg; + } + } else { + # Who knows? + push @{ $rec{unknown} } => $arg; + } + } + + # Replace missing names with unknown values. + my @names = map { $_ || shift @{ $rec{unknown} } } @{ $p{names} || [] }; + + # Die on unknowns. + if (my @unknown = @{ $rec{unknown} } ) { + hurl $self->command => __nx( + 'Unknown argument "{arg}"', + 'Unknown arguments: {arg}', + scalar @unknown, + arg => join ', ', @unknown + ); + } + + # Figure out what targets to access. Use default unless --all. + my @targets = @{ $rec{targets} }; + if ($p{all}) { + # Got --all. + hurl $self->command => __( + 'Cannot specify both --all and engine, target, or plan arugments' + ) if @targets; + @targets = App::Sqitch::Target->all_targets(@params ); + } elsif (!@targets) { + # Use all if tag.all is set, otherwise just the default. + my $key = $self->command . '.all'; + @targets = $self->sqitch->config->get(key => $key, as => 'bool') + ? App::Sqitch::Target->all_targets(@params ) + : do { + # Fall back on the default unless it's invalid. + die $deftarget_err if $deftarget_err; + ($target) + } + } + + return (@names, \@targets, $rec{changes}); +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command - Sqitch Command support + +=head1 Synopsis + + my $cmd = App::Sqitch::Command->load( deploy => \%params ); + $cmd->run; + +=head1 Description + +App::Sqitch::Command is the base class for all Sqitch commands. + +=head1 Interface + +=head2 Constants + +=head3 C + +Returns the list of supported engines, currently: + +=over + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=back + +=head2 Class Methods + +=head3 C + + my @spec = App::Sqitch::Command->options; + +Returns a list of L options specifications. When C loads +the class, any options passed to the command will be parsed using these +values. The keys in the resulting hash will be the first part of each option, +with dashes converted to underscores. This hash will be passed to C +along with a L object for munging into parameters to be +passed to the constructor. + +Here's an example excerpted from the C command: + + sub options { + return qw( + get + unset + list + global + system + config-file=s + ); + } + +This will result in hash keys with the same names as each option except for +C, which will be named C. + +=head3 C + + my $params = App::Sqitch::Command->configure($config, $options); + +Takes two arguments, an L object and the hash of +command-line options as specified by C. The returned hash should be +the result of munging these two objects into a hash reference of parameters to +be passed to the command subclass constructor. + +By default, this method converts dashes to underscores in command-line options +keys, and then merges the configuration values with the options, with the +command-line options taking priority. You may wish to override this method to +do something different. + +=head3 C + + my $subclass = App::Sqitch::Command->subclass_for($sqitch, $cmd_name); + +This method attempts to load the subclass of App::Sqitch::Commmand that +corresponds to the command name. Returns C and sends errors to the +C method of the <$sqitch> object if no such subclass can +be loaded. + +=head2 Constructors + +=head3 C + + my $cmd = App::Sqitch::Command->load( \%params ); + +A factory method for instantiating Sqitch commands. It loads the subclass for +the specified command and calls C to instantiate and return an +instance of the subclass. Sends error messages to the C method of the +C parameter and throws an exception if the subclass does not exist or +cannot be loaded. Supported parameters are: + +=over + +=item C + +The App::Sqitch object driving the whole thing. + +=item C + +An L representing the current application configuration +state. + +=item C + +The name of the command to be executed. + +=item C + +An array reference of command-line arguments passed to the command. + +=back + +=head3 C + + my $pkg = App::Sqitch::Command->class_for( $sqitch, $cmd_name ) + or die "No such command $cmd_name"; + my $cmd = $pkg->create({ + sqitch => $sqitch, + config => $config, + args => \@ARGV, + }); + +Creates and returns a new object for a subclass of App::Sqitch::Command. It +parses options from the C parameter, calls C to merge +configuration with the options, and finally calls C with the resulting +hash. Supported parameters are the same as for C except for the +C parameter, which will be ignored. + +=head3 C + + my $cmd = App::Sqitch::Command->new(%params); + +Instantiates and returns a App::Sqitch::Command object. This method is not +designed to be overridden by subclasses; they should implement +L|Moo::Manual::Construction/BUILDARGS> or +L|Moo::Manual::Construction/BUILD>, instead. + +=head2 Accessors + +=head3 C + + my $sqitch = $cmd->sqitch; + +Returns the L object that instantiated the command. Commands may +access its properties in order to manage global state. + +=head2 Overridable Instance Methods + +These methods should be overridden by all subclasses. + +=head3 C + + $cmd->execute; + +Executes the command. This is the method that does the work of the command. +Must be overridden in all subclasses. Dies if the method is not overridden for +the object on which it is called, or if it is called against a base +App::Sqitch::Command object. + +=head3 C + + my $command = $cmd->command; + +The name of the command. Defaults to the last part of the package name, so as +a rule you should not need to override it, since it is that string that Sqitch +uses to find the command class. + +=head2 Utility Instance Methods + +These methods are mainly provided as utilities for the command subclasses to +use. + +=head3 C + + my $target = $cmd->default_target; + +This method returns the default target. It should only be used by commands +that don't use a C to find and load a target. + +This method should always return a target option, never C. If the +C configuration option has been set, then the target will support +that engine. In the latter case, if C is set, that +value will be used. Otherwise, the returned target will have a URI of C +and no associated engine; the C method will throw an exception. This +behavior should be fine for commands that don't need to load the engine. + +=head3 C + + my ($name1, $name2, $targets, $changes) = $cmd->parse_args( + names => \@names, + target => $target_name, + args => \@args + ); + +Examines each argument to determine whether it's a known change spec or +identifies a target or engine. Unrecognized arguments will replace false +values in the C array reference. Any remaining unknown arguments will +trigger an error. + +Returns a list consisting all the desired names, followed by an array +reference of target objects and an array reference of change specs. + +This method is useful for commands that take a number of arguments where the +order may be mixed. + +The supported parameters are: + +=over + +=item C + +An array reference of the command arguments. + +=item C + +The name of a target, if any. Useful for commands that offer their own +C<--target> option. This target will be the default target, and the first +returned in the targets array. + +=item C + +An array reference of names. If any is false, its place will be taken by an +otherwise unrecognized argument. The number of values in this array reference +determines the number of values returned as names in the return values. Such +values may still be false or undefined; it's up to the caller to decide what +to do about that. + +=item C + +In the event that no targets are recognized (or changes that implicitly +recognize the default target), if this parameter is true, then all known +targets from the configuration will be returned. + +=item C + +If true, the parser will not check to see if any argument corresponds to a +change. The last value returned will be C instead of the usual array +reference. Any argument that might have been recognized as a change will +instead be included in either the C array -- if it's recognized as a +target -- or used to set names to return. Any remaining are considered +unknown arguments and will result in an exception. + +=back + +If a target parameter is passed, it will always be instantiated and returned +as the first item in the "target" array, and arguments recognized as changes +in the plan associated with that target will be returned as changes. + +If no target is passed or appears in the arguments, a default target will be +instantiated based on the command-line options and configuration. Unlike the +target returned by C, this target B have an associated +engine specified by the configuration. This is on the assumption that it will +be used by commands that require an engine to do their work. Of course, any +changes must be recognized from the plan associated with this target. + +Changes are only recognized if they're found in the plan of the target that +precedes them. If no target precedes them, the target specified by the +C parameter or the default target will be searched. Such changes can +be specified in any way documented in L. + +Targets may be recognized by any one of these types of arguments: + +=over + +=item * Target Name + +=item * Database URI + +=item * Engine Name + +=item * Plan File + +=back + +In the case of plan files, C will return the first target it +finds for that plan file, even if multiple targets use the same plan file. The +order of precedence for this determination is the default project target, +followed by named targets, then engine targets. + +=head3 C + + my $target = App::Sqitch::Target->new( $cmd->target_params ); + +Returns a list of parameters suitable for passing to the C or +C constructors of App::Sqitch::Target. + +=head3 C + + $cmd->run('echo hello'); + +Runs a system command and waits for it to finish. Throws an exception on +error. + +=head3 C + + my @files = $cmd->capture(qw(ls -lah)); + +Runs a system command and captures its output to C. Returns the output +lines in list context and the concatenation of the lines in scalar context. +Throws an exception on error. + +=head3 C + + my $git_version = $cmd->capture(qw(git --version)); + +Like C, but returns just the Ced first line of output. + +=head3 C + + my $verbosity = $cmd->verbosity; + +Returns the verbosity level. + +=head3 C + +Send trace information to C if the verbosity level is 3 or higher. +Trace messages will have C prefixed to every line. If it's lower than +3, nothing will be output. + +=head3 C + + $cmd->debug('Found snuggle in the crib.'); + +Send debug information to C if the verbosity level is 2 or higher. +Debug messages will have C prefixed to every line. If it's lower than +2, nothing will be output. + +=head3 C + + $cmd->info('Nothing to deploy (up-to-date)'); + +Send informational message to C if the verbosity level is 1 or higher, +which, by default, it is. Should be used for normal messages the user would +normally want to see. If verbosity is lower than 1, nothing will be output. + +=head3 C + + $cmd->comment('On database flipr_test'); + +Send comments to C if the verbosity level is 1 or higher, which, by +default, it is. Comments have C<# > prefixed to every line. If verbosity is +lower than 1, nothing will be output. + +=head3 C + + $cmd->emit('core.editor=emacs'); + +Send a message to C, without regard to the verbosity. Should be used +only if the user explicitly asks for output, such as for +C. + +=head3 C + + $cmd->vent('That was a misage.'); + +Send a message to C, without regard to the verbosity. Should be used +only for error messages to be printed before exiting with an error, such as +when reverting failed changes. + +=head3 C + + $sqitch->page('Search results:'); + +Like C, but sends the output to a pager handle rather than C. +Unless there is no TTY (such as when output is being piped elsewhere), in +which case it I sent to C. Meant to be used to send a lot of data +to the user at once, such as when display the results of searching the event +log: + + $iter = $engine->search_events; + while ( my $change = $iter->() ) { + $cmd->page(join ' - ', @{ $change }{ qw(change_id event change) }); + } + +=head3 C + + $cmd->warn('Could not find nerble; using nobble instead.'); + +Send a warning messages to C. Warnings will have C prefixed +to every line. Use if something unexpected happened but you can recover from +it. + +=head3 C + + $cmd->usage('Missing "value" argument'); + +Sends the specified message to C, followed by the usage sections of +the command's documentation. Those sections may be named "Name", "Synopsis", +or "Options". Any or all of these will be shown. The doc used to display them +will be the first found of: + +=over + +=item C + +=item C + +=item C + +=item C + +=item C + +=back + +For an ideal usage messages, C should be created by +all command subclasses. + +=head1 See Also + +=over + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/add.pm b/lib/App/Sqitch/Command/add.pm new file mode 100644 index 00000000..633aa660 --- /dev/null +++ b/lib/App/Sqitch/Command/add.pm @@ -0,0 +1,565 @@ +package App::Sqitch::Command::add; + +use 5.010; +use strict; +use warnings; +use utf8; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use Moo; +use App::Sqitch::Types qw(Str Int ArrayRef HashRef Dir Bool Maybe); +use Path::Class; +use Try::Tiny; +use File::Path qw(make_path); +use Clone qw(clone); +use List::Util qw(first); +use namespace::autoclean; + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::ContextCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has change_name => ( + is => 'ro', + isa => Maybe[Str], +); + +has requires => ( + is => 'ro', + isa => ArrayRef[Str], + default => sub { [] }, +); + +has conflicts => ( + is => 'ro', + isa => ArrayRef[Str], + default => sub { [] }, +); + +has all => ( + is => 'ro', + isa => Bool, + default => 0 +); + +has note => ( + is => 'ro', + isa => ArrayRef[Str], + default => sub { [] }, +); + +has variables => ( + is => 'ro', + isa => HashRef, + lazy => 1, + default => sub { + shift->sqitch->config->get_section( section => 'add.variables' ); + }, +); + +has template_directory => ( + is => 'ro', + isa => Maybe[Dir], +); + +has template_name => ( + is => 'ro', + isa => Maybe[Str], +); + +has with_scripts => ( + is => 'ro', + isa => HashRef, + default => sub { {} }, +); + +has templates => ( + is => 'ro', + isa => HashRef, + lazy => 1, + default => sub { + my $self = shift; + $self->_config_templates($self->sqitch->config); + }, +); + +has open_editor => ( + is => 'ro', + isa => Bool, + lazy => 1, + default => sub { + shift->sqitch->config->get( + key => 'add.open_editor', + as => 'bool', + ) // 0; + }, +); + +sub _check_script($) { + my $file = file shift; + + hurl add => __x( + 'Template {template} does not exist', + template => $file, + ) unless -e $file; + + hurl add => __x( + 'Template {template} is not a file', + template => $file, + ) unless -f $file; + + return $file; +} + +sub _config_templates { + my ($self, $config) = @_; + my $tmpl = $config->get_section( section => 'add.templates' ); + $_ = _check_script $_ for values %{ $tmpl }; + return $tmpl; +} + +sub all_templates { + my ($self, $name) = @_; + my $config = $self->sqitch->config; + my $tmpl = $self->templates; + + # Read all the template directories. + for my $dir ( + $self->template_directory, + $config->user_dir->subdir('templates'), + $config->system_dir->subdir('templates'), + ) { + next unless $dir && -d $dir; + for my $subdir($dir->children) { + next unless $subdir->is_dir; + next if $tmpl->{my $script = $subdir->basename}; + my $file = $subdir->file("$name.tmpl"); + $tmpl->{$script} = $file if -f $file + } + } + + # Make sure we have core templates. + my $with = $self->with_scripts; + for my $script (qw(deploy revert verify)) { + hurl add => __x( + 'Cannot find {script} template', + script => $script, + ) if !$tmpl->{$script} && ($with->{$script} || !exists $with->{$script}); + } + + return $tmpl; +} + +sub options { + return qw( + change-name|change|c=s + requires|r=s@ + conflicts|x=s@ + note|n|m=s@ + all|a! + template-name|template|t=s + template-directory=s + with=s@ + without=s@ + use=s% + open-editor|edit|e! + ); +} + +# Override to convert multiple vars to an array. +sub _parse_opts { + my ( $class, $args ) = @_; + + my (%opts, %vars); + Getopt::Long::Configure(qw(bundling no_pass_through)); + Getopt::Long::GetOptionsFromArray( + $args, \%opts, + $class->options, + 'set|s=s%' => sub { + my ($opt, $key, $val) = @_; + if (exists $vars{$key}) { + $vars{$key} = [$vars{$key}] unless ref $vars{$key}; + push @{ $vars{$key} } => $val; + } else { + $vars{$key} = $val; + } + } + ) or $class->usage; + $opts{set} = \%vars if %vars; + + # Convert dashes to underscores. + for my $k (keys %opts) { + next unless ( my $nk = $k ) =~ s/-/_/g; + $opts{$nk} = delete $opts{$k}; + } + + # Merge with and without. + $opts{with_scripts} = { + ( map { $_ => 1 } qw(deploy revert verify) ), + ( map { $_ => 1 } @{ delete $opts{with} || [] } ), + ( map { $_ => 0 } @{ delete $opts{without} || [] } ), + }; + return \%opts; +} + +sub configure { + my ( $class, $config, $opt ) = @_; + + my %params = ( + requires => $opt->{requires} || [], + conflicts => $opt->{conflicts} || [], + note => $opt->{note} || [], + ); + + for my $key (qw(with_scripts change_name)) { + $params{$key} = $opt->{$key} if $opt->{$key}; + } + + if ( + my $dir = $opt->{template_directory} + || $config->get( key => 'add.template_directory' ) + ) { + $dir = $params{template_directory} = dir $dir; + hurl add => __x( + 'Directory "{dir}" does not exist', + dir => $dir, + ) unless -e $dir; + + hurl add => __x( + '"{dir}" is not a directory', + dir => $dir, + ) unless -d $dir; + } + + if ( + my $name = $opt->{template_name} + || $config->get( key => 'add.template_name' ) + ) { + $params{template_name} = $name; + } + + # Merge variables. + if ( my $vars = $opt->{set} ) { + $params{variables} = { + %{ $config->get_section( section => 'add.variables' ) }, + %{ $vars }, + }; + } + + # Merge template info. + my $tmpl = $class->_config_templates($config); + if ( my $use = delete $opt->{use} ) { + while (my ($k, $v) = each %{ $use }) { + $tmpl->{$k} = _check_script $v; + } + } + $params{templates} = $tmpl if %{ $tmpl }; + + # Copy other options. + for my $key (qw(all open_editor)) { + $params{$key} = $opt->{$key} if exists $opt->{$key}; + } + + return \%params; +} + +sub execute { + my $self = shift; + $self->usage unless @_ || $self->change_name; + + my ($name, $targets) = $self->parse_args( + names => [$self->change_name], + all => $self->all, + args => \@_, + no_changes => 1, + ); + + # Check for missing name. + unless (defined $name) { + if (my $target = first { my $n = $_->name; first { $_ eq $n } @_ } @{ $targets }) { + # Name conflicts with a target. + hurl add => __x( + 'Name "{name}" identifies a target; use "--change {name}" to use it for the change name', + name => $target->name, + ); + } + $self->usage; + } + + my $note = join "\n\n", => @{ $self->note }; + my ($first_change, %added, @files, %seen); + + for my $target (@{ $targets }) { + my $plan = $target->plan; + my $with = $self->with_scripts; + my $tmpl = $self->all_templates($self->template_name || $target->engine_key); + my $file = $plan->file; + my $spec = $added{$file} ||= { scripts => [], seen => {} }; + my $change = $spec->{change}; + if ($change) { + # Need a dupe for *this* target so script names are right. + $change = ref($change)->new( + plan => $plan, + name => $change->name, + ); + } else { + $change = $spec->{change} = $plan->add( + name => $name, + requires => $self->requires, + conflicts => $self->conflicts, + note => $note, + ); + $first_change ||= $change; + } + + # Suss out the files we'll need to write. + push @{ $spec->{scripts} } => map { + push @files => $_->[1] unless $seen{$_->[1]}++; + [ $_->[1], $tmpl->{ $_->[0] }, $target->engine_key, $plan->project ]; + } grep { + !$spec->{seen}{ $_->[1] }++; + } map { + [$_ => $change->script_file($_)]; + } grep { + !exists $with->{$_} || $with->{$_} + } sort keys %{ $tmpl }; + } + + # Make sure we have a note. + $note = $first_change->request_note( + for => __ 'add', + scripts => \@files, + ); + + # Time to write everything out. + for my $target (@{ $targets }) { + my $plan = $target->plan; + my $file = $plan->file; + my $spec = delete $added{$file} or next; + + # Write out the scripts. + $self->_add($name, @{ $_ }) for @{ $spec->{scripts} }; + + # We good. Set the note on all changes and write out the plan files. + my $change = $spec->{change}; + $change->note($note); + $plan->write_to( $plan->file ); + $self->info(__x( + 'Added "{change}" to {file}', + change => $spec->{change}->format_op_name_dependencies, + file => $plan->file, + )); + } + + # Let 'em at it. + if ($self->open_editor) { + my $sqitch = $self->sqitch; + $sqitch->shell( $sqitch->editor . ' ' . $sqitch->quote_shell(@files) ); + } + + return $self; +} + +sub _add { + my ( $self, $name, $file, $tmpl, $engine, $project ) = @_; + if (-e $file) { + $self->info(__x( + 'Skipped {file}: already exists', + file => $file, + )); + return $self; + } + + # Create the directory for the file, if it does not exist. + make_path $file->dir->stringify, { error => \my $err }; + if ( my $diag = shift @{ $err } ) { + my ( $path, $msg ) = %{ $diag }; + hurl add => __x( + 'Error creating {path}: {error}', + path => $path, + error => $msg, + ) if $path; + hurl add => $msg; + } + + my $vars = clone { + %{ $self->variables }, + change => $name, + engine => $engine, + project => $project, + requires => $self->requires, + conflicts => $self->conflicts, + }; + + my $fh = $file->open('>:utf8_strict') or hurl add => __x( + 'Cannot open {file}: {error}', + file => $file, + error => $! + ); + + if (eval 'use Template; 1') { + my $tt = Template->new; + $tt->process( $self->_slurp($tmpl), $vars, $fh ) or hurl add => __x( + 'Error executing {template}: {error}', + template => $tmpl, + error => $tt->error, + ); + } else { + eval 'use Template::Tiny 0.11; 1' or die $@; + my $output = ''; + Template::Tiny->new->process( $self->_slurp($tmpl), $vars, \$output ); + print $fh $output; + } + + close $fh or hurl add => __x( + 'Error closing {file}: {error}', + file => $file, + error => $! + ); + $self->info(__x 'Created {file}', file => $file); +} + +sub _slurp { + my ( $self, $tmpl ) = @_; + open my $fh, "<:utf8_strict", $tmpl or hurl add => __x( + 'Cannot open {file}: {error}', + file => $tmpl, + error => $! + ); + local $/; + return \<$fh>; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::add - Add a new change to Sqitch plans + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::add->new(%params); + $cmd->execute; + +=head1 Description + +Adds a new deployment change. This will result in the creation of a scripts in +the deploy, revert, and verify directories. The scripts are based on +L templates in F<~/.sqitch/templates/> or +C<$(prefix)/etc/sqitch/templates> (call C to find out +where, exactly (e.g., C<$(sqitch --etc-path)/sqitch.conf>). + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::add->options; + +Returns a list of L option specifications for the command-line +options for the C command. + +=head3 C + + my $params = App::Sqitch::Command::add->configure( + $config, + $options, + ); + +Processes the configuration and command options and returns a hash suitable +for the constructor. + +=head2 Attributes + +=head3 C + +The name of the change to be added. + +=head3 C + +Text of the change note. + +=head3 C + +List of required changes. + +=head3 C + +List of conflicting changes. + +=head3 C + +Boolean indicating whether or not to run the command against all plans in the +project. + +=head3 C + +The name of the templates to use when generating scripts. Defaults to the +engine for which the scripts are being generated. + +=head3 C + +Directory in which to find the change script templates. + +=head3 C + +Hash reference indicating which scripts to create. + +=head2 Instance Methods + +=head3 C + + $add->execute($command); + +Executes the C command. + +=head3 C + +Returns a hash reference of script names mapped to template files for all +scripts that should be generated for the new change. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/bundle.pm b/lib/App/Sqitch/Command/bundle.pm new file mode 100644 index 00000000..ce68292f --- /dev/null +++ b/lib/App/Sqitch/Command/bundle.pm @@ -0,0 +1,398 @@ +package App::Sqitch::Command::bundle; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo; +use App::Sqitch::Types qw(Str Dir Maybe Bool); +use File::Path qw(make_path); +use Path::Class; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use File::Copy (); +use List::Util qw(first); +use namespace::autoclean; + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::ContextCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has from => ( + is => 'ro', + isa => Maybe[Str], +); + +has to => ( + is => 'ro', + isa => Maybe[Str], +); + +has dest_dir => ( + is => 'ro', + isa => Dir, + lazy => 1, + default => sub { dir 'bundle' }, +); + +has all => ( + is => 'ro', + isa => Bool, + default => 0 +); + +sub dest_top_dir { + my $self = shift; + dir $self->dest_dir, shift->top_dir->relative; +} + +sub dest_dirs_for { + my ($self, $target) = @_; + my $dest = $self->dest_dir; + return { + deploy => dir($dest, $target->deploy_dir->relative), + revert => dir($dest, $target->revert_dir->relative), + verify => dir($dest, $target->verify_dir->relative), + reworked_deploy => dir($dest, $target->reworked_deploy_dir->relative), + reworked_revert => dir($dest, $target->reworked_revert_dir->relative), + reworked_verify => dir($dest, $target->reworked_verify_dir->relative), + }; +} + +sub options { + return qw( + dest-dir|dir=s + all|a! + from=s + to=s + ); +} + +sub configure { + my ( $class, $config, $opt ) = @_; + + my %params; + + if (my $dir = $opt->{dest_dir} || $config->get(key => 'bundle.dest_dir') ) { + $params{dest_dir} = dir $dir; + } + + # Make sure we get the --all, --from and --to options passed through. + for my $key (qw(all from to)) { + $params{$key} = $opt->{$key} if exists $opt->{$key}; + } + + return \%params; +} + +sub execute { + my $self = shift; + my ($targets, $changes) = $self->parse_args( + all => $self->all, + args => \@_, + ); + + # Warn if --to or --from is specified for more thane one target. + if ( @{ $targets } > 1 && ($self->from || $self->to) ) { + $self->sqitch->warn(__( + "Use of --to or --from to bundle multiple targets is not recommended.\nPass them as arguments after each target argument, instead." + )); + } + + # Die if --to or --from and changes are specified. + if ( @{ $changes } && ($self->from || $self->to) ) { + hurl bundle => __( + 'Cannot specify both --from or --to and change arguments' + ); + } + + # Time to get started! + $self->info(__x 'Bundling into {dir}', dir => $self->dest_dir ); + $self->bundle_config; + + if (my @fromto = grep { $_ } $self->from, $self->to) { + # One set of from/to options for all targets. + for my $target (@{ $targets }) { + $self->bundle_plan($target, @fromto); + $self->bundle_scripts($target, @fromto); + } + } else { + # Separate from/to options for all targets. + for my $target (@{ $ targets }) { + my @fromto = splice @{ $changes }, 0, 2; + $self->bundle_plan($target, @fromto); + $self->bundle_scripts($target, @fromto); + } + } + + return $self; +} + +sub _mkpath { + my ( $self, $dir ) = @_; + $self->debug( ' ', __x 'Created {file}', file => $dir ) + if make_path $dir, { error => \my $err }; + + my $diag = shift @{ $err } or return $self; + + my ( $path, $msg ) = %{ $diag }; + hurl bundle => __x( + 'Error creating {path}: {error}', + path => $path, + error => $msg, + ) if $path; + hurl bundle => $msg; +} + +sub _copy_if_modified { + my ( $self, $src, $dst ) = @_; + + hurl bundle => __x( + 'Cannot copy {file}: does not exist', + file => $src, + ) unless -e $src; + + if (-e $dst) { + # Skip the file if it is up-to-date. + return $self if -M $dst <= -M $src; + } else { + # Create the directory. + $self->_mkpath( $dst->dir ); + } + + $self->debug(' ', __x( + "Copying {source} -> {dest}", + source => $src, + dest => $dst + )); + + # Stringify to work around bug in File::Copy warning on 5.10.0. + File::Copy::copy "$src", "$dst" or hurl bundle => __x( + 'Cannot copy "{source}" to "{dest}": {error}', + source => $src, + dest => $dst, + error => $!, + ); + return $self; +} + +sub bundle_config { + my $self = shift; + $self->info(__ 'Writing config'); + my $file = $self->sqitch->config->local_file; + $self->_copy_if_modified( $file, $self->dest_dir->file( $file->basename ) ); +} + +sub bundle_plan { + my ($self, $target, $from, $to) = @_; + + my $dir = $self->dest_top_dir($target); + + if (!defined $from && !defined $to) { + $self->info(__ 'Writing plan'); + my $file = $target->plan_file; + return $self->_copy_if_modified( + $file, + $dir->file( $file->basename ), + ); + } + + $self->info(__x( + 'Writing plan from {from} to {to}', + from => $from // '@ROOT', + to => $to // '@HEAD', + )); + + $self->_mkpath( $dir ); + $target->plan->write_to( + $dir->file( $target->plan_file->basename ), + $from, + $to, + ); +} + +sub bundle_scripts { + my ($self, $target, $from, $to) = @_; + my $plan = $target->plan; + + my $from_index = $plan->index_of( + $from // '@ROOT' + ) // hurl bundle => __x( + 'Cannot find change {change}', + change => $from, + ); + + my $to_index = $plan->index_of( + $to // '@HEAD' + ) // hurl bundle => __x( + 'Cannot find change {change}', + change => $to, + ); + + $self->info(__ 'Writing scripts'); + $plan->position( $from_index ); + my $dir_for = $self->dest_dirs_for($target); + + while ( $plan->position <= $to_index ) { + my $change = $plan->current // last; + $self->info(' + ', $change->format_name_with_tags); + my $prefix = $change->is_reworked ? 'reworked_' : ''; + my @path = $change->path_segments; + if (-e ( my $file = $change->deploy_file )) { + $self->_copy_if_modified( + $file, + $dir_for->{"${prefix}deploy"}->file(@path) + ); + } + if (-e ( my $file = $change->revert_file )) { + $self->_copy_if_modified( + $file, + $dir_for->{"${prefix}revert"}->file(@path) + ); + } + if (-e ( my $file = $change->verify_file )) { + $self->_copy_if_modified( + $file, + $dir_for->{"${prefix}verify"}->file(@path) + ); + } + $plan->next; + } + + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::bundle - Bundle Sqitch changes for distribution + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::bundle->new(%params); + $cmd->execute; + +=head1 Description + +Bundles a Sqitch project for distribution. Done by creating a new directory +and copying the configuration file, plan file, and change files into it. + +=head1 Interface + +=head2 Attributes + +=head3 C + +Change from which to build the bundled plan. + +=head3 C + +Change up to which to build the bundled plan. + +=head3 C + +Boolean indicating whether or not to run the command against all plans in the +project. + +=head2 Instance Methods + +=head3 C + + $bundle->execute($command); + +Executes the C command. + +=head3 C + + $bundle->bundle_config; + +Copies the configuration file to the bundle directory. + +=head3 C + + $bundle->bundle_plan($target); + +Copies the plan file for the specified target to the bundle directory. + +=head3 C + + $bundle->bundle_scripts($target); + +Copies the deploy, revert, and verify scripts for each step in the plan for +the specified target to the bundle directory. Files in the script directories +that do not correspond to changes in the plan will not be copied. + +=head3 C + + my $top_dir = $bundle->top_dir($target); + +Returns the destination top directory for the specified target. + +=head3 C + + my $dirs = $bundle->dest__dirs_for($target); + +Returns a hash of change script destination directories for the specified +target. The keys are the types of scripts, and include: + +=over + +=item C + +=item C + +=item C + +=item C + +=item C + +=item C + +=back + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/checkout.pm b/lib/App/Sqitch/Command/checkout.pm new file mode 100644 index 00000000..4381def6 --- /dev/null +++ b/lib/App/Sqitch/Command/checkout.pm @@ -0,0 +1,211 @@ +package App::Sqitch::Command::checkout; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo; +use App::Sqitch::Types qw(Str); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use App::Sqitch::Plan; +use Path::Class qw(dir); +use Try::Tiny; +use namespace::autoclean; + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::RevertDeployCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has client => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $sqitch = shift->sqitch; + return $sqitch->config->get( key => 'core.vcs.client' ) + || 'git' . ( App::Sqitch::ISWIN ? '.exe' : '' ); + }, +); + +sub configure { {} } + +sub execute { + my $self = shift; + my ($branch, $targets) = $self->parse_args( + target => $self->target, + names => [undef], + args => \@_, + no_changes => 1, + ); + + # Warn on multiple targets. + my $target = shift @{ $targets }; + $self->warn(__x( + 'Too many targets specified; connecting to {target}', + target => $target->name, + )) if @{ $targets }; + + # Now get to work. + my $sqitch = $self->sqitch; + my $git = $self->client; + my $engine = $target->engine; + $engine->with_verify( $self->verify ); + $engine->no_prompt( $self->no_prompt ); + $engine->prompt_accept( $self->prompt_accept ); + $engine->log_only( $self->log_only ); + + # What branch are we on? + my $current_branch = $sqitch->probe($git, qw(rev-parse --abbrev-ref HEAD)); + hurl { + ident => 'checkout', + message => __x('Already on branch {branch}', branch => $branch), + exitval => 1, + } if $current_branch eq $branch; + + # Instantitate a plan without calling $target->plan. + my $from_plan = App::Sqitch::Plan->new( + sqitch => $sqitch, + target => $target, + ); + + # Load the branch plan from Git, assuming the same path. + my $to_plan = App::Sqitch::Plan->new( + sqitch => $sqitch, + target => $target, + )->parse( + # XXX Handle missing file/no contents. + scalar $sqitch->capture( $git, 'show', "$branch:" . $target->plan_file) + ); + + # Find the last change the plans have in common. + my $last_common_change; + for my $change ($to_plan->changes){ + last unless $from_plan->get( $change->id ); + $last_common_change = $change; + } + + hurl checkout => __x( + 'Branch {branch} has no changes in common with current branch {current}', + branch => $branch, + current => $current_branch, + ) unless $last_common_change; + + $sqitch->info(__x( + 'Last change before the branches diverged: {last_change}', + last_change => $last_common_change->format_name_with_tags, + )); + + # Revert to the last common change. + $engine->set_variables( $self->_collect_revert_vars($target) ); + $engine->plan( $from_plan ); + try { + $engine->revert( $last_common_change->id ); + } catch { + # Rethrow unknown errors or errors with exitval > 1. + die $_ if ! eval { $_->isa('App::Sqitch::X') } + || $_->exitval > 1 + || $_->ident eq 'revert:confirm'; + # Emite notice of non-fatal errors (e.g., nothing to revert). + $self->info($_->message) + }; + + + # Check out the new branch. + $sqitch->run($git, 'checkout', $branch); + + # Deploy! + $engine->set_variables( $self->_collect_deploy_vars($target) ); + $engine->plan( $to_plan ); + $engine->deploy( undef, $self->mode); + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::checkout - Revert, change checkout a VCS branch, and redeploy + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::checkout->new(%params); + $cmd->execute; + +=head1 Description + +If you want to know how to use the C command, you probably want to +be reading C. But if you really want to know how the +C command works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::checkout->options; + +Returns a list of L option specifications for the command-line +options for the C command. + +=head2 Instance Methods + +=head3 C + + $checkout->execute; + +Executes the checkout command. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Authors + +=over + +=item * Ronan Dunklau + +=item * David E. Wheeler + +=back + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Copyright (c) 2012-2013 Ronan Dunklau + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/config.pm b/lib/App/Sqitch/Command/config.pm new file mode 100644 index 00000000..82fd7f8c --- /dev/null +++ b/lib/App/Sqitch/Command/config.pm @@ -0,0 +1,666 @@ +package App::Sqitch::Command::config; + +use 5.010; +use strict; +use warnings; +use utf8; +use Path::Class (); +use Try::Tiny; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use List::Util qw(first); +use Moo; +use App::Sqitch::Types qw(Str Dir Maybe); +use Type::Utils qw(enum); +use namespace::autoclean; +extends 'App::Sqitch::Command'; + +our $VERSION = 'v1.0.0'; # VERSION + +has file => ( + is => 'ro', + lazy => 1, + default => sub { + my $self = shift; + my $meth = ( $self->context || 'local' ) . '_file'; + return $self->sqitch->config->$meth; + } +); + +has action => ( + is => 'ro', + isa => enum([qw( + get + get_all + get_regex + set + unset + list + edit + add + replace_all + unset_all + rename_section + remove_section + )]), +); + +has context => ( + is => 'ro', + isa => Maybe[enum([qw( + local + user + system + )])], +); + +has type => ( is => 'ro', isa => enum( [qw(int num bool bool-or-int)] ) ); + +sub options { + return qw( + file|config-file|f=s + local + user|global + system + + int + bool + bool-or-int + num + + get + get-all + get-regex|get-regexp + add + replace-all + unset + unset-all + rename-section + remove-section + list|l + edit|e + ); +} + +sub configure { + my ( $class, $config, $opt ) = @_; + + # Make sure we are accessing only one file. + my @file = grep { $opt->{$_} } qw(local user system file); + $class->usage('Only one config file at a time.') if @file > 1; + + # Make sure we have only one type. + my @type = grep { $opt->{$_} } qw(bool int num bool_or_int); + $class->usage('Only one type at a time.') if @type > 1; + + # Make sure we are performing only one action. + my @action = grep { $opt->{$_} } qw( + get + get_all + get_regex + unset + list + edit + add + replace_all + unset_all + rename_section + remove_section + ); + $class->usage('Only one action at a time.') if @action > 1; + + # Get the action and context. + my $context = first { $opt->{$_} } qw(local user system); + + # Make it so. + return { + ( $action[0] ? ( action => $action[0] ) : () ), + ( $type[0] ? ( type => $type[0] ) : () ), + ( $context ? ( context => $context ) : () ), + ( $opt->{file} ? ( file => $opt->{file} ) : () ), + }; +} + +sub execute { + my $self = shift; + my $action = $self->action || ( @_ > 1 ? 'set' : 'get' ); + $action =~ s/-/_/g; + my $meth = $self->can($action) or hurl config => __x( + 'Unknown config action: {action}', + action => $action, + ); + return $self->$meth(@_); +} + +sub get { + my ( $self, $key, $rx ) = @_; + $self->usage('Wrong number of arguments.') if !defined $key || $key eq ''; + + my $val = try { + $self->sqitch->config->get( + key => $key, + filter => $rx, + as => $self->type, + human => 1, + ); + } + catch { + hurl config => __x( + 'More then one value for the key "{key}"', + key => $key, + ) if /^\QMultiple values/i; + hurl config => $_; + }; + + hurl { + ident => 'config', + message => '', + exitval => 1, + } unless defined $val; + $self->emit($val); + return $self; +} + +sub get_all { + my ( $self, $key, $rx ) = @_; + $self->usage('Wrong number of arguments.') if !defined $key || $key eq ''; + + my @vals = try { + $self->sqitch->config->get_all( + key => $key, + filter => $rx, + as => $self->type, + human => 1, + ); + } + catch { + hurl config => $_; + }; + hurl { + ident => 'config', + message => '', + exitval => 1, + } unless @vals; + $self->emit( join "\n", @vals ); + return $self; +} + +sub get_regex { + my ( $self, $key, $rx ) = @_; + $self->usage('Wrong number of arguments.') if !defined $key || $key eq ''; + + my $config = $self->sqitch->config; + my %vals = try { + $config->get_regexp( + key => $key, + filter => $rx, + as => $self->type, + human => 1, + ); + } + catch { + hurl config => $_; + }; + hurl { + ident => 'config', + message => '', + exitval => 1, + } unless %vals; + my @out; + for my $key ( sort keys %vals ) { + if ( defined $vals{$key} ) { + if ( $config->is_multiple($key) ) { + push @out => "$key=[" . join( ', ', @{ $vals{$key} } ) . ']'; + } + else { + push @out => "$key=$vals{$key}"; + } + } + else { + push @out => $key; + } + } + $self->emit( join "\n" => @out ); + + return $self; +} + +sub set { + my ( $self, $key, $value, $rx ) = @_; + $self->_set( $key, $value, $rx, multiple => 0 ); +} + +sub add { + my ( $self, $key, $value ) = @_; + $self->_set( $key, $value, undef, multiple => 1 ); +} + +sub replace_all { + my ( $self, $key, $value, $rx ) = @_; + $self->_set( $key, $value, $rx, multiple => 1, replace_all => 1 ); +} + +sub _set { + my ( $self, $key, $value, $rx, @p ) = @_; + $self->usage('Wrong number of arguments.') + if !defined $key || $key eq '' || !defined $value; + + $self->_touch_dir; + try { + $self->sqitch->config->set( + key => $key, + value => $value, + filename => $self->file, + filter => $rx, + as => $self->type, + @p, + ); + } + catch { + hurl config => __( + 'Cannot overwrite multiple values with a single value' + ) if /^Multiple occurrences/i; + hurl config => $_; + }; + return $self; +} + +sub _file_config { + my $file = shift->file; + return unless -e $file; + my $config = App::Sqitch::Config->new; + $config->load_file($file); + return $config; +} + +sub unset { + my ( $self, $key, $rx ) = @_; + $self->usage('Wrong number of arguments.') if !defined $key || $key eq ''; + $self->_touch_dir; + + try { + $self->sqitch->config->set( + key => $key, + filename => $self->file, + filter => $rx, + multiple => 0, + ); + } + catch { + hurl config => __( + 'Cannot unset key with multiple values' + ) if /^Multiple occurrences/i; + hurl config => $_; + }; + return $self; +} + +sub unset_all { + my ( $self, $key, $rx ) = @_; + $self->usage('Wrong number of arguments.') if !defined $key || $key eq ''; + + $self->_touch_dir; + $self->sqitch->config->set( + key => $key, + filename => $self->file, + filter => $rx, + multiple => 1, + ); + return $self; +} + +sub list { + my $self = shift; + my $config = $self->context + ? $self->_file_config + : $self->sqitch->config; + $self->emit( scalar $config->dump ) if $config; + return $self; +} + +sub edit { + my $self = shift; + + # Let the editor deal with locking. + $self->shell( + $self->sqitch->editor . ' ' . $self->quote_shell( $self->file ) + ); +} + +sub rename_section { + my ( $self, $old_name, $new_name ) = @_; + $self->usage('Wrong number of arguments.') + unless defined $old_name && $old_name ne '' + && defined $new_name && $new_name ne ''; + + try { + $self->sqitch->config->rename_section( + from => $old_name, + to => $new_name, + filename => $self->file + ); + } + catch { + hurl config => __ 'No such section!' if /\Qno such section/i; + hurl config => $_; + }; + return $self; +} + +sub remove_section { + my ( $self, $section ) = @_; + $self->usage('Wrong number of arguments.') + unless defined $section && $section ne ''; + try { + $self->sqitch->config->remove_section( + section => $section, + filename => $self->file + ); + } + catch { + hurl config => __ 'No such section!' if /\Qno such section/i; + hurl config => $_; + }; + return $self; +} + +sub _touch_dir { + my $self = shift; + unless ( -e $self->file ) { + require File::Basename; + my $dir = File::Basename::dirname( $self->file ); + unless ( -e $dir && -d _ ) { + require File::Path; + File::Path::make_path($dir); + } + } +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::config - Get and set local, user, or system Sqitch options + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::config->new(\%params); + $cmd->execute; + +=head1 Description + +You can query/set/replace/unset Sqitch options with this command. The name is +actually the section and the key separated by a dot, and the value will be +escaped. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::config->options; + +Returns a list of L option specifications for the command-line +options for the C command. + +=head3 C + + my $params = App::Sqitch::Command::config->configure( + $config, + $options, + ); + +Processes the configuration and command options and returns a hash suitable +for the constructor. Exits with an error on option specification errors. + +=head2 Constructor + +=head3 C + + my $config = App::Sqitch::Command::config->new($params); + +Creates and returns a new C command object. The supported parameters +include: + +=over + +=item C + +The core L object. + +=item C + +Configuration file to read from and write to. + +=item C + +The action to be executed. May be one of: + +=over + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=back + +If not specified, the action taken by C will depend on the number +of arguments passed to it. If only one, the action will be C. If two or +more, the action will be C. + +=item C + +The configuration file context. Must be one of: + +=over + +=item * C + +=item * C + +=item * C + +=back + +=item C + +The type to cast a value to be set to or fetched as. May be one of: + +=over + +=item * C + +=item * C + +=item * C + +=item * C + +=back + +If not specified or C, no casting will be performed. + +=back + +=head2 Instance Methods + +These methods are mainly provided as utilities for the command subclasses to +use. + +=head3 C + + $config->execute($property, $value); + +Executes the config command. Pass the name of the property and the value to +be assigned to it, if applicable. + +=head3 C + + $config->get($key); + $config->get($key, $regex); + +Emits the value for the specified key. The optional second argument is a +regular expression that the value to be returned must match. Exits with an +error if the is more than one value for the specified key, or if the key does +not exist. + +=head3 C + + $config->get_all($key); + $config->get_all($key, $regex); + +Like C, but emits all of the values for the given key, rather then +exiting with an error when there is more than one value. + +=head3 C + + $config->get_regex($key); + $config->get_regex($key, $regex); + +Like C, but the first parameter is a regular expression that will +be matched against all keys. + +=head3 C + + $config->set($key, $value); + $config->set($key, $value, $regex); + +Sets the value for a key. Exits with an error if the key already exists and +has multiple values. + +=head3 C + + $config->add($key, $value); + +Adds a value for a key. If the key already exists, the value will be added as +an additional value. + +=head3 C + + $config->replace_all($key, $value); + $config->replace_all($key, $value, $regex); + +Replace all matching values. + +=head3 C + + $config->unset($key); + $config->unset($key, $regex); + +Unsets a key. If the optional second argument is passed, the key will be unset +only if the value matches the regular expression. If the key has multiple +values, C will exit with an error. + +=head3 C + + $config->unset_all($key); + $config->unset_all($key, $regex); + +Like C, but will not exit with an error if the key has multiple +values. + +=head3 C + + $config->rename_section($old_name, $new_name); + +Renames a section. Exits with an error if the section does not exist or if +either name is not a valid section name. + +=head3 C + + $config->remove_section($section); + +Removes a section. Exits with an error if the section does not exist. + +=head3 C + + $config->list; + +Lists all of the values in the configuration. If the context is C, +C, or C, only the settings set for that context will be emitted. +Otherwise, all settings will be listed. + +=head3 C + + $config->edit; + +Opens the context-specific configuration file in a text editor for direct +editing. If no context is specified, the local config file will be opened. The +editor is determined by L. + +=head2 Instance Accessors + +=head3 C + + my $file_name = $config->file; + +Returns the path to the configuration file to be acted upon. If the context is +C, then the value returned is C<$($etc_prefix)/sqitch.conf>. If the +context is C, then the value returned is C<~/.sqitch/sqitch.conf>. +Otherwise, the default is F<./sqitch.conf>. + +=head1 See Also + +=over + +=item L + +Help for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut + diff --git a/lib/App/Sqitch/Command/deploy.pm b/lib/App/Sqitch/Command/deploy.pm new file mode 100644 index 00000000..adb9f4b7 --- /dev/null +++ b/lib/App/Sqitch/Command/deploy.pm @@ -0,0 +1,230 @@ +package App::Sqitch::Command::deploy; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo; +use App::Sqitch::Types qw(URI Str Bool HashRef); +use Locale::TextDomain qw(App-Sqitch); +use Type::Utils qw(enum); +use App::Sqitch::X qw(hurl); +use List::Util qw(first); +use namespace::autoclean; + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::ContextCommand'; +with 'App::Sqitch::Role::ConnectingCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has target => ( + is => 'ro', + isa => Str, +); + +has to_change => ( + is => 'ro', + isa => Str, +); + +has mode => ( + is => 'ro', + isa => enum([qw( + change + tag + all + )]), + default => 'all', +); + +has log_only => ( + is => 'ro', + isa => Bool, + default => 0, +); + +has verify => ( + is => 'ro', + isa => Bool, + default => 0, +); + +has variables => ( + is => 'ro', + isa => HashRef, + lazy => 1, + default => sub { {} }, +); + +sub options { + return qw( + target|t=s + to-change|to|change=s + mode=s + set|s=s% + log-only + verify! + ); +} + +sub configure { + my ( $class, $config, $opt ) = @_; + + my %params = ( + mode => $opt->{mode} || $config->get( key => 'deploy.mode' ) || 'all', + verify => $opt->{verify} // $config->get( key => 'deploy.verify', as => 'boolean' ) // 0, + log_only => $opt->{log_only} || 0, + ); + $params{to_change} = $opt->{to_change} if exists $opt->{to_change}; + $params{target} = $opt->{target} if exists $opt->{target}; + + if ( my $vars = $opt->{set} ) { + $params{variables} = $vars; + } + + return \%params; +} + +sub _collect_vars { + my ($self, $target) = @_; + my $cfg = $self->sqitch->config; + return ( + %{ $cfg->get_section(section => 'core.variables') }, + %{ $cfg->get_section(section => 'deploy.variables') }, + %{ $target->variables }, # includes engine + %{ $self->variables }, # --set + ); +} + +sub execute { + my $self = shift; + my ($targets, $changes) = $self->parse_args( + target => $self->target, + args => \@_, + ); + + # Warn on multiple targets. + my $target = shift @{ $targets }; + $self->warn(__x( + 'Too many targets specified; connecting to {target}', + target => $target->name, + )) if @{ $targets }; + + # Warn on too many changes. + my $change = $self->to_change // shift @{ $changes }; + $self->warn(__x( + 'Too many changes specified; deploying to "{change}"', + change => $change, + )) if @{ $changes }; + + # Now get to work. + my $engine = $target->engine; + $engine->with_verify( $self->verify ); + $engine->log_only( $self->log_only ); + $engine->set_variables( $self->_collect_vars($target) ); + $engine->deploy( $change, $self->mode ); + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::deploy - Deploy Sqitch changes to a database + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::deploy->new(%params); + $cmd->execute; + +=head1 Description + +If you want to know how to use the C command, you probably want to be +reading C. But if you really want to know how the C command +works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::deploy->options; + +Returns a list of L option specifications for the command-line +options for the C command. + +=head2 Attributes + +=head3 C + +Boolean indicating whether to log the deploy without running the scripts. + +=head3 C + +Deploy mode, one of "change", "tag", or "all". + +=head3 C + +The deployment target URI. + +=head3 C + +Change up to which to deploy. + +=head3 C + +Boolean indicating whether or not to run verify scripts after each change. + +=head2 Instance Methods + +=head3 C + + $deploy->execute; + +Executes the deploy command. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/engine.pm b/lib/App/Sqitch/Command/engine.pm new file mode 100644 index 00000000..7ae72e1c --- /dev/null +++ b/lib/App/Sqitch/Command/engine.pm @@ -0,0 +1,457 @@ +package App::Sqitch::Command::engine; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo; +use Types::Standard qw(Str Int HashRef); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use Try::Tiny; +use URI::db; +use Path::Class qw(file dir); +use List::Util qw(max first); +use namespace::autoclean; +use constant extra_target_keys => qw(target); + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::TargetConfigCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +sub _chk_engine($) { + my $engine = shift; + hurl engine => __x( + 'Unknown engine "{engine}"', engine => $engine + ) unless first { $engine eq $_ } App::Sqitch::Command::ENGINES; +} + +sub configure { + # No config; engine config is actually engines. + return {}; +} + +sub execute { + my ( $self, $action ) = (shift, shift); + $action ||= 'list'; + $action =~ s/-/_/g; + my $meth = $self->can($action) or $self->usage(__x( + 'Unknown action "{action}"', + action => $action, + )); + + return $self->$meth(@_); +} + +sub list { + my $self = shift; + my $sqitch = $self->sqitch; + my $rx = join '|' => App::Sqitch::Command::ENGINES; + my %engines = $sqitch->config->get_regexp(key => qr/^engine[.](?:$rx)[.]target$/); + + # Make it verbose if --verbose was passed at all. + my $format = $sqitch->options->{verbosity} ? "%1\$s\t%2\$s" : '%1$s'; + for my $key (sort keys %engines) { + my ($engine) = $key =~ /engine[.]([^.]+)/; + $sqitch->emit(sprintf $format, $engine, $engines{$key}) + } + + return $self; +} + +sub _target { + my ($self, $engine, $name) = @_; + my $target = $self->properties->{target} || $name || return; + + if ($target =~ /:/) { + # It's URI. Return it if it uses the proper engine. + my $uri = URI::db->new($target, 'db:'); + hurl engine => __x( + 'Cannot assign URI using engine "{new}" to engine "{old}"', + new => $uri->canonical_engine, + old => $engine, + ) if $uri->canonical_engine ne $engine; + return $uri->as_string; + } + + # Otherwise, it needs to be a known target from the config. + return $target if $self->sqitch->config->get(key => "target.$target.uri"); + hurl engine => __x( + 'Unknown target "{target}"', + target => $target + ); +} + +sub add { + my ($self, $engine, $target) = @_; + $self->usage unless $engine; + _chk_engine $engine; + + my $key = "engine.$engine"; + my $config = $self->sqitch->config; + + hurl engine => __x( + 'Engine "{engine}" already exists', + engine => $engine + ) if $config->get( key => "$key.target"); + + # Set up the target and other config variables. + my $vars = $self->config_params($key); + unshift @{ $vars } => { + key => "$key.target", + value => $self->_target($engine, $target) || "db:$engine:", + }; + + # Make it so. + $config->group_set( $config->local_file, $vars ); + $target = $self->config_target( + name => $target, + engine => $engine, + ); + $self->write_plan(target => $target); + $self->make_directories_for($target); +} + +sub alter { + my ($self, $engine) = @_; + $self->usage unless $engine; + _chk_engine $engine; + + my $key = "engine.$engine"; + my $config = $self->sqitch->config; + my $props = $self->properties; + + hurl engine => __x( + 'Missing Engine "{engine}"; use "{command}" to add it', + engine => $engine, + command => "add $engine " . ($props->{target} || "db:$engine:"), + ) unless $config->get( key => "engine.$engine.target"); + + if (my $targ = $props->{target}) { + $props->{target} = $self->_target($engine, $targ) or hurl engine => __( + 'Cannot unset an engine target' + ); + } + + # Make it so. + $config->group_set( $config->local_file, $self->config_params($key) ); + $self->make_directories_for( $self->config_target( engine => $engine) ); +} + +sub rm { shift->remove(@_) } +sub remove { + my ($self, $engine) = @_; + $self->usage unless $engine; + + my $config = $self->sqitch->config; + try { + $config->rename_section( + from => "engine.$engine", + filename => $config->local_file, + ); + } catch { + die $_ unless /No such section/; + hurl engine => __x( + 'Unknown engine "{engine}"', + engine => $engine, + ); + }; + try { + $config->rename_section( + from => "engine.$engine.variables", + filename => $config->local_file, + ); + } catch { + die $_ unless /No such section/; + }; + return $self; +} + +sub show { + my ($self, @names) = @_; + return $self->list unless @names; + my $sqitch = $self->sqitch; + my $config = $sqitch->config; + + # Set up labels. + my %label_for = ( + target => __ 'Target', + registry => __ 'Registry', + client => __ 'Client', + top_dir => __ 'Top Directory', + plan_file => __ 'Plan File', + extension => __ 'Extension', + revert => ' ' . __ 'Revert', + deploy => ' ' . __ 'Deploy', + verify => ' ' . __ 'Verify', + reworked => ' ' . __ 'Reworked', + ); + + my $len = max map { length } values %label_for; + $_ .= ': ' . ' ' x ($len - length $_) for values %label_for; + + # Header labels. + $label_for{script_dirs} = __('Script Directories') . ':'; + $label_for{reworked_dirs} = __('Reworked Script Directories') . ':'; + $label_for{variables} = __('Variables') . ':'; + $label_for{no_variables} = __('No Variables'); + + require App::Sqitch::Target; + for my $engine (@names) { + my $target = App::Sqitch::Target->new( + $self->target_params, + name => $config->get(key => "engine.$engine.target") || "db:$engine", + ); + + $self->emit("* $engine"); + $self->emit(' ', $label_for{target}, $target->target); + $self->emit(' ', $label_for{registry}, $target->registry); + $self->emit(' ', $label_for{client}, $target->client); + $self->emit(' ', $label_for{top_dir}, $target->top_dir); + $self->emit(' ', $label_for{plan_file}, $target->plan_file); + $self->emit(' ', $label_for{extension}, $target->extension); + $self->emit(' ', $label_for{script_dirs}); + $self->emit(' ', $label_for{deploy}, $target->deploy_dir); + $self->emit(' ', $label_for{revert}, $target->revert_dir); + $self->emit(' ', $label_for{verify}, $target->verify_dir); + $self->emit(' ', $label_for{reworked_dirs}); + $self->emit(' ', $label_for{reworked}, $target->reworked_dir); + $self->emit(' ', $label_for{deploy}, $target->reworked_deploy_dir); + $self->emit(' ', $label_for{revert}, $target->reworked_revert_dir); + $self->emit(' ', $label_for{verify}, $target->reworked_verify_dir); + my $vars = $target->variables; + if (%{ $vars }) { + my $len = max map { length } keys %{ $vars }; + $self->emit(' ', $label_for{variables}); + $self->emit(" $_: " . (' ' x ($len - length $_)) . $vars->{$_}) + for sort { lc $a cmp lc $b } keys %{ $vars }; + } else { + $self->emit(' ', $label_for{no_variables}); + } + } + + return $self; +} + +# DEPRECATTION: Added in v0.997 (Oct 2014). As of v0.9999, Sqitch no longer +# notices and warns about core.$engine; most folks should long since have +# updated their configurations. Keeping this method for now, since it might +# still be useful and doesn't add much overhead in general except for the +# compilation of the engine command. But consider removing in the future. +sub update_config { + my $self = shift; + my $sqitch = $self->sqitch; + my $config = $sqitch->config; + + my $local_file = $config->local_file; + for my $file ( + $local_file, + $config->user_file, + $config->system_file, + ) { + $sqitch->emit(__x( 'Loading {file}', file => $file )); + # Hide all other files. Just want to deal with the one. + local $ENV{SQITCH_CONFIG} = '/dev/null/not.conf'; + local $ENV{SQITCH_USER_CONFIG} = '/dev/null/not.user'; + local $ENV{SQITCH_SYSTEM_CONFIG} = '/dev/null/not.sys'; + my $c = App::Sqitch::Config->new; + $c->load_file($file); + my %engines; + for my $ekey (App::Sqitch::Command::ENGINES) { + my $sect = $c->get_section( section => "core.$ekey"); + if (%{ $sect }) { + if (%{ $c->get_section( section => "engine.$ekey") }) { + $sqitch->warn(' - ' . __x( + "Deprecated {section} found in {file}; to remove it, run\n {sqitch} config --file {file} --remove-section {section}", + section => "core.$ekey", + file => $file, + sqitch => $0, + )); + next; + } + # Migrate this one. + $engines{$ekey} = $sect; + } + } + unless (%engines) { + $sqitch->emit(__ ' - No engines to update'); + next; + } + + # Make sure we can write to the file. + unless (-w $file) { + $sqitch->warn(' - ' . __x( + 'Cannot update {file}. Please make it writable', + file => $file, + )); + next; + } + + # Move all of the engines. + for my $ekey (sort keys %engines) { + my $old = $engines{$ekey}; + + my @new; + if ( my $target = delete $old->{target} ) { + # Good, there is already a specific target. + push @new => { + key => "engine.$ekey.target", + value => $target, + }; + # Kill off deprecated variables. + delete $old->{$_} for qw(host port username password db_name); + } elsif ( $file eq $local_file ) { + # Start with a default and migrate deprecated configs. + my $uri = URI::db->new("db:$ekey:"); + for my $spec ( + [host => 'host'], + [port => 'port'], + [username => 'user'], + [password => 'password'], + [db_name => 'dbname'], + ) { + my ($key, $meth) = @{ $spec }; + my $val = delete $old->{$key} or next; + $uri->$meth($val); + } + push @new => { + key => "engine.$ekey.target", + value => $uri->as_string, + }; + } else { + # Just kill off any of the deprecated variables. + delete $old->{$_} for qw(host port username password db_name); + } + + # Copy over the remaining variabls. + push @new => map {{ + key => "engine.$ekey.$_", + value => $old->{$_}, + }} keys %{ $old }; + + # Create the new variables and delete the old section. + $config->group_set( $file, \@new ); + # $c->rename_section( + # from => "core.$ekey", + # filename => $file, + # ); + + $sqitch->emit(' - ' . __x( + "Migrated {old} to {new}; To remove {old}, run\n {sqitch} config --file {file} --remove-section {old}", + old => "core.$ekey", + new => "engine.$ekey", + sqitch => $0, + file => $file, + )); + } + } + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::engine - Add, modify, or list Sqitch database engines + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::engine->new(%params); + $cmd->execute; + +=head1 Description + +Manages Sqitch database engines, which are stored in the local configuration file. + +=head1 Interface + +=head3 Class Methods + +=head3 C + +Returns a list of additional option keys to be specified via options. + +=head2 Instance Methods + +=head2 Attributes + +=head3 C + +Hash of property values to set. + +=head3 C + + $engine->execute($command); + +Executes the C command. + +=head3 C + +Implements the C action. + +=head3 C + +Implements the C action. + +=head3 C + +Implements the C action. + +=head3 C + +=head3 C + +Implements the C action. + +=head3 C + +Implements the C action. + +=head3 C + +Implements the C action. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/help.pm b/lib/App/Sqitch/Command/help.pm new file mode 100644 index 00000000..1b4a7a62 --- /dev/null +++ b/lib/App/Sqitch/Command/help.pm @@ -0,0 +1,142 @@ +package App::Sqitch::Command::help; + +use 5.010; +use strict; +use warnings; +use utf8; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use Types::Standard qw(Bool); +use Pod::Find; +use Moo; +extends 'App::Sqitch::Command'; + +our $VERSION = 'v1.0.0'; # VERSION + +has guide => ( + is => 'ro', + isa => Bool, + default => 0, +); + +sub options { + # XXX Add --all at some point, to output a list of all possible commands. + return qw( + guide|g + ); +} + +sub execute { + my ( $self, $command ) = @_; + $self->find_and_show('sqitch' . ( + $command ? ($command =~ /^changes|tutorial/ ? '' : '-') . $command + : $self->guide ? 'guides' + : 'commands' + )); +} + +sub find_and_show { + my ( $self, $look_for ) = (shift, shift); + + my $pod = Pod::Find::pod_where({ + '-inc' => 1, + '-script' => 1 + }, $look_for ) or hurl { + ident => 'help', + message => __x('No manual entry for {command}', command => $look_for), + exitval => 1, + }; + + $self->_pod2usage( + '-input' => $pod, + '-verbose' => 2, + '-exitval' => 0, + @_, + ); +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::help - Display help information about Sqitch + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::help->new(%params); + $cmd->execute; + +=head1 Description + +If you want to know how to use the C command, you probably want to be +reading C. But if you really want to know how the C command +works, read on. + +=head1 Interface + +=head2 Attributes + +=head3 C + +Boolean indicating whether to list the guides. + +=head2 Instance Methods + +=head3 C + + $help->execute($command); + +Executes the help command. If a command is passed, the help for that command will +be shown. If it cannot be found, Sqitch will throw an error and exit. If no +command is specified, the L will be shown. + +=head3 C + + $help->find_and_show($file, %options); + +Does the work of finding the pod file C<$file> and passing it on to +L, along with any additional options for Pod::Usage's constructor. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/init.pm b/lib/App/Sqitch/Command/init.pm new file mode 100644 index 00000000..815882e6 --- /dev/null +++ b/lib/App/Sqitch/Command/init.pm @@ -0,0 +1,292 @@ +package App::Sqitch::Command::init; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo; +use App::Sqitch::Types qw(URI Maybe); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use List::MoreUtils qw(natatime); +use Path::Class; +use App::Sqitch::Plan; +use namespace::autoclean; +use constant extra_target_keys => qw(engine target); + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::TargetConfigCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +sub execute { + my ( $self, $project ) = @_; + $self->_validate_project($project); + $self->write_config; + my $target = $self->config_target; + $self->write_plan( + project => $project, + uri => $self->uri, + target => $target, + ); + $self->make_directories_for($target); + return $self; +} + +has uri => ( + is => 'ro', + isa => Maybe[URI], +); + +sub options { + return qw(uri=s); +} + +sub _validate_project { + my ( $self, $project ) = @_; + $self->usage unless $project; + my $name_re = 'App::Sqitch::Plan'->name_regex; + hurl init => __x( + qq{invalid project name "{project}": project names must not } + . 'begin with punctuation, contain "@", ":", "#", or blanks, or end in ' + . 'punctuation or digits following punctuation', + project => $project + ) unless $project =~ /\A$name_re\z/; +} + +sub configure { + my ( $class, $config, $opt ) = @_; + + if ( my $uri = $opt->{uri} ) { + require URI; + $opt->{uri} = 'URI'->new($uri); + } + + return $opt; +} + +sub write_config { + my $self = shift; + my $sqitch = $self->sqitch; + my $config = $sqitch->config; + my $file = $config->local_file; + if ( -f $file ) { + + # Do nothing? Update config? + return $self; + } + + my ( @vars, @comments ); + + # Get the props, and make sure the target can find the engine. + my $props = $self->properties; + my $target = $self->config_target; + + # Write the engine from --engine or core.engine. + my $ekey = $props->{engine} || $target->engine_key; + if ($ekey) { + push @vars => { + key => "core.engine", + value => $ekey, + }; + } + else { + push @comments => "\tengine = "; + } + + # Add core properties. + for my $name (qw( + plan_file + top_dir + )) { + # Set properties passed on the command-line. + if ( my $val = $props->{$name} ) { + push @vars => { + key => "core.$name", + value => $val, + }; + } + else { + my $val //= $target->$name // ''; + push @comments => "\t$name = $val"; + } + } + + # Add script options passed to the init command. No comments if not set. + for my $attr (qw( + extension + deploy_dir + revert_dir + verify_dir + reworked_dir + reworked_deploy_dir + reworked_revert_dir + reworked_verify_dir + )) { + push @vars => { key => "core.$attr", value => $props->{$attr} } + if defined $props->{$attr}; + } + + # Add variables. + if (my $vars = $props->{variables}) { + push @vars => map {{ + key => "core.variables.$_", + value => $vars->{$_}, + }} keys %{ $vars }; + } + + # Emit them. + if (@vars) { + $config->group_set( $file => \@vars ); + } + else { + unshift @comments => '[core]'; + } + + # Emit the comments. + $config->add_comment( + filename => $file, + indented => 1, + comment => join "\n" => @comments, + ) if @comments; + + if ($ekey) { + # Write out the engine.$engine section. + my $config_key = "engine.$ekey"; + @comments = @vars = (); + + for my $key (qw(target registry client)) { + # Was it passed as an option? + if ( my $val = $props->{$key} ) { + push @vars => { + key => "$config_key.$key", + value => $val, + }; + # We're good on this one. + next; + } + + # No value, but add it as a comment, possibly with a default. + my $def = $target->$key + // $config->get( key => "$config_key.$key" ) + // ''; + push @comments => "\t$key = $def"; + } + + if (@vars) { + # Emit them. + $config->group_set( $file => \@vars ) if @vars; + } + else { + # Still want the section, emit it as a comment. + unshift @comments => qq{[engine "$ekey"]}; + } + + # Emit the comments. + $config->add_comment( + filename => $file, + indented => 1, + comment => join "\n" => @comments, + ) if @comments; + } + + # Is there are target? + if (my $target_name = $props->{target}) { + # If it's a named target, add it to the configuration. + $config->set( + filename => $file, + key => "target.$target_name.uri", + value => $target->uri, + ) if $target_name !~ /:/ + } + + $self->info( __x 'Created {file}', file => $file ); + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::init - Initialize a Sqitch project + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::init->new(%params); + $cmd->execute; + +=head1 Description + +This command creates the files and directories for a new Sqitch project - +basically a F file and directories for deploy and revert +scripts. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::init->options; + +Returns a list of L option specifications for the command-line +options for the C command. + +=head3 C + +Returns a list of additional option keys to be specified via options. + +=head2 Attributes + +=head3 C + +URI for the project. + +=head3 C + +Hash of property values to set. + +=head2 Instance Methods + +=head3 C + + $init->execute($project); + +Executes the C command. + +=head3 C + + $init->write_config; + +Writes out the configuration file. Called by C. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut + diff --git a/lib/App/Sqitch/Command/log.pm b/lib/App/Sqitch/Command/log.pm new file mode 100644 index 00000000..add1dffc --- /dev/null +++ b/lib/App/Sqitch/Command/log.pm @@ -0,0 +1,373 @@ +package App::Sqitch::Command::log; + +use 5.010; +use strict; +use warnings; +use utf8; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use Moo; +use Types::Standard qw(Str Int ArrayRef Bool); +use Type::Utils qw(class_type); +use App::Sqitch::ItemFormatter; +use namespace::autoclean; +use Try::Tiny; + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::ConnectingCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +my %FORMATS; +$FORMATS{raw} = < +planned %{date:raw}p +committer %{name}c <%{email}c> +committed %{date:raw}c + +%{ }B +EOF + +$FORMATS{full} = < ( + is => 'ro', + isa => Str, +); + +has event => ( + is => 'ro', + isa => ArrayRef, +); + +has change_pattern => ( + is => 'ro', + isa => Str, +); + +has project_pattern => ( + is => 'ro', + isa => Str, +); + +has committer_pattern => ( + is => 'ro', + isa => Str, +); + +has max_count => ( + is => 'ro', + isa => Int, +); + +has skip => ( + is => 'ro', + isa => Int, +); + +has reverse => ( + is => 'ro', + isa => Bool, + default => 0, +); + +has headers => ( + is => 'ro', + isa => Bool, + default => 1, +); + +has format => ( + is => 'ro', + isa => Str, + default => $FORMATS{medium}, +); + +has formatter => ( + is => 'ro', + isa => class_type('App::Sqitch::ItemFormatter'), + lazy => 1, + default => sub { App::Sqitch::ItemFormatter->new }, +); + +sub options { + return qw( + event=s@ + target|t=s + change-pattern|change=s + project-pattern|project=s + committer-pattern|committer=s + format|f=s + date-format|date=s + max-count|n=i + skip=i + reverse! + color=s + no-color + abbrev=i + oneline + headers! + ); +} + +sub configure { + my ( $class, $config, $opt ) = @_; + + # Set base values if --oneline. + if ($opt->{oneline}) { + $opt->{format} ||= 'oneline'; + $opt->{abbrev} //= 6; + } + + # Determine and validate the date format. + my $date_format = delete $opt->{date_format} || $config->get( + key => 'log.date_format' + ); + if ($date_format) { + require App::Sqitch::DateTime; + App::Sqitch::DateTime->validate_as_string_format($date_format); + } else { + $date_format = 'iso'; + } + + # Make sure the log format is valid. + if (my $format = $opt->{format} + || $config->get(key => 'log.format') + ) { + if ($format =~ s/^format://) { + $opt->{format} = $format; + } else { + $opt->{format} = $FORMATS{$format} or hurl log => __x( + 'Unknown log format "{format}"', + format => $format + ); + } + } + + # Determine how to handle ANSI colors. + my $color = delete $opt->{no_color} ? 'never' + : delete $opt->{color} || $config->get(key => 'log.color'); + + $opt->{formatter} = App::Sqitch::ItemFormatter->new( + ( $date_format ? ( date_format => $date_format ) : () ), + ( $color ? ( color => $color ) : () ), + ( $opt->{abbrev} ? ( abbrev => delete $opt->{abbrev} ) : () ), + ); + + return $class->SUPER::configure( $config, $opt ); +} + +sub execute { + my $self = shift; + my ($targets) = $self->parse_args( + target => $self->target, + args => \@_, + ); + + # Warn on multiple targets. + my $target = shift @{ $targets }; + $self->warn(__x( + 'Too many targets specified; connecting to {target}', + target => $target->name, + )) if @{ $targets }; + my $engine = $target->engine; + + # Exit with status 1 on uninitialized database, probably not expected. + hurl { + ident => 'log', + exitval => 1, + message => __x( + 'Database {db} has not been initialized for Sqitch', + db => $engine->registry_destination, + ), + } unless $engine->initialized; + + # Exit with status 1 on no events, probably not expected. + my $iter = $engine->search_events(limit => 1); + hurl { + ident => 'log', + exitval => 1, + message => __x( + 'No events logged for {db}', + db => $engine->destination, + ), + } unless $iter->(); + + # Search the event log. + $iter = $engine->search_events( + event => $self->event, + change => $self->change_pattern, + project => $self->project_pattern, + committer => $self->committer_pattern, + limit => $self->max_count, + offset => $self->skip, + direction => $self->reverse ? 'ASC' : 'DESC', + ); + + # Send the results. + my $formatter = $self->formatter; + my $format = $self->format; + $self->page( __x 'On database {db}', db => $engine->destination ) + if $self->headers; + while ( my $change = $iter->() ) { + $self->page( $formatter->format( $format, $change ) ); + } + + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::log - Show a database event log + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::log->new(%params); + $cmd->execute; + +=head1 Description + +If you want to know how to use the C command, you probably want to be +reading C. But if you really want to know how the C command +works, read on. + +=head1 Interface + +=head2 Attributes + +=head3 C + +Regular expression to match against change names. + +=head3 C + +Regular expression to match against committer names. + +=head3 C + +Regular expression to match against project names. + +=head3 C + +Event type buy which to filter entries to display. + +=head3 C + +Display format template. + +=head3 C + +Maximum number of entries to display. + +=head3 C + +Reverse the usual order of the display of entries. + +=head3 C + +Output headers. Defaults to true. + +=head3 C + +Number of entries to skip before displaying entries. + +=head3 C + +The database target from which to read the log. + +=head2 Instance Methods + +=head3 C + + $log->execute; + +Executes the log command. The current log for the target database will be +searched and the resulting change history displayed. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/plan.pm b/lib/App/Sqitch/Command/plan.pm new file mode 100644 index 00000000..e31a1738 --- /dev/null +++ b/lib/App/Sqitch/Command/plan.pm @@ -0,0 +1,354 @@ +package App::Sqitch::Command::plan; + +use 5.010; +use strict; +use warnings; +use utf8; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use Moo; +use Types::Standard qw(Str Int ArrayRef Bool); +use Type::Utils qw(class_type); +use App::Sqitch::ItemFormatter; +use namespace::autoclean; +use Try::Tiny; +extends 'App::Sqitch::Command'; + +our $VERSION = 'v1.0.0'; # VERSION + +my %FORMATS; +$FORMATS{raw} = < +planned %{date:raw}p + +%{ }B +EOF + +$FORMATS{full} = < ( + is => 'ro', + isa => Str, +); + +has event => ( + is => 'ro', + isa => Str, +); + +has change_pattern => ( + is => 'ro', + isa => Str, +); + +has planner_pattern => ( + is => 'ro', + isa => Str, +); + +has max_count => ( + is => 'ro', + isa => Int, +); + +has skip => ( + is => 'ro', + isa => Int, +); + +has reverse => ( + is => 'ro', + isa => Bool, + default => 0, +); + +has headers => ( + is => 'ro', + isa => Bool, + default => 1, +); + +has format => ( + is => 'ro', + isa => Str, + default => $FORMATS{medium}, +); + +has formatter => ( + is => 'ro', + isa => class_type('App::Sqitch::ItemFormatter'), + lazy => 1, + default => sub { App::Sqitch::ItemFormatter->new }, +); + +sub options { + return qw( + event=s + target|t=s + change-pattern|change=s + planner-pattern|planner=s + format|f=s + date-format|date=s + max-count|n=i + skip=i + reverse! + color=s + no-color + abbrev=i + oneline + headers! + ); +} + +sub configure { + my ( $class, $config, $opt ) = @_; + + # Set base values if --oneline. + if ($opt->{oneline}) { + $opt->{format} ||= 'oneline'; + $opt->{abbrev} //= 6; + } + + # Determine and validate the date format. + my $date_format = delete $opt->{date_format} || $config->get( + key => 'plan.date_format' + ); + if ($date_format) { + require App::Sqitch::DateTime; + App::Sqitch::DateTime->validate_as_string_format($date_format); + } else { + $date_format = 'iso'; + } + + # Make sure the plan format is valid. + if (my $format = $opt->{format} + || $config->get(key => 'plan.format') + ) { + if ($format =~ s/^format://) { + $opt->{format} = $format; + } else { + $opt->{format} = $FORMATS{$format} or hurl plan => __x( + 'Unknown plan format "{format}"', + format => $format + ); + } + } + + # Determine how to handle ANSI colors. + my $color = delete $opt->{no_color} ? 'never' + : delete $opt->{color} || $config->get(key => 'plan.color'); + + $opt->{formatter} = App::Sqitch::ItemFormatter->new( + ( $date_format ? ( date_format => $date_format ) : () ), + ( $color ? ( color => $color ) : () ), + ( $opt->{abbrev} ? ( abbrev => delete $opt->{abbrev} ) : () ), + ); + + return $class->SUPER::configure( $config, $opt ); +} + +sub execute { + my $self = shift; + my ($targets) = $self->parse_args( + target => $self->target, + args => \@_, + ); + + # Warn on multiple targets. + my $target = shift @{ $targets }; + $self->warn(__x( + 'Too many targets specified; using {target}', + target => $target->name, + )) if @{ $targets }; + my $plan = $target->plan; + + # Exit with status 1 on no changes, probably not expected. + hurl { + ident => 'plan', + exitval => 1, + message => __x( + 'No changes in {file}', + file => $plan->file, + ), + } unless $plan->count; + + # Search the changes. + my $iter = $plan->search_changes( + operation => $self->event, + name => $self->change_pattern, + planner => $self->planner_pattern, + limit => $self->max_count, + offset => $self->skip, + direction => $self->reverse ? 'DESC' : 'ASC', + ); + + # Send the results. + my $formatter = $self->formatter; + my $format = $self->format; + if ($self->headers) { + $self->page( '# ', __x 'Project: {project}', project => $plan->project ); + $self->page( '# ', __x 'File: {file}', file => $plan->file ); + $self->page(''); + } + while ( my $change = $iter->() ) { + $self->page( $formatter->format( $format, { + event => $change->is_deploy ? 'deploy' : 'revert', + project => $change->project, + change_id => $change->id, + change => $change->name, + note => $change->note, + tags => [ map { $_->format_name } $change->tags ], + requires => [ map { $_->as_string } $change->requires ], + conflicts => [ map { $_->as_string } $change->conflicts ], + planned_at => $change->timestamp, + planner_name => $change->planner_name, + planner_email => $change->planner_email, + } ) ); + } + + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::plan - List the changes in the plan + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::plan->new(%params); + $cmd->execute; + +=head1 Description + +If you want to know how to use the C command, you probably want to be +reading C. But if you really want to know how the C command +works, read on. + +=head1 Interface + +=head2 Attributes + +=head3 C + +Regular expression to match against change names. + +=head3 C + +Regular expression to match against planner names. + +=head3 C + +Event type buy which to filter entries to display. + +=head3 C + +Display format template. + +=head3 C + +Maximum number of entries to display. + +=head3 C + +Reverse the usual order of the display of entries. + +=head3 C + +Output headers. Defaults to true. + +=head3 C + +Number of entries to skip before displaying entries. + +=head2 Instance Methods + +=head3 C + + $plan->execute; + +Executes the plan command. The plan will be searched and the results output. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/rebase.pm b/lib/App/Sqitch/Command/rebase.pm new file mode 100644 index 00000000..995a8097 --- /dev/null +++ b/lib/App/Sqitch/Command/rebase.pm @@ -0,0 +1,183 @@ +package App::Sqitch::Command::rebase; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo; +use Types::Standard qw(Str); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use List::Util qw(first); +use Try::Tiny; +use namespace::autoclean; + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::RevertDeployCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has onto_change => ( + is => 'ro', + isa => Str, +); + +has upto_change => ( + is => 'ro', + isa => Str, +); + +sub options { + return qw( + onto-change|onto=s + upto-change|upto=s + ); +} + +sub configure { + my ( $class, $config, $opt ) = @_; + return { map { $_ => $opt->{$_} } grep { exists $opt->{$_} } qw( + onto_change + upto_change + ) }; +} + +sub execute { + my $self = shift; + my ($targets, $changes) = $self->parse_args( + target => $self->target, + args => \@_, + ); + + # Warn on multiple targets. + my $target = shift @{ $targets }; + $self->warn(__x( + 'Too many targets specified; connecting to {target}', + target => $target->name, + )) if @{ $targets }; + + # Warn on too many changes. + my $onto = $self->onto_change // shift @{ $changes }; + my $upto = $self->upto_change // shift @{ $changes }; + $self->warn(__x( + 'Too many changes specified; rebasing onto "{onto}" up to "{upto}"', + onto => $onto, + upto => $upto, + )) if @{ $changes }; + + + # Now get to work. + my $engine = $target->engine; + $engine->with_verify( $self->verify ); + $engine->no_prompt( $self->no_prompt ); + $engine->prompt_accept( $self->prompt_accept ); + $engine->log_only( $self->log_only ); + + # Revert. + $engine->set_variables( $self->_collect_revert_vars($target) ); + try { + $engine->revert( $onto ); + } catch { + # Rethrow unknown errors or errors with exitval > 1. + die $_ if ! eval { $_->isa('App::Sqitch::X') } + || $_->exitval > 1 + || $_->ident eq 'revert:confirm'; + # Emit notice of non-fatal errors (e.g., nothing to revert). + $self->info($_->message) + }; + + # Deploy. + $engine->set_variables( $self->_collect_deploy_vars($target) ); + $engine->deploy( $upto, $self->mode ); + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::rebase - Revert and redeploy Sqitch changes + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::rebase->new(%params); + $cmd->execute; + +=head1 Description + +If you want to know how to use the C command, you probably want to be +reading C. But if you really want to know how the C command +works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::rebase->options; + +Returns a list of L option specifications for the command-line +options for the C command. + +=head2 Attributes + +=head3 C + +Change onto which to rebase the target. + +=head3 C + +Change up to which to rebase the target. + +=head2 Instance Methods + +=head3 C + + $rebase->execute; + +Executes the rebase command. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/revert.pm b/lib/App/Sqitch/Command/revert.pm new file mode 100644 index 00000000..b731c159 --- /dev/null +++ b/lib/App/Sqitch/Command/revert.pm @@ -0,0 +1,234 @@ +package App::Sqitch::Command::revert; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo; +use Types::Standard qw(Str Bool HashRef); +use List::Util qw(first); +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use namespace::autoclean; + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::ContextCommand'; +with 'App::Sqitch::Role::ConnectingCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has target => ( + is => 'ro', + isa => Str, +); + +has to_change => ( + is => 'ro', + isa => Str, +); + +has no_prompt => ( + is => 'ro', + isa => Bool +); + +has prompt_accept => ( + is => 'ro', + isa => Bool +); + +has log_only => ( + is => 'ro', + isa => Bool, + default => 0, +); + +has variables => ( + is => 'ro', + isa => HashRef, + lazy => 1, + default => sub { {} }, +); + +sub options { + return qw( + target|t=s + to-change|to|change=s + set|s=s% + log-only + y + ); +} + +sub configure { + my ( $class, $config, $opt ) = @_; + + my %params = map { $_ => $opt->{$_} } grep { exists $opt->{$_} } qw( + to_change + log_only + target + ); + + if ( my $vars = $opt->{set} ) { + $params{variables} = $vars + } + + $params{no_prompt} = delete $opt->{y} // $config->get( + key => 'revert.no_prompt', + as => 'bool', + ) // 0; + + $params{prompt_accept} = $config->get( + key => 'revert.prompt_accept', + as => 'bool', + ) // 1; + + return \%params; +} + +sub _collect_vars { + my ($self, $target) = @_; + my $cfg = $self->sqitch->config; + return ( + %{ $cfg->get_section(section => 'core.variables') }, + %{ $cfg->get_section(section => 'deploy.variables') }, + %{ $cfg->get_section(section => 'revert.variables') }, + %{ $target->variables }, # includes engine + %{ $self->variables }, # --set + ); +} + +sub execute { + my $self = shift; + my ($targets, $changes) = $self->parse_args( + target => $self->target, + args => \@_, + ); + + # Warn on multiple targets. + my $target = shift @{ $targets }; + $self->warn(__x( + 'Too many targets specified; connecting to {target}', + target => $target->name, + )) if @{ $targets }; + + # Warn on too many changes. + my $change = $self->to_change // shift @{ $changes }; + $self->warn(__x( + 'Too many changes specified; reverting to "{change}"', + change => $change, + )) if @{ $changes }; + + # Now get to work. + my $engine = $target->engine; + $engine->no_prompt( $self->no_prompt ); + $engine->prompt_accept( $self->prompt_accept ); + $engine->log_only( $self->log_only ); + $engine->set_variables( $self->_collect_vars($target) ); + $engine->revert( $change ); + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::revert - Revert Sqitch changes from a database + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::revert->new(%params); + $cmd->execute; + +=head1 Description + +If you want to know how to use the C command, you probably want to be +reading C. But if you really want to know how the C command +works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::revert->options; + +Returns a list of L option specifications for the command-line +options for the C command. + +=head2 Attributes + +=head3 C + +Boolean indicating whether to log the deploy without running the scripts. + +=head3 C + +Boolean indicating whether or not to prompt the user to really go through with +the revert. + +=head3 C + +Boolean value to indicate whether or not the default value for the prompt, +should the user hit C, is to accept the prompt or deny it. + +=head3 C + +The deployment target URI. + +=head3 C + +Change to revert to. + +=head2 Instance Methods + +=head3 C + + $revert->execute; + +Executes the revert command. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/rework.pm b/lib/App/Sqitch/Command/rework.pm new file mode 100644 index 00000000..7b885689 --- /dev/null +++ b/lib/App/Sqitch/Command/rework.pm @@ -0,0 +1,333 @@ +package App::Sqitch::Command::rework; + +use 5.010; +use strict; +use warnings; +use utf8; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use File::Copy; +use Moo; +use App::Sqitch::Types qw(Str ArrayRef Bool Maybe); +use namespace::autoclean; + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::ContextCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has change_name => ( + is => 'ro', + isa => Maybe[Str], +); + +has requires => ( + is => 'ro', + isa => ArrayRef[Str], + default => sub { [] }, +); + +has conflicts => ( + is => 'ro', + isa => ArrayRef[Str], + default => sub { [] }, +); + +has all => ( + is => 'ro', + isa => Bool, + default => 0 +); + +has note => ( + is => 'ro', + isa => ArrayRef[Str], + default => sub { [] }, +); + +has open_editor => ( + is => 'ro', + isa => Bool, + lazy => 1, + default => sub { + my $self = shift; + return $self->sqitch->config->get( + key => 'rework.open_editor', + as => 'bool', + ) // $self->sqitch->config->get( + key => 'add.open_editor', + as => 'bool', + ) // 0; + }, +); + +sub options { + return qw( + change-name|change|c=s + requires|r=s@ + conflicts|x=s@ + all|a! + note|n|m=s@ + open-editor|edit|e! + ); +} + +sub configure { + my ( $class, $config, $opt ) = @_; + # Just keep the options. + return $opt; +} + +sub execute { + my $self = shift; + my ($name, $targets, $changes) = $self->parse_args( + names => [$self->change_name], + all => $self->all, + args => \@_, + no_changes => 1, + ); + + # Check if the name is identified as a change. + $name ||= shift @{ $changes } || $self->usage; + + my $note = join "\n\n", => @{ $self->note }; + my ($first_change, %reworked, @files, %seen); + + for my $target (@{ $targets }) { + my $plan = $target->plan; + my $file = $plan->file; + my $spec = $reworked{$file} ||= { scripts => [] }; + my ($prev, $reworked); + if ($prev = $spec->{prev}) { + # Need a dupe for *this* target so script names are right. + $reworked = ref($prev)->new( + plan => $plan, + name => $name, + ); + + # Copy the rework tags to the previous instance in this plan. + my $new_prev = $spec->{prev} = $plan->get( + $name . [$plan->last_tagged_change->tags]->[-1]->format_name + ); + $new_prev->add_rework_tags($prev->rework_tags); + $prev = $new_prev; + + } else { + # Rework it. + $reworked = $spec->{change} = $plan->rework( + name => $name, + requires => $self->requires, + conflicts => $self->conflicts, + note => $note, + ); + $first_change ||= $reworked; + + # Get the latest instance of the change. + $prev = $spec->{prev} = $plan->get( + $name . [$plan->last_tagged_change->tags]->[-1]->format_name + ); + } + + # Record the files to be copied to the previous change name. + push @{ $spec->{scripts} } => map { + push @files => $_->[0] if -e $_->[0]; + $_; + } grep { + !$seen{ $_->[0] }++; + } ( + [ $reworked->deploy_file, $prev->deploy_file ], + [ $reworked->revert_file, $prev->revert_file ], + [ $reworked->verify_file, $prev->verify_file ], + ); + + # Replace the revert file with the previous deploy file. + push @{ $spec->{scripts} } => [ + $reworked->deploy_file, + $reworked->revert_file, + $prev->revert_file, + ] unless $seen{$prev->revert_file}++; + } + + # Make sure we have a note. + $note = $first_change->request_note( + for => __ 'rework', + scripts => \@files, + ); + + # Time to write everything out. + for my $target (@{ $targets }) { + my $plan = $target->plan; + my $file = $plan->file; + my $spec = delete $reworked{$file} or next; + + # Copy the files for this spec. + $self->_copy(@{ $_ }) for @{ $spec->{scripts } }; + + # We good, write the plan file back out. + $plan->write_to( $plan->file ); + + # Let the user know. + $self->info(__x( + 'Added "{change}" to {file}.', + change => $spec->{change}->format_op_name_dependencies, + file => $plan->file, + )); + } + + # Now tell them what to do. + $self->info(__n( + 'Modify this file as appropriate:', + 'Modify these files as appropriate:', + scalar @files, + )); + $self->info(" * $_") for @files; + + # Let 'em at it. + if ($self->open_editor) { + my $sqitch = $self->sqitch; + $sqitch->shell( $sqitch->editor . ' ' . $sqitch->quote_shell(@files) ); + } + + return $self; +} + +sub _copy { + my ( $self, $src, $dest, $orig ) = @_; + $orig ||= $src; + if (!-e $orig) { + $self->debug(__x( + 'Skipped {dest}: {src} does not exist', + dest => $dest, + src => $orig, + )); + return; + } + + # Stringify to work around bug in File::Copy warning on 5.10.0. + File::Copy::syscopy "$src", "$dest" or hurl rework => __x( + 'Cannot copy {src} to {dest}: {error}', + src => $src, + dest => $dest, + error => $!, + ); + + $self->debug(__x( + 'Copied {src} to {dest}', + dest => $dest, + src => $src, + )); + return $orig; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::rework - Rework a Sqitch change + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::rework->new(%params); + $cmd->execute; + +=head1 Description + +Reworks a change. This will result in the copying of the existing deploy, +revert, and verify scripts for the change to preserve the earlier instances of +the change. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::rework->options; + +Returns a list of L option specifications for the command-line +options for the C command. + +=head3 C + + my $params = App::Sqitch::Command::rework->configure( + $config, + $options, + ); + +Processes the configuration and command options and returns a hash suitable +for the constructor. + +=head2 Attributes + +=head3 C + +The name of the change to be reworked. + +=head3 C + +Text of the change note. + +=head3 C + +List of required changes. + +=head3 C + +List of conflicting changes. + +=head3 C + +Boolean indicating whether or not to run the command against all plans in the +project. + +=head2 Instance Methods + +=head3 C + + $rework->execute($command); + +Executes the C command. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/show.pm b/lib/App/Sqitch/Command/show.pm new file mode 100644 index 00000000..2fdcdc2b --- /dev/null +++ b/lib/App/Sqitch/Command/show.pm @@ -0,0 +1,203 @@ +package App::Sqitch::Command::show; + +use 5.010; +use strict; +use warnings; +use utf8; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use List::Util qw(first); +use Moo; +use App::Sqitch::Types qw(Bool Str); + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::ContextCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has target => ( + is => 'ro', + isa => Str, +); + +has exists_only => ( + is => 'ro', + isa => Bool, + default => 0, +); + +sub options { + return qw( + target|t=s + exists|e! + ); +} + +sub configure { + my ( $class, $config, $opt ) = @_; + $opt->{exists_only} = delete $opt->{exists} + if exists $opt->{exists}; + return $class->SUPER::configure( $config, $opt ); +} + +sub execute { + my ( $self, $type, $key ) = @_; + $self->usage unless $type && $key; + + my $target = $self->target ? App::Sqitch::Target->new( + $self->target_params, + name => $self->target, + ) : $self->default_target; + my $plan = $target->plan; + + # Handle tags first. + if ( $type eq 'tag' ) { + my $is_id = $key =~ /^[0-9a-f]{40}/; + my $change = $plan->get( + $is_id ? $key : ($key =~ /^@/ ? '' : '@') . $key + ); + + my $tag = $change ? do { + if ($is_id) { + # It's a tag ID. + first { $_->id eq $key } $change->tags; + } else { + # Tag name. + (my $name = $key) =~ s/^[@]//; + first { $_->name eq $name } $change->tags; + } + } : undef; + unless ($tag) { + return if $self->exists_only; + hurl show => __x( 'Unknown tag "{tag}"', tag => $key ); + } + $self->emit( $tag->info ) unless $self->exists_only; + return $self; + } + + # Make sure we recognize the type. + hurl show => __x( + 'Unknown object type "{type}', + type => $type, + ) unless first { $type eq $_ } qw(change deploy revert verify); + + # Make sure we have a change object. + my $change = $plan->get($key) or do { + return if $self->exists_only; + hurl show => __x( + 'Unknown change "{change}"', + change => $key + ); + }; + + if ($type eq 'change') { + # Just show its info. + $self->emit( $change->info ) unless $self->exists_only; + return $self; + } + + my $meth = $change->can("$type\_file"); + my $path = $change->$meth; + unless (-e $path) { + return if $self->exists_only; + hurl show => __x('File "{path}" does not exist', path => $path); + } + hurl show => __x('"{path}" is not a file', path => $path) + if $path->is_dir; + + return $self if $self->exists_only; + + # Assume nothing about the encoding. + binmode STDOUT, ':raw'; + $self->emit( $path->slurp(iomode => '<:raw') ); + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::show - Show Sqitch changes to a database + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::show->new(%params); + $cmd->execute($type, $name); + +=head1 Description + +Shows the content of a Sqitch object. + +If you want to know how to use the C command, you probably want to be +reading C. But if you really want to know how the C command +works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::show->options; + +Returns a list of L option specifications for the command-line +options for the C command. + +=head2 Attributes + +=head3 C + +Boolean indicating whether or not to suppress output and instead exit with +zero status if object exists and is a valid object. + +=head2 Instance Methods + +=head3 C + + $show->execute; + +Executes the show command. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/status.pm b/lib/App/Sqitch/Command/status.pm new file mode 100644 index 00000000..51a9071e --- /dev/null +++ b/lib/App/Sqitch/Command/status.pm @@ -0,0 +1,432 @@ +package App::Sqitch::Command::status; + +use 5.010; +use strict; +use warnings; +use utf8; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use Moo; +use App::Sqitch::Types qw(Str Bool Target); +use List::Util qw(max); +use Try::Tiny; +use namespace::autoclean; + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::ContextCommand'; +with 'App::Sqitch::Role::ConnectingCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has target_name => ( + is => 'ro', + isa => Str, +); + +has target => ( + is => 'rw', + isa => Target, + handles => [qw(engine plan plan_file)], +); + +has show_changes => ( + is => 'ro', + isa => Bool, + lazy => 1, + default => sub { + shift->sqitch->config->get( + key => "status.show_changes", + as => 'bool', + ) // 0; + } +); + +has show_tags => ( + is => 'ro', + isa => Bool, + lazy => 1, + default => sub { + shift->sqitch->config->get( + key => "status.show_tags", + as => 'bool', + ) // 0; + } +); + +has date_format => ( + is => 'ro', + lazy => 1, + isa => Str, + default => sub { + shift->sqitch->config->get( key => 'status.date_format' ) || 'iso' + } +); + +has project => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $self = shift; + try { $self->plan->project } catch { + # Just die on parse and I/O errors. + die $_ if try { $_->ident eq 'parse' || $_->ident eq 'io' }; + + # Try to extract a project name from the registry. + my $engine = $self->engine; + hurl status => __ 'Database not initialized for Sqitch' + unless $engine->initialized; + my @projs = $engine->registered_projects + or hurl status => __ 'No projects registered'; + hurl status => __x( + 'Use --project to select which project to query: {projects}', + projects => join __ ', ', @projs, + ) if @projs > 1; + return $projs[0]; + }; + }, +); + +sub options { + return qw( + project=s + target|t=s + show-tags + show-changes + date-format|date=s + ); +} + +sub execute { + my $self = shift; + my ($targets) = $self->parse_args( + target => $self->target_name, + args => \@_, + ); + + # Warn on multiple targets. + my $target = shift @{ $targets }; + $self->warn(__x( + 'Too many targets specified; connecting to {target}', + target => $target->name, + )) if @{ $targets }; + + # Good to go. + $self->target($target); + my $engine = $target->engine; + + # Where are we? + $self->comment( __x 'On database {db}', db => $engine->destination ); + + # Exit with status 1 on no state, probably not expected. + my $state = try { + $engine->current_state( $self->project ) + } catch { + # Just die on parse and I/O errors. + die $_ if try { $_->ident eq 'parse' || $_->ident eq 'io' }; + + # Hrm. Maybe not initialized? + die $_ if $engine->initialized; + hurl status => __x( + 'Database {db} has not been initialized for Sqitch', + db => $engine->registry_destination + ); + }; + + hurl { + ident => 'status', + message => __ 'No changes deployed', + exitval => 1, + } unless $state; + + # Emit the state basics. + $self->emit_state($state); + + # Emit changes and tags, if required. + $self->emit_changes; + $self->emit_tags; + + my $plan_proj = try { $target->plan->project }; + if (defined $plan_proj && $self->project eq $plan_proj ) { + $self->emit_status($state); + } else { + # If we have no access to the project plan, we can't emit the status. + $self->comment(''); + $self->emit(__x( + 'Status unknown. Use --plan-file to assess "{project}" status', + project => $self->project, + )); + } + + return $self; +} + +sub configure { + my ( $class, $config, $opt ) = @_; + + # Make sure the date format is valid. + if (my $format = $opt->{date_format} + || $config->get(key => 'status.date_format') + ) { + require App::Sqitch::DateTime; + App::Sqitch::DateTime->validate_as_string_format($format); + } + + # Set boolean options from config. + for my $key (qw(show_changes show_tags)) { + next if exists $opt->{$key}; + my $val = $config->get(key => "status.$key", as => 'bool') // next; + $opt->{$key} = $val; + } + + my $ret = $class->SUPER::configure( $config, $opt ); + $ret->{target_name} = delete $ret->{target} if exists $ret->{target}; + return $ret; +} + +sub emit_state { + my ( $self, $state ) = @_; + $self->comment(__x( + 'Project: {project}', + project => $state->{project}, + )); + $self->comment(__x( + 'Change: {change_id}', + change_id => $state->{change_id}, + )); + $self->comment(__x( + 'Name: {change}', + change => $state->{change}, + )); + if (my @tags = @{ $state->{tags}} ) { + $self->comment(__nx( + 'Tag: {tags}', + 'Tags: {tags}', + @tags, + tags => join(__ ', ', @tags), + )); + } + + $self->comment(__x( + 'Deployed: {date}', + date => $state->{committed_at}->as_string( + format => $self->date_format + ), + )); + $self->comment(__x( + 'By: {name} <{email}>', + name => $state->{committer_name}, + email=> $state->{committer_email}, + )); + return $self; +} + +sub _all { + my $iter = shift; + my @res; + while (my $row = $iter->()) { + push @res => $row; + } + return \@res; +} + +sub emit_changes { + my $self = shift; + return $self unless $self->show_changes; + + # Emit the header. + my $changes = _all $self->engine->current_changes( $self->project ); + $self->comment(''); + $self->comment(__n 'Change:', 'Changes:', @{ $changes }); + + # Find the longest change name. + my $len = max map { length $_->{change} } @{ $changes }; + my $format = $self->date_format; + + # Emit each change. + $self->comment(sprintf( + ' %s%s - %s - %s <%s>', + $_->{change}, + ((' ') x ($len - length $_->{change})) || '', + $_->{committed_at}->as_string( format => $format ), + $_->{committer_name}, + $_->{committer_email}, + )) for @{ $changes }; + + return $self; +} + +sub emit_tags { + my $self = shift; + return $self unless $self->show_tags; + + # Emit the header. + my $tags = _all $self->engine->current_tags( $self->project ); + $self->comment(''); + + # If no tags, say so and return. + unless (@{ $tags }) { + $self->comment(__ 'Tags: None.'); + return $self; + } + + $self->comment(__n 'Tag:', 'Tags:', @{ $tags }); + + # Find the longest tag name. + my $len = max map { length $_->{tag} } @{ $tags }; + my $format = $self->date_format; + + # Emit each tag. + $self->comment(sprintf( + ' %s%s - %s - %s <%s>', + $_->{tag}, + ((' ') x ($len - length $_->{tag})) || '', + $_->{committed_at}->as_string( format => $format ), + $_->{committer_name}, + $_->{committer_email}, + )) for @{ $tags }; + + return $self; +} + +sub emit_status { + my ( $self, $state ) = @_; + my $plan = $self->plan; + $self->comment(''); + + my $idx = $plan->index_of( $state->{change_id} ) // do { + $self->vent(__x( + 'Cannot find this change in {file}', + file => $self->plan_file + )); + hurl status => __ 'Make sure you are connected to the proper ' + . 'database for this project.'; + }; + + # Say something about our current state. + if ( $idx == $plan->count - 1 ) { + $self->emit( __ 'Nothing to deploy (up-to-date)' ); + } else { + $self->emit(__n( + 'Undeployed change:', + 'Undeployed changes:', + $plan->count - ( $idx + 1 ) + )); + $plan->position($idx); + while ( my $change = $plan->next ) { + $self->emit( ' * ', $change->format_name_with_tags ); + } + } + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::status - Display status information about Sqitch + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::status->new(%params); + $cmd->execute; + +=head1 Description + +If you want to know how to use the C command, you probably want to be +reading C. But if you really want to know how the C command +works, read on. + +=head1 Interface + +=head2 Attributes + +=head3 C + +The name or URI of the database target as specified by the C<--target> option. + +=head3 C + +An L object from which to read the status. Must be +instantiated by C. + +=head2 Instance Methods + +=head3 C + + $status->execute; + +Executes the status command. The current state of the target database will be +compared to the plan in order to show where things stand. + +=head3 C + + $status->emit_changes; + +Emits a list of deployed changes if C is true. + +=head3 C + + $status->emit_tags; + +Emits a list of deployed tags if C is true. + +=head3 C + + $status->emit_state($state); + +Emits the current state of the target database. Pass in a state hash as +returned by L C. + +=head3 C + + $status->emit_state($state); + +Emits information about the current status of the target database compared to +the plan. Pass in a state hash as returned by L +C. Throws an exception if the current state's change cannot +be found in the plan. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/tag.pm b/lib/App/Sqitch/Command/tag.pm new file mode 100644 index 00000000..37193e93 --- /dev/null +++ b/lib/App/Sqitch/Command/tag.pm @@ -0,0 +1,206 @@ +package App::Sqitch::Command::tag; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo; +use App::Sqitch::X qw(hurl); +use Types::Standard qw(Str ArrayRef Maybe Bool); +use Locale::TextDomain qw(App-Sqitch); +use List::Util qw(first); +use namespace::autoclean; + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::ContextCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has tag_name => ( + is => 'ro', + isa => Maybe[Str], +); + +has change_name => ( + is => 'ro', + isa => Maybe[Str], +); + +has all => ( + is => 'ro', + isa => Bool, + default => 0 +); + +has note => ( + is => 'ro', + isa => ArrayRef[Str], + default => sub { [] }, +); + +sub options { + return qw( + tag-name|tag|t=s + change-name|change|c=s + all|a! + note|n|m=s@ + ); +} + +sub configure { + my ( $class, $config, $opt ) = @_; + # Just keep options. + return $opt; +} + +sub execute { + my $self = shift; + my ($name, $change, $targets) = $self->parse_args( + names => [$self->tag_name, $self->change_name], + all => $self->all, + args => \@_, + no_changes => 1, + ); + + if (defined $name) { + my $note = join "\n\n" => @{ $self->note }; + my (%seen, @plans, @tags); + for my $target (@{ $targets }) { + next if $seen{$target->plan_file}++; + my $plan = $target->plan; + push @tags => $plan->tag( + name => $name, + change => $change, + note => $note, + ); + push @plans => $plan; + } + + # Make sure we have a note. + $note = $tags[0]->request_note(for => __ 'tag'); + + # We good, write the plan files back out. + for my $plan (@plans) { + my $tag = shift @tags; + $tag->note($note); + $plan->write_to( $plan->file ); + $self->info(__x( + 'Tagged "{change}" with {tag} in {file}', + change => $tag->change->format_name, + tag => $tag->format_name, + file => $plan->file, + )); + } + } else { + # Check for missing name. + if (@_) { + if (my $target = first { my $n = $_->name; first { $_ eq $n } @_ } @{ $targets }) { + # Name conflicts with a target. + hurl tag => __x( + 'Name "{name}" identifies a target; use "--tag {name}" to use it for the tag name', + name => $target->name, + ); + } + } + + # Show unique tags. + my %seen; + for my $target (@{ $targets }) { + my $plan = $target->plan; + for my $tag ($plan->tags) { + my $name = $tag->format_name; + $self->info($name) unless $seen{$name}++; + } + } + } + + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::tag - Add or list tags in Sqitch plans + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::tag->new(%params); + $cmd->execute; + +=head1 Description + +Tags a Sqitch change. The tag will be added to the last change in the plan. + +=head1 Interface + +=head2 Attributes + +=head3 C + +The name of the tag to add. + +=head3 C + +The name of the change to tag. + +=head3 C + +Boolean indicating whether or not to run the command against all plans in the +project. + +=head3 C + +Text of the tag note. + +=head2 Instance Methods + +=head3 C + + $tag->execute($command); + +Executes the C command. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/target.pm b/lib/App/Sqitch/Command/target.pm new file mode 100644 index 00000000..35c6f6da --- /dev/null +++ b/lib/App/Sqitch/Command/target.pm @@ -0,0 +1,337 @@ +package App::Sqitch::Command::target; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo; +use Types::Standard qw(Str Int HashRef); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use URI::db; +use Try::Tiny; +use Path::Class qw(file dir); +use List::Util qw(max); +use namespace::autoclean; +use constant extra_target_keys => qw(uri); + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::TargetConfigCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +sub configure { + # No config; target config is actually targets. + return {}; +} + +sub execute { + my ( $self, $action ) = (shift, shift); + $action ||= 'list'; + $action =~ s/-/_/g; + my $meth = $self->can($action) or $self->usage(__x( + 'Unknown action "{action}"', + action => $action, + )); + return $self->$meth(@_); +} + +sub list { + my $self = shift; + my $sqitch = $self->sqitch; + my %targets = $sqitch->config->get_regexp(key => qr/^target[.][^.]+[.]uri$/); + + # Make it verbose if --verbose was passed at all. + my $format = $sqitch->options->{verbosity} ? "%1\$s\t%2\$s" : '%1$s'; + for my $key (sort keys %targets) { + my ($target) = $key =~ /target[.]([^.]+)/; + $sqitch->emit(sprintf $format, $target, $targets{$key}); + } + + return $self; +} + +sub add { + my ($self, $name, $uri) = @_; + $self->usage unless $name && $uri; + + my $key = "target.$name"; + my $config = $self->sqitch->config; + + hurl target => __x( + 'Target "{target}" already exists', + target => $name + ) if $config->get( key => "$key.uri"); + + # Put together the URI and other config variables. + my $vars = $self->config_params($key); + unshift @{ $vars } => { + key => "$key.uri", + value => URI::db->new($uri, 'db:')->as_string, + }; + + # Make it so. + $config->group_set( $config->local_file, $vars ); + my $target = $self->config_target(name => $name); + $self->write_plan(target => $target); + $self->make_directories_for( $target ); + return $self; +} + +sub alter { + my ($self, $target) = @_; + $self->usage unless $target; + + my $key = "target.$target"; + my $config = $self->sqitch->config; + my $props = $self->properties; + + hurl target => __x( + 'Missing Target "{target}"; use "{command}" to add it', + target => $target, + command => "add $target " . ($props->{uri} || '$uri'), + ) unless $config->get( key => "target.$target.uri"); + + # Make it so. + $config->group_set( $config->local_file, $self->config_params($key) ); + $self->make_directories_for( $self->config_target(name => $target) ); +} + +sub rm { shift->remove(@_) } +sub remove { + my ($self, $name) = @_; + $self->usage unless $name; + if ( my @deps = $self->_dependencies($name) ) { + hurl target => __x( + q{Cannot rename target "{target}" because it's referenced by: {engines}}, + target => $name, + engines => join ', ', @deps + ); + } + $self->_rename($name); +} + +sub rename { + my ($self, $old, $new) = @_; + $self->usage unless $old && $new; + if ( my @deps = $self->_dependencies($old) ) { + hurl target => __x( + q{Cannot rename target "{target}" because it's referenced by: {engines}}, + target => $old, + engines => join ', ', @deps + ); + } + $self->_rename($old, $new); +} + +sub _dependencies { + my ($self, $name) = @_; + my %depends = $self->sqitch->config->get_regexp( + key => qr/^(?:core|engine[.][^.]+)[.]target$/ + ); + return grep { $depends{$_} eq $name } sort keys %depends; +} + +sub _rename { + my ($self, $old, $new) = @_; + my $config = $self->sqitch->config; + + try { + $config->rename_section( + from => "target.$old", + ($new ? (to => "target.$new") : ()), + filename => $config->local_file, + ); + } catch { + die $_ unless /No such section/; + hurl target => __x( + 'Unknown target "{target}"', + target => $old, + ); + }; + try { + $config->rename_section( + from => "target.$old.variables", + ($new ? (to => "target.$new.variables") : ()), + filename => $config->local_file, + ); + } catch { + die $_ unless /No such section/; + }; + return $self; +} + +sub show { + my ($self, @names) = @_; + return $self->list unless @names; + my $sqitch = $self->sqitch; + my $config = $sqitch->config; + + my %label_for = ( + uri => __('URI'), + registry => __('Registry'), + client => __('Client'), + top_dir => __('Top Directory'), + plan_file => __('Plan File'), + extension => __('Extension'), + revert => ' ' . __ 'Revert', + deploy => ' ' . __ 'Deploy', + verify => ' ' . __ 'Verify', + reworked => ' ' . __ 'Reworked', + ); + + my $len = max map { length } values %label_for; + $_ .= ': ' . ' ' x ($len - length $_) for values %label_for; + + # Header labels. + $label_for{script_dirs} = __('Script Directories') . ':'; + $label_for{reworked_dirs} = __('Reworked Script Directories') . ':'; + $label_for{variables} = __('Variables') . ':'; + $label_for{no_variables} = __('No Variables'); + + require App::Sqitch::Target; + for my $name (@names) { + my $target = App::Sqitch::Target->new( + $self->target_params, + name => $name, + ); + $self->emit("* $name"); + $self->emit(' ', $label_for{uri}, $target->uri->as_string); + $self->emit(' ', $label_for{registry}, $target->registry); + $self->emit(' ', $label_for{client}, $target->client); + $self->emit(' ', $label_for{top_dir}, $target->top_dir); + $self->emit(' ', $label_for{plan_file}, $target->plan_file); + $self->emit(' ', $label_for{extension}, $target->extension); + $self->emit(' ', $label_for{script_dirs}); + $self->emit(' ', $label_for{deploy}, $target->deploy_dir); + $self->emit(' ', $label_for{revert}, $target->revert_dir); + $self->emit(' ', $label_for{verify}, $target->verify_dir); + $self->emit(' ', $label_for{reworked_dirs}); + $self->emit(' ', $label_for{reworked}, $target->reworked_dir); + $self->emit(' ', $label_for{deploy}, $target->reworked_deploy_dir); + $self->emit(' ', $label_for{revert}, $target->reworked_revert_dir); + $self->emit(' ', $label_for{verify}, $target->reworked_verify_dir); + my $vars = $target->variables; + if (%{ $vars }) { + my $len = max map { length } keys %{ $vars }; + $self->emit(' ', $label_for{variables}); + $self->emit(" $_: " . (' ' x ($len - length $_)) . $vars->{$_}) + for sort { lc $a cmp lc $b } keys %{ $vars }; + } else { + $self->emit(' ', $label_for{no_variables}); + } + } + + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::target - Add, modify, or list Sqitch target databases + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::target->new(%params); + $cmd->execute; + +=head1 Description + +Manages Sqitch targets, which are stored in the local configuration file. + +=head1 Interface + +=head3 Class Methods + +=head3 C + +Returns a list of additional option keys to be specified via options. + +=head2 Instance Methods + +=head2 Attributes + +=head3 C + +Hash of property values to set. + +=head3 C + +Verbosity. + +=head3 C + + $target->execute($command); + +Executes the C command. + +=head3 C + +Implements the C action. + +=head3 C + +Implements the C action. + +=head3 C + +Implements the C action. + +=head3 C + +=head3 C + +Implements the C action. + +=head3 C + +Implements the C action. + +=head3 C + +Implements the C action. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/upgrade.pm b/lib/App/Sqitch/Command/upgrade.pm new file mode 100644 index 00000000..55393187 --- /dev/null +++ b/lib/App/Sqitch/Command/upgrade.pm @@ -0,0 +1,148 @@ +package App::Sqitch::Command::upgrade; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo; +use App::Sqitch::Types qw(URI Maybe Str Bool HashRef); +use Locale::TextDomain qw(App-Sqitch); +use Type::Utils qw(enum); +use App::Sqitch::X qw(hurl); +use List::Util qw(first); +use namespace::autoclean; +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::ConnectingCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has target => ( + is => 'ro', + isa => Str, +); + +sub options { + return qw( + target|t=s + ); +} + +sub execute { + my $self = shift; + my ($targets) = $self->parse_args( + target => $self->target, + args => \@_, + ); + + # Warn on multiple targets. + my $target = shift @{ $targets }; + $self->warn(__x( + 'Too many targets specified; using {target}', + target => $target->name, + )) if @{ $targets }; + + my $engine = $target->engine; + + if ($engine->needs_upgrade) { + $self->info(__x( + 'Upgrading registry {registry} to version {version}', + registry => $engine->registry_destination, + version => $engine->registry_release, + )); + $engine->upgrade_registry; + } else { + $self->info(__x( + 'Registry {registry} is up-to-date at version {version}', + registry => $engine->registry_destination, + version => $engine->registry_release, + )); + } + + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::upgrade - Upgrade the Sqitch registry + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::upgrade->new(%params); + $cmd->execute; + +=head1 Description + +If you want to know how to use the C command, you probably want to be +reading C. But if you really want to know how the C +command works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::upgrade->options; + +Returns a list of L option specifications for the command-line +options for the C command. + +=head2 Attributes + +=head3 C + +The upgrade target. + +=head2 Instance Methods + +=head3 C + + $upgrade->execute; + +Executes the upgrade command. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Command/verify.pm b/lib/App/Sqitch/Command/verify.pm new file mode 100644 index 00000000..497ae807 --- /dev/null +++ b/lib/App/Sqitch/Command/verify.pm @@ -0,0 +1,205 @@ +package App::Sqitch::Command::verify; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo; +use Types::Standard qw(Str HashRef); +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use List::Util qw(first); +use namespace::autoclean; + +extends 'App::Sqitch::Command'; +with 'App::Sqitch::Role::ContextCommand'; +with 'App::Sqitch::Role::ConnectingCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has target => ( + is => 'ro', + isa => Str, +); + +has from_change => ( + is => 'ro', + isa => Str, +); + +has to_change => ( + is => 'ro', + isa => Str, +); + +has variables => ( + is => 'ro', + isa => HashRef, + lazy => 1, + default => sub { {} }, +); + +sub options { + return qw( + target|t=s + from-change|from=s + to-change|to=s + set|s=s% + ); +} + +sub configure { + my ( $class, $config, $opt ) = @_; + + my %params = map { + $_ => $opt->{$_} + } grep { + exists $opt->{$_} + } qw(target from_change to_change); + + if ( my $vars = $opt->{set} ) { + $params{variables} = $vars; + } + + return \%params; +} + +sub _collect_vars { + my ($self, $target) = @_; + my $cfg = $self->sqitch->config; + return ( + %{ $cfg->get_section(section => 'core.variables') }, + %{ $cfg->get_section(section => 'deploy.variables') }, + %{ $cfg->get_section(section => 'verify.variables') }, + %{ $target->variables }, # includes engine + %{ $self->variables }, # --set + ); +} + +sub execute { + my $self = shift; + my ($targets, $changes) = $self->parse_args( + target => $self->target, + args => \@_, + ); + + # Warn on multiple targets. + my $target = shift @{ $targets }; + $self->warn(__x( + 'Too many targets specified; connecting to {target}', + target => $target->name, + )) if @{ $targets }; + + # Warn on too many changes. + my $from = $self->from_change // shift @{ $changes }; + my $to = $self->to_change // shift @{ $changes }; + $self->warn(__x( + 'Too many changes specified; verifying from "{from}" to "{to}"', + from => $from, + to => $to, + )) if @{ $changes }; + + # Now get to work. + my $engine = $target->engine; + $engine->set_variables( $self->_collect_vars($target) ); + $engine->verify($from, $to); + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::verify - Verify deployed Sqitch changes + +=head1 Synopsis + + my $cmd = App::Sqitch::Command::verify->new(%params); + $cmd->execute; + +=head1 Description + +If you want to know how to use the C command, you probably want to be +reading C. But if you really want to know how the C command +works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::verify->options; + +Returns a list of L option specifications for the command-line +options for the C command. + +=head2 Attributes + +=head3 C + +Change onto which to rebase the target. + +=head3 C + +The verify target database URI. + +=head3 C + +Change from which to verify changes. + +=head3 C + +Change up to which to verify changes. + +=head2 Instance Methods + +=head3 C + + $verify->execute; + +Executes the verify command. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Config.pm b/lib/App/Sqitch/Config.pm new file mode 100644 index 00000000..cd79ab17 --- /dev/null +++ b/lib/App/Sqitch/Config.pm @@ -0,0 +1,231 @@ +package App::Sqitch::Config; + +use 5.010; +use Moo; +use strict; +use warnings; +use Path::Class; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use Config::GitLike 1.15; +use utf8; + +extends 'Config::GitLike'; + +our $VERSION = 'v1.0.0'; # VERSION + +has '+confname' => ( default => 'sqitch.conf' ); +has '+encoding' => ( default => 'UTF-8' ); + +# Set by ./Build; see Module::Build::Sqitch for details. +my $SYSTEM_DIR = undef; + +sub user_dir { + my $hd = $^O eq 'MSWin32' && "$]" < '5.016' ? $ENV{HOME} || $ENV{USERPROFILE} : (glob('~'))[0]; + hurl config => __("Could not determine home directory") if not $hd; + return dir $hd, '.sqitch'; +} + +sub system_dir { + dir $SYSTEM_DIR || do { + require Config; + $Config::Config{prefix}, 'etc', 'sqitch'; + }; +} + +sub system_file { + my $self = shift; + return file $ENV{SQITCH_SYSTEM_CONFIG} + || $self->system_dir->file( $self->confname ); +} + +sub global_file { shift->system_file } + +sub user_file { + my $self = shift; + return file $ENV{SQITCH_USER_CONFIG} + || $self->user_dir->file( $self->confname ); +} + +sub local_file { + return file $ENV{SQITCH_CONFIG} if $ENV{SQITCH_CONFIG}; + return file shift->confname; +} + +sub dir_file { shift->local_file } + +# Section keys always have the top section lowercase, and subsections are +# left as-is. +sub _skey($) { + my $key = shift // return ''; + my ($sec, $sub, $name) = Config::GitLike::_split_key($key); + return lc $key unless $sec; + return lc($sec) . '.' . join '.', grep { defined } $sub, $name; +} + +sub get_section { + my ( $self, %p ) = @_; + $self->load unless $self->is_loaded; + my $section = _skey $p{section}; + my $data = $self->data; + return { + map { + ( split /[.]/ => $self->initial_key("$section.$_") )[-1], + $data->{"$section.$_"} + } + grep { s{^\Q$section.\E([^.]+)$}{$1} } keys %{$data} + }; +} + +sub initial_key { + my $key = shift->original_key(shift); + return ref $key ? $key->[0] : $key; +} + +sub initialized { + my $self = shift; + $self->load unless $self->is_loaded; + return $self->{_initialized}; +} + +sub load_dirs { + my $self = shift; + local $self->{__loading_dirs} = 1; + $self->SUPER::load_dirs(@_); +} + +sub load_file { + my $self = shift; + $self->{_initialized} ||= $self->{__loading_dirs}; + $self->SUPER::load_file(@_); +} + +1; + +=head1 Name + +App::Sqitch::Config - Sqitch configuration management + +=head1 Synopsis + + my $config = App::Sqitch::Config->new; + say scalar $config->dump; + +=head1 Description + +This class provides the interface to Sqitch configuration. It inherits from +L, and therefore provides the complete interface of that +module. + +=head1 Interface + +=head2 Instance Methods + +=head3 C + +Returns the configuration file base name, which is F. + +=head3 C + +Returns the path to the system configuration directory, which is +F<$(prefix)/etc/sqitch/templates>. Call C to find out +where, exactly (e.g., C<$(sqitch --etc-path)/sqitch.plan>). + +=head3 C + +Returns the path to the user configuration directory, which is F<~/.sqitch/>. + +=head3 C + +Returns the path to the system configuration file. The value returned will be +the contents of the C<$SQITCH_SYSTEM_CONFIG> environment variable, if it's +defined, or else F<$(prefix)/etc/sqitch/templates>. Call C +to find out where, exactly (e.g., C<$(sqitch --etc-path)/sqitch.plan>). + +=head3 C + +An alias for C for use by the parent class. + +=head3 C + +Returns the path to the user configuration file. The value returned will be +the contents of the C<$SQITCH_USER_CONFIG> environment variable, if it's +defined, or else C<~/.sqitch/sqitch.conf>. + +=head3 C + +Returns the path to the local configuration file, which is just +F<./sqitch.conf>, unless C<$SQITCH_CONFIG> is set, in which case its value +will be returned. + +=head3 C + +An alias for C for use by the parent class. + +=head3 C + + say 'Project not initialized' unless $config->initialized; + +Returns true if the project configuration file was found, and false if it was +not. Useful for detecting when a command has been run from a directory with no +Sqitch configuration. + +=head3 C + + my $core = $config->get_section(section => 'core'); + my $pg = $config->get_section(section => 'engine.pg'); + +Returns a hash reference containing only the keys within the specified +section or subsection. + +=head3 C + +Adds a comment to the configuration file. + +=head3 C + + my $key = $config->initial_key($data_key); + +Given the lowercase key from the loaded data, this method returns it in its +original case. This is like C, only in the case where there are +multiple keys (for multivalue keys), only the first key is returned. + +=head1 See Also + +=over + +=item * L + +=item * L + +=item * L + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/DateTime.pm b/lib/App/Sqitch/DateTime.pm new file mode 100644 index 00000000..f599de0a --- /dev/null +++ b/lib/App/Sqitch/DateTime.pm @@ -0,0 +1,214 @@ +package App::Sqitch::DateTime; + +use 5.010; +use strict; +use warnings; +use utf8; +use parent 'DateTime'; +use DateTime 1.04; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use List::Util qw(first); +use constant ISWIN => $^O eq 'MSWin32'; + +our $VERSION = 'v1.0.0'; # VERSION + +sub as_string_formats { + return qw( + raw + iso + iso8601 + rfc + rfc2822 + full + long + medium + short + ); +} + +sub validate_as_string_format { + my ( $self, $format ) = @_; + hurl datetime => __x( + 'Unknown date format "{format}"', + format => $format + ) unless (first { $format eq $_ } $self->as_string_formats) + || $format =~ /^(?:cldr|strftime):/; + return $self; +} + +sub as_string { + my ( $self, %opts ) = @_; + my $format = $opts{format} || 'raw'; + my $dt = $self->clone; + + if ($format eq 'raw') { + $dt->set_time_zone('UTC'); + return $dt->iso8601 . 'Z'; + } + + $dt->set_time_zone('local'); + + if ( first { $format eq $_ } qw(iso iso8601) ) { + return join ' ', $dt->ymd('-'), $dt->hms(':'), $dt->strftime('%z'); + } elsif ( first { $format eq $_ } qw(rfc rfc2822) ) { + $dt->set_locale('en_US'); + ( my $rv = $dt->strftime('%a, %d %b %Y %H:%M:%S %z') ) =~ + s/\+0000$/-0000/; + return $rv; + } else { + if (ISWIN) { + require Win32::Locale; + $dt->set_locale( Win32::Locale::get_locale() ); + } else { + require POSIX; + $dt->set_locale( POSIX::setlocale( POSIX::LC_TIME() ) ); + } + return $dt->format_cldr($format) if $format =~ s/^cldr://; + return $dt->strftime($format) if $format =~ s/^strftime://; + my $meth = $dt->locale->can("datetime_format_$format") or hurl( + datetime => __x( + 'Unknown date format "{format}"', + format => $format + ) + ); + return $dt->format_cldr( $dt->locale->$meth ); + } +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::DateTime - Sqitch DateTime object + +=head1 Synopsis + + my $dt = App::Sqitch::DateTime->new(%params); + say $dt->as_string( format => 'iso' ); + +=head1 Description + +This subclass of L provides additional interfaces to support named +formats. These can be used for L or L +C<--date-format> options. App::Sqitch::DateTime provides a list of supported +formats, validates that a format string, and uses the formats to convert +itself into the appropriate string. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @formats = App::Sqitch::DateTime->as_string_formats; + +Returns a list of formats supported by the C parameter to +C. The list currently includes: + +=over + +=item C + +=item C + +ISO-8601 format. + +=item C + +=item C + +RFC-2822 format. + +=item C + +=item C + +=item C + +=item C + +Localized format of the specified length. + +=item C + +Show timestamps in raw format, which is strict ISO-8601 in the UTC time zone. + +=item C + +Show timestamps using an arbitrary C pattern. See +L for comprehensive documentation of supported +patterns. + +=item C + +Show timestamps using an arbitrary C pattern. See L for comprehensive documentation of supported patterns. + +=back + +=head3 C + + App::Sqitch::DateTime->validate_as_string_format($format); + +Validates that a format is supported by C. Throws an exception if +it's not, and returns if it is. + +=head2 Instance Methods + +=head3 C + + $dt->as_string; + $dt->as_string( format => $format ); + +Returns a string representation using the provided format. The format must be +one of those listed by C or an exception will be thrown. If +no format is passed, the string will be formatted with the C format. + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Engine.pm b/lib/App/Sqitch/Engine.pm new file mode 100644 index 00000000..2c1ce685 --- /dev/null +++ b/lib/App/Sqitch/Engine.pm @@ -0,0 +1,2463 @@ +package App::Sqitch::Engine; + +use 5.010; +use Moo; +use strict; +use utf8; +use Try::Tiny; +use Locale::TextDomain qw(App-Sqitch); +use Path::Class qw(file); +use App::Sqitch::X qw(hurl); +use List::Util qw(first max); +use URI::db 0.19; +use App::Sqitch::Types qw(Str Int Sqitch Plan Bool HashRef URI Maybe Target); +use namespace::autoclean; +use constant registry_release => '1.1'; + +our $VERSION = 'v1.0.0'; # VERSION + +has sqitch => ( + is => 'ro', + isa => Sqitch, + required => 1, + weak_ref => 1, +); + +has target => ( + is => 'ro', + isa => Target, + required => 1, + weak_ref => 1, + handles => { + uri => 'uri', + client => 'client', + registry => 'registry', + destination => 'name', + } +); + +has username => ( + is => 'ro', + isa => Maybe[Str], + lazy => 1, + default => sub { + my $self = shift; + $self->target->username || $self->_def_user + }, +); + +has password => ( + is => 'ro', + isa => Maybe[Str], + lazy => 1, + default => sub { + my $self = shift; + $self->target->password || $self->_def_pass + }, +); + +sub _def_user { } +sub _def_pass { } + +sub registry_destination { shift->destination } + +has start_at => ( + is => 'rw', + isa => Str +); + +has no_prompt => ( + is => 'rw', + isa => Bool, + default => 0, +); + +has prompt_accept => ( + is => 'rw', + isa => Bool, + default => 1, +); + +has log_only => ( + is => 'rw', + isa => Bool, + default => 0, +); + +has with_verify => ( + is => 'rw', + isa => Bool, + default => 0, +); + +has max_name_length => ( + is => 'rw', + isa => Int, + default => 0, + lazy => 1, + default => sub { + my $plan = shift->plan; + max map { + length $_->format_name_with_tags + } $plan->changes; + }, +); + +has plan => ( + is => 'rw', + isa => Plan, + lazy => 1, + default => sub { shift->target->plan } +); + +has _variables => ( + is => 'rw', + isa => HashRef[Str], + default => sub { {} }, +); + +sub variables { %{ shift->_variables } } +sub set_variables { shift->_variables({ @_ }) } +sub clear_variables { %{ shift->_variables } = () } + +sub default_registry { 'sqitch' } + +sub load { + my ( $class, $p ) = @_; + + # We should have an engine param. + my $target = $p->{target} or hurl 'Missing "target" parameter to load()'; + + # Load the engine class. + my $ekey = $target->engine_key or hurl engine => __( + 'No engine specified; specify via target or core.engine' + ); + + my $pkg = __PACKAGE__ . '::' . $target->engine_key; + eval "require $pkg" or hurl "Unable to load $pkg"; + return $pkg->new( $p ); +} + +sub driver { shift->key } + +sub key { + my $class = ref $_[0] || shift; + hurl engine => __ 'No engine specified; specify via target or core.engine' + if $class eq __PACKAGE__; + my $pkg = quotemeta __PACKAGE__; + $class =~ s/^$pkg\:://; + return $class; +} + +sub name { shift->key } + +sub config_vars { + return ( + target => 'any', + registry => 'any', + client => 'any' + ); +} + +sub use_driver { + my $self = shift; + my $driver = $self->driver; + eval "use $driver"; + hurl $self->key => __x( + '{driver} required to manage {engine}', + driver => $driver, + engine => $self->name, + ) if $@; + return $self; +} + +sub deploy { + my ( $self, $to, $mode ) = @_; + my $sqitch = $self->sqitch; + my $plan = $self->_sync_plan; + my $to_index = $plan->count - 1; + + hurl plan => __ 'Nothing to deploy (empty plan)' if $to_index < 0; + + if (defined $to) { + $to_index = $plan->index_of($to) // hurl plan => __x( + 'Unknown change: "{change}"', + change => $to, + ); + + # Just return if there is nothing to do. + if ($to_index == $plan->position) { + $sqitch->info(__x( + 'Nothing to deploy (already at "{change}")', + change => $to + )); + return $self; + } + } + + if ($plan->position == $to_index) { + # We are up-to-date. + $sqitch->info( __ 'Nothing to deploy (up-to-date)' ); + return $self; + + } elsif ($plan->position == -1) { + # Initialize or upgrade the database, if necessary. + if ($self->initialized) { + $self->upgrade_registry; + } else { + $sqitch->info(__x( + 'Adding registry tables to {destination}', + destination => $self->registry_destination, + )); + $self->initialize; + } + $self->register_project; + + } else { + # Make sure that $to_index is greater than the current point. + hurl deploy => __ 'Cannot deploy to an earlier change; use "revert" instead' + if $to_index < $plan->position; + # Upgrade database if it needs it. + $self->upgrade_registry; + } + + $sqitch->info( + defined $to ? __x( + 'Deploying changes through {change} to {destination}', + change => $plan->change_at($to_index)->format_name_with_tags, + destination => $self->destination, + ) : __x( + 'Deploying changes to {destination}', + destination => $self->destination, + ) + ); + + # Check that all dependencies will be satisfied. + $self->check_deploy_dependencies($plan, $to_index); + + # Do it! + $mode ||= 'all'; + my $meth = $mode eq 'change' ? '_deploy_by_change' + : $mode eq 'tag' ? '_deploy_by_tag' + : $mode eq 'all' ? '_deploy_all' + : hurl deploy => __x 'Unknown deployment mode: "{mode}"', mode => $mode; + ; + + $self->max_name_length( + max map { + length $_->format_name_with_tags + } ($plan->changes)[$plan->position + 1..$to_index] + ); + + $self->$meth( $plan, $to_index ); +} + +sub revert { + my ( $self, $to ) = @_; + $self->_check_registry; + my $sqitch = $self->sqitch; + my $plan = $self->plan; + + my @changes; + + if (defined $to) { + my ($change) = $self->_load_changes( + $self->change_for_key($to) + ) or do { + # Not deployed. Is it in the plan? + if ( $plan->find($to) ) { + # Known but not deployed. + hurl revert => __x( + 'Change not deployed: "{change}"', + change => $to + ); + } + # Never heard of it. + hurl revert => __x( + 'Unknown change: "{change}"', + change => $to, + ); + }; + + @changes = $self->deployed_changes_since( + $self->_load_changes($change) + ) or do { + $sqitch->info(__x( + 'No changes deployed since: "{change}"', + change => $to, + )); + return $self; + }; + + if ($self->no_prompt) { + $sqitch->info(__x( + 'Reverting changes to {change} from {destination}', + change => $change->format_name_with_tags, + destination => $self->destination, + )); + } else { + hurl { + ident => 'revert:confirm', + message => __ 'Nothing reverted', + exitval => 1, + } unless $sqitch->ask_yes_no(__x( + 'Revert changes to {change} from {destination}?', + change => $change->format_name_with_tags, + destination => $self->destination, + ), $self->prompt_accept ); + } + + } else { + @changes = $self->deployed_changes or do { + $sqitch->info(__ 'Nothing to revert (nothing deployed)'); + return $self; + }; + + if ($self->no_prompt) { + $sqitch->info(__x( + 'Reverting all changes from {destination}', + destination => $self->destination, + )); + } else { + hurl { + ident => 'revert', + message => __ 'Nothing reverted', + exitval => 1, + } unless $sqitch->ask_yes_no(__x( + 'Revert all changes from {destination}?', + destination => $self->destination, + ), $self->prompt_accept ); + } + } + + # Make change objects and check that all dependencies will be satisfied. + @changes = reverse $self->_load_changes( @changes ); + $self->check_revert_dependencies(@changes); + + # Do we want to support modes, where failures would re-deploy to previous + # tag or all the way back to the starting point? This would be very much + # like deploy() mode. I'm thinking not, as a failure on a revert is not + # something you generally want to recover from by deploying back to where + # you started. But maybe I'm wrong? + $self->max_name_length( + max map { length $_->format_name_with_tags } @changes + ); + $self->revert_change($_) for @changes; + + return $self; +} + +sub verify { + my ( $self, $from, $to ) = @_; + $self->_check_registry; + my $sqitch = $self->sqitch; + my $plan = $self->plan; + my @changes = $self->_load_changes( $self->deployed_changes ); + + $sqitch->info(__x( + 'Verifying {destination}', + destination => $self->destination, + )); + + if (!@changes) { + my $msg = $plan->count + ? __ 'No changes deployed' + : __ 'Nothing to verify (no planned or deployed changes)'; + $sqitch->info($msg); + return $self; + } + + if ($plan->count == 0) { + # Oy, there are deployed changes, but not planned! + hurl verify => __ 'There are deployed changes, but none planned!'; + } + + # Figure out where to start and end relative to the plan. + my $from_idx = defined $from + ? $self->_trim_to('verify', $from, \@changes) + : 0; + + my $to_idx = defined $to ? $self->_trim_to('verify', $to, \@changes, 1) : do { + if (my $id = $self->latest_change_id) { + $plan->index_of( $id ); + } + } // $plan->count - 1; + + # Run the verify tests. + if ( my $count = $self->_verify_changes($from_idx, $to_idx, !$to, @changes) ) { + # Emit a quick report. + # XXX Consider coloring red. + my $num_changes = 1 + $to_idx - $from_idx; + $num_changes = @changes if @changes > $num_changes; + my $msg = __ 'Verify Summary Report'; + $sqitch->emit("\n", $msg); + $sqitch->emit('-' x length $msg); + $sqitch->emit(__x 'Changes: {number}', number => $num_changes ); + $sqitch->emit(__x 'Errors: {number}', number => $count ); + hurl verify => __ 'Verify failed'; + } + + # Success! + # XXX Consider coloring green. + $sqitch->emit(__ 'Verify successful'); + + return $self; +} + +sub _trim_to { + my ( $self, $ident, $key, $changes, $pop ) = @_; + my $sqitch = $self->sqitch; + my $plan = $self->plan; + + # Find the to change in the database. + my $to_id = $self->change_id_for_key( $key ) || hurl $ident => ( + $plan->contains( $key ) ? __x( + 'Change "{change}" has not been deployed', + change => $key, + ) : __x( + 'Cannot find "{change}" in the database or the plan', + change => $key, + ) + ); + + # Find the change in the plan. + my $to_idx = $plan->index_of( $to_id ) // hurl $ident => __x( + 'Change "{change}" is deployed, but not planned', + change => $key, + ); + + # Pop or shift changes till we find the change we want. + if ($pop) { + pop @{ $changes } while $changes->[-1]->id ne $to_id; + } else { + shift @{ $changes } while $changes->[0]->id ne $to_id; + } + + # We good. + return $to_idx; +} + +sub _verify_changes { + my $self = shift; + my $from_idx = shift; + my $to_idx = shift; + my $pending = shift; + my $sqitch = $self->sqitch; + my $plan = $self->plan; + my $errcount = 0; + my $i = -1; + my @seen; + + my $max_name_len = max map { + length $_->format_name_with_tags + } @_, map { $plan->change_at($_) } $from_idx..$to_idx; + + for my $change (@_) { + $i++; + my $errs = 0; + my $reworked = 0; + my $name = $change->format_name_with_tags; + $sqitch->emit_literal( + " * $name ..", + '.' x ($max_name_len - length $name), ' ' + ); + + my $plan_index = $plan->index_of( $change->id ); + if (defined $plan_index) { + push @seen => $plan_index; + if ( $plan_index != ($from_idx + $i) ) { + $sqitch->comment(__ 'Out of order'); + $errs++; + } + # Is it reworked? + $reworked = $plan->change_at($plan_index)->is_reworked; + } else { + $sqitch->comment(__ 'Not present in the plan'); + $errs++; + } + + # Run the verify script. + try { $self->verify_change( $change ) } catch { + $sqitch->comment(eval { $_->message } // $_); + $errs++; + } unless $reworked; + + # Emit pass/fail and add to the total error count. + $sqitch->emit( $errs ? __ 'not ok' : __ 'ok' ); + $errcount += $errs; + } + + # List any undeployed changes. + for my $idx ($from_idx..$to_idx) { + next if defined first { $_ == $idx } @seen; + my $change = $plan->change_at( $idx ); + my $name = $change->format_name_with_tags; + $sqitch->emit_literal( + " * $name ..", + '.' x ($max_name_len - length $name), ' ', + __ 'not ok', ' ' + ); + $sqitch->comment(__ 'Not deployed'); + $errcount++; + } + + # List any pending changes. + if ($pending && $to_idx < ($plan->count - 1)) { + if (my @pending = map { + $plan->change_at($_) + } ($to_idx + 1)..($plan->count - 1) ) { + $sqitch->emit(__n( + 'Undeployed change:', + 'Undeployed changes:', + @pending, + )); + + $sqitch->emit( ' * ', $_->format_name_with_tags ) for @pending; + } + } + + return $errcount; +} + +sub verify_change { + my ( $self, $change ) = @_; + my $file = $change->verify_file; + if (-e $file) { + return try { $self->run_verify($file) } + catch { + hurl { + ident => 'verify', + previous_exception => $_, + message => __x( + 'Verify script "{script}" failed.', + script => $file, + ), + }; + }; + } + + # The file does not exist. Complain, but don't die. + $self->sqitch->vent(__x( + 'Verify script {file} does not exist', + file => $file, + )); + + return $self; +} + +sub run_deploy { shift->run_file(@_) } +sub run_revert { shift->run_file(@_) } +sub run_verify { shift->run_file(@_) } +sub run_upgrade { shift->run_file(@_) } + +sub check_deploy_dependencies { + my ( $self, $plan, $to_index ) = @_; + my $from_index = $plan->position + 1; + $to_index //= $plan->count - 1; + my @changes = map { $plan->change_at($_) } $from_index..$to_index; + my (%seen, @conflicts, @required); + + for my $change (@changes) { + # Check for conflicts. + push @conflicts => grep { + $seen{ $_->id // '' } || $self->change_id_for_depend($_) + } $change->conflicts; + + # Check for prerequisites. + push @required => grep { !$_->resolved_id(do { + if ( my $req = $seen{ $_->id // '' } ) { + $req->id; + } else { + $self->change_id_for_depend($_); + } + }) } $change->requires; + $seen{ $change->id } = $change; + } + + if (@conflicts or @required) { + require List::MoreUtils; + my $listof = sub { List::MoreUtils::uniq(map { $_->as_string } @_) }; + # Dependencies not satisfied. Put together the error messages. + my @msg; + push @msg, __nx( + 'Conflicts with previously deployed change: {changes}', + 'Conflicts with previously deployed changes: {changes}', + scalar @conflicts, + changes => join ' ', @conflicts, + ) if @conflicts = $listof->(@conflicts); + + push @msg, __nx( + 'Missing required change: {changes}', + 'Missing required changes: {changes}', + scalar @required, + changes => join ' ', @required, + ) if @required = $listof->(@required); + + hurl deploy => join "\n" => @msg; + } + + # Make sure nothing isn't already deployed. + if ( my @ids = $self->are_deployed_changes(@changes) ) { + hurl deploy => __nx( + 'Change "{changes}" has already been deployed', + 'Changes have already been deployed: {changes}', + scalar @ids, + changes => join ' ', map { $seen{$_} } @ids + ); + } + + return $self; +} + +sub check_revert_dependencies { + my $self = shift; + my $proj = $self->plan->project; + my (%seen, @msg); + + for my $change (@_) { + $seen{ $change->id } = 1; + my @requiring = grep { + !$seen{ $_->{change_id} } + } $self->changes_requiring_change($change) or next; + + # XXX Include change_id in the output? + push @msg => __nx( + 'Change "{change}" required by currently deployed change: {changes}', + 'Change "{change}" required by currently deployed changes: {changes}', + scalar @requiring, + change => $change->format_name_with_tags, + changes => join ' ', map { + ($_->{project} eq $proj ? '' : "$_->{project}:" ) + . $_->{change} + . ($_->{asof_tag} // '') + } @requiring + ); + } + + hurl revert => join "\n", @msg if @msg; + + # XXX Should we make sure that they are all deployed before trying to + # revert them? + + return $self; +} + +sub change_id_for_depend { + my ( $self, $dep ) = @_; + hurl engine => __x( + 'Invalid dependency: {dependency}', + dependency => $dep->as_string, + ) unless defined $dep->id + || defined $dep->change + || defined $dep->tag; + + # Return the first one. + return $self->change_id_for( + change_id => $dep->id, + change => $dep->change, + tag => $dep->tag, + project => $dep->project, + first => 1, + ); +} + +sub _params_for_key { + my ( $self, $key ) = @_; + my $offset = App::Sqitch::Plan::ChangeList::_offset $key; + my ( $cname, $tag ) = split /@/ => $key, 2; + + my @off = ( offset => $offset ); + return ( @off, change => $cname, tag => $tag ) if $tag; + return ( @off, change_id => $cname ) if $cname =~ /^[0-9a-f]{40}$/; + return ( @off, tag => $cname ) if $cname eq 'HEAD' || $cname eq 'ROOT'; + return ( @off, change => $cname ); +} + +sub change_id_for_key { + my $self = shift; + return $self->find_change_id( $self->_params_for_key(shift) ); +} + +sub find_change_id { + my ( $self, %p ) = @_; + + # Find the change ID or return undef. + my $change_id = $self->change_id_for( + change_id => $p{change_id}, + change => $p{change}, + tag => $p{tag}, + project => $p{project} || $self->plan->project, + ) // return; + + # Return relative to the offset. + return $self->change_id_offset_from_id($change_id, $p{offset}); +} + +sub change_for_key { + my $self = shift; + return $self->find_change( $self->_params_for_key(shift) ); +} + +sub find_change { + my ( $self, %p ) = @_; + + # Find the change ID or return undef. + my $change_id = $self->change_id_for( + change_id => $p{change_id}, + change => $p{change}, + tag => $p{tag}, + project => $p{project} || $self->plan->project, + ) // return; + + # Return relative to the offset. + return $self->change_offset_from_id($change_id, $p{offset}); +} + +sub _load_changes { + my $self = shift; + my $plan = $self->plan; + my (@changes, %seen); + my %rework_tags_for; + for my $params (@_) { + next unless $params; + my $tags = $params->{tags} || []; + my $c = App::Sqitch::Plan::Change->new(%{ $params }, plan => $plan ); + + # Add tags. + $c->add_tag( + App::Sqitch::Plan::Tag->new(name => $_, plan => $plan, change => $c ) + ) for map { s/^@//; $_ } @{ $tags }; + + if ( defined ( my $prev_idx = $seen{ $params->{name} } ) ) { + # It's reworked; grab all subsequent tags up to but not including + # the reworking change to the reworked change. + my $ctags = $rework_tags_for{ $prev_idx } ||= []; + my $i; + for my $x ($prev_idx..$#changes) { + my $rtags = $ctags->[$i++] ||= []; + my %s = map { $_->name => 1 } @{ $rtags }; + push @{ $rtags } => grep { !$s{$_->name} } $changes[$x]->tags; + } + } + + if ( defined ( my $reworked_idx = eval { + $plan->first_index_of( @{ $params }{qw(name id)} ) + } ) ) { + # The plan has it reworked later; grab all tags from this change + # up to but not including the reworked change. + my $ctags = $rework_tags_for{ $#changes + 1 } ||= []; + my $idx = $plan->index_of($params->{id}); + my $i; + for my $x ($idx..$reworked_idx - 1) { + my $c = $plan->change_at($x); + my $rtags = $ctags->[$i++] ||= []; + push @{ $rtags } => $plan->change_at($x)->tags; + } + } + + push @changes => $c; + $seen{ $params->{name} } = $#changes; + } + + # Associate all rework tags in reverse order. Tags fetched from the plan + # have priority over tags fetched from the database. + while (my ($idx, $tags) = each %rework_tags_for) { + my %seen; + $changes[$idx]->add_rework_tags( + grep { !$seen{$_->name}++ } + map { @{ $_ } } reverse @{ $tags } + ); + } + + return @changes; +} + +sub _handle_lookup_index { + my ( $self, $change, $ids ) = @_; + + # Return if 0 or 1 ID. + return $ids->[0] if @{ $ids } <= 1; + + # Too many found! Let the user know. + my $sqitch = $self->sqitch; + $sqitch->vent(__x( + 'Change "{change}" is ambiguous. Please specify a tag-qualified change:', + change => $change, + )); + + # Lookup, emit reverse-chron list of tag-qualified changes, and die. + my $plan = $self->plan; + for my $id ( reverse @{ $ids } ) { + # Look in the plan, first. + if ( my $change = $plan->find($id) ) { + $self->sqitch->vent( ' * ', $change->format_tag_qualified_name ) + } else { + # Look it up in the database. + $self->sqitch->vent( ' * ', $self->name_for_change_id($id) // '' ) + } + } + hurl engine => __ 'Change Lookup Failed'; +} + +sub _deploy_by_change { + my ( $self, $plan, $to_index ) = @_; + + # Just deploy each change. If any fails, we just stop. + while ($plan->position < $to_index) { + $self->deploy_change($plan->next); + } + + return $self; +} + +sub _rollback { + my ($self, $tagged) = (shift, shift); + my $sqitch = $self->sqitch; + + if (my @run = reverse @_) { + $tagged = $tagged ? $tagged->format_name_with_tags : $self->start_at; + $sqitch->vent( + $tagged ? __x('Reverting to {change}', change => $tagged) + : __ 'Reverting all changes' + ); + + try { + $self->revert_change($_) for @run; + } catch { + # Sucks when this happens. + $sqitch->vent(eval { $_->message } // $_); + $sqitch->vent(__ 'The schema will need to be manually repaired'); + }; + } + + hurl deploy => __ 'Deploy failed'; +} + +sub _deploy_by_tag { + my ( $self, $plan, $to_index ) = @_; + + my ($last_tagged, @run); + try { + while ($plan->position < $to_index) { + my $change = $plan->next; + $self->deploy_change($change); + push @run => $change; + if ($change->tags) { + @run = (); + $last_tagged = $change; + } + } + } catch { + if (my $ident = eval { $_->ident }) { + $self->sqitch->vent($_->message) unless $ident eq 'private' + } else { + $self->sqitch->vent($_); + } + $self->_rollback($last_tagged, @run); + }; + + return $self; +} + +sub _deploy_all { + my ( $self, $plan, $to_index ) = @_; + + my @run; + try { + while ($plan->position < $to_index) { + my $change = $plan->next; + $self->deploy_change($change); + push @run => $change; + } + } catch { + if (my $ident = eval { $_->ident }) { + $self->sqitch->vent($_->message) unless $ident eq 'private' + } else { + $self->sqitch->vent($_); + } + $self->_rollback(undef, @run); + }; + + return $self; +} + +sub _sync_plan { + my $self = shift; + my $plan = $self->plan; + + if (my $state = $self->current_state) { + my $idx = $plan->index_of($state->{change_id}) // hurl plan => __x( + 'Cannot find change {id} ({change}) in {file}', + id => $state->{change_id}, + change => join(' ', $state->{change}, @{ $state->{tags} || [] }), + file => $plan->file, + ); + + # Upgrade the registry if there is no script_hash column. + unless ( exists $state->{script_hash} ) { + $self->upgrade_registry; + $state->{script_hash} = $state->{change_id}; + } + + # Update the script hashes if they're the same as the change ID. + # DEPRECATTION: Added in v0.998 (Jan 2015, c86cba61c); consider removing + # in the future when all databases are likely to be updated already. + $self->_update_script_hashes if $state->{script_hash} + && $state->{script_hash} eq $state->{change_id}; + + $plan->position($idx); + my $change = $plan->change_at($idx); + if (my @tags = $change->tags) { + $self->log_new_tags($change); + $self->start_at( $change->format_name . $tags[-1]->format_name ); + } else { + $self->start_at( $change->format_name ); + } + + } else { + $plan->reset; + } + return $plan; +} + +sub is_deployed { + my ($self, $thing) = @_; + return $thing->isa('App::Sqitch::Plan::Tag') + ? $self->is_deployed_tag($thing) + : $self->is_deployed_change($thing); +} + +sub deploy_change { + my ( $self, $change ) = @_; + my $sqitch = $self->sqitch; + my $name = $change->format_name_with_tags; + $sqitch->info_literal( + " + $name ..", + '.' x ($self->max_name_length - length $name), ' ' + ); + $self->begin_work($change); + + return try { + $self->run_deploy($change->deploy_file) unless $self->log_only; + try { + $self->verify_change( $change ) if $self->with_verify; + $self->log_deploy_change($change); + $sqitch->info(__ 'ok'); + } catch { + # Oy, logging or verify failed. Rollback. + $sqitch->vent(eval { $_->message } // $_); + $self->rollback_work($change); + + # Begin work and run the revert. + try { + # Don't bother displaying the reverting change name. + # $self->sqitch->info(' - ', $change->format_name_with_tags); + $self->begin_work($change); + $self->run_revert($change->revert_file) unless $self->log_only; + } catch { + # Oy, the revert failed. Just emit the error. + $sqitch->vent(eval { $_->message } // $_); + }; + hurl private => __ 'Deploy failed'; + }; + } finally { + $self->finish_work($change); + } catch { + $self->log_fail_change($change); + $sqitch->info(__ 'not ok'); + die $_; + }; +} + +sub revert_change { + my ( $self, $change ) = @_; + my $sqitch = $self->sqitch; + my $name = $change->format_name_with_tags; + $sqitch->info_literal( + " - $name ..", + '.' x ($self->max_name_length - length $name), ' ' + ); + + $self->begin_work($change); + + try { + $self->run_revert($change->revert_file) unless $self->log_only; + try { + $self->log_revert_change($change); + $sqitch->info(__ 'ok'); + } catch { + # Oy, our logging died. Rollback and revert this change. + $self->sqitch->vent(eval { $_->message } // $_); + $self->rollback_work($change); + hurl revert => 'Revert failed'; + }; + } finally { + $self->finish_work($change); + } catch { + $sqitch->info(__ 'not ok'); + die $_; + }; +} + +sub begin_work { shift } +sub finish_work { shift } +sub rollback_work { shift } + +sub earliest_change { + my $self = shift; + my $change_id = $self->earliest_change_id(@_) // return undef; + return $self->plan->get( $change_id ); +} + +sub latest_change { + my $self = shift; + my $change_id = $self->latest_change_id(@_) // return undef; + return $self->plan->get( $change_id ); +} + +sub needs_upgrade { + my $self = shift; + $self->registry_version != $self->registry_release; +} + +sub _check_registry { + my $self = shift; + my $newver = $self->registry_release; + my $oldver = $self->registry_version; + return $self if $newver == $oldver; + + hurl engine => __x( + 'No registry found in {destination}. Have you ever deployed?', + destination => $self->registry_destination, + ) if $oldver == 0 && !$self->initialized; + + hurl engine => __x( + 'Registry version is {old} but {new} is the latest known. Please upgrade Sqitch', + old => $oldver, + new => $newver, + ) if $newver < $oldver; + + hurl engine => __x( + 'Registry is at version {old} but latest is {new}. Please run the "upgrade" command', + old => $oldver, + new => $newver, + ) if $newver > $oldver; +} + +sub upgrade_registry { + my $self = shift; + return $self unless $self->needs_upgrade; + + my $sqitch = $self->sqitch; + my $newver = $self->registry_release; + my $oldver = $self->registry_version; + + hurl __x( + 'Registry version is {old} but {new} is the latest known. Please upgrade Sqitch.', + old => $oldver, + new => $newver, + ) if $newver < $oldver; + + my $key = $self->key; + my $dir = file(__FILE__)->dir->subdir(qw(Engine Upgrade)); + + my @scripts = sort { $a->[0] <=> $b->[0] } grep { $_->[0] > $oldver } map { + $_->basename =~ /\A\Q$key\E-(\d(?:[.]\d*)?)/; + [ $1 || 0, $_ ]; + } $dir->children; + + # Make sure we're upgrading to where we want to be. + hurl engine => __x( + 'Cannot upgrade to {version}: Cannot find upgrade script "{file}"', + version => $newver, + file => $dir->file("$key-$newver.*"), + ) unless @scripts && $scripts[-1]->[0] == $newver; + + # Run the upgrades. + $sqitch->info(__x( + 'Upgrading the Sqitch registry from {old} to {new}', + old => $oldver, + new => $newver, + )); + for my $script (@scripts) { + my ($version, $file) = @{ $script }; + $sqitch->info(' * ' . __x( + 'From {old} to {new}', + old => $oldver, + new => $version, + )); + $self->run_upgrade($file); + $self->_register_release($version); + $oldver = $version; + } + + return $self; +} + +sub initialized { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented initialized()"; +} + +sub initialize { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented initialize()"; +} + +sub register_project { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented register_project()"; +} + +sub run_file { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented run_file()"; +} + +sub run_handle { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented run_handle()"; +} + +sub log_deploy_change { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented log_deploy_change()"; +} + +sub log_fail_change { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented log_fail_change()"; +} + +sub log_revert_change { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented log_revert_change()"; +} + +sub log_new_tags { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented log_new_tags()"; +} + +sub is_deployed_tag { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented is_deployed_tag()"; +} + +sub is_deployed_change { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented is_deployed_change()"; +} + +sub are_deployed_changes { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented are_deployed_changes()"; +} + +sub change_id_for { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented change_id_for()"; +} + +sub earliest_change_id { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented earliest_change_id()"; +} + +sub latest_change_id { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented latest_change_id()"; +} + +sub deployed_changes { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented deployed_changes()"; +} + +sub deployed_changes_since { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented deployed_changes_since()"; +} + +sub load_change { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented load_change()"; +} + +sub changes_requiring_change { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented changes_requiring_change()"; +} + +sub name_for_change_id { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented name_for_change_id()"; +} + +sub change_offset_from_id { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented change_offset_from_id()"; +} + +sub change_id_offset_from_id { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented change_id_offset_from_id()"; +} + +sub registered_projects { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented registered_projects()"; +} + +sub current_state { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented current_state()"; +} + +sub current_changes { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented current_changes()"; +} + +sub current_tags { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented current_tags()"; +} + +sub search_events { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented search_events()"; +} + +sub registry_version { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented registry_version()"; +} + +sub _update_script_hashes { + my $class = ref $_[0] || $_[0]; + hurl "$class has not implemented _update_script_hashes()"; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Engine - Sqitch Deployment Engine + +=head1 Synopsis + + my $engine = App::Sqitch::Engine->new( sqitch => $sqitch ); + +=head1 Description + +App::Sqitch::Engine provides the base class for all Sqitch storage engines. +Most likely this will not be of much interest to you unless you are hacking on +the engine code. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my $name = App::Sqitch::Engine->key; + +The key name of the engine. Should be the last part of the package name. + +=head3 C + + my $name = App::Sqitch::Engine->name; + +The name of the engine. Returns the same value as C by default, but +should probably be overridden to return a display name for the engine. + +=head3 C + + my $reg = App::Sqitch::Engine->default_registry; + +Returns the name of the default registry for the engine. Most engines just +inherit the default value, C, but some must do more munging, such as +specifying a file name, to determine the default registry name. + +=head3 C + + my $cli = App::Sqitch::Engine->default_client; + +Returns the name of the default client for the engine. Must be implemented by +each engine. + +=head3 C + + my $driver = App::Sqitch::Engine->driver; + +The name and version of the database driver to use with the engine, returned +as a string suitable for passing to C. Used internally by C +to C the driver and, if it dies, to display an appropriate error message. +Must be overridden by subclasses. + +=head3 C + + App::Sqitch::Engine->use_driver; + +Uses the driver and version returned by C. Returns an error on failure +and returns true on success. + +=head3 C + + my %vars = App::Sqitch::Engine->config_vars; + +Returns a hash of names and types to use for configuration variables for the +engine. These can be set under the C section in any +configuration file. + +The keys in the returned hash are the names of the variables. The values are +the data types. Valid data types include: + +=over + +=item C + +=item C + +=item C + +=item C + +=item C + +=back + +Values ending in C<+> (a plus sign) may be specified multiple times. Example: + + ( + client => 'any', + host => 'any', + port => 'int', + set => 'any+', + ) + +In this example, the C variable will be stored and retrieved as an +integer. The C variable may be of any type and may be included multiple +times. All the other variables may be of any type. + +By default, App::Sqitch::Engine returns: + + ( + target => 'any', + registry => 'any', + client => 'any', + ) + +Subclasses for supported engines will return more. + +=head3 C + +Returns the version of the registry understood by this release of Sqitch. The +C method compares this value to that returned by +C to determine whether the target's registry needs +upgrading. + +=head2 Constructors + +=head3 C + + my $cmd = App::Sqitch::Engine->load(%params); + +A factory method for instantiating Sqitch engines. It loads the subclass for +the specified engine and calls C, passing the Sqitch object. Supported +parameters are: + +=over + +=item C + +The App::Sqitch object driving the whole thing. + +=back + +=head3 C + + my $engine = App::Sqitch::Engine->new(%params); + +Instantiates and returns a App::Sqitch::Engine object. + +=head2 Instance Accessors + +=head3 C + +The current Sqitch object. + +=head3 C + +An L object identifying the database target, usually +derived from the name of target specified on the command-line, or the default. + +=head3 C + +A L object representing the target database. Defaults to a URI +constructed from the L C attributes. + +=head3 C + +A string identifying the target database. Usually the same as the C, +unless it's a URI with the password included, in which case it returns the +value of C with the password removed. + +=head3 C + +The name of the registry schema or database. + +=head3 C + +The point in the plan from which to start deploying changes. + +=head3 C + +Boolean indicating whether or not to prompt for reverts. False by default. + +=head3 C + +Boolean indicating whether or not to log changes I. This is useful for an existing database schema that needs to +be converted to Sqitch. False by default. + +=head3 C + +Boolean indicating whether or not to run the verification script after each +deploy script. False by default. + +=head3 C + +A hash of engine client variables to be set. May be set and retrieved as a +list. + +=head2 Instance Methods + +=head3 C + + my $username = $engine->username; + +The username to use to connect to the database, for engines that require +authentication. The username is looked up in the following places, returning +the first to have a value: + +=over + +=item 1. + +The C<$SQITCH_USERNAME> environment variable. + +=item 2. + +The username from the target URI. + +=item 3. + +An engine-specific default password, which may be derived from an environment +variable, engine configuration file, the system user, or none at all. + +=back + +See L for details and best practices for Sqitch engine +authentication. + +=head3 C + + my $password = $engine->password; + +The password to use to connect to the database, for engines that require +authentication. The password is looked up in the following places, returning +the first to have a value: + +=over + +=item 1. + +The C<$SQITCH_PASSWORD> environment variable. + +=item 2. + +The password from the target URI. + +=item 3. + +An engine-specific default password, which may be derived from an environment +variable, engine configuration file, or none at all. + +=back + +See L for details and best practices for Sqitch engine +authentication. + +=head3 C + + my $registry_destination = $engine->registry_destination; + +Returns the name of the registry database. In other words, the database in +which Sqitch's own data is stored. It will usually be the same as C, +but some engines, such as L, may use a +separate database. Used internally to name the target when the registration +tables are created. + +=head3 C + +=head3 C + +=head3 C + + my %vars = $engine->variables; + $engine->set_variables(foo => 'bar', baz => 'hi there'); + $engine->clear_variables; + +Get, set, and clear engine variables. Variables are defined as key/value pairs +to be passed to the engine client in calls to C and C, if the +client supports variables. For example, the +L and +L engines pass all the variables to +their C and C clients via the C<--set> option, while the +L engine sets them via the C +command and the L engine sets them +via the SQL*Plus C command. + + +=head3 C + + $engine->deploy($to_change); + $engine->deploy($to_change, $mode); + $engine->deploy($to_change, $mode); + +Deploys changes to the target database, starting with the current deployment +state, and continuing to C<$to_change>. C<$to_change> must be a valid change +specification as passable to the C method of L. +If C<$to_change> is not specified, all changes will be applied. + +The second argument specifies the reversion mode in the case of deployment +failure. The allowed values are: + +=over + +=item C + +In the event of failure, revert all deployed changes, back to the point at +which deployment started. This is the default. + +=item C + +In the event of failure, revert all deployed changes to the last +successfully-applied tag. If no tags were applied during this deployment, all +changes will be reverted to the pint at which deployment began. + +=item C + +In the event of failure, no changes will be reverted. This is on the +assumption that a change failure is total, and the change may be applied again. + +=back + +Note that, in the event of failure, if a reversion fails, the target database +B. Write your revert scripts carefully! + +=head3 C + + $engine->revert; + $engine->revert($tag); + $engine->revert($tag); + +Reverts the L from the database, including all of its +associated changes. + +=head3 C + + $engine->verify; + $engine->verify( $from ); + $engine->verify( $from, $to ); + $engine->verify( undef, $to ); + +Verifies the database against the plan. Pass in change identifiers, as +described in L, to limit the changes to verify. For each +change, information will be emitted if: + +=over + +=item * + +It does not appear in the plan. + +=item * + +It has not been deployed to the database. + +=item * + +It has been deployed out-of-order relative to the plan. + +=item * + +Its verify script fails. + +=back + +Changes without verify scripts will emit a warning, but not constitute a +failure. If there are any failures, an exception will be thrown once all +verifications have completed. + +=head3 C + + $engine->check_deploy_dependencies; + $engine->check_deploy_dependencies($to_index); + +Validates that all dependencies will be met for all changes to be deployed, +starting with the currently-deployed change up to the specified index, or to +the last change in the plan if no index is passed. If any of the changes to be +deployed would conflict with previously-deployed changes or are missing any +required changes, an exception will be thrown. Used internally by C +to ensure that dependencies will be satisfied before deploying any changes. + +=head3 C + + $engine->check_revert_dependencies(@changes); + +Validates that the list of changes to be reverted, which should be passed in +the order in which they will be reverted, are not depended upon by other +changes. If any are depended upon by other changes, an exception will be +thrown listing the changes that cannot be reverted and what changes depend on +them. Used internally by C to ensure no dependencies will be +violated before revering any changes. + +=head3 C + + $engine->deploy_change($change); + $engine->deploy_change($change); + +Used internally by C to deploy an individual change. + +=head3 C + + $engine->revert_change($change); + $engine->revert_change($change); + +Used internally by C (and, by C when a deploy fails) to +revert an individual change. + +=head3 C + + $engine->verify_change($change); + +Used internally by C to verify a just-deployed change if +C is true. + +=head3 C + + say "Tag deployed" if $engine->is_deployed($tag); + say "Change deployed" if $engine->is_deployed($change); + +Convenience method that dispatches to C or +C as appropriate to its argument. + +=head3 C + + my $change = $engine->earliest_change; + my $change = $engine->earliest_change($offset); + +Returns the L object representing the earliest +applied change. With the optional C<$offset> argument, the returned change +will be the offset number of changes following the earliest change. + + +=head3 C + + my $change = $engine->latest_change; + my $change = $engine->latest_change($offset); + +Returns the L object representing the latest +applied change. With the optional C<$offset> argument, the returned change +will be the offset number of changes before the latest change. + +=head3 C + + my $change = if $engine->change_for_key($key); + +Searches the deployed changes for a change corresponding to the specified key, +which should be in a format as described in L. Throws an +exception if the key matches more than one changes. Returns C if it +matches no changes. + +=head3 C + + my $change_id = if $engine->change_id_for_key($key); + +Searches the deployed changes for a change corresponding to the specified key, +which should be in a format as described in L, and returns the +change's ID. Throws an exception if the key matches more than one change. +Returns C if it matches no changes. + +=head3 C + + my $change = if $engine->change_for_key($key); + +Searches the list of deployed changes for a change corresponding to the +specified key, which should be in a format as described in L. +Throws an exception if the key matches multiple changes. + +=head3 C + + say 'Dependency satisfied' if $engine->change_id_for_depend($depend); + +Returns the change ID for a L, if the +dependency resolves to a change currently deployed to the database. Returns +C if the dependency resolves to no currently-deployed change. + +=head3 C + + my $change = $engine->find_change(%params); + +Finds and returns a deployed change, or C if the change has not been +deployed. The supported parameters are: + +=over + +=item C + +The change ID. + +=item C + +A change name. + +=item C + +A tag name. + +=item C + +A project name. Defaults to the current project. + +=item C + +The number of changes offset from the change found by the other parameters +should actually be returned. May be positive or negative. + +=back + +The order of precedence for the search is: + +=over + +=item 1. + +Search by change ID, if passed. + +=item 2. + +Search by change name as of tag, if both are passed. + +=item 3. + +Search by change name or tag. + +=back + +The offset, if passed, will be applied relative to whatever change is found by +the above algorithm. + +=head3 C + + my $change_id = $engine->find_change_id(%params); + +Like C, taking the same parameters, but returning an ID instead +of a change. + +=head3 C + + $engine->run_deploy($deploy_file); + +Runs a deploy script. The implementation is just an alias for C; +subclasses may override as appropriate. + +=head3 C + + $engine->run_revert($revert_file); + +Runs a revert script. The implementation is just an alias for C; +subclasses may override as appropriate. + +=head3 C + + $engine->run_verify($verify_file); + +Runs a verify script. The implementation is just an alias for C; +subclasses may override as appropriate. + +=head3 C + + $engine->run_upgrade($upgrade_file); + +Runs an upgrade script. The implementation is just an alias for C; +subclasses may override as appropriate. + +=head3 C + + if ($engine->needs_upgrade) { + $engine->upgrade_registry; + } + +Determines if the target's registry needs upgrading and returns true if it +does. + +=head3 C + + $engine->upgrade_registry; + +Upgrades the target's registry, if it needs upgrading. Used by the +L|App::Sqitch::Command::upgrade> command. + +=head2 Abstract Instance Methods + +These methods must be overridden in subclasses. + +=head3 C + + $engine->begin_work($change); + +This method is called just before a change is deployed or reverted. It should +create a lock to prevent any other processes from making changes to the +database, to be freed in C or C. + +=head3 C + + $engine->finish_work($change); + +This method is called after a change has been deployed or reverted. It should +unlock the lock created by C. + +=head3 C + + $engine->rollback_work($change); + +This method is called after a change has been deployed or reverted and the +logging of that change has failed. It should rollback changes started by +C. + +=head3 C + + $engine->initialize unless $engine->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C + + $engine->initialize; + +Initializes the target database for Sqitch by installing the Sqitch registry +schema and/or tables. Should be overridden by subclasses. This implementation +throws an exception + +=head3 C + + $engine->register_project; + +Registers the current project plan in the registry database. The +implementation should insert the project name and URI if they have not already +been inserted. If a project already exists with the same name but different +URI, or a different name and the same URI, an exception should be thrown. + +=head3 C + + say 'Tag deployed' if $engine->is_deployed_tag($tag); + +Should return true if the L has been applied to +the database, and false if it has not. + +=head3 C + + say 'Change deployed' if $engine->is_deployed_change($change); + +Should return true if the L has been +deployed to the database, and false if it has not. + +=head3 C + + say "Change $_ is deployed" for $engine->are_deployed_change(@changes); + +Should return the IDs of any of the changes passed in that are currently +deployed. Used by C to ensure that no changes already deployed are +re-deployed. + +=head3 C + + say $engine->change_id_for( + change => $change_name, + tag => $tag_name, + project => $project, + ); + +Searches the database for the change with the specified name, tag, project, +or ID. Returns C if it matches no changes. If it matches more than one +change, it returns the earliest deployed change if the C parameter is +passed; otherwise it throws an exception The parameters are as follows: + +=over + +=item C + +The name of a change. Required unless C or C is passed. + +=item C + +The ID of a change. Required unless C or C is passed. Useful +to determine whether an ID in a plan has been deployed to the database. + +=item C + +The name of a tag. Required unless C is passed. + +=item C + +The name of the project to search. Defaults to the current project. + +=item C + +Return the earliest deployed change ID if the search matches more than one +change. If false or not passed and more than one change is found, an +exception will be thrown. + +=back + +If both C and C are passed, C will search for the +last instance of the named change deployed I the tag. + +=head3 C + + my @requiring = $engine->changes_requiring_change($change); + +Returns a list of hash references representing currently deployed changes that +require the passed change. When this method returns one or more hash +references, the change should not be reverted. Each hash reference should +contain the following keys: + +=over + +=item C + +The requiring change ID. + +=item C + +The requiring change name. + +=item C + +The project the requiring change is from. + +=item C + +Name of the first tag to be applied after the requiring change was deployed, +if any. + +=back + +=head3 C + + $engine->log_deploy_change($change); + +Should write the records to the registry necessary to indicate that the change +has been deployed. + +=head3 C + + $engine->log_fail_change($change); + +Should write to the database event history a record reflecting that deployment +of the change failed. + +=head3 C + + $engine->log_revert_change($change); + +Should write to and/or remove from the registry the records necessary to +indicate that the change has been reverted. + +=head3 C + + $engine->log_new_tags($change); + +Given a change, if it has any tags that are not currently logged in the +database, they should be logged. This is assuming, of course, that the change +itself has previously been logged. + +=head3 C + + my $change_id = $engine->earliest_change_id($offset); + +Returns the ID of the earliest applied change from the current project. With +the optional C<$offset> argument, the ID of the change the offset number of +changes following the earliest change will be returned. + +=head3 C + + my $change_id = $engine->latest_change_id; + my $change_id = $engine->latest_change_id($offset); + +Returns the ID of the latest applied change from the current project. +With the optional C<$offset> argument, the ID of the change the offset +number of changes before the latest change will be returned. + +=head3 C + + my @change_hashes = $engine->deployed_changes; + +Returns a list of hash references, each representing a change from the current +project in the order in which they were deployed. The keys in each hash +reference must be: + +=over + +=item C + +The change ID. + +=item C + +The change name. + +=item C + +The name of the project with which the change is associated. + +=item C + +The note attached to the change. + +=item C + +The name of the user who planned the change. + +=item C + +The email address of the user who planned the change. + +=item C + +An L object representing the time the change was planned. + +=item C + +An array reference of the tag names associated with the change. + +=back + +=head3 C + + my @change_hashes = $engine->deployed_changes_since($change); + +Returns a list of hash references, each representing a change from the current +project deployed after the specified change. The keys in the hash references +should be the same as for those returned by C. + +=head3 C + + my $change_name = $engine->name_for_change_id($change_id); + +Returns the tag-qualified name of the change identified by the ID. If a tag +was applied to a change after that change, the name will be returned with the +tag qualification, e.g., C. Otherwise, it will include the +symbolic tag C<@HEAD>. e.g., C. This value should be suitable +for uniquely identifying the change, and passing to the C or C +methods of L. + +=head3 C + + my @projects = $engine->registered_projects; + +Returns a list of the names of Sqitch projects registered in the database. + +=head3 C + + my $state = $engine->current_state; + my $state = $engine->current_state($project); + +Returns a hash reference representing the current project deployment state of +the database, or C if the database has no changes deployed. If a +project name is passed, the state will be returned for that project. Otherwise, +the state will be returned for the local project. + +The hash contains information about the last successfully deployed change, as +well as any associated tags. The keys to the hash should include: + +=over + +=item C + +The name of the project for which the state is reported. + +=item C + +The current change ID. + +=item C + +The deploy script SHA-1 hash. + +=item C + +The current change name. + +=item C + +A brief description of the change. + +=item C + +An array reference of the names of associated tags. + +=item C + +An L object representing the date and time at which the +change was deployed. + +=item C + +Name of the user who deployed the change. + +=item C + +Email address of the user who deployed the change. + +=item C + +An L object representing the date and time at which the +change was added to the plan. + +=item C + +Name of the user who added the change to the plan. + +=item C + +Email address of the user who added the change to the plan. + +=back + +=head3 C + + my $iter = $engine->current_changes; + my $iter = $engine->current_changes($project); + while (my $change = $iter->()) { + say '* ', $change->{change}; + } + +Returns a code reference that iterates over a list of the currently deployed +changes in reverse chronological order. If a project name is not passed, the +current project will be assumed. Each change is represented by a hash +reference containing the following keys: + +=over + +=item C + +The current change ID. + +=item C + +The deploy script SHA-1 hash. + +=item C + +The current change name. + +=item C + +An L object representing the date and time at which the +change was deployed. + +=item C + +Name of the user who deployed the change. + +=item C + +Email address of the user who deployed the change. + +=item C + +An L object representing the date and time at which the +change was added to the plan. + +=item C + +Name of the user who added the change to the plan. + +=item C + +Email address of the user who added the change to the plan. + +=back + +=head3 C + + my $iter = $engine->current_tags; + my $iter = $engine->current_tags($project); + while (my $tag = $iter->()) { + say '* ', $tag->{tag}; + } + +Returns a code reference that iterates over a list of the currently deployed +tags in reverse chronological order. If a project name is not passed, the +current project will be assumed. Each tag is represented by a hash reference +containing the following keys: + +=over + +=item C + +The tag ID. + +=item C + +The name of the tag. + +=item C + +An L object representing the date and time at which the +tag was applied. + +=item C + +Name of the user who applied the tag. + +=item C + +Email address of the user who applied the tag. + +=item C + +An L object representing the date and time at which the +tag was added to the plan. + +=item C + +Name of the user who added the tag to the plan. + +=item C + +Email address of the user who added the tag to the plan. + +=back + +=head3 C + + my $iter = $engine->search_events( %params ); + while (my $change = $iter->()) { + say '* $change->{event}ed $change->{change}"; + } + +Searches the deployment event log and returns an iterator code reference with +the results. If no parameters are provided, a list of all events will be +returned from the iterator reverse chronological order. The supported parameters +are: + +=over + +=item C + +An array of the type of event to search for. Allowed values are "deploy", +"revert", and "fail". + +=item C + +Limit the events to those with project names matching the specified regular +expression. + +=item C + +Limit the events to those with changes matching the specified regular +expression. + +=item C + +Limit the events to those logged for the actions of the committers with names +matching the specified regular expression. + +=item C + +Limit the events to those with changes who's planner's name matches the +specified regular expression. + +=item C + +Limit the number of events to the specified number. + +=item C + +Skip the specified number of events. + +=item C + +Return the results in the specified order, which must be a value matching +C for "ascending" or "descending". + +=back + +Each event is represented by a hash reference containing the following keys: + +=over + +=item C + +The type of event, which is one of: + +=over + +=item C + +=item C + +=item C + +=back + +=item C + +The name of the project with which the change is associated. + +=item C + +The change ID. + +=item C + +The name of the change. + +=item C + +A brief description of the change. + +=item C + +An array reference of the names of associated tags. + +=item C + +An array reference of the names of any changes required by the change. + +=item C + +An array reference of the names of any changes that conflict with the change. + +=item C + +An L object representing the date and time at which the +event was logged. + +=item C + +Name of the user who deployed the change. + +=item C + +Email address of the user who deployed the change. + +=item C + +An L object representing the date and time at which the +change was added to the plan. + +=item C + +Name of the user who added the change to the plan. + +=item C + +Email address of the user who added the change to the plan. + +=back + +=head3 C + + $engine->run_file($file); + +Should execute the commands in the specified file. This will generally be an +SQL file to run through the engine's native client. + +=head3 C + + $engine->run_handle($file_handle); + +Should execute the commands in the specified file handle. The file handle's +contents should be piped to the engine's native client. + +=head3 C + + my $change = $engine->load_change($change_id); + +Given a deployed change ID, loads an returns a hash reference representing the +change in the database. The keys should be the same as those in the hash +references returned by C. Returns C if the change +has not been deployed. + +=head3 C + + my $change = $engine->change_offset_from_id( $change_id, $offset ); + +Given a change ID and an offset, returns a hash reference of the data for a +deployed change (with the same keys as defined for C) in +the current project that was deployed C<$offset> steps before the change +identified by C<$change_id>. If C<$offset> is C<0> or C, the change +represented by C<$change_id> should be returned (just like C). +Otherwise, the change returned should be C<$offset> steps from that change ID, +where C<$offset> may be positive (later step) or negative (earlier step). +Returns C if the change was not found or if the offset is more than the +number of changes before or after the change, as appropriate. + +=head3 C + + my $id = $engine->change_id_offset_from_id( $change_id, $offset ); + +Like C but returns the change ID rather than the +change object. + +=head3 C + +Should return the current version of the target's registry. + +=head1 See Also + +=over + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Engine/Upgrade/exasol-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/exasol-1.0.sql new file mode 100644 index 00000000..f03a0f0f --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/exasol-1.0.sql @@ -0,0 +1,21 @@ +CREATE TABLE ®istry..releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL, + installer_name VARCHAR2(512) NOT NULL, + installer_email VARCHAR2(512) NOT NULL +); + +COMMENT ON TABLE ®istry..releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN ®istry..releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN ®istry..releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN ®istry..releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN ®istry..releases.installer_email IS 'Email address of the user who installed the registry release.'; + +-- Add the script_hash column to the changes table. Copy change_id for now. +ALTER TABLE ®istry..changes ADD script_hash CHAR(40) NULL; +UPDATE ®istry..changes SET script_hash = change_id; +COMMENT ON COLUMN ®istry..changes.script_hash IS 'Deploy script SHA-1 hash.'; + +COMMENT ON SCHEMA ®istry IS 'Sqitch database deployment metadata v1.0.'; + +COMMIT; diff --git a/lib/App/Sqitch/Engine/Upgrade/exasol-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/exasol-1.1.sql new file mode 100644 index 00000000..5a0bba0a --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/exasol-1.1.sql @@ -0,0 +1,3 @@ +-- Nothing to do here.. Exasol doesn't support UNIQUE constraints, except in +-- the form of PRIMARY KEY. +COMMENT ON SCHEMA ®istry IS 'Sqitch database deployment metadata v1.1.'; diff --git a/lib/App/Sqitch/Engine/Upgrade/firebird-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/firebird-1.0.sql new file mode 100644 index 00000000..d666c416 --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/firebird-1.0.sql @@ -0,0 +1,45 @@ +SET AUTOddl OFF; + +CREATE TABLE releases ( + version FLOAT NOT NULL PRIMARY KEY, + installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + installer_name VARCHAR(255) NOT NULL, + installer_email VARCHAR(255) NOT NULL +); + +COMMENT ON TABLE releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN releases.installer_email IS 'Email address of the user who installed the registry release.'; + +-- Add the script_hash column to the changes table. +ALTER TABLE changes ADD script_hash VARCHAR(40) UNIQUE; +COMMIT; +UPDATE changes SET script_hash = change_id; +COMMENT ON COLUMN changes.script_hash IS 'Deploy script SHA-1 hash.'; + +-- Allow "merge" events. +SET TERM ^; +EXECUTE BLOCK AS + DECLARE trig VARCHAR(64); +BEGIN + SELECT TRIM(cc.rdb$constraint_name) + FROM rdb$relation_constraints rc + JOIN rdb$check_constraints cc ON rc.rdb$constraint_name = cc.rdb$constraint_name + JOIN rdb$triggers trg ON cc.rdb$trigger_name = trg.rdb$trigger_name + WHERE rc.rdb$relation_name = 'EVENTS' + AND rc.rdb$constraint_type = 'CHECK' + AND trg.rdb$trigger_type = 1 + INTO trig; + EXECUTE STATEMENT 'ALTER TABLE EVENTS DROP CONSTRAINT ' || trig; +END^ + +SET TERM ;^ +COMMIT; + +ALTER TABLE events ADD CONSTRAINT check_event_type CHECK ( + event IN ('deploy', 'revert', 'fail', 'merge') +); + +COMMIT; diff --git a/lib/App/Sqitch/Engine/Upgrade/firebird-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/firebird-1.1.sql new file mode 100644 index 00000000..246a03f8 --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/firebird-1.1.sql @@ -0,0 +1,49 @@ +SET AUTOddl OFF; + +SET TERM ^; +EXECUTE BLOCK AS + DECLARE uniq VARCHAR(64); +BEGIN + SELECT TRIM(rdb$constraint_name) + FROM rdb$relation_constraints + WHERE rdb$relation_name = 'CHANGES' + AND rdb$constraint_type = 'UNIQUE' + INTO uniq; + EXECUTE STATEMENT 'ALTER TABLE CHANGES DROP CONSTRAINT ' || uniq; +END^ + +EXECUTE BLOCK AS + DECLARE trig VARCHAR(64); +BEGIN + SELECT TRIM(cc.rdb$constraint_name) + FROM rdb$relation_constraints rc + JOIN rdb$check_constraints cc ON rc.rdb$constraint_name = cc.rdb$constraint_name + JOIN rdb$triggers trg ON cc.rdb$trigger_name = trg.rdb$trigger_name + WHERE rc.rdb$relation_name = 'DEPENDENCIES' + AND rc.rdb$constraint_type = 'CHECK' + AND trg.rdb$trigger_type = 1 + INTO trig; + EXECUTE STATEMENT 'ALTER TABLE DEPENDENCIES DROP CONSTRAINT ' || trig; +END^ + +SET TERM ;^ +COMMIT; + +-- Drop check_event_type; we give it a new name below. +ALTER TABLE events DROP CONSTRAINT check_event_type; +COMMIT; + +-- Create the new unique constraint. +ALTER TABLE changes ADD UNIQUE (project, script_hash); + +-- Give the check constraints name consistent with other engines. +ALTER TABLE dependencies ADD CONSTRAINT dependencies_check CHECK ( + (type = 'require' AND dependency_id IS NOT NULL) + OR (type = 'conflict' AND dependency_id IS NULL) +); + +ALTER TABLE events ADD CONSTRAINT events_event_check CHECK ( + event IN ('deploy', 'revert', 'fail', 'merge') +); + +COMMIT; diff --git a/lib/App/Sqitch/Engine/Upgrade/mysql-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/mysql-1.0.sql new file mode 100644 index 00000000..ac88c549 --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/mysql-1.0.sql @@ -0,0 +1,20 @@ +CREATE TABLE releases ( + version FLOAT PRIMARY KEY + COMMENT 'Version of the Sqitch registry.', + installed_at TIMESTAMP NOT NULL + COMMENT 'Date the registry release was installed.', + installer_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who installed the registry release.', + installer_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who installed the registry release.' +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Sqitch registry releases.' +; + +-- Add the script_hash column to the changes table. Copy change_id for now. +ALTER TABLE changes ADD COLUMN script_hash VARCHAR(40) NULL UNIQUE AFTER change_id; +UPDATE changes SET script_hash = change_id; + +-- Allow "merge" events. +ALTER TABLE events CHANGE event event ENUM ('deploy', 'fail', 'merge', 'revert') NOT NULL; diff --git a/lib/App/Sqitch/Engine/Upgrade/mysql-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/mysql-1.1.sql new file mode 100644 index 00000000..6b3db35f --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/mysql-1.1.sql @@ -0,0 +1,2 @@ +DROP INDEX script_hash ON changes; +ALTER TABLE changes ADD UNIQUE(project, script_hash); diff --git a/lib/App/Sqitch/Engine/Upgrade/oracle-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/oracle-1.0.sql new file mode 100644 index 00000000..d019d904 --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/oracle-1.0.sql @@ -0,0 +1,44 @@ +CREATE TABLE ®istry..releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL, + installer_name VARCHAR2(512 CHAR) NOT NULL, + installer_email VARCHAR2(512 CHAR) NOT NULL +); + +COMMENT ON TABLE ®istry..releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN ®istry..releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN ®istry..releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN ®istry..releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN ®istry..releases.installer_email IS 'Email address of the user who installed the registry release.'; + +-- Add the script_hash column to the changes table. Copy change_id for now. +ALTER TABLE ®istry..changes ADD script_hash CHAR(40) NULL UNIQUE; +UPDATE ®istry..changes SET script_hash = change_id; +COMMENT ON COLUMN ®istry..changes.script_hash IS 'Deploy script SHA-1 hash.'; + +DECLARE + CURSOR c_event_constraints IS + SELECT constraint_name + FROM user_cons_columns + WHERE table_name = 'EVENTS' AND column_name = 'EVENT'; + rec_consname c_event_constraints%ROWTYPE; +BEGIN + OPEN c_event_constraints; + LOOP + FETCH c_event_constraints INTO rec_consname; + IF c_event_constraints%NOTFOUND THEN EXIT; END IF; + + -- Drop the constraint. + EXECUTE IMMEDIATE 'ALTER TABLE ®istry..events DROP CONSTRAINT ' + || rec_consname.constraint_name; + END LOOP; + CLOSE c_event_constraints; + + -- Use EXECUTE IMMEDIATE because ALTER isn't allowed in PL/SQL. + EXECUTE IMMEDIATE 'ALTER TABLE ®istry..events MODIFY event NOT NULL'; +END; +/ + +ALTER TABLE ®istry..events ADD CONSTRAINT check_event_type CHECK ( + event IN ('deploy', 'revert', 'fail', 'merge') +); diff --git a/lib/App/Sqitch/Engine/Upgrade/oracle-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/oracle-1.1.sql new file mode 100644 index 00000000..70d84f0b --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/oracle-1.1.sql @@ -0,0 +1,31 @@ +COLUMN cname for a30 new_value check_name; + +SELECT u.constraint_name AS cname + FROM user_constraints u + JOIN user_cons_columns c ON u.constraint_name = c.constraint_name + WHERE u.table_name = 'CHANGES' + AND u.constraint_type = 'U' + AND c.column_name = 'SCRIPT_HASH'; + +ALTER TABLE ®istry..changes DROP CONSTRAINT &check_name; +ALTER TABLE ®istry..changes ADD CONSTRAINT &check_name UNIQUE (project, script_hash); + +-- Rename the changes check constraint. +ALTER TABLE ®istry..events RENAME CONSTRAINT check_event_type TO events_event_check; + +-- Rename the dependencies check constraint. +SELECT constraint_name AS cname + FROM user_cons_columns + WHERE table_name = 'DEPENDENCIES' + AND column_name = 'DEPENDENCY_ID' + AND constraint_name IN ( + SELECT constraint_name + FROM user_cons_columns + WHERE table_name = 'DEPENDENCIES' + AND column_name = 'TYPE' + ); + +ALTER TABLE ®istry..dependencies + RENAME CONSTRAINT &check_name TO dependencies_check; + + diff --git a/lib/App/Sqitch/Engine/Upgrade/pg-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/pg-1.0.sql new file mode 100644 index 00000000..fda82216 --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/pg-1.0.sql @@ -0,0 +1,30 @@ +BEGIN; + +SET client_min_messages = warning; + +CREATE TABLE :"registry".releases ( + version REAL PRIMARY KEY, + installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + installer_name TEXT NOT NULL, + installer_email TEXT NOT NULL +); + +COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN :"registry".releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN :"registry".releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN :"registry".releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN :"registry".releases.installer_email IS 'Email address of the user who installed the registry release.'; + +-- Add the script_hash column to the changes table. Copy change_id for now. +ALTER TABLE :"registry".changes ADD COLUMN script_hash TEXT NULL UNIQUE; +UPDATE :"registry".changes SET script_hash = change_id; +COMMENT ON COLUMN :"registry".changes.script_hash IS 'Deploy script SHA-1 hash.'; + +-- Allow "merge" events. +ALTER TABLE :"registry".events DROP CONSTRAINT events_event_check; +ALTER TABLE :"registry".events ADD CONSTRAINT events_event_check + CHECK (event IN ('deploy', 'revert', 'fail', 'merge')); + +COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.0.'; + +COMMIT; diff --git a/lib/App/Sqitch/Engine/Upgrade/pg-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/pg-1.1.sql new file mode 100644 index 00000000..45422c08 --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/pg-1.1.sql @@ -0,0 +1,7 @@ +BEGIN; + +SET client_min_messages = warning; +ALTER TABLE :"registry".changes DROP CONSTRAINT changes_script_hash_key; +ALTER TABLE :"registry".changes ADD UNIQUE (project, script_hash); +COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.1.'; +COMMIT; diff --git a/lib/App/Sqitch/Engine/Upgrade/snowflake-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/snowflake-1.0.sql new file mode 100644 index 00000000..24d1338e --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/snowflake-1.0.sql @@ -0,0 +1,22 @@ +CREATE TABLE ®istry.releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp, + installer_name TEXT NOT NULL, + installer_email TEXT NOT NULL +); + +COMMENT ON TABLE ®istry.releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN ®istry.releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN ®istry.releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN ®istry.releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN ®istry.releases.installer_email IS 'Email address of the user who installed the registry release.'; + +-- Add the script_hash column to the changes table. Copy change_id for now. +ALTER TABLE ®istry.changes ADD script_hash TEXT NULL; +ALTER WAREHOUSE &warehouse RESUME IF SUSPENDED; +USE WAREHOUSE &warehouse; +UPDATE ®istry.changes SET script_hash = change_id; +ALTER TABLE ®istry.changes ADD UNIQUE(script_hash); +COMMENT ON COLUMN ®istry.changes.script_hash IS 'Deploy script SHA-1 hash.'; + +COMMENT ON SCHEMA ®istry IS 'Sqitch database deployment metadata v1.0.'; diff --git a/lib/App/Sqitch/Engine/Upgrade/snowflake-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/snowflake-1.1.sql new file mode 100644 index 00000000..445b23f6 --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/snowflake-1.1.sql @@ -0,0 +1,3 @@ +ALTER TABLE ®istry.changes DROP UNIQUE(script_hash); +ALTER TABLE ®istry.changes ADD UNIQUE(project, script_hash); +COMMENT ON SCHEMA ®istry IS 'Sqitch database deployment metadata v1.0.'; diff --git a/lib/App/Sqitch/Engine/Upgrade/sqlite-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/sqlite-1.0.sql new file mode 100644 index 00000000..291a676c --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/sqlite-1.0.sql @@ -0,0 +1,62 @@ +BEGIN; + +CREATE TABLE releases ( + version FLOAT PRIMARY KEY, + installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + installer_name TEXT NOT NULL, + installer_email TEXT NOT NULL +); + +-- Create a new changes table with script_hash. +CREATE TABLE new_changes ( + change_id TEXT PRIMARY KEY, + script_hash TEXT NULL UNIQUE, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at DATETIME NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL +); + +-- Copy all the data to the new table and move it into place. +INSERT INTO new_changes +SELECT change_id, change_id, change, project, note, + committed_at, committer_name, committer_email, + planned_at, planner_name, planner_email + FROM changes; +PRAGMA foreign_keys = OFF; +DROP TABLE changes; +ALTER TABLE new_changes RENAME TO changes; +PRAGMA foreign_keys = ON; + +-- Create a new events table with support for "merge" events. +CREATE TABLE new_events ( + event TEXT NOT NULL CHECK (event IN ('deploy', 'revert', 'fail', 'merge')), + change_id TEXT NOT NULL, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + requires TEXT NOT NULL DEFAULT '', + conflicts TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '', + committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at DATETIME NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + PRIMARY KEY (change_id, committed_at) +); + +INSERT INTO new_events +SELECT * FROM events; +PRAGMA foreign_keys = OFF; +DROP TABLE events; +ALTER TABLE new_events RENAME TO events; +PRAGMA foreign_keys = ON; + +COMMIT; diff --git a/lib/App/Sqitch/Engine/Upgrade/sqlite-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/sqlite-1.1.sql new file mode 100644 index 00000000..8ff0beda --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/sqlite-1.1.sql @@ -0,0 +1,27 @@ +BEGIN; + +-- Create a new changes table with updated unique constraint. +CREATE TABLE new_changes ( + change_id TEXT PRIMARY KEY, + script_hash TEXT NULL, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at DATETIME NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + UNIQUE(project, script_hash) +); + +-- Copy all the data to the new table and move it into place. +INSERT INTO new_changes +SELECT * FROM changes; +PRAGMA foreign_keys = OFF; +DROP TABLE changes; +ALTER TABLE new_changes RENAME TO changes; +PRAGMA foreign_keys = ON; + +COMMIT; diff --git a/lib/App/Sqitch/Engine/Upgrade/vertica-1.0.sql b/lib/App/Sqitch/Engine/Upgrade/vertica-1.0.sql new file mode 100644 index 00000000..e636bc20 --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/vertica-1.0.sql @@ -0,0 +1,15 @@ +CREATE TABLE :"registry".releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + installer_name VARCHAR(1024) NOT NULL, + installer_email VARCHAR(1024) NOT NULL +); + +COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.'; + +-- Add the script_hash column to the changes table. Copy change_id for now. +ALTER TABLE :"registry".changes ADD COLUMN script_hash CHAR(40); +UPDATE :"registry".changes SET script_hash = change_id; +ALTER TABLE :"registry".changes ADD UNIQUE(script_hash); + +COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.0.'; diff --git a/lib/App/Sqitch/Engine/Upgrade/vertica-1.1.sql b/lib/App/Sqitch/Engine/Upgrade/vertica-1.1.sql new file mode 100644 index 00000000..4f05271d --- /dev/null +++ b/lib/App/Sqitch/Engine/Upgrade/vertica-1.1.sql @@ -0,0 +1,3 @@ +ALTER TABLE :"registry".changes DROP CONSTRAINT c_unique; +ALTER TABLE :"registry".changes ADD UNIQUE(project, script_hash); +COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.1.'; diff --git a/lib/App/Sqitch/Engine/exasol.pm b/lib/App/Sqitch/Engine/exasol.pm new file mode 100644 index 00000000..99518a17 --- /dev/null +++ b/lib/App/Sqitch/Engine/exasol.pm @@ -0,0 +1,587 @@ +package App::Sqitch::Engine::exasol; + +use 5.010; +use Moo; +use utf8; +use Path::Class; +use DBI; +use Try::Tiny; +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::Types qw(DBH Dir ArrayRef); +use App::Sqitch::Plan::Change; +use List::Util qw(first); +use namespace::autoclean; + +extends 'App::Sqitch::Engine'; + +our $VERSION = 'v1.0.0'; # VERSION + +sub _dt($) { + require App::Sqitch::DateTime; + return App::Sqitch::DateTime->new(split /:/ => shift); +} + +sub key { 'exasol' } +sub name { 'Exasol' } +sub driver { 'DBD::ODBC 1.59' } +sub default_client { 'exaplus' } + +BEGIN { + # Disable SQLPATH so that we don't read scripts from unexpected places. + $ENV{SQLPATH} = ''; +} + +sub destination { + my $self = shift; + + # Just use the target name if it doesn't look like a URI or if the URI + # includes the database name. + return $self->target->name if $self->target->name !~ /:/ + || $self->target->uri->dbname; + + # Use the URI sans password. + my $uri = $self->target->uri->clone; + $uri->password(undef) if $uri->password; + return $uri->as_string; +} + +# No username or password defaults. +sub _def_user { } +sub _def_pass { } + +has _exaplus => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->uri; + my @ret = ( $self->client ); + + for my $spec ( + [ u => $self->username ], + [ p => $self->password ], + [ c => $uri->host && $uri->_port ? $uri->host . ':' . $uri->_port : undef ], + [ profile => $uri->host ? undef : $uri->dbname ] + ) { + push @ret, "-$spec->[0]" => $spec->[1] if $spec->[1]; + } + + push @ret => ( + '-q', # Quiet mode + '-L', # Don't prompt if login fails, just exit + '-pipe', # Enable piping of scripts to 'exaplus' + '-x', # Stop in case of errors + '-autoCompletion' => 'OFF', + '-encoding' => 'UTF8', + '-autocommit' => 'OFF', + ); + return \@ret; + }, +); + +sub exaplus { @{ shift->_exaplus } } + +has tmpdir => ( + is => 'ro', + isa => Dir, + lazy => 1, + default => sub { + require File::Temp; + dir File::Temp::tempdir( CLEANUP => 1 ); + }, +); + +has dbh => ( + is => 'rw', + isa => DBH, + lazy => 1, + default => sub { + my $self = shift; + $self->use_driver; + + my $uri = $self->uri; + DBI->connect($uri->dbi_dsn, $self->username, $self->password, { + PrintError => 0, + RaiseError => 0, + AutoCommit => 1, + odbc_utf8_on => 1, + FetchHashKeyName => 'NAME_lc', + HandleError => sub { + my ($err, $dbh) = @_; + $@ = $err; + @_ = ($dbh->state || 'DEV' => $dbh->errstr); + goto &hurl; + }, + Callbacks => { + connected => sub { + my $dbh = shift; + $dbh->do("ALTER SESSION SET $_='YYYY-MM-DD HH24:MI:SS'") for qw( + nls_date_format + nls_timestamp_format + ); + $dbh->do("ALTER SESSION SET TIME_ZONE='UTC'"); + if (my $schema = $self->registry) { + try { + $dbh->do("OPEN SCHEMA $schema"); + # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html + $dbh->set_err(undef, undef) if $dbh->err; + }; + } + return; + }, + }, + }); + } +); + +# Need to wait until dbh is defined. +with 'App::Sqitch::Role::DBIEngine'; + +# Timestamp formats + +sub _char2ts { + my $dt = $_[1]; + $dt->set_time_zone('UTC'); + $dt->ymd('-') . ' ' . $dt->hms(':'); +} + +sub _ts2char_format { + return qq{'year:' || CAST(EXTRACT(YEAR FROM %s) AS SMALLINT) + || ':month:' || CAST(EXTRACT(MONTH FROM %1\$s) AS SMALLINT) + || ':day:' || CAST(EXTRACT(DAY FROM %1\$s) AS SMALLINT) + || ':hour:' || CAST(EXTRACT(HOUR FROM %1\$s) AS SMALLINT) + || ':minute:' || CAST(EXTRACT(MINUTE FROM %1\$s) AS SMALLINT) + || ':second:' || FLOOR(CAST(EXTRACT(SECOND FROM %1\$s) AS NUMERIC(9,4))) + || ':time_zone:UTC'}; +} + +sub _ts_default { 'current_timestamp' } + +sub _listagg_format { + return q{GROUP_CONCAT(%s SEPARATOR ' ')}; +} + +sub _regex_op { 'REGEXP_LIKE' } + +# LIMIT in Exasol doesn't behave properly with values > 18446744073709551611 +sub _limit_default { '18446744073709551611' } + +sub _simple_from { ' FROM dual' } + +sub _multi_values { + my ($self, $count, $expr) = @_; + return join "\nUNION ALL ", ("SELECT $expr FROM dual") x $count; +} + +sub initialized { + my $self = shift; + return $self->dbh->selectcol_arrayref(q{ + SELECT EXISTS( + SELECT TRUE FROM exa_all_tables + WHERE table_schema = ? AND table_name = ? + ) + }, undef, uc $self->registry, 'CHANGES')->[0]; +} + +# LIMIT / OFFSET in Exasol doesn't seem to play nice in the original query with +# JOIN and GROUP BY; wrap it in a subquery instead.. +sub change_offset_from_id { + my ( $self, $change_id, $offset ) = @_; + + # Just return the object if there is no offset. + return $self->load_change($change_id) unless $offset; + + # Are we offset forwards or backwards? + my ($dir, $op, $offset_expr) = $self->_offset_op($offset); + my $tscol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $tagcol = sprintf $self->_listagg_format, 't.tag'; + + my $change = $self->dbh->selectrow_hashref(qq{ + SELECT id, name, project, note, "timestamp", planner_name, planner_email, + tags + FROM ( + SELECT c.change_id AS id, c.change AS name, c.project, c.note, + $tscol AS "timestamp", c.planner_name, c.planner_email, + $tagcol AS tags, c.committed_at + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + AND c.committed_at $op ( + SELECT committed_at FROM changes WHERE change_id = ? + ) + GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at, + c.planner_name, c.planner_email, c.committed_at + ) changes + ORDER BY changes.committed_at $dir + LIMIT 1 $offset_expr + }, undef, $self->plan->project, $change_id) || return undef; + $change->{timestamp} = _dt $change->{timestamp}; + unless (ref $change->{tags}) { + $change->{tags} = $change->{tags} ? [ split / / => $change->{tags} ] : []; + } + return $change; +} + +sub changes_requiring_change { + my ( $self, $change ) = @_; + # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221 + # NOTE: Query from DBIEngine doesn't work in Exasol: + # Error: [00444] more than one column in select list of correlated subselect + # The CTE-based query below seems to be fine, however. + return @{ $self->dbh->selectall_arrayref(q{ + WITH tag AS ( + SELECT tag, committed_at, project, + ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk + FROM tags + ) + SELECT c.change_id, c.project, c.change, t.tag AS asof_tag + FROM dependencies d + JOIN changes c ON c.change_id = d.change_id + LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at + WHERE d.dependency_id = ? + AND (t.rnk IS NULL OR t.rnk = 1) + }, { Slice => {} }, $change->id) }; +} + +sub name_for_change_id { + my ( $self, $change_id ) = @_; + # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221 + # NOTE: Query from DBIEngine doesn't work in Exasol: + # Error: [0A000] Feature not supported: non-equality correlations in correlated subselect + # The CTE-based query below seems to be fine, however. + return $self->dbh->selectcol_arrayref(q{ + WITH tag AS ( + SELECT tag, committed_at, project, + ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk + FROM tags + ) + SELECT change || COALESCE(t.tag, '@HEAD') + FROM changes c + LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at + WHERE change_id = ? + AND (t.rnk IS NULL OR t.rnk = 1) + }, undef, $change_id)->[0]; +} + +sub _cid { + my ( $self, $ord, $offset, $project ) = @_; + + my $offset_expr = $offset ? " OFFSET $offset" : ''; + return try { + $self->dbh->selectcol_arrayref(qq{ + SELECT change_id + FROM changes + WHERE project = ? + ORDER BY committed_at $ord + LIMIT 1$offset_expr + }, undef, $project || $self->plan->project)->[0]; + } catch { + return if $self->_no_table_error && !$self->initialized; + die $_; + }; +} + +sub is_deployed_tag { + my ( $self, $tag ) = @_; + return $self->dbh->selectcol_arrayref( + 'SELECT 1 FROM tags WHERE tag_id = ?', + undef, $tag->id + )->[0]; +} + +sub are_deployed_changes { + my $self = shift; + my @qs; + my $i = @_; + while ($i > 250) { + push @qs => 'change_id IN (' . join(', ' => ('?') x 250) . ')'; + $i -= 250; + } + push @qs => 'change_id IN (' . join(', ' => ('?') x @_) . ')'; + my $expr = join ' OR ', @qs; + @{ $self->dbh->selectcol_arrayref( + "SELECT change_id FROM changes WHERE $expr", + undef, + map { $_->id } @_, + ) }; +} + +sub _registry_variable { + my $self = shift; + my $schema = $self->registry; + return "DEFINE registry=$schema;"; +} + +sub initialize { + my $self = shift; + my $schema = $self->registry; + hurl engine => __ 'Sqitch already initialized' if $self->initialized; + + # Load up our database. + (my $file = file(__FILE__)->dir->file('exasol.sql')) =~ s/"/""/g; + $self->_run_with_verbosity($file); + $self->dbh->do("OPEN SCHEMA $schema") if $schema; + $self->_register_release; +} + +sub _limit_offset { + # LIMIT/OFFSET don't support parameters, alas. So just put them in the query. + my ($self, $lim, $off) = @_; + # OFFSET cannot be used without LIMIT, sadly. + return ['LIMIT ' . ($lim || $self->_limit_default), "OFFSET $off"], [] if $off; + return ["LIMIT $lim"], [] if $lim; + return [], []; +} + +sub _regex_expr { + my ( $self, $col, $regex ) = @_; + $regex = '.*' . $regex if $regex !~ m{^\^}; + $regex .= '.*' if $regex !~ m{\$$}; + my $op = $self->_regex_op; + return "$col $op ?", $regex; +} + +# Override to lock the changes table. This ensures that only one instance of +# Sqitch runs at one time. +sub begin_work { + my $self = shift; + my $dbh = $self->dbh; + + # Start transaction and lock changes to allow only one change at a time. + # https://www.exasol.com/portal/pages/viewpage.action?pageId=22518143 + $dbh->begin_work; + $dbh->do('DELETE FROM changes WHERE FALSE'); + return $self; +} + +# Release lock by comitting or rolling back. +sub finish_work { + my $self = shift; + $self->dbh->commit; + return $self; +} + +sub rollback_work { + my $self = shift; + $self->dbh->rollback; + return $self; +} + +sub _file_for_script { + my ($self, $file) = @_; + + # Just use the file if no special character. + if ($file !~ /[@?%\$]/) { + $file =~ s/"/""/g; + return $file; + } + + # Alias or copy the file to a temporary directory that's removed on exit. + (my $alias = $file->basename) =~ s/[@?%\$]/_/g; + $alias = $self->tmpdir->file($alias); + + # Remove existing file. + if (-e $alias) { + $alias->remove or hurl exasol => __x( + 'Cannot remove {file}: {error}', + file => $alias, + error => $! + ); + } + + if (App::Sqitch::ISWIN) { + # Copy it. + $file->copy_to($alias) or hurl exasol => __x( + 'Cannot copy {file} to {alias}: {error}', + file => $file, + alias => $alias, + error => $! + ); + } else { + # Symlink it. + $alias->remove; + symlink $file->absolute, $alias or hurl exasol => __x( + 'Cannot symlink {file} to {alias}: {error}', + file => $file, + alias => $alias, + error => $! + ); + } + + # Return the alias. + $alias =~ s/"/""/g; + return $alias; +} + +sub run_file { + my $self = shift; + my $file = $self->_file_for_script(shift); + $self->_capture(qq{\@"$file"}); +} + +sub _run_with_verbosity { + my $self = shift; + my $file = $self->_file_for_script(shift); + # Suppress STDOUT unless we want extra verbosity. + #my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture'); + my $meth = '_capture'; + $self->$meth(qq{\@"$file"}); +} + +sub run_upgrade { shift->_run_with_verbosity(@_) } +sub run_verify { shift->_run_with_verbosity(@_) } + +sub run_handle { + my ($self, $fh) = @_; + my $conn = $self->_script; + open my $tfh, '<:utf8_strict', \$conn; + $self->sqitch->spool( [$tfh, $fh], $self->exaplus ); +} + +# Exasol treats empty string as NULL; adjust accordingly.. + +sub _log_tags_param { + my $res = join ' ' => map { $_->format_name } $_[1]->tags; + return $res || ' '; +} + +sub _log_requires_param { + my $res = join ',' => map { $_->as_string } $_[1]->requires; + return $res || ' '; +} + +sub _log_conflicts_param { + my $res = join ',' => map { $_->as_string } $_[1]->conflicts; + return $res || ' '; +} + +sub _no_table_error { + return $DBI::errstr && $DBI::errstr =~ /object \w+ not found/m; +} + +sub _no_column_error { + return $DBI::errstr && $DBI::errstr =~ /object \w+ not found/m; +} + +sub _script { + my $self = shift; + my $uri = $self->uri; + my %vars = $self->variables; + + return join "\n" => ( + 'SET FEEDBACK OFF;', + 'SET HEADING OFF;', + 'WHENEVER OSERROR EXIT 9;', + 'WHENEVER SQLERROR EXIT 4;', + (map {; (my $v = $vars{$_}) =~ s/'/''/g; qq{DEFINE $_='$v';} } sort keys %vars), + $self->_registry_variable, + # Just 'map { s/;?$/;/r } ...' doesn't work in earlier Perl versions; + # see: https://www.perlmonks.org/index.pl?node_id=1048579 + map { (my $foo=$_) =~ s/;?$/;/; $foo } @_ + ); +} + +sub _run { + my $self = shift; + my $script = $self->_script(@_); + open my $fh, '<:utf8_strict', \$script; + return $self->sqitch->spool( $fh, $self->exaplus ); +} + +sub _capture { + my $self = shift; + my $conn = $self->_script(@_); + my @out; + my @errout; + + $self->sqitch->debug('CMD: ' . join(' ', $self->exaplus)); + $self->sqitch->debug("SQL:\n---\n", $conn, "\n---"); + + require IPC::Run3; + IPC::Run3::run3( + [$self->exaplus], \$conn, \@out, \@out, + { return_if_system_error => 1 }, + ); + + # EXAplus doesn't always seem to give a useful exit value; we need to match + # on output as well.. + if (my $err = $? || grep { /^Error:/m } @out) { + # Ugh, send everything to STDERR. + $self->sqitch->vent(@out); + hurl io => __x( + '{command} unexpectedly failed; exit value = {exitval}', + command => $self->client, + exitval => ($err >> 8), + ); + } + return wantarray ? @out : \@out; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Engine::exasol - Sqitch Exasol Engine + +=head1 Synopsis + + my $exasol = App::Sqitch::Engine->load( engine => 'exasol' ); + +=head1 Description + +App::Sqitch::Engine::exasol provides the Exasol storage engine for Sqitch. It +is tested with Exasol 6.0 and higher. + +=head1 Interface + +=head2 Instance Methods + +=head3 C + + $exasol->initialize unless $exasol->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C + + $exasol->initialize; + +Initializes a database for Sqitch by installing the Sqitch registry schema. + +=head3 C + +Returns a list containing the C client and options to be passed to it. +Used internally when executing scripts. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Engine/exasol.sql b/lib/App/Sqitch/Engine/exasol.sql new file mode 100644 index 00000000..3f16005e --- /dev/null +++ b/lib/App/Sqitch/Engine/exasol.sql @@ -0,0 +1,142 @@ +CREATE SCHEMA IF NOT EXISTS ®istry; + +COMMENT ON SCHEMA ®istry IS 'Sqitch database deployment metadata v1.1.'; + +CREATE TABLE ®istry..releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL, + installer_name VARCHAR2(512 CHAR) NOT NULL, + installer_email VARCHAR2(512 CHAR) NOT NULL +); + +COMMENT ON TABLE ®istry..releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN ®istry..releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN ®istry..releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN ®istry..releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN ®istry..releases.installer_email IS 'Email address of the user who installed the registry release.'; + +CREATE TABLE ®istry..projects ( + project VARCHAR2(512 CHAR) PRIMARY KEY, + uri VARCHAR2(512 CHAR) NULL, -- UNIQUE should also be used here, but not supported in EXASOL + created_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL, + creator_name VARCHAR2(512 CHAR) NOT NULL, + creator_email VARCHAR2(512 CHAR) NOT NULL +); + +COMMENT ON TABLE ®istry..projects IS 'Sqitch projects deployed to this database.'; +COMMENT ON COLUMN ®istry..projects.project IS 'Unique Name of a project.'; +COMMENT ON COLUMN ®istry..projects.uri IS 'Optional project URI'; +COMMENT ON COLUMN ®istry..projects.created_at IS 'Date the project was added to the database.'; +COMMENT ON COLUMN ®istry..projects.creator_name IS 'Name of the user who added the project.'; +COMMENT ON COLUMN ®istry..projects.creator_email IS 'Email address of the user who added the project.'; + +CREATE TABLE ®istry..changes ( + change_id CHAR(40) PRIMARY KEY, + script_hash CHAR(40) NULL, + change VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + note VARCHAR2(4000 CHAR) DEFAULT '', + committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL + -- UNIQUE(project, script_hash) +); + +COMMENT ON TABLE ®istry..changes IS 'Tracks the changes currently deployed to the database.'; +COMMENT ON COLUMN ®istry..changes.change_id IS 'Change primary key.'; +COMMENT ON COLUMN ®istry..changes.script_hash IS 'Deploy script SHA-1 hash.'; +COMMENT ON COLUMN ®istry..changes.change IS 'Name of a deployed change.'; +COMMENT ON COLUMN ®istry..changes.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry..changes.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry..changes.committed_at IS 'Date the change was deployed.'; +COMMENT ON COLUMN ®istry..changes.committer_name IS 'Name of the user who deployed the change.'; +COMMENT ON COLUMN ®istry..changes.committer_email IS 'Email address of the user who deployed the change.'; +COMMENT ON COLUMN ®istry..changes.planned_at IS 'Date the change was added to the plan.'; +COMMENT ON COLUMN ®istry..changes.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry..changes.planner_email IS 'Email address of the user who planned the change.'; + +CREATE TABLE ®istry..tags ( + tag_id CHAR(40) PRIMARY KEY, + tag VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + change_id CHAR(40) NOT NULL REFERENCES ®istry..changes(change_id), + note VARCHAR2(4000 CHAR) DEFAULT '', + committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL + -- UNIQUE(project, tag) +); + +COMMENT ON TABLE ®istry..tags IS 'Tracks the tags currently applied to the database.'; +COMMENT ON COLUMN ®istry..tags.tag_id IS 'Tag primary key.'; +COMMENT ON COLUMN ®istry..tags.tag IS 'Project-unique tag name.'; +COMMENT ON COLUMN ®istry..tags.project IS 'Name of the Sqitch project to which the tag belongs.'; +COMMENT ON COLUMN ®istry..tags.change_id IS 'ID of last change deployed before the tag was applied.'; +COMMENT ON COLUMN ®istry..tags.note IS 'Description of the tag.'; +COMMENT ON COLUMN ®istry..tags.committed_at IS 'Date the tag was applied to the database.'; +COMMENT ON COLUMN ®istry..tags.committer_name IS 'Name of the user who applied the tag.'; +COMMENT ON COLUMN ®istry..tags.committer_email IS 'Email address of the user who applied the tag.'; +COMMENT ON COLUMN ®istry..tags.planned_at IS 'Date the tag was added to the plan.'; +COMMENT ON COLUMN ®istry..tags.planner_name IS 'Name of the user who planed the tag.'; +COMMENT ON COLUMN ®istry..tags.planner_email IS 'Email address of the user who planned the tag.'; + +CREATE TABLE ®istry..dependencies ( + change_id CHAR(40) NOT NULL REFERENCES ®istry..changes(change_id), -- ON DELETE CASCADE, + type VARCHAR2(8) NOT NULL, + dependency VARCHAR2(1024 CHAR) NOT NULL, + dependency_id CHAR(40) NULL REFERENCES ®istry..changes(change_id), + -- CONSTRAINT dependencies_check CHECK ( + -- (type = 'require' AND dependency_id IS NOT NULL) + -- OR (type = 'conflict' AND dependency_id IS NULL) + -- ), + PRIMARY KEY (change_id, dependency) +); + +COMMENT ON TABLE ®istry..dependencies IS 'Tracks the currently satisfied dependencies.'; +COMMENT ON COLUMN ®istry..dependencies.change_id IS 'ID of the depending change.'; +COMMENT ON COLUMN ®istry..dependencies.type IS 'Type of dependency.'; +COMMENT ON COLUMN ®istry..dependencies.dependency IS 'Dependency name.'; +COMMENT ON COLUMN ®istry..dependencies.dependency_id IS 'Change ID the dependency resolves to.'; + +CREATE TABLE ®istry..events ( + event VARCHAR2(6) NOT NULL, + change_id CHAR(40) NOT NULL, + change VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + note VARCHAR2(4000 CHAR) DEFAULT '', + requires VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL, + conflicts VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL, + tags VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL, + committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL +); + +-- CREATE INDEX ®istry..events_pkey ON ®istry..events(change_id, committed_at); + +COMMENT ON TABLE ®istry..events IS 'Contains full history of all deployment events.'; +COMMENT ON COLUMN ®istry..events.event IS 'Type of event.'; +COMMENT ON COLUMN ®istry..events.change_id IS 'Change ID.'; +COMMENT ON COLUMN ®istry..events.change IS 'Change name.'; +COMMENT ON COLUMN ®istry..events.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry..events.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry..events.requires IS 'List of the names of required changes.'; +COMMENT ON COLUMN ®istry..events.conflicts IS 'List of the names of conflicting changes.'; +COMMENT ON COLUMN ®istry..events.tags IS 'Tags associated with the change.'; +COMMENT ON COLUMN ®istry..events.committed_at IS 'Date the event was committed.'; +COMMENT ON COLUMN ®istry..events.committer_name IS 'Name of the user who committed the event.'; +COMMENT ON COLUMN ®istry..events.committer_email IS 'Email address of the user who committed the event.'; +COMMENT ON COLUMN ®istry..events.planned_at IS 'Date the event was added to the plan.'; +COMMENT ON COLUMN ®istry..events.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry..events.planner_email IS 'Email address of the user who plan planned the change.'; + +COMMIT; diff --git a/lib/App/Sqitch/Engine/firebird.pm b/lib/App/Sqitch/Engine/firebird.pm new file mode 100644 index 00000000..79afabdb --- /dev/null +++ b/lib/App/Sqitch/Engine/firebird.pm @@ -0,0 +1,998 @@ +package App::Sqitch::Engine::firebird; + +use 5.010; +use strict; +use warnings; +use utf8; +use Try::Tiny; +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::Plan::Change; +use Path::Class; +use File::Basename; +use Time::Local; +use Time::HiRes qw(sleep); +use Moo; +use App::Sqitch::Types qw(DBH URIDB ArrayRef Maybe Int); +use namespace::autoclean; + +extends 'App::Sqitch::Engine'; + +our $VERSION = 'v1.0.0'; # VERSION + +has registry_uri => ( + is => 'ro', + isa => URIDB, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->uri->clone; + my $reg = $self->registry; + + if ( file($reg)->is_absolute ) { + # Just use an absolute path. + $uri->dbname($reg); + } elsif (my @segs = $uri->path_segments) { + # Use the same name, but replace $name.$ext with $reg.$ext. + my $reg = $self->registry; + if ($reg =~ /[.]/) { + $segs[-1] =~ s/^[^.]+(?:[.].+)?$/$reg/; + } else { + $segs[-1] =~ s{^[^.]+([.].+)?$}{$reg . ($1 // '')}e; + } + $uri->path_segments(@segs); + } else { + # No known path, so no name. + $uri->dbname(undef); + } + + return $uri; + }, +); + +sub registry_destination { + my $uri = shift->registry_uri; + if ($uri->password) { + $uri = $uri->clone; + $uri->password(undef); + } + return $uri->as_string; +} + +sub _def_user { $ENV{ISC_USER} } +sub _def_pass { $ENV{ISC_PASSWORD} } + +has dbh => ( + is => 'rw', + isa => DBH, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->registry_uri; + $self->use_driver; + + my $dsn = $uri->dbi_dsn . ';ib_dialect=3;ib_charset=UTF8'; + return DBI->connect($dsn, scalar $self->username, scalar $self->password, { + PrintError => 0, + RaiseError => 0, + AutoCommit => 1, + ib_enable_utf8 => 1, + FetchHashKeyName => 'NAME_lc', + HandleError => sub { + my ($err, $dbh) = @_; + $@ = $err; + @_ = ($dbh->state || 'DEV' => $dbh->errstr); + goto &hurl; + }, + }); + } +); + +# Need to wait until dbh is defined. +with 'App::Sqitch::Role::DBIEngine'; + +has _isql => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->uri; + my @ret = ( $self->client ); + for my $spec ( + [ user => $self->username ], + [ password => $self->password ], + ) { + push @ret, "-$spec->[0]" => $spec->[1] if $spec->[1]; + } + + push @ret => ( + '-quiet', + '-bail', + '-sqldialect' => '3', + '-pagelength' => '16384', + '-charset' => 'UTF8', + $self->connection_string($uri), + ); + + return \@ret; + }, +); + +sub isql { @{ shift->_isql } } + +has tz_offset => ( + is => 'ro', + isa => Maybe[Int], + lazy => 1, + default => sub { + # From: https://stackoverflow.com/questions/2143528/whats-the-best-way-to-get-the-utc-offset-in-perl + my @t = localtime(time); + my $gmt_offset_in_seconds = timegm(@t) - timelocal(@t); + my $offset = -($gmt_offset_in_seconds / 3600); + return $offset; + }, +); + +sub key { 'firebird' } +sub name { 'Firebird' } +sub driver { 'DBD::Firebird 1.11' } + +sub _char2ts { + my $dt = $_[1]; + $dt->set_time_zone('UTC'); + return join ' ', $dt->ymd('-'), $dt->hms(':'); +} + +sub _ts2char_format { + return qq{'year:' || CAST(EXTRACT(YEAR FROM %s) AS SMALLINT) + || ':month:' || CAST(EXTRACT(MONTH FROM %1\$s) AS SMALLINT) + || ':day:' || CAST(EXTRACT(DAY FROM %1\$s) AS SMALLINT) + || ':hour:' || CAST(EXTRACT(HOUR FROM %1\$s) AS SMALLINT) + || ':minute:' || CAST(EXTRACT(MINUTE FROM %1\$s) AS SMALLINT) + || ':second:' || FLOOR(CAST(EXTRACT(SECOND FROM %1\$s) AS NUMERIC(9,4))) + || ':time_zone:UTC'}; +} + +sub _ts_default { + my $offset = shift->tz_offset; + sleep 0.01; # give Firebird a little time to tick microseconds. + return qq(DATEADD($offset HOUR TO CURRENT_TIMESTAMP(3))); +} + +sub _version_query { + # Turns out, if you cast to varchar, the trailing 0s get removed. So value + # 1.1, represented as 1.10000002384186, returns as preferred value 1.1. + 'SELECT CAST(ROUND(MAX(version), 1) AS VARCHAR(24)) AS v FROM releases', +} + +sub is_deployed_change { + my ( $self, $change ) = @_; + return $self->dbh->selectcol_arrayref( + 'SELECT 1 FROM changes WHERE change_id = ?', + undef, $change->id + )->[0]; +} + +sub is_deployed_tag { + my ( $self, $tag ) = @_; + return $self->dbh->selectcol_arrayref(q{ + SELECT 1 + FROM tags + WHERE tag_id = ? + }, undef, $tag->id)->[0]; +} + +sub initialized { + my $self = shift; + + # Try to connect. + my $err = 0; + my $dbh = try { $self->dbh } catch { $err = $DBI::err; $self->sqitch->debug($_); }; + return 0 if $err; + + return $self->dbh->selectcol_arrayref(qq{ + SELECT COUNT(RDB\$RELATION_NAME) + FROM RDB\$RELATIONS + WHERE RDB\$SYSTEM_FLAG=0 + AND RDB\$VIEW_BLR IS NULL + AND RDB\$RELATION_NAME = ? + }, undef, 'CHANGES')->[0]; +} + +sub initialize { + my $self = shift; + my $uri = $self->registry_uri; + hurl engine => __x( + 'Sqitch database {database} already initialized', + database => $uri->dbname, + ) if $self->initialized; + + my $sqitch_db = $self->connection_string($uri); + + # Create the registry database if it does not exist. + $self->use_driver; + try { + DBD::Firebird->create_database({ + db_path => $sqitch_db, + user => scalar $self->username, + password => scalar $self->password, + character_set => 'UTF8', + page_size => 16384, + }); + } + catch { + hurl firebird => __x( + 'Cannot create database {database}: {error}', + database => $sqitch_db, + error => $_, + ); + }; + + # Load up our database. The database must exist! + $self->run_upgrade( file(__FILE__)->dir->file('firebird.sql') ); + $self->_register_release; +} + +sub connection_string { + my ($self, $uri) = @_; + my $file = $uri->dbname or hurl firebird => __x( + 'Database name missing in URI {uri}', + uri => $uri, + ); + my $host = $uri->host or return $file; + my $port = $uri->_port or return "$host:$file"; + return "$host/$port:$file"; +} + +# Override to lock the Sqitch tables. This ensures that only one instance of +# Sqitch runs at one time. +sub begin_work { + my $self = shift; + my $dbh = $self->dbh; + + # Start transaction and lock all tables to disallow concurrent changes. + # This should be equivalent to 'LOCK TABLE changes' ??? + # http://conferences.embarcadero.com/article/32280#TableReservation + $dbh->func( + -lock_resolution => 'no_wait', + -reserving => { + changes => { + lock => 'read', + access => 'protected', + }, + }, + 'ib_set_tx_param' + ); + $dbh->begin_work; + return $self; +} + +# Override to unlock the tables, otherwise future transactions on this +# connection can fail. +sub finish_work { + my $self = shift; + my $dbh = $self->dbh; + $dbh->commit; + $dbh->func( 'ib_set_tx_param' ); # reset parameters + return $self; +} + +sub _dt($) { + require App::Sqitch::DateTime; + return App::Sqitch::DateTime->new(split /:/ => shift); +} + +sub _no_table_error { + return $DBI::errstr && $DBI::errstr =~ /^-Table unknown|No such file or directory/m; +} + +sub _no_column_error { + return $DBI::errstr && $DBI::errstr =~ /^-Column unknown|/m; +} + +sub _regex_op { 'SIMILAR TO' } # NOT good match for + # REGEXP :( + +sub _limit_default { '18446744073709551615' } + +sub _listagg_format { + return q{LIST(ALL %s, ' ')}; # Firebird v2.1.4 minimum +} + +sub _run { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->run( $self->isql, @_ ); + local $ENV{ISC_PASSWORD} = $pass; + return $sqitch->run( $self->isql, @_ ); +} + +sub _capture { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->capture( $self->isql, @_ ); + local $ENV{ISC_PASSWORD} = $pass; + return $sqitch->capture( $self->isql, @_ ); +} + +sub _spool { + my $self = shift; + my $fh = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->spool( $fh, $self->isql, @_ ); + local $ENV{ISC_PASSWORD} = $pass; + return $sqitch->spool( $fh, $self->isql, @_ ); +} + +sub run_file { + my ($self, $file) = @_; + $self->_run( '-input' => $file ); +} + +sub run_verify { + my ($self, $file) = @_; + # Suppress STDOUT unless we want extra verbosity. + my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture'); + $self->$meth( '-input' => $file ); +} + +sub run_upgrade { + my ($self, $file) = @_; + my $uri = $self->registry_uri; + my @cmd = $self->isql; + $cmd[-1] = $self->connection_string($uri); + my $sqitch = $self->sqitch; + $sqitch->run( @cmd, '-input' => $sqitch->quote_shell($file) ); +} + +sub run_handle { + my ($self, $fh) = @_; + $self->_spool($fh); +} + +sub _cid { + my ( $self, $ord, $offset, $project ) = @_; + + my $offexpr = $offset ? " SKIP $offset" : ''; + return try { + return $self->dbh->selectcol_arrayref(qq{ + SELECT FIRST 1$offexpr change_id + FROM changes + WHERE project = ? + ORDER BY committed_at $ord; + }, undef, $project || $self->plan->project)->[0]; + } catch { + # Firebird generic error code -902, one possible message: + # -I/O error during "open" operation for file... + # -Error while trying to open file + # -No such file or directory + # print "===DBI ERROR: $DBI::err\n"; + return if $DBI::err == -902; # can't connect to database + die $_; + }; +} + +sub current_state { + my ( $self, $project ) = @_; + my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at'; + my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $tagcol = sprintf $self->_listagg_format, 't.tag'; + my $state = try { + $self->dbh->selectrow_hashref(qq{ + SELECT FIRST 1 c.change_id + , c.script_hash + , c.change + , c.project + , c.note + , c.committer_name + , c.committer_email + , $cdtcol AS committed_at + , c.planner_name + , c.planner_email + , $pdtcol AS planned_at + , $tagcol AS tags + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + GROUP BY c.change_id + , c.script_hash + , c.change + , c.project + , c.note + , c.committer_name + , c.committer_email + , c.committed_at + , c.planner_name + , c.planner_email + , c.planned_at + ORDER BY c.committed_at DESC + }, undef, $project // $self->plan->project ); + } catch { + return if $self->_no_table_error && !$self->initialized; + die $_; + } or return undef; + + unless (ref $state->{tags}) { + $state->{tags} = $state->{tags} ? [ split / / => $state->{tags} ] : []; + } + $state->{committed_at} = _dt $state->{committed_at}; + $state->{planned_at} = _dt $state->{planned_at}; + return $state; +} + +sub search_events { + my ( $self, %p ) = @_; + + # Determine order direction. + my $dir = 'DESC'; + if (my $d = delete $p{direction}) { + $dir = $d =~ /^ASC/i ? 'ASC' + : $d =~ /^DESC/i ? 'DESC' + : hurl 'Search direction must be either "ASC" or "DESC"'; + } + + # Limit with regular expressions? + my (@wheres, @params); + my $op = $self->_regex_op; + for my $spec ( + [ committer => 'e.committer_name' ], + [ planner => 'e.planner_name' ], + [ change => 'e.change' ], + [ project => 'e.project' ], + ) { + my $regex = delete $p{ $spec->[0] } // next; + # Trying to adapt REGEXP for SIMILAR TO from Firebird 2.5 :) + # Yes, I know is ugly... + # There is no support for ^ and $ as in normal REGEXP. + # + # From the docs: + # Description: SIMILAR TO matches a string against an SQL + # regular expression pattern. UNLIKE in some other languages, + # the pattern MUST MATCH THE ENTIRE STRING in order to succeed + # – matching a substring is not enough. If any operand is + # NULL, the result is NULL. Otherwise, the result is TRUE or + # FALSE. + # + # Maybe use the CONTAINING operator instead? + # print "===REGEX: $regex\n"; + if ( $regex =~ m{^\^} and $regex =~ m{\$$} ) { + $regex =~ s{\^}{}; + $regex =~ s{\$}{}; + $regex = "%$regex%"; + } + else { + if ( $regex !~ m{^\^} and $regex !~ m{\$$} ) { + $regex = "%$regex%"; + } + } + if ( $regex =~ m{\$$} ) { + $regex =~ s{\$}{}; + $regex = "%$regex"; + } + if ( $regex =~ m{^\^} ) { + $regex =~ s{\^}{}; + $regex = "$regex%"; + } + # print "== SIMILAR TO: $regex\n"; + push @wheres => "$spec->[1] $op ?"; + push @params => "$regex"; + } + + # Match events? + if (my $e = delete $p{event} ) { + my ($in, @vals) = $self->_in_expr( $e ); + push @wheres => "e.event $in"; + push @params => @vals; + } + + # Assemble the where clause. + my $where = @wheres + ? "\n WHERE " . join( "\n ", @wheres ) + : ''; + + # Handle remaining parameters. + my $limits = ''; + if (exists $p{limit} || exists $p{offset}) { + my $lim = delete $p{limit}; + if ($lim) { + $limits = " FIRST ? "; + push @params => $lim; + } + if (my $off = delete $p{offset}) { + $limits .= " SKIP ? "; + push @params => $off; + } + } + + hurl 'Invalid parameters passed to search_events(): ' + . join ', ', sort keys %p if %p; + + $self->dbh->{ib_softcommit} = 1; + + # Prepare, execute, and return. + my $cdtcol = sprintf $self->_ts2char_format, 'e.committed_at'; + my $pdtcol = sprintf $self->_ts2char_format, 'e.planned_at'; + my $sth = $self->dbh->prepare(qq{ + SELECT $limits e.event + , e.project + , e.change_id + , e.change + , e.note + , e.requires + , e.conflicts + , e.tags + , e.committer_name + , e.committer_email + , $cdtcol AS committed_at + , e.planner_name + , e.planner_email + , $pdtcol AS planned_at + FROM events e$where + ORDER BY e.committed_at $dir + }); + $sth->execute(@params); + return sub { + my $row = $sth->fetchrow_hashref or return; + $row->{committed_at} = _dt $row->{committed_at}; + $row->{planned_at} = _dt $row->{planned_at}; + return $row; + }; +} + +sub changes_requiring_change { + my ( $self, $change ) = @_; + return @{ $self->dbh->selectall_arrayref(q{ + SELECT c.change_id, c.project, c.change, ( + SELECT FIRST 1 tag + FROM changes c2 + JOIN tags ON c2.change_id = tags.change_id + WHERE c2.project = c.project + AND c2.committed_at >= c.committed_at + ORDER BY c2.committed_at + ) AS asof_tag + FROM dependencies d + JOIN changes c ON c.change_id = d.change_id + WHERE d.dependency_id = ? + }, { Slice => {} }, $change->id) }; +} + +sub name_for_change_id { + my ( $self, $change_id ) = @_; + return $self->dbh->selectcol_arrayref(q{ + SELECT c.change || COALESCE(( + SELECT FIRST 1 tag + FROM changes c2 + JOIN tags ON c2.change_id = tags.change_id + WHERE c2.committed_at >= c.committed_at + AND c2.project = c.project + ), '@HEAD') + FROM changes c + WHERE change_id = ? + }, undef, $change_id)->[0]; +} + +sub _offset_op { + my ( $self, $offset ) = @_; + my ( $dir, $op ) = $offset > 0 ? ( 'ASC', '>' ) : ( 'DESC' , '<' ); + return $dir, $op, 'SKIP ' . (abs($offset) - 1); +} + +sub change_id_offset_from_id { + my ( $self, $change_id, $offset ) = @_; + + # Just return the ID if there is no offset. + return $change_id unless $offset; + + my ($dir, $op, $offset_expr) = $self->_offset_op($offset); + return $self->dbh->selectcol_arrayref(qq{ + SELECT FIRST 1 $offset_expr change_id AS "id" + FROM changes + WHERE project = ? + AND committed_at $op ( + SELECT committed_at FROM changes WHERE change_id = ? + ) + ORDER BY committed_at $dir + }, undef, $self->plan->project, $change_id )->[0]; +} + +sub change_offset_from_id { + my ( $self, $change_id, $offset ) = @_; + + # Just return the object if there is no offset. + return $self->load_change($change_id) unless $offset; + + # Are we offset forwards or backwards? + my ($dir, $op, $offset_expr) = $self->_offset_op($offset); + my $tscol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $tagcol = sprintf $self->_listagg_format, 't.tag'; + + my $change = $self->dbh->selectrow_hashref(qq{ + SELECT FIRST 1 $offset_expr + c.change_id AS "id", c.change AS name, c.project, c.note, + $tscol AS "timestamp", c.planner_name, c.planner_email, + $tagcol AS tags + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + AND c.committed_at $op ( + SELECT committed_at FROM changes WHERE change_id = ? + ) + GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at, + c.planner_name, c.planner_email, c.committed_at + ORDER BY c.committed_at $dir + }, undef, $self->plan->project, $change_id ) || return undef; + $change->{timestamp} = _dt $change->{timestamp}; + unless ( ref $change->{tags} ) { + $change->{tags} = $change->{tags} ? [ split / / => $change->{tags} ] : []; + } + return $change; +} + +sub _cid_head { + my ($self, $project, $change) = @_; + return $self->dbh->selectcol_arrayref(q{ + SELECT FIRST 1 change_id + FROM changes + WHERE project = ? + AND changes.change = ? + ORDER BY committed_at DESC + }, undef, $project, $change)->[0]; +} + +sub change_id_for { + my ( $self, %p) = @_; + my $dbh = $self->dbh; + + if ( my $cid = $p{change_id} ) { + # Find by ID. + return $dbh->selectcol_arrayref(q{ + SELECT change_id + FROM changes + WHERE change_id = ? + }, undef, $cid)->[0]; + } + + my $project = $p{project} || $self->plan->project; + if ( my $change = $p{change} ) { + if ( my $tag = $p{tag} ) { + # There is nothing before the first tag. + return undef if $tag eq 'ROOT'; + + # Find closest to the end for @HEAD. + return $self->_cid_head($project, $change) if $tag eq 'HEAD'; + + # Find by change name and following tag. + return $dbh->selectcol_arrayref(q{ + SELECT FIRST 1 changes.change_id + FROM changes + JOIN tags + ON changes.committed_at <= tags.committed_at + AND changes.project = tags.project + WHERE changes.project = ? + AND changes.change = ? + AND tags.tag = ? + ORDER BY changes.committed_at DESC + }, undef, $project, $change, '@' . $tag)->[0]; + } + + # Find earliest by change name. + my $ids = $dbh->selectcol_arrayref(qq{ + SELECT change_id + FROM changes + WHERE project = ? + AND changes.change = ? + ORDER BY changes.committed_at ASC + }, undef, $project, $change); + + # Return the ID. + return $ids->[0] if $p{first}; + return $self->_handle_lookup_index($change, $ids); + } + + if ( my $tag = $p{tag} ) { + # Just return the latest for @HEAD. + return $self->_cid('DESC', 0, $project) if $tag eq 'HEAD'; + + # Just return the earliest for @ROOT. + return $self->_cid('ASC', 0, $project) if $tag eq 'ROOT'; + + # Find by tag name. + return $dbh->selectcol_arrayref(q{ + SELECT change_id + FROM tags + WHERE project = ? + AND tag = ? + }, undef, $project, '@' . $tag)->[0]; + } + + # We got nothin. + return undef; +} + +sub log_new_tags { + my ( $self, $change ) = @_; + my @tags = $change->tags or return $self; + my $sqitch = $self->sqitch; + + my ($id, $name, $proj, $user, $email) = ( + $change->id, + $change->format_name, + $change->project, + $sqitch->user_name, + $sqitch->user_email + ); + + my $ts = $self->_ts_default; + my $sf = $self->_simple_from; + + my $sql = q{ + INSERT INTO tags ( + tag_id + , tag + , project + , change_id + , note + , committer_name + , committer_email + , planned_at + , planner_name + , planner_email + , committed_at + ) + SELECT i.* FROM ( + } . join( + "\n UNION ALL ", + ("SELECT CAST(? AS CHAR(40)) AS tid + , CAST(? AS VARCHAR(250)) AS tname + , CAST(? AS VARCHAR(255)) AS proj + , CAST(? AS CHAR(40)) AS cid + , CAST(? AS VARCHAR(4000)) AS note + , CAST(? AS VARCHAR(512)) AS cuser + , CAST(? AS VARCHAR(512)) AS cemail + , CAST(? AS TIMESTAMP) AS tts + , CAST(? AS VARCHAR(512)) AS puser + , CAST(? AS VARCHAR(512)) AS pemail + , CAST($ts$sf AS TIMESTAMP) AS cts" + ) x @tags ) . q{ + FROM RDB$DATABASE ) i + LEFT JOIN tags ON i.tid = tags.tag_id + WHERE tags.tag_id IS NULL + }; + my @params = map { ( + $_->id, + $_->format_name, + $proj, + $id, + $_->note, + $user, + $email, + $self->_char2ts( $_->timestamp ), + $_->planner_name, + $_->planner_email, + ) } @tags; + $self->dbh->do($sql, undef, @params ); + return $self; +} + +sub log_deploy_change { + my ($self, $change) = @_; + my $dbh = $self->dbh; + my $sqitch = $self->sqitch; + + my ($id, $name, $proj, $user, $email) = ( + $change->id, + $change->format_name, + $change->project, + $sqitch->user_name, + $sqitch->user_email + ); + + my $ts = $self->_ts_default; + my $cols = join "\n , ", $self->_quote_idents(qw( + change_id + script_hash + change + project + note + committer_name + committer_email + planned_at + planner_name + planner_email + committed_at + )); + $dbh->do(qq{ + INSERT INTO changes ( + $cols + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, $ts) + }, undef, + $id, + $change->script_hash, + $name, + $proj, + $change->note, + $user, + $email, + $self->_char2ts( $change->timestamp ), + $change->planner_name, + $change->planner_email, + ); + + if ( my @deps = $change->dependencies ) { + foreach my $dep (@deps) { + my $sql = q{ + INSERT INTO dependencies ( + change_id + , type + , dependency + , dependency_id + ) VALUES ( ?, ?, ?, ? ) }; + $dbh->do( $sql, undef, + ( $id, $dep->type, $dep->as_string, $dep->resolved_id ) ); + } + } + + if ( my @tags = $change->tags ) { + foreach my $tag (@tags) { + my $sql = qq{ + INSERT INTO tags ( + tag_id + , tag + , project + , change_id + , note + , committer_name + , committer_email + , planned_at + , planner_name + , planner_email + , committed_at + ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, $ts) }; + $dbh->do( + $sql, undef, + ( $tag->id, $tag->format_name, + $proj, $id, + $tag->note, $user, + $email, $self->_char2ts( $tag->timestamp ), + $tag->planner_name, $tag->planner_email, + ) + ); + } + } + + return $self->_log_event( deploy => $change ); +} + +sub default_client { + my $self = shift; + my $ext = App::Sqitch::ISWIN || $^O eq 'cygwin' ? '.exe' : ''; + + # Create a script to run. + require File::Temp; + my $fh = File::Temp->new( CLEANUP => 1 ); + my @opts = (qw(-z -q -i), $fh->filename); + $fh->print("quit;\n"); + $fh->close; + + # Suppress STDERR, including in subprocess. + open my $olderr, '>&', \*STDERR or hurl firebird => __x( + 'Cannot dup STDERR: {error}', $! + ); + close STDERR; + open STDERR, '>', \my $stderr or hurl firebird => __x( + 'Cannot reirect STDERR: {error}', $! + ); + + # Try to find a client in the path. + for my $try ( map { $_ . $ext } qw(fbsql isql-fb isql) ) { + my $loops = 0; + for my $dir (File::Spec->path) { + my $path = file $dir, $try; + $path = Win32::GetShortPathName($path) if App::Sqitch::ISWIN; + if (-f $path && -x $path) { + if (try { App::Sqitch->probe($path, @opts) =~ /Firebird/ } ) { + # Restore STDERR and return. + open STDERR, '>&', $olderr or hurl firebird => __x( + 'Cannot dup STDERR: {error}', $! + ); + return $loops ? $path->stringify : $try; + } + $loops++; + } + } + } + + # Restore STDERR and die. + open STDERR, '>&', $olderr or hurl firebird => __x( + 'Cannot dup STDERR: {error}', $! + ); + hurl firebird => __( + 'Unable to locate Firebird ISQL; set "engine.firebird.client" via sqitch config' + ); +} + +sub _update_script_hashes { + my $self = shift; + my $plan = $self->plan; + my $proj = $plan->project; + my $dbh = $self->dbh; + + $self->begin_work; + # Firebird refuses to update via a prepared statement, so use do(). :-( + $dbh->do( + 'UPDATE changes SET script_hash = ? WHERE change_id = ?', + undef, $_->script_hash, $_->id + ) for $plan->changes; + $dbh->do(q{ + UPDATE changes SET script_hash = NULL + WHERE project = ? AND script_hash = change_id + }, undef, $proj); + + $self->finish_work; + return $self; +} + +1; + +__END__ + +=encoding utf8 + +=head1 Name + +App::Sqitch::Engine::firebird - Sqitch Firebird Engine + +=head1 Synopsis + + my $firebird = App::Sqitch::Engine->load( engine => 'firebird' ); + +=head1 Description + +App::Sqitch::Engine::firebird provides the Firebird storage engine for Sqitch. + +=head1 Interface + +=head2 Instance Methods + +=head3 C + +Constructs a connection string from a database URI for passing to C. + +=head3 C + +Returns a list containing the C client and options to be passed to it. +Used internally when executing scripts. + +=head1 Author + +David E. Wheeler + +Ștefan Suciu + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Copyright (c) 2013 Ștefan Suciu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Engine/firebird.sql b/lib/App/Sqitch/Engine/firebird.sql new file mode 100644 index 00000000..5757c172 --- /dev/null +++ b/lib/App/Sqitch/Engine/firebird.sql @@ -0,0 +1,166 @@ +/* + * Sqitch database deployment metadata v1.1.; + */ + +/* + * Required PAGE SIZE = 16384 to avoid error: "key size exceeds + * implementation restriction for index..." + */ + +-- Table: releases + +CREATE TABLE releases ( + version FLOAT NOT NULL PRIMARY KEY, + installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + installer_name VARCHAR(255) NOT NULL, + installer_email VARCHAR(255) NOT NULL +); + +COMMENT ON TABLE releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN releases.installer_email IS 'Email address of the user who installed the registry release.'; + +-- Table: projects + +CREATE TABLE projects ( + project VARCHAR(255) NOT NULL PRIMARY KEY, + uri VARCHAR(255) UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + creator_name VARCHAR(255) NOT NULL, + creator_email VARCHAR(255) NOT NULL +); + +COMMENT ON TABLE projects IS 'Sqitch projects deployed to this database.'; +COMMENT ON COLUMN projects.project IS 'Unique Name of a project.'; +COMMENT ON COLUMN projects.uri IS 'Optional project URI'; +COMMENT ON COLUMN projects.created_at IS 'Date the project was added to the database.'; +COMMENT ON COLUMN projects.creator_name IS 'Name of the user who added the project.'; +COMMENT ON COLUMN projects.creator_email IS 'Email address of the user who added the project.'; + +-- Table: changes + +CREATE TABLE changes ( + change_id VARCHAR(40) NOT NULL PRIMARY KEY, + script_hash VARCHAR(40), + change VARCHAR(255) NOT NULL, + project VARCHAR(255) NOT NULL REFERENCES projects(project) + ON UPDATE CASCADE, + note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + committer_name VARCHAR(255) NOT NULL, + committer_email VARCHAR(255) NOT NULL, + planned_at TIMESTAMP NOT NULL, + planner_name VARCHAR(255) NOT NULL, + planner_email VARCHAR(255) NOT NULL, + UNIQUE(project, script_hash) +); + +COMMENT ON TABLE changes IS 'Tracks the changes currently deployed to the database.'; +COMMENT ON COLUMN changes.change_id IS 'Change primary key.'; +COMMENT ON COLUMN changes.script_hash IS 'Deploy script SHA-1 hash.'; +COMMENT ON COLUMN changes.change IS 'Name of a deployed change.'; +COMMENT ON COLUMN changes.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN changes.note IS 'Description of the change.'; +COMMENT ON COLUMN changes.committed_at IS 'Date the change was deployed.'; +COMMENT ON COLUMN changes.committer_name IS 'Name of the user who deployed the change.'; +COMMENT ON COLUMN changes.committer_email IS 'Email address of the user who deployed the change.'; +COMMENT ON COLUMN changes.planned_at IS 'Date the change was added to the plan.'; +COMMENT ON COLUMN changes.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN changes.planner_email IS 'Email address of the user who planned the change.'; + +-- Table: tags + +CREATE TABLE tags ( + tag_id CHAR(40) NOT NULL PRIMARY KEY, + tag VARCHAR(250) NOT NULL, + project VARCHAR(255) NOT NULL REFERENCES projects(project) + ON UPDATE CASCADE, + change_id CHAR(40) NOT NULL REFERENCES changes(change_id) + ON UPDATE CASCADE, + note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + committer_name VARCHAR(512) NOT NULL, + committer_email VARCHAR(512) NOT NULL, + planned_at TIMESTAMP NOT NULL, + planner_name VARCHAR(512) NOT NULL, + planner_email VARCHAR(512) NOT NULL, + UNIQUE(project, tag) +); + +COMMENT ON TABLE tags IS 'Tracks the tags currently applied to the database.'; +COMMENT ON COLUMN tags.tag_id IS 'Tag primary key.'; +COMMENT ON COLUMN tags.tag IS 'Project-unique tag name.'; +COMMENT ON COLUMN tags.project IS 'Name of the Sqitch project to which the tag belongs.'; +COMMENT ON COLUMN tags.change_id IS 'ID of last change deployed before the tag was applied.'; +COMMENT ON COLUMN tags.note IS 'Description of the tag.'; +COMMENT ON COLUMN tags.committed_at IS 'Date the tag was applied to the database.'; +COMMENT ON COLUMN tags.committer_name IS 'Name of the user who applied the tag.'; +COMMENT ON COLUMN tags.committer_email IS 'Email address of the user who applied the tag.'; +COMMENT ON COLUMN tags.planned_at IS 'Date the tag was added to the plan.'; +COMMENT ON COLUMN tags.planner_name IS 'Name of the user who planed the tag.'; +COMMENT ON COLUMN tags.planner_email IS 'Email address of the user who planned the tag.'; + +-- Table: dependencies + +CREATE TABLE dependencies ( + change_id CHAR(40) NOT NULL REFERENCES changes(change_id) + ON UPDATE CASCADE ON DELETE CASCADE, + type VARCHAR(8) NOT NULL, + dependency VARCHAR(512) NOT NULL, + dependency_id CHAR(40) REFERENCES changes(change_id) + ON UPDATE CASCADE CONSTRAINT dependencies_check CHECK ( + (type = 'require' AND dependency_id IS NOT NULL) + OR (type = 'conflict' AND dependency_id IS NULL) + ), + PRIMARY KEY (change_id, dependency) +); + +COMMENT ON TABLE dependencies IS 'Tracks the currently satisfied dependencies.'; +COMMENT ON COLUMN dependencies.change_id IS 'ID of the depending change.'; +COMMENT ON COLUMN dependencies.type IS 'Type of dependency.'; +COMMENT ON COLUMN dependencies.dependency IS 'Dependency name.'; +COMMENT ON COLUMN dependencies.dependency_id IS 'Change ID the dependency resolves to.'; + +-- Table: events + +CREATE TABLE events ( + event VARCHAR(6) NOT NULL + CONSTRAINT events_event_check CHECK ( + event IN ('deploy', 'revert', 'fail', 'merge') + ), + change_id CHAR(40) NOT NULL, + change VARCHAR(512) NOT NULL, + project VARCHAR(255) NOT NULL REFERENCES projects(project) + ON UPDATE CASCADE, + note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + requires BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + conflicts BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + tags BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + committer_name VARCHAR(512) NOT NULL, + committer_email VARCHAR(512) NOT NULL, + planned_at TIMESTAMP NOT NULL, + planner_name VARCHAR(512) NOT NULL, + planner_email VARCHAR(512) NOT NULL, + PRIMARY KEY (change_id, committed_at) +); + +COMMENT ON TABLE events IS 'Contains full history of all deployment events.'; +COMMENT ON COLUMN events.event IS 'Type of event.'; +COMMENT ON COLUMN events.change_id IS 'Change ID.'; +COMMENT ON COLUMN events.change IS 'Change name.'; +COMMENT ON COLUMN events.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN events.note IS 'Description of the change.'; +COMMENT ON COLUMN events.requires IS 'Array of the names of required changes.'; +COMMENT ON COLUMN events.conflicts IS 'Array of the names of conflicting changes.'; +COMMENT ON COLUMN events.tags IS 'Tags associated with the change.'; +COMMENT ON COLUMN events.committed_at IS 'Date the event was committed.'; +COMMENT ON COLUMN events.committer_name IS 'Name of the user who committed the event.'; +COMMENT ON COLUMN events.committer_email IS 'Email address of the user who committed the event.'; +COMMENT ON COLUMN events.planned_at IS 'Date the event was added to the plan.'; +COMMENT ON COLUMN events.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN events.planner_email IS 'Email address of the user who plan planned the change.'; + +COMMIT; diff --git a/lib/App/Sqitch/Engine/mysql.pm b/lib/App/Sqitch/Engine/mysql.pm new file mode 100644 index 00000000..3070936d --- /dev/null +++ b/lib/App/Sqitch/Engine/mysql.pm @@ -0,0 +1,551 @@ +package App::Sqitch::Engine::mysql; + +use 5.010; +use strict; +use warnings; +use utf8; +use Try::Tiny; +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::Plan::Change; +use Path::Class; +use Moo; +use App::Sqitch::Types qw(DBH URIDB ArrayRef Bool Str HashRef); +use namespace::autoclean; +use List::MoreUtils qw(firstidx); + +extends 'App::Sqitch::Engine'; + +our $VERSION = 'v1.0.0'; # VERSION + +has uri => ( + is => 'ro', + isa => URIDB, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->SUPER::uri; + $uri->host($ENV{MYSQL_HOST}) if !$uri->host && $ENV{MYSQL_HOST}; + $uri->port($ENV{MYSQL_TCP_PORT}) if !$uri->_port && $ENV{MYSQL_TCP_PORT}; + return $uri; + }, +); + +has registry_uri => ( + is => 'ro', + isa => URIDB, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->uri->clone; + $uri->dbname($self->registry); + return $uri; + }, +); + +sub registry_destination { + my $uri = shift->registry_uri; + if ($uri->password) { + $uri = $uri->clone; + $uri->password(undef); + } + return $uri->as_string; +} + +has _mycnf => ( + is => 'rw', + isa => HashRef, + default => sub { + eval 'require MySQL::Config; 1' or return {}; + return scalar MySQL::Config::parse_defaults('my', [qw(client mysql)]); + }, +); + +sub _def_user { $_[0]->_mycnf->{user} || $_[0]->sqitch->sysuser } +sub _def_pass { $ENV{MYSQL_PWD} || shift->_mycnf->{password} } + +has dbh => ( + is => 'rw', + isa => DBH, + lazy => 1, + default => sub { + my $self = shift; + $self->use_driver; + my $uri = $self->registry_uri; + my $dbh = DBI->connect($uri->dbi_dsn, $self->username, $self->password, { + PrintError => 0, + RaiseError => 0, + AutoCommit => 1, + mysql_enable_utf8 => 1, + mysql_auto_reconnect => 0, + mysql_use_result => 0, # Prevent "Commands out of sync" error. + HandleError => sub { + my ($err, $dbh) = @_; + $@ = $err; + @_ = ($dbh->state || 'DEV' => $dbh->errstr); + goto &hurl; + }, + Callbacks => { + connected => sub { + my $dbh = shift; + $dbh->do("SET SESSION $_") for ( + q{character_set_client = 'utf8'}, + q{character_set_server = 'utf8'}, + ($dbh->{mysql_serverversion} || 0 < 50500 ? () : (q{default_storage_engine = 'InnoDB'})), + q{time_zone = '+00:00'}, + q{group_concat_max_len = 32768}, + q{sql_mode = '} . join(',', qw( + ansi + strict_trans_tables + no_auto_value_on_zero + no_zero_date + no_zero_in_date + only_full_group_by + error_for_division_by_zero + )) . q{'}, + ); + if (!$dbh->{mysql_serverversion} && DBI->VERSION < 1.631) { + # Prior to 1.631, callbacks were inner handles and + # mysql_* aren't set yet. So set InnoDB in a try block. + try { + $dbh->do(q{SET SESSION default_storage_engine = 'InnoDB'}); + }; + # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html + $dbh->set_err(undef, undef) if $dbh->err; + } + return; + }, + }, + }); + + # Make sure we support this version. + my ($dbms, $vnum, $vstr) = $dbh->{mysql_serverinfo} =~ /mariadb/i + ? ('MariaDB', 50300, '5.3') + : ('MySQL', 50000, '5.0.0'); + hurl mysql => __x( + 'Sqitch requires {rdbms} {want_version} or higher; this is {have_version}', + rdbms => $dbms, + want_version => $vstr, + have_version => $dbh->selectcol_arrayref('SELECT version()')->[0], + ) unless $dbh->{mysql_serverversion} >= $vnum; + + return $dbh; + } +); + +has _ts_default => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + return 'utc_timestamp(6)' if shift->_fractional_seconds; + return 'utc_timestamp'; + }, +); + +# Need to wait until dbh and _ts_default are defined. +with 'App::Sqitch::Role::DBIEngine'; + +has _mysql => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->uri; + + $self->sqitch->warn(__x + 'Database name missing in URI "{uri}"', + uri => $uri + ) unless $uri->dbname; + + my @ret = ( $self->client ); + for my $spec ( + [ user => $self->username ], + [ database => $uri->dbname ], + [ host => $uri->host ], + [ port => $uri->_port ], + ) { + push @ret, "--$spec->[0]" => $spec->[1] if $spec->[1]; + } + + # Special-case --password, which requires = before the value. O_o + if (my $pw = $self->password) { + push @ret, "--password=$pw"; + } + + # Options to keep things quiet. + push @ret => ( + (App::Sqitch::ISWIN ? () : '--skip-pager' ), + '--silent', + '--skip-column-names', + '--skip-line-numbers', + ); + + # Get Maria to abort properly on error. + my $vinfo = try { $self->sqitch->probe($self->client, '--version') } || ''; + if ($vinfo =~ /mariadb/i) { + my ($version) = $vinfo =~ /Ver\s(\S+)/; + my ($maj, undef, $pat) = split /[.]/ => $version; + push @ret => '--abort-source-on-error' + if $maj > 5 || ($maj == 5 && $pat >= 66); + } + + # Add relevant query args. + if (my @p = $uri->query_params) { + my %option_for = ( + mysql_compression => sub { $_[0] ? '--compress' : () }, + mysql_ssl => sub { $_[0] ? '--ssl' : () }, + mysql_connect_timeout => sub { '--connect_timeout', $_[0] }, + mysql_init_command => sub { '--init-command', $_[0] }, + mysql_socket => sub { '--socket', $_[0] }, + mysql_ssl_client_key => sub { '--ssl-key', $_[0] }, + mysql_ssl_client_cert => sub { '--ssl-cert', $_[0] }, + mysql_ssl_ca_file => sub { '--ssl-ca', $_[0] }, + mysql_ssl_ca_path => sub { '--ssl-capath', $_[0] }, + mysql_ssl_cipher => sub { '--ssl-cipher', $_[0] }, + ); + while (@p) { + my ($k, $v) = (shift @p, shift @p); + my $code = $option_for{$k} or next; + push @ret => $code->($v); + } + } + + return \@ret; + }, +); + +has _fractional_seconds => ( + is => 'ro', + isa => Bool, + lazy => 1, + default => sub { + my $dbh = shift->dbh; + return $dbh->{mysql_serverinfo} !~ /mariadb/i + && $dbh->{mysql_serverversion} >= 50604; + }, +); + +sub mysql { @{ shift->_mysql } } + +sub key { 'mysql' } +sub name { 'MySQL' } +sub driver { 'DBD::mysql 4.018' } +sub default_client { 'mysql' } + +sub _char2ts { + $_[1]->set_time_zone('UTC')->iso8601; +} + +sub _ts2char_format { + return q{date_format(%s, 'year:%%Y:month:%%m:day:%%d:hour:%%H:minute:%%i:second:%%S:time_zone:UTC')}; +} + +sub _quote_idents { + shift; + map { $_ eq 'change' ? '"change"' : $_ } @_; +} + +sub _version_query { 'SELECT CAST(ROUND(MAX(version), 1) AS CHAR) FROM releases' } + +sub initialized { + my $self = shift; + + # Try to connect. + my $dbh = try { $self->dbh } catch { + # MySQL error code 1049 (ER_BAD_DB_ERROR): Unknown database '%-.192s' + return if $DBI::err && $DBI::err == 1049; + die $_; + } or return 0; + + return $dbh->selectcol_arrayref(q{ + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = ? + AND table_name = ? + }, undef, $self->registry, 'changes')->[0]; +} + +sub initialize { + my $self = shift; + hurl engine => __x( + 'Sqitch database {database} already initialized', + database => $self->registry, + ) if $self->initialized; + + # Create the Sqitch database if it does not exist. + (my $db = $self->registry) =~ s/"/""/g; + $self->_run( + '--execute' => sprintf( + 'SET sql_mode = ansi; CREATE DATABASE IF NOT EXISTS "%s"', + $self->registry + ), + ); + + # Deploy the registry to the Sqitch database. + $self->run_upgrade( file(__FILE__)->dir->file('mysql.sql') ); + $self->_register_release; +} + +# Override to lock the Sqitch tables. This ensures that only one instance of +# Sqitch runs at one time. +sub begin_work { + my $self = shift; + my $dbh = $self->dbh; + + # Start transaction and lock all tables to disallow concurrent changes. + $dbh->do('LOCK TABLES ' . join ', ', map { + "$_ WRITE" + } qw(releases changes dependencies events projects tags)); + $dbh->begin_work; + return $self; +} + +# Override to unlock the tables, otherwise future transactions on this +# connection can fail. +sub finish_work { + my $self = shift; + my $dbh = $self->dbh; + $dbh->commit; + $dbh->do('UNLOCK TABLES'); + return $self; +} + +sub _no_table_error { + return $DBI::state && ( + $DBI::state eq '42S02' # ER_BAD_TABLE_ERROR + || + ($DBI::state eq '42000' && $DBI::err == '1049') # ER_BAD_DB_ERROR + ) +} + +sub _no_column_error { + return $DBI::state && $DBI::state eq '42S22' && $DBI::err == '1054'; # ER_BAD_FIELD_ERROR +} + +sub _regex_op { 'REGEXP' } + +sub _limit_default { '18446744073709551615' } + +sub _listagg_format { + return q{GROUP_CONCAT(%s SEPARATOR ' ')}; +} + +sub _prepare_to_log { + my ($self, $table, $change) = @_; + return $self if $self->_fractional_seconds; + + # No sub-second precision, so delay logging a change until a second has passed. + my $dbh = $self->dbh; + my $sth = $dbh->prepare(qq{ + SELECT UNIX_TIMESTAMP(committed_at) >= UNIX_TIMESTAMP() + FROM $table + WHERE project = ? + ORDER BY committed_at DESC + LIMIT 1 + }); + while ($dbh->selectcol_arrayref($sth, undef, $change->project)->[0]) { + # Sleep for 100 ms. + require Time::HiRes; + Time::HiRes::sleep(0.1); + } + + return $self; +} + +sub _set_vars { + my %vars = shift->variables or return; + return 'SET ' . join(', ', map { + (my $k = $_) =~ s/"/""/g; + (my $v = $vars{$_}) =~ s/'/''/g; + qq{\@"$k" = '$v'}; + } sort keys %vars) . ";\n"; +} + +sub _source { + my ($self, $file) = @_; + my $set = $self->_set_vars || ''; + return ('--execute' => "${set}source $file"); +} + +sub _run { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->run( $self->mysql, @_ ); + local $ENV{MYSQL_PWD} = $pass; + return $sqitch->run( $self->mysql, @_ ); +} + +sub _capture { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->capture( $self->mysql, @_ ); + local $ENV{MYSQL_PWD} = $pass; + return $sqitch->capture( $self->mysql, @_ ); +} + +sub _spool { + my $self = shift; + my @fh = (shift); + my $sqitch = $self->sqitch; + if (my $set = $self->_set_vars) { + open my $sfh, '<:utf8_strict', \$set; + unshift @fh, $sfh; + } + my $pass = $self->password or return $sqitch->spool( \@fh, $self->mysql, @_ ); + local $ENV{MYSQL_PWD} = $pass; + return $sqitch->spool( \@fh, $self->mysql, @_ ); +} + +sub run_file { + my $self = shift; + $self->_run( $self->_source(@_) ); +} + +sub run_verify { + my $self = shift; + # Suppress STDOUT unless we want extra verbosity. + my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture'); + $self->$meth( $self->_source(@_) ); +} + +sub run_upgrade { + my ($self, $file) = @_; + my $dbh = $self->dbh; + my @cmd = $self->mysql; + $cmd[1 + firstidx { $_ eq '--database' } @cmd ] = $self->registry; + return $self->sqitch->run( @cmd, $self->_source($file) ) + if $self->_fractional_seconds; + + # Need to strip out datetime precision. + (my $sql = scalar $file->slurp) =~ s{DATETIME\(\d+\)}{DATETIME}g; + + # Strip out 5.5 stuff on earlier versions. + $sql =~ s/-- ## BEGIN 5[.]5.+?-- ## END 5[.]5//ms if $dbh->{mysql_serverversion} < 50500; + + # Write out a temp file and execute it. + require File::Temp; + my $fh = File::Temp->new; + print $fh $sql; + close $fh; + $self->sqitch->run( @cmd, $self->_source($fh) ); +} + +sub run_handle { + my ($self, $fh) = @_; + $self->_spool($fh); +} + +sub _cid { + my ( $self, $ord, $offset, $project ) = @_; + + my $offexpr = $offset ? " OFFSET $offset" : ''; + return try { + return $self->dbh->selectcol_arrayref(qq{ + SELECT change_id + FROM changes + WHERE project = ? + ORDER BY committed_at $ord + LIMIT 1$offexpr + }, undef, $project || $self->plan->project)->[0]; + } catch { + # MySQL error code 1049 (ER_BAD_DB_ERROR): Unknown database '%-.192s' + # MySQL error code 1146 (ER_NO_SUCH_TABLE): Table '%s.%s' doesn't exist + return if $DBI::err && ($DBI::err == 1049 || $DBI::err == 1146); + die $_; + }; +} + +1; + +1; + +__END__ + +=head1 Name + +App::Sqitch::Engine::mysql - Sqitch MySQL Engine + +=head1 Synopsis + + my $mysql = App::Sqitch::Engine->load( engine => 'mysql' ); + +=head1 Description + +App::Sqitch::Engine::mysql provides the MySQL storage engine for Sqitch. It +supports MySQL 5.1.0 and higher (best on 5.6.4 and higher), as well as MariaDB +5.3.0 and higher. + +=head1 Interface + +=head2 Instance Methods + +=head3 C + +Returns a list containing the C client and options to be passed to it. +Used internally when executing scripts. Query parameters in the URI that map +to C client options will be passed to the client, as follows: + +=over + +=item * C: C<--compress> + +=item * C: C<--ssl> + +=item * C: C<--connect_timeout> + +=item * C: C<--init-command> + +=item * C: C<--socket> + +=item * C: C<--ssl-key> + +=item * C: C<--ssl-cert> + +=item * C: C<--ssl-ca> + +=item * C: C<--ssl-capath> + +=item * C: C<--ssl-cipher> + +=back + +=head3 C + +=head3 C + +Overrides the methods provided by the target so that, if the target has +no username or password, Sqitch looks them up in the +L and F<~/.my.cnf> files|https://dev.mysql.com/doc/refman/5.7/en/password-security-user.html>. +These files must limit access only to the current user (C<0600>). Sqitch will +look for a username and password under the C<[client]> and C<[mysql]> +sections, in that order. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Engine/mysql.sql b/lib/App/Sqitch/Engine/mysql.sql new file mode 100644 index 00000000..8c9caaaf --- /dev/null +++ b/lib/App/Sqitch/Engine/mysql.sql @@ -0,0 +1,192 @@ +BEGIN; + +SET SESSION sql_mode = ansi; + +CREATE TABLE releases ( + version FLOAT(4, 1) PRIMARY KEY + COMMENT 'Version of the Sqitch registry.', + installed_at DATETIME(6) NOT NULL + COMMENT 'Date the registry release was installed.', + installer_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who installed the registry release.', + installer_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who installed the registry release.' +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Sqitch registry releases.' +; + +CREATE TABLE projects ( + project VARCHAR(255) PRIMARY KEY + COMMENT 'Unique Name of a project.', + uri VARCHAR(255) NULL UNIQUE + COMMENT 'Optional project URI', + created_at DATETIME(6) NOT NULL + COMMENT 'Date the project was added to the database.', + creator_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who added the project.', + creator_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who added the project.' +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Sqitch projects deployed to this database.' +; + +CREATE TABLE changes ( + change_id VARCHAR(40) PRIMARY KEY + COMMENT 'Change primary key.', + script_hash VARCHAR(40) NULL + COMMENT 'Deploy script SHA-1 hash.', + "change" VARCHAR(255) NOT NULL + COMMENT 'Name of a deployed change.', + project VARCHAR(255) NOT NULL + COMMENT 'Name of the Sqitch project to which the change belongs.' + REFERENCES projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL + COMMENT 'Description of the change.', + committed_at DATETIME(6) NOT NULL + COMMENT 'Date the change was deployed.', + committer_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who deployed the change.', + committer_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who deployed the change.', + planned_at DATETIME(6) NOT NULL + COMMENT 'Date the change was added to the plan.', + planner_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who planed the change.', + planner_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who planned the change.', + UNIQUE(project, script_hash) +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Tracks the changes currently deployed to the database.' +; + +CREATE TABLE tags ( + tag_id VARCHAR(40) PRIMARY KEY + COMMENT 'Tag primary key.', + tag VARCHAR(255) NOT NULL + COMMENT 'Project-unique tag name.', + project VARCHAR(255) NOT NULL + COMMENT 'Name of the Sqitch project to which the tag belongs.' + REFERENCES projects(project) ON UPDATE CASCADE, + change_id VARCHAR(40) NOT NULL + COMMENT 'ID of last change deployed before the tag was applied.' + REFERENCES changes(change_id) ON UPDATE CASCADE, + note VARCHAR(255) NOT NULL + COMMENT 'Description of the tag.', + committed_at DATETIME(6) NOT NULL + COMMENT 'Date the tag was applied to the database.', + committer_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who applied the tag.', + committer_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who applied the tag.', + planned_at DATETIME(6) NOT NULL + COMMENT 'Date the tag was added to the plan.', + planner_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who planed the tag.', + planner_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who planned the tag.', + UNIQUE(project, tag) +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Tracks the tags currently applied to the database.' +; + +CREATE TABLE dependencies ( + change_id VARCHAR(40) NOT NULL + COMMENT 'ID of the depending change.' + REFERENCES changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE, + type VARCHAR(8) NOT NULL + COMMENT 'Type of dependency.', + dependency VARCHAR(255) NOT NULL + COMMENT 'Dependency name.', + dependency_id VARCHAR(40) NULL + COMMENT 'Change ID the dependency resolves to.' + REFERENCES changes(change_id) ON UPDATE CASCADE, + PRIMARY KEY (change_id, dependency) +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Tracks the currently satisfied dependencies.' +; + +CREATE TABLE events ( + event ENUM ('deploy', 'fail', 'merge', 'revert') NOT NULL + COMMENT 'Type of event.', + change_id VARCHAR(40) NOT NULL + COMMENT 'Change ID.', + "change" VARCHAR(255) NOT NULL + COMMENT 'Change name.', + project VARCHAR(255) NOT NULL + COMMENT 'Name of the Sqitch project to which the change belongs.' + REFERENCES projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL + COMMENT 'Description of the change.', + requires TEXT NOT NULL + COMMENT 'List of the names of required changes.', + conflicts TEXT NOT NULL + COMMENT 'List of the names of conflicting changes.', + tags TEXT NOT NULL + COMMENT 'List of tags associated with the change.', + committed_at DATETIME(6) NOT NULL + COMMENT 'Date the event was committed.', + committer_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who committed the event.', + committer_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who committed the event.', + planned_at DATETIME(6) NOT NULL + COMMENT 'Date the event was added to the plan.', + planner_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who planed the change.', + planner_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who plan planned the change.', + PRIMARY KEY (change_id, committed_at) +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Contains full history of all deployment events.' +; + +-- ## BEGIN 5.5 +-- MySQL does not support checks, so we kind of create our own. The checkit() +-- function works sort of like a CHECK: if the first argument is 0 or NULL, it +-- throws the second argument as an exception. Conveniently, verify scripts +-- can also use it to ensure an error is thrown when a change cannot be +-- verified. Requires MySQL 5.5.0. + +DELIMITER | + +CREATE FUNCTION checkit(doit INTEGER, message VARCHAR(256)) RETURNS INTEGER DETERMINISTIC +BEGIN + IF doit IS NULL OR doit = 0 THEN + SIGNAL SQLSTATE 'ERR0R' SET MESSAGE_TEXT = message; + END IF; + RETURN doit; +END; +| + +CREATE TRIGGER ck_insert_dependency BEFORE INSERT ON dependencies +FOR EACH ROW BEGIN + -- DO does not work. https://bugs.mysql.com/bug.php?id=69647 + SET @dummy := checkit( + (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL) + OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL), + 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set' + ); +END; +| + +CREATE TRIGGER ck_update_dependency BEFORE UPDATE ON dependencies +FOR EACH ROW BEGIN + -- DO does not work. https://bugs.mysql.com/bug.php?id=69647 + SET @dummy := checkit( + (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL) + OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL), + 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set' + ); +END; +| + +DELIMITER ; +-- ## END 5.5 + +COMMIT; diff --git a/lib/App/Sqitch/Engine/oracle.pm b/lib/App/Sqitch/Engine/oracle.pm new file mode 100644 index 00000000..49fd446f --- /dev/null +++ b/lib/App/Sqitch/Engine/oracle.pm @@ -0,0 +1,832 @@ +package App::Sqitch::Engine::oracle; + +use 5.010; +use Moo; +use utf8; +use Path::Class; +use DBI; +use Try::Tiny; +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::Plan::Change; +use List::Util qw(first); +use App::Sqitch::Types qw(DBH Dir ArrayRef); +use namespace::autoclean; + +extends 'App::Sqitch::Engine'; + +our $VERSION = 'v1.0.0'; # VERSION + +BEGIN { + # We tell the Oracle connector which encoding to use. The last part of the + # environment variable NLS_LANG is relevant concerning data encoding. + $ENV{NLS_LANG} = 'AMERICAN_AMERICA.AL32UTF8'; + + # Disable SQLPATH so that no start scripts run. + $ENV{SQLPATH} = ''; +} + +sub destination { + my $self = shift; + + # Just use the target name if it doesn't look like a URI or if the URI + # includes the database name. + return $self->target->name if $self->target->name !~ /:/ + || $self->target->uri->dbname; + + # Use the URI sans password, and with the database name added. + my $uri = $self->target->uri->clone; + $uri->password(undef) if $uri->password; + $uri->dbname( + $ENV{TWO_TASK} + || ( App::Sqitch::ISWIN ? $ENV{LOCAL} : undef ) + || $ENV{ORACLE_SID} + || $self->username + ); + return $uri->as_string; +} + +# No username or password defaults. +sub _def_user { } +sub _def_pass { } + +has _sqlplus => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + default => sub { + my $self = shift; + [ $self->client, qw(-S -L /nolog) ]; + }, +); + +sub sqlplus { @{ shift->_sqlplus } } + +has tmpdir => ( + is => 'ro', + isa => Dir, + lazy => 1, + default => sub { + require File::Temp; + dir File::Temp::tempdir( CLEANUP => 1 ); + }, +); + +sub key { 'oracle' } +sub name { 'Oracle' } +sub driver { 'DBD::Oracle 1.23' } +sub default_registry { '' } + +sub default_client { + file( ($ENV{ORACLE_HOME} || ()), 'sqlplus' )->stringify +} + +has dbh => ( + is => 'rw', + isa => DBH, + lazy => 1, + default => sub { + my $self = shift; + $self->use_driver; + + my $uri = $self->uri; + DBI->connect($uri->dbi_dsn, $self->username, $self->password, { + PrintError => 0, + RaiseError => 0, + AutoCommit => 1, + FetchHashKeyName => 'NAME_lc', + HandleError => sub { + my ($err, $dbh) = @_; + $@ = $err; + @_ = ($dbh->state || 'DEV' => $dbh->errstr); + goto &hurl; + }, + Callbacks => { + connected => sub { + my $dbh = shift; + $dbh->do("ALTER SESSION SET $_='YYYY-MM-DD HH24:MI:SS TZR'") for qw( + nls_date_format + nls_timestamp_format + nls_timestamp_tz_format + ); + if (my $schema = $self->registry) { + try { + $dbh->do("ALTER SESSION SET CURRENT_SCHEMA = $schema"); + # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html + $dbh->set_err(undef, undef) if $dbh->err; + }; + } + return; + }, + }, + }); + } +); + +# Need to wait until dbh is defined. +with 'App::Sqitch::Role::DBIEngine'; + +sub _log_tags_param { + [ map { $_->format_name } $_[1]->tags ]; +} + +sub _log_requires_param { + [ map { $_->as_string } $_[1]->requires ]; +} + +sub _log_conflicts_param { + [ map { $_->as_string } $_[1]->conflicts ]; +} + +sub _ts2char_format { + # q{CAST(to_char(%1$s AT TIME ZONE 'UTC', '"year":YYYY:"month":MM:"day":DD') AS VARCHAR2(100 byte)) || CAST(to_char(%1$s AT TIME ZONE 'UTC', ':"hour":HH24:"minute":MI:"second":SS:"time_zone":"UTC"') AS VARCHAR2(168 byte))} + # Good grief, Oracle, WTF? https://github.com/sqitchers/sqitch/issues/316 + join ' || ', ( + q{to_char(%1$s AT TIME ZONE 'UTC', '"year":YYYY')}, + q{to_char(%1$s AT TIME ZONE 'UTC', ':"month":MM')}, + q{to_char(%1$s AT TIME ZONE 'UTC', ':"day":DD')}, + q{to_char(%1$s AT TIME ZONE 'UTC', ':"hour":HH24')}, + q{to_char(%1$s AT TIME ZONE 'UTC', ':"minute":MI')}, + q{to_char(%1$s AT TIME ZONE 'UTC', ':"second":SS')}, + q{':time_zone:UTC'}, + ); +} +sub _ts_default { 'current_timestamp' } + +sub _can_limit { 0 } + +sub _char2ts { + my $dt = $_[1]; + join ' ', $dt->ymd('-'), $dt->hms(':'), $dt->time_zone->name; +} + +sub _listagg_format { + # https://stackoverflow.com/q/16313631/79202 + return q{CAST(COLLECT(CAST(%s AS VARCHAR2(512))) AS sqitch_array)}; +} + +sub _regex_op { 'REGEXP_LIKE(%s, ?)' } + +sub _simple_from { ' FROM dual' } + +sub _multi_values { + my ($self, $count, $expr) = @_; + return join "\nUNION ALL ", ("SELECT $expr FROM dual") x $count; +} + +sub _dt($) { + require App::Sqitch::DateTime; + return App::Sqitch::DateTime->new(split /:/ => shift); +} + +sub _cid { + my ( $self, $ord, $offset, $project ) = @_; + + return try { + return $self->dbh->selectcol_arrayref(qq{ + SELECT change_id FROM ( + SELECT change_id, rownum as rnum FROM ( + SELECT change_id + FROM changes + WHERE project = ? + ORDER BY committed_at $ord + ) + ) WHERE rnum = ? + }, undef, $project || $self->plan->project, ($offset // 0) + 1)->[0]; + } catch { + return if $self->_no_table_error; + die $_; + }; +} + +sub _cid_head { + my ($self, $project, $change) = @_; + return $self->dbh->selectcol_arrayref(qq{ + SELECT change_id FROM ( + SELECT change_id + FROM changes + WHERE project = ? + AND change = ? + ORDER BY committed_at DESC + ) WHERE rownum = 1 + }, undef, $project, $change)->[0]; +} + +sub _select_state { + my ( $self, $project, $with_hash ) = @_; + my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at'; + my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $tagcol = sprintf $self->_listagg_format, 't.tag'; + my $hshcol = $with_hash ? "c.script_hash\n , " : ''; + my $dbh = $self->dbh; + return $dbh->selectrow_hashref(qq{ + SELECT * FROM ( + SELECT c.change_id + , ${hshcol}c.change + , c.project + , c.note + , c.committer_name + , c.committer_email + , $cdtcol AS committed_at + , c.planner_name + , c.planner_email + , $pdtcol AS planned_at + , $tagcol AS tags + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + GROUP BY c.change_id + , ${hshcol}c.change + , c.project + , c.note + , c.committer_name + , c.committer_email + , c.committed_at + , c.planner_name + , c.planner_email + , c.planned_at + ORDER BY c.committed_at DESC + ) WHERE rownum = 1 + }, undef, $project // $self->plan->project); +} + +sub is_deployed_change { + my ( $self, $change ) = @_; + $self->dbh->selectcol_arrayref( + 'SELECT 1 FROM changes WHERE change_id = ?', + undef, $change->id + )->[0]; +} + +sub initialized { + my $self = shift; + return $self->dbh->selectcol_arrayref(q{ + SELECT 1 + FROM all_tables + WHERE owner = UPPER(?) + AND table_name = 'CHANGES' + }, undef, $self->registry || $self->username)->[0]; +} + +sub _log_event { + my ( $self, $event, $change, $tags, $requires, $conflicts) = @_; + my $dbh = $self->dbh; + my $sqitch = $self->sqitch; + + $tags ||= $self->_log_tags_param($change); + $requires ||= $self->_log_requires_param($change); + $conflicts ||= $self->_log_conflicts_param($change); + + # Use the sqitch_array() constructor to insert arrays of values. + my $tag_ph = 'sqitch_array('. join(', ', ('?') x @{ $tags }) . ')'; + my $req_ph = 'sqitch_array('. join(', ', ('?') x @{ $requires }) . ')'; + my $con_ph = 'sqitch_array('. join(', ', ('?') x @{ $conflicts }) . ')'; + my $ts = $self->_ts_default; + + $dbh->do(qq{ + INSERT INTO events ( + event + , change_id + , change + , project + , note + , tags + , requires + , conflicts + , committer_name + , committer_email + , planned_at + , planner_name + , planner_email + , committed_at + ) + VALUES (?, ?, ?, ?, ?, $tag_ph, $req_ph, $con_ph, ?, ?, ?, ?, ?, $ts) + }, undef, + $event, + $change->id, + $change->name, + $change->project, + $change->note, + @{ $tags }, + @{ $requires }, + @{ $conflicts }, + $sqitch->user_name, + $sqitch->user_email, + $self->_char2ts( $change->timestamp ), + $change->planner_name, + $change->planner_email, + ); + + return $self; +} + +sub changes_requiring_change { + my ( $self, $change ) = @_; + # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221 + return @{ $self->dbh->selectall_arrayref(q{ + WITH tag AS ( + SELECT tag, committed_at, project, + ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk + FROM tags + ) + SELECT c.change_id, c.project, c.change, t.tag AS asof_tag + FROM dependencies d + JOIN changes c ON c.change_id = d.change_id + LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at + WHERE d.dependency_id = ? + AND (t.rnk IS NULL OR t.rnk = 1) + }, { Slice => {} }, $change->id) }; +} + +sub name_for_change_id { + my ( $self, $change_id ) = @_; + # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221 + return $self->dbh->selectcol_arrayref(q{ + WITH tag AS ( + SELECT tag, committed_at, project, + ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk + FROM tags + ) + SELECT change || COALESCE(t.tag, '@HEAD') + FROM changes c + LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at + WHERE change_id = ? + AND (t.rnk IS NULL OR t.rnk = 1) + }, undef, $change_id)->[0]; +} + +sub change_id_offset_from_id { + my ( $self, $change_id, $offset ) = @_; + + # Just return the ID if there is no offset. + return $change_id unless $offset; + + # Are we offset forwards or backwards? + my ( $dir, $op ) = $offset > 0 ? ( 'ASC', '>' ) : ( 'DESC' , '<' ); + return $self->dbh->selectcol_arrayref(qq{ + SELECT id FROM ( + SELECT id, rownum AS rnum FROM ( + SELECT change_id AS id + FROM changes + WHERE project = ? + AND committed_at $op ( + SELECT committed_at FROM changes WHERE change_id = ? + ) + ORDER BY committed_at $dir + ) + ) WHERE rnum = ? + }, undef, $self->plan->project, $change_id, abs $offset)->[0]; +} + +sub change_offset_from_id { + my ( $self, $change_id, $offset ) = @_; + + # Just return the object if there is no offset. + return $self->load_change($change_id) unless $offset; + + # Are we offset forwards or backwards? + my ( $dir, $op ) = $offset > 0 ? ( 'ASC', '>' ) : ( 'DESC' , '<' ); + my $tscol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $tagcol = sprintf $self->_listagg_format, 't.tag'; + + my $change = $self->dbh->selectrow_hashref(qq{ + SELECT id, name, project, note, timestamp, planner_name, planner_email, tags + FROM ( + SELECT id, name, project, note, timestamp, planner_name, planner_email, tags, rownum AS rnum + FROM ( + SELECT c.change_id AS id, c.change AS name, c.project, c.note, + $tscol AS timestamp, c.planner_name, c.planner_email, + $tagcol AS tags + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + AND c.committed_at $op ( + SELECT committed_at FROM changes WHERE change_id = ? + ) + GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at, + c.planner_name, c.planner_email, c.committed_at + ORDER BY c.committed_at $dir + ) + ) WHERE rnum = ? + }, undef, $self->plan->project, $change_id, abs $offset) || return undef; + $change->{timestamp} = _dt $change->{timestamp}; + return $change; +} + +sub is_deployed_tag { + my ( $self, $tag ) = @_; + return $self->dbh->selectcol_arrayref( + 'SELECT 1 FROM tags WHERE tag_id = ?', + undef, $tag->id + )->[0]; +} + +sub are_deployed_changes { + my $self = shift; + my @qs; + my $i = @_; + while ($i > 250) { + push @qs => 'change_id IN (' . join(', ' => ('?') x 250) . ')'; + $i -= 250; + } + push @qs => 'change_id IN (' . join(', ' => ('?') x @_) . ')'; + my $expr = join ' OR ', @qs; + @{ $self->dbh->selectcol_arrayref( + "SELECT change_id FROM changes WHERE $expr", + undef, + map { $_->id } @_, + ) }; +} + +sub _registry_variable { + my $self = shift; + my $schema = $self->registry; + return $schema ? ("DEFINE registry=$schema") : ( + # Select the current schema into ®istry. + # https://www.orafaq.com/node/515 + 'COLUMN sname for a30 new_value registry', + q{SELECT SYS_CONTEXT('USERENV', 'SESSION_SCHEMA') AS sname FROM DUAL;}, + ); +} + +sub initialize { + my $self = shift; + my $schema = $self->registry; + hurl engine => __ 'Sqitch already initialized' if $self->initialized; + + # Load up our database. + (my $file = file(__FILE__)->dir->file('oracle.sql')) =~ s/"/""/g; + $self->_run_with_verbosity($file); + $self->dbh->do("ALTER SESSION SET CURRENT_SCHEMA = $schema") if $schema; + $self->_register_release; +} + +# Override for special handling of regular the expression operator and +# LIMIT/OFFSET. +sub search_events { + my ( $self, %p ) = @_; + + # Determine order direction. + my $dir = 'DESC'; + if (my $d = delete $p{direction}) { + $dir = $d =~ /^ASC/i ? 'ASC' + : $d =~ /^DESC/i ? 'DESC' + : hurl 'Search direction must be either "ASC" or "DESC"'; + } + + # Limit with regular expressions? + my (@wheres, @params); + for my $spec ( + [ committer => 'committer_name' ], + [ planner => 'planner_name' ], + [ change => 'change' ], + [ project => 'project' ], + ) { + my $regex = delete $p{ $spec->[0] } // next; + push @wheres => "REGEXP_LIKE($spec->[1], ?)"; + push @params => $regex; + } + + # Match events? + if (my $e = delete $p{event} ) { + my ($in, @vals) = $self->_in_expr( $e ); + push @wheres => "event $in"; + push @params => @vals; + } + + # Assemble the where clause. + my $where = @wheres + ? "\n WHERE " . join( "\n ", @wheres ) + : ''; + + # Handle remaining parameters. + my ($lim, $off) = (delete $p{limit}, delete $p{offset}); + + hurl 'Invalid parameters passed to search_events(): ' + . join ', ', sort keys %p if %p; + + # Prepare, execute, and return. + my $cdtcol = sprintf $self->_ts2char_format, 'committed_at'; + my $pdtcol = sprintf $self->_ts2char_format, 'planned_at'; + my $sql = qq{ + SELECT event + , project + , change_id + , change + , note + , requires + , conflicts + , tags + , committer_name + , committer_email + , $cdtcol AS committed_at + , planner_name + , planner_email + , $pdtcol AS planned_at + FROM events$where + ORDER BY events.committed_at $dir + }; + + if ($lim || $off) { + my @limits; + if ($lim) { + $off //= 0; + push @params => $lim + $off; + push @limits => 'rnum <= ?'; + } + if ($off) { + push @params => $off; + push @limits => 'rnum > ?'; + } + + $sql = "SELECT * FROM ( SELECT ROWNUM AS rnum, i.* FROM ($sql) i ) WHERE " + . join ' AND ', @limits; + } + + my $sth = $self->dbh->prepare($sql); + $sth->execute(@params); + return sub { + my $row = $sth->fetchrow_hashref or return; + delete $row->{rnum}; + $row->{committed_at} = _dt $row->{committed_at}; + $row->{planned_at} = _dt $row->{planned_at}; + return $row; + }; +} + +# Override to lock the changes table. This ensures that only one instance of +# Sqitch runs at one time. +sub begin_work { + my $self = shift; + my $dbh = $self->dbh; + + # Start transaction and lock changes to allow only one change at a time. + $dbh->begin_work; + $dbh->do('LOCK TABLE changes IN EXCLUSIVE MODE'); + return $self; +} + +sub _file_for_script { + my ($self, $file) = @_; + + # Just use the file if no special character. + if ($file !~ /[@?%\$]/) { + $file =~ s/"/""/g; + return $file; + } + + # Alias or copy the file to a temporary directory that's removed on exit. + (my $alias = $file->basename) =~ s/[@?%\$]/_/g; + $alias = $self->tmpdir->file($alias); + + # Remove existing file. + if (-e $alias) { + $alias->remove or hurl oracle => __x( + 'Cannot remove {file}: {error}', + file => $alias, + error => $! + ); + } + + if (App::Sqitch::ISWIN) { + # Copy it. + $file->copy_to($alias) or hurl oracle => __x( + 'Cannot copy {file} to {alias}: {error}', + file => $file, + alias => $alias, + error => $! + ); + } else { + # Symlink it. + $alias->remove; + symlink $file->absolute, $alias or hurl oracle => __x( + 'Cannot symlink {file} to {alias}: {error}', + file => $file, + alias => $alias, + error => $! + ); + } + + # Return the alias. + $alias =~ s/"/""/g; + return $alias; +} + +sub run_file { + my $self = shift; + my $file = $self->_file_for_script(shift); + $self->_run(qq{\@"$file"}); +} + +sub _run_with_verbosity { + my $self = shift; + my $file = $self->_file_for_script(shift); + # Suppress STDOUT unless we want extra verbosity. + my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture'); + $self->$meth(qq{\@"$file"}); +} + +sub run_upgrade { shift->_run_with_verbosity(@_) } +sub run_verify { shift->_run_with_verbosity(@_) } + +sub run_handle { + my ($self, $fh) = @_; + my $conn = $self->_script; + open my $tfh, '<:utf8_strict', \$conn; + $self->sqitch->spool( [$tfh, $fh], $self->sqlplus ); +} + +# Override to take advantage of the RETURNING expression, and to save tags as +# an array rather than a space-delimited string. +sub log_revert_change { + my ($self, $change) = @_; + my $dbh = $self->dbh; + my $cid = $change->id; + + # Delete tags. + my $sth = $dbh->prepare( + 'DELETE FROM tags WHERE change_id = ? RETURNING tag INTO ?', + ); + $sth->bind_param(1, $cid); + $sth->bind_param_inout_array(2, my $del_tags = [], 0, { + ora_type => DBD::Oracle::ORA_VARCHAR2() + }); + $sth->execute; + + # Retrieve dependencies. + my $depcol = sprintf $self->_listagg_format, 'dependency'; + my ($req, $conf) = $dbh->selectrow_array(qq{ + SELECT ( + SELECT $depcol + FROM dependencies + WHERE change_id = ? + AND type = 'require' + ), + ( + SELECT $depcol + FROM dependencies + WHERE change_id = ? + AND type = 'conflict' + ) FROM dual + }, undef, $cid, $cid); + + # Delete the change record. + $dbh->do( + 'DELETE FROM changes where change_id = ?', + undef, $change->id, + ); + + # Log it. + return $self->_log_event( revert => $change, $del_tags, $req, $conf ); +} + +sub _no_table_error { + return $DBI::err && $DBI::err == 942; # ORA-00942: table or view does not exist +} + +sub _no_column_error { + return $DBI::err && $DBI::err == 904; # ORA-00904: invalid identifier +} + +sub _script { + my $self = shift; + my $uri = $self->uri; + my $conn = ''; + my ($user, $pass, $host, $port) = ( + $self->username, $self->password, $uri->host, $uri->_port + ); + if ($user || $pass || $host || $port) { + $conn = $user // ''; + if ($pass) { + $pass =~ s/"/""/g; + $conn .= qq{/"$pass"}; + } + if (my $db = $uri->dbname) { + $conn .= '@'; + $db =~ s/"/""/g; + if ($host || $port) { + $conn .= '//' . ($host || ''); + if ($port) { + $conn .= ":$port"; + } + $conn .= qq{/"$db"}; + } else { + $conn .= qq{"$db"}; + } + } + } else { + # OS authentication or Oracle wallet (no username or password). + if (my $db = $uri->dbname) { + $db =~ s/"/""/g; + $conn = qq{/@"$db"}; + } + } + my %vars = $self->variables; + + return join "\n" => ( + 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF', + 'WHENEVER OSERROR EXIT 9;', + 'WHENEVER SQLERROR EXIT SQL.SQLCODE;', + (map {; (my $v = $vars{$_}) =~ s/"/""/g; qq{DEFINE $_="$v"} } sort keys %vars), + "connect $conn", + $self->_registry_variable, + @_ + ); +} + +sub _run { + my $self = shift; + my $script = $self->_script(@_); + open my $fh, '<:utf8_strict', \$script; + return $self->sqitch->spool( $fh, $self->sqlplus ); +} + +sub _capture { + my $self = shift; + my $conn = $self->_script(@_); + my @out; + + require IPC::Run3; + IPC::Run3::run3( + [$self->sqlplus], \$conn, \@out, \@out, + { return_if_system_error => 1 }, + ); + if (my $err = $?) { + # Ugh, send everything to STDERR. + $self->sqitch->vent(@out); + hurl io => __x( + '{command} unexpectedly returned exit value {exitval}', + command => $self->client, + exitval => ($err >> 8), + ); + } + + return wantarray ? @out : \@out; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Engine::oracle - Sqitch Oracle Engine + +=head1 Synopsis + + my $oracle = App::Sqitch::Engine->load( engine => 'oracle' ); + +=head1 Description + +App::Sqitch::Engine::oracle provides the Oracle storage engine for Sqitch. It +supports Oracle 10g and higher. + +=head1 Interface + +=head2 Instance Methods + +=head3 C + + $oracle->initialize unless $oracle->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C + + $oracle->initialize; + +Initializes a database for Sqitch by installing the Sqitch registry schema. + +=head3 C + +Returns a list containing the C client and options to be passed to it. +Used internally when executing scripts. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Engine/oracle.sql b/lib/App/Sqitch/Engine/oracle.sql new file mode 100644 index 00000000..2c3d2cf6 --- /dev/null +++ b/lib/App/Sqitch/Engine/oracle.sql @@ -0,0 +1,142 @@ +CREATE TABLE ®istry..releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL, + installer_name VARCHAR2(512 CHAR) NOT NULL, + installer_email VARCHAR2(512 CHAR) NOT NULL +); + +COMMENT ON TABLE ®istry..releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN ®istry..releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN ®istry..releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN ®istry..releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN ®istry..releases.installer_email IS 'Email address of the user who installed the registry release.'; + +CREATE TABLE ®istry..projects ( + project VARCHAR2(512 CHAR) PRIMARY KEY, + uri VARCHAR2(512 CHAR) NULL UNIQUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL, + creator_name VARCHAR2(512 CHAR) NOT NULL, + creator_email VARCHAR2(512 CHAR) NOT NULL +); + +COMMENT ON TABLE ®istry..projects IS 'Sqitch projects deployed to this database.'; +COMMENT ON COLUMN ®istry..projects.project IS 'Unique Name of a project.'; +COMMENT ON COLUMN ®istry..projects.uri IS 'Optional project URI'; +COMMENT ON COLUMN ®istry..projects.created_at IS 'Date the project was added to the database.'; +COMMENT ON COLUMN ®istry..projects.creator_name IS 'Name of the user who added the project.'; +COMMENT ON COLUMN ®istry..projects.creator_email IS 'Email address of the user who added the project.'; + +CREATE TABLE ®istry..changes ( + change_id CHAR(40) PRIMARY KEY, + script_hash CHAR(40) NULL, + change VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + note VARCHAR2(4000 CHAR) DEFAULT '', + committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL, + UNIQUE(project, script_hash) +); + +COMMENT ON TABLE ®istry..changes IS 'Tracks the changes currently deployed to the database.'; +COMMENT ON COLUMN ®istry..changes.change_id IS 'Change primary key.'; +COMMENT ON COLUMN ®istry..changes.script_hash IS 'Deploy script SHA-1 hash.'; +COMMENT ON COLUMN ®istry..changes.change IS 'Name of a deployed change.'; +COMMENT ON COLUMN ®istry..changes.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry..changes.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry..changes.committed_at IS 'Date the change was deployed.'; +COMMENT ON COLUMN ®istry..changes.committer_name IS 'Name of the user who deployed the change.'; +COMMENT ON COLUMN ®istry..changes.committer_email IS 'Email address of the user who deployed the change.'; +COMMENT ON COLUMN ®istry..changes.planned_at IS 'Date the change was added to the plan.'; +COMMENT ON COLUMN ®istry..changes.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry..changes.planner_email IS 'Email address of the user who planned the change.'; + +CREATE TABLE ®istry..tags ( + tag_id CHAR(40) PRIMARY KEY, + tag VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + change_id CHAR(40) NOT NULL REFERENCES ®istry..changes(change_id), + note VARCHAR2(4000 CHAR) DEFAULT '', + committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL, + UNIQUE(project, tag) +); + +COMMENT ON TABLE ®istry..tags IS 'Tracks the tags currently applied to the database.'; +COMMENT ON COLUMN ®istry..tags.tag_id IS 'Tag primary key.'; +COMMENT ON COLUMN ®istry..tags.tag IS 'Project-unique tag name.'; +COMMENT ON COLUMN ®istry..tags.project IS 'Name of the Sqitch project to which the tag belongs.'; +COMMENT ON COLUMN ®istry..tags.change_id IS 'ID of last change deployed before the tag was applied.'; +COMMENT ON COLUMN ®istry..tags.note IS 'Description of the tag.'; +COMMENT ON COLUMN ®istry..tags.committed_at IS 'Date the tag was applied to the database.'; +COMMENT ON COLUMN ®istry..tags.committer_name IS 'Name of the user who applied the tag.'; +COMMENT ON COLUMN ®istry..tags.committer_email IS 'Email address of the user who applied the tag.'; +COMMENT ON COLUMN ®istry..tags.planned_at IS 'Date the tag was added to the plan.'; +COMMENT ON COLUMN ®istry..tags.planner_name IS 'Name of the user who planed the tag.'; +COMMENT ON COLUMN ®istry..tags.planner_email IS 'Email address of the user who planned the tag.'; + +CREATE TABLE ®istry..dependencies ( + change_id CHAR(40) NOT NULL REFERENCES ®istry..changes(change_id) ON DELETE CASCADE, + type VARCHAR2(8) NOT NULL, + dependency VARCHAR2(1024 CHAR) NOT NULL, + dependency_id CHAR(40) NULL REFERENCES ®istry..changes(change_id), + CONSTRAINT dependencies_check CHECK ( + (type = 'require' AND dependency_id IS NOT NULL) + OR (type = 'conflict' AND dependency_id IS NULL) + ), + PRIMARY KEY (change_id, dependency) +); + +COMMENT ON TABLE ®istry..dependencies IS 'Tracks the currently satisfied dependencies.'; +COMMENT ON COLUMN ®istry..dependencies.change_id IS 'ID of the depending change.'; +COMMENT ON COLUMN ®istry..dependencies.type IS 'Type of dependency.'; +COMMENT ON COLUMN ®istry..dependencies.dependency IS 'Dependency name.'; +COMMENT ON COLUMN ®istry..dependencies.dependency_id IS 'Change ID the dependency resolves to.'; + +CREATE TYPE ®istry..sqitch_array AS varray(1024) OF VARCHAR2(512); +/ + +CREATE TABLE ®istry..events ( + event VARCHAR2(6) NOT NULL + CONSTRAINT events_event_check CHECK ( + event IN ('deploy', 'revert', 'fail', 'merge') + ), + change_id CHAR(40) NOT NULL, + change VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + note VARCHAR2(4000 CHAR) DEFAULT '', + requires ®istry..SQITCH_ARRAY DEFAULT ®istry..SQITCH_ARRAY() NOT NULL, + conflicts ®istry..SQITCH_ARRAY DEFAULT ®istry..SQITCH_ARRAY() NOT NULL, + tags ®istry..SQITCH_ARRAY DEFAULT ®istry..SQITCH_ARRAY() NOT NULL, + committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL +); + +CREATE UNIQUE INDEX ®istry..events_pkey ON ®istry..events(change_id, committed_at); + +COMMENT ON TABLE ®istry..events IS 'Contains full history of all deployment events.'; +COMMENT ON COLUMN ®istry..events.event IS 'Type of event.'; +COMMENT ON COLUMN ®istry..events.change_id IS 'Change ID.'; +COMMENT ON COLUMN ®istry..events.change IS 'Change name.'; +COMMENT ON COLUMN ®istry..events.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry..events.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry..events.requires IS 'Array of the names of required changes.'; +COMMENT ON COLUMN ®istry..events.conflicts IS 'Array of the names of conflicting changes.'; +COMMENT ON COLUMN ®istry..events.tags IS 'Tags associated with the change.'; +COMMENT ON COLUMN ®istry..events.committed_at IS 'Date the event was committed.'; +COMMENT ON COLUMN ®istry..events.committer_name IS 'Name of the user who committed the event.'; +COMMENT ON COLUMN ®istry..events.committer_email IS 'Email address of the user who committed the event.'; +COMMENT ON COLUMN ®istry..events.planned_at IS 'Date the event was added to the plan.'; +COMMENT ON COLUMN ®istry..events.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry..events.planner_email IS 'Email address of the user who plan planned the change.'; diff --git a/lib/App/Sqitch/Engine/pg.pm b/lib/App/Sqitch/Engine/pg.pm new file mode 100644 index 00000000..3753df0e --- /dev/null +++ b/lib/App/Sqitch/Engine/pg.pm @@ -0,0 +1,505 @@ +package App::Sqitch::Engine::pg; + +use 5.010; +use Moo; +use utf8; +use Path::Class; +use DBI; +use Try::Tiny; +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::Plan::Change; +use List::Util qw(first); +use App::Sqitch::Types qw(DBH ArrayRef); +use namespace::autoclean; + +extends 'App::Sqitch::Engine'; + +our $VERSION = 'v1.0.0'; # VERSION + +sub destination { + my $self = shift; + + # Just use the target name if it doesn't look like a URI or if the URI + # includes the database name. + return $self->target->name if $self->target->name !~ /:/ + || $self->target->uri->dbname; + + # Use the URI sans password, and with the database name added. + my $uri = $self->target->uri->clone; + $uri->password(undef) if $uri->password; + $uri->dbname( + $ENV{PGDATABASE} + || $self->username + || $ENV{PGUSER} + || $self->sqitch->sysuser + ); + return $uri->as_string; +} + +# DBD::pg and psql use fallbacks consistently, thanks to libpq. These include +# environment variables, system info (username), the password file, and the +# connection service file. Best for us not to second-guess these values, +# though we admittedly try when setting the database name in the destination +# URI for unnamed targets a few lines up from here. +sub _def_user { } +sub _def_pass { } + +has _psql => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->uri; + my @ret = ( $self->client ); + + my %query_params = $uri->query_params; + my @conninfo; + for my $spec ( + [ user => $self->username ], + [ dbname => $uri->dbname ], + [ host => $uri->host ], + [ port => $uri->_port ], + map { [ $_ => $query_params{$_} ] } + sort keys %query_params, + ) { + next unless defined $spec->[1] && length $spec->[1]; + if ($spec->[1] =~ /[ "'\\]/) { + $spec->[1] =~ s/([ "'\\])/\\$1/g; + } + push @conninfo, "$spec->[0]=$spec->[1]"; + } + + push @ret => '--dbname', join ' ', @conninfo if @conninfo; + + if (my %vars = $self->variables) { + push @ret => map {; '--set', "$_=$vars{$_}" } sort keys %vars; + } + + push @ret => $self->_client_opts; + return \@ret; + }, +); + +sub _client_opts { + my $self = shift; + return ( + '--quiet', + '--no-psqlrc', + '--no-align', + '--tuples-only', + '--set' => 'ON_ERROR_STOP=1', + '--set' => 'registry=' . $self->registry, + ); +} + +sub psql { @{ shift->_psql } } + +sub key { 'pg' } +sub name { 'PostgreSQL' } +sub driver { 'DBD::Pg 2.0' } +sub default_client { 'psql' } + +has dbh => ( + is => 'rw', + isa => DBH, + lazy => 1, + default => sub { + my $self = shift; + $self->use_driver; + + my $uri = $self->uri; + local $ENV{PGCLIENTENCODING} = 'UTF8'; + DBI->connect($uri->dbi_dsn, $self->username, $self->password, { + PrintError => 0, + RaiseError => 0, + AutoCommit => 1, + pg_enable_utf8 => 1, + pg_server_prepare => 1, + HandleError => sub { + my ($err, $dbh) = @_; + $@ = $err; + @_ = ($dbh->state || 'DEV' => $dbh->errstr); + goto &hurl; + }, + Callbacks => { + connected => sub { + my $dbh = shift; + $dbh->do('SET client_min_messages = WARNING'); + try { + $dbh->do( + 'SET search_path = ?', + undef, $self->registry + ); + # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html + $dbh->set_err(undef, undef) if $dbh->err; + }; + return; + }, + }, + }); + } +); + +# Need to wait until dbh is defined. +with 'App::Sqitch::Role::DBIEngine'; + +sub _log_tags_param { + [ map { $_->format_name } $_[1]->tags ]; +} + +sub _log_requires_param { + [ map { $_->as_string } $_[1]->requires ]; +} + +sub _log_conflicts_param { + [ map { $_->as_string } $_[1]->conflicts ]; +} + +sub _ts2char_format { + q{to_char(%s AT TIME ZONE 'UTC', '"year":YYYY:"month":MM:"day":DD:"hour":HH24:"minute":MI:"second":SS:"time_zone":"UTC"')}; +} + +sub _ts_default { 'clock_timestamp()' } + +sub _char2ts { $_[1]->as_string(format => 'iso') } + +sub _listagg_format { + q{ARRAY(SELECT * FROM UNNEST( array_agg(%s) ) a WHERE a IS NOT NULL)} +} + +sub _regex_op { '~' } + +sub _version_query { 'SELECT MAX(version)::TEXT FROM releases' } + +sub initialized { + my $self = shift; + return $self->dbh->selectcol_arrayref(q{ + SELECT EXISTS( + SELECT TRUE FROM pg_catalog.pg_tables + WHERE schemaname = ? AND tablename = ? + ) + }, undef, $self->registry, 'changes')->[0]; +} + +sub initialize { + my $self = shift; + hurl engine => __x( + 'Sqitch schema "{schema}" already exists', + schema => $self->registry + ) if $self->initialized; + $self->_run_registry_file( file(__FILE__)->dir->file('pg.sql') ); + $self->_register_release; +} + +sub _psql_major_version { + my $self = shift; + my $psql_version = $self->sqitch->probe($self->client, '--version'); + my @parts = split /\s+/, $psql_version; + my ($maj) = $parts[-1] =~ /^(\d+)/; + return $maj || 0; +} + +sub _run_registry_file { + my ($self, $file) = @_; + my $schema = $self->registry; + + # Fetch the client version. 8.4 == 80400 + my $version = $self->_probe('-c', 'SHOW server_version_num'); + my $psql_maj = $self->_psql_major_version; + + # Is this XC? + my $opts = $self->_probe('-c', q{ + SELECT count(*) + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON p.pronamespace = n.oid + WHERE nspname = 'pg_catalog' + AND proname = 'pgxc_version'; + }) ? ' DISTRIBUTE BY REPLICATION' : ''; + + if ($version < 90300 || $psql_maj < 9) { + # Need to transform the SQL and write it to a temp file. + my $sql = scalar $file->slurp; + + # No CREATE SCHEMA IF NOT EXISTS syntax prior to 9.3. + $sql =~ s/SCHEMA IF NOT EXISTS/SCHEMA/ if $version < 90300; + if ($psql_maj < 9) { + # Also no :"registry" variable syntax prior to psql 9.0.s + ($schema) = $self->dbh->selectrow_array( + 'SELECT quote_ident(?)', undef, $schema + ); + $sql =~ s{:"registry"}{$schema}g; + } + require File::Temp; + my $fh = File::Temp->new; + print $fh $sql; + close $fh; + $self->_run( + '--file' => $fh->filename, + '--set' => "tableopts=$opts", + ); + } else { + # We can take advantage of the :"registry" variable syntax. + $self->_run( + '--file' => $file, + '--set' => "registry=$schema", + '--set' => "tableopts=$opts", + ); + } + + $self->dbh->do('SET search_path = ?', undef, $schema); +} + +# Override to lock the changes table. This ensures that only one instance of +# Sqitch runs at one time. +sub begin_work { + my $self = shift; + my $dbh = $self->dbh; + + # Start transaction and lock changes to allow only one change at a time. + $dbh->begin_work; + $dbh->do('LOCK TABLE changes IN EXCLUSIVE MODE'); + return $self; +} + +sub run_file { + my ($self, $file) = @_; + $self->_run('--file' => $file); +} + +sub run_verify { + my $self = shift; + # Suppress STDOUT unless we want extra verbosity. + my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture'); + return $self->$meth('--file' => @_); +} + +sub run_handle { + my ($self, $fh) = @_; + $self->_spool($fh); +} + +sub run_upgrade { + shift->_run_registry_file(@_); +} + +# Override to avoid cast errors, and to use VALUES instead of a UNION query. +sub log_new_tags { + my ( $self, $change ) = @_; + my @tags = $change->tags or return $self; + my $sqitch = $self->sqitch; + + my ($id, $name, $proj, $user, $email) = ( + $change->id, + $change->format_name, + $change->project, + $sqitch->user_name, + $sqitch->user_email + ); + + $self->dbh->do( + q{ + INSERT INTO tags ( + tag_id + , tag + , project + , change_id + , note + , committer_name + , committer_email + , planned_at + , planner_name + , planner_email + ) + SELECT tid, tg, proj, chid, n, name, email, at, pname, pemail FROM ( VALUES + } . join( ",\n ", ( q{(?, ?, ?, ?, ?, ?, ?, ?::timestamptz, ?, ?)} ) x @tags ) + . q{ + ) i(tid, tg, proj, chid, n, name, email, at, pname, pemail) + LEFT JOIN tags ON i.tid = tags.tag_id + WHERE tags.tag_id IS NULL + }, + undef, + map { ( + $_->id, + $_->format_name, + $proj, + $id, + $_->note, + $user, + $email, + $_->timestamp->as_string(format => 'iso'), + $_->planner_name, + $_->planner_email, + ) } @tags + ); + + return $self; +} + +# Override to take advantage of the RETURNING expression, and to save tags as +# an array rather than a space-delimited string. +sub log_revert_change { + my ($self, $change) = @_; + my $dbh = $self->dbh; + + # Delete tags. + my $del_tags = $dbh->selectcol_arrayref( + 'DELETE FROM tags WHERE change_id = ? RETURNING tag', + undef, $change->id + ) || []; + + # Retrieve dependencies. + my ($req, $conf) = $dbh->selectrow_array(q{ + SELECT ARRAY( + SELECT dependency + FROM dependencies + WHERE change_id = $1 + AND type = 'require' + ), ARRAY( + SELECT dependency + FROM dependencies + WHERE change_id = $1 + AND type = 'conflict' + ) + }, undef, $change->id); + + # Delete the change record. + $dbh->do( + 'DELETE FROM changes where change_id = ?', + undef, $change->id, + ); + + # Log it. + return $self->_log_event( revert => $change, $del_tags, $req, $conf ); +} + +sub _dt($) { + require App::Sqitch::DateTime; + return App::Sqitch::DateTime->new(split /:/ => shift); +} + +sub _no_table_error { + return 0 unless $DBI::state && $DBI::state eq '42P01'; # undefined_table + my $dbh = shift->dbh; + my @msg = map { $dbh->quote($_) } ( + __ 'Sqitch registry not initialized', + __ 'Because the "changes" table does not exist, Sqitch will now initialize the database to create its registry tables.', + ); + $dbh->do(sprintf q{DO $$ + BEGIN + SET LOCAL client_min_messages = 'ERROR'; + RAISE WARNING USING ERRCODE = 'undefined_table', MESSAGE = %s, DETAIL = %s; + END; + $$}, @msg) if $dbh->{pg_server_version} >= 90000; + return 1; +} + +sub _no_column_error { + return $DBI::state && $DBI::state eq '42703'; # undefined_column +} + +sub _in_expr { + my ($self, $vals) = @_; + return '= ANY(?)', $vals; +} + +sub _run { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->run( $self->psql, @_ ); + local $ENV{PGPASSWORD} = $pass; + return $sqitch->run( $self->psql, @_ ); +} + +sub _capture { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->capture( $self->psql, @_ ); + local $ENV{PGPASSWORD} = $pass; + return $sqitch->capture( $self->psql, @_ ); +} + +sub _probe { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->probe( $self->psql, @_ ); + local $ENV{PGPASSWORD} = $pass; + return $sqitch->probe( $self->psql, @_ ); +} + +sub _spool { + my $self = shift; + my $fh = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->spool( $fh, $self->psql, @_ ); + local $ENV{PGPASSWORD} = $pass; + return $sqitch->spool( $fh, $self->psql, @_ ); +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Engine::pg - Sqitch PostgreSQL Engine + +=head1 Synopsis + + my $pg = App::Sqitch::Engine->load( engine => 'pg' ); + +=head1 Description + +App::Sqitch::Engine::pg provides the PostgreSQL storage engine for Sqitch. It +supports PostgreSQL 8.4.0 and higher as well as Postgres-XC 1.2 and higher. + +=head1 Interface + +=head2 Instance Methods + +=head3 C + + $pg->initialize unless $pg->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C + + $pg->initialize; + +Initializes a database for Sqitch by installing the Sqitch registry schema. + +=head3 C + +Returns a list containing the C client and options to be passed to it. +Used internally when executing scripts. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Engine/pg.sql b/lib/App/Sqitch/Engine/pg.sql new file mode 100644 index 00000000..87a0375a --- /dev/null +++ b/lib/App/Sqitch/Engine/pg.sql @@ -0,0 +1,145 @@ +BEGIN; + +SET client_min_messages = warning; +CREATE SCHEMA IF NOT EXISTS :"registry"; + +COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.1.'; + +CREATE TABLE :"registry".releases ( + version REAL PRIMARY KEY, + installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + installer_name TEXT NOT NULL, + installer_email TEXT NOT NULL +); + +COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN :"registry".releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN :"registry".releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN :"registry".releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN :"registry".releases.installer_email IS 'Email address of the user who installed the registry release.'; + +CREATE TABLE :"registry".projects ( + project TEXT PRIMARY KEY, + uri TEXT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + creator_name TEXT NOT NULL, + creator_email TEXT NOT NULL +):tableopts; + +COMMENT ON TABLE :"registry".projects IS 'Sqitch projects deployed to this database.'; +COMMENT ON COLUMN :"registry".projects.project IS 'Unique Name of a project.'; +COMMENT ON COLUMN :"registry".projects.uri IS 'Optional project URI'; +COMMENT ON COLUMN :"registry".projects.created_at IS 'Date the project was added to the database.'; +COMMENT ON COLUMN :"registry".projects.creator_name IS 'Name of the user who added the project.'; +COMMENT ON COLUMN :"registry".projects.creator_email IS 'Email address of the user who added the project.'; + +CREATE TABLE :"registry".changes ( + change_id TEXT PRIMARY KEY, + script_hash TEXT NULL, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + UNIQUE(project, script_hash) +):tableopts; + +COMMENT ON TABLE :"registry".changes IS 'Tracks the changes currently deployed to the database.'; +COMMENT ON COLUMN :"registry".changes.change_id IS 'Change primary key.'; +COMMENT ON COLUMN :"registry".changes.script_hash IS 'Deploy script SHA-1 hash.'; +COMMENT ON COLUMN :"registry".changes.change IS 'Name of a deployed change.'; +COMMENT ON COLUMN :"registry".changes.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN :"registry".changes.note IS 'Description of the change.'; +COMMENT ON COLUMN :"registry".changes.committed_at IS 'Date the change was deployed.'; +COMMENT ON COLUMN :"registry".changes.committer_name IS 'Name of the user who deployed the change.'; +COMMENT ON COLUMN :"registry".changes.committer_email IS 'Email address of the user who deployed the change.'; +COMMENT ON COLUMN :"registry".changes.planned_at IS 'Date the change was added to the plan.'; +COMMENT ON COLUMN :"registry".changes.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN :"registry".changes.planner_email IS 'Email address of the user who planned the change.'; + +CREATE TABLE :"registry".tags ( + tag_id TEXT PRIMARY KEY, + tag TEXT NOT NULL, + project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE, + change_id TEXT NOT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + UNIQUE(project, tag) +):tableopts; + +COMMENT ON TABLE :"registry".tags IS 'Tracks the tags currently applied to the database.'; +COMMENT ON COLUMN :"registry".tags.tag_id IS 'Tag primary key.'; +COMMENT ON COLUMN :"registry".tags.tag IS 'Project-unique tag name.'; +COMMENT ON COLUMN :"registry".tags.project IS 'Name of the Sqitch project to which the tag belongs.'; +COMMENT ON COLUMN :"registry".tags.change_id IS 'ID of last change deployed before the tag was applied.'; +COMMENT ON COLUMN :"registry".tags.note IS 'Description of the tag.'; +COMMENT ON COLUMN :"registry".tags.committed_at IS 'Date the tag was applied to the database.'; +COMMENT ON COLUMN :"registry".tags.committer_name IS 'Name of the user who applied the tag.'; +COMMENT ON COLUMN :"registry".tags.committer_email IS 'Email address of the user who applied the tag.'; +COMMENT ON COLUMN :"registry".tags.planned_at IS 'Date the tag was added to the plan.'; +COMMENT ON COLUMN :"registry".tags.planner_name IS 'Name of the user who planed the tag.'; +COMMENT ON COLUMN :"registry".tags.planner_email IS 'Email address of the user who planned the tag.'; + +CREATE TABLE :"registry".dependencies ( + change_id TEXT NOT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE, + type TEXT NOT NULL, + dependency TEXT NOT NULL, + dependency_id TEXT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE CONSTRAINT dependencies_check CHECK ( + (type = 'require' AND dependency_id IS NOT NULL) + OR (type = 'conflict' AND dependency_id IS NULL) + ), + PRIMARY KEY (change_id, dependency) +):tableopts; + +COMMENT ON TABLE :"registry".dependencies IS 'Tracks the currently satisfied dependencies.'; +COMMENT ON COLUMN :"registry".dependencies.change_id IS 'ID of the depending change.'; +COMMENT ON COLUMN :"registry".dependencies.type IS 'Type of dependency.'; +COMMENT ON COLUMN :"registry".dependencies.dependency IS 'Dependency name.'; +COMMENT ON COLUMN :"registry".dependencies.dependency_id IS 'Change ID the dependency resolves to.'; + +CREATE TABLE :"registry".events ( + event TEXT NOT NULL CONSTRAINT events_event_check CHECK ( + event IN ('deploy', 'revert', 'fail', 'merge') + ), + change_id TEXT NOT NULL, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + requires TEXT[] NOT NULL DEFAULT '{}', + conflicts TEXT[] NOT NULL DEFAULT '{}', + tags TEXT[] NOT NULL DEFAULT '{}', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + PRIMARY KEY (change_id, committed_at) +):tableopts; + +COMMENT ON TABLE :"registry".events IS 'Contains full history of all deployment events.'; +COMMENT ON COLUMN :"registry".events.event IS 'Type of event.'; +COMMENT ON COLUMN :"registry".events.change_id IS 'Change ID.'; +COMMENT ON COLUMN :"registry".events.change IS 'Change name.'; +COMMENT ON COLUMN :"registry".events.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN :"registry".events.note IS 'Description of the change.'; +COMMENT ON COLUMN :"registry".events.requires IS 'Array of the names of required changes.'; +COMMENT ON COLUMN :"registry".events.conflicts IS 'Array of the names of conflicting changes.'; +COMMENT ON COLUMN :"registry".events.tags IS 'Tags associated with the change.'; +COMMENT ON COLUMN :"registry".events.committed_at IS 'Date the event was committed.'; +COMMENT ON COLUMN :"registry".events.committer_name IS 'Name of the user who committed the event.'; +COMMENT ON COLUMN :"registry".events.committer_email IS 'Email address of the user who committed the event.'; +COMMENT ON COLUMN :"registry".events.planned_at IS 'Date the event was added to the plan.'; +COMMENT ON COLUMN :"registry".events.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN :"registry".events.planner_email IS 'Email address of the user who plan planned the change.'; + +COMMIT; diff --git a/lib/App/Sqitch/Engine/snowflake.pm b/lib/App/Sqitch/Engine/snowflake.pm new file mode 100644 index 00000000..758423ea --- /dev/null +++ b/lib/App/Sqitch/Engine/snowflake.pm @@ -0,0 +1,724 @@ +package App::Sqitch::Engine::snowflake; + +use 5.010; +use Moo; +use utf8; +use Path::Class; +use DBI; +use Try::Tiny; +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::Types qw(DBH ArrayRef HashRef URIDB Str); + +extends 'App::Sqitch::Engine'; + +our $VERSION = 'v1.0.0'; # VERSION + +sub key { 'snowflake' } +sub name { 'Snowflake' } +sub driver { 'DBD::ODBC 1.59' } +sub default_client { 'snowsql' } + +sub destination { + my $self = shift; + # Just use the target name if it doesn't look like a URI. + return $self->target->name if $self->target->name !~ /:/; + + # Use the URI sans password. + my $uri = $self->target->uri->clone; + $uri->password(undef) if $uri->password; + return $uri->as_string; +} + +has _snowsql => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->uri; + my @ret = ( $self->client ); + for my $spec ( + [ accountname => $self->account ], + [ username => $self->username ], + [ dbname => $uri->dbname ], + [ rolename => $self->role ], + ) { + push @ret, "--$spec->[0]" => $spec->[1] if $spec->[1]; + } + + if (my %vars = $self->variables) { + push @ret => map {; '--variable', "$_=$vars{$_}" } sort keys %vars; + } + + push @ret => $self->_client_opts; + return \@ret; + }, +); + +sub snowsql { @{ shift->_snowsql } } + +has _snowcfg => ( + is => 'rw', + isa => HashRef, + lazy => 1, + default => sub { + my $hd = $^O eq 'MSWin32' && "$]" < '5.016' ? $ENV{HOME} || $ENV{USERPROFILE} : (glob('~'))[0]; + return {} if not $hd; + my $fn = dir $hd, '.snowsql', 'config'; + return {} unless -e $fn; + my $data = App::Sqitch::Config->new->load_file($fn); + my $cfg = {}; + for my $k (keys %{ $data }) { + # We only want the default connections config. No named config. + # (For now, anyway; maybe use database as config name laster?) + next unless $k =~ /\Aconnections[.]([^.]+)\z/; + my $key = $1; + my $val = $data->{$k}; + # Apparently snowsql config supports single quotes, while + # Config::GitLike does not. + # https://support.snowflake.net/s/case/5000Z000010xUYJQA2 + # https://docs.snowflake.net/manuals/user-guide/snowsql-config.html#snowsql-config-file + if ($val =~ s/\A'//) { + $val = $data->{$k} unless $val =~ s/'\z//; + } + $cfg->{$key} = $val; + } + return $cfg; + }, +); + +has uri => ( + is => 'ro', + isa => URIDB, + default => sub { + my $self = shift; + my $uri = $self->SUPER::uri; + + # Set defaults in the URI. + $uri->host($self->_host($uri)); + $uri->port($ENV{SNOWSQL_PORT}) if !$uri->_port && $ENV{SNOWSQL_PORT}; + $uri->dbname( + $ENV{SNOWSQL_DATABASE} + || $self->_snowcfg->{dbname} + || $self->username + ) if !$uri->dbname; + return $uri; + }, +); + +sub _def_user { + $ENV{SNOWSQL_USER} || $_[0]->_snowcfg->{username} || $_[0]->sqitch->sysuser +} + +sub _def_pass { $ENV{SNOWSQL_PWD} || shift->_snowcfg->{password} } +sub _def_acct { + return $ENV{SNOWSQL_ACCOUNT} || shift->_snowcfg->{accountname} + || hurl engine => __('Cannot determine Snowflake account name'); +} + +has account => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $self = shift; + if (my $host = $self->uri->host) { + # ..snowflakecomputing.com + $host =~ s/[.].+//; + return $host; + } + return $self->_def_acct; + }, +); + +sub _host { + my ($self, $uri) = @_; + if (my $host = $uri->host) { + # Allow host to just be account name or account + region. + return $host if $host =~ /\.snowflakecomputing\.com$/; + return $host . ".snowflakecomputing.com"; + } + return $ENV{SNOWSQL_HOST} if $ENV{SNOWSQL_HOST}; + return join '.', ( + $self->_def_acct, + (grep { $_ } $ENV{SNOWSQL_REGION} || $self->_snowcfg->{region} || ()), + 'snowflakecomputing.com', + ); +} + +has warehouse => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->uri; + require URI::QueryParam; + $uri->query_param('warehouse') + || $ENV{SNOWSQL_WAREHOUSE} + || $self->_snowcfg->{warehousename} + || 'sqitch'; + }, +); + +has role => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->uri; + require URI::QueryParam; + $uri->query_param('role') + || $ENV{SNOWSQL_ROLE} + || $self->_snowcfg->{rolename} + || ''; + }, +); + +has dbh => ( + is => 'rw', + isa => DBH, + lazy => 1, + default => sub { + my $self = shift; + $self->use_driver; + my $uri = $self->uri; + my $wh = $self->warehouse; + my $role = $self->role; + DBI->connect($uri->dbi_dsn, $self->username, $self->password, { + PrintError => 0, + RaiseError => 0, + AutoCommit => 1, + odbc_utf8_on => 1, + FetchHashKeyName => 'NAME_lc', + HandleError => sub { + my ($err, $dbh) = @_; + $@ = $err; + @_ = ($dbh->state || 'DEV' => $dbh->errstr); + goto &hurl; + }, + Callbacks => { + connected => sub { + my $dbh = shift; + try { + $dbh->do($_) for ( + ($role ? ("USE ROLE $role") : ()), + "ALTER WAREHOUSE $wh RESUME IF SUSPENDED", + "USE WAREHOUSE $wh", + 'USE SCHEMA ' . $self->registry, + 'ALTER SESSION SET TIMESTAMP_TYPE_MAPPING=TIMESTAMP_LTZ', + "ALTER SESSION SET TIMESTAMP_OUTPUT_FORMAT='YYYY-MM-DD HH24:MI:SS'", + "ALTER SESSION SET TIMEZONE='UTC'", + ); + $dbh->set_err(undef, undef) if $dbh->err; + }; + return; + }, + disconnect => sub { + shift->do("ALTER WAREHOUSE $wh SUSPEND"); + return; + }, + }, + }); + } +); + +# Need to wait until dbh is defined. +with 'App::Sqitch::Role::DBIEngine'; + +sub _client_opts { + return ( + '--noup', + '--option' => 'auto_completion=false', + '--option' => 'echo=false', + '--option' => 'execution_only=false', + '--option' => 'friendly=false', + '--option' => 'header=false', + '--option' => 'exit_on_error=true', + '--option' => 'stop_on_error=true', + '--option' => 'output_format=csv', + '--option' => 'paging=false', + '--option' => 'timing=false', + # results=false suppresses errors! Bug report: + # https://support.snowflake.net/s/case/5000Z000010wm6BQAQ/ + '--option' => 'results=true', + '--option' => 'wrap=false', + '--option' => 'rowset_size=1000', + '--option' => 'syntax_style=default', + '--option' => 'variable_substitution=true', + '--variable' => 'registry=' . $_[0]->registry, + '--variable' => 'warehouse=' . $_[0]->warehouse, + ); +} + +sub _quiet_opts { + return ( + '--option' => 'quiet=true', + ); +} + +sub _verbose_opts { + return ( + '--option' => 'quiet=false', + ); +} + +# Not using arrays, but delimited strings that are the default in +# App::Sqitch::Role::DBIEngine, because: +# * There is currently no literal syntax for arrays +# https://support.snowflake.net/s/case/5000Z000010wXBRQA2/ +# * Scalar variables like the array constructor can't be used in WHERE clauses +# https://support.snowflake.net/s/case/5000Z000010wX7yQAE/ +sub _listagg_format { + return q{listagg(%s, ' ')}; +} + +sub _ts_default { 'current_timestamp' } + +sub initialized { + my $self = shift; + return $self->dbh->selectcol_arrayref(q{ + SELECT true + FROM information_schema.tables + WHERE TABLE_CATALOG = current_database() + AND TABLE_SCHEMA = UPPER(?) + AND TABLE_NAME = UPPER(?) + }, undef, $self->registry, 'changes')->[0]; +} + +sub initialize { + my $self = shift; + my $schema = $self->registry; + hurl engine => __x( + 'Sqitch schema "{schema}" already exists', + schema => $schema + ) if $self->initialized; + + $self->run_file( file(__FILE__)->dir->file('snowflake.sql') ); + $self->dbh->do("USE SCHEMA $schema"); + $self->_register_release; +} + +sub _no_table_error { + return $DBI::state && $DBI::state eq '42S02'; # ERRCODE_UNDEFINED_TABLE +} + +sub _no_column_error { + return $DBI::state && $DBI::state eq '42703'; # ERRCODE_UNDEFINED_COLUMN +} + +sub _ts2char_format { + # The colon has to be inside the quotation marks, because otherwise it + # generates wayward single quotation marks. Bug report: + # https://support.snowflake.net/s/case/5000Z000010wTkKQAU/ + qq{to_varchar(CONVERT_TIMEZONE('UTC', %s), '"year:"YYYY":month:"MM":day:"DD":hour:"HH24":minute:"MI":second:"SS":time_zone:UTC"')}; +} + +sub _char2ts { $_[1]->as_string(format => 'iso') } + +sub _dt($) { + require App::Sqitch::DateTime; + return App::Sqitch::DateTime->new(split /:/ => shift); +} + +sub _regex_op { 'REGEXP' } # XXX But not used; see regex_expr() below. + +sub _simple_from { ' FROM dual' } + +sub _cid { + my ( $self, $ord, $offset, $project ) = @_; + + my $offset_expr = $offset ? " OFFSET $offset" : ''; + return try { + $self->dbh->selectcol_arrayref(qq{ + SELECT change_id + FROM changes + WHERE project = ? + ORDER BY committed_at $ord + LIMIT 1$offset_expr + }, undef, $project || $self->plan->project)->[0]; + } catch { + return if $self->_no_table_error && !$self->initialized; + die $_; + }; +} + +sub changes_requiring_change { + my ( $self, $change ) = @_; + # NOTE: Query from DBIEngine doesn't work in Snowflake: + # SQL compilation error: Unsupported subquery type cannot be evaluated (SQL-42601) + # Looks like it doesn't yet support correlated subqueries. + # https://docs.snowflake.net/manuals/sql-reference/operators-subquery.html + # The CTE-based query borrowed from Exasol seems to be fine, however. + return @{ $self->dbh->selectall_arrayref(q{ + WITH tag AS ( + SELECT tag, committed_at, project, + ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk + FROM tags + ) + SELECT c.change_id, c.project, c.change, t.tag AS asof_tag + FROM dependencies d + JOIN changes c ON c.change_id = d.change_id + LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at + WHERE d.dependency_id = ? + AND (t.rnk IS NULL OR t.rnk = 1) + }, { Slice => {} }, $change->id) }; +} + +sub name_for_change_id { + my ( $self, $change_id ) = @_; + # NOTE: Query from DBIEngine doesn't work in Snowflake: + # SQL compilation error: Unsupported subquery type cannot be evaluated (SQL-42601) + # Looks like it doesn't yet support correlated subqueries. + # https://docs.snowflake.net/manuals/sql-reference/operators-subquery.html + # The CTE-based query borrowed from Exasol seems to be fine, however. + return $self->dbh->selectcol_arrayref(q{ + WITH tag AS ( + SELECT tag, committed_at, project, + ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk + FROM tags + ) + SELECT change || COALESCE(t.tag, '@HEAD') + FROM changes c + LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at + WHERE change_id = ? + AND (t.rnk IS NULL OR t.rnk = 1) + }, undef, $change_id)->[0]; +} + +# https://support.snowflake.net/s/question/0D50Z00008BENO5SAP +sub _limit_default { '4611686018427387903' } + +sub _limit_offset { + # LIMIT/OFFSET don't support parameters, alas. So just put them in the query. + my ($self, $lim, $off) = @_; + # OFFSET cannot be used without LIMIT, sadly. + # https://support.snowflake.net/s/case/5000Z000010wfnWQAQ + return ['LIMIT ' . ($lim || $self->_limit_default), "OFFSET $off"], [] if $off; + return ["LIMIT $lim"], [] if $lim; + return [], []; +} + +sub _regex_expr { + my ( $self, $col, $regex ) = @_; + # Snowflake regular expressions are implicitly anchored to match the + # entire string. To work around this, issue, we use regexp_substr(), which + # is not so anchored, and just check to see that if it returns a string. + # https://support.snowflake.net/s/case/5000Z000010wbUSQAY + # https://support.snowflake.net/s/question/0D50Z00008C90beSAB/ + return "regexp_substr($col, ?) IS NOT NULL", $regex; +} + +sub run_file { + my ($self, $file) = @_; + $self->_run(_quiet_opts, '--filename' => $file); +} + +sub run_verify { + my ($self, $file) = @_; + # Suppress STDOUT unless we want extra verbosity. + return $self->run_file($file) unless $self->sqitch->verbosity > 1; + $self->_run(_verbose_opts, '--filename' => $file); +} + +sub run_handle { + my ($self, $fh) = @_; + $self->_spool($fh); +} + +sub _run { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or + # Use capture and emit instead of _run to avoid a wayward newline in + # the output. + return $sqitch->emit_literal( $sqitch->capture( $self->snowsql, @_ ) ); + # Does not override connection config, alas. + local $ENV{SNOWSQL_PWD} = $pass; + return $sqitch->emit_literal( $sqitch->capture( $self->snowsql, @_ ) ); +} + +sub _capture { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or + return $sqitch->capture( $self->snowsql, _verbose_opts, @_ ); + local $ENV{SNOWSQL_PWD} = $pass; + return $sqitch->capture( $self->snowsql, _verbose_opts, @_ ); +} + +sub _probe { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or + return $sqitch->probe( $self->snowsql, _verbose_opts, @_ ); + local $ENV{SNOWSQL_PWD} = $pass; + return $sqitch->probe( $self->snowsql, _verbose_opts, @_ ); +} + +sub _spool { + my $self = shift; + my $fh = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or + return $sqitch->spool( $fh, $self->snowsql, _verbose_opts, @_ ); + local $ENV{SNOWSQL_PWD} = $pass; + return $sqitch->spool( $fh, $self->snowsql, _verbose_opts, @_ ); +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Engine::snowflake - Sqitch Snowflake Engine + +=head1 Synopsis + + my $snowflake = App::Sqitch::Engine->load( engine => 'snowflake' ); + +=head1 Description + +App::Sqitch::Engine::snowflake provides the Snowflake storage engine for Sqitch. + +=head1 Interface + +=head2 Attributes + +=head3 C + +Returns the Snowflake database URI name. It starts with the URI for the target +and builds out missing parts. Sqitch looks for the host name in this order: + +=over + +=item 1 + +In the host name of the target URI. If that host name does not end in +C, Sqitch appends it. This lets Snowflake URLs just +reference the Snowflake account name or the account name and region in URLs. + +=item 2 + +In the C<$SNOWSQL_HOST> environment variable. + +=item 3 + +By concatenating the account name and region, if available, from the +C<$SNOWSQL_ACCOUNT> environment variable or C setting +in the +L, +the C<$SNOWSQL_REGION> or C setting in the +L, +and C. + +=back + +The port defaults to 443, but uses to the C<$SNOWSQL_PORT> environment +variable if it's set. The database name is determined by the following methods: + +=over + +=item 1. + +The path par t of the database URI. + +=item 2. + +The C<$SNOWSQL_DATABASE> environment variable. + +=item 3. + +In the C setting in the +L. + +=item 4. + +If sqitch finds no value in the above places, it falls back on the system +username. + +=back + +Other attributes of the URI are set from the C, C and +C attributes documented below. + +=head3 C + +Returns the Snowflake account name, or an exception if none can be determined. +Sqitch looks for the account code in this order: + +=over + +=item 1 + +In the host name of the target URI. + +=item 2 + +In the C<$SNOWSQL_ACCOUNT> environment variable. + +=item 3 + +In the C setting in the +L. + +=back + +=head3 username + +Returns the snowflake user name. Sqitch looks for the user name in this order: + +=over + +=item 1 + +In the C<$SQITCH_USERNAME> environment variable. + +=item 2 + +In the target URI. + +=item 3 + +In the C<$SNOWSQL_USER> environment variable. + +=item 4 + +In the C variable from the +L. + +=item 5 + +The system username. + +=back + +=head3 password + +Returns the snowflake password. Sqitch looks for the password in this order: + +=over + +=item 1 + +In the C<$SQITCH_PASSWORD> environment variable. + +=item 2 + +In the target URI. + +=item 3 + +In the C<$SNOWSQL_PWD> environment variable. + +=item 4 + +In the C variable from the +L. + +=back + +=head3 C + +Returns the warehouse to use for all connections. This value will be available +to all Snowflake change scripts as the C<&warehouse> variable. Sqitch looks +for the warehouse in this order: + +=over + +=item 1 + +In the C query parameter of the target URI + +=item 2 + +In the C<$SNOWSQL_WAREHOUSE> environment variable. + +=item 3 + +In the C variable from the +L. + +=item 4 + +If none of the above are found, it falls back on the hard-coded value +"sqitch". + +=back + +=head3 C + +Returns the role to use for all connections. Sqitch looks for the role in this +order: + +=over + +=item 1 + +In the C query parameter of the target URI + +=item 2 + +In the C<$SNOWSQL_ROLE> environment variable. + +=item 3 + +In the C variable from the +L. + +=item 4 + +If none of the above are found, no role will be set. + +=back + +=head2 Instance Methods + +=head3 C + + $snowflake->initialize unless $snowflake->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C + + $snowflake->initialize; + +Initializes a database for Sqitch by installing the Sqitch registry schema. + +=head3 C + +Returns a list containing the C client and options to be passed to +it. Used internally when executing scripts. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Engine/snowflake.sql b/lib/App/Sqitch/Engine/snowflake.sql new file mode 100644 index 00000000..93b244e2 --- /dev/null +++ b/lib/App/Sqitch/Engine/snowflake.sql @@ -0,0 +1,142 @@ +CREATE SCHEMA IF NOT EXISTS ®istry; + +COMMENT ON SCHEMA ®istry IS 'Sqitch database deployment metadata v1.1.'; + +CREATE TABLE ®istry.releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp, + installer_name TEXT NOT NULL, + installer_email TEXT NOT NULL +); + +COMMENT ON TABLE ®istry.releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN ®istry.releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN ®istry.releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN ®istry.releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN ®istry.releases.installer_email IS 'Email address of the user who installed the registry release.'; + +CREATE TABLE ®istry.projects ( + project TEXT PRIMARY KEY, + uri TEXT NULL UNIQUE, + created_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp, + creator_name TEXT NOT NULL, + creator_email TEXT NOT NULL +); + +COMMENT ON TABLE ®istry.projects IS 'Sqitch projects deployed to this database.'; +COMMENT ON COLUMN ®istry.projects.project IS 'Unique Name of a project.'; +COMMENT ON COLUMN ®istry.projects.uri IS 'Optional project URI'; +COMMENT ON COLUMN ®istry.projects.created_at IS 'Date the project was added to the database.'; +COMMENT ON COLUMN ®istry.projects.creator_name IS 'Name of the user who added the project.'; +COMMENT ON COLUMN ®istry.projects.creator_email IS 'Email address of the user who added the project.'; + +CREATE TABLE ®istry.changes ( + change_id TEXT PRIMARY KEY, + script_hash TEXT NULL, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES ®istry.projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMP_TZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + UNIQUE(project, script_hash) +); + +COMMENT ON TABLE ®istry.changes IS 'Tracks the changes currently deployed to the database.'; +COMMENT ON COLUMN ®istry.changes.change_id IS 'Change primary key.'; +COMMENT ON COLUMN ®istry.changes.script_hash IS 'Deploy script SHA-1 hash.'; +COMMENT ON COLUMN ®istry.changes.change IS 'Name of a deployed change.'; +COMMENT ON COLUMN ®istry.changes.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry.changes.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry.changes.committed_at IS 'Date the change was deployed.'; +COMMENT ON COLUMN ®istry.changes.committer_name IS 'Name of the user who deployed the change.'; +COMMENT ON COLUMN ®istry.changes.committer_email IS 'Email address of the user who deployed the change.'; +COMMENT ON COLUMN ®istry.changes.planned_at IS 'Date the change was added to the plan.'; +COMMENT ON COLUMN ®istry.changes.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry.changes.planner_email IS 'Email address of the user who planned the change.'; + +CREATE TABLE ®istry.tags ( + tag_id TEXT PRIMARY KEY, + tag TEXT NOT NULL, + project TEXT NOT NULL REFERENCES ®istry.projects(project) ON UPDATE CASCADE, + change_id TEXT NOT NULL REFERENCES ®istry.changes(change_id) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + UNIQUE(project, tag) +); + +COMMENT ON TABLE ®istry.tags IS 'Tracks the tags currently applied to the database.'; +COMMENT ON COLUMN ®istry.tags.tag_id IS 'Tag primary key.'; +COMMENT ON COLUMN ®istry.tags.tag IS 'Project-unique tag name.'; +COMMENT ON COLUMN ®istry.tags.project IS 'Name of the Sqitch project to which the tag belongs.'; +COMMENT ON COLUMN ®istry.tags.change_id IS 'ID of last change deployed before the tag was applied.'; +COMMENT ON COLUMN ®istry.tags.note IS 'Description of the tag.'; +COMMENT ON COLUMN ®istry.tags.committed_at IS 'Date the tag was applied to the database.'; +COMMENT ON COLUMN ®istry.tags.committer_name IS 'Name of the user who applied the tag.'; +COMMENT ON COLUMN ®istry.tags.committer_email IS 'Email address of the user who applied the tag.'; +COMMENT ON COLUMN ®istry.tags.planned_at IS 'Date the tag was added to the plan.'; +COMMENT ON COLUMN ®istry.tags.planner_name IS 'Name of the user who planed the tag.'; +COMMENT ON COLUMN ®istry.tags.planner_email IS 'Email address of the user who planned the tag.'; + +CREATE TABLE ®istry.dependencies ( + change_id TEXT NOT NULL REFERENCES ®istry.changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE, + type TEXT NOT NULL, + dependency TEXT NOT NULL, + dependency_id TEXT NULL REFERENCES ®istry.changes(change_id) ON UPDATE CASCADE, + -- CONSTRAINT dependencies_check CHECK ( + -- (type = 'require' AND dependency_id IS NOT NULL) + -- OR (type = 'conflict' AND dependency_id IS NULL) + -- ), + PRIMARY KEY (change_id, dependency) +); + +COMMENT ON TABLE ®istry.dependencies IS 'Tracks the currently satisfied dependencies.'; +COMMENT ON COLUMN ®istry.dependencies.change_id IS 'ID of the depending change.'; +COMMENT ON COLUMN ®istry.dependencies.type IS 'Type of dependency.'; +COMMENT ON COLUMN ®istry.dependencies.dependency IS 'Dependency name.'; +COMMENT ON COLUMN ®istry.dependencies.dependency_id IS 'Change ID the dependency resolves to.'; + +CREATE TABLE ®istry.events ( + event TEXT NOT NULL, + -- CONSTRAINT events_event_check CHECK ( + -- event IN ('deploy', 'revert', 'fail', 'merge') + -- ), + change_id TEXT NOT NULL, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES ®istry.projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + requires TEXT NOT NULL DEFAULT '', + conflicts TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + PRIMARY KEY (change_id, committed_at) +); + +COMMENT ON TABLE ®istry.events IS 'Contains full history of all deployment events.'; +COMMENT ON COLUMN ®istry.events.event IS 'Type of event.'; +COMMENT ON COLUMN ®istry.events.change_id IS 'Change ID.'; +COMMENT ON COLUMN ®istry.events.change IS 'Change name.'; +COMMENT ON COLUMN ®istry.events.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry.events.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry.events.requires IS 'Array of the names of required changes.'; +COMMENT ON COLUMN ®istry.events.conflicts IS 'Array of the names of conflicting changes.'; +COMMENT ON COLUMN ®istry.events.tags IS 'Tags associated with the change.'; +COMMENT ON COLUMN ®istry.events.committed_at IS 'Date the event was committed.'; +COMMENT ON COLUMN ®istry.events.committer_name IS 'Name of the user who committed the event.'; +COMMENT ON COLUMN ®istry.events.committer_email IS 'Email address of the user who committed the event.'; +COMMENT ON COLUMN ®istry.events.planned_at IS 'Date the event was added to the plan.'; +COMMENT ON COLUMN ®istry.events.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry.events.planner_email IS 'Email address of the user who plan planned the change.'; diff --git a/lib/App/Sqitch/Engine/sqlite.pm b/lib/App/Sqitch/Engine/sqlite.pm new file mode 100644 index 00000000..dc86276d --- /dev/null +++ b/lib/App/Sqitch/Engine/sqlite.pm @@ -0,0 +1,305 @@ +package App::Sqitch::Engine::sqlite; + +use 5.010; +use strict; +use warnings; +use utf8; +use Try::Tiny; +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::Plan::Change; +use Path::Class; +use Moo; +use App::Sqitch::Types qw(URIDB DBH ArrayRef); +use namespace::autoclean; + +extends 'App::Sqitch::Engine'; + +our $VERSION = 'v1.0.0'; # VERSION + +has registry_uri => ( + is => 'ro', + isa => URIDB, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->uri->clone; + my $reg = $self->registry; + + if ( file($reg)->is_absolute ) { + # Just use an absolute path. + $uri->dbname($reg); + } elsif (my @segs = $uri->path_segments) { + # Use the same name, but replace $name.$ext with $reg.$ext. + my $bn = file( $segs[-1] )->basename; + if ($reg =~ /[.]/ || $bn !~ /[.]/) { + $segs[-1] =~ s/\Q$bn\E$/$reg/; + } else { + my ($b, $e) = split /[.]/, $bn, 2; + $segs[-1] =~ s/\Q$b\E[.]$e$/$reg.$e/; + } + $uri->path_segments(@segs); + } else { + # No known path, so no name. + $uri->dbname(undef); + } + + return $uri; + }, +); + +sub registry_destination { + my $uri = shift->registry_uri; + if ($uri->password) { + $uri = $uri->clone; + $uri->password(undef); + } + return $uri->as_string; +} + +sub key { 'sqlite' } +sub name { 'SQLite' } +sub driver { 'DBD::SQLite 1.37' } +sub default_client { 'sqlite3' } + +has dbh => ( + is => 'rw', + isa => DBH, + lazy => 1, + default => sub { + my $self = shift; + $self->use_driver; + + my $uri = $self->registry_uri; + my $dbh = DBI->connect($uri->dbi_dsn, '', '', { + PrintError => 0, + RaiseError => 0, + AutoCommit => 1, + sqlite_unicode => 1, + sqlite_use_immediate_transaction => 1, + HandleError => sub { + my ($err, $dbh) = @_; + $@ = $err; + @_ = ($dbh->state || 'DEV' => $dbh->errstr); + goto &hurl; + }, + Callbacks => { + connected => sub { + my $dbh = shift; + $dbh->do('PRAGMA foreign_keys = ON'); + return; + }, + }, + }); + + # Make sure we support this version. + my @v = split /[.]/ => $dbh->{sqlite_version}; + hurl sqlite => __x( + 'Sqitch requires SQLite 3.7.11 or later; DBD::SQLite was built with {version}', + version => $dbh->{sqlite_version} + ) unless $v[0] > 3 || ($v[0] == 3 && ($v[1] > 7 || ($v[1] == 7 && $v[2] >= 11))); + + return $dbh; + } +); + +# Need to wait until dbh is defined. +with 'App::Sqitch::Role::DBIEngine'; + +has _sqlite3 => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + default => sub { + my $self = shift; + + # Make sure we can use this version of SQLite. + my @v = split /[.]/ => ( + split / / => scalar $self->sqitch->capture( $self->client, '-version' ) + )[0]; + hurl sqlite => __x( + 'Sqitch requires SQLite 3.3.9 or later; {client} is {version}', + client => $self->client, + version => join( '.', @v) + ) unless $v[0] > 3 || ($v[0] == 3 && ($v[1] > 3 || ($v[1] == 3 && $v[2] >= 9))); + + my $dbname = $self->uri->dbname or hurl sqlite => __x( + 'Database name missing in URI {uri}', + uri => $self->uri, + ); + + return [ + $self->client, + '-noheader', + '-bail', + '-batch', + '-csv', # or -column or -line? + $dbname, + ]; + }, +); + +sub sqlite3 { @{ shift->_sqlite3 } } + +sub _version_query { 'SELECT CAST(ROUND(MAX(version), 1) AS TEXT) FROM releases' } + +sub initialized { + my $self = shift; + return $self->dbh->selectcol_arrayref(q{ + SELECT EXISTS( + SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? + ) + }, undef, 'changes')->[0]; +} + +sub initialize { + my $self = shift; + hurl engine => __x( + 'Sqitch database {database} already initialized', + database => $self->registry_uri->dbname, + ) if $self->initialized; + + # Load up our database. + my @cmd = $self->sqlite3; + $cmd[-1] = $self->registry_uri->dbname; + my $file = file(__FILE__)->dir->file('sqlite.sql'); + $self->sqitch->run( @cmd, $self->_read($file) ); + $self->_register_release; +} + +sub _no_table_error { + return $DBI::errstr && $DBI::errstr =~ /^\Qno such table:/; +} + +sub _no_column_error { + return try { $_->message =~ /^\Qno such column:/ }; +} + +sub _regex_op { 'REGEXP' } + +sub _limit_default { -1 } + +sub _ts_default { + q{strftime('%Y-%m-%d %H:%M:%f')}; +} + +sub _ts2char_format { + return q{strftime('year:%%Y:month:%%m:day:%%d:hour:%%H:minute:%%M:second:%%S:time_zone:UTC', %s)}; +} + +sub _listagg_format { + return q{group_concat(%s, ' ')}; +} + +sub _char2ts { + my $dt = $_[1]; + $dt->set_time_zone('UTC'); + return join ' ', $dt->ymd('-'), $dt->hms(':'); +} + +sub _run { + my $self = shift; + return $self->sqitch->run( $self->sqlite3, @_ ); +} + +sub _capture { + my $self = shift; + return $self->sqitch->capture( $self->sqlite3, @_ ); +} + +sub _spool { + my $self = shift; + my $fh = shift; + return $self->sqitch->spool( $fh, $self->sqlite3, @_ ); +} + +sub run_file { + my ($self, $file) = @_; + $self->_run( $self->_read($file) ); +} + +sub run_verify { + my ($self, $file) = @_; + # Suppress STDOUT unless we want extra verbosity. + my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture'); + $self->$meth( $self->_read($file) ); +} + +sub run_handle { + my ($self, $fh) = @_; + $self->_spool($fh); +} + +sub run_upgrade { + my ($self, $file) = @_; + my @cmd = $self->sqlite3; + $cmd[-1] = $self->registry_uri->dbname; + return $self->sqitch->run( @cmd, $self->_read($file) ); +} + +sub _read { + my $self = shift; + return '.read ' . $self->dbh->quote(shift); +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Engine::sqlite - Sqitch SQLite Engine + +=head1 Synopsis + + my $sqlite = App::Sqitch::Engine->load( engine => 'sqlite' ); + +=head1 Description + +App::Sqitch::Engine::sqlite provides the SQLite storage engine for Sqitch. + +=head1 Interface + +=head2 Accessors + +=head3 C + +Returns the path to the SQLite client. If C<--client> was passed to C, +that's what will be returned. Otherwise, it uses the C +configuration value, or else defaults to C (or C on +Windows), which should work if it's in your path. + +=head2 Instance Methods + +=head3 C + +Returns a list containing the C client and options to be passed to it. +Used internally when executing scripts. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Engine/sqlite.sql b/lib/App/Sqitch/Engine/sqlite.sql new file mode 100644 index 00000000..fe35605e --- /dev/null +++ b/lib/App/Sqitch/Engine/sqlite.sql @@ -0,0 +1,80 @@ +BEGIN; + +CREATE TABLE releases ( + version FLOAT PRIMARY KEY, + installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + installer_name TEXT NOT NULL, + installer_email TEXT NOT NULL +); + +CREATE TABLE projects ( + project TEXT PRIMARY KEY, + uri TEXT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + creator_name TEXT NOT NULL, + creator_email TEXT NOT NULL +); + +CREATE TABLE changes ( + change_id TEXT PRIMARY KEY, + script_hash TEXT NULL, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at DATETIME NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + UNIQUE(project, script_hash) +); + +CREATE TABLE tags ( + tag_id TEXT PRIMARY KEY, + tag TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE, + change_id TEXT NOT NULL REFERENCES changes(change_id) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at DATETIME NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + UNIQUE(project, tag) +); + +CREATE TABLE dependencies ( + change_id TEXT NOT NULL REFERENCES changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE, + type TEXT NOT NULL, + dependency TEXT NOT NULL, + dependency_id TEXT NULL REFERENCES changes(change_id) ON UPDATE CASCADE + CONSTRAINT dependencies_check CHECK ( + (type = 'require' AND dependency_id IS NOT NULL) + OR (type = 'conflict' AND dependency_id IS NULL) + ), + PRIMARY KEY (change_id, dependency) +); + +CREATE TABLE events ( + event TEXT NOT NULL CONSTRAINT events_event_check CHECK ( + event IN ('deploy', 'revert', 'fail', 'merge') + ), + change_id TEXT NOT NULL, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + requires TEXT NOT NULL DEFAULT '', + conflicts TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '', + committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at DATETIME NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + PRIMARY KEY (change_id, committed_at) +); + +COMMIT; diff --git a/lib/App/Sqitch/Engine/vertica.pm b/lib/App/Sqitch/Engine/vertica.pm new file mode 100644 index 00000000..bb42e29c --- /dev/null +++ b/lib/App/Sqitch/Engine/vertica.pm @@ -0,0 +1,585 @@ +package App::Sqitch::Engine::vertica; + +use 5.010; +use Moo; +use utf8; +use Path::Class; +use DBI; +use Try::Tiny; +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::Types qw(DBH ArrayRef); + +extends 'App::Sqitch::Engine'; + +our $VERSION = 'v1.0.0'; # VERSION + +sub key { 'vertica' } +sub name { 'Vertica' } +sub driver { 'DBD::ODBC 1.59' } +sub default_client { 'vsql' } + +sub destination { + my $self = shift; + + # Just use the target name if it doesn't look like a URI or if the URI + # includes the database name. + return $self->target->name if $self->target->name !~ /:/ + || $self->target->uri->dbname; + + # Use the URI sans password, and with the database name added. + my $uri = $self->target->uri->clone; + $uri->password(undef) if $uri->password; + $uri->dbname( $ENV{VSQL_DATABASE} || $self->username ); + return $uri->as_string; +} + + +sub _def_user { $ENV{VSQL_USER} || shift->sqitch->sysuser } +sub _def_pass { $ENV{VSQL_PASSWORD} } + +has _vsql => ( + is => 'ro', + isa => ArrayRef, + lazy => 1, + default => sub { + my $self = shift; + my $uri = $self->uri; + my @ret = ( $self->client ); + for my $spec ( + [ username => $self->username ], + [ dbname => $uri->dbname ], + [ host => $uri->host ], + [ port => $uri->_port ], + ) { + push @ret, "--$spec->[0]" => $spec->[1] if $spec->[1]; + } + + if (my %vars = $self->variables) { + push @ret => map {; '--set', "$_=$vars{$_}" } sort keys %vars; + } + + push @ret => $self->_client_opts; + return \@ret; + }, +); + +sub vsql { @{ shift->_vsql } } + +has dbh => ( + is => 'rw', + isa => DBH, + lazy => 1, + default => sub { + my $self = shift; + $self->use_driver; + + # Set defaults in the URI. + my $target = $self->target; + my $uri = $self->uri; + # https://my.vertica.com/docs/5.1.6/HTML/index.htm#2736.htm + $uri->dbname($ENV{VSQL_DATABASE}) if !$uri->dbname && $ENV{VSQL_DATABASE}; + $uri->host($ENV{VSQL_HOST}) if !$uri->host && $ENV{VSQL_HOST}; + $uri->port($ENV{VSQL_PORT}) if !$uri->_port && $ENV{VSQL_PORT}; + + DBI->connect($uri->dbi_dsn, $self->username, $self->password, { + PrintError => 0, + RaiseError => 0, + AutoCommit => 1, + odbc_utf8_on => 1, + HandleError => sub { + my ($err, $dbh) = @_; + $@ = $err; + @_ = ($dbh->state || 'DEV' => $dbh->errstr); + goto &hurl; + }, + Callbacks => { + connected => sub { + my $dbh = shift; + try { + $dbh->do( + 'SET search_path = ' . $dbh->quote($self->registry) + ); + # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html + $dbh->set_err(undef, undef) if $dbh->err; + }; + return; + }, + }, + }); + } +); + +sub _listagg_format { undef } # Vertica has none! + +# Need to wait until dbh is defined. +with 'App::Sqitch::Role::DBIEngine'; + +sub _client_opts { + return ( + '--quiet', + '--no-vsqlrc', + '--no-align', + '--tuples-only', + '--set' => 'ON_ERROR_STOP=1', + '--set' => 'registry=' . shift->registry, + ); +} + +sub initialized { + my $self = shift; + return $self->dbh->selectcol_arrayref(q{ + SELECT EXISTS( + SELECT TRUE FROM v_catalog.schemata WHERE schema_name = ? + ) + }, undef, $self->registry)->[0]; +} + +sub initialize { + my $self = shift; + my $schema = $self->registry; + hurl engine => __x( + 'Sqitch schema "{schema}" already exists', + schema => $schema + ) if $self->initialized; + + $self->_run_registry_file( file(__FILE__)->dir->file('vertica.sql') ); + $self->dbh->do('SET search_path = ' . $self->dbh->quote($schema)); + $self->_register_release; +} + +sub run_upgrade { + shift->_run_registry_file(@_); +} + +sub _run_registry_file { + my ($self, $file) = @_; + + # Check the database version. + my $vline = $self->dbh->selectcol_arrayref('SELECT version()')->[0]; + my ($maj) = $vline =~ /\bv?(\d+)/; + + # Need to write a temp file; no :"registry" variable syntax. + my ($schema) = $self->dbh->selectrow_array( + 'SELECT quote_ident(?)', undef, $self->registry + ); + (my $sql = scalar $file->slurp) =~ s{:"registry"}{$schema}g; + + # No LONG VARCHAR before Vertica 7. + $sql =~ s/LONG //g if $maj < 7; + + # Write out the temporary file. + require File::Temp; + my $fh = File::Temp->new; + print $fh $sql; + close $fh; + + # Now we can execute the file. + $self->_run_with_verbosity( $fh->filename ); +} + +sub _no_table_error { + return $DBI::state && $DBI::state eq '42V01'; # ERRCODE_UNDEFINED_TABLE +} + +sub _no_column_error { + return $DBI::state && $DBI::state eq '42703'; # ERRCODE_UNDEFINED_COLUMN +} + +sub _dt($) { + require App::Sqitch::DateTime; + return App::Sqitch::DateTime->new(split /:/ => shift); +} + +sub _multi_values { + my ($self, $count, $expr) = @_; + return join "\nUNION ALL ", ("SELECT $expr") x $count; +} + +sub _dependency_placeholders { + return 'CAST(? AS CHAR(40)), CAST(? AS VARCHAR), CAST(? AS VARCHAR), CAST(? AS CHAR(40))'; +} + +sub _tag_placeholders { + my $self = shift; + return join(', ', + 'CAST(? AS CHAR(40))', + 'CAST(? AS VARCHAR)', + 'CAST(? AS VARCHAR)', + 'CAST(? AS CHAR(40))', + 'CAST(? AS VARCHAR)', + 'CAST(? AS VARCHAR)', + 'CAST(? AS VARCHAR)', + 'CAST(? AS TIMESTAMPTZ)', + 'CAST(? AS VARCHAR)', + 'CAST(? AS VARCHAR)', + $self->_ts_default, + ); +} + +sub _tag_subselect_columns { + my $self = shift; + return join(', ', + 'CAST(? AS CHAR(40)) AS tid', + 'CAST(? AS VARCHAR) AS tname', + 'CAST(? AS VARCHAR) AS proj', + 'CAST(? AS CHAR(40)) AS cid', + 'CAST(? AS VARCHAR) AS note', + 'CAST(? AS VARCHAR) AS cuser', + 'CAST(? AS VARCHAR) AS cemail', + 'CAST(? AS TIMESTAMPTZ) AS tts', + 'CAST(? AS VARCHAR) AS puser', + 'CAST(? AS VARCHAR) AS pemail', + $self->_ts_default, + ); +} + +sub _select_state { + my ( $self, $project, $with_hash ) = @_; + my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at'; + my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $hshcol = $with_hash ? "c.script_hash\n , " : ''; + return $self->dbh->selectrow_hashref(qq{ + SELECT c.change_id + , ${hshcol}c.change + , c.project + , c.note + , c.committer_name + , c.committer_email + , $cdtcol AS committed_at + , c.planner_name + , c.planner_email + , $pdtcol AS planned_at + FROM changes c + WHERE c.project = ? + ORDER BY c.committed_at DESC + LIMIT 1 + }, undef, $project // $self->plan->project ); +} + +sub current_state { + my ( $self, $project ) = @_; + my $dbh = $self->dbh; + my $state = try { + $self->_select_state($project, 1) + } catch { + return if $self->_no_table_error && !$self->initialized; + return $self->_select_state($project, 0) if $self->_no_column_error; + die $_; + } or return undef; + + $state->{tags} = $dbh->selectcol_arrayref( + 'SELECT tag FROM tags WHERE change_id = ? ORDER BY committed_at', + undef, $state->{change_id} + ); + $state->{committed_at} = _dt $state->{committed_at}; + $state->{planned_at} = _dt $state->{planned_at}; + return $state; +} + +sub _deployed_changes { + my ($self, $sql, @params) = @_; + my $sth = $self->dbh->prepare($sql); + $sth->execute(@params); + + my ($last_id, @changes) = (''); + while (my $res = $sth->fetchrow_hashref) { + if ($res->{id} eq $last_id) { + push @{ $changes[-1]->{tags} } => $res->{tag}; + } else { + $last_id = $res->{id}; + $res->{tags} = [ delete $res->{tag} || () ]; + $res->{timestamp} = _dt $res->{timestamp}; + push @changes => $res; + } + } + return @changes; +} + +sub deployed_changes { + my $self = shift; + my $tscol = sprintf $self->_ts2char_format, 'c.planned_at'; + return $self->_deployed_changes(qq{ + SELECT c.change_id AS id, c.change AS name, c.project, c.note, + $tscol AS "timestamp", c.planner_name, c.planner_email, + t.tag AS tag + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + ORDER BY c.committed_at ASC + }, $self->plan->project); +} + +sub deployed_changes_since { + my ( $self, $change ) = @_; + my $tscol = sprintf $self->_ts2char_format, 'c.planned_at'; + $self->_deployed_changes(qq{ + SELECT c.change_id AS id, c.change AS name, c.project, c.note, + $tscol AS "timestamp", c.planner_name, c.planner_email, + t.tag AS tag + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + AND c.committed_at > (SELECT committed_at FROM changes WHERE change_id = ?) + ORDER BY c.committed_at ASC + }, $self->plan->project, $change->id); +} + +sub load_change { + my ( $self, $change_id ) = @_; + my $tscol = sprintf $self->_ts2char_format, 'c.planned_at'; + my @res = $self->_deployed_changes(qq{ + SELECT c.change_id AS id, c.change AS name, c.project, c.note, + $tscol AS "timestamp", c.planner_name, c.planner_email, + t.tag AS tag + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.change_id = ? + }, $change_id); + return $res[0]; +} + +sub _offset_op { + my ( $self, $offset ) = @_; + my ( $dir, $op ) = $offset > 0 ? ( 'ASC', '>' ) : ( 'DESC' , '<' ); + return $dir, $op, 'OFFSET ' . (abs($offset) - 1); +} + +sub change_id_offset_from_id { + my ( $self, $change_id, $offset ) = @_; + + # Just return the ID if there is no offset. + return $change_id unless $offset; + + # Are we offset forwards or backwards? + my ($dir, $op, $offset_expr) = $self->_offset_op($offset); + return $self->dbh->selectcol_arrayref(qq{ + SELECT change_id + FROM changes + WHERE project = ? + AND committed_at $op ( + SELECT committed_at FROM changes WHERE change_id = ? + ) + ORDER BY committed_at $dir + LIMIT 1 $offset_expr + }, undef, $self->plan->project, $change_id)->[0]; +} + +sub change_offset_from_id { + my ( $self, $change_id, $offset ) = @_; + + # Just return the object if there is no offset. + return $self->load_change($change_id) unless $offset; + + # Are we offset forwards or backwards? + my ($dir, $op, $offset_expr) = $self->_offset_op($offset); + my $tscol = sprintf $self->_ts2char_format, 'c.planned_at'; + + my @res = $self->_deployed_changes(qq{ + SELECT c.change_id AS id, c.change AS name, c.project, c.note, + $tscol AS "timestamp", c.planner_name, c.planner_email, + t.tag AS tag + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + AND c.committed_at $op ( + SELECT committed_at FROM changes WHERE change_id = ? + ) + ORDER BY c.committed_at $dir + $offset_expr + }, $self->plan->project, $change_id); + return $res[0]; +} + +sub _ts2char_format { + q{to_char(%s AT TIME ZONE 'UTC', '"year":YYYY:"month":MM:"day":DD:"hour":HH24:"minute":MI:"second":SS:"time_zone":"UTC"')}; +} + +sub _ts_default { 'clock_timestamp()' } + +sub _char2ts { $_[1]->as_string(format => 'iso') } + +sub _regex_op { '~' } + +# Override to lock the changes table. This ensures that only one instance of +# Sqitch runs at one time. +sub begin_work { + my $self = shift; + my $dbh = $self->dbh; + + # Start transaction and lock changes to allow only one change at a time. + $dbh->begin_work; + $dbh->do('LOCK TABLE changes IN EXCLUSIVE MODE'); + return $self; +} + +sub run_file { + my ($self, $file) = @_; + $self->_run('--file' => $file); +} + +sub run_verify { shift->_run_with_verbosity(@_) } + +sub _run_with_verbosity { + my $self = shift; + my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture'); + return $self->$meth('--file' => @_); +} + +sub run_handle { + my ($self, $fh) = @_; + $self->_spool($fh); +} + +sub _cid { + my ( $self, $ord, $offset, $project ) = @_; + + my $offexpr = $offset ? " OFFSET $offset" : ''; + return try { + return $self->dbh->selectcol_arrayref(qq{ + SELECT change_id + FROM changes + WHERE project = ? + ORDER BY committed_at $ord + LIMIT 1$offexpr + }, undef, $project || $self->plan->project)->[0]; + } catch { + return if $self->_no_table_error && !$self->initialized; + die $_; + }; +} + +sub changes_requiring_change { + my ( $self, $change ) = @_; + # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221 + return @{ $self->dbh->selectall_arrayref(q{ + WITH tag AS ( + SELECT tag, committed_at, project, + ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk + FROM tags + ) + SELECT c.change_id, c.project, c.change, t.tag AS asof_tag + FROM dependencies d + JOIN changes c ON c.change_id = d.change_id + LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at + WHERE d.dependency_id = ? + AND (t.rnk IS NULL OR t.rnk = 1) + }, { Slice => {} }, $change->id) }; +} + +sub name_for_change_id { + my ( $self, $change_id ) = @_; + # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221 + return $self->dbh->selectcol_arrayref(q{ + WITH tag AS ( + SELECT tag, committed_at, project, + ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk + FROM tags + ) + SELECT change || COALESCE(t.tag, '@HEAD') + FROM changes c + LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at + WHERE change_id = ? + AND (t.rnk IS NULL OR t.rnk = 1) + }, undef, $change_id)->[0]; +} + +sub _run { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->run( $self->vsql, @_ ); + local $ENV{VSQL_PASSWORD} = $pass; + return $sqitch->run( $self->vsql, @_ ); +} + +sub _capture { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->capture( $self->vsql, @_ ); + local $ENV{VSQL_PASSWORD} = $pass; + return $sqitch->capture( $self->vsql, @_ ); +} + +sub _probe { + my $self = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->probe( $self->vsql, @_ ); + local $ENV{VSQL_PASSWORD} = $pass; + return $sqitch->probe( $self->vsql, @_ ); +} + +sub _spool { + my $self = shift; + my $fh = shift; + my $sqitch = $self->sqitch; + my $pass = $self->password or return $sqitch->spool( $fh, $self->vsql, @_ ); + local $ENV{VSQL_PASSWORD} = $pass; + return $sqitch->spool( $fh, $self->vsql, @_ ); +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Engine::vertica - Sqitch Vertica Engine + +=head1 Synopsis + + my $vertica = App::Sqitch::Engine->load( engine => 'vertica' ); + +=head1 Description + +App::Sqitch::Engine::vertica provides the Vertica storage engine for Sqitch. +It supports Vertica 6. + +=head1 Interface + +=head2 Instance Methods + +=head3 C + + $vertica->initialize unless $vertica->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C + + $vertica->initialize; + +Initializes a database for Sqitch by installing the Sqitch registry schema. + +=head3 C + +Returns a list containing the C client and options to be passed to it. +Used internally when executing scripts. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Engine/vertica.sql b/lib/App/Sqitch/Engine/vertica.sql new file mode 100644 index 00000000..db5f111d --- /dev/null +++ b/lib/App/Sqitch/Engine/vertica.sql @@ -0,0 +1,85 @@ +CREATE SCHEMA :"registry"; + +COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.1.'; + +CREATE TABLE :"registry".releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + installer_name VARCHAR(1024) NOT NULL, + installer_email VARCHAR(1024) NOT NULL +); + +COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.'; + +CREATE TABLE :"registry".projects ( + project VARCHAR(1024) PRIMARY KEY ENCODING AUTO, + uri VARCHAR(1024) NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + creator_name VARCHAR(1024) NOT NULL, + creator_email VARCHAR(1024) NOT NULL +); + +COMMENT ON TABLE :"registry".projects IS 'Sqitch projects deployed to this database.'; + +CREATE TABLE :"registry".changes ( + change_id CHAR(40) PRIMARY KEY ENCODING AUTO, + script_hash CHAR(40) NULL UNIQUE, + change VARCHAR(1024) NOT NULL, + project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project), + note VARCHAR(65000) NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name VARCHAR(1024) NOT NULL, + committer_email VARCHAR(1024) NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name VARCHAR(1024) NOT NULL, + planner_email VARCHAR(1024) NOT NULL +); + +COMMENT ON TABLE :"registry".changes IS 'Tracks the changes currently deployed to the database.'; + +CREATE TABLE :"registry".tags ( + tag_id CHAR(40) PRIMARY KEY ENCODING AUTO, + tag VARCHAR(1024) NOT NULL, + project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project), + change_id CHAR(40) NOT NULL REFERENCES :"registry".changes(change_id), + note VARCHAR(65000) NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name VARCHAR(1024) NOT NULL, + committer_email VARCHAR(1024) NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name VARCHAR(1024) NOT NULL, + planner_email VARCHAR(1024) NOT NULL, + UNIQUE(project, tag) +); + +COMMENT ON TABLE :"registry".tags IS 'Tracks the tags currently applied to the database.'; + +CREATE TABLE :"registry".dependencies ( + change_id CHAR(40) NOT NULL REFERENCES :"registry".changes(change_id), + type VARCHAR(8) NOT NULL ENCODING AUTO, + dependency VARCHAR(2048) NOT NULL, + dependency_id CHAR(40) NULL REFERENCES :"registry".changes(change_id), + PRIMARY KEY (change_id, dependency) +); + +COMMENT ON TABLE :"registry".dependencies IS 'Tracks the currently satisfied dependencies.'; + +CREATE TABLE :"registry".events ( + event VARCHAR(6) NOT NULL ENCODING AUTO, + change_id CHAR(40) NOT NULL, + change VARCHAR(1024) NOT NULL, + project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project), + note VARCHAR(65000) NOT NULL DEFAULT '', + requires LONG VARCHAR NOT NULL DEFAULT '{}', + conflicts LONG VARCHAR NOT NULL DEFAULT '{}', + tags LONG VARCHAR NOT NULL DEFAULT '{}', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name VARCHAR(1024) NOT NULL, + committer_email VARCHAR(1024) NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name VARCHAR(1024) NOT NULL, + planner_email VARCHAR(1024) NOT NULL, + PRIMARY KEY (change_id, committed_at) +); + +COMMENT ON TABLE :"registry".events IS 'Contains full history of all deployment events.'; diff --git a/lib/App/Sqitch/ItemFormatter.pm b/lib/App/Sqitch/ItemFormatter.pm new file mode 100644 index 00000000..f96c21ba --- /dev/null +++ b/lib/App/Sqitch/ItemFormatter.pm @@ -0,0 +1,607 @@ +package App::Sqitch::ItemFormatter; + +use 5.010; +use strict; +use warnings; +use utf8; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use List::Util qw(max); +use Moo; +use Types::Standard qw(Str Int); +use Type::Utils qw(enum class_type); +use String::Formatter; +use namespace::autoclean; +use Try::Tiny; +use Term::ANSIColor 2.02 qw(colorvalid); +my $encolor = \&Term::ANSIColor::color; + +use constant CAN_OUTPUT_COLOR => $^O eq 'MSWin32' + ? try { require Win32::Console::ANSI } + : -t *STDOUT; + +BEGIN { + $ENV{ANSI_COLORS_DISABLED} = 1 unless CAN_OUTPUT_COLOR; +} + +our $VERSION = 'v1.0.0'; # VERSION + +has abbrev => ( + is => 'ro', + isa => Int, + default => 0, +); + +has date_format => ( + is => 'ro', + isa => Str, + default => 'iso', +); + +has color => ( + is => 'ro', + isa => enum([ qw(always never auto) ]), + default => 'auto', +); + +has formatter => ( + is => 'ro', + lazy => 1, + isa => class_type('String::Formatter'), + default => sub { + my $self = shift; + no if $] >= 5.017011, warnings => 'experimental::smartmatch'; + String::Formatter->new({ + input_processor => 'require_single_input', + string_replacer => 'method_replace', + codes => { + e => sub { $_[0]->{event} }, + L => sub { + given ($_[0]->{event}) { + when ('deploy') { return __ 'Deploy' } + when ('revert') { return __ 'Revert' } + when ('fail') { return __ 'Fail' } + } + }, + l => sub { + given ($_[0]->{event}) { + when ('deploy') { return __ 'deploy' } + when ('revert') { return __ 'revert' } + when ('fail') { return __ 'fail' } + } + }, + _ => sub { + given ($_[1]) { + when ('event') { return __ 'Event: ' } + when ('change') { return __ 'Change: ' } + when ('committer') { return __ 'Committer:' } + when ('planner') { return __ 'Planner: ' } + when ('by') { return __ 'By: ' } + when ('date') { return __ 'Date: ' } + when ('committed') { return __ 'Committed:' } + when ('planned') { return __ 'Planned: ' } + when ('name') { return __ 'Name: ' } + when ('project') { return __ 'Project: ' } + when ('email') { return __ 'Email: ' } + when ('requires') { return __ 'Requires: ' } + when ('conflicts') { return __ 'Conflicts:' } + when (undef) { + hurl format => __ 'No label passed to the _ format'; + } + default { + hurl format => __x( + 'Unknown label "{label}" passed to the _ format', + label => $_[1], + ); + } + }; + }, + H => sub { $_[0]->{change_id} }, + h => sub { + if (my $abb = $_[1] || $self->abbrev) { + return substr $_[0]->{change_id}, 0, $abb; + } + return $_[0]->{change_id}; + }, + n => sub { $_[0]->{change} }, + o => sub { $_[0]->{project} }, + + c => sub { + return "$_[0]->{committer_name} <$_[0]->{committer_email}>" + unless defined $_[1]; + return $_[0]->{committer_name} if $_[1] ~~ [qw(n name)]; + return $_[0]->{committer_email} if $_[1] ~~ [qw(e email)]; + return $_[0]->{committed_at}->as_string( + format => $_[1] || $self->date_format + ) if $_[1] =~ s/^d(?:ate)?(?::|$)//; + }, + + p => sub { + return "$_[0]->{planner_name} <$_[0]->{planner_email}>" + unless defined $_[1]; + return $_[0]->{planner_name} if $_[1] ~~ [qw(n name)]; + return $_[0]->{planner_email} if $_[1] ~~ [qw(e email)]; + return $_[0]->{planned_at}->as_string( + format => $_[1] || $self->date_format + ) if $_[1] =~ s/^d(?:ate)?(?::|$)//; + }, + + t => sub { + @{ $_[0]->{tags} } + ? ' ' . join $_[1] || ', ' => @{ $_[0]->{tags} } + : ''; + }, + T => sub { + @{ $_[0]->{tags} } + ? ' (' . join($_[1] || ', ' => @{ $_[0]->{tags} }) . ')' + : ''; + }, + v => sub { "\n" }, + C => sub { + if (($_[1] // '') eq ':event') { + # Select a color based on some attribute. + return $encolor->( + $_[0]->{event} eq 'deploy' ? 'green' + : $_[0]->{event} eq 'revert' ? 'blue' + : 'red' + ); + } + hurl format => __x( + '{color} is not a valid ANSI color', color => $_[1] + ) unless $_[1] && colorvalid( $_[1] ); + $encolor->( $_[1] ); + }, + s => sub { + ( my $s = $_[0]->{note} ) =~ s/\v.*//ms; + return ($_[1] // '') . $s; + }, + b => sub { + return '' unless $_[0]->{note} =~ /\v/; + ( my $b = $_[0]->{note} ) =~ s/^.+\v+//; + $b =~ s/^/$_[1]/gms if defined $_[1] && length $b; + return $b; + }, + B => sub { + return $_[0]->{note} unless defined $_[1]; + ( my $note = $_[0]->{note} ) =~ s/^/$_[1]/gms; + return $note; + }, + r => sub { + @{ $_[0]->{requires} } + ? ' ' . join $_[1] || ', ' => @{ $_[0]->{requires} } + : ''; + }, + R => sub { + return '' unless @{ $_[0]->{requires} }; + return __ ('Requires: ') . ' ' . join( + $_[1] || ', ' => @{ $_[0]->{requires} } + ) . "\n"; + }, + x => sub { + @{ $_[0]->{conflicts} } + ? ' ' . join $_[1] || ', ' => @{ $_[0]->{conflicts} } + : ''; + }, + X => sub { + return '' unless @{ $_[0]->{conflicts} }; + return __('Conflicts:') . ' ' . join( + $_[1] || ', ' => @{ $_[0]->{conflicts} } + ) . "\n"; + }, + a => sub { + hurl format => __x( + '{attr} is not a valid change attribute', attr => $_[1] + ) unless $_[1] && exists $_[0]->{ $_[1] }; + my $val = $_[0]->{ $_[1] } // return ''; + + if (ref $val eq 'ARRAY') { + return '' unless @{ $val }; + $val = join ', ' => @{ $val }; + } elsif (eval { $val->isa('App::Sqitch::DateTime') }) { + $val = $val->as_string( format => 'raw' ); + } + + my $sp = ' ' x max(9 - length $_[1], 0); + return "$_[1]$sp $val\n"; + } + }, + }); + } +); + +sub format { + my $self = shift; + local $SIG{__DIE__} = sub { + die @_ if $_[0] !~ /^Unknown conversion in stringf: (\S+)/; + hurl format => __x 'Unknown format code "{code}"', code => $1; + }; + + # Older versions of TERM::ANSIColor check for definedness. + local $ENV{ANSI_COLORS_DISABLED} = $self->color eq 'always' ? undef + : $self->color eq 'never' ? 1 + : $ENV{ANSI_COLORS_DISABLED}; + + return $self->formatter->format(@_); +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::ItemFormatter - Format events and changes for command output + +=head1 Synopsis + + my $formatter = App::Sqitch::ItemFormatter->new(%params); + say $formatter->format($format, $item); + +=head1 Description + +This class is used by commands to format items for output. For example, +L|sqitch-log> uses it to format the events it finds. It uses +L to do the actual formatting, but configures it for all +the various times of things typically displayed, such as change names, IDs, +event types, etc. This keeps things relatively simple, as all one needs to +pass to C is a format and then a hash reference of values to be used +in the format. + +=head1 Interface + +=head2 Constructor + +=head3 C + + my $formatter = App::Sqitch::ItemFormatter->new(%params); + +Constructs and returns a formatter object. The supported parameters are: + +=over + +=item C + +Instead of showing the full 40-byte hexadecimal change ID, format as a partial +prefix the specified number of characters long. + +=item C + +Format to use for timestamps. Defaults to C. Allowed values: + +=over + +=item C + +=item C + +ISO-8601 format. + +=item C + +=item C + +RFC-2822 format. + +=item C + +=item C + +=item C + +=item C + +A format length to pass to the system locale's C category. + +=item C + +Raw format, which is strict ISO-8601 in the UTC time zone. + +=item C + +An arbitrary C pattern. See L for +comprehensive documentation of supported patterns. + +=item C + +An arbitrary C pattern. See L for comprehensive +documentation of supported patterns. + +=back + +=item C + +Controls the use of ANSI color formatting. The value may be one of: + +=over + +=item C (the default) + +=item C + +=item C + +=back + +=item C + +A String::Formatter object. You probably don't want to pass one of these, as +the default one understands all the values to that Sqitch is likely to want to +format. + +=back + +=head2 Instance Methods + +=head3 C + + $formatter->format( $format, $item ); + +Formats an item as a string and returns it. The item will be formatted using +the first argument. See L for the gory details. + +The second argument is a hash reference defining the item to be formatted. +These are simple key/value pairs, generally identifying attribute names and +values. The supported keys are: + +=over + +=item C + +The type of event, which is one of: + +=over + +=item C + +=item C + +=item C + +=back + +=item C + +The name of the project with which the change is associated. + +=item C + +The change ID. + +=item C + +The name of the change. + +=item C + +A brief description of the change. + +=item C + +An array reference of the names of associated tags. + +=item C + +An array reference of the names of any changes required by the change. + +=item C + +An array reference of the names of any changes that conflict with the change. + +=item C + +An L object representing the date and time at which the +event was logged. + +=item C + +Name of the user who deployed the change. + +=item C + +Email address of the user who deployed the change. + +=item C + +An L object representing the date and time at which the +change was added to the plan. + +=item C + +Name of the user who added the change to the plan. + +=item C + +Email address of the user who added the change to the plan. + +=back + +=head1 Formats + +The format argument to C specifies the item information to be +included in the resulting string. It works a little bit like C format +and a little like Git log format. For example, this format: + + format:The committer of %h was %{name}c%vThe title was >>%s<<%v + +Would show something like this: + + The committer of f26a3s was Tom Lane + The title was >>We really need to get this right.<< + +The placeholders are: + +=over + +=item * C<%H>: Event change ID + +=item * C<%h>: Event change ID (respects C<--abbrev>) + +=item * C<%n>: Event change name + +=item * C<%o>: Event change project name + +=item * C<%($len)h>: abbreviated change of length C<$len> + +=item * C<%e>: Event type (deploy, revert, fail) + +=item * C<%l>: Localized lowercase event type label + +=item * C<%L>: Localized title case event type label + +=item * C<%c>: Event committer name and email address + +=item * C<%{name}c>: Event committer name + +=item * C<%{email}c>: Event committer email address + +=item * C<%{date}c>: commit date (respects C<--date-format>) + +=item * C<%{date:rfc}c>: commit date, RFC2822 format + +=item * C<%{date:iso}c>: commit date, ISO-8601 format + +=item * C<%{date:full}c>: commit date, full format + +=item * C<%{date:long}c>: commit date, long format + +=item * C<%{date:medium}c>: commit date, medium format + +=item * C<%{date:short}c>: commit date, short format + +=item * C<%{date:cldr:$pattern}c>: commit date, formatted with custom L + +=item * C<%{date:strftime:$pattern}c>: commit date, formatted with custom L + +=item * C<%c>: Change planner name and email address + +=item * C<%{name}p>: Change planner name + +=item * C<%{email}p>: Change planner email address + +=item * C<%{date}p>: plan date (respects C<--date-format>) + +=item * C<%{date:rfc}p>: plan date, RFC2822 format + +=item * C<%{date:iso}p>: plan date, ISO-8601 format + +=item * C<%{date:full}p>: plan date, full format + +=item * C<%{date:long}p>: plan date, long format + +=item * C<%{date:medium}p>: plan date, medium format + +=item * C<%{date:short}p>: plan date, short format + +=item * C<%{date:cldr:$pattern}p>: plan date, formatted with custom L + +=item * C<%{date:strftime:$pattern}p>: plan date, formatted with custom L + +=item * C<%t>: Comma-delimited list of tags + +=item * C<%{$sep}t>: list of tags delimited by C<$sep> + +=item * C<%T>: Parenthesized list of comma-delimited tags + +=item * C<%{$sep}T>: Parenthesized list of tags delimited by C<$sep> + +=item * C<%s>: Subject (a.k.a. title line) + +=item * C<%r>: Comma-delimited list of required changes + +=item * C<%{$sep}r>: list of required changes delimited by C<$sep> + +=item * C<%R>: Localized label and list of comma-delimited required changes + +=item * C<%{$sep}R>: Localized label and list of required changes delimited by C<$sep> + +=item * C<%x>: Comma-delimited list of conflicting changes + +=item * C<%{$sep}x>: list of conflicting changes delimited by C<$sep> + +=item * C<%X>: Localized label and list of comma-delimited conflicting changes + +=item * C<%{$sep}X>: Localized label and list of conflicting changes delimited by C<$sep> + +=item * C<%b>: Body + +=item * C<%B>: Raw body (unwrapped subject and body) + +=item * C<%{$prefix}>B: Raw body with C<$prefix> prefixed to every line + +=item * C<%{event}_> Localized label for "event" + +=item * C<%{change}_> Localized label for "change" + +=item * C<%{committer}_> Localized label for "committer" + +=item * C<%{planner}_> Localized label for "planner" + +=item * C<%{by}_> Localized label for "by" + +=item * C<%{date}_> Localized label for "date" + +=item * C<%{committed}_> Localized label for "committed" + +=item * C<%{planned}_> Localized label for "planned" + +=item * C<%{name}_> Localized label for "name" + +=item * C<%{project}_> Localized label for "project" + +=item * C<%{email}_> Localized label for "email" + +=item * C<%{requires}_> Localized label for "requires" + +=item * C<%{conflicts}_> Localized label for "conflicts" + +=item * C<%v> vertical space (newline) + +=item * C<%{$color}C>: An ANSI color: black, red, green, yellow, reset, etc. + +=item * C<%{:event}C>: An ANSI color based on event type (green deploy, blue revert, red fail) + +=item * C<%{$attribute}a>: The raw attribute name and value, if it exists and has a value + +=back + +=head1 See Also + +=over + +=item L + +Documentation for the C command to the Sqitch command-line client. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Plan.pm b/lib/App/Sqitch/Plan.pm new file mode 100644 index 00000000..3106f547 --- /dev/null +++ b/lib/App/Sqitch/Plan.pm @@ -0,0 +1,1620 @@ +package App::Sqitch::Plan; + +use 5.010; +use utf8; +use App::Sqitch::Plan::Tag; +use App::Sqitch::Plan::Change; +use App::Sqitch::Plan::Blank; +use App::Sqitch::Plan::Pragma; +use App::Sqitch::Plan::Depend; +use Path::Class; +use App::Sqitch::Plan::ChangeList; +use App::Sqitch::Plan::LineList; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use List::MoreUtils qw(uniq any); +use namespace::autoclean; +use Moo; +use App::Sqitch::Types qw(Str Int HashRef ChangeList LineList Maybe Sqitch URI File Target); +use constant SYNTAX_VERSION => '1.0.0'; + +our $VERSION = 'v1.0.0'; # VERSION + +# Like [:punct:], but excluding _. Copied from perlrecharclass. +my $punct = q{-!"#$%&'()*+,./:;<=>?@[\\]^`{|}~}; +my $name_re = qr{ + (?![$punct]) # first character isn't punctuation + (?: # start non-capturing group, repeated once or more ... + (?! # negative look ahead for... + [~/=%^] # symbolic reference punctuation + [[:digit:]]+ # digits + (?:$|[[:blank:]]) # eol or blank + ) # ... + [^[:blank:]:@#] # match a valid character + )+ # ... end non-capturing group + (? undef } qw(ROOT HEAD); + +sub name_regex { $name_re } + +has sqitch => ( + is => 'ro', + isa => Sqitch, + required => 1, + weak_ref => 1, +); + +has target => ( + is => 'ro', + isa => Target, + required => 1, + weak_ref => 1, +); + +has file => ( + is => 'ro', + isa => File, + lazy => 1, + default => sub { + shift->target->plan_file + }, +); + +has _plan => ( + is => 'rw', + isa => HashRef, + builder => 'load', + init_arg => 'plan', + lazy => 1, + required => 1, +); + +has _changes => ( + is => 'ro', + isa => ChangeList, + lazy => 1, + default => sub { + App::Sqitch::Plan::ChangeList->new(@{ shift->_plan->{changes} }), + }, +); + +has _lines => ( + is => 'ro', + isa => LineList, + lazy => 1, + default => sub { + App::Sqitch::Plan::LineList->new(@{ shift->_plan->{lines} }), + }, +); + +has position => ( + is => 'rw', + isa => Int, + default => -1, +); + +has project => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + shift->_plan->{pragmas}{project}; + } +); + +has uri => ( + is => 'ro', + isa => Maybe[URI], + lazy => 1, + default => sub { + my $uri = shift->_plan->{pragmas}{uri} || return; + require URI; + 'URI'->new($uri); + } +); + +sub parse { + my ( $self, $data ) = @_; + open my $fh, '<:utf8_strict', \$data; + $self->_plan( $self->load($fh) ); + return $self; +} + +sub load { + my $self = shift; + my $file = $self->file; + my $fh = shift || do { + hurl plan => __x('Plan file {file} does not exist', file => $file) + unless -e $file; + hurl plan => __x('Plan file {file} is not a regular file', file => $file) + unless -f $file; + $file->open('<:utf8_strict') or hurl io => __x( + 'Cannot open {file}: {error}', + file => $file, + error => $! + ); + }; + + return $self->_parse($file, $fh); +} + +sub _parse { + my ( $self, $file, $fh ) = @_; + + my @lines; # List of lines. + my @changes; # List of changes. + my @curr_changes; # List of changes since last tag. + my %line_no_for; # Maps tags and changes to line numbers. + my %change_named; # Maps change names to change objects. + my %tag_changes; # Maps changes in current tag section to line numbers. + my %pragmas; # Maps pragma names to values. + my $seen_version; # Have we seen a version pragma? + my $prev_tag; # Last seen tag. + my $prev_change; # Last seen change. + + # Regex to match timestamps. + my $ts_re = qr/ + (?[[:digit:]]{4}) # year + - # dash + (?[[:digit:]]{2}) # month + - # dash + (?[[:digit:]]{2}) # day + T # T + (?
[[:digit:]]{2}) # hour + : # colon + (?[[:digit:]]{2}) # minute + : # colon + (?[[:digit:]]{2}) # second + Z # Zulu time + /x; + + my $planner_re = qr/ + (?[^<]+) # name + [[:blank:]]+ # blanks + <(?[^>]+)> # email + /x; + + # Use for raising syntax error exceptions. + my $raise_syntax_error = sub { + hurl parse => __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => $file, + lineno => $fh->input_line_number, + error => shift + ); + }; + + # First, find pragmas. + HEADER: while ( my $line = $fh->getline ) { + $line =~ s/\r?\n\z//; + + # Grab blank lines first. + if ($line =~ /\A(?[[:blank:]]*)(?:#[[:blank:]]*(?.+)|$)/) { + my $line = App::Sqitch::Plan::Blank->new( plan => $self, %+ ); + push @lines => $line; + last HEADER if @lines && !$line->note; + next HEADER; + } + + # Grab inline note. + $line =~ s/(?[[:blank:]]*)(?:[#][[:blank:]]*(?.*))?$//; + my %params = %+; + + $raise_syntax_error->( + __ 'Invalid pragma; a blank line must come between pragmas and changes' + ) unless $line =~ / + \A # Beginning of line + (?[[:blank:]]*)? # Optional leading space + [%] # Required % + (?[[:blank:]]*)? # Optional space + (? # followed by name consisting of... + [^$punct] # not punct + (?: # followed by... + [^[:blank:]=]*? # any number non-blank, non-= + [^$punct[:blank:]] # one not blank or punct + )? # ... optionally + ) # ... required + (?: # followed by value consisting of... + (?[[:blank:]]*) # Optional blanks + (?=) # Required = + (?[[:blank:]]*) # Optional blanks + (?.+) # String value + )? # ... optionally + \z # end of line + /x; + + # XXX Die if the pragma is a dupe? + + if ($+{name} eq 'syntax-version') { + # Set explicit version in case we write it out later. In future + # releases, may change parsers depending on the version. + $pragmas{syntax_version} = $params{value} = SYNTAX_VERSION; + } elsif ($+{name} eq 'project') { + my $proj = $+{value}; + $raise_syntax_error->(__x( + qq{invalid project name "{project}": project names must not } + . 'begin with punctuation, contain "@", ":", "#", or blanks, or end in ' + . 'punctuation or digits following punctuation', + project => $proj, + )) unless $proj =~ /\A$name_re\z/; + $pragmas{project} = $proj; + } else { + $pragmas{ $+{name} } = $+{value} // 1; + } + + push @lines => App::Sqitch::Plan::Pragma->new( + plan => $self, + %+, + %params + ); + next HEADER; + } + + # We should have a version pragma. + unless ( $pragmas{syntax_version} ) { + unshift @lines => $self->_version_line; + $pragmas{syntax_version} = SYNTAX_VERSION; + } + + # Should have valid project pragma. + hurl parse => __x( + 'Missing %project pragma in {file}', + file => $file, + ) unless $pragmas{project}; + + LINE: while ( my $line = $fh->getline ) { + $line =~ s/\r?\n\z//; + + # Grab blank lines first. + if ($line =~ /\A(?[[:blank:]]*)(?:#[[:blank:]]*(?.+)|$)/) { + my $line = App::Sqitch::Plan::Blank->new( plan => $self, %+ ); + push @lines => $line; + next LINE; + } + + # Grab inline note. + $line =~ s/(?[[:blank:]]*)(?:[#][[:blank:]]*(?.*))?$//; + my %params = %+; + + # Is it a tag or a change? + my $type = $line =~ /^[[:blank:]]*[@]/ ? 'tag' : 'change'; + $line =~ / + ^ # Beginning of line + (?[[:blank:]]*)? # Optional leading space + + (?: # followed by... + [@] # @ for tag + | # ...or... + (?[[:blank:]]*) # Optional blanks + (?[+-]) # Required + or - + (?[[:blank:]]*) # Optional blanks + )? # ... optionally + + (?$name_re) # followed by name + (?[[:blank:]]+)? # blanks + + (?: # followed by... + [[](?[^]]+)[]] # dependencies + [[:blank:]]* # blanks + )? # ... optionally + + (?: # followed by... + $ts_re # timestamp + [[:blank:]]* # blanks + )? # ... optionally + + (?: # followed by + $planner_re # planner + )? # ... optionally + $ # end of line + /x; + + %params = ( %params, %+ ); + + # Raise errors for missing data. + $raise_syntax_error->(__( + qq{Invalid name; names must not begin with punctuation, } + . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation', + )) if !$params{name} + || (!$params{yr} && $line =~ $ts_re); + + $raise_syntax_error->(__ 'Missing timestamp and planner name and email') + unless $params{yr} || $params{planner_name}; + $raise_syntax_error->(__ 'Missing timestamp') unless $params{yr}; + + $raise_syntax_error->(__ 'Missing planner name and email') + unless $params{planner_name}; + + # It must not be a reserved name. + $raise_syntax_error->(__x( + '"{name}" is a reserved name', + name => ($type eq 'tag' ? '@' : '') . $params{name}, + )) if exists $reserved{ $params{name} }; + + # It must not look like a SHA1 hash. + $raise_syntax_error->(__x( + '"{name}" is invalid because it could be confused with a SHA1 ID', + name => $params{name}, + )) if $params{name} =~ /^[0-9a-f]{40}/; + + # Assemble the timestamp. + require App::Sqitch::DateTime; + $params{timestamp} = App::Sqitch::DateTime->new( + year => delete $params{yr}, + month => delete $params{mo}, + day => delete $params{dy}, + hour => delete $params{hr}, + minute => delete $params{mi}, + second => delete $params{sc}, + time_zone => 'UTC', + ); + + if ($type eq 'tag') { + # Fail if no changes. + unless ($prev_change) { + $raise_syntax_error->(__x( + 'Tag "{tag}" declared without a preceding change', + tag => $params{name}, + )); + } + + # Fail on duplicate tag. + my $key = '@' . $params{name}; + if ( my $at = $line_no_for{$key} ) { + $raise_syntax_error->(__x( + 'Tag "{tag}" duplicates earlier declaration on line {line}', + tag => $params{name}, + line => $at, + )); + } + + # Fail on dependencies. + $raise_syntax_error->(__x( + __ 'Tags may not specify dependencies' + )) if $params{dependencies}; + + if (@curr_changes) { + # Sort all changes up to this tag by their dependencies. + push @changes => $self->check_changes( + $pragmas{project}, + \%line_no_for, + @curr_changes, + ); + @curr_changes = (); + } + + # Create the tag and associate it with the previous change. + $prev_tag = App::Sqitch::Plan::Tag->new( + plan => $self, + change => $prev_change, + %params, + ); + + # Keep track of everything and clean up. + $prev_change->add_tag($prev_tag); + push @lines => $prev_tag; + %line_no_for = (%line_no_for, %tag_changes, $key => $fh->input_line_number); + %tag_changes = (); + } else { + # Fail on duplicate change since last tag. + if ( my $at = $tag_changes{ $params{name} } ) { + $raise_syntax_error->(__x( + 'Change "{change}" duplicates earlier declaration on line {line}', + change => $params{name}, + line => $at, + )); + } + + # Got dependencies? + if (my $deps = $params{dependencies}) { + my (@req, @con, %seen_dep); + for my $depstring (split /[[:blank:]]+/, $deps) { + my $dep_params = App::Sqitch::Plan::Depend->parse( + $depstring, + ) or $raise_syntax_error->(__x( + '"{dep}" is not a valid dependency specification', + dep => $depstring, + )); + my $dep = App::Sqitch::Plan::Depend->new( + plan => $self, + %{ $dep_params }, + ); + # Prevent dupes. + $raise_syntax_error->( + __x( 'Duplicate dependency "{dep}"', dep => $depstring ), + ) if $seen_dep{$depstring}++; + if ($dep->conflicts) { + push @con => $dep; + } else { + push @req => $dep; + } + } + $params{requires} = \@req; + $params{conflicts} = \@con; + } + + $tag_changes{ $params{name} } = $fh->input_line_number; + push @curr_changes => $prev_change = App::Sqitch::Plan::Change->new( + plan => $self, + ( $prev_tag ? ( since_tag => $prev_tag ) : () ), + ( $prev_change ? ( parent => $prev_change ) : () ), + %params, + ); + push @lines => $prev_change; + + if (my $duped = $change_named{ $params{name} }) { + # Get rework tags by change in reverse order to reworked change. + my @rework_tags; + for (my $i = $#changes; $changes[$i] ne $duped; $i--) { + push @rework_tags => $changes[$i]->tags; + } + # Add list of rework tags to the reworked change. + $duped->add_rework_tags(@rework_tags, $duped->tags); + } + $change_named{ $params{name} } = $prev_change; + } + } + + # Sort and store any remaining changes. + push @changes => $self->check_changes( + $pragmas{project}, + \%line_no_for, + @curr_changes, + ) if @curr_changes; + + return { + changes => \@changes, + lines => \@lines, + pragmas => \%pragmas, + }; +} + +sub _version_line { + App::Sqitch::Plan::Pragma->new( + plan => shift, + name => 'syntax-version', + operator => '=', + value => SYNTAX_VERSION, + ); +} + +sub check_changes { + my ( $self, $proj ) = ( shift, shift ); + my $seen = ref $_[0] eq 'HASH' ? shift : {}; + + my %position; + my @invalid; + + my $i = 0; + for my $change (@_) { + my @bad; + + # XXX Ignoring conflicts for now. + for my $dep ( $change->requires ) { + # Ignore dependencies on other projects. + if ($dep->got_project) { + # Skip if parsed project name different from current project. + next if $dep->project ne $proj; + } else { + # Skip if an ID was passed, is it could be internal or external. + next if $dep->got_id; + } + my $key = $dep->key_name; + + # Skip it if it's a change from an earlier tag. + if ($key =~ /.@/) { + # Need to look it up before the tag. + my ( $change, $tag ) = split /@/ => $key, 2; + if ( my $tag_at = $seen->{"\@$tag"} ) { + if ( my $change_at = $seen->{$change}) { + next if $change_at < $tag_at; + } + } + } else { + # Skip it if we've already seen it in the plan. + next if exists $seen->{$key} || $position{$key}; + } + + # Hrm, unknown dependency. + push @bad, $key; + } + $position{$change->name} = ++$i; + push @invalid, [ $change->name => \@bad ] if @bad; + } + + + # Nothing bad, then go! + return @_ unless @invalid; + + # Build up all of the error messages. + my @errors; + for my $bad (@invalid) { + my $change = $bad->[0]; + my $max_delta = 0; + for my $dep (@{ $bad->[1] }) { + if ($change eq $dep) { + push @errors => __x( + 'Change "{change}" cannot require itself', + change => $change, + ); + } elsif (my $pos = $position{ $dep }) { + my $delta = $pos - $position{$change}; + $max_delta = $delta if $delta > $max_delta; + push @errors => __xn( + 'Change "{change}" planned {num} change before required change "{required}"', + 'Change "{change}" planned {num} changes before required change "{required}"', + $delta, + change => $change, + required => $dep, + num => $delta, + ); + } else { + push @errors => __x( + 'Unknown change "{required}" required by change "{change}"', + required => $dep, + change => $change, + ); + } + } + if ($max_delta) { + # Suggest that the change be moved. + # XXX Potentially offer to move it and rewrite the plan. + $errors[-1] .= "\n " . __xn( + 'HINT: move "{change}" down {num} line in {plan}', + 'HINT: move "{change}" down {num} lines in {plan}', + $max_delta, + change => $change, + num => $max_delta, + plan => $self->file, + ); + } + } + + # Throw the exception with all of the errors. + hurl parse => join( + "\n ", + __n( + 'Dependency error detected:', + 'Dependency errors detected:', + @errors + ), + @errors, + ); +} + +sub open_script { + my ( $self, $file ) = @_; + # return has higher precedence than or, so use ||. + return $file->open('<:utf8_strict') || hurl io => __x( + 'Cannot open {file}: {error}', + file => $file, + error => $!, + ); +} + +sub syntax_version { shift->_plan->{pragmas}{syntax_version} }; +sub lines { shift->_lines->items } +sub changes { shift->_changes->changes } +sub tags { shift->_changes->tags } +sub count { shift->_changes->count } +sub index_of { shift->_changes->index_of(shift) } +sub get { shift->_changes->get(shift) } +sub contains { shift->_changes->contains( shift ) } +sub find { shift->_changes->find(shift) } +sub first_index_of { shift->_changes->first_index_of(@_) } +sub change_at { shift->_changes->change_at(shift) } +sub last_tagged_change { shift->_changes->last_tagged_change } + +sub search_changes { + my ( $self, %p ) = @_; + + my $reverse = 0; + if (my $d = delete $p{direction}) { + $reverse = $d =~ /^ASC/i ? 0 + : $d =~ /^DESC/i ? 1 + : hurl 'Search direction must be either "ASC" or "DESC"'; + } + + # Limit with regular expressions? + my @filters; + if (my $regex = delete $p{planner}) { + $regex = qr/$regex/; + push @filters => sub { $_[0]->planner_name =~ $regex }; + } + if (my $regex = delete $p{name}) { + $regex = qr/$regex/; + push @filters => sub { $_[0]->name =~ $regex }; + } + + # Match events? + if (my $op = lc(delete $p{operation} || '') ) { + push @filters => $op eq 'deploy' ? sub { $_[0]->is_deploy } + : $op eq 'revert' ? sub { $_[0]->is_revert } + : hurl qq{Unknown change operation "$op"}; + } + + my $changes = $self->_changes; + my $offset = delete $p{offset} || 0; + my $limit = delete $p{limit} || 0; + + hurl 'Invalid parameters passed to search_changes(): ' + . join ', ', sort keys %p if %p; + + # If no filters, we want to return everything. + push @filters => sub { 1 } unless @filters; + + if ($reverse) { + # Go backwards. + my $index = $changes->count - ($offset + 1); + my $end_at = $limit - 1; + return sub { + while ($index > $end_at) { + my $change = $changes->change_at($index--) or return; + return $change if any { $_->($change) } @filters; + } + return; + }; + } + + my $index = $offset - 1; + my $end_at = $limit ? $index + $limit : $changes->count - 1; + return sub { + while ($index < $end_at) { + my $change = $changes->change_at(++$index) or return; + return $change if any { $_->($change) } @filters; + } + return; + }; +} + +sub seek { + my ( $self, $key ) = @_; + my $index = $self->index_of($key); + hurl plan => __x( + 'Cannot find change "{change}" in plan', + change => $key, + ) unless defined $index; + $self->position($index); + return $self; +} + +sub reset { + my $self = shift; + $self->position(-1); + return $self; +} + +sub next { + my $self = shift; + if ( my $next = $self->peek ) { + $self->position( $self->position + 1 ); + return $next; + } + $self->position( $self->position + 1 ) if defined $self->current; + return undef; +} + +sub current { + my $self = shift; + my $pos = $self->position; + return if $pos < 0; + $self->_changes->change_at( $pos ); +} + +sub peek { + my $self = shift; + $self->_changes->change_at( $self->position + 1 ); +} + +sub last { + shift->_changes->change_at( -1 ); +} + +sub do { + my ( $self, $code ) = @_; + while ( local $_ = $self->next ) { + return unless $code->($_); + } +} + +sub tag { + my ( $self, %p ) = @_; + ( my $name = $p{name} ) =~ s/^@//; + $self->_is_valid(tag => $name); + + my $changes = $self->_changes; + my $key = "\@$name"; + + hurl plan => __x( + 'Tag "{tag}" already exists', + tag => $key + ) if defined $changes->index_of($key); + + my $change; + if (my $spec = $p{change}) { + $change = $changes->get($spec) or hurl plan => __x( + 'Unknown change: "{change}"', + change => $spec, + ); + } else { + $change = $changes->last_change or hurl plan => __x( + 'Cannot apply tag "{tag}" to a plan with no changes', + tag => $key + ); + } + + my $tag = App::Sqitch::Plan::Tag->new( + %p, + plan => $self, + name => $name, + change => $change, + ); + + $change->add_tag($tag); + $changes->index_tag( $changes->index_of( $change->id ), $tag ); + + # Add tag to line list, after the change and any preceding tags. + my $lines = $self->_lines; + $lines->insert_at( $tag, $lines->index_of($change) + $change->tags ); + return $tag; +} + +sub _parse_deps { + my ( $self, $p ) = @_; + # Dependencies must be parsed into objects. + $p->{requires} = [ map { + my $p = App::Sqitch::Plan::Depend->parse($_) // hurl plan => __x( + '"{dep}" is not a valid dependency specification', + dep => $_, + ); + App::Sqitch::Plan::Depend->new( + %{ $p }, + plan => $self, + conflicts => 0, + ); + } uniq @{ $p->{requires} } ] if $p->{requires}; + + $p->{conflicts} = [ map { + my $p = App::Sqitch::Plan::Depend->parse("!$_") // hurl plan => __x( + '"{dep}" is not a valid dependency specification', + dep => $_, + ); + App::Sqitch::Plan::Depend->new( + %{ $p }, + plan => $self, + conflicts => 1, + ); + } uniq @{ $p->{conflicts} } ] if $p->{conflicts}; +} + +sub add { + my ( $self, %p ) = @_; + $self->_is_valid(change => $p{name}); + my $changes = $self->_changes; + + if ( defined( my $idx = $changes->index_of( $p{name} . '@HEAD' ) ) ) { + my $tag_idx = $changes->index_of_last_tagged; + hurl plan => __x( + qq{Change "{change}" already exists in plan {file}.\n} + . 'Use "sqitch rework" to copy and rework it', + change => $p{name}, + file => $self->file, + ); + } + + $self->_parse_deps(\%p); + my $change = App::Sqitch::Plan::Change->new( %p, plan => $self ); + + # Make sure dependencies are valid. + $self->_check_dependencies( $change, 'add' ); + + # We good. Append a blank line if the previous change has a tag. + if ( $changes->count ) { + my $prev = $changes->change_at( $changes->count - 1 ); + if ( $prev->tags ) { + $self->_lines->append( + App::Sqitch::Plan::Blank->new( plan => $self ) + ); + } + } + + # Append the change and return. + $changes->append( $change ); + $self->_lines->append( $change ); + return $change; +} + +sub rework { + my ( $self, %p ) = @_; + my $changes = $self->_changes; + my $idx = $changes->index_of( $p{name} . '@HEAD') // hurl plan => __x( + qq{Change "{change}" does not exist in {file}.\n} + . 'Use "sqitch add {change}" to add it to the plan', + change => $p{name}, + file => $self->file, + ); + + my $tag_idx = $changes->index_of_last_tagged; + hurl plan => __x( + qq{Cannot rework "{change}" without an intervening tag.\n} + . 'Use "sqitch tag" to create a tag and try again', + change => $p{name}, + ) if !defined $tag_idx || $tag_idx < $idx; + + $self->_parse_deps(\%p); + + my ($tag) = $changes->change_at($tag_idx)->tags; + unshift @{ $p{requires} ||= [] } => App::Sqitch::Plan::Depend->new( + plan => $self, + change => $p{name}, + tag => $tag->name, + ); + + my $orig = $changes->change_at($idx); + my $new = App::Sqitch::Plan::Change->new( %p, plan => $self ); + + # Make sure dependencies are valid. + $self->_check_dependencies( $new, 'rework' ); + + # We good. + $orig->add_rework_tags($tag); + $changes->append( $new ); + $self->_lines->append( $new ); + return $new; +} + +sub _check_dependencies { + my ( $self, $change, $action ) = @_; + my $changes = $self->_changes; + my $project = $self->project; + for my $req ( $change->requires ) { + next if $req->project ne $project; + $req = $req->key_name; + next if defined $changes->index_of($req =~ /@/ ? $req : $req . '@HEAD'); + my $name = $change->name; + if ($action eq 'add') { + hurl plan => __x( + 'Cannot add change "{change}": requires unknown change "{req}"', + change => $name, + req => $req, + ); + } else { + hurl plan => __x( + 'Cannot rework change "{change}": requires unknown change "{req}"', + change => $name, + req => $req, + ); + } + } + return $self; +} + +sub _is_valid { + my ( $self, $type, $name ) = @_; + hurl plan => __x( + '"{name}" is a reserved name', + name => $name + ) if exists $reserved{$name}; + hurl plan => __x( + '"{name}" is invalid because it could be confused with a SHA1 ID', + name => $name, + ) if $name =~ /^[0-9a-f]{40}/; + + unless ($name =~ /\A$name_re\z/) { + if ($type eq 'change') { + hurl plan => __x( + qq{"{name}" is invalid: changes must not begin with punctuation, } + . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation', + name => $name, + ); + } else { + hurl plan => __x( + qq{"{name}" is invalid: tags must not begin with punctuation, } + . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation', + name => $name, + ); + } + } +} + +sub write_to { + my ( $self, $file, $from, $to ) = @_; + + my @lines = $self->lines; + + if (defined $from || defined $to) { + my $lines = $self->_lines; + + # Where are the pragmas? + my $head_ends_at = do { + my $i = 0; + while ( my $line = $lines[$i] ) { + last if $line->isa('App::Sqitch::Plan::Blank') + && !length $line->note; + ++$i; + } + $i; + }; + + # Where do we start with the changes? + my $from_idx = defined $from ? do { + my $change = $self->find($from // '@ROOT') // hurl plan => __x( + 'Cannot find change {change}', + change => $from, + ); + $lines->index_of($change); + } : $head_ends_at + 1; + + # Where do we end up? + my $to_idx = defined $to ? do { + my $change = $self->find( $to // '@HEAD' ) // hurl plan => __x( + 'Cannot find change {change}', + change => $to, + ); + + # Include any subsequent tags. + if (my @tags = $change->tags) { + $change = $tags[-1]; + } + $lines->index_of($change); + } : $#lines; + + # Collect the lines to write. + @lines = ( + @lines[ 0 .. $head_ends_at ], + @lines[ $from_idx .. $to_idx ], + ); + } + + my $fh = $file->open('>:utf8_strict') or hurl io => __x( + 'Cannot open {file}: {error}', + file => $file, + error => $! + ); + $fh->say($_->as_string) for @lines; + $fh->close or hurl io => __x( + '"Error closing {file}: {error}', + file => $file, + error => $!, + ); + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Plan - Sqitch Deployment Plan + +=head1 Synopsis + + my $plan = App::Sqitch::Plan->new( sqitch => $sqitch ); + while (my $change = $plan->next) { + say "Deploy ", $change->format_name; + } + +=head1 Description + +App::Sqitch::Plan provides the interface for a Sqitch plan. It parses a plan +file and provides an iteration interface for working with the plan. + +=head1 Interface + +=head2 Constants + +=head3 C + +Returns the current version of the Sqitch plan syntax. Used for the +C<%sytax-version> pragma. + +=head2 Class Methods + +=head3 C + + die "$this has no name" unless $this =~ App::Sqitch::Plan->name_regex; + +Returns a regular expression that matches names. Note that it is not anchored, +so if you need to make sure that a string is a valid name and nothing else, +you will need to anchor it yourself, like so: + + my $name_re = App::Sqitch::Plan->name_regex; + die "$this is not a valid name" if $this !~ /\A$name_re\z/; + +=head2 Constructors + +=head3 C + + my $plan = App::Sqitch::Plan->new( sqitch => $sqitch ); + +Instantiates and returns a App::Sqitch::Plan object. Takes a single parameter: +an L object. + +=head2 Accessors + +=head3 C + + my $sqitch = $plan->sqitch; + +Returns the L object that instantiated the plan. + +=head3 C + + my $target = $plan->target + +Returns the L passed to the constructor. + +=head3 C + + my $file = $plan->file; + +The file name from which to read the plan. + +=head3 C + +Returns the current position of the iterator. This is an integer that's used +as an index into plan. If C has not been called, or if C has +been called, the value will be -1, meaning it is outside of the plan. When +C returns C, the value will be the last index in the plan plus 1. + +=head3 C + + my $project = $plan->project; + +Returns the name of the project as set via the C<%project> pragma in the plan +file. + +=head3 C + + my $uri = $plan->uri; + +Returns the URI for the project as set via the C<%uri> pragma, which is +optional. If it is not present, C will be returned. + +=head3 C + + my $syntax_version = $plan->syntax_version; + +Returns the plan syntax version, which is always the latest version. + +=head2 Instance Methods + +=head3 C + + my $index = $plan->index_of('6c2f28d125aff1deea615f8de774599acf39a7a1'); + my $foo_index = $plan->index_of('@foo'); + my $bar_index = $plan->index_of('bar'); + my $bar1_index = $plan->index_of('bar@alpha') + my $bar2_index = $plan->index_of('bar@HEAD'); + +Returns the index of the specified change. Returns C if no such change +exists. The argument may be any one of: + +=over + +=item * An ID + + my $index = $plan->index_of('6c2f28d125aff1deea615f8de774599acf39a7a1'); + +This is the SHA1 hash of a change or tag. Currently, the full 40-character hexed +hash string must be specified. + +=item * A change name + + my $index = $plan->index_of('users_table'); + +The name of a change. Will throw an exception if the named change appears more +than once in the list. + +=item * A tag name + + my $index = $plan->index_of('@beta1'); + +The name of a tag, including the leading C<@>. + +=item * A tag-qualified change name + + my $index = $plan->index_of('users_table@beta1'); + +The named change as it was last seen in the list before the specified tag. + +=back + +=head3 C + + say 'Yes!' if $plan->contains('6c2f28d125aff1deea615f8de774599acf39a7a1'); + +Like C, but never throws an exception, and returns true if the +plan contains the specified change, and false if it does not. + +=head3 C + + my $change = $plan->get('6c2f28d125aff1deea615f8de774599acf39a7a1'); + my $foo = $plan->get('@foo'); + my $bar = $plan->get('bar'); + my $bar1 = $plan->get('bar@alpha') + my $bar2 = $plan->get('bar@HEAD'); + +Returns the change corresponding to the specified ID or name. The argument may +be in any of the formats described for C. + +=head3 C + + my $change = $plan->find('6c2f28d125aff1deea615f8de774599acf39a7a1'); + my $foo = $plan->find('@foo'); + my $bar = $plan->find('bar'); + my $bar1 = $plan->find('bar@alpha') + my $bar2 = $plan->find('bar@HEAD'); + +Finds the change corresponding to the specified ID or name. The argument may be +in any of the formats described for C. Unlike C, C +will not throw an error if more than one change exists with the specified name, +but will return the first instance. + +=head3 C + + my $index = $plan->first_index_of($change_name); + my $index = $plan->first_index_of($change_name, $change_or_tag_name); + +Returns the index of the first instance of the named change in the plan. If a +second argument is passed, the index of the first instance of the change +I the index of the second argument will be returned. This is useful +for getting the index of a change as it was deployed after a particular tag, for +example, to get the first index of the F change since the C<@beta> tag, do +this: + + my $index = $plan->first_index_of('foo', '@beta'); + +You can also specify the first instance of a change after another change, +including such a change at the point of a tag: + + my $index = $plan->first_index_of('foo', 'users_table@beta1'); + +The second argument must unambiguously refer to a single change in the plan. As +such, it should usually be a tag name or tag-qualified change name. Returns +C if the change does not appear in the plan, or if it does not appear +after the specified second argument change name. + +=head3 C + + my $change = $plan->last_tagged_change; + +Returns the last tagged change object. Returns C if no changes have +been tagged. + +=head3 C + + my $change = $plan->change_at($index); + +Returns the change at the specified index. + +=head3 C + + $plan->seek('@foo'); + $plan->seek('bar'); + +Move the plan position to the specified change. Dies if the change cannot be found +in the plan. + +=head3 C + + $plan->reset; + +Resets iteration. Same as C<< $plan->position(-1) >>, but better. + +=head3 C + + while (my $change = $plan->next) { + say "Deploy ", $change->format_name; + } + +Returns the next L in the plan. Returns C +if there are no more changes. + +=head3 C + + my $change = $plan->last; + +Returns the last change in the plan. Does not change the current position. + +=head3 C + + my $change = $plan->current; + +Returns the same change as was last returned by C. Returns C if +C has not been called or if the plan has been reset. + +=head3 C + + my $change = $plan->peek; + +Returns the next change in the plan without incrementing the iterator. Returns +C if there are no more changes beyond the current change. + +=head3 C + + my @changes = $plan->changes; + +Returns all of the changes in the plan. This constitutes the entire plan. + +=head3 C + + my @tags = $plan->tags; + +Returns all of the tags in the plan. + +=head3 C + + my $count = $plan->count; + +Returns the number of changes in the plan. + +=head3 C + + my @lines = $plan->lines; + +Returns all of the lines in the plan. This includes all the +L, L, +L, and L. + +=head3 C + + $plan->do(sub { say $_[0]->name; return $_[0]; }); + $plan->do(sub { say $_->name; return $_; }); + +Pass a code reference to this method to execute it for each change in the plan. +Each change will be stored in C<$_> before executing the code reference, and +will also be passed as the sole argument. If C has been called prior +to the call to C, then only the remaining changes in the iterator will +passed to the code reference. Iteration terminates when the code reference +returns false, so be sure to have it return a true value if you want it to +iterate over every change. + +=head3 C + + my $iter = $engine->search_changes( %params ); + while (my $change = $iter->()) { + say '* $change->{event}ed $change->{change}"; + } + +Searches the changes in the plan returns an iterator code reference with the +results. If no parameters are provided, a list of all changes will be returned +from the iterator in plan order. The supported parameters are: + +=over + +=item C + +An array of the type of event to search for. Allowed values are "deploy" and + "revert". + +=item C + +Limit the results to changes with names matching the specified regular +expression. + +=item C + +Limit the changes to those added by planners matching the specified regular +expression. + +=item C + +Limit the number of changes to the specified number. + +=item C + +Skip the specified number of events. + +=item C + +Return the results in the specified order, which must be a value matching +C for "ascending" or "descending". + +=back + +=head3 C + + $plan->write_to($file); + $plan->write_to($file, $from, $to); + +Write the plan to the named file, including notes and white space from the +original plan file. If C and/or C<$to> are provided, the plan will be +written only with the pragmas headers and the lines between those specified +changes. + +=head3 C + + my $file_handle = $plan->open_script( $change->deploy_file ); + +Opens the script file passed to it and returns a file handle for reading. The +script file must be encoded in UTF-8. + +=head3 C + + my $plan_data = $plan->load; + +Loads the plan data. Called internally, not meant to be called directly, as it +parses the plan file and deploy scripts every time it's called. If you want +the all of the changes, call C instead. And if you want to load an +alternate plan, use C. + +=head3 C + + $plan->parse($plan_data); + +Load an alternate plan by passing the complete text of the plan. The text +should be UTF-8 encoded. Useful for loading a plan from a different VCS +branch, for example. + +=head3 C + + @changes = $plan->check_changes( $project, @changes ); + @changes = $plan->check_changes( $project, { '@foo' => 1 }, @changes ); + +Checks a list of changes to validate their dependencies and returns them. If +the second argument is a hash reference, its keys should be previously-seen +change and tag names that can be assumed to be satisfied requirements for the +succeeding changes. + +=head3 C + + $plan->tag( name => 'whee' ); + +Tags a change in the plan. Exits with a fatal error if the tag already exists +in the plan or if a change cannot be found to tag. The supported parameters +are: + +=over + +=item C + +The tag name to use. Required. + +=item C + +The change to be tagged, specified as a supported change specification as +described in L. Defaults to the last change in the plan. + +=item C + +A brief note about the tag. + +=item C + +The name of the user adding the tag to the plan. Defaults to the value of the +C configuration variable. + +=item C + +The email address of the user adding the tag to the plan. Defaults to the +value of the C configuration variable. + +=back + +=head3 C + + $plan->add( name => 'whatevs' ); + $plan->add( + name => 'widgets', + requires => [qw(foo bar)], + conflicts => [qw(dr_evil)], + ); + +Adds a change to the plan. The supported parameters are the same as those +passed to the L constructor. Exits with a fatal +error if the change already exists, or if the any of the dependencies are +unknown. + +=head3 C + + $plan->rework( 'whatevs' ); + $plan->rework( 'widgets', [qw(foo bar)], [qw(dr_evil)] ); + +Reworks an existing change. Said change must already exist in the plan and be +tagged or have a tag following it or an exception will be thrown. The previous +occurrence of the change will have the suffix of the most recent tag added to +it, and a new tag instance will be added to the list. + +=head1 Plan File + +A plan file describes the deployment changes to be run against a database, and +is typically maintained using the L|sqitch-add> and +L|sqitch-rework> commands. Its contents must be plain text encoded +as UTF-8. Each line of a plan file may be one of four things: + +=over + +=item * + +A blank line. May include any amount of white space, which will be ignored. + +=item * A Pragma + +Begins with a C<%>, followed by a pragma name, optionally followed by C<=> and +a value. Currently, the only pragma recognized by Sqitch is C. + +=item * A change. + +A named change change as defined in L. A change may then also +contain a space-delimited list of dependencies, which are the names of other +changes or tags prefixed with a colon (C<:>) for required changes or with an +exclamation point (C) for conflicting changes. + +Changes with a leading C<-> are slated to be reverted, while changes with no +character or a leading C<+> are to be deployed. + +=item * A tag. + +A named deployment tag, generally corresponding to a release name. Begins with +a C<@>, followed by one or more non-blanks characters, excluding "@", ":", +"#", and blanks. The first and last characters must not be punctuation +characters. + +=item * A note. + +Begins with a C<#> and goes to the end of the line. Preceding white space is +ignored. May appear on a line after a pragma, change, or tag. + +=back + +Here's an example of a plan file with a single deploy change and tag: + + %syntax-version=1.0.0 + +users_table + @alpha + +There may, of course, be any number of tags and changes. Here's an expansion: + + %syntax-version=1.0.0 + +users_table + +insert_user + +update_user + +delete_user + @root + @alpha + +Here we have four changes -- "users_table", "insert_user", "update_user", and +"delete_user" -- followed by two tags: "@root" and "@alpha". + +Most plans will have many changes and tags. Here's a longer example with three +tagged deployment points, as well as a change that is deployed and later +reverted: + + %syntax-version=1.0.0 + +users_table + +insert_user + +update_user + +delete_user + +dr_evil + @root + @alpha + + +widgets_table + +list_widgets + @beta + + -dr_evil + +ftw + @gamma + +Using this plan, to deploy to the "beta" tag, all of the changes up to the +"@root" and "@alpha" tags must be deployed, as must changes listed before the +"@beta" tag. To then deploy to the "@gamma" tag, the "dr_evil" change must be +reverted and the "ftw" change must be deployed. If you then choose to revert +to "@alpha", then the "ftw" change will be reverted, the "dr_evil" change +re-deployed, and the "@gamma" tag removed; then "list_widgets" must be +reverted and the associated "@beta" tag removed, then the "widgets_table" +change must be reverted. + +Changes can only be repeated if one or more tags intervene. This allows Sqitch +to distinguish between them. An example: + + %syntax-version=1.0.0 + +users_table + @alpha + + +add_widget + +widgets_table + @beta + + +add_user + @gamma + + +widgets_created_at + @delta + + +add_widget + +Note that the "add_widget" change is repeated after the "@beta" tag, and at +the end. Sqitch will notice the repetition when it parses this file, and allow +it, because at least one tag "@beta" appears between the instances of +"add_widget". When deploying, Sqitch will fetch the instance of the deploy +script as of the "@delta" tag and apply it as the first change, and then, when +it gets to the last change, retrieve the current instance of the deploy +script. How does it find such files? The first instances files will either be +named F or (soon) findable in the VCS history as of a +VCS "delta" tag. + +=head2 Grammar + +Here is the EBNF Grammar for the plan file: + + plan-file = { | | | | }* ; + + blank-line = [ ] ; + note-line = ; + change-line = [ "[" { | } "]" ] ( | ) ; + tag-line = ( | ) ; + pragma = "%" [ ] [ ] = [ ] ( | ) ; + + tag = "@" ; + requires = ; + conflicts = "!" ; + name = [ [ ? non-blank and not "@", ":", or "#" characters ? ] ] ; + non-punct = ? non-punctuation, non-blank character ? ; + value = ? non-EOL or "#" characters ? + + note = [ ] "#" [ ] ; + eol = [ ] ; + + blanks = ? blank characters ? ; + string = ? non-EOL characters ? ; + +And written as regular expressions: + + my $eol = qr/[[:blank:]]*$/ + my $note = qr/(?:[[:blank:]]+)?[#].+$/; + my $punct = q{-!"#$%&'()*+,./:;<=>?@[\\]^`{|}~}; + my $name = qr/[^$punct[:blank:]](?:(?:[^[:space:]:#@]+)?[^$punct[:blank:]])?/; + my $tag = qr/[@]$name/; + my $requires = qr/$name/; + my conflicts = qr/[!]$name/; + my $tag_line = qr/^$tag(?:$note|$eol)/; + my $change_line = qr/^$name(?:[[](?:$requires|$conflicts)+[]])?(?:$note|$eol)/; + my $note_line = qr/^$note/; + my $pragma = qr/^][[:blank:]]*[%][[:blank:]]*$name[[:blank:]]*=[[:blank:]].+?(?:$note|$eol)$/; + my $blank_line = qr/^$eol/; + my $plan = qr/(?:$pragma|$change_line|$tag_line|$note_line|$blank_line)+/ms; + +=head1 See Also + +=over + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Plan/Blank.pm b/lib/App/Sqitch/Plan/Blank.pm new file mode 100644 index 00000000..ec44682c --- /dev/null +++ b/lib/App/Sqitch/Plan/Blank.pm @@ -0,0 +1,62 @@ +package App::Sqitch::Plan::Blank; + +use 5.010; +use utf8; +use namespace::autoclean; +use Moo; +extends 'App::Sqitch::Plan::Line'; + +our $VERSION = 'v1.0.0'; # VERSION + +has '+name' => ( default => '', required => 0 ); + +sub format_name { '' } + +1; + +__END__ + +=head1 Name + +App::Sqitch::Plan::Blank - Sqitch deployment plan blank line + +=head1 Synopsis + + my $plan = App::Sqitch::Plan->new( sqitch => $sqitch ); + for my $line ($plan->lines) { + say $line->as_string; + } + +=head1 Description + +An App::Sqitch::Plan::Blank represents a blank line or comment-only line in +the plan file. See L for its interface. The only +difference is that the C is always an empty string. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Plan/Change.pm b/lib/App/Sqitch/Plan/Change.pm new file mode 100644 index 00000000..2d08c724 --- /dev/null +++ b/lib/App/Sqitch/Plan/Change.pm @@ -0,0 +1,670 @@ +package App::Sqitch::Plan::Change; + +use 5.010; +use utf8; +use namespace::autoclean; +use Encode; +use Moo; +use App::Sqitch::Types qw(Str Bool Maybe Change Tag Depend UserEmail DateTime ArrayRef); +use App::Sqitch::Plan::Depend; +use Locale::TextDomain qw(App-Sqitch); +extends 'App::Sqitch::Plan::Line'; + +our $VERSION = 'v1.0.0'; # VERSION + +has _requires => ( + is => 'ro', + isa => ArrayRef[Depend], + init_arg => 'requires', + default => sub { [] }, +); + +sub requires { @{ shift->_requires } } + +has _conflicts => ( + is => 'ro', + isa => ArrayRef[Depend], + init_arg => 'conflicts', + default => sub { [] }, +); + +sub conflicts { @{ shift->_conflicts } } + +has pspace => ( + is => 'ro', + isa => Str, + default => ' ', +); + +has since_tag => ( + is => 'ro', + isa => Tag, +); + +has parent => ( + is => 'ro', + isa => Change, +); + +has _rework_tags => ( + is => 'ro', + isa => ArrayRef[Tag], + init_arg => 'rework_tags', + lazy => 1, + default => sub { [] }, +); + +sub rework_tags { @{ shift->_rework_tags } } +sub add_rework_tags { push @{ shift->_rework_tags } => @_ } +sub clear_rework_tags { @{ shift->_rework_tags } = () } +sub is_reworked { @{ shift->_rework_tags } > 0 } + +after add_rework_tags => sub { + my $self = shift; + # Need to reset the file name if a new value is passed. + $self->_clear_path_segments(undef); +}; + +has _tags => ( + is => 'ro', + isa => ArrayRef[Tag], + lazy => 1, + default => sub { [] }, +); + +sub tags { @{ shift->_tags } } +sub add_tag { push @{ shift->_tags } => @_ } + +has _path_segments => ( + is => 'ro', + isa => ArrayRef[Str], + lazy => 1, + clearer => 1, # Creates _clear_path_segments(). + default => sub { + my $self = shift; + my @path = split m{/} => $self->name; + my $ext = '.' . $self->target->extension; + if (my @rework_tags = $self->rework_tags) { + # Determine suffix based on the first one found in the deploy dir. + my $dir = $self->deploy_dir; + my $bn = pop @path; + my $first; + for my $tag (@rework_tags) { + my $fn = join '', $bn, $tag->format_name, $ext; + $first //= $fn; + if ( -e $dir->file(@path, $fn) ) { + push @path => $fn; + $first = undef; + last; + } + } + push @path => $first if defined $first; + } else { + $path[-1] .= $ext; + } + return \@path; + }, +); + +sub path_segments { @{ shift->_path_segments } } + +has info => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $self = shift; + my $reqs = join "\n + ", map { $_->as_string } $self->requires; + my $confs = join "\n - ", map { $_->as_string } $self->conflicts; + return join "\n", ( + 'project ' . $self->project, + ( $self->uri ? ( 'uri ' . $self->uri->canonical ) : () ), + 'change ' . $self->format_name, + ( $self->parent ? ( 'parent ' . $self->parent->id ) : () ), + 'planner ' . $self->format_planner, + 'date ' . $self->timestamp->as_string, + ( $reqs ? "requires\n + $reqs" : ()), + ( $confs ? "conflicts\n - $confs" : ()), + ( $self->note ? ('', $self->note) : ()), + ); + } +); + +has id => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $content = encode_utf8 shift->info; + require Digest::SHA; + return Digest::SHA->new(1)->add( + 'change ' . length($content) . "\0" . $content + )->hexdigest; + } +); + +has timestamp => ( + is => 'ro', + isa => DateTime, + default => sub { require App::Sqitch::DateTime && App::Sqitch::DateTime->now }, +); + +has planner_name => ( + is => 'ro', + isa => Str, + default => sub { shift->sqitch->user_name }, +); + +has planner_email => ( + is => 'ro', + isa => UserEmail, + default => sub { shift->sqitch->user_email }, +); + +has script_hash => ( + is => 'ro', + isa => Maybe[Str], + lazy => 1, + builder => '_deploy_hash' +); + +sub dependencies { + my $self = shift; + return $self->requires, $self->conflicts; +} + +sub deploy_dir { + my $self = shift; + my $target = $self->target; + return $self->is_reworked + ? $target->reworked_deploy_dir + : $target->deploy_dir; +} + +sub deploy_file { + my $self = shift; + $self->deploy_dir->file( $self->path_segments ); +} + +sub _deploy_hash { + my $path = shift->deploy_file; + return undef unless -f $path; + require Digest::SHA; + my $sha = Digest::SHA->new(1); + $sha->add( $path->slurp(iomode => '<:raw') ); + return $sha->hexdigest; +} + +sub revert_dir { + my $self = shift; + my $target = $self->target; + return $self->is_reworked + ? $target->reworked_revert_dir + : $target->revert_dir; +} + +sub revert_file { + my $self = shift; + $self->revert_dir->file( $self->path_segments ); +} + +sub verify_dir { + my $self = shift; + my $target = $self->target; + return $self->is_reworked + ? $target->reworked_verify_dir + : $target->verify_dir; +} + +sub verify_file { + my $self = shift; + $self->verify_dir->file( $self->path_segments ); +} + +sub script_file { + my ($self, $name) = @_; + if ( my $meth = $self->can("$name\_file") ) { + return $self->$meth; + } + return $self->target->top_dir->subdir($name)->cleanup->file( + $self->path_segments + ); +} + +sub is_revert { + shift->operator eq '-'; +} + +sub is_deploy { + shift->operator ne '-'; +} + +sub action { + shift->is_deploy ? 'deploy' : 'revert'; +} + +sub format_name_with_tags { + my $self = shift; + return join ' ', $self->format_name, map { $_->format_name } $self->tags; +} + +sub format_tag_qualified_name { + my $self = shift; + my ($tag) = $self->tags; + unless ($tag) { + ($tag) = $self->rework_tags or return $self->format_name . '@HEAD'; + } + return join '', $self->format_name, $tag->format_name; +} + +sub format_dependencies { + my $self = shift; + my $deps = join( + ' ', + map { $_->as_plan_string } $self->requires, $self->conflicts + ) or return ''; + return "[$deps]"; +} + +sub format_name_with_dependencies { + my $self = shift; + my $dep = $self->format_dependencies or return $self->format_name; + return $self->format_name . $self->pspace . $dep; +} + +sub format_op_name_dependencies { + my $self = shift; + return $self->format_operator . $self->format_name_with_dependencies; +} + +sub format_planner { + my $self = shift; + return join ' ', $self->planner_name, '<' . $self->planner_email . '>'; +} + +sub deploy_handle { + my $self = shift; + $self->plan->open_script($self->deploy_file); +} + +sub revert_handle { + my $self = shift; + $self->plan->open_script($self->revert_file); +} + +sub verify_handle { + my $self = shift; + $self->plan->open_script($self->verify_file); +} + +sub format_content { + my $self = shift; + return $self->SUPER::format_content . $self->pspace . join ( + ' ', + ($self->format_dependencies || ()), + $self->timestamp->as_string, + $self->format_planner + ); +} + +sub requires_changes { + my $self = shift; + my $plan = $self->plan; + return map { $plan->find( $_->key_name ) } $self->requires; +} + +sub conflicts_changes { + my $self = shift; + my $plan = $self->plan; + return map { $plan->find( $_->key_name ) } $self->conflicts; +} + +sub note_prompt { + my ( $self, %p ) = @_; + + return join( + '', + __x( + "Please enter a note for your change. Lines starting with '#' will\n" . + "be ignored, and an empty message aborts the {command}.", + command => $p{for}, + ), + "\n", + __x('Change to {command}:', command => $p{for}), + "\n\n", + ' ', $self->format_op_name_dependencies, + join "\n ", '', @{ $p{scripts} }, + "\n", + ); +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Plan::Change - Sqitch deployment plan tag + +=head1 Synopsis + + my $plan = App::Sqitch::Plan->new( sqitch => $sqitch ); + for my $line ($plan->lines) { + say $line->as_string; + } + +=head1 Description + +A App::Sqitch::Plan::Change represents a change as parsed from a plan file. In +addition to the interface inherited from L, it offers +interfaces for parsing dependencies from the deploy script, as well as for +opening the deploy, revert, and verify scripts. + +=head1 Interface + +See L for the basics. + +=head2 Accessors + +=head3 C + +An L object representing the last tag to appear in the +plan B the change. May be C. + +=head3 C + +Blank space separating the change name from the dependencies, timestamp, and +planner in the file. + +=head3 C + +Boolean indicting whether or not the change has been reworked. + +=head3 C + +Information about the change, returned as a string. Includes the change ID, +the name and email address of the user who added the change to the plan, and +the timestamp for when the change was added to the plan. + +=head3 C + +A SHA1 hash of the data returned by C, which can be used as a +globally-unique identifier for the change. + +=head3 C + +Returns the an L object representing the time at which +the change was added to the plan. + +=head3 C + +Returns the name of the user who added the change to the plan. + +=head3 C + +Returns the email address of the user who added the change to the plan. + +=head3 C + +Parent change object. + +=head3 C + +A list of tag objects associated with the change. + +=head2 Instance Methods + +=head3 C + + my @segments = $change->path_segments; + +Returns the path segment for the change. For example, if the change is named +"foo", C<('foo.sql')> is returned. If the change is named "functions/bar> +C<('functions', 'bar.sql')> is returned. Internally, this data is used to +create the deploy, revert, and verify file names. + +=head3 C + + my $file = $change->deploy_dir; + +Returns the path to the deploy directory for the change. + +=head3 C + + my $file = $change->deploy_file; + +Returns the path to the deploy script file for the change. + +=head3 C + + my $file = $change->revert_dir; + +Returns the path to the revert directory for the change. + +=head3 C + + my $file = $change->revert_file; + +Returns the path to the revert script file for the change. + +=head3 C + + my $file = $change->verify_dir; + +Returns the path to the verify directory for the change. + +=head3 C + + my $file = $change->verify_file; + +Returns the path to the verify script file for the change. + +=head3 C + + my $file = $sqitch->script_file($script_name); + +Returns the path to a script, for the change. + +=head3 C + + my $hash = $change->script_hash; + +Returns the hex digest of the SHA-1 hash for the deploy script. + +=head3 C + + my @tags = $change->rework_tags; + +Returns a list of tags that occur between a change and its next reworking. +Returns an empty list if the change is not reworked. + +=head3 C + + $change->add_tag($tag); + +Adds a tag object to the change. + +=head3 C + + $change->add_rework_tags(@tags); + +Adds tags to the list of rework tags. + +=head3 C + + $change->clear_rework_tags(@tags); + +Clears the list of rework tags. + +=head3 C + + my @requires = $change->requires; + +Returns a list of L objects representing changes +required by this change. + +=head3 C + + my @requires_changes = $change->requires_changes; + +Returns a list of the C objects representing +changes required by this change. + +=head3 C + + my @conflicts = $change->conflicts; + +Returns a list of L objects representing changes +with which this change conflicts. + +=head3 C + + my @conflicts_changes = $change->conflicts_changes; + +Returns a list of the C objects representing +changes with which this change conflicts. + +=head3 C + + my @dependencies = $change->dependencies; + +Returns a list of L objects representing all +dependencies, required and conflicting. + +=head3 C + +Returns true if the change is intended to be deployed, and false if it should be +reverted. + +=head3 C + +Returns true if the change is intended to be reverted, and false if it should be +deployed. + +=head3 C + +Returns "deploy" if the change should be deployed, or "revert" if it should be +reverted. + +=head3 C + + my $tag_qualified_name = $change->format_tag_qualified_name; + +Returns a string with the change name followed by the next tag in the plan. +Useful for displaying unambiguous change specifications for reworked changes. +If there is no tag appearing in the file after the change, the C<@HEAD> will +be used. + +=head3 C + + my $name_with_tags = $change->format_name_with_tags; + +Returns a string formatted with the change name followed by the list of tags, if +any, associated with the change. Used to display a change as it is deployed. + +=head3 C + + my $dependencies = $change->format_dependencies; + +Returns a string containing a bracketed list of dependencies. If there are no +dependencies, an empty string will be returned. + +=head3 C + + my $name_with_dependencies = $change->format_name_with_dependencies; + +Returns a string formatted with the change name followed by a bracketed list +of dependencies, if any, associated with the change. Used to display a change +when added to a plan. + +=head3 C + + my $op_name_dependencies = $change->format_op_name_dependencies; + +Like C, but includes the operator, if present. + +=head3 C + + my $planner = $change->format_planner; + +Returns a string formatted with the name and email address of the user who +added the change to the plan. + +=head3 C + + my $fh = $change->deploy_handle; + +Returns an L file handle, opened for reading, for the deploy script +for the change. + +=head3 C + + my $fh = $change->revert_handle; + +Returns an L file handle, opened for reading, for the revert script +for the change. + +=head3 C + + my $fh = $change->verify_handle; + +Returns an L file handle, opened for reading, for the verify script +for the change. + +=head3 C + + my $prompt = $change->note_prompt( + for => 'rework', + scripts => [$change->deploy_file, $change->revert_file], + ); + +Overrides the implementation from C to add the +C parameter. This is a list of the files to be created for the command. +These will usually be the deploy, revert, and verify files, but the caller +might not be creating all of them, so it needs to pass the list. + +=head1 See Also + +=over + +=item L + +Class representing a plan. + +=item L + +Base class from which App::Sqitch::Plan::Change inherits. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Plan/ChangeList.pm b/lib/App/Sqitch/Plan/ChangeList.pm new file mode 100644 index 00000000..a1b19aaf --- /dev/null +++ b/lib/App/Sqitch/Plan/ChangeList.pm @@ -0,0 +1,433 @@ +package App::Sqitch::Plan::ChangeList; + +use 5.010; +use utf8; +use strict; +use List::Util; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); + +our $VERSION = 'v1.0.0'; # VERSION + +sub new { + my $class = shift; + my $self = bless { + list => [], + lookup => {}, + last_tagged_at => undef, + } => $class; + $self->append(@_) if @_; + return $self; +} + +sub count { scalar @{ shift->{list} } } +sub changes { @{ shift->{list} } } +sub tags { map { $_->tags } @{ shift->{list} } } +sub items { @{ shift->{list} } } +sub change_at { shift->{list}[shift] } +sub last_change { return shift->{list}[ -1 ] } + +# Like [:punct:], but excluding _. Copied from perlrecharclass. +my $punct = q{-!"#$%&'()*+,./:;<=>?@[\\]^`{|}~}; + +sub _offset($) { + # Look for symbolic references. + if ( $_[0] =~ s{(?_index_of( $key ) // return undef; + $idx += $offset; + return $idx < 0 ? undef : $idx > $#{ $self->{list} } ? undef : $idx; + } else { + return $self->_index_of( $key ); + } +} + +sub _index_of { + my ( $self, $key ) = @_; + + my ( $change, $tag ) = split /@/ => $key, 2; + + if ($change eq '') { + # Just want the change with the associated tag. + my $idx = $self->{lookup}{'@' . $tag} or return undef; + return $idx->[0]; + } + + my $idx = $self->{lookup}{$change} or return undef; + if (defined $tag) { + # Wanted for a change as of a specific tag. + my $tag_idx = $self->{lookup}{'@' . $tag} or hurl plan => __x( + 'Unknown tag "{tag}"', + tag => '@' . $tag, + ); + $tag_idx = $tag_idx->[0]; + for my $i (reverse @{ $idx }) { + return $i if $i <= $tag_idx; + } + return undef; + }; + + # Just want index for a change name. Return if we have 0 or 1. + return $idx->[0] if @{ $idx } < 2; + + + # Too many changes found. Give the user some options and die. + App::Sqitch->vent(__x( + 'Change "{change}" is ambiguous. Please specify a tag-qualified change:', + change => $key, + )); + + my $list = $self->{list}; + App::Sqitch->vent( ' * ', $list->[$_]->format_tag_qualified_name ) + for reverse @{ $idx }; + + hurl plan => __ 'Change lookup failed'; +} + +sub first_index_of { + my ( $self, $key, $since ) = @_; + + # Look for non-deployed symbolic references. + if ( my $offset = _offset $key ) { + my $idx = $self->_first_index_of( $key, $since ) // return undef; + $idx += $offset; + return $idx < 0 ? undef : $idx > $#{ $self->{list} } ? undef : $idx; + } else { + return $self->_first_index_of( $key, $since ); + } +} + +sub _first_index_of { + my ( $self, $change, $since ) = @_; + + # Just return the first index if no tag. + my $idx = $self->{lookup}{$change} or return undef; + return $idx->[0] unless defined $since; + + # Find the tag index. + my $since_index = $self->index_of($since) // hurl plan => __x( + 'Unknown change: "{change}"', + change => $since, + ); + + # Return the first change after the tag. + return List::Util::first { $_ > $since_index } @{ $idx }; +} + +sub index_of_last_tagged { + shift->{last_tagged_at}; +} + +sub last_tagged_change { + my $self = shift; + return defined $self->{last_tagged_at} + ? $self->{list}[ $self->{last_tagged_at} ] + : undef; +} + +sub get { + my $self = shift; + my $idx = $self->index_of(@_) // return undef; + return $self->{list}[ $idx ]; +} + +sub contains { + my ( $self, $name ) = @_; + return defined ( $name =~ /@/ + ? $self->index_of($name) + : $self->first_index_of($name) + ); +} + +sub find { + my ( $self, $name ) = @_; + my $idx = $name =~ /@/ + ? $self->index_of($name) + : $self->first_index_of($name); + return defined $idx ? $self->change_at($idx) : undef; +} + +sub append { + my $self = shift; + my $list = $self->{list}; + my $lookup = $self->{lookup}; + + for my $change (@_) { + push @{ $list } => $change; + push @{ $lookup->{ $change->format_name } } => $#$list; + $lookup->{ $change->id } = my $pos = [$#$list]; + + # Index on the tags, too. + for my $tag ($change->tags) { + $lookup->{ $tag->format_name } = $pos; + $lookup->{ $tag->id } = $pos; + $self->{last_tagged_at} = $#$list; + } + } + + $lookup->{'HEAD'} = $lookup->{'@HEAD'} = [$#$list]; + $lookup->{'ROOT'} = $lookup->{'@ROOT'} = [0]; + + return $self; +} + +sub index_tag { + my ( $self, $index, $tag ) = @_; + my $list = $self->{list}; + my $lookup = $self->{lookup}; + my $pos = [$index]; + $lookup->{ $tag->id } = $pos; + $lookup->{ $tag->format_name } = $pos; + $self->{last_tagged_at} = $index if $index == $#{ $self->{list} }; + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Plan::ChangeList - Sqitch deployment plan change list + +=head1 Synopsis + + my $list = App::Sqitch::Plan::ChangeList->new( + $add_roles, + $add_users, + $insert_user, + $insert_user2, + ); + + my @changes = $list->changes; + my $add_users = $list->change_at(1); + my $add_users = $list->get('add_users'); + + my $insert_user1 = $list->get('insert_user@alpha'); + my $insert_user2 = $list->get('insert_user'); + +=head1 Description + +This module is used internally by L to manage plan changes. +It's modeled on L and L, but makes allowances +for finding changes relative to tags. + +=head1 Interface + +=head2 Constructors + +=head3 C + + my $plan = App::Sqitch::Plan::ChangeList->new( @changes ); + +Instantiates and returns a App::Sqitch::Plan::ChangeList object with the list of +changes. Each change should be a L object. Order will be +preserved but the location of each change will be indexed by its name and ID, as +well as the names and IDs of any associated tags. + +=head2 Instance Methods + +=head3 C + + my $count = $changelist->count; + +Returns the number of changes in the list. + +=head3 C + + my @changes = $changelist->changes; + +Returns all of the changes in the list. + +=head3 C + + my @tags = $changelist->tags; + +Returns all of the tags associated with changes in the list. + +=head3 C + + my @changes = $changelist->items; + +An alias for C. + +=head3 C + + my $change = $change_list->change_at(10); + +Returns the change at the specified index. + +=head3 C + + my $index = $changelist->index_of($change_id); + my $index = $changelist->index_of($change_name); + +Returns the index of the change with the specified ID or name. The value passed +may be one of these forms: + +=over + +=item * An ID + + my $index = $changelist->index_of('6c2f28d125aff1deea615f8de774599acf39a7a1'); + +This is the SHA1 ID of a change or tag. Currently, the full 40-character hexed +hash string must be specified. + +=item * A change name + + my $index = $changelist->index_of('users_table'); + +The name of a change. Will throw an exception if the more than one change in the +list goes by that name. + +=item * A tag name + + my $index = $changelist->index_of('@beta1'); + +The name of a tag, including the leading C<@>. + +=item * A tag-qualified change name + + my $index = $changelist->index_of('users_table@beta1'); + +The named change as it was last seen in the list before the specified tag. + +=back + +=head3 C + + my $index = $changelist->first_index_of($change_name); + my $index = $changelist->first_index_of($change_name, $name); + +Returns the index of the first instance of the named change in the list. If a +second argument is passed, the index of the first instance of the change +I the index of the second argument will be returned. This is useful +for getting the index of a change as it was deployed after a particular tag, for +example: + + my $index = $changelist->first_index_of('foo', '@beta'); + my $index = $changelist->first_index_of('foo', 'users_table@beta1'); + +The second argument must unambiguously refer to a single change in the list. As +such, it should usually be a tag name or tag-qualified change name. Returns +C if the change does not appear in the list, or if it does not appear +after the specified second argument change name. + +=head3 C + + my $change = $changelist->last_change; + +Returns the last change to be appear in the list. Returns C if the list +contains no changes. + +=head3 C + + my $change = $changelist->last_tagged_change; + +Returns the last tagged change in the list. Returns C if the list +contains no tagged changes. + +=head3 C + + my $index = $changelist->index_of_last_tagged; + +Returns the index of the last tagged change in the list. Returns C if the +list contains no tags. + +=head3 C + + my $change = $changelist->get($id); + my $change = $changelist->get($change_name); + my $change = $changelist->get($tag_name); + +Returns the change for the specified ID or name. The name may be specified as +described for C. An exception will be thrown if more than one change +goes by a specified name. As such, it is best to specify it as unambiguously +as possible: as a tag name, a tag-qualified change name, or an ID. + +=head3 C + + say 'Yes!' if $plan->contains('6c2f28d125aff1deea615f8de774599acf39a7a1'); + +Like C, but never throws an exception, and returns true if the +plan contains the specified change, and false if it does not. + +=head3 C + + my $change = $changelist->find($id); + my $change = $changelist->find($change_name); + my $change = $changelist->find($tag_name); + my $change = $changelist->find("$change_name\@$tag_name"); + +Tries to find and return a change based on the argument. If no tag is specified, +finds and returns the first instance of the named change. Otherwise, it returns +the change as of the specified tag. Unlike C, it will not throw an error +if more than one change exists with the specified name, but will return the +first instance. + +=head3 C + + $changelist->append(@changes); + +Append one or more changes to the list. Does not check for duplicates, so +use with care. + +=head3 C + + $changelist->index_tag($index, $tag); + +Index the tag at the specified index. That is, the tag is assumed to be +associated with the change at the specified index, and so the internal look up +table is updated so that the change at that index can be found via the tag's +name and ID. + +=head1 See Also + +=over + +=item L + +The Sqitch plan. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Plan/Depend.pm b/lib/App/Sqitch/Plan/Depend.pm new file mode 100644 index 00000000..cbb792b2 --- /dev/null +++ b/lib/App/Sqitch/Plan/Depend.pm @@ -0,0 +1,389 @@ +package App::Sqitch::Plan::Depend; + +use 5.010; +use utf8; +use Moo; +use App::Sqitch::Types qw(Str Bool Maybe Plan); +use App::Sqitch::Plan; +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use namespace::autoclean; + +our $VERSION = 'v1.0.0'; # VERSION + +has conflicts => ( + is => 'ro', + isa => Bool, + default => 0, +); + +has got_id => ( + is => 'ro', + isa => Bool, + required => 1 +); + +has got_project => ( + is => 'ro', + isa => Bool, + required => 1 +); + +has project => ( + is => 'ro', + isa => Maybe[Str], + lazy => 1, + default => sub { + my $self = shift; + my $plan = $self->plan; + + # Local project is the default unless an ID was passed. + return $plan->project unless $self->got_id; + + # Local project is default if passed ID is in plan. + return $plan->project if $plan->find( $self->id ); + + # Otherwise, the project is unknown (and external). + return undef; + } +); + +has change => ( + is => 'ro', + isa => Maybe[Str], +); + +has tag => ( + is => 'ro', + isa => Maybe[Str], +); + +has plan => ( + is => 'ro', + isa => Plan, + weak_ref => 1, + required => 1, +); + +has id => ( + is => 'ro', + isa => Maybe[Str], + lazy => 1, + default => sub { + my $self = shift; + my $plan = $self->plan; + my $proj = $self->project // return undef; + return undef if $proj ne $plan->project; + my $change = $plan->find( $self->key_name ) // hurl plan => __x( + 'Unable to find change "{change}" in plan {file}', + change => $self->key_name, + file => $plan->file, + ); + return $change->id; + } +); + +has resolved_id => ( + is => 'rw', + isa => Maybe[Str], +); + +has is_external => ( + is => 'ro', + isa => Bool, + lazy => 1, + default => sub { + my $self = shift; + + # If no project, then it must be external. + my $proj = $self->project // return 1; + + # Just compare to the local project. + return $proj eq $self->plan->project ? 0 : 1; + }, +); + +sub type { shift->conflicts ? 'conflict' : 'require' } +sub required { shift->conflicts ? 0 : 1 } +sub is_internal { shift->is_external ? 0 : 1 } + +sub BUILDARGS { + my $class = shift; + my $p = @_ == 1 && ref $_[0] ? { %{ +shift } } : { @_ }; + hurl 'Depend object must have either "change", "tag", or "id" defined' + unless length($p->{change} // '') || length($p->{tag} // '') || $p->{id}; + + hurl 'Depend object cannot contain both an ID and a tag or change' + if $p->{id} && (length($p->{change} // '') || length($p->{tag} // '')); + + $p->{got_id} = defined $p->{id} ? 1 : 0; + $p->{got_project} = defined $p->{project} ? 1 : 0; + + return $p; +} + +sub parse { + my ( $class, $string ) = @_; + my $name_re = Plan->class->name_regex; + return undef if $string !~ / + \A # Beginning of string + (?!?) # Optional negation + (?:(?$name_re)[:])? # Optional project + : + (?: # Followed by... + (?[0-9a-f]{40}) # SHA1 hash + | # - OR - + (?$name_re) # Change name + (?:[@](?$name_re))? # Optional tag + | # - OR - + (?:[@](?$name_re))? # Tag + ) # ... required + \z # End of string + /x; + + return { %+, conflicts => $+{conflicts} ? 1 : 0 }; +} + +sub key_name { + my $self = shift; + my @parts; + + if (defined (my $change = $self->change)) { + push @parts => $change; + } + + if (defined (my $tag = $self->tag)) { + push @parts => '@' . $tag; + } + + if ( !@parts && defined ( my $id = $self->id ) ) { + push @parts, $id; + } + + return join '' => @parts; +} + +sub as_string { + my $self = shift; + my $proj = $self->project // return $self->key_name; + return $self->key_name if $proj eq $self->plan->project; + return "$proj:" . $self->key_name; +} + +sub as_plan_string { + my $self = shift; + return ($self->conflicts ? '!' : '') . $self->as_string; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Plan::Depend - Sqitch dependency specification + +=head1 Synopsis + + my $depend = App::Sqitch::Plan::Depend->new( + plan => $plan, + App::Sqitch::Plan::Depend->parse('!proj:change@tag') + ); + +=head1 Description + +An App::Sqitch::Plan::Line represents a single dependency from the dependency +list for a planned change. Is is constructed by L and +included in L objects C and C +attributes. + +=head1 Interface + +=head2 Constructors + +=head3 C + + my $depend = App::Sqitch::Plan::Depend->new(%params); + +Instantiates and returns a App::Sqitch::Plan::Line object. Parameters: + +=over + +=item C + +The plan with which the dependency is associated. Required. + +=item C + +Name of the project. Required. + +=item C + +Boolean to indicate whether the dependency is a conflicting dependency. + +=item C + +The name of the change. + +=item C + +The name of the tag claimed as the dependency. + +=item C + +The ID of a change. Mutually exclusive with C and C. + +=back + +=head3 C + + my %params = App::Sqitch::Plan::Depend->parse($string); + +Parses a dependency specification as extracted from a plan and returns a hash +reference of parameters suitable for passing to C. Returns C if +the string is not a properly-formatted dependency. + +=head2 Accessors + +=head3 C + + my $plan = $depend->plan; + +Returns the L object with which the dependency +specification is associated. + +=head3 C + + say $depend->as_string, ' conflicts' if $depend->conflicts; + +Returns true if the dependency is a conflicting dependency, and false if it +is not (in which case it is a required dependency). + +=head3 C + + say $depend->as_string, ' required' if $depend->required; + +Returns true if the dependency is a required, and false if it is not (in which +case it is a conflicting dependency). + +=head3 C + + say $depend->type; + +Returns a string indicating the type of dependency, either "require" or +"conflict". + +=head3 C + + my $proj = $depend->project; + +Returns the name of the project with which the dependency is associated. + +=head3 C + +Returns true if the C parameter was passed to the constructor with a +defined value, and false if it was not passed to the constructor. + +=head3 C + + my $change = $depend->change; + +Returns the name of the change, if any. If C is returned, the dependency +is a tag-only dependency. + +=head3 C + + my $tag = $depend->tag; + +Returns the name of the tag, if any. If C is returned, the dependency +is a change-only dependency. + +=head3 C + +Returns the ID of the change if the dependency was specified as an ID, or if +the dependency is a local dependency. + +=head3 C + +Returns true if the C parameter was passed to the constructor with a +defined value, and false if it was not passed to the constructor. + +=head3 C + +Change ID used by the engine when deploying a change. That is, if the +dependency is in the database, it will be assigned this ID from the database. +If it is not in the database, C will be undef. + +=head3 C + +Returns true if the dependency references a change external to the current +project, and false if it is part of the current project. + +=head3 C + +The opposite of C: returns true if the dependency is in the +internal (current) project, and false if not. + +=head2 Instance Methods + +=head3 C + +Returns the key name of the dependency, with the change name and/or tag, +properly formatted for passing to the C method of +L. If the dependency was specified as an ID, rather than a +change or tag, then the ID will be returned. + +=head3 C + +Returns the project-qualified key name. That is, if there is a project name, +it returns a string with the project name, a colon, and the key name. If there +is no project name, the key name is returned. + +=head3 C + + my $string = $depend->as_string; + +Returns the full stringification of the dependency, suitable for output to a +plan file. That is, the same as C unless C returns true, +in which case it is prepended with "!". + +=head1 See Also + +=over + +=item L + +Class representing a plan. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Plan/Line.pm b/lib/App/Sqitch/Plan/Line.pm new file mode 100644 index 00000000..2392f06b --- /dev/null +++ b/lib/App/Sqitch/Plan/Line.pm @@ -0,0 +1,370 @@ +package App::Sqitch::Plan::Line; + +use 5.010; +use utf8; +use namespace::autoclean; +use Moo; +use App::Sqitch::Types qw(Str Plan); +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); + +our $VERSION = 'v1.0.0'; # VERSION + +has name => ( + is => 'ro', + isa => Str, + required => 1, +); + +has operator => ( + is => 'ro', + isa => Str, + default => '', +); + +has lspace => ( + is => 'ro', + isa => Str, + default => '', +); + +has rspace => ( + is => 'rwp', + isa => Str, + default => '', +); + +has lopspace => ( + is => 'ro', + isa => Str, + default => '', +); + +has ropspace => ( + is => 'ro', + isa => Str, + default => '', +); + +has note => ( + is => 'rw', + isa => Str, + default => '', +); + +after note => sub { + my $self = shift; + $self->_set_rspace(' ') if $_[0] && !$self->rspace; +}; + +has plan => ( + is => 'ro', + isa => Plan, + weak_ref => 1, + required => 1, + handles => [qw(sqitch project uri target)], +); + +my %escape = ( + "\n" => '\\n', + "\r" => '\\r', + '\\' => '\\\\', +); + +my %unescape = reverse %escape; + +sub BUILDARGS { + my $class = shift; + my $p = @_ == 1 && ref $_[0] ? { %{ +shift } } : { @_ }; + if (my $note = $p->{note}) { + # Trim and then encode newlines. + $note =~ s/\A\s+//; + $note =~ s/\s+\z//; + $note =~ s/(\\[\\nr])/$unescape{$1}/g; + $p->{note} = $note; + $p->{rspace} //= ' ' if $note && $p->{name}; + } + return $p; +} + +sub request_note { + my ( $self, %p ) = @_; + my $note = $self->note // ''; + return $note if $note =~ /\S/; + + # Edit in a file. + require File::Temp; + my $tmp = File::Temp->new; + binmode $tmp, ':utf8_strict'; + ( my $prompt = $self->note_prompt(%p) ) =~ s/^/# /gms; + $tmp->print( "\n", $prompt, "\n" ); + $tmp->close; + + my $sqitch = $self->sqitch; + $sqitch->shell( $sqitch->editor . ' ' . $sqitch->quote_shell($tmp) ); + + open my $fh, '<:utf8_strict', $tmp or hurl add => __x( + 'Cannot open {file}: {error}', + file => $tmp, + error => $! + ); + + $note = join '', grep { $_ !~ /^\s*#/ } <$fh>; + hurl { + ident => 'plan', + message => __ 'Aborting due to empty note', + exitval => 1, + } unless $note =~ /\S/; + + # Trim the note. + $note =~ s/\A\v+//; + $note =~ s/\v+\z//; + + # Set the note. + $self->note($note); + return $note; +} + +sub note_prompt { + my ( $self, %p ) = @_; + __x( + "Write a {command} note.\nLines starting with '#' will be ignored.", + command => $p{for} + ); +} + +sub format_name { + shift->name; +} + +sub format_operator { + my $self = shift; + join '', $self->lopspace, $self->operator, $self->ropspace; +} + +sub format_content { + my $self = shift; + join '', $self->format_operator, $self->format_name; +} + +sub format_note { + my $note = shift->note; + return '' unless length $note; + $note =~ s/([\r\n\\])/$escape{$1}/g; + return "# $note"; +} + +sub as_string { + my $self = shift; + return $self->lspace + . $self->format_content + . $self->rspace + . $self->format_note; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Plan::Line - Sqitch deployment plan line + +=head1 Synopsis + + my $plan = App::Sqitch::Plan->new( sqitch => $sqitch ); + for my $line ($plan->lines) { + say $line->as_string; + } + +=head1 Description + +An App::Sqitch::Plan::Line represents a single line from a Sqitch plan file. +Each object managed by an L object is derived from this +class. This is actually an abstract base class. See +L, L, and +L for concrete subclasses. + +=head1 Interface + +=head2 Constructors + +=head3 C + + my $plan = App::Sqitch::Plan::Line->new(%params); + +Instantiates and returns a App::Sqitch::Plan::Line object. Parameters: + +=over + +=item C + +The L object with which the line is associated. + +=item C + +The name of the line. Should be empty for blank lines. Tags names should +not include the leading C<@>. + +=item C + +The white space from the beginning of the line, if any. + +=item C + +The white space to the left of the operator, if any. + +=item C + +An operator, if any. + +=item C + +The white space to the right of the operator, if any. + +=item C + +The white space after the name until the end of the line or the start of a +note. + +=item C + +A note. Does not include the leading C<#>, but does include any white space +immediate after the C<#> when the plan file is parsed. + +=back + +=head2 Accessors + +=head3 C + + my $plan = $line->plan; + +Returns the plan object with which the line object is associated. + +=head3 C + + my $name = $line->name; + +Returns the name of the line. Returns an empty string if there is no name. + +=head3 C + + my $lspace = $line->lspace. + +Returns the white space from the beginning of the line, if any. + +=head3 C + + my $rspace = $line->rspace. + +Returns the white space after the name until the end of the line or the start +of a note. + +=head3 C + + my $note = $line->note. + +Returns the note. Does not include the leading C<#>, but does include any +white space immediate after the C<#> when the plan file is parsed. Returns the +empty string if there is no note. + +=head2 Instance Methods + +=head3 C + + my $formatted_name = $line->format_name; + +Returns the name of the line properly formatted for output. For +L, it's the name with a leading C<@>. For all +other lines, it is simply the name. + +=head3 C + + my $formatted_operator = $line->format_operator; + +Returns the formatted representation of the operator. This is just the +operator an its associated white space. If neither the operator nor its white +space exists, an empty string is returned. Used internally by C. + +=head3 C + + my $formatted_content $line->format_content; + +Formats and returns the main content of the line. This consists of an operator +and its associated white space, if any, followed by the formatted name. + +=head3 C + + my $note = $line->format_note; + +Returns the note formatted for output. That is, with a leading C<#> and +newlines encoded. + +=head3 C + + my $string = $line->as_string; + +Returns the full stringification of the line, suitable for output to a plan +file. + +=head3 C + + my $note = $line->request_note( for => 'add' ); + +Request the note from the user. Pass in the name of the command for which the +note is requested via the C parameter. If there is a note, it is simply +returned. Otherwise, an editor will be launched and the user asked to write +one. Once the editor exits, the note will be retrieved from the file, saved, +and returned. If no note was written, an exception will be thrown with an +C of 1. + +=head3 C + + my $prompt = $line->note_prompt( for => 'tag' ); + +Returns a localized string for use in the temporary file created by +C. Pass in the name of the command for which to prompt via the +C parameter. + +=head1 See Also + +=over + +=item L + +Class representing a plan. + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Plan/LineList.pm b/lib/App/Sqitch/Plan/LineList.pm new file mode 100644 index 00000000..c15ea735 --- /dev/null +++ b/lib/App/Sqitch/Plan/LineList.pm @@ -0,0 +1,133 @@ +package App::Sqitch::Plan::LineList; + +use 5.010; +use strict; +use utf8; + +our $VERSION = 'v1.0.0'; # VERSION + +sub new { + my $class = shift; + my (@list, %index); + for my $line (@_) { + push @list => $line; + $index{ $line } = $#list; + } + + return bless { + list => \@list, + lookup => \%index, + } +} + +sub count { scalar @{ shift->{list} } } +sub items { @{ shift->{list} } } +sub item_at { shift->{list}->[shift] } +sub index_of { shift->{lookup}{+shift} } + +sub append { + my ( $self, $line ) = @_; + my $list = $self->{list}; + push @{ $list } => $line; + $self->{lookup}{$line} = $#$list; +} + +sub insert_at { + my ( $self, $line, $idx ) = @_; + + # Add the line to the list. + my $list = $self->{list}; + splice @{ $list }, $idx, 0, $line; + + # Reindex. + my $index = $self->{lookup}; + $index->{ $list->[$_] } = $_ for $idx..$#$list; + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Plan::LineList - Sqitch deployment plan line list + +=head1 Synopsis + + my $list = App::Sqitch::Plan::LineList->new(@lines); + + my @lines = $list->items; + my $index = $list->index_of($line); + + $lines->append($another_line); + +=head1 Description + +This module is used internally by L to manage plan file +lines. It's modeled on L, but is much simpler and hews closer +to the API of L. + +=head1 Interface + +=head2 Constructors + +=head3 C + + my $plan = App::Sqitch::Plan::LineList->new(map { $_->name => @_ } @changes ); + +Instantiates and returns a App::Sqitch::Plan::LineList object. The parameters +should be a key/value list of lines. Keys may be duplicated, as long as a tag +appears between each instance of a key. + +=head2 Instance Methods + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head1 See Also + +=over + +=item L + +The Sqitch plan. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Plan/Pragma.pm b/lib/App/Sqitch/Plan/Pragma.pm new file mode 100644 index 00000000..8b55c8eb --- /dev/null +++ b/lib/App/Sqitch/Plan/Pragma.pm @@ -0,0 +1,125 @@ +package App::Sqitch::Plan::Pragma; + +use 5.010; +use utf8; +use namespace::autoclean; +use Moo; +use App::Sqitch::Types qw(Str); +extends 'App::Sqitch::Plan::Line'; + +our $VERSION = 'v1.0.0'; # VERSION + +has value => ( + is => 'ro', + isa => Str, +); + +has hspace => ( + is => 'ro', + isa => Str, + default => '', +); + +sub BUILDARGS { + my $class = shift; + my $p = @_ == 1 && ref $_[0] ? { %{ +shift } } : { @_ }; + $p->{value} =~ s/\s+$// if $p->{value}; + $p->{op} //= ''; + return $p; +} + +sub format_name { + my $self = shift; + return '%' . $self->hspace . $self->name; +} + +sub format_value { + shift->value // ''; +} + +sub format_content { + my $self = shift; + join '', $self->format_name, $self->format_operator, $self->format_value; +} + +sub as_string { + my $self = shift; + return $self->lspace + . $self->format_content + . $self->rspace + . $self->format_note; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Plan::Pragma.pm - Sqitch deployment plan blank line + +=head1 Synopsis + + my $plan = App::Sqitch::Plan->new( sqitch => $sqitch ); + for my $line ($plan->lines) { + say $line->as_string; + } + +=head1 Description + +An App::Sqitch::Plan::Pragma represents a plan file pragma. See +L for its interface. + +=head1 Interface + +In addition to the interface inherited from L, +App::Sqitch::Plan::Line::Pragma adds a few methods of its own. + +=head2 Accessors + +=head3 C + +The value of the pragma. + +=head3 C + +The operator, including surrounding white space. + +=head3 C + +The horizontal space between the pragma and its value. + +=head2 Instance Methods + +=head3 C + +Formats the value for output. If there is no value, an empty string is +returned. Otherwise the value is returned as-is. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Plan/Tag.pm b/lib/App/Sqitch/Plan/Tag.pm new file mode 100644 index 00000000..2dcfe7fe --- /dev/null +++ b/lib/App/Sqitch/Plan/Tag.pm @@ -0,0 +1,181 @@ +package App::Sqitch::Plan::Tag; + +use 5.010; +use utf8; +use namespace::autoclean; +use Moo; +use App::Sqitch::Types qw(Str Change UserEmail DateTime); +use Encode; + +extends 'App::Sqitch::Plan::Line'; + +our $VERSION = 'v1.0.0'; # VERSION + +sub format_name { + '@' . shift->name; +} + +has info => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $self = shift; + my $plan = $self->plan; + + return join "\n", ( + 'project ' . $self->project, + ( $self->uri ? ( 'uri ' . $self->uri->canonical ) : () ), + 'tag ' . $self->format_name, + 'change ' . $self->change->id, + 'planner ' . $self->format_planner, + 'date ' . $self->timestamp->as_string, + ( $self->note ? ('', $self->note) : ()), + ); + } +); + +has id => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $content = encode_utf8 shift->info; + require Digest::SHA; + return Digest::SHA->new(1)->add( + 'tag ' . length($content) . "\0" . $content + )->hexdigest; + } +); + +has change => ( + is => 'ro', + isa => Change, + weak_ref => 1, + required => 1, +); + +has timestamp => ( + is => 'ro', + isa => DateTime, + default => sub { require App::Sqitch::DateTime && App::Sqitch::DateTime->now }, +); + +has planner_name => ( + is => 'ro', + isa => Str, + default => sub { shift->sqitch->user_name }, +); + +has planner_email => ( + is => 'ro', + isa => UserEmail, + default => sub { shift->sqitch->user_email }, +); + +sub format_planner { + my $self = shift; + return join ' ', $self->planner_name, '<' . $self->planner_email . '>'; +} + +sub format_content { + my $self = shift; + return join ' ', + $self->SUPER::format_content, + $self->timestamp->as_string, + $self->format_planner; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Plan::Tag - Sqitch deployment plan tag + +=head1 Synopsis + + my $plan = App::Sqitch::Plan->new( sqitch => $sqitch ); + for my $line ($plan->lines) { + say $line->as_string; + } + +=head1 Description + +A App::Sqitch::Plan::Tag represents a tag as parsed from a plan file. In +addition to the interface inherited from L, it offers +interfaces fetching and formatting timestamp and planner information. + +=head1 Interface + +See L for the basics. + +=head2 Accessors + +=head3 C + +Returns the L object with which the tag is +associated. + +=head3 C + +Returns the an L object representing the time at which +the tag was added to the plan. + +=head3 C + +Returns the name of the user who added the tag to the plan. + +=head3 C + +Returns the email address of the user who added the tag to the plan. + +=head3 C + +Information about the tag, returned as a string. Includes the tag ID, the ID +of the associated change, the name and email address of the user who added the +tag to the plan, and the timestamp for when the tag was added to the plan. + +=head3 C + +A SHA1 hash of the data returned by C, which can be used as a +globally-unique identifier for the tag. + +=head2 Instance Methods + +=head3 C + + my $planner = $tag->format_planner; + +Returns a string formatted with the name and email address of the user who +added the tag to the plan. + + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Role/ConnectingCommand.pm b/lib/App/Sqitch/Role/ConnectingCommand.pm new file mode 100644 index 00000000..479f0be6 --- /dev/null +++ b/lib/App/Sqitch/Role/ConnectingCommand.pm @@ -0,0 +1,143 @@ +package App::Sqitch::Role::ConnectingCommand; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo::Role; +use App::Sqitch::Types qw(ArrayRef); + +our $VERSION = 'v1.0.0'; # VERSION + +requires 'options'; +requires 'configure'; +requires 'target_params'; + +has _params => ( + is => 'ro', + isa => ArrayRef, + default => sub { [] }, +); + +around options => sub { + my $orig = shift; + return $orig->(@_), qw( + registry=s + client|db-client=s + db-name|d=s + db-user|db-username|u=s + db-host|h=s + db-port|p=i + ); +}; + +around configure => sub { + my ( $orig, $class, $config, $opt ) = @_; + + # Grab the options we're responsible for. + my @params = ( + (exists $opt->{db_user} ? ('user', => delete $opt->{db_user}) : ()), + (exists $opt->{db_host} ? ('host', => delete $opt->{db_host}) : ()), + (exists $opt->{db_port} ? ('port', => delete $opt->{db_port}) : ()), + (exists $opt->{db_name} ? ('dbname' => delete $opt->{db_name}) : ()), + (exists $opt->{registry} ? ('registry' => delete $opt->{registry}) : ()), + (exists $opt->{client} ? ('client' => delete $opt->{client}) : ()), + ); + + # Let the command take care of its options. + my $params = $class->$orig($config, $opt); + + # Hang on to the target parameters. + $params->{_params} = \@params; + return $params; +}; + +around target_params => sub { + my ($orig, $self) = (shift, shift); + return $self->$orig(@_), @{ $self->_params }; +}; + +1; + +__END__ + +=head1 Name + +App::Sqitch::Role::ConnectingCommand - A command that connects to a target + +=head1 Synopsis + + package App::Sqitch::Command::deploy; + extends 'App::Sqitch::Command'; + with 'App::Sqitch::Role::ConnectingCommand'; + +=head1 Description + +This role encapsulates the options and target parameters required by commands +that connect to a database target. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::deploy->options; + +Adds database connection options. + +=head3 C + +Configures the options used for target parameters. + +=head2 Instance Methods + +=head3 C + +Returns a list of parameters to be passed to App::Sqitch::Target's C +and C methods. +=head1 See Also + +=over + +=item L + +The C command deploys changes to a database. + +=item L + +The C command reverts changes from a database. + +=item L + +The C command shows the event log for a database. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Role/ContextCommand.pm b/lib/App/Sqitch/Role/ContextCommand.pm new file mode 100644 index 00000000..de9263f4 --- /dev/null +++ b/lib/App/Sqitch/Role/ContextCommand.pm @@ -0,0 +1,145 @@ +package App::Sqitch::Role::ContextCommand; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo::Role; +use Path::Class; +use App::Sqitch::Types qw(ArrayRef); +use Locale::TextDomain qw(App-Sqitch); # XXX Until deprecation removed below. + +our $VERSION = 'v1.0.0'; # VERSION + +requires 'options'; +requires 'configure'; +requires 'target_params'; +requires 'command'; # XXX Until deprecation removed below. + +has _cx => ( + is => 'ro', + isa => ArrayRef, + default => sub { [] }, +); + +around options => sub { + my $orig = shift; + return $orig->(@_), qw( + plan-file|f=s + top-dir=s + ); +}; + +around configure => sub { + my ( $orig, $class, $config, $opt ) = @_; + + # DEPRECATTION: --top-dir deprecated in v0.9999. Remove at some point. + App::Sqitch->warn(__x( + " Option --top-dir is deprecated for {command} and other non-configuration commands.\n Use --chdir instead.", + command => $class->command, + )) if $opt->{top_dir}; + + # Grab the target params. + my @cx = ( + do { my $f = delete $opt->{top_dir}; $f ? ( top_dir => dir($f)) : () }, + do { my $f = delete $opt->{plan_file}; $f ? ( plan_file => file($f)) : () }, + ); + + # Let the command take care of its options. + my $params = $class->$orig($config, $opt); + + # Hang on to the target parameters. + $params->{_cx} = \@cx; + return $params; +}; + +around target_params => sub { + my ($orig, $self) = (shift, shift); + return $self->$orig(@_), @{ $self->_cx }; +}; + +1; + +__END__ + +=head1 Name + +App::Sqitch::Role::ContextCommand - A command that needs to know where things are + +=head1 Synopsis + + package App::Sqitch::Command::add; + extends 'App::Sqitch::Command'; + with 'App::Sqitch::Role::ContextCommand'; + +=head1 Description + +This role encapsulates the options and target parameters required by commands +that need to know where to find project files. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::add->options; + +Adds contextual options C<--plan-file> and C<--top-dir>. + +=head3 C + +Configures the options used for target parameters. + +=head2 Instance Methods + +=head3 C + +Returns a list of parameters to be passed to App::Sqitch::Target's C +and C methods. + +=head1 See Also + +=over + +=item L + +The C command adds changes to the the plan and change scripts to the project. + +=item L + +The C command deploys changes to a database. + +=item L + +The C command bundles Sqitch changes for distribution. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Role/DBIEngine.pm b/lib/App/Sqitch/Role/DBIEngine.pm new file mode 100644 index 00000000..4b51062c --- /dev/null +++ b/lib/App/Sqitch/Role/DBIEngine.pm @@ -0,0 +1,1132 @@ +package App::Sqitch::Role::DBIEngine; + +use 5.010; +use strict; +use warnings; +use utf8; +use DBI; +use Moo::Role; +use Try::Tiny; +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use namespace::autoclean; + +our $VERSION = 'v1.0.0'; # VERSION + +requires 'dbh'; +requires 'sqitch'; +requires 'plan'; +requires '_regex_op'; +requires '_ts2char_format'; +requires '_char2ts'; +requires '_listagg_format'; +requires '_no_table_error'; +requires '_handle_lookup_index'; + +sub _dt($) { + require App::Sqitch::DateTime; + return App::Sqitch::DateTime->new(split /:/ => shift); +} + +sub _log_tags_param { + join ' ' => map { $_->format_name } $_[1]->tags; +} + +sub _log_requires_param { + join ',' => map { $_->as_string } $_[1]->requires; +} + +sub _log_conflicts_param { + join ',' => map { $_->as_string } $_[1]->conflicts; +} + +sub _ts_default { 'DEFAULT' } + +sub _can_limit { 1 } +sub _limit_default { undef } + +sub _simple_from { '' } + +sub _quote_idents { shift; @_ } + +sub _in_expr { + my ($self, $vals) = @_; + my $in = sprintf 'IN (%s)', join ', ', ('?') x @{ $vals }; + return $in, @{ $vals }; +} + +sub _register_release { + my $self = shift; + my $version = shift || $self->registry_release; + my $sqitch = $self->sqitch; + my $ts = $self->_ts_default; + + $self->begin_work; + $self->dbh->do(qq{ + INSERT INTO releases (version, installed_at, installer_name, installer_email) + VALUES (?, $ts, ?, ?) + }, undef, $version, $sqitch->user_name, $sqitch->user_email); + $self->finish_work; + return $self; +} + +sub _version_query { 'SELECT MAX(version) FROM releases' } + +sub registry_version { + my $self = shift; + try { + $self->dbh->selectcol_arrayref($self->_version_query)->[0]; + } catch { + return 0 if $self->_no_table_error; + die $_; + }; +} + +sub _cid { + my ( $self, $ord, $offset, $project ) = @_; + return try { + $self->dbh->selectcol_arrayref(qq{ + SELECT change_id + FROM changes + WHERE project = ? + ORDER BY committed_at $ord + LIMIT 1 + OFFSET COALESCE(?, 0) + }, undef, $project || $self->plan->project, $offset)->[0]; + } catch { + return if $self->_no_table_error && !$self->initialized; + die $_; + }; +} + +sub earliest_change_id { + shift->_cid('ASC', @_); +} + +sub latest_change_id { + shift->_cid('DESC', @_); +} + +sub _select_state { + my ( $self, $project, $with_hash ) = @_; + my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at'; + my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $tagcol = sprintf $self->_listagg_format, 't.tag'; + my $hshcol = $with_hash ? "c.script_hash\n , " : ''; + my $dbh = $self->dbh; + $dbh->selectrow_hashref(qq{ + SELECT c.change_id + , ${hshcol}c.change + , c.project + , c.note + , c.committer_name + , c.committer_email + , $cdtcol AS committed_at + , c.planner_name + , c.planner_email + , $pdtcol AS planned_at + , $tagcol AS tags + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + GROUP BY c.change_id + , ${hshcol}c.change + , c.project + , c.note + , c.committer_name + , c.committer_email + , c.committed_at + , c.planner_name + , c.planner_email + , c.planned_at + ORDER BY c.committed_at DESC + LIMIT 1 + }, undef, $project // $self->plan->project ); +} + +sub current_state { + my ( $self, $project ) = @_; + my $state = try { + $self->_select_state($project, 1) + } catch { + return if $self->_no_table_error && !$self->initialized; + return $self->_select_state($project, 0) if $self->_no_column_error; + die $_; + } or return undef; + + unless (ref $state->{tags}) { + $state->{tags} = $state->{tags} ? [ split / / => $state->{tags} ] : []; + } + $state->{committed_at} = _dt $state->{committed_at}; + $state->{planned_at} = _dt $state->{planned_at}; + return $state; +} + +sub current_changes { + my ( $self, $project ) = @_; + my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at'; + my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $sth = $self->dbh->prepare(qq{ + SELECT c.change_id + , c.script_hash + , c.change + , c.committer_name + , c.committer_email + , $cdtcol AS committed_at + , c.planner_name + , c.planner_email + , $pdtcol AS planned_at + FROM changes c + WHERE project = ? + ORDER BY c.committed_at DESC + }); + $sth->execute($project // $self->plan->project); + return sub { + my $row = $sth->fetchrow_hashref or return; + $row->{committed_at} = _dt $row->{committed_at}; + $row->{planned_at} = _dt $row->{planned_at}; + return $row; + }; +} + +sub current_tags { + my ( $self, $project ) = @_; + my $cdtcol = sprintf $self->_ts2char_format, 'committed_at'; + my $pdtcol = sprintf $self->_ts2char_format, 'planned_at'; + my $sth = $self->dbh->prepare(qq{ + SELECT tag_id + , tag + , committer_name + , committer_email + , $cdtcol AS committed_at + , planner_name + , planner_email + , $pdtcol AS planned_at + FROM tags + WHERE project = ? + ORDER BY tags.committed_at DESC + }); + $sth->execute($project // $self->plan->project); + return sub { + my $row = $sth->fetchrow_hashref or return; + $row->{committed_at} = _dt $row->{committed_at}; + $row->{planned_at} = _dt $row->{planned_at}; + return $row; + }; +} + +sub search_events { + my ( $self, %p ) = @_; + + # Determine order direction. + my $dir = 'DESC'; + if (my $d = delete $p{direction}) { + $dir = $d =~ /^ASC/i ? 'ASC' + : $d =~ /^DESC/i ? 'DESC' + : hurl 'Search direction must be either "ASC" or "DESC"'; + } + + # Limit with regular expressions? + my (@wheres, @params); + for my $spec ( + [ committer => 'e.committer_name' ], + [ planner => 'e.planner_name' ], + [ change => 'e.change' ], + [ project => 'e.project' ], + ) { + my $regex = delete $p{ $spec->[0] } // next; + my ($op, $expr) = $self->_regex_expr($spec->[1], $regex); + push @wheres => $op; + push @params => $expr; + } + + # Match events? + if (my $e = delete $p{event} ) { + my ($in, @vals) = $self->_in_expr( $e ); + push @wheres => "e.event $in"; + push @params => @vals; + } + + # Assemble the where clause. + my $where = @wheres + ? "\n WHERE " . join( "\n ", @wheres ) + : ''; + + # Handle remaining parameters. + my $limits = ''; + if (exists $p{limit} || exists $p{offset}) { + my ($exprs, $values) = $self->_limit_offset(delete $p{limit}, delete $p{offset}); + if (@{ $exprs}) { + $limits = join "\n ", '', @{ $exprs }; + push @params => @{ $values || [] }; + } + } + + hurl 'Invalid parameters passed to search_events(): ' + . join ', ', sort keys %p if %p; + + # Prepare, execute, and return. + my $cdtcol = sprintf $self->_ts2char_format, 'e.committed_at'; + my $pdtcol = sprintf $self->_ts2char_format, 'e.planned_at'; + my $sth = $self->dbh->prepare(qq{ + SELECT e.event + , e.project + , e.change_id + , e.change + , e.note + , e.requires + , e.conflicts + , e.tags + , e.committer_name + , e.committer_email + , $cdtcol AS committed_at + , e.planner_name + , e.planner_email + , $pdtcol AS planned_at + FROM events e$where + ORDER BY e.committed_at $dir$limits + }); + + $sth->execute(@params); + return sub { + my $row = $sth->fetchrow_hashref or return; + $row->{committed_at} = _dt $row->{committed_at}; + $row->{planned_at} = _dt $row->{planned_at}; + return $row; + }; +} + +sub _regex_expr { + my ( $self, $col, $regex ) = @_; + my $op = $self->_regex_op; + return "$col $op ?", $regex; +} + +sub _limit_offset { + my ($self, $lim, $off) = @_; + my (@limits, @params); + + if ($lim) { + push @limits => 'LIMIT ?'; + push @params => $lim; + } + if ($off) { + if (!$lim && ($lim = $self->_limit_default)) { + # Some drivers require LIMIT when OFFSET is set. + push @limits => 'LIMIT ?'; + push @params => $lim; + } + push @limits => 'OFFSET ?'; + push @params => $off; + } + return \@limits, \@params; +} + +sub registered_projects { + return @{ shift->dbh->selectcol_arrayref( + 'SELECT project FROM projects ORDER BY project' + ) }; +} + +sub register_project { + my $self = shift; + my $sqitch = $self->sqitch; + my $dbh = $self->dbh; + my $plan = $self->plan; + my $proj = $plan->project; + my $uri = $plan->uri; + + my $res = $dbh->selectcol_arrayref( + 'SELECT uri FROM projects WHERE project = ?', + undef, $proj + ); + + if (@{ $res }) { + # A project with that name is already registered. Compare URIs. + my $reg_uri = $res->[0]; + if ( defined $uri && !defined $reg_uri ) { + hurl engine => __x( + 'Cannot register "{project}" with URI {uri}: already exists with NULL URI', + project => $proj, + uri => $uri + ); + } elsif ( !defined $uri && defined $reg_uri ) { + hurl engine => __x( + 'Cannot register "{project}" without URI: already exists with URI {uri}', + project => $proj, + uri => $reg_uri + ); + } elsif ( defined $uri && defined $reg_uri ) { + hurl engine => __x( + 'Cannot register "{project}" with URI {uri}: already exists with URI {reg_uri}', + project => $proj, + uri => $uri, + reg_uri => $reg_uri, + ) if $uri ne $reg_uri; + } else { + # Both are undef, so cool. + } + } else { + # No project with that name exists. Check to see if the URI does. + if (defined $uri) { + # Does the URI already exist? + my $res = $dbh->selectcol_arrayref( + 'SELECT project FROM projects WHERE uri = ?', + undef, $uri + ); + + hurl engine => __x( + 'Cannot register "{project}" with URI {uri}: project "{reg_proj}" already using that URI', + project => $proj, + uri => $uri, + reg_proj => $res->[0], + ) if @{ $res }; + } + + # Insert the project. + my $ts = $self->_ts_default; + $dbh->do(qq{ + INSERT INTO projects (project, uri, creator_name, creator_email, created_at) + VALUES (?, ?, ?, ?, $ts) + }, undef, $proj, $uri, $sqitch->user_name, $sqitch->user_email); + } + + return $self; +} + +sub is_deployed_change { + my ( $self, $change ) = @_; + $self->dbh->selectcol_arrayref(q{ + SELECT EXISTS( + SELECT 1 + FROM changes + WHERE change_id = ? + ) + }, undef, $change->id)->[0]; +} + +sub are_deployed_changes { + my $self = shift; + my $qs = join ', ' => ('?') x @_; + @{ $self->dbh->selectcol_arrayref( + "SELECT change_id FROM changes WHERE change_id IN ($qs)", + undef, + map { $_->id } @_, + ) }; +} + +sub is_deployed_tag { + my ( $self, $tag ) = @_; + return $self->dbh->selectcol_arrayref(q{ + SELECT EXISTS( + SELECT 1 + FROM tags + WHERE tag_id = ? + ); + }, undef, $tag->id)->[0]; +} + +sub _multi_values { + my ($self, $count, $expr) = @_; + return 'VALUES ' . join(', ', ("($expr)") x $count) +} + +sub _dependency_placeholders { + return '?, ?, ?, ?'; +} + +sub _tag_placeholders { + my $self = shift; + return '?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ' . $self->_ts_default; +} + +sub _tag_subselect_columns { + my $self = shift; + return join(', ', + '? AS tid', + '? AS tname', + '? AS proj', + '? AS cid', + '? AS note', + '? AS cuser', + '? AS cemail', + '? AS tts', + '? AS puser', + '? AS pemail', + $self->_ts_default, + ); +} + +sub _prepare_to_log { $_[0] } + +sub log_deploy_change { + my ($self, $change) = @_; + my $dbh = $self->dbh; + my $sqitch = $self->sqitch; + + my ($id, $name, $proj, $user, $email) = ( + $change->id, + $change->format_name, + $change->project, + $sqitch->user_name, + $sqitch->user_email + ); + + my $ts = $self->_ts_default; + my $cols = join "\n , ", $self->_quote_idents(qw( + change_id + script_hash + change + project + note + committer_name + committer_email + planned_at + planner_name + planner_email + committed_at + )); + + $self->_prepare_to_log(changes => $change); + $dbh->do(qq{ + INSERT INTO changes ( + $cols + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, $ts) + }, undef, + $id, + $change->script_hash, + $name, + $proj, + $change->note, + $user, + $email, + $self->_char2ts( $change->timestamp ), + $change->planner_name, + $change->planner_email, + ); + + if ( my @deps = $change->dependencies ) { + $dbh->do(q{ + INSERT INTO dependencies( + change_id + , type + , dependency + , dependency_id + ) } . $self->_multi_values(scalar @deps, $self->_dependency_placeholders), + undef, + map { ( + $id, + $_->type, + $_->as_string, + $_->resolved_id, + ) } @deps + ); + } + + if ( my @tags = $change->tags ) { + $dbh->do(q{ + INSERT INTO tags ( + tag_id + , tag + , project + , change_id + , note + , committer_name + , committer_email + , planned_at + , planner_name + , planner_email + , committed_at + ) } . $self->_multi_values(scalar @tags, $self->_tag_placeholders), + undef, + map { ( + $_->id, + $_->format_name, + $proj, + $id, + $_->note, + $user, + $email, + $self->_char2ts( $_->timestamp ), + $_->planner_name, + $_->planner_email, + ) } @tags + ); + } + + return $self->_log_event( deploy => $change ); +} + +sub log_fail_change { + shift->_log_event( fail => shift ); +} + +sub _log_event { + my ( $self, $event, $change, $tags, $requires, $conflicts) = @_; + my $dbh = $self->dbh; + my $sqitch = $self->sqitch; + + my $ts = $self->_ts_default; + my $cols = join "\n , ", $self->_quote_idents(qw( + event + change_id + change + project + note + tags + requires + conflicts + committer_name + committer_email + planned_at + planner_name + planner_email + committed_at + )); + + $self->_prepare_to_log(events => $change); + $dbh->do(qq{ + INSERT INTO events ( + $cols + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, $ts) + }, undef, + $event, + $change->id, + $change->name, + $change->project, + $change->note, + $tags || $self->_log_tags_param($change), + $requires || $self->_log_requires_param($change), + $conflicts || $self->_log_conflicts_param($change), + $sqitch->user_name, + $sqitch->user_email, + $self->_char2ts( $change->timestamp ), + $change->planner_name, + $change->planner_email, + ); + + return $self; +} + +sub changes_requiring_change { + my ( $self, $change ) = @_; + return @{ $self->dbh->selectall_arrayref(q{ + SELECT c.change_id, c.project, c.change, ( + SELECT tag + FROM changes c2 + JOIN tags ON c2.change_id = tags.change_id + WHERE c2.project = c.project + AND c2.committed_at >= c.committed_at + ORDER BY c2.committed_at + LIMIT 1 + ) AS asof_tag + FROM dependencies d + JOIN changes c ON c.change_id = d.change_id + WHERE d.dependency_id = ? + }, { Slice => {} }, $change->id) }; +} + +sub name_for_change_id { + my ( $self, $change_id ) = @_; + return $self->dbh->selectcol_arrayref(q{ + SELECT c.change || COALESCE(( + SELECT tag + FROM changes c2 + JOIN tags ON c2.change_id = tags.change_id + WHERE c2.committed_at >= c.committed_at + AND c2.project = c.project + LIMIT 1 + ), '@HEAD') + FROM changes c + WHERE change_id = ? + }, undef, $change_id)->[0]; +} + +sub log_new_tags { + my ( $self, $change ) = @_; + my @tags = $change->tags or return $self; + my $sqitch = $self->sqitch; + + my ($id, $name, $proj, $user, $email) = ( + $change->id, + $change->format_name, + $change->project, + $sqitch->user_name, + $sqitch->user_email + ); + + my $subselect = 'SELECT ' . $self->_tag_subselect_columns . $self->_simple_from; + $self->dbh->do( + q{ + INSERT INTO tags ( + tag_id + , tag + , project + , change_id + , note + , committer_name + , committer_email + , planned_at + , planner_name + , planner_email + , committed_at + ) + SELECT i.* FROM ( + } . join( + "\n UNION ALL ", + ($subselect) x @tags + ) . q{ + ) i + LEFT JOIN tags ON i.tid = tags.tag_id + WHERE tags.tag_id IS NULL + }, + undef, + map { ( + $_->id, + $_->format_name, + $proj, + $id, + $_->note, + $user, + $email, + $self->_char2ts( $_->timestamp ), + $_->planner_name, + $_->planner_email, + ) } @tags + ); + + return $self; +} + +sub log_revert_change { + my ($self, $change) = @_; + my $dbh = $self->dbh; + my $cid = $change->id; + + # Retrieve and delete tags. + my $del_tags = join ',' => @{ $dbh->selectcol_arrayref( + 'SELECT tag FROM tags WHERE change_id = ?', + undef, $cid + ) || [] }; + + $dbh->do( + 'DELETE FROM tags WHERE change_id = ?', + undef, $cid + ); + + # Retrieve dependencies and delete. + my $sth = $dbh->prepare(q{ + SELECT dependency + FROM dependencies + WHERE change_id = ? + AND type = ? + }); + my $req = join ',' => @{ $dbh->selectcol_arrayref( + $sth, undef, $cid, 'require' + ) }; + + my $conf = join ',' => @{ $dbh->selectcol_arrayref( + $sth, undef, $cid, 'conflict' + ) }; + + $dbh->do('DELETE FROM dependencies WHERE change_id = ?', undef, $cid); + + # Delete the change record. + $dbh->do( + 'DELETE FROM changes where change_id = ?', + undef, $cid, + ); + + # Log it. + return $self->_log_event( revert => $change, $del_tags, $req, $conf ); +} + +sub deployed_changes { + my $self = shift; + my $tscol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $tagcol = sprintf $self->_listagg_format, 't.tag'; + return map { + $_->{timestamp} = _dt $_->{timestamp}; + unless (ref $_->{tags}) { + $_->{tags} = $_->{tags} ? [ split / / => $_->{tags} ] : []; + } + $_; + } @{ $self->dbh->selectall_arrayref(qq{ + SELECT c.change_id AS id, c.change AS name, c.project, c.note, + $tscol AS "timestamp", c.planner_name, c.planner_email, + $tagcol AS tags + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at, + c.planner_name, c.planner_email, c.committed_at + ORDER BY c.committed_at ASC + }, { Slice => {} }, $self->plan->project) }; +} + +sub deployed_changes_since { + my ( $self, $change ) = @_; + my $tscol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $tagcol = sprintf $self->_listagg_format, 't.tag'; + return map { + $_->{timestamp} = _dt $_->{timestamp}; + unless (ref $_->{tags}) { + $_->{tags} = $_->{tags} ? [ split / / => $_->{tags} ] : []; + } + $_; + } @{ $self->dbh->selectall_arrayref(qq{ + SELECT c.change_id AS id, c.change AS name, c.project, c.note, + $tscol AS "timestamp", c.planner_name, c.planner_email, + $tagcol AS tags + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + AND c.committed_at > (SELECT committed_at FROM changes WHERE change_id = ?) + GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at, + c.planner_name, c.planner_email, c.committed_at + ORDER BY c.committed_at ASC + }, { Slice => {} }, $self->plan->project, $change->id) }; +} + +sub load_change { + my ( $self, $change_id ) = @_; + my $tscol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $tagcol = sprintf $self->_listagg_format, 't.tag'; + my $change = $self->dbh->selectrow_hashref(qq{ + SELECT c.change_id AS id, c.change AS name, c.project, c.note, + $tscol AS "timestamp", c.planner_name, c.planner_email, + $tagcol AS tags + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.change_id = ? + GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at, + c.planner_name, c.planner_email + }, undef, $change_id) || return undef; + $change->{timestamp} = _dt $change->{timestamp}; + unless (ref $change->{tags}) { + $change->{tags} = $change->{tags} ? [ split / / => $change->{tags} ] : []; + } + return $change; +} + +sub _offset_op { + my ( $self, $offset ) = @_; + my ( $dir, $op ) = $offset > 0 ? ( 'ASC', '>' ) : ( 'DESC' , '<' ); + return $dir, $op, 'OFFSET ' . (abs($offset) - 1); +} + +sub change_id_offset_from_id { + my ( $self, $change_id, $offset ) = @_; + + # Just return the ID if there is no offset. + return $change_id unless $offset; + + my ($dir, $op, $offset_expr) = $self->_offset_op($offset); + return $self->dbh->selectcol_arrayref(qq{ + SELECT change_id + FROM changes + WHERE project = ? + AND committed_at $op ( + SELECT committed_at FROM changes WHERE change_id = ? + ) + ORDER BY committed_at $dir + LIMIT 1 $offset_expr + }, undef, $self->plan->project, $change_id)->[0]; +} + +sub change_offset_from_id { + my ( $self, $change_id, $offset ) = @_; + + # Just return the object if there is no offset. + return $self->load_change($change_id) unless $offset; + + # Are we offset forwards or backwards? + my ($dir, $op, $offset_expr) = $self->_offset_op($offset); + my $tscol = sprintf $self->_ts2char_format, 'c.planned_at'; + my $tagcol = sprintf $self->_listagg_format, 't.tag'; + + my $change = $self->dbh->selectrow_hashref(qq{ + SELECT c.change_id AS id, c.change AS name, c.project, c.note, + $tscol AS "timestamp", c.planner_name, c.planner_email, + $tagcol AS tags + FROM changes c + LEFT JOIN tags t ON c.change_id = t.change_id + WHERE c.project = ? + AND c.committed_at $op ( + SELECT committed_at FROM changes WHERE change_id = ? + ) + GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at, + c.planner_name, c.planner_email, c.committed_at + ORDER BY c.committed_at $dir + LIMIT 1 $offset_expr + }, undef, $self->plan->project, $change_id) || return undef; + $change->{timestamp} = _dt $change->{timestamp}; + unless (ref $change->{tags}) { + $change->{tags} = $change->{tags} ? [ split / / => $change->{tags} ] : []; + } + return $change; +} + +sub _cid_head { + my ($self, $project, $change) = @_; + return $self->dbh->selectcol_arrayref(q{ + SELECT change_id + FROM changes + WHERE project = ? + AND changes.change = ? + ORDER BY committed_at DESC + LIMIT 1 + }, undef, $project, $change)->[0]; +} + +sub change_id_for { + my ( $self, %p) = @_; + my $dbh = $self->dbh; + + if ( my $cid = $p{change_id} ) { + # Find by ID. + return $dbh->selectcol_arrayref(q{ + SELECT change_id + FROM changes + WHERE change_id = ? + }, undef, $cid)->[0]; + } + + my $project = $p{project} || $self->plan->project; + if ( my $change = $p{change} ) { + if ( my $tag = $p{tag} ) { + # There is nothing before the first tag. + return undef if $tag eq 'ROOT'; + + # Find closest to the end for @HEAD. + return $self->_cid_head($project, $change) if $tag eq 'HEAD'; + + # Find by change name and following tag. + my $limit = $self->_can_limit ? "\n LIMIT 1" : ''; + return $dbh->selectcol_arrayref(qq{ + SELECT changes.change_id + FROM changes + JOIN tags + ON changes.committed_at <= tags.committed_at + AND changes.project = tags.project + WHERE changes.project = ? + AND changes.change = ? + AND tags.tag = ? + ORDER BY changes.committed_at DESC$limit + }, undef, $project, $change, '@' . $tag)->[0]; + } + + # Find earliest by change name. + my $ids = $dbh->selectcol_arrayref(qq{ + SELECT change_id + FROM changes + WHERE project = ? + AND changes.change = ? + ORDER BY changes.committed_at ASC + }, undef, $project, $change); + + # Return the ID. + return $ids->[0] if $p{first}; + return $self->_handle_lookup_index($change, $ids); + } + + if ( my $tag = $p{tag} ) { + # Just return the latest for @HEAD. + return $self->_cid('DESC', 0, $project) if $tag eq 'HEAD'; + + # Just return the earliest for @ROOT. + return $self->_cid('ASC', 0, $project) if $tag eq 'ROOT'; + + # Find by tag name. + return $dbh->selectcol_arrayref(q{ + SELECT change_id + FROM tags + WHERE project = ? + AND tag = ? + }, undef, $project, '@' . $tag)->[0]; + } + + # We got nothin. + return undef; +} + +sub _update_script_hashes { + my $self = shift; + my $plan = $self->plan; + my $proj = $plan->project; + my $dbh = $self->dbh; + my $sth = $dbh->prepare( + 'UPDATE changes SET script_hash = ? WHERE change_id = ? AND script_hash = ?' + ); + + $self->begin_work; + $sth->execute($_->script_hash, $_->id, $_->id) for $plan->changes; + $dbh->do(q{ + UPDATE changes SET script_hash = NULL + WHERE project = ? AND script_hash = change_id + }, undef, $proj); + + $self->finish_work; + return $self; +} + + +sub begin_work { + my $self = shift; + # Note: Engines should acquire locks to prevent concurrent Sqitch activity. + $self->dbh->begin_work; + return $self; +} + +sub finish_work { + my $self = shift; + $self->dbh->commit; + return $self; +} + +sub rollback_work { + my $self = shift; + $self->dbh->rollback; + return $self; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Command::checkout - An engine based on the DBI + +=head1 Synopsis + + package App::Sqitch::Engine::sqlite; + extends 'App::Sqitch::Engine'; + with 'App::Sqitch::Role::DBIEngine'; + +=head1 Description + +This role encapsulates the common attributes and methods required by +DBI-powered engines. + +=head1 Interface + +=head2 Instance Methods + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head3 C + +=head1 See Also + +=over + +=item L + +The PostgreSQL engine. + +=item L + +The SQLite engine. + +=item L + +The Oracle engine. + +=item L + +The MySQL engine. + +=item L + +The Vertica engine. + +=item L + +The Exasol engine. + +=item L + +The Snowflake engine. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Role/RevertDeployCommand.pm b/lib/App/Sqitch/Role/RevertDeployCommand.pm new file mode 100644 index 00000000..172c77a9 --- /dev/null +++ b/lib/App/Sqitch/Role/RevertDeployCommand.pm @@ -0,0 +1,272 @@ +package App::Sqitch::Role::RevertDeployCommand; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo::Role; +use App::Sqitch::Types qw(Str Bool HashRef); +use Type::Utils qw(enum); +use namespace::autoclean; + +requires 'sqitch'; +requires 'command'; +requires 'options'; +requires 'configure'; + +with 'App::Sqitch::Role::ContextCommand'; +with 'App::Sqitch::Role::ConnectingCommand'; + +our $VERSION = 'v1.0.0'; # VERSION + +has target => ( + is => 'ro', + isa => Str, +); + +has verify => ( + is => 'ro', + isa => Bool, + default => 0, +); + +has log_only => ( + is => 'ro', + isa => Bool, + default => 0, +); + +has no_prompt => ( + is => 'ro', + isa => Bool +); + +has prompt_accept => ( + is => 'ro', + isa => Bool +); + +has mode => ( + is => 'ro', + isa => enum([qw( + change + tag + all + )]), + default => 'all', +); + +has deploy_variables => ( + is => 'ro', + isa => HashRef, + lazy => 1, + default => sub { {} }, +); + +has revert_variables => ( + is => 'ro', + isa => HashRef, + lazy => 1, + default => sub { {} }, +); + +sub _collect_deploy_vars { + my ($self, $target) = @_; + my $cfg = $self->sqitch->config; + return ( + %{ $cfg->get_section(section => 'core.variables') }, + %{ $cfg->get_section(section => 'deploy.variables') }, + %{ $target->variables }, # includes engine + %{ $self->deploy_variables }, # --set, --set-deploy + ); +} + +sub _collect_revert_vars { + my ($self, $target) = @_; + my $cfg = $self->sqitch->config; + return ( + %{ $cfg->get_section(section => 'core.variables') }, + %{ $cfg->get_section(section => 'deploy.variables') }, + %{ $cfg->get_section(section => 'revert.variables') }, + %{ $target->variables }, # includes engine + %{ $self->revert_variables }, # --set, --set-revert + ); +} + +around options => sub { + my ($orig, $class) = @_; + return ($class->$orig), qw( + target|t=s + mode=s + verify! + set|s=s% + set-deploy|e=s% + set-revert|r=s% + log-only + y + ); +}; + +around configure => sub { + my ( $orig, $class, $config, $opt ) = @_; + my $cmd = $class->command; + + my $params = $class->$orig($config, $opt); + $params->{log_only} = $opt->{log_only} if $opt->{log_only}; + $params->{target} = $opt->{target} if $opt->{target}; + + # Verify? + $params->{verify} = $opt->{verify} + // $config->get( key => "$cmd.verify", as => 'boolean' ) + // $config->get( key => 'deploy.verify', as => 'boolean' ) + // 0; + $params->{mode} = $opt->{mode} + || $config->get( key => "$cmd.mode" ) + || $config->get( key => 'deploy.mode' ) + || 'all'; + + if ( my $vars = $opt->{set} ) { + # --set used for both revert and deploy. + $params->{revert_variables} = $params->{deploy_variables} = $vars; + } + + if ( my $vars = $opt->{set_deploy} ) { + # --set-deploy used only for deploy. + $params->{deploy_variables} = { + %{ $params->{deploy_variables} || {} }, + %{ $vars }, + }; + } + + if ( my $vars = $opt->{set_revert} ) { + # --set-revert used only for revert. + $params->{revert_variables} = { + %{ $params->{revert_variables} || {} }, + %{ $vars }, + }; + } + + $params->{no_prompt} = delete $opt->{y} // $config->get( + key => "$cmd.no_prompt", + as => 'bool', + ) // $config->get( + key => 'revert.no_prompt', + as => 'bool', + ) // 0; + + $params->{prompt_accept} = $config->get( + key => "$cmd.prompt_accept", + as => 'bool', + ) // $config->get( + key => 'revert.prompt_accept', + as => 'bool', + ) // 1; + + return $params; +}; + +1; + +__END__ + +=head1 Name + +App::Sqitch::Role::RevertDeployCommand - A command that reverts and deploys + +=head1 Synopsis + + package App::Sqitch::Command::rebase; + extends 'App::Sqitch::Command'; + with 'App::Sqitch::Role::RevertDeployCommand'; + +=head1 Description + +This role encapsulates the common attributes and methods required by commands +that both revert and deploy. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::checkout->options; + +Adds options common to the commands that revert and deploy. + +=head3 C + +Configures the options common to commands that revert and deploy. + +=head2 Attributes + +=head3 C + +Boolean indicating whether to log the deploy without running the scripts. + +=head3 C + +Boolean indicating whether or not to prompt the user to really go through with +the revert. + +=head3 C + +Boolean value to indicate whether or not the default value for the prompt, +should the user hit C, is to accept the prompt or deny it. + +=head3 C + +The deployment target URI. + +=head3 C + +Boolean indicating whether or not to run verify scripts after deploying +changes. + +=head3 C + +Deploy mode, one of "change", "tag", or "all". + +=head1 See Also + +=over + +=item L + +The C command reverts and deploys changes. + +=item L + +The C command takes a VCS commit name, determines the last change in +common with the current commit, reverts to that change, then checks out the +named commit and re-deploys. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Role/TargetConfigCommand.pm b/lib/App/Sqitch/Role/TargetConfigCommand.pm new file mode 100644 index 00000000..0e4db743 --- /dev/null +++ b/lib/App/Sqitch/Role/TargetConfigCommand.pm @@ -0,0 +1,549 @@ +package App::Sqitch::Role::TargetConfigCommand; + +use 5.010; +use strict; +use warnings; +use utf8; +use Moo::Role; +use App::Sqitch::Types qw(HashRef); +use App::Sqitch::X qw(hurl); +use Path::Class; +use Try::Tiny; +use URI::db; +use Locale::TextDomain qw(App-Sqitch); +use List::Util qw(first); +use File::Path qw(make_path); +use namespace::autoclean; +use constant extra_target_keys => (); + +our $VERSION = 'v1.0.0'; # VERSION + +requires 'command'; +requires 'options'; +requires 'configure'; +requires 'sqitch'; +requires 'extra_target_keys'; +requires 'default_target'; + +has properties => ( + is => 'ro', + isa => HashRef, + default => sub { {} }, +); + +around options => sub { + my ($orig, $class) = @_; + return ($class->$orig), (map { "$_=s" } $class->extra_target_keys), qw( + plan-file|f=s + registry=s + client=s + extension=s + top-dir=s + dir|d=s% + set|s=s% + ); +}; + +around configure => sub { + my ( $orig, $class, $config, $opt ) = @_; + + # Grab the options we're responsible for. + my $props = {}; + for my $key ( + $class->extra_target_keys, + qw(plan_file registry client extension top_dir dir) + ) { + $props->{$key} = delete $opt->{$key} if exists $opt->{$key}; + } + + # Let the command take care of its options. + my $params = $class->$orig($config, $opt); + + # Convert file option to Class::Path::File object. + if ( my $file = $props->{plan_file} ) { + $props->{plan_file} = file($file)->cleanup; + } + + # Convert directory option to Class::Path::Dir object. + if ( my $file = $props->{top_dir} ) { + $props->{top_dir} = dir($file)->cleanup; + } + + # Convert URI. + if ( my $uri = $props->{uri} ) { + require URI; + $props->{uri} = URI->new($uri); + } + + # Convert directory properties to Class::Path::Dir objects. + if (my $dirs = delete $props->{dir}) { + my %ok_keys = map {; $_ => undef } ( + qw(reworked), + map { ($_, "reworked_$_") } qw(deploy revert verify) + ); + + my @unknown; + for my $key (keys %{ $dirs }) { + unless (exists $ok_keys{$key}) { + push @unknown => $key; + next; + } + $props->{"$key\_dir"} = dir(delete $dirs->{$key})->cleanup + } + + if (@unknown) { + hurl $class->command => __nx( + 'Unknown directory name: {dirs}', + 'Unknown directory names: {dirs}', + @unknown, + dirs => join(__ ', ', sort @unknown), + ); + } + } + + # Copy variables. + if ( my $vars = $opt->{set} ) { + $props->{variables} = $vars; + } + + # All done. + $params->{properties} = $props; + return $params; +}; + +sub BUILD { + my $self = shift; + my $props = $self->properties; + + if (my $engine = $props->{engine}) { + # Validate engine. + hurl $self->command => __x( + 'Unknown engine "{engine}"', engine => $engine + ) unless first { $engine eq $_ } App::Sqitch::Command::ENGINES; + } + + if (my $uri = $props->{uri}) { + # Validate URI. + hurl $self->command => __x( + 'URI "{uri}" is not a database URI', + uri => $uri, + ) unless eval { $uri->isa('URI::db') }; + + my $engine = $uri->canonical_engine or hurl $self->command => __x( + 'No database engine in URI "{uri}"', + uri => $uri, + ); + hurl $self->command => __x( + 'Unknown engine "{engine}" in URI "{uri}"', + engine => $engine, + uri => $uri, + ) unless first { $engine eq $_ } App::Sqitch::Command::ENGINES; + + } +} + +sub config_target { + my ($self, %p) = @_; + my $sqitch = $self->sqitch; + my $props = $self->properties; + my @params = (sqitch => $sqitch); + + if (my $name = $p{name} || $props->{target}) { + push @params => (name => $name); + if (my $uri = $p{uri}) { + push @params => (uri => $uri); + } else { + my $config = $sqitch->config; + if ($name !~ /:/ && !$config->get(key => "target.$name.uri")) { + # No URI. Give it one. + my $engine = $p{engine} || $props->{engine} + || $config->get(key => 'core.engine'); + push @params => (uri => URI::db->new("db:$engine:")); + } + } + } elsif (my $engine = $p{engine} || $props->{engine}) { + my $config = $sqitch->config; + push @params => ( + name => $config->get(key => "engine.$engine.target") + || $config->get(key => 'core.target') + || "db:$engine:" + ); + } else { + # Get the name and URI from the default target. + my $default = $self->default_target; + push @params => ( + name => $default->name, + uri => $default->uri, + ); + } + + # Return the target with all relevant attributes overridden. + require App::Sqitch::Target; + return App::Sqitch::Target->new( + @params, + map { $_ => $props->{$_} } grep { $props->{$_} } qw( + top_dir + plan_file + registry + client + deploy_dir + revert_dir + verify_dir + reworked_dir + reworked_deploy_dir + reworked_revert_dir + reworked_verify_dir + extension + ) + ); +} + +sub directories_for { + my $self = shift; + my $props = $self->properties; + my (@dirs, %seen); + + for my $target (@_) { + # Script directories. + if (my $top_dir = $props->{top_dir}) { + push @dirs => grep { !$seen{$_}++ } map { + $props->{"$_\_$_"} || $top_dir->subdir($_); + } qw(deploy revert verify); + } else { + push @dirs => grep { !$seen{$_}++ } map { + my $name = "$_\_dir"; + $props->{$name} || $target->$name; + } qw(deploy revert verify); + } + + # Reworked script directories. + if (my $reworked_dir = $props->{reworked_dir} || $props->{top_dir}) { + push @dirs => grep { !$seen{$_}++ } map { + $props->{"reworked_$_\_dir"} || $reworked_dir->subdir($_); + } qw(deploy revert verify); + } else { + push @dirs => grep { !$seen{$_}++ } map { + my $name = "reworked_$_\_dir"; + $props->{$name} || $target->$name; + } qw(deploy revert verify); + } + } + + return @dirs; +} + +sub make_directories_for { + my $self = shift; + $self->mkdirs( $self->directories_for(@_) ); +} + +sub mkdirs { + my $self = shift; + + for my $dir (@_) { + next if -d $dir; + my $sep = dir('')->stringify; # OS-specific directory separator. + $self->info(__x( + 'Created {file}', + file => "$dir$sep" + )) if make_path $dir, { error => \my $err }; + if ( my $diag = shift @{ $err } ) { + my ( $path, $msg ) = %{ $diag }; + hurl $self->command => __x( + 'Error creating {path}: {error}', + path => $path, + error => $msg, + ) if $path; + hurl $self->command => $msg; + } + } + + return $self; +} + +sub write_plan { + my ( $self, %p ) = @_; + my $project = $p{project}; + my $uri = $p{uri}; + my $target = $p{target} || $self->config_target; + my $file = $target->plan_file; + + unless ($project && $uri) { + # Find a plan to copy the project name and URI from. + my $conf_plan = $target->plan; + my $def_plan = $self->default_target->plan; + if (try { $def_plan->project }) { + $project ||= $def_plan->project; + $uri ||= $def_plan->uri; + } elsif (try { $conf_plan->project }) { + $project ||= $conf_plan->project; + $uri ||= $conf_plan->uri; + } else { + hurl $self->command => __x( + 'Cannot write a plan file without a project name' + ) unless $project; + } + } + + if (-e $file) { + hurl init => __x( + 'Cannot initialize because {file} already exists and is not a file', + file => $file, + ) unless -f $file; + + # Try to load the plan file. + my $plan = App::Sqitch::Plan->new( + sqitch => $self->sqitch, + file => $file, + target => $target, + ); + my $file_proj = try { $plan->project } or hurl init => __x( + 'Cannot initialize because {file} already exists and is not a valid plan file', + file => $file, + ); + + # Bail if this plan file looks like it's for a different project. + hurl init => __x( + 'Cannot initialize because project "{project}" already initialized in {file}', + project => $plan->project, + file => $file, + ) if $plan->project ne $project; + return $self; + } + + $self->mkdirs( $file->dir ) unless -d $file->dir; + + my $fh = $file->open('>:utf8_strict') or hurl init => __x( + 'Cannot open {file}: {error}', + file => $file, + error => $!, + ); + require App::Sqitch::Plan; + $fh->print( + '%syntax-version=', App::Sqitch::Plan::SYNTAX_VERSION(), "\n", + '%project=', "$project\n", + ( $uri ? ('%uri=', $uri->canonical, "\n") : () ), "\n", + ); + $fh->close or hurl add => __x( + 'Error closing {file}: {error}', + file => $file, + error => $! + ); + + $self->sqitch->info( __x 'Created {file}', file => $file ); + return $self; +} + +sub config_params { + my ($self, $key) = @_; + my @vars; + while (my ($prop, $val) = each %{ $self->properties } ) { + if (ref $val eq 'HASH') { + push @vars => map {{ + key => "$key.$prop.$_", + value => $val->{$_}, + }} keys %{ $val }; + } else { + push @vars => { + key => "$key.$prop", + value => $val, + }; + } + } + return \@vars; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Role::TargetConfigCommand - A command that handles target-related configuration + +=head1 Synopsis + + package App::Sqitch::Command::init; + extends 'App::Sqitch::Command'; + with 'App::Sqitch::Role::TargetConfigCommand'; + +=head1 Description + +This role encapsulates the common attributes and methods required by commands +that deal with change script configuration, including script directories and +extensions. + +=head1 Interface + +=head2 Class Methods + +=head3 C + + my @opts = App::Sqitch::Command::checkout->options; + +Adds options common to the commands that manage script configuration. + +=head3 C + +Configures the options common to commands manage script configuration. + +=head2 Attributes + +=head3 C + +A hash reference of target configurations. The keys may be as follows: + +=over + +=item C + +=item C + +=item C + +=item C + +=item C + +=item C + +=item C + +=item C + +=back + +=head2 Instance Methods + +=head3 C + + my $target = $cmd->config_target; + my $target = $cmd->config_target(%params); + +Constructs a target based on the contents of C. The supported +parameters are: + +=over + +=item C + +A target name. + +=item C + +A target URI. + +=item C + +An engine name. + +=back + +The passed target and engine names take highest precedence, falling back on +the properties and the C. All other properties are applied to +the target before returning it. + +=head3 C + + $cmd->write_plan(%params); + +Writes out the plan file. Supported parameters are: + +=over + +=item C + +The target for which the plan will be written. Defaults to the target returned +by C. + +=item C + +The project name. If not passed, the project name will be read from the +default target's plan, if it exists. Otherwise an error will be thrown. + +=item C + +The project URI. Optional. If not passed, the URI will be read from the +default target's plan, if it exists. Optional. + +=back + +=head3 C + + my @dirs = $cmd->directories_for(@targets); + +Returns a set of script directories for a list of targets. Options passed to +the command are preferred. Paths are pulled from the command only when they +have not been passed as options. + +=head3 C + + $cmd->directories_for(@targets); + +Creates script directories for one or more targets. Options passed to the +command are preferred. Paths are pulled from the command only when they have +not been passed as options. + +=head3 C + + $cmd->directories_for(@dirs); + +Creates the list of directories on the file system. Directories that already +exist are skipped. Messages are sent to C for each directory, and an +error is thrown on the first to fail. + +=head3 C + + my @params = $cmd->config_params($key); + +Returns a list of parameters to pass to the L C +method, built up from the C. + +=head1 See Also + +=over + +=item L + +The C command initializes a Sqitch project, setting up the change script +configuration and directories. + +=item L + +The C command manages engine configuration, including engine-specific +change script configuration. + +=item L + +The C command manages target configuration, including target-specific +change script configuration. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Target.pm b/lib/App/Sqitch/Target.pm new file mode 100644 index 00000000..b24556ae --- /dev/null +++ b/lib/App/Sqitch/Target.pm @@ -0,0 +1,865 @@ +package App::Sqitch::Target; + +use 5.010; +use Moo; +use strict; +use warnings; +use App::Sqitch::Types qw(Maybe URIDB Str Dir Engine Sqitch File Plan HashRef); +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use Path::Class qw(dir file); +use URI::db; +use namespace::autoclean; + +our $VERSION = 'v1.0.0'; # VERSION + +has name => ( + is => 'ro', + isa => Str, + required => 1, +); +sub target { shift->name } + +has uri => ( + is => 'ro', + isa => URIDB, + required => 1, + handles => { + engine_key => 'canonical_engine', + dsn => 'dbi_dsn', + }, +); + +has username => ( + is => 'ro', + isa => Maybe[Str], + lazy => 1, + default => sub { + my $self = shift; + $ENV{SQITCH_USERNAME} || $self->uri->user + }, +); + +has password => ( + is => 'ro', + isa => Maybe[Str], + lazy => 1, + default => sub { + $ENV{SQITCH_PASSWORD} || shift->uri->password + }, +); + +has sqitch => ( + is => 'ro', + isa => Sqitch, + required => 1, + handles => { + _config => 'config', + _options => 'options', + }, +); + +has engine => ( + is => 'ro', + isa => Engine, + lazy => 1, + default => sub { + my $self = shift; + require App::Sqitch::Engine; + App::Sqitch::Engine->load({ + sqitch => $self->sqitch, + target => $self, + }); + }, +); + +sub _fetch { + my ($self, $key) = @_; + my $config = $self->_config; + return $config->get( key => "target." . $self->name . ".$key" ) + || do { + my $ekey = $self->engine_key; + $ekey ? $config->get( key => "engine.$ekey.$key") : (); + } || $config->get( key => "core.$key"); +} + +has variables => ( + is => 'rw', + isa => HashRef[Str], + lazy => 1, + default => sub { + my $self = shift; + my $config = $self->sqitch->config; + return { + map { %{ $config->get_section( section => "$_.variables" ) || {} } } ( + 'engine.' . $self->engine_key, + 'target.' . $self->name, + ) + }; + }, +); + +has registry => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $self = shift; + $self->_fetch('registry') || $self->engine->default_registry; + }, +); + +has client => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + my $self = shift; + $self->_fetch('client') || do { + my $client = $self->engine->default_client; + return $client unless App::Sqitch::ISWIN; + return $client if $client =~ /[.](?:exe|bat)$/; + return $client . '.exe'; + }; + }, +); + +has plan_file => ( + is => 'ro', + isa => File, + lazy => 1, + default => sub { + my $self = shift; + if ( my $f = $self->_fetch('plan_file') ) { + return file $f; + } + return $self->top_dir->file('sqitch.plan')->cleanup; + }, +); + +has plan => ( + is => 'ro', + isa => Plan, + lazy => 1, + default => sub { + my $self = shift; + App::Sqitch::Plan->new( + sqitch => $self->sqitch, + target => $self, + ); + }, +); + +has top_dir => ( + is => 'ro', + isa => Dir, + lazy => 1, + default => sub { + my $self = shift; + dir $self->_fetch('top_dir') || (); + }, +); + +has reworked_dir => ( + is => 'ro', + isa => Dir, + lazy => 1, + default => sub { + my $self = shift; + if ( my $dir = $self->_fetch('reworked_dir') ) { + return dir $dir; + } + $self->top_dir; + }, +); + +for my $script (qw(deploy revert verify)) { + has "$script\_dir" => ( + is => 'ro', + isa => Dir, + lazy => 1, + default => sub { + my $self = shift; + if ( my $dir = $self->_fetch("$script\_dir") ) { + return dir $dir; + } + $self->top_dir->subdir($script)->cleanup; + }, + ); + has "reworked_$script\_dir" => ( + is => 'ro', + isa => Dir, + lazy => 1, + default => sub { + my $self = shift; + if ( my $dir = $self->_fetch("reworked_$script\_dir") ) { + return dir $dir; + } + $self->reworked_dir->subdir($script)->cleanup; + }, + ); +} + +has extension => ( + is => 'ro', + isa => Str, + lazy => 1, + default => sub { + shift->_fetch('extension') || 'sql'; + }, +); + +sub BUILDARGS { + my $class = shift; + my $p = @_ == 1 && ref $_[0] ? { %{ +shift } } : { @_ }; + + # Fetch params. URI can come from passed name. + my $sqitch = $p->{sqitch} or return $p; + my $name = $p->{name} || $ENV{SQITCH_TARGET} || ''; + my $uri = $p->{uri}; + + # If we have a URI up-front, it's all good. + if ($uri) { + unless ($name) { + # Set the URI as the name, sans password. + if ($uri->password) { + $uri = $uri->clone; + $uri->password(undef); + } + $p->{name} = $uri->as_string; + } + return $p; + } + + my $ekey; + my $config = $sqitch->config; + + # If no name, try to find one. + if (!$name) { + # There are a couple of places to look for a name. + NAME: { + # Look for core target. + if ( $uri = $config->get( key => 'core.target' ) ) { + # We got core.target. + $p->{name} = $name = $uri; + last NAME; + } + + # No core target, look for an engine key. + $ekey = $config->get( key => 'core.engine' ) or do { + hurl target => __( + 'No engine specified; specify via target or core.engine' + ) if $config->initialized; + hurl target => __( + 'No project configuration found. Run the "init" command to initialize a project' + ); + }; + $ekey =~ s/\s+$//; + + # Find the name in the engine config, or fall back on a simple URI. + $uri = $config->get( key => "engine.$ekey.target" ) || "db:$ekey:"; + $p->{name} = $name = $uri; + } + } + + # Now we should have a name. What is it? + if ($name =~ /:/) { + # The name is a URI. + $uri = $name; + $name = $p->{name} = undef; + } else { + $p->{name} = $name; + # Well then, there had better be a config with a URI. + $uri = $config->get( key => "target.$name.uri" ) or do { + # Die on no section or no URI. + hurl target => __x( + 'Cannot find target "{target}"', + target => $name + ) unless %{ $config->get_section( + section => "target.$name" + ) }; + hurl target => __x( + 'No URI associated with target "{target}"', + target => $name, + ); + }; + } + + # Instantiate the URI. + $uri = $p->{uri} = URI::db->new( $uri ); + $ekey ||= $uri->canonical_engine or hurl target => __x( + 'No engine specified by URI {uri}; URI must start with "db:$engine:"', + uri => $uri->as_string, + ); + + # Override with optional parameters. + for my $attr (qw(user host port dbname)) { + $uri->$attr(delete $p->{$attr}) if exists $p->{$attr}; + } + + unless ($name) { + # Set the name. + if ($uri->password) { + # Remove the password from the name. + my $tmp = $uri->clone; + $tmp->password(undef); + $p->{name} = $tmp->as_string; + } else { + $p->{name} = $uri->as_string; + } + } + + return $p; +} + +sub all_targets { + my ($class, %p) = @_; + my $sqitch = $p{sqitch} or hurl 'Missing required argument: sqitch'; + my $config = delete $p{config} || $sqitch->config; + my (@targets, %seen); + my %dump = $config->dump; + + # First, load the default target. + my $core = $dump{'core.target'} || do { + if ( my $engine = $dump{'core.engine'} ) { + $engine =~ s/\s+$//; + $dump{"engine.$engine.target"} || "db:$engine:"; + } + }; + push @targets => $seen{$core} = $class->new(%p, name => $core) + if $core; + + # Next, load named targets. + for my $key (keys %dump) { + next if $key !~ /^target[.]([^.]+)[.]uri$/; + push @targets => $seen{$1} = $class->new(%p, name => $1) + unless $seen{$1}; + } + + # Now, load the engine targets. + while ( my ($key, $val) = each %dump ) { + next if $key !~ /^engine[.]([^.]+)[.]target$/; + push @targets => $seen{$val} = $class->new(%p, name => $val) + unless $seen{$val}; + $seen{$1} = $seen{$val}; + } + + # Finally, load any engines for which no target name was specified. + while ( my ($key, $val) = each %dump ) { + my ($engine) = $key =~ /^engine[.]([^.]+)/ or next; + $engine =~ s/\s+$//; + next if $seen{$engine}++; + my $uri = URI->new("db:$engine:"); + push @targets => $seen{$uri} = $class->new(%p, uri => $uri) + unless $seen{$uri}; + } + + # Return all the targets. + return @targets; +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::Target - Sqitch deployment target + +=head1 Synopsis + + my $plan = App::Sqitch::Target->new( + sqitch => $sqitch, + name => 'development', + ); + $target->engine->deploy; + +=head1 Description + +App::Sqitch::Target provides collects, in one place, the +L, L, and file locations +required to carry out Sqitch commands. All commands should instantiate a +target to work with the plan or database. + +=head1 Interface + +=head2 Constructors + +=head3 C + + my $target = App::Sqitch::Target->new( sqitch => $sqitch ); + +Instantiates and returns an App::Sqitch::Target object. The most important +parameters are C, C, and C. The constructor tries really +hard to figure out the proper name and URI during construction. If the C +parameter is passed, this is straight-forward: if no C is passed, +C will be set to the stringified format of the URI (minus the password, +if present). + +Otherwise, when no URI is passed, the name and URI are determined by taking +the following steps: + +=over + +=item * + +If there is no name, get the engine key from or the C ++configuration option. If no key can be determined, an exception will be +thrown. + +=item * + +Use the key to look up the target name in the C +configuration option. If none is found, use C. + +=item * + +If the name contains a colon (C<:>), assume it is also the value for the URI. + +=item * + +Otherwise, it should be the name of a configured target, so look for a URI in +the C configuration option. + +=back + +As a general rule, then, pass either a target name or URI string in the +C parameter, and Sqitch will do its best to find all the relevant target +information. And if there is no name or URI, it will try to construct a +reasonable default from the command-line options or engine configuration. + +All Target attributes may be passed as parameters to C. In addition, +C accepts a few non-attribute parameters that may be used to override +parts of the connection URI. They are: + +=over + +=item * C + +=item * C + +=item * C + +=item * C + +=back + +For example, if the the named target had its URI configured as +C, The C would be set as such by: + + my $target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'work'); + say $target->uri; + +However, passing the URI parameters like this: + + my $target = App::Sqitch::Target->new( + sqitch => $sqitch, + name => 'work', + user => 'bill', + port => 1212, + ); + say $target->uri; + +Sets the URI to C. + +=head3 C + +Returns a list of all the targets defined by the local Sqitch configuration +file. Done by examining the configuration object to find all defined targets +and engines, as well as the default "core" target. Duplicates are removed and +the list returned. This method takes the same parameters as C; only +C is required. All other parameters will be set on all of the returned +targets. + +=head2 Accessors + +=head3 C + + my $sqitch = $target->sqitch; + +Returns the L object that instantiated the target. + +=head3 C + +=head3 C + + my $name = $target->name; + $name = $target->target; + +The name of the target. If there was no name specified, the URI will be used +(minus the password, if there is one). + +=head3 C + + my $uri = $target->uri; + +The L object encapsulating the database connection information. + +=head3 C + + my $username = $target->username; + +Returns the target username, if any. The username is looked up from the URI. + +=head3 C + + my $password = $target->password; + +Returns the target password, if any. The password is looked up from the URI +or the C<$SQITCH_PASSWORD> environment variable. + +=head3 C + + my $engine = $target->engine; + +A L object to use for database interactions with the +target. + +=head3 C + + my $registry = $target->registry; + +The name of the registry used by the database. The value comes from one of +these options, searched in this order: + +=over + +=item * C<--registry> + +=item * C + +=item * C + +=item * C + +=item * Engine-specific default + +=back + +=head3 C + + my $client = $target->client; + +Path to the engine command-line client. The value comes from one of these +options, searched in this order: + +=over + +=item * C<--client> + +=item * C + +=item * C + +=item * C + +=item * Engine-and-OS-specific default + +=back + +=head3 C + + my $top_dir = $target->top_dir; + +The path to the top directory of the project. This directory generally +contains the plan file and subdirectories for deploy, revert, and verify +scripts. The value comes from one of these options, searched in this order: + +=over + +=item * C<--top-dir> + +=item * C + +=item * C + +=item * C + +=item * F<.> + +=back + +=head3 C + + my $plan_file = $target->plan_file; + +The path to the plan file. The value comes from one of these options, searched +in this order: + +=over + +=item * C<--plan-file> + +=item * C + +=item * C + +=item * C + +=item * F/sqitch.plan> + +=back + +=head3 C + + my $deploy_dir = $target->deploy_dir; + +The path to the deploy directory of the project. This directory contains all +of the deploy scripts referenced by changes in the C. The value +comes from one of these options, searched in this order: + +=over + +=item * C<--dir deploy_dir=$deploy_dir> + +=item * C + +=item * C + +=item * C + +=item * F> + +=back + +=head3 C + + my $revert_dir = $target->revert_dir; + +The path to the revert directory of the project. This directory contains all +of the revert scripts referenced by changes the C. The value comes +from one of these options, searched in this order: + +=over + +=item * C<--dir revert_dir=$revert_dir> + +=item * C + +=item * C + +=item * C + +=item * F> + +=back + +=head3 C + + my $verify_dir = $target->verify_dir; + +The path to the verify directory of the project. This directory contains all +of the verify scripts referenced by changes in the C. The value +comes from one of these options, searched in this order: + +=over + +=item * C<--dir verify_dir=$verify_dir> + +=item * C + +=item * C + +=item * C + +=item * F> + +=back + +=head3 C + + my $reworked_dir = $target->reworked_dir; + +The path to the reworked directory of the project. This directory contains +subdirectories for reworked deploy, revert, and verify scripts. The value +comes from one of these options, searched in this order: + +=over + +=item * C<--dir reworked_dir=$reworked_dir> + +=item * C + +=item * C + +=item * C + +=item * C<$top_dir> + +=back + +=head3 C + + my $reworked_deploy_dir = $target->reworked_deploy_dir; + +The path to the reworked deploy directory of the project. This directory +contains all of the reworked deploy scripts referenced by changes in the +C. The value comes from one of these options, searched in this +order: + +=over + +=item * C<--dir reworked_deploy_dir=$reworked_deploy_dir> + +=item * C + +=item * C + +=item * C + +=item * F> + +=back + +=head3 C + + my $reworked_revert_dir = $target->reworked_revert_dir; + +The path to the reworked revert directory of the project. This directory +contains all of the reworked revert scripts referenced by changes the +C. The value comes from one of these options, searched in this +order: + +=over + +=item * C<--dir reworked_revert_dir=$reworked_revert_dir> + +=item * C + +=item * C + +=item * C + +=item * F> + +=back + +=head3 C + + my $reworked_verify_dir = $target->reworked_verify_dir; + +The path to the reworked verify directory of the project. This directory +contains all of the reworked verify scripts referenced by changes in the +C. The value comes from one of these options, searched in this +order: + +=over + +=item * C<--dir reworked_verify_dir=$reworked_verify_dir> + +=item * C + +=item * C + +=item * C + +=item * F> + +=back + +=head3 C + + my $extension = $target->extension; + +The file name extension to append to change names to create script file names. +The value comes from one of these options, searched in this order: + +=over + +=item * C<--extension> + +=item * C + +=item * C + +=item * C + +=item * C<"sql"> + +=back + +=head3 C + + my $variables = $target->variables; + +The database variables to use in change scripts. The value are merged from +these options, in this order: + +=over + +=item * C + +=item * C + +=back + +The C configuration is not read, because command-specific +configurations, such as C and C take +priority. The command themselves therefore pass them to the engine in the +proper priority order. + +=head3 C + + my $key = $target->engine_key; + +The key defining which engine to use. This value defines the class loaded by +C. Convenience method for C<< $target->uri->canonical_engine >>. + +=head3 C + + my $dsn = $target->dsn; + +The DSN to use when connecting to the target via the DBI. Convenience method +for C<< $target->uri->dbi_dsn >>. + +=head3 C + + my $username = $target->username; + +The username to use when connecting to the target via the DBI. Convenience +method for C<< $target->uri->user >>. + +=head3 C + + my $password = $target->password; + +The password to use when connecting to the target via the DBI. Convenience +method for C<< $target->uri->password >>. + +=head1 See Also + +=over + +=item L + +The Sqitch command-line client. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/Types.pm b/lib/App/Sqitch/Types.pm new file mode 100644 index 00000000..fe49ee33 --- /dev/null +++ b/lib/App/Sqitch/Types.pm @@ -0,0 +1,191 @@ +package App::Sqitch::Types; + +use 5.010; +use strict; +use warnings; +use utf8; +use Type::Library 0.040 -base, -declare => qw( + Sqitch + Engine + Target + UserName + UserEmail + Plan + Change + ChangeList + LineList + Tag + Depend + DateTime + URI + URIDB + File + Dir + Config + DBH +); +use Type::Utils -all; +use Types::Standard -types; +use Locale::TextDomain 1.20 qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use App::Sqitch::Config; +use Scalar::Util qw(blessed); +use List::Util qw(first); + +our $VERSION = 'v1.0.0'; # VERSION + +# Inherit standard types. +BEGIN { extends 'Types::Standard' }; + +class_type Sqitch, { class => 'App::Sqitch' }; +class_type Engine, { class => 'App::Sqitch::Engine' }; +class_type Target, { class => 'App::Sqitch::Target' }; +class_type Plan, { class => 'App::Sqitch::Plan' }; +class_type Change, { class => 'App::Sqitch::Plan::Change' }; +class_type ChangeList, { class => 'App::Sqitch::Plan::ChangeList' }; +class_type LineList, { class => 'App::Sqitch::Plan::LineList' }; +class_type Tag, { class => 'App::Sqitch::Plan::Tag' }; +class_type Depend, { class => 'App::Sqitch::Plan::Depend' }; +class_type DateTime, { class => 'App::Sqitch::DateTime' }; +class_type URIDB, { class => 'URI::db' }; +class_type Config { class => 'App::Sqitch::Config' }; +class_type File { class => 'Path::Class::File' }; +class_type Dir { class => 'Path::Class::Dir' }; +class_type DBH { class => 'DBI::db' }; + +subtype UserName, as Str, where { + hurl user => __ 'User name may not contain "<" or start with "["' + if /^[[]/ || / __ 'User email may not contain ">"' if />/; + 1; +}; + +# URI can be URI or URI::Nested. +declare name => URI, constraint => sub { + my $o = $_; + return blessed $o && first { $o->isa($_)} qw(URI URI::Nested URI::WithBase) +}; + +1; +__END__ + +=head1 Name + +App::Sqitch::Types - Definition of attribute data types + +=head1 Synopsis + + use App::Sqitch::Types qw(Bool); + +=head1 Description + +This module defines data types use in Sqitch object attributes. Supported types +are: + +=over + +=item C + +An L object. + +=item C + +An L object. + +=item C + +An L object. + +=item C + +A Sqitch user name. + +=item C + +A Sqitch user email address. + +=item C + +A L object. + +=item C + +A L object. + +=item C + +A L object. + +=item C + +A L object. + +=item C + +A L object. + +=item C + +A L object. + +=item C + +A L object. + +=item C + +A L object. + +=item C + +A L object. + +=item C + +A C object. + +=item C + +A C object. + +=item C + +A L object. + +=item C + +A L database handle. + +=back + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/App/Sqitch/X.pm b/lib/App/Sqitch/X.pm new file mode 100644 index 00000000..6a5596d7 --- /dev/null +++ b/lib/App/Sqitch/X.pm @@ -0,0 +1,199 @@ +package App::Sqitch::X; + +use 5.010; +use utf8; +use Moo; +use Types::Standard qw(Str Int); +use Sub::Exporter::Util (); +use Throwable 0.200009; +use Sub::Exporter -setup => [qw(hurl)]; +use overload '""' => 'as_string'; + +our $VERSION = 'v1.0.0'; # VERSION + +has message => ( + is => 'ro', + isa => Str, + required => 1, +); + +has exitval => ( + is => 'ro', + isa => Int, + default => 2, +); + + +with qw( + Throwable + StackTrace::Auto +); + +has ident => ( + is => 'ro', + isa => Str, + default => 'DEV' +); + +has '+previous_exception' => (init_arg => 'previous_exception') + if Throwable->VERSION < 0.200007; + +sub hurl { + @_ = ( + __PACKAGE__, + # Always pass $@, as Throwable is unreliable about getting it thanks + # to hash randomization. Its workaround in v0.200006: + # https://github.com/rjbs/throwable/commit/596dfbafed970a30324dc21539d4edf2cbda767a + previous_exception => $@, + ref $_[0] ? %{ $_[0] } + : @_ == 1 ? (message => $_[0]) + : (ident => $_[0], message => $_[1]) + ); + goto __PACKAGE__->can('throw'); +} + +sub as_string { + my $self = shift; + join "\n", grep { defined } ( + $self->message, + $self->previous_exception, + $self->stack_trace + ); +} + +1; + +__END__ + +=head1 Name + +App::Sqitch::X - Sqitch Exception class + +=head1 Synopsis + + use Locale::TextDomain; + use App::Sqitch::X qw(hurl); + open my $fh, '>', 'foo.txt' or hurl { + ident => 'io', + message => __x 'Cannot open {file}: {err}", file => 'foo.txt', err => $!, + }; + +Developer: + + hurl 'Odd number of arguments passed to burf()' if @_ % 2; + +=head1 Description + +This module provides implements Sqitch exceptions. Exceptions may be thrown by +any part of the code, and, as long as a command is running, they will be +handled, showing the error message to the user. + +=head1 Interface + +=head2 Function + +=head3 C + +Throws an exception. Pass the parameters as a hash reference, like so: + + use App::Sqitch::X qw(hurl); + open my $fh, '>', 'foo.txt' or hurl { + ident => 'io', + message => __x 'Cannot open {file}: {err}", file => 'foo.txt', err => $!, + }; + +More simply, if all you need to pass are the C and C +parameters, you can pass them as the only arguments to C: + + open my $fh, '>', 'foo.txt' + or hurl io => __x 'Cannot open {file}: {err}", file => 'foo.txt', err => $! + +For errors that should only happen during development (e.g., an invalid +parameter passed by some other library that should know better), you can omit +the C: + + hurl 'Odd number of arguments passed to burf()' if @_ % 2; + +In this case, the C will be C, which you should not otherwise use. +Sqitch will emit a more detailed error message, including a stack trace, when +it sees C exceptions. + +The supported parameters are: + +=over + +=item C + +A non-localized string identifying the type of exception. + +=item C + +The exception message. Use L to craft localized messages. + +=item C + +Suggested exit value to use. Defaults to 2. This will be used if Sqitch +handles an exception while a command is running. + +=back + +=head2 Methods + +=head3 C + + my $errstr = $x->as_string; + +Returns the stringified representation of the exception. This value is also +used for string overloading of the exception object, which means it is the +output shown for uncaught exceptions. Its contents are the concatenation of +the exception message, the previous exception (if any), and the stack trace. + +=head1 Handling Exceptions + +use L to do exception handling, like so: + + use Try::Tiny; + try { + # ... + } catch { + die $_ unless eval { $_->isa('App::Sqitch::X') }; + $sqitch->vent($x_->message); + if ($_->ident eq 'DEV') { + $sqitch->vent($_->stack_trace->as_string); + } else { + $sqitch->debug($_->stack_trace->as_string); + } + exit $_->exitval; + }; + +Use the C attribute to determine what category of exception it is, and +take changes as appropriate. + +=head1 Author + +David E. Wheeler + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut + diff --git a/lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo b/lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo new file mode 100644 index 00000000..6c977d94 Binary files /dev/null and b/lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo differ diff --git a/lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo b/lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo new file mode 100644 index 00000000..c0eea0a1 Binary files /dev/null and b/lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo differ diff --git a/lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo b/lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo new file mode 100644 index 00000000..c449b8ac Binary files /dev/null and b/lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo differ diff --git a/lib/sqitch-add-usage.pod b/lib/sqitch-add-usage.pod new file mode 100644 index 00000000..905102d6 --- /dev/null +++ b/lib/sqitch-add-usage.pod @@ -0,0 +1,25 @@ +=head1 Name + +sqitch-add-usage - Sqitch add usage statement + +=head1 Usage + + sqitch add [options] [template options] [] + +=head1 Options + + -c --change name of the change to add + -r --requires require change + -x --conflicts declare conflicting change + -a --all add change to all project plans + -s --set set a template variable + -n --note a note describing the change + + -t --template name of the templates to use + --template-directory path to directory containing templates + --use [template=path] path to named template + + --with [script] create named script + --without [script] do not create named script + -e --edit, --open-editor open change scripts in an editor + -f --plan-file path to a deployment plan file diff --git a/lib/sqitch-add.pod b/lib/sqitch-add.pod new file mode 100644 index 00000000..7a8923d4 --- /dev/null +++ b/lib/sqitch-add.pod @@ -0,0 +1,435 @@ +=head1 Name + +sqitch-add - Add a database change to plans + +=head1 Synopsis + + sqitch add widgets [options + sqitch add blankets --all + sqitch add --change sprockets pg sql + sqitch add slinkies --require sprockets --set schema=industry + +=head1 Description + +This command adds a database change to one or more plans. This will result in +the creation of script files in the deploy, revert, and verify directories, +and possibly others. The content of these files is determined by the +evaluation of templates. By default, system templates in +F<$(prefix)/etc/sqitch/templates> are used. These can be overridden by a +single user by creating templates in F<~/.sqitch/templates/> See L +for details. + +The paths and extensions of the generated scripts depend on the configuration +of Sqitch targets, engines, and the core. See L for +details. + +Note that the name of the new change must adhere to the rules as defined in +L. + +By default, the C command will add the change to the default plan and the +scripts to any top directories for that plan, as defined by the core +configuration and command-line options. This works well for projects in which +there is a single plan with separate top directories for each engine, for +example. Pass the C<--all> option to have it iterate over all known plans and +top directories (as specified for engines and targets) and add the change to +them all. + +To specify which plans and top directories to which the change and its scripts +will be added, pass the target, engine, or plan file names as arguments. Use +C<--change> to disambiguate the tag and change names from the other parameters +if necessary (or preferable). See L for examples. + +=head1 Options + +=over + +=item C<-c> + +=item C<--change> + +=item C<--change-name> + +The name of the change to add. The name can be specified with or without this +option, but the option can be useful for disambiguating the change name from +other arguments. + +=item C<-r> + +=item C<--requires> + +Name of a change that is required by the new change. May be specified multiple +times. See L for the various ways in which changes can be +specified. + +=item C<-x> + +=item C<--conflicts> + +Name of a change that conflicts with the new change. May be specified multiple +times. See L for the various ways in which changes can be +specified. + +=item C<-a> + +=item C<--all> + +Add the change to all plans in the project. Cannot be mixed with target, +engine, or plan file name arguments; doing so will result in an error. Useful +for multi-plan projects in which changes should be kept in sync. Overrides the +value of the C configuration; use C<--no-all> to override a true +C configuration. + +=item C<-n> + +=item C<--note> + +A brief note describing the purpose of the change. The note will be attached +to the change as a comment. Multiple invocations will be concatenated together +as separate paragraphs. + +For you Git folks out there, C<-m> also works. + +=item C<-s> + +=item C<--set> + +Set a variable name and value for use in the templates. The format must be +C, e.g., C<--set comment='This one is for you, babe.'>. + +=item C<--template-directory> + +Location to look for the templates. If none is specified, C will +first look in F<~/.sqitch/templates/> for each template, and fall back on +F<$(prefix)/etc/sqitch/templates>. + +=item C<-t> + +=item C<--template> + +=item C<--template-name> + +Name of the templates to use for the scripts. When Sqitch searches the +template directory for templates, it uses this name to find them in subdirectories +named for the various types of scripts, including: + +=over + +=item C + +=item C + +=item C + +=back + +Any templates found with the same name in additional subdirectories will also +be evaluated. + +This option allows one to define templates for specific tasks, such as +creating a table, and then use them for changes that perform those tasks. +Defaults to the name of the database engine (C, C, C, +C, C, C, C, or C). + +=item C<--use script=template> + +Specify the path to a template for a specific type of script. Defaults to the +individual templates and using C<--template-name>, found in +C<--template-directory> and the configuration template directories. + +=item C<--with> + +=item C<--without> + +Specify a type of template to generate or not generate. + +=item C<-e> + +=item C<--edit> + +=item C<--open-editor> + +Open the generated change scripts in an editor. + +=item C<--no-edit> + +=item C<--no-open-editor> + +Do not open the change scripts in an editor. Useful when L> +is true. + +=item C<--plan-file> + +=item C<-f> + +Path to the deployment plan file. Overrides target, engine, and core +configuration values. Defaults to F<$top_dir/sqitch.plan>. + +=back + +=head1 Examples + +Add a change to a project and be prompted for a note. + + sqitch add widgets + +Add a change and specify the note. + + sqitch add sprockets --note 'Adds the sprockets table.' + +Add a change that requires the C change from earlier in the plan. + + sqitch add contacts --requires users -n 'Adds the contacts table' + +Add a change that requires multiple changes, including the change named +C from a completely different Sqitch project named C: + + sqitch add coffee -r users -r utilities:extract -n 'Mmmmm...coffee!' + +Add a change that uses the C templates to generate the scripts, +as well as variables to be used in that template (See +L for a custom template +tutorial): + + sqitch add corp_widgets --template createtable \ + -s schema=corp -s table=widgets \ + -s column=id -s type=SERIAL \ + -s column=name -s type=TEXT \ + -s column=quantity -s type=INTEGER \ + -n 'Add corp.widgets table.' + +Add a change only to the plan used by the C engine in a project: + + sqitch add --change logs vertica -n 'Adds the logs table to Vertica.' + +Add a change to just two plans in a project, and generate the scripts only for +those plans: + + sqitch add -a coolfunctions sqlite.plan pg.plan -n 'Adds functions.' + +=head1 Templates + +Sqitch contains a very simple set of templates for generating the deploy, +revert, and verify scripts, and you can create more of your own. By default, +Sqitch uses system-wide templates installed in +F<$(prefix)/etc/sqitch/templates>; call C to find out +where, exactly (e.g., C<$(sqitch --etc-path)/templates>). Individual templates +may be overridden on a user basis by copying templates to +F<~/.sqitch/templates> and making modifications. They may also be overridden +by using the C<--template-directory> or C<--template-name> options, as well as +the template-specific options. + +=head2 Directory Layout + +Sqitch looks for templates in the following directories, and in this order: + +=over + +=item * C<--template-directory> or C + +=item * F<~/.sqitch/templates/> + +=item * F<$(prefix)/etc/sqitch/templates> + +=back + +Each should consist of subdirectories named for the types of scripts to be +generated. These should include F, F, and F, but you +can create any number of other directories to create additional scripts that +will end up in a directory of the same name. + +Each directory should include one or more files ending in F<.tmpl>. The +main part of the file name can be anything, but by default Sqitch will +look for a file named for the database engine. Use the C<--template> option +to have Sqitch use a different file. + +For example, say you have this directory structure: + + templates/deploy/pg.tmpl + templates/deploy/create_table.tmpl + templates/revert/pg.tmpl + templates/revert/create_table.tmpl + templates/test/pg.tmpl + templates/verify/pg.tmpl + templates/verify/create_table.tmpl + +Assuming that you're using the PostgreSQL engine, the code for which is C, +when you add a new change like so: + + sqitch add schema -n 'Creates schema' + +Sqitch will use the C files to create the following files in the +top directory configured for the project (See L for +details). + + deploy/schema.sql + revert/schema.sql + test/schema.sql + verify/schema.sql + +If you want to use the C templates, instead, use the +C<--template> option, like so: + + sqitch add user_table --template create_table -n 'Create user table' + +Sqitch will use the C files to create the following files +in the top directory configured for the project (See L +for details). + + deploy/user_table.sql + revert/user_table.sql + verify/user_table.sql + +Note that the C file was not created, because no +F template file exists. + +=head2 Syntax + +The syntax of Sqitch templates is the very simple language provided by +L, which is limited to: + +=over + +=item C<[% %]> + +This is the directive syntax. By default, the return value of the expression +is output: + + -- Deploy [% project %]:[% change %] to [% engine %] + +You can add C<-> to the immediate start or end of a directive tag to control +the whitespace chomping options: + + [% IF foo -%] # remove trailing newline + We have foo! + [%- END %] # remove leading newline + +=item C<[% IF %]> + +=item C<[% IF %] / [% ELSE %]> + +=item C<[% UNLESS %]> + +Conditional blocks: + + [% IF transactions %] + BEGIN; + [% ELSE %] + -- No transaction, beware! + [% END %] + +=item C<[% FOREACH item IN list %]> + +Loop over a list of values: + + [% FOREACH item IN requires -%] + -- requires: [% item %] + [% END -%] + +=back + +If this is not sufficient for your needs, simply install L +and all templates will be processed by its more comprehensive features. See +the L for +details, especially the L + +=head2 Variables + +Sqitch defines five variables for all templates. Any number of additional +variables can be added via the C<--set> option, like so: + + sqitch add --set transactions=1 --set schema=foo + +Any number of variables may be specified in this manner. You may then use +those variables in custom templates. Variables that appear multiple times will +be passed to the templates as lists of values for which you will likely want +to use C<[% FOREACH %]>. If the templates do not reference your variables, +they will be ignored. Variables may also be specified in a C +L section (see L). Variables +specified via C<--set> will override configuration variables. + +The five core variables are: + +=over + +=item C + +The name of the change being added. + +=item C + +The name of the engine for which the change was added. One of C, +C, C, C, C, C C, or +C. + +=item C + +The name of the Sqitch project to which the change was added. The project name +is set in the plan by the L command>|sqitch-init>. + +=item C + +A list of required changes as passed via one or more instances of the +C<--requires> option. + +=item C + +A list of conflicting changes as passed via one or more instances of the +C<--conflicts> option. + +=back + +=head1 Configuration Variables + +=over + +=item C + +Add the change to all the plans in the project. Useful for multi-plan projects +in which changes should be kept in sync. May be overridden by C<--all>, +C<--no-all>, or target, engine, and plan file name arguments. + +=item C + +Directory in which to find the templates. Any templates found in this +directory take precedence over user- or system-specific templates, and may in +turn be overridden by the C<--use> option. + +=item C + +Name used for template files. Should not include the F<.tmpl> suffix. +Overrides the default, which is the name of the database engine, and may in +turn be overridden by the C<--template> option. + +=item C<[add.templates]> + +Location of templates of different types. Core templates include: + +=over + +=item C + +=item C + +=item C + +=back + +But a custom template type can have its location specified here, as well, +such as C. May be overridden by C<--use>. + +=item C<[add.variables]> + +A section defining template variables. Useful if you've customized templates +with your own variables and want project-, user-, or system-specific defaults +for them. + +=item C + +Boolean indicating if the add command should spawn an editor after generating +change scripts. When true, equivalent to passing C<--edit>. Defaults off. + +=back + +=head1 Sqitch + +Part of the L suite. diff --git a/lib/sqitch-authentication.pod b/lib/sqitch-authentication.pod new file mode 100644 index 00000000..ac5aaabd --- /dev/null +++ b/lib/sqitch-authentication.pod @@ -0,0 +1,391 @@ +=encoding UTF-8 + +=head1 Name + +sqitch-authentication - Guide to using database authentication credentials with Sqitch + +=head1 Description + +For database engines that require authentication, Sqitch supports a number +of credential-specification options, and searches for them in a specific +sequence. These searches are performed in two parts: a search for a username +and a search for a password. + +=head1 Usernames + +Sqitch searches for usernames sequentially, using the first value it finds. +Any of these approaches may be used to specify a username, in this order: + +=over + +=item 1. In the C<$SQITCH_USERNAME> environment variable + +=item 2. Via the C<--db-username> option + +=item 3. In the deploy target URI; this is the preferred option + +=item 4. In an engine-specific environment variable or configuration + +=back + +Naturally, this last option varies by database engine. The details are as +follows: + +=over + +=item PostgreSQL + +The Postgres engine uses the C environment variable, if set. +Otherwise, it uses the system username. + + +=item MySQL + +For MySQL, if the L module is installed, usernames and +passwords can be specified in the +L and F<~/.my.cnf> files|https://dev.mysql.com/doc/refman/5.7/en/password-security-user.html>. +These files must limit access only to the current user (C<0600>). Sqitch will +look for a username and password under the C<[client]> and C<[mysql]> +sections, in that order. + +=item Oracle + +Oracle provides no default to search for a username. + +=item Vertica + +The Vertica engine uses the C environment variable, if set. +Otherwise, it uses the system username. + +=item Firebird + +The Firebird engine uses the C environment variable, if set. + +=item Exasol + +Exasol provides no default to search for a username. + +=item Snowflake + + +The Snowflake engine uses the C environment variable, if set. +Next, it looks in the +L file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#snowsql-config-file> +and use the default C value. Otherwise, it uses the +system username. + +=back + +=head1 Passwords + +You may have noticed that Sqitch has no C<--password> option. This is +intentional. It's generally not a great idea to specify a password on the +command-line: from there, it gets logged to your command history and is easy +to extract by anyone with access to your system. So you might wonder how to +specify passwords so that Sqitch an successfully deploy to databases that +require passwords. There are four approaches, in order from most- to +least-recommended: + +=over + +=item 1. Avoid using a password at all + +=item 2. Use a database engine-specific password file + +=item 3. Use the C<$SQITCH_PASSWORD> environment variable + +=item 4. Include the password in the deploy target URI + +=back + +Each is covered in detail in the sections below. + +=head2 Don't use Passwords + +Of course, the best way to protect your passwords is not to use them at all. +If your database engine is able to do passwordless authentication, it's worth +taking the time to make it work, especially on your production database +systems. Some examples: + +=over + +=item PostgreSQL + +PostgreSQL supports a number of +L, +including the passwordless L, L, and, for local connections, +L. + +=item MySQL + +MySQL supports a number of +L, +plus L. + +=item Oracle + +Oracle supports a number of +L, +including +L, +L, +and, for local connections, +L. + +=item Vertica + +Vertica supports a number of +L +including the passwordless L, +L, +and, for local connections, +L. + +=item Firebird + +Firebird supports passwordless authentication only via +L +for local connections. + +=item Exasol + +Exasol doesn't seem to support password-less authentication at this time; for +other options, see the L. + +=item Snowflake + +Snowflake does not support password-less authentication, but does support +key-pair authentication. Follow +L +to create a key pair, then set the following variables in the F<~/.snowsql/config> +file: + + authenticator = SNOWFLAKE_JWT + private_key_path = "path/to/privatekey.p8" + +To connect, set the C<$SNOWSQL_PRIVATE_KEY_PASSPHRASE> environment variable to +the passphrase for the private key, and add these parameters to the query part +of your connection URI: + +=over + +=item * C + +=item * C + +=item * C + +=item * C + +=back + +For example: + + db:snowflake://movera@example.snowflakecomputing.com/flipr?Driver=Snowflake;warehouse=sqitch;authenticator=SNOWFLAKE_JWT;uid=movera;priv_key_file=path/to/privatekey.p8;priv_key_file_pwd=s0up3rs3cre7 + +=back + +=head2 Use a Password File + +If you must use password authentication with your database server, you may be +able to use a protected password file. This is file with access limited only +to the current user that the server client library can read in. As such, the +format is specified by the database vendor, and not all database servers offer +the feature. Here's how the database engines supported by Sqitch shake out: + +=over + +=item PostgreSQL + +PostgreSQL will use a +L file|https://www.postgresql.org/docs/current/static/libpq-pgpass.html> in the +user's home directory to or referenced by the C<$PGPASSFILE> environment +variable. This file must limit access only to the current user (C<0600>) and +contains lines specify authentication rules as follows: + + hostname:port:database:username:password + +=item MySQL + +For MySQL, if the L module is installed, usernames and +passwords can be specified in the +L and F<~/.my.cnf> files|https://dev.mysql.com/doc/refman/5.7/en/password-security-user.html>. +These files must limit access only to the current user (C<0600>). Sqitch will +look for a username and password under the C<[client]> and C<[mysql]> +sections, in that order. + +=item Oracle + +Oracle supports +L +created with the C utility to authenticate C and C +users, but B Neither can +one L +into a +L|https://docs.oracle.com/cd/B28359_01/network.111/b28317/tnsnames.htm#NETRF007> +file. + +=item Vertica + +Vertica does not currently support a password file. + +=item Firebird + +Firebird does not currently support a password file. + +=item Exasol + +Exasol allows configuring connection profiles for the 'exaplus' client: + + > exaplus -u sys -p exasol -c localhost:8563 -wp flipr_test + EXAplus 6.0.4 (c) EXASOL AG + + Profile flipr_test is saved. + > exaplus -profile flipr_test -q -sql "select current_timestamp;" + + CURRENT_TIMESTAMP + -------------------------- + 2017-11-02 13:35:48.360000 + +These profiles are stored in F<~/.exasol/profiles.xml>, readable only to the user +by default. See the L +for more information on connection profiles, specifically the EXAplus section in +the chapter on "Clients and interfaces". + +For ODBC connections from Sqitch, we can use connection settings in F<~/.odbc.ini>: + + [flipr_test] + DRIVER = Exasol + EXAHOST = localhost:8563 + EXAUID = sys + EXAPWD = exasol + +When combining the above, Sqitch doesn't need to know any credentials; they are +stored somewhat safely in F<~/.exasol/profiles.xml> and F<~/.odbc.ini>: + + > sqitch status db:exasol:flipr_test + # On database db:exasol:flipr_test + # Project: flipr + # ... + # + Nothing to deploy (up-to-date) + > sqitch rebase --onto '@HEAD^' -y db:exasol:flipr_test + Reverting changes to hashtags @v1.0.0-dev2 from db:exasol:flipr_test + - userflips .. ok + Deploying changes to db:exasol:flipr_test + + userflips .. ok + +=item Snowflake + +For Snowflake, Sqitch will read the +L file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#snowsql-config-file> +and use the default connections settings; named connections are not supported. +An example: + + [connections] + accountname = myaccount + region = us-east-1 + warehousename = compute + username = frank + password = fistula postmark bag + rolename = ACCOUNTADMIN + dbname = reporting + +The variables that Sqitch currently reads are: + +=over + +=item C + +=item C + +=item C + +=item C + +=item C + +=item C + +=item C + +=back + +=back + +=head2 Use C<$SQITCH_PASSWORD> + +The C<$SQITCH_PASSWORD> environment variable can be used to specify the +password for any supported database engine. However use of this environment +variable is not recommended for security reasons, as some operating systems +allow non-root users to see process environment variables via C. + +The behavior of C<$SQITCH_PASSWORD> is consistent across all supported +engines, as is the complementary C<$SQITCH_USERNAME> environment variable. +Some database engines support their own password environment variables, which +you may wish to use instead. However, their behaviors may not be consistent: + +=over + +=item PostgreSQL + +C<$PGPASSWORD> + +=item MySQL + +C<$MYSQL_PWD> + +=item Vertica + +C<$VSQL_PASSWORD> + +=item Firebird + +C<$ISC_PASSWORD> + +=item Snowflake + +C<$SNOWSQL_PWD> + +=back + +=head2 Use Target URIs + +Passwords may also be specified in L. +This is not generally recommended, since such URIs are either specified via +the command-line (and therefore visible in C and your shell history) or +stored in the L, the project instance of +which is generally pushed to your source code repository. But it's provided +here as an absolute last resort (and because web URLs support it, though it's +heavily frowned upon there, too). + +Such URIs can either be specified on the command-line: + + sqitch deploy db:pg://fred:s3cr3t@db.example.com/widgets + +Or stored as named targets in the project configuration file: + + sqitch target add wigets db:pg://fred:s3cr3t@db.example.com/widgets + +After which the target is available by its name: + + sqitch deploy widgets + +See L and C for details on target +configuration. + +=head1 See Also + +=over + +=item * L + +=item * L + +=item * L + +=back + +=head1 Sqitch + +Part of the L suite. diff --git a/lib/sqitch-bundle-usage.pod b/lib/sqitch-bundle-usage.pod new file mode 100644 index 00000000..7219b5f3 --- /dev/null +++ b/lib/sqitch-bundle-usage.pod @@ -0,0 +1,14 @@ +=head1 Name + +sqitch-bundle-usage - Sqitch bundle usage statement + +=head1 Usage + + sqitch bundle [options] [] [] [] + +=head1 Options + + --dest-dir --dir destination directory to which to copy files + --from change from which to start bundling + --to change to which to end bundling + -f --plan-file path to a deployment plan file diff --git a/lib/sqitch-bundle.pod b/lib/sqitch-bundle.pod new file mode 100644 index 00000000..633e7128 --- /dev/null +++ b/lib/sqitch-bundle.pod @@ -0,0 +1,133 @@ +=head1 Name + +sqitch-bundle - Bundle a Sqitch project for distribution + +=head1 Synopsis + + sqitch bundle [options + sqitch bundle --dest-dir widgets-1.0.0 + sqitch bundle --all + sqitch bundle pg mysql + +=head1 Description + +This command bundles up a sqitch project for distribution. At its simplest, it +copies the project configuration file, plan files, and all of the change +scripts to a directory. This directory can then be packaged up for +distribution (as a tarball, RPM, etc.). + +By default, the C command will bundle the plan and scripts for the +default plan and top directory, as defined by the core configuration and +command-line options. Pass the C<--all> option to have it iterate over all +known plans and top directories (as specified for engines and targets) and +bundle them all. This works well for creating a a single bundle with all +plans and scripts. + +To specify which plans an top directories to bundle, pass the target, engine, +or plan file names as arguments. See L for examples. + +=over + +=item * Engine names + +=item * Target names + +=item * Plan file names + +=back + +The bundle command also allows you to limit bundled changes to a subset of +those in a plan. When bundling a single plan, use the C<--from> and/or C<--to> +options to do the limiting. When using multiple plans, specify the changes +after each target argument. In either case, the changes can be specified in +any way documented on L. See L for examples. + +=head1 Options + +=over + +=item C<--dest-dir> + +=item C<--dir> + +The name of the directory in which to bundle the project. The configuration +file will be created in this directory, and all top, deploy, revert, and +verify directories will be created relative to it. Defaults to F. + +=item C<--from> + +The change from which to start bundling. If you need to bundle up only a subset +of a plan, specify a change (using a supported L +from which to start the bundling via this option. This option is probably only +useful when bundling a single plan. + +=item C<--to> + +The change to which to end bundling. If you need to bundle up only a subset +of a plan, specify a change (using a supported L +that should be the last change to be included in the bundle. This option is +probably only useful when bundling a single plan. + +=item C<-a> + +=item C<--all> + +Bundle all the project plans and their associated scripts. Cannot be mixed +with target, engine, or plan file name arguments; doing so will result in an +error. Useful for multi-plan projects that should have all the plans bundled +together. Overrides the value of the C configuration; use +C<--no-all> to override a true C configuration. + +=item C<--plan-file> + +=item C<-f> + +Path to the deployment plan file. Overrides target, engine, and core +configuration values. Defaults to F<$top_dir/sqitch.plan>. + +=back + +=head1 Configuration Variables + +=over + +=item C + +The name of the directory in which to bundle the project. + +=back + +=head1 Examples + +Bundle a Sqitch project with the default plan and scripts into F: + + sqitch bundle + +Bundle a Sqitch project with all plans and scripts into F: + + sqitch bundle --all + +Bundle a Sqitch project into F: + + sqitch bundle --dest-dir BUILDROOT/MyProj + +Bundle a project including changes C through C<@v1.0>: + + sqitch bundle --from adduser --to @v1.0 + +Bundle a the C engine plans with changes C through C<@v1.0>, and +the C engine with changes from the start of the plan up to C: + + sqitch bundle pg adduser @v1.0 sqlite @ROOT wigets + +Bundle just the files necessary to execute the plan for the C engine: + + sqitch bundle pg + +Bundle the files necessary for two plan files: + + sqitch bundle sqlite/sqitch.plan mysql/sqitch.plan + +=head1 Sqitch + +Part of the L suite. diff --git a/lib/sqitch-checkout-usage.pod b/lib/sqitch-checkout-usage.pod new file mode 100644 index 00000000..4d766733 --- /dev/null +++ b/lib/sqitch-checkout-usage.pod @@ -0,0 +1,26 @@ +=head1 Name + +sqitch-checkout-usage - Sqitch checkout usage statement + +=head1 Usage + + sqitch checkout [options] [] + +=head1 Options + + -t --target database to which to connect + --mode deploy failure reversion mode (all, tag, or change) + --verify run verify scripts after deploying each change + --no-verify do not run verify scripts + -s --set set a database client variable + -r --set-revert set a database client revert variable + -e --set-deploy set a database client deploy variable + --log-only log changes without running them + -y disable the prompt before reverting + --registry registry schema or database + --db-client path to the engine command-line client + -d --db-name database name + -u --db-user database user name + -h --db-host database server host name + -p --db-port database server port number + -f --plan-file path to a deployment plan file diff --git a/lib/sqitch-checkout.pod b/lib/sqitch-checkout.pod new file mode 100644 index 00000000..9039b311 --- /dev/null +++ b/lib/sqitch-checkout.pod @@ -0,0 +1,278 @@ +=head1 Name + +sqitch-checkout - Revert, checkout another VCS branch, and re-deploy changes + +=head1 Synopsis + + sqitch checkout [options] [] + +=head1 Description + +Checkout another branch in your project's VCS (such as +L), while performing the necessary database changes +to update your database for the new branch. + +More specifically, the C command compares the plan in the current +branch to that in the branch to check out, identifies the last common changes +between them, reverts to that change as if L|sqitch-revert> +was called (unless there have been no changes deployed since that change), +checks out the new branch, and then deploys all changes as if +L|sqitch-deploy> had been called. + +If the VCS is already on the specified branch, nothing will be done. + +The C<< >> parameter specifies the database to which to connect, +and may also be specified as the C<--target> option. It can be target name, +a URI, an engine name, or plan file path. + +=head1 Options + +=over + +=item C<-t> + +=item C<--target> + +The target database to which to connect. This option can be either a URI or +the name of a target in the configuration. + +=item C<--mode> + +Specify the reversion mode to use in case of failure. Possible values are: + +=over + +=item C + +In the event of failure, revert all deployed changes, back to the point at +which deployment started. This is the default. + +=item C + +In the event of failure, revert all deployed changes to the last +successfully-applied tag. If no tags were applied during this deployment, all +changes will be reverted to the point at which deployment began. + +=item C + +In the event of failure, no changes will be reverted. This is on the +assumption that a change is atomic, and thus may may be deployed again. + +=back + +=item C<--verify> + +Verify each change by running its verify script, if there is one. If a verify +test fails, the deploy will be considered to have failed and the appropriate +reversion will be carried out, depending on the value of C<--mode>. + +=item C<--no-verify> + +Don't verify each change. This is the default. + +=item C<-s> + +=item C<--set> + +Set a variable name and value for use by the database engine client, if it +supports variables. The format must be C, e.g., +C<--set defuser='Homer Simpson'>. Overrides any values loaded from +L. + +=item C<-e> + +=item C<--set-deploy> + +Set a variable name and value for use by the database engine client when +deploying, if it supports variables. The format must be C, e.g., +C<--set defuser='Homer Simpson'>. Overrides any values from C<--set> or values +loaded from L. + +=item C<-r> + +=item C<--set-revert> + +Sets a variable name to be used by the database engine client during when +reverting, if it supports variables. The format must be C, e.g., +C<--set defuser='Homer Simpson'>. Overrides any values from C<--set> or values +loaded from L. + +=item C<--log-only> + +Log the changes as if they were deployed, but without actually running the +deploy scripts. Useful for an existing database that is being converted to +Sqitch, and you need to log changes as deployed because they have been +deployed by other means in the past. + +=item C<-y> + +Disable the prompt that normally asks whether or not to execute the revert. + +=item C<--registry> + + sqitch checkout --registry registry + +The name of the Sqitch registry schema or database in which sqitch stores its +own data. + +=item C<--db-client> + +=item C<--client> + + sqitch checkout --client /usr/local/pgsql/bin/psql + +Path to the command-line client for the database engine. Defaults to a client +in the current path named appropriately for the database engine. + +=item C<-d> + +=item C<--db-name> + + sqitch checkout --db-name widgets + sqitch checkout -d bricolage + +Name of the database. In general, L and URIs are +preferred, but this option can be used to override the database name in a +target. + +=item C<-u> + +=item C<--db-user> + +=item C<--db-username> + + sqitch checkout --db-username root + sqitch checkout --db-user postgres + sqitch checkout -u Mom + +User name to use when connecting to the database. Does not apply to all +engines. In general, L and URIs are preferred, but this +option can be used to override the user name in a target. + +=item C<-h> + +=item C<--db-host> + + sqitch checkout --db-host db.example.com + sqitch checkout -h appdb.example.net + +Host name to use when connecting to the database. Does not apply to all +engines. In general, L and URIs are preferred, but this +option can be used to override the host name in a target. + +=item C<-p> + +=item C<--db-port> + + sqitch checkout --db-port 7654 + sqitch checkout -p 5431 + +Port number to connect to. Does not apply to all engines. In general, +L and URIs are preferred, but this option can be used +to override the port in a target. + +=item C<--plan-file> + +=item C<-f> + + sqitch checkout --plan-file my.plan + +Path to the deployment plan file. Overrides target, engine, and core +configuration values. Defaults to F<$top_dir/sqitch.plan>. + +=back + +=head1 Configuration Variables + +=over + +=item C<[deploy.variables]> + +=item C<[revert.variables]> + +A section defining database client variables. These variables are useful if +your database engine supports variables in scripts, such as PostgreSQL's +L +variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>, +Vertica's L +variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm>, +MySQL's L, +SQL*Plus's L +variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>, +and Snowflake's L. + +May be overridden by C<--set>, C<--set-deploy>, C<--set-revert>, or target and +engine configuration. Variables are merged in the following priority order: + +=over + +=item C<--set-revert> + +Used only while reverting changes. + +=item C<--set-deploy> + +Used only while deploying changes. + +=item C<--set> + +Used while reverting and deploying changes. + +=item C + +Used while reverting and deploying changes. + +=item C + +Used while reverting and deploying changes. + +=item C + +Used only while reverting changes. + +=item C + +Used while reverting and deploying changes. + +=item C + +Used while reverting and deploying changes. + +=back + +=item C + +=item C + +Boolean indicating whether or not to verify each change after deploying it. + +=item C + +=item C + +Deploy mode. The supported values are the same as for the C<--mode> option. + +=item C<[checkout.no_prompt]> + +=item C<[revert.no_prompt]> + +A boolean value indicating whether or not to disable the prompt before +executing the revert. The C variable takes precedence over +C, and both may of course be overridden by C<-y>. + +=item C<[checkout.prompt_accept]> + +=item C<[revert.prompt_accept]> + +A boolean value indicating whether default reply to the prompt before +executing the revert should be "yes" or "no". The C +variable takes precedence over C, and both default to +true, meaning to accept the revert. + +=back + +=head1 Sqitch + +Part of the L suite. diff --git a/lib/sqitch-config-usage.pod b/lib/sqitch-config-usage.pod new file mode 100644 index 00000000..077ab54d --- /dev/null +++ b/lib/sqitch-config-usage.pod @@ -0,0 +1,37 @@ +=head1 Name + +sqitch-config-usage - Sqitch config usage statement + +=head1 Usage + + sqitch [options] config [config options] + +=head1 Options + +Config file location + + --local use local config file + --user use user config file + --system use system config file + -f, --file use given config file + +Action + + --get get value: name [value-regex] + --get-all get all values: key [value-regex] + --get-regexp get values for regexp: name-regex [value-regex] + --replace-all replace all matching variables: name value [value_regex] + --add adds a new variable: name value + --unset removes a variable: name [value-regex] + --unset-all removes all matches: name [value-regex] + --rename-section rename section: old-name new-name + --remove-section remove a section: name + -l, --list list all + -e, --edit opens an editor + +Type + + --bool value is "true" or "false" + --int value is decimal number + --num value is decimal number + --bool-or-int value is --bool or --int diff --git a/lib/sqitch-config.pod b/lib/sqitch-config.pod new file mode 100644 index 00000000..f15ae95e --- /dev/null +++ b/lib/sqitch-config.pod @@ -0,0 +1,645 @@ +=head1 Name + +sqitch-config - Get and set local, user, or system Sqitch options + +=head1 Synopsis + + sqitch config [] [type] name [value [value_regex]] + sqitch config [] [type] --add name value + sqitch config [] [type] --replace-all name value [value_regex] + sqitch config [] [type] --get name [value_regex] + sqitch config [] [type] --get-all name [value_regex] + sqitch config [] [type] --get-regexp name_regex [value_regex] + sqitch config [] --unset name [value_regex] + sqitch config [] --unset-all name [value_regex] + sqitch config [] --rename-section old_name new_name + sqitch config [] --remove-section name + sqitch config [] -l | --list + sqitch config [] -e | --edit + +=head1 Description + +You can query/set/replace/unset Sqitch options with this command. The name is +actually the section and the key separated by a dot, and the value will be +escaped. + +Multiple lines can be added to an option by using the C<--add> option. If you +want to update or unset an option which can occur on multiple lines, a Perl +regular expression C needs to be given. Only the existing values +that match the regex will be updated or unset. If you want to handle lines +that do not match the regex, just prepend a single C (exclamation point) in +front (see L). + +The C specifier can be C<--int>, C<--num>, or C<--bool>, to ensure that +the variable(s) are of the given type and convert the value to the canonical +form (simple integer for C<--int>, decimal number for C<--num>, a "true" or +"false" string for C<--bool>) If no type specifier is passed, no checks or +transformations are performed on the value. + +The C can be one of C<--local>, C<--user>, C<--system>, or +C<--file>, which specify where the values will be read from or written to. The +default is to assume the local config file in the current project directory, +for editing, and the all files merged for fetching (see L). + +=begin comment + +XXX Need to implmenent these. + +This command will fail (with exit code ret) if: + +=over + +=item 1. + +The config file is invalid (ret=3) + +=item 2. + +Cannot write to the config file (ret=4) + +=item 3. + +No section or name was provided (ret=2) + +=item 4. + +The section or key is invalid (ret=1) + +=item 5. + +You try to unset an option which does not exist (ret=5) + +=item 6. + +You try to unset/set an option for which multiple lines match (ret=5) + +=item 7. + +You try to use an invalid regexp (ret=6) + +=item 8. + +You use C<--user> option without C<$HOME> being properly set (ret=128) + +=back + +=end comment + +On success, the command returns the exit code 0. + +=head1 Options + +=over + +=item C<--replace-all> + +The default behavior is to replace at most one line. This replaces all lines +matching the key (and optionally the C). + +=item C<--add> + +Adds a new line to the option without altering any existing values. This is +the same as providing C<^$> as the value_regex in C<--replace-all>. + +=item C<--get> + +Get the value for a given key (optionally filtered by a regex matching the +value). Returns error code 1 if the key was not found and error code 2 if +multiple values were found. + +=item C<--get-all> + +Like C<--get>, but does not fail if the number of values for the key is not +exactly one. + +=item C<--get-regexp> + +Like C<--get-all>, but interprets the name as a regular expression and writes +out the key names and value. + +=item C<--local> + +For writing options: write to the local F<./sqitch.conf> file. This is +the default if no file option is specified. + +For reading options: read only from the local F<./sqitch.conf> file rather +than from all available files. + +See also L. + +=item C<--user> + +For writing options: write to the user F<~/.sqitch/sqitch.conf> file rather +than the repository F<./sqitch.conf>. + +For reading options: read only from the user F<~/.sqitch/sqitch.conf> file +rather than from all available files. + +See also L. + +=item C<--global> + +An alias for C<--user> for the benefit of the muscle memory of Git users. + +=item C<--system> + +For writing options: write to system-wide F<$(prefix)/etc/sqitch/sqitch.conf> +file rather than the repository F<./sqitch.conf>. + +For reading options: read only from system-wide +F<$(prefix)/etc/sqitch/sqitch.conf> file rather than from all available files. + +Call C to find out exactly where the system configuration +file lives (e.g., C<$(sqitch --etc-path)/sqitch.conf>). + +See also L. + +=item C<-f config-file, --file config-file> + +Use the given config file instead of the one specified by C<$SQITCH_CONFIG>. + +=item C<--remove-section> + +Remove the given section from the configuration file. + +=item C<--rename-section> + +Rename the given section to a new name. + +=item C<--unset> + +Remove the line matching the key from config file. + +=item C<--unset-all> + +Remove all lines matching the key from config file. + +=item C<-l, --list> + +List all variables set in config file. + +=item C<--bool> + +C will ensure that the output is "true" or "false". + +=item C<--int> + +C will ensure that the output is a simple integer. + +=item C<--num> + +C will ensure that the output is a simple decimal number. + +=item C<--bool-or-int> + +C will ensure that the output matches the format of either +C<--bool> or C<--int>, as described above. + +=item C<-e, --edit> + +Opens an editor to modify the specified config file; either C<--local>, +C<--user>, C<--system>, or C<--file>. If none of those options is specified, +the local file will be opened. + +=back + +=head1 Files + +If not set explicitly with C<--file>, there are three files in which +C will search for configuration options: + +=over + +=item F<./sqitch.conf> + +Local, project-specific configuration file. + +=item F<~/.sqitch/sqitch.conf> + +User-specific configuration file. + +=item F<$(prefix)/etc/sqitch/sqitch.conf> + +System-wide configuration file. + +=back + +=head1 Environment + +=over + +=item C + +Take the local configuration from the given file instead of F<./sqitch.conf>. + +=item C + +Take the user configuration from the given file instead of +F<~/.sqitch/sqitch.conf>. + +=item C + +Take the system configuration from the given file instead of +F<$($etc_prefix)/sqitch.conf>. + +=back + +=head1 Examples + +Given a F<./sqitch.conf> like this: + + # + # This is the config file, and + # a '#' or ';' character indicates + # a comment + # + + ; core variables + [core] + ; Use PostgreSQL + engine = pg + + ; Bundle command settings. + [bundle] + from = gamma + tags_only = false + dest_dir = _build/sql + + ; Fuzzle command settings + [core "fuzzle"] + clack = foo + clack = bar + clack = barzlewidth + +You can set the C setting to true with + + % sqitch config bundle.tags_only true + +The hypothetical C key in the C section might need to set +C to "hi" instead of "foo". You can make the replacement by passing an +additional argument to match the old value, which will be evaluated as a +regular expression. Here's one way to make that change: + + % sqitch config core.fuzzle.clack hi '^foo$' + +To delete the entry for C, do + + % sqitch config --unset bundle.from + +If you want to delete an entry for a multivalue setting (like +C), provide a regex matching the value of exactly one line. +This example deletes the "bar" value: + + % sqitch config --unset core.fuzzle.clack '^bar$' + +To query the value for a given key, do: + + % sqitch config --get core.engine + +Or: + + % sqitch config core.engine + +Or, to query a multivalue setting for only those values that match C: + + % sqitch config --get core.fuzzle.clack ba + +If you want to know all the values for a multivalue setting, do: + + % sqitch config --get-all core.fuzzle.clack + +If you like to live dangerously, you can replace all C with a +new one with + + % sqitch config --replace-all core.fuzzle.clack funk + +However, if you only want to replace lines that don't match C, prepend +the matching regular expression with an exclamation point (C), like so: + + % sqitch config --replace-all core.fuzzle.clack yow '!bar' + +To match only values with an exclamation mark, you have to escape it: + + % sqitch config section.key '[!]' + +To add a new setting without altering any of the existing ones, use: + + % sqitch config --add core.fuzzle.set widget=fred + +=head1 Configuration File + +The sqitch configuration file contains a number of variables that affect the +sqitch command's behavior. The F<./sqitch.conf> file local to each project is +used to store the configuration for that project, and +F<$HOME/.sqitch/sqitch.conf> is used to store a per-user configuration as +fallback values for the F<./sqitch.conf> file. The file +F<$($etc_prefix)/sqitch.conf> can be used to store a system-wide default +configuration. + +The variables are divided into sections, wherein the fully qualified variable +name of the variable itself is the last dot-separated segment and the section +name is everything before the last dot. The variable names are +case-insensitive, allow only alphanumeric characters and -, and must start +with an alphabetic character. Some variables may appear multiple times. + +=head2 Syntax + +The syntax is fairly flexible and permissive; white space is mostly ignored. +The C<#> and C<;> characters begin comments to the end of line, blank lines +are ignored. + +The file consists of sections and variables. A section begins with the name of +the section in square brackets and continues until the next section begins. +Section names are not case sensitive. Only alphanumeric characters, C<-> and +C<.> are allowed in section names. Each variable must belong to some section, +which means that there must be a section header before the first setting of a +variable. + +Sections can be further divided into subsections. To begin a subsection put +its name in double quotes, separated by space from the section name, in the +section header, like in the example below: + + [section "subsection"] + +Subsection names are case sensitive and can contain any characters except +newline (double quote and backslash have to be escaped as C<\"> and C<\\>, +respectively). Section headers cannot span multiple lines. Variables may +belong directly to a section or to a given subsection. You can have +C<[section]> if you have C<[section "subsection"]>, but you don't need to. + +All the other lines (and the remainder of the line after the section header) +are recognized as setting variables, in the form C. If there is +no equal sign on the line, the entire line is taken as name and the variable +is recognized as boolean C. The variable names are case-insensitive, +allow only alphanumeric characters and C<->, and must start with an alphabetic +character. There can be more than one value for a given variable; we say then +that the variable is multivalued. + +Leading and trailing whitespace in a variable value is discarded. Internal +whitespace within a variable value is retained verbatim. + +The values following the equals sign in variable assignments are either +strings, integers, numbers, or booleans. Boolean values may be given as +yes/no, 1/0, true/false or on/off. Case is not significant in boolean values, +when converting value to the canonical form using the C<--bool> type +specifier; C will ensure that the output is "true" or "false". + +String values may be entirely or partially enclosed in double quotes. You need +to enclose variable values in double quotes if you want to preserve leading or +trailing whitespace, or if the variable value contains comment characters +(i.e. it contains C<#> or C<;>). Double quote and backslash characters in +variable values must be escaped: use C<\"> for C<"> and C<\\> for C<\>. + +The following escape sequences (beside C<\"> and C<\\>) are recognized: C<\n> +for newline character (NL), C<\t> for horizontal tabulation (HT, TAB) and +C<\b> for backspace (BS). No other character escape sequence or octal +character sequence is valid. + +Variable values ending in a C<\> are continued on the next line in the +customary UNIX fashion. + +Some variables may require a special value format. + +=head2 Example + + # Core variables + [core] + engine = pg + top_dir = migrations + extension = ddl + + [engine "pg"] + registry = widgetopolis + + [revert] + to = gamma + + [bundle] + from = gamma + tags_only = yes + dest_dir = _build/sql + +=head2 Variables + +Note that this list is not comprehensive and not necessarily complete. For +command-specific variables, you will find a more detailed description in the +appropriate manual page. + +=over + +=item C + +The plan file to use. Defaults to F<$top_dir/sqitch.plan>. + +=item C + +The database engine to use. Supported engines include: + +=over + +=item * C - L and L + +=item * C - L + +=item * C - L + +=item * C - L and L + +=item * C - L + +=item * C - L + +=item * C - L + +=item * C - L + +=back + +=item C + +Path to directory containing deploy, revert, and verify SQL scripts. It +should contain subdirectories named C, C, and (optionally) +C. These may be overridden by C, C, and +C. Defaults to C<.>. + +=item C + +Path to a directory containing SQL deployment scripts. Overrides the value +implied by C. + +=item C + +Path to a directory containing SQL reversion scripts. Overrides the value +implied by C. + +=item C + +Path to a directory containing SQL verify scripts. Overrides the value implied +by C. + +=item C + +The file name extension on deploy, revert, and verify SQL scripts. Defaults to +C. + +=item C + +An integer determining how verbose Sqitch should be. Defaults to 1. Set to 0 +to silence status messages and to 2 or three to increase verbosity. Error +message output will not be affected by this property. + +=item C + +The command to use as a pager program. This overrides the C +environment variable on UNIX like systems. Both can be overridden by setting +the C<$SQITCH_PAGER> environment variable. If none of these variables are +set, Sqitch makes a best-effort search among the commonly installed pager +programs like C and C. + +=item C + +The command to use as a editor program. This overrides the C +environment variable on UNIX like systems. Both can be overridden by setting +the C<$SQITCH_EDITOR> environment variable. If none of these variables are +set, Sqitch defaults to C on Windows and C elsewhere. + +=back + +=head3 C + +Configuration properties that identify the user. + +=over + +=item C + +Your full name, to be recorded in changes and tags added to the plan, +and to commits to the database. + +=item C + +Your email address, to be recorded in changes and tags added to the plan, and +to commits to the database. + +=back + +=head3 C + +Each supported engine offers a set of configuration variables, falling under +the key C where C<$engine> may be any value accepted for +C. + +=over + +=item C + +A database target, either the name of target managed by the +L|sqitch-target> command, or a database connection URI. If it's a +target name, then the associated C, C, and C values +will override any values specified for the values below. Targets are the +preferred way to configure engines on a per-database basis, and the one +specified here should be considered the default. + +=item C + +A database connection URI. + +=item C + +The name of the Sqitch registry schema or database. Sqitch will store its own +data in this schema. + +=item C + +Path to the engine command-line client. Defaults to the first instance found +in the path. + +=back + +Notes on engine-specific configuration: + +=over + +=item C + +For the PostgreSQL engine, the C value identifies the schema for +Sqitch to use for its own data. No other data should be stored there. Defaults +to C. + +=item C + +For the SQLite engine, if the C value looks like an absolute path, +then it will be the database file. Otherwise, it will be in the same directory +as the database specified by the C. Defaults to C. + +=item C + +For the MySQL engine, the C value identifies the database for Sqitch +to use for its own data. If you need to manage multiple databases on a single +server, and don't want them all to share the same registry, change this +property to a value specific for your database. Defaults to C. + +=item C + +For Oracle, C value identifies the schema for Sqitch to use for its +own data. No other data should be stored there. Uses the current schema by +default (usually the same name as the connection user). + +=item C + +For the Firebird engine, if the C value looks like an absolute path, +then it will be the database file. Otherwise, it will be in the same directory +as the database specified by the C. Defaults to C, +where C<$extension> is the same as that in the C, if any. + +=item C + +For the Vertica engine, the C value identifies the schema for Sqitch +to use for its own data. No other data should be stored there. Defaults to +C. + +=item C + +For the Exasol engine, the C value identifies the schema for Sqitch +to use for its own data. No other data should be stored there. Defaults to +C. + +=item C + +For the Snowflake engine, the C value identifies the schema for +Sqitch to use for its own data. No other data should be stored there. Defaults +to C. + +=back + +=head3 C + +Configuration properties for the version control system. Currently, only Git +is supported. + +=over + +=item C + +Path to the C command-line client. Defaults to the first instance of +F found in the path. + +=back + +=head3 C + +=over + +=item C + +Your email address to be recorded in any newly planned changes. + +=item C + +Your full name to be recorded in any newly planned changes. + +=back + +=head1 Sqitch + +Part of the L suite. diff --git a/lib/sqitch-configuration.pod b/lib/sqitch-configuration.pod new file mode 100644 index 00000000..10675cff --- /dev/null +++ b/lib/sqitch-configuration.pod @@ -0,0 +1,1041 @@ +=encoding UTF-8 + +=head1 Name + +sqitch-configuration - Hierarchical engine and target configuration + +=head1 Description + +The specification of database targets is core to Sqitch database change +management. A target consists of a +L, a plan file, +change script directories, a registry schema or database name, and the path to +a database engine command-line client. Sqitch determines the values for these +attributes via a hierarchical evaluation of the runtime configuration, +examining and selecting from these values: + +=over + +=item 1. + +Command-line options + +=item 2. + +Target-specific configuration + +=item 3. + +Engine-specific configuration + +=item 4. + +Core configuration + +=item 5. + +A reasonable default + +=back + +This document explains how this evaluation works, and how to use the +L|sqitch-init>, L|sqitch-config>, +L|sqitch-engine>, and L|sqitch-target> commands to +configure these values for various deployment scenarios. + +=head1 Project Initialization + +Typically, the first thing you do with Sqitch is use the +L|sqitch-init> command to start a new project. Now, the most important +thing Sqitch needs to know is what database engine you'll be managing, so it's +best to use C<--engine> to configure the engine right up front to start off on +the right foot. Here, we start a project called "widgets" to manage PostgreSQL +databases: + + > sqitch init widgets --engine pg + Created sqitch.conf + Created sqitch.plan + Created deploy/ + Created revert/ + Created verify/ + +This creates a very simple configuration file with most of the settings +commented out, like so: + + > cat sqitch.conf + [core] + engine = pg + # plan_file = sqitch.plan + # top_dir = . + # [engine "pg"] + # target = db:pg: + # registry = sqitch + # client = psql + +The C<[core]> section contains default configurations, the most important of +which is the default engine, C. Of course, it's the I engine this +project supports, and the values of the other configuration variables are +reasonable for a single-engine project. If your Sqitch project never needs to +manage more than one database engine, this might be all you need: the current +directory is the top directory of the project, and it's here you'll find the +plan file as well as the deploy, revert, and verify script directories. Once +you start using the L|sqitch-add> command to add changes, and the +L|sqitch-deploy> command to deploy changes to a database, these +variables will be used extensively. + +The C<[engine "pg"]> section houses the variables specific to the engine. The +C defines the default L +for connecting to a PostgreSQL database. As you can see there isn't much here, +but if you were to distribute this project, it's likely that your users would +specify a target URI when deploying to their own databases. The C +determines where Sqitch will store its own metadata when managing a database; +generally the default, "sqitch", is fine. + +More interesting, perhaps, is the C setting, which defaults to the +appropriate engine-specific client name appropriate for your OS. In this +example, sqitch will assume it can find F in your path. + +=head1 Global Configuration + +But sometimes that's not the case. Let's say that the C client on your +system is not in the path, but instead in F. You +could set its location right here in the project configuration file, but that +won't do if you end up distributing the project to other users who might have +their client somewhere else. For that use case, the default path-specific +value is probably best. + +A better idea is to tell Sqitch where to find F for I of your +projects. Use the L|sqitch-config> command's C<--user> option to set +that configuration for yourself: + + > sqitch config --user engine.pg.client /usr/local/pgsql/bin/psql + +This won't change the project configuration file at all, but add the value to +F<~/.sqitch/sqitch.conf>, which is your personal cross-project Sqitch +configuration. In other words, it sets the PostgreSQL client for all Sqitch +projects you manage on this host. In fact, it can be a good idea to configure +clients not in the path first thing whenever you start working on a new host: + + > sqitch config --user user.name 'Marge N. O’Vera' + > sqitch config --user user.email 'marge@example.com' + > sqitch config --user engine.pg.client /usr/local/pgsql/bin/psql + > sqitch config --user engine.mysql.client /usr/local/mysql/bin/mysql + > sqitch config --user engine.sqlite.client /sbin/sqlite3 + +If you'd like to make the configuration global to all accounts on your host, +use the C<--system> option, instead: + + > sudo sqitch config --system engine.pg.client /usr/local/pgsql/bin/psql + > sudo sqitch config --system engine.mysql.client /usr/local/mysql/bin/mysql + > sudo sqitch config --system engine.sqlite.client /sbin/sqlite3 + +That will put the values into the global Sqitch configuration file, which is +in C<`sqitch --etc-path`/sqitch.conf>. + +=head1 Engine Configuration + +So you've got the widgets project well developed, and now you've been asked to +port it to SQLite. Fundamentally, that means porting all of your deploy, +revert, and verify scripts. The simplest way to organize files for this +configuration is with top-level directories for each engine. First, let's move +the existing PostgreSQL stuff to a subdirectory. + + > mkdir pg + > mv deploy revert verify sqitch.plan pg + > ls pg + deploy/ revert/ sqitch.plan verify/ + +Now we need to tell Sqitch where things are. To create an engine-specific +configuration, use the L|sqitch-engine> command's C action: + + sqitch engine add pg --top-dir pg + +The C action adds the C engine to the configuration, setting the top +directory to our newly-created C directory. The configuration looks like +this (with comments removed for clarity): + + [core] + engine = pg + [engine "pg"] + target = db:pg: + top_dir = pg + +Curious about all the other settings for the engine? Let C +show you: + + > sqitch engine show pg + * pg + Target: db:pg: + Registry: sqitch + Client: psql + Top Directory: pg + Plan File: pg/sqitch.plan + Extension: sql + Script Directories: + Deploy: pg/deploy + Revert: pg/revert + Verify: pg/verify + Reworked Script Directories: + Reworked: pg + Deploy: pg/deploy + Revert: pg/revert + Verify: pg/verify + No Variables + +The C action nicely presents the result of the fully-evaluated +configuration, even though only the top directory and client have been set. +Nice, right? + +Now, to add the SQLite support. There are two basic ways to go about it. We'll +start with the more obvious one. + +=head2 Separate Plans + +The first approach is to create an entirely independent SQLite project with +its own plan and scripts. This is I like starting from scratch: just +create a new directory and add the Sqitch engine using it for its top +directory: add initialize it as a new Sqitch project: + + > sqitch engine add sqlite --top-dir sqlite + Created sqlite/ + Created sqlite/sqitch.plan + Created sqlite/deploy/ + Created sqlite/revert/ + Created sqlite/verify/ + +Note the creation of a new F file. It will have copied the +project name and URI from the existing plan file. The SQLite configuration is +now added to the configuration file: + + > sqitch engine show sqlite + * sqlite + Target: db:sqlite: + Registry: sqitch + Client: sqlite3 + Top Directory: sqlite + Plan File: sqlite/sqitch.plan + Extension: sql + Script Directories: + Deploy: sqlite/deploy + Revert: sqlite/revert + Verify: sqlite/verify + Reworked Script Directories: + Reworked: sqlite + Deploy: sqlite/deploy + Revert: sqlite/revert + Verify: sqlite/verify + No Variables + +Good, everything's in the right place. Start adding changes to the SQLite plan +by passing the engine name to the C command: + + > sqitch add users sqlite -m 'Creates users table.' + Created sqlite/deploy/users.sql + Created sqlite/revert/users.sql + Created sqlite/verify/users.sql + Added "users" to sqlite/sqitch.plan + +Pass C when adding PostgreSQL changes, or omit it, in which case Sqitch +will fall back on the default engine, defined by the C variable +set when we created the PostgreSQL project. Want to add a change with the same +name to both engines? Simply pass them both, or use the C<--all> option: + + > sqitch add users --all -m 'Creates users table.' + Created pg/deploy/users.sql + Created pg/revert/users.sql + Created pg/test/users.sql + Created pg/verify/users.sql + Added "users" to pg/sqitch.plan + Created sqlite/deploy/users.sql + Created sqlite/revert/users.sql + Created sqlite/test/users.sql + Created sqlite/verify/users.sql + Added "users" to sqlite/sqitch.plan + +=head2 Shared Plan + +The other approach is to have both the PostgreSQL and the SQLite projects +share the same plan. In that case, we should move the plan file out of the +PostgreSQL directory: + + > mv pg/sqitch.plan . + > sqitch engine alter pg --plan-file sqitch.plan + > sqitch engine show pg + * pg + Target: db:pg: + Registry: sqitch + Client: psql + Top Directory: pg + Plan File: sqitch.plan + Extension: sql + Script Directories: + Deploy: pg/deploy + Revert: pg/revert + Verify: pg/verify + Reworked Script Directories: + Reworked: pg + Deploy: pg/deploy + Revert: pg/revert + Verify: pg/verify + No Variables + +Good, it's now using F<./sqitch.plan>. Now let's start the SQLite project. +Since we're going to use the same plan, we'll need to port all the scripts +from PostgreSQL. Let's just copy them, and then configure the SQLite engine to +use the shared plan file: + + > cp -rf pg sqlite + > sqitch engine add sqlite --plan-file sqitch.plan --top-dir sqlite + > sqitch engine show sqlite + * sqlite + Target: db:sqlite: + Registry: sqitch + Client: sqlite3 + Top Directory: sqlite + Plan File: sqitch.plan + Extension: sql + Script Directories: + Deploy: sqlite/deploy + Revert: sqlite/revert + Verify: sqlite/verify + Reworked Script Directories: + Reworked: sqlite + Deploy: sqlite/deploy + Revert: sqlite/revert + Verify: sqlite/verify + No Variables + +Looks good! Now port all the scripts in the F directory from +PostgreSQL to SQLite and you're ready to go. + +Later, when you want to add a new change to both projects, just pass the +C<--all> option to the C command: + + > sqitch add users --all -n 'Creates users table.' + Created pg/deploy/users.sql + Created pg/revert/users.sql + Created pg/verify/users.sql + Created sqlite/deploy/users.sql + Created sqlite/revert/users.sql + Created sqlite/verify/users.sql + Added "users" to sqitch.plan + +This option also works for the C, C, and C commands. If +you know you always want to act on all plans, set the C configuration +variable for each command: + + sqitch config --bool add.all 1 + sqitch config --bool tag.all 1 + sqitch config --bool rework.all 1 + sqitch config --bool bundle.all 1 + +=head2 Database Interactions + +With either of these two approaches, you can manage database interactions by +passing an engine name or a L +to the database commands. For example, to deploy to a PostgreSQL database to +the default PostgreSQL database: + + sqitch deploy pg + +You usually won't want to use the default database in production, though. +Here's how to deploy to a PostgreSQL database named "widgets" on host +C: + + sqitch deploy db:pg://db.example.com/widgets + +Sqitch is smart enough to pick out the proper engine from the URI. If you pass +a C URI, rest assured that Sqitch won't try to deploy the SQLite +changes. Use a C URI to interact with an SQLite database: + + sqitch log db:sqlite:/var/db/widgets.db + +The commands that take engine and target URI arguments include: + +=over + +=item * L|sqitch-status> + +=item * L|sqitch-log> + +=item * L|sqitch-deploy> + +=item * L|sqitch-revert> + +=item * L|sqitch-rebase> + +=item * L|sqitch-checkout> + +=item * L|sqitch-verify> + +=item * L|sqitch-upgrade> + +=back + +=head1 Target Configuration + +Great, now we can easily manage changes for multiple database engines. But +what about multiple databases for the same engine? For example, you might want +to deploy your database to two hosts in a primary/standby configuration. To +make things as simple as possible for your IT organization, set up named +targets for those servers: + + > sqitch target add prod-primary db:pg://sqitch@db1.example.com/widgets + > sqitch target add prod-standby db:pg://sqitch@db2.example.com/widgets + +Targets inherit configuration from engines, based on the engine specified in +the URI. Thus the configuration all comes together: + + > sqitch target show prod-primary prod-standby + * prod-primary + URI: db:pg://sqitch@db1.example.com/widgets + Registry: sqitch + Client: psql + Top Directory: pg + Plan File: sqitch.plan + Extension: sql + Script Directories: + Deploy: pg/deploy + Revert: pg/revert + Verify: pg/verify + Reworked Script Directories: + Reworked: pg + Deploy: pg/deploy + Revert: pg/revert + Verify: pg/verify + No Variables + * prod-standby + URI: db:pg://sqitch@db2.example.com/widgets + Registry: sqitch + Client: psql + Top Directory: pg + Plan File: sqitch.plan + Extension: sql + Script Directories: + Deploy: pg/deploy + Revert: pg/revert + Verify: pg/verify + Reworked Script Directories: + Reworked: pg + Deploy: pg/deploy + Revert: pg/revert + Verify: pg/verify + No Variables + +Note the use of the shared plan and the F directory for scripts. We can +add a target for our SQLite database, too. Maybe it's used for development? + + > sqitch target add dev-sqlite db:sqlite:/var/db/widgets_dev.db + > sqitch target show dev-sqlite + * dev-sqlite + URI: db:sqlite:/var/db/widgets_dev.db + Registry: sqitch + Client: sqlite3 + Top Directory: sqlite + Plan File: sqitch.plan + Extension: sql + Script Directories: + Deploy: sqlite/deploy + Revert: sqlite/revert + Verify: sqlite/verify + Reworked Script Directories: + Reworked: sqlite + Deploy: sqlite/deploy + Revert: sqlite/revert + Verify: sqlite/verify + No Variables + +Now deploying any of these databases is as simple as specifying the target +name when executing the L|sqitch-deploy> command (assuming the +C user is configured to authenticate to PostgreSQL without prompting +for a password): + + > sqitch deploy prod-primary + > sqitch deploy prod-standby + +Want them all? Just query the targets and pass each in turn: + + for target in `sqitch target | grep prod-`; do + sqitch deploy $target + done + +The commands that accept a target name are identical to those that take +an engine name or target URI, as described in L. + +=head3 Different Target, Different Plan + +What about a project that manages different -- but related -- schemas on the +same engine? For example, say you have two plans for PostgreSQL, one for a +canonical data store, and one for a read-only copy that will have a subset of +data replicated to it. Maybe your billing database just needs an up-to-date +copy of the C and C tables. + +Targets can help us here, too. Just create the new plan file. It might use +some of the same change scripts as the canonical plan, or its own scripts, or +some of each. Just be sure all of its scripts are in the same top directory. +Then add targets for the specific servers and plans: + + > sqitch target add prod-primary db:pg://db1.example.com/widgets + > sqitch target add prod-billing db:pg://cpa.example.com/billing --plan-file target.plan + > sqitch target show prod-billing + * prod-billing + URI: db:pg://cpa.example.com/billing + Registry: sqitch + Client: psql + Top Directory: pg + Plan File: target.plan + Extension: sql + Script Directories: + Deploy: pg/deploy + Revert: pg/revert + Verify: pg/verify + Reworked Script Directories: + Reworked: pg + Deploy: pg/deploy + Revert: pg/revert + Verify: pg/verify + No Variables + +Now, any management of the C target will use the F +plan file. Want to add changes to that plan? specify the plan file. Here's +an example that re-uses the existing change scripts: + + > sqitch add users target.plan -n 'Creates users table.' + Skipped pg/deploy/users.sql: already exists + Skipped pg/revert/users.sql: already exists + Skipped pg/test/users.sql: already exists + Skipped pg/verify/users.sql: already exists + Added "users" to target.plan + +=head1 Overworked + +Say you've been working on your project for some time, and now you have a slew +of changes you've L. (You really only do that with +procedures and views, right? Because it's silly to use for C +statements; just add new changes in those cases.) As a result, your deploy, +revert, and verify directories are full of files representing older versions +of the changes, all containing the C<@> symbol, and they're starting to get in +the way (in general you'll never modify them). Here's an example adapted from +a real project: + + > find pg -name '*@*' + pg/deploy/extensions@v2.9.0.sql + pg/deploy/jobs/func_enabler@v2.6.1.sql + pg/deploy/stem/func_check_all_widgets@v2.11.0.sql + pg/deploy/stem/func_check_all_widgets@v2.12.2.sql + pg/deploy/stem/func_check_all_widgets@v2.12.3.sql + pg/deploy/crank/func_update_jobs@v2.12.0.sql + pg/deploy/crank/func_update_jobs@v2.8.0.sql + pg/deploy/utility/func_get_sleepercell@v2.9.0.sql + pg/deploy/utility/func_update_connection@v2.10.0.sql + pg/deploy/utility/func_update_connection@v2.10.1.sql + pg/deploy/utility/func_update_connection@v2.11.0.sql + pg/revert/extensions@v2.9.0.sql + pg/revert/jobs/func_enabler@v2.6.1.sql + pg/revert/stem/func_check_all_widgets@v2.11.0.sql + pg/revert/stem/func_check_all_widgets@v2.12.2.sql + pg/revert/stem/func_check_all_widgets@v2.12.3.sql + pg/revert/crank/func_update_jobs@v2.12.0.sql + pg/revert/crank/func_update_jobs@v2.8.0.sql + pg/revert/utility/func_get_sleepercell@v2.9.0.sql + pg/revert/utility/func_update_connection@v2.10.0.sql + pg/revert/utility/func_update_connection@v2.10.1.sql + pg/revert/utility/func_update_connection@v2.11.0.sql + pg/verify/extensions@v2.9.0.sql + pg/verify/jobs/func_enabler@v2.6.1.sql + pg/verify/stem/func_check_all_widgets@v2.11.0.sql + pg/verify/stem/func_check_all_widgets@v2.12.2.sql + pg/verify/stem/func_check_all_widgets@v2.12.3.sql + pg/verify/crank/func_update_jobs@v2.12.0.sql + pg/verify/crank/func_update_jobs@v2.8.0.sql + pg/verify/utility/func_get_sleepercell@v2.9.0.sql + pg/verify/utility/func_update_connection@v2.10.0.sql + pg/verify/utility/func_update_connection@v2.10.1.sql + pg/verify/utility/func_update_connection@v2.11.0.sql + +Ugh. Wouldn't it be nice to move them out of the way? Of course it would! So +let's do that. We want all of the PostgreSQL engine's reworked scripts all to +go into to a new directory named "reworked", so tell Sqitch where to find +them: + + > sqitch engine alter pg --dir reworked=pg/reworked + Created pg/reworked/deploy/ + Created pg/reworked/revert/ + Created pg/reworked/verify/ + +Great, it created the new directories. Note that if you wanted the directories +to have different names or locations, you can use the C, +C, and C options. + +Now all we have to do is move the files: + + cd pg + for file in `find . -name '*@*'` + do + mkdir -p reworked/`dirname $file` + mv $file reworked/`dirname $file` + done + cd .. + +Now all the reworked deploy files are in F, the reworked +revert files are in F, and the reworked verify files are +in F. And you're good to go! From here on in Sqitch always +knows to find the reworked scripts when doing a L, +L, or L. And meanwhile, they're +tucked out of the way, less likely to break your brain or your IDE. + +=head1 Other Options + +You can see by the output of the L|sqitch-init>, +L|sqitch-engine>, and L|sqitch-target> commands that there +are quite a few other properties that can be set on a per-engine or per-target +database. To determine the value of each, Sqitch looks at a combination of +command-line options and configuration variables. Here's a complete list, +including specification of their values and how to set them. + +=over + +=item C + +The target database. May be a L or +a named target managed by the L|sqitch-target> commands. On each run, +its value will be determined by examining each of the following in turn: + +=over + +=item Command target argument or option + + sqitch deploy $target + sqitch revert --target $target + +=item C<$SQITCH_TARGET> environment variable + + env SQITCH_TARGET=$target sqitch deploy + env SQITCH_TARGET=$target sqitch revert + +=item C + + sqitch init $project --engine $engine --target $target + sqitch engine add $engine --target $target + sqitch engine alter $engine --target target + +=item C + + sqitch config core.target $target + +=back + +=item C + +The L to which to connect. May +only be specified as a target argument or via a named target: + +=over + +=item Command target argument or option + + sqitch deploy $uri + sqitch revert --target $uri + +=item C<$SQITCH_TARGET> environment variable + + env SQITCH_TARGET=$uri sqitch deploy + env SQITCH_TARGET=$uri sqitch revert + +=item C + + sqitch init $project --engine $engine --target $uri + sqitch target add $target --uri $uri + sqitch target alter $target --uri $uri + +=back + +=item C + +The path to the engine client. The default is engine- and OS-specific, which +will generally work for clients in the path. If you need a custom client, you +can specify it via the following: + +=over + +=item C<--client> + + sqitch deploy --client $client + +=item C + + sqitch target add $target --client $client + sqitch target alter $target --client $client + sqitch config --user target.$target.client $client + +=item C + + sqitch init $project --engine $engine --client client + sqitch engine add $engine --client $client + sqitch engine alter $engine --client $client + sqitch config --user engine.$engine.client $client + +=item C + + sqitch config core.client $client + sqitch config --user core.client $client + +=back + +=item C + +The name of the Sqitch registry schema or database. The default is C, +which should work for most uses. If you need a custom registry, specify it via +the following: + +=over + +=item C<--registry> + + sqitch deploy --registry $registry + +=item C + + sqitch target add $target --registry $registry + sqitch target alter $target --registry $registry + +=item C + + sqitch init $project --engine $engine --registry $registry + sqitch engine add $engine --registry $registry + sqitch engine alter $engine --registry $registry + +=item C + + sqitch config core.registry $registry + +=back + +=item C + +The directory in which project files an subdirectories can be found, including +the plan file and script directories. The default is the current directory. If +you need a custom directory, specify it via the following: + +=over + +=item C + + sqitch target add $target --top-dir $top_dir + sqitch target alter $target --top-dir $top_dir + +=item C + + sqitch engine add $engine --top-dir $top_dir + sqitch engine alter $engine --top-dir $top_dir + +=item C + + sqitch init $project --top-dir $top_dir + sqitch config core.top_dir $top_dir + +=back + +=item C + +The project deployment plan file, which defaults to F>. +If you need a different file, specify it via the following: + +=over + +=item C<--plan-file> + +=item C<-f> + + sqitch $command --plan-file $plan_file + +=item C + + sqitch target add $target --plan-file $plan_file + sqitch target alter $target --plan-file $plan_file + +=item C + + sqitch engine add $engine --plan-file $plan_file + sqitch engine alter $engine --plan-file $plan_file + +=item C + + sqitch init $project --plan-file $plan_file + sqitch config core.plan_file $plan_file + +=back + +=item C + +The file name extension to append to change names for change script file +names. Defaults to C. If you need a custom extension, specify it via the +following: + +=over + +=item C + + sqitch target add $target --extension $extension + sqitch target alter $target --extension $extension + +=item C + + sqitch engine add $engine --extension $extension + sqitch engine alter $engine --extension $extension + +=item C + + sqitch init $project --extension $extension + sqitch config core.extension $extension + +=back + +=item C + +Database client variables. Useful if your database engine +supports variables in scripts, such as PostgreSQL's +L variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>, +Vertica's +L variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm> +MySQL's +L, +SQL*Plus's +L variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>, +and Snowflake's +L. +To set variables, specify them via the following: + +=over + +=item Command variable option + + sqitch deploy --set $key=$val -s $key2=$val2 + sqitch revert --set $key=$val -s $key2=$val2 + sqitch verify --set $key=$val -s $key2=$val2 + sqitch rework --set $key=$val -s $key2=$val2 + sqitch rework --set-deploy $key=$val --set-revert $key=$val + sqitch checkout --set $key=$val -s $key2=$val2 + sqitch checkout --set-deploy $key=$val --set-revert $key=$val + +=item C + + sqitch target add $target --set $key=$val -s $key2=$val2 + sqitch target alter $target --set $key=$val -s $key2=$val2 + +=item C + + sqitch engine add $engine --set $key=$val -s $key2=$val2 + sqitch engine alter $engine --set $key=$val -s $key2=$val2 + +=item C<$command.variables> + + sqitch config deploy.variables.$key $val + sqitch config revert.variables.$key $val + sqitch config verify.variables.$key $val + +=item C + + sqitch init $project --set $key=$val -s $key2=$val2 + sqitch config core.variables.$key $val + sqitch config core.variables.$key2 $val2 + +=back + +=item C + +The directory in which project deploy scripts can be found. Defaults to +F>. If you need a different directory, specify it via the +following: + +=over + +=item C + + sqitch target add $target --dir deploy=$deploy_dir + sqitch target alter $target --dir deploy=$deploy_dir + +=item C + + sqitch engine add $engine --dir deploy=$deploy_dir + sqitch engine alter --dir deploy=$deploy_dir + +=item C + + sqitch init $project --dir deploy=$deploy_dir + sqitch config core.deploy_dir $deploy_dir + +=back + +=item C + +=item F> + + +The directory in which project revert scripts can be found. Defaults to +F>. If you need a different directory, specify it via the +following: + +=over + +=item C + + sqitch target add $target --dir revert=$revert_dir + sqitch target alter $target --dir revert=$revert_dir + +=item C + + sqitch engine add $engine --dir revert=$revert_dir + sqitch engine alter --dir revert=$revert_dir + +=item C + + sqitch init $project --dir revert=$revert_dir + sqitch config core.revert_dir $revert_dir + +=back + +=item C + +The directory in which project verify scripts can be found. Defaults to +F>. If you need a different directory, specify it via the +following: + +=over + +=item C + + sqitch target add $target --dir verify=$verify_dir + sqitch target alter $target --dir verify=$verify_dir + +=item C + + sqitch engine add $engine --dir verify=$verify_dir + sqitch engine alter $engine --dir verify=$verify_dir + +=item C + + sqitch init $project --dir verify=$verify_dir + sqitch config core.verify_dir $verify_dir + +=back + +=item C + +The directory in which subdirectories for reworked scripts can be found. +Defaults to F>. If you need a different directory, specify it via +the following: + +=over + +=item C + + sqitch target add $target --dir reworked=$reworked_dir + sqitch target alter $target --dir reworked=$reworked_dir + +=item C + + sqitch engine add $engine --dir reworked=$reworked_dir + sqitch engine alter $engine --dir reworked=$reworked_dir + +=item C + + sqitch init $project --dir reworked=$reworked_dir + sqitch config core.reworked_dir $reworked_dir + +=back + +=item C + +The directory in which project deploy scripts can be found. Defaults to +F>. If you need a different directory, specify it via the +following: + +=over + +=item C + + sqitch target add $target --dir deploy=$reworked_deploy_dir + sqitch target alter $target --dir deploy=$reworked_deploy_dir + +=item C + + sqitch engine add $engine --dir deploy=$reworked_deploy_dir + sqitch engine alter --dir deploy=$reworked_deploy_dir + +=item C + + sqitch init $project --dir deploy=$reworked_deploy_dir + sqitch config core.reworked_deploy_dir $reworked_deploy_dir + +=back + +=item C + +The directory in which project revert scripts can be found. Defaults to +F>. If you need a different directory, specify it via the +following: + +=over + +=item C + + sqitch target add $target --dir revert=$reworked_revert_dir + sqitch target alter $target --dir revert=$reworked_revert_dir + +=item C + + sqitch engine add $engine --dir revert=$reworked_revert_dir + sqitch engine alter --dir revert=$reworked_revert_dir + +=item C + + sqitch init $project --dir revert=$reworked_revert_dir + sqitch config core.reworked_revert_dir $reworked_revert_dir + +=back + +=item C + +The directory in which project verify scripts can be found. Defaults to +F>. If you need a different directory, specify it via the +following: + +=over + +=item C + + sqitch target add $target --dir verify=$reworked_verify_dir + sqitch target alter $target --dir verify=$reworked_verify_dir + +=item C + + sqitch engine add $engine --dir verify=$reworked_verify_dir + sqitch engine alter $engine --dir verify=$reworked_verify_dir + +=item C + + sqitch init $project --dir verify=$reworked_verify_dir + sqitch config core.reworked_verify_dir $reworked_verify_dir + +=back + +=back + +=head1 See Also + +=over + +=item * L + +=item * L + +=item * L + +=item * L + +=back + +=head1 Sqitch + +Part of the L suite. diff --git a/lib/sqitch-deploy-usage.pod b/lib/sqitch-deploy-usage.pod new file mode 100644 index 00000000..50c11a38 --- /dev/null +++ b/lib/sqitch-deploy-usage.pod @@ -0,0 +1,24 @@ +=head1 Name + +sqitch-deploy-usage - Sqitch deploy usage statement + +=head1 Usage + + sqitch deploy [options] [] + +=head1 Options + + -t --target database to which to connect + --to-change deploy to change + --mode failure reversion mode + -s --set set a database client variable + --verify run verify scripts after each change + --no-verify do not run verify scripts + --log-only log changes without running them + --registry registry schema or database + --db-client path to the engine command-line client + -d --db-name database name + -u --db-user database user name + -h --db-host database server host name + -p --db-port database server port number + -f --plan-file path to a deployment plan file diff --git a/lib/sqitch-deploy.pod b/lib/sqitch-deploy.pod new file mode 100644 index 00000000..2051eb43 --- /dev/null +++ b/lib/sqitch-deploy.pod @@ -0,0 +1,223 @@ +=head1 Name + +sqitch-deploy - Deploy changes to a database + +=head1 Synopsis + + sqitch deploy [options] [] + sqitch deploy [options] [] + sqitch deploy [options] [] --to-change + +=head1 Description + +Deploy changes to the database. Changes will begin from the current deployment +state. They will run to the latest change, unless a change is specified, +either via C<--to> or with no option flag, in which case changes will be +deployed up-to and including that change. + +If the database it up-to-date or already deployed to the specified change, no +changes will be made. If the change appears earlier in the plan than the +currently-deployed state, an error will be returned, along with a suggestion +to instead use L. + +The C<< >> parameter specifies the database to which to connect, +and may also be specified as the C<--target> option. It can be target name, +a URI, an engine name, or plan file path. + +=head1 Options + +=over + +=item C<-t> + +=item C<--target> + +The target database to which to connect. This option can be either a URI or +the name of a target in the configuration. + +=item C<--to-change> + +=item C<--change> + +=item C<--to> + +Specify the deployment change. Defaults to the last point in the plan. See +L for the various ways in which changes can be specified. + +=item C<--mode> + +Specify the reversion mode to use in case of deploy or verify failure. +Possible values are: + +=over + +=item C + +In the event of failure, revert all deployed changes, back to the point at +which deployment started. This is the default. + +=item C + +In the event of failure, revert all deployed changes to the last +successfully-applied tag. If no tags were applied during this deployment, all +changes will be reverted to the point at which deployment began. + +=item C + +In the event of deploy failure, no changes will be reverted; for a verify +failure, only the failed change will be reverted. + +=back + +Note that Sqitch assumes that each change is atomic. Therefore, when a deploy +script fails, it assumes there is nothing to revert. If a verify script fails, +it will of course run the revert script for the failed change. + +=item C<--verify> + +Verify each change by running its verify script, if there is one. If a verify +test fails, the deploy will be considered to have failed and the appropriate +reversion will be carried out, depending on the value of C<--mode>. + +=item C<--no-verify> + +Don't verify each change. This is the default. + +=item C<-s> + +=item C<--set> + +Set a variable name and value for use by the database engine client, if it +supports variables. The format must be C, e.g., +C<--set defuser='Homer Simpson'>. Overrides any values loaded from +L. + +=item C<--log-only> + +Log the changes as if they were deployed, but without actually running the +deploy scripts. Useful for an existing database that is being converted to +Sqitch, and you need to log changes as deployed because they have been +deployed by other means in the past. + +=item C<--registry> + + sqitch deploy --registry registry + +The name of the Sqitch registry schema or database in which sqitch stores its +own data. + +=item C<--db-client> + +=item C<--client> + + sqitch deploy --client /usr/local/pgsql/bin/psql + +Path to the command-line client for the database engine. Defaults to a client +in the current path named appropriately for the database engine. + +=item C<-d> + +=item C<--db-name> + + sqitch deploy --db-name widgets + sqitch deploy -d bricolage + +Name of the database. In general, L and URIs are +preferred, but this option can be used to override the database name in a +target. + +=item C<-u> + +=item C<--db-user> + +=item C<--db-username> + + sqitch deploy --db-username root + sqitch deploy --db-user postgres + sqitch deploy -u Mom + +User name to use when connecting to the database. Does not apply to all +engines. In general, L and URIs are preferred, but this +option can be used to override the user name in a target. + +=item C<-h> + +=item C<--db-host> + + sqitch deploy --db-host db.example.com + sqitch deploy -h appdb.example.net + +Host name to use when connecting to the database. Does not apply to all +engines. In general, L and URIs are preferred, but this +option can be used to override the host name in a target. + +=item C<-p> + +=item C<--db-port> + + sqitch deploy --db-port 7654 + sqitch deploy -p 5431 + +Port number to connect to. Does not apply to all engines. In general, +L and URIs are preferred, but this option can be used +to override the port in a target. + +=item C<--plan-file> + +=item C<-f> + + sqitch deploy --plan-file my.plan + +Path to the deployment plan file. Overrides target, engine, and core +configuration values. Defaults to F<$top_dir/sqitch.plan>. + +=back + +=head1 Configuration Variables + +=over + +=item C + +Deploy mode. The supported values are the same as for the C<--mode> option. + +=item C + +Boolean indicating whether or not to verify each change. + +=item C<[deploy.variables]> + +A section defining database client variables. Useful if your database engine +supports variables in scripts, such as PostgreSQL's +L variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>, +Vertica's +L variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm> +MySQL's +L, +SQL*Plus's +L variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>, +and Snowflake's +L. + +May be overridden by C<--set> or target and engine configuration. Variables +are merged in the following priority order: + +=over + +=item C<--set> + +=item C + +=item C + +=item C + +=item C + +=back + +=back + +=head1 Sqitch + +Part of the L suite. diff --git a/lib/sqitch-engine-usage.pod b/lib/sqitch-engine-usage.pod new file mode 100644 index 00000000..78b7af11 --- /dev/null +++ b/lib/sqitch-engine-usage.pod @@ -0,0 +1,25 @@ +=head1 Name + +sqitch-engine-usage - Sqitch engine usage statement + +=head1 Usage + + sqitch engine + sqitch engine [-v | --verbose] + sqitch engine add [engine-options] + sqitch engine alter [engine-options] + sqitch engine remove + sqitch engine show + sqitch engine update-config + +=head1 Options + + -v, --verbose be verbose; must be placed before an action + --target database target + --registry registry schema or database + --client path to engine command-line client + -f --plan-file path to deployment plan file + --top-dir path to directory with plan and scripts + --extension change script file name extension + --dir = path to named directory + -s --set set a database client variable diff --git a/lib/sqitch-engine.pod b/lib/sqitch-engine.pod new file mode 100644 index 00000000..acda9e14 --- /dev/null +++ b/lib/sqitch-engine.pod @@ -0,0 +1,267 @@ +=head1 Name + +sqitch-engine - Manage database engine configuration + +=head1 Synopsis + + sqitch engine + sqitch engine [-v | --verbose] + sqitch engine add [engine-options] + sqitch engine alter [engine-options] + sqitch engine remove + sqitch engine show + sqitch engine update-config + +=head1 Description + +Manage the database engines you deploy to. The list of supported engines +includes: + +=over + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=back + +Each engine may have a number of properties: + +=over + +=item C + +The name or URI of the database target. Note that if the value is a URI, the +engine in the URI must match the engine being added or altered. The default is +C. See L for details on target configuration. + +=item C + +The name of the registry schema or database. The default is C. + +=item C + +The command-line client to use. If not specified, each engine looks in the OS +Path for an appropriate client. + +=item C + +The path to the top directory for the engine. This directory generally +contains the plan file and subdirectories for deploy, revert, and verify +scripts, as well as reworked instances of those scripts. The default is F<.>, +the current directory. + +=item C + +The plan file to use for this engine. The default is C<$top_dir/sqitch.plan>. + +=item C + +The path to the deploy directory for the engine. This directory contains all +of the deploy scripts referenced by changes in the C. The default +is C<$top_dir/deploy>. + +=item C + +The path to the revert directory for the engine. This directory contains all +of the revert scripts referenced by changes in the C. The default +is C<$top_dir/revert>. + +=item C + +The path to the verify directory for the engine. This directory contains all +of the verify scripts referenced by changes in the C. The default +is C<$top_dir/verify>. + +=item C + +The path to the reworked directory for the engine. This directory contains all +subdirectories for all reworked scripts referenced by changes in the +C. The default is C<$top_dir>. + +=item C + +The path to the reworked deploy directory for the engine. This directory +contains all of the reworked deploy scripts referenced by changes in the +C. The default is C<$reworked_dir/deploy>. + +=item C + +The path to the reworked revert directory for the engine. This directory +contains all of the reworked revert scripts referenced by changes in the +C. The default is C<$reworked_dir/revert>. + +=item C + +The path to the reworked verify directory for the engine. This directory +contains all of the reworked verify scripts referenced by changes in the +C. The default is C<$reworked_dir/verify>. + +=item C + +The file name extension to append to change names to create script file names. +The default is C. + +=back + +Each of these overrides the corresponding core configuration -- for example, +the C, C, C, and C +L options. + +=head1 Options + +=over + +=item List Option + +=over + +=item C<-v> + +=item C<--verbose> + + sqitch engine --verbose + +Be more verbose when listing engines. + +=back + +=item Add and Alter Options + +=over + +=item C<--top-dir> + + sqitch engine add pg --top-dir sql + +Specifies the top directory to use for the engine. Typically contains the +deployment plan file and the change script directories. + +=item C<--plan-file> + +=item C<-f> + + sqitch engine add pg --plan-file my.plan + +Specifies the path to the deployment plan file. Defaults to +C<$top_dir/sqitch.plan>. + +=item C<--extension> + + sqitch engine add pg --extension ddl + +Specifies the file name extension to use for change script file names. +Defaults to C. + +=item C<--dir> + + sqitch engine add pg --dir deploy=dep --dir revert=rev --dir verify=tst + +Sets the path to a script directory. May be specified multiple times. +Supported keys are: + +=over + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=back + +=item C<--target> + + sqitch engine add pg --target db:pg:widgets + +Specifies the name or L of the target +database for the engine. + +=item C<--registry> + + sqitch engine add pg --registry meta + +Specifies the name of the database object where Sqitch's state and history +data is stored. Typically a schema name (as in PostgreSQL and Oracle) or a +database name (as in SQLite and MySQL). Defaults to C. + +=item C<--client> + + sqitch engine add pg --client /usr/local/pgsql/bin/psql + +Specifies the path to the command-line client for the engine. Defaults to a +client in the current path named appropriately for the engine. + +=item C<-s> + +=item C<--set> + +Set a variable name and value for use by the database engine client, if it +supports variables. The format must be C, e.g., +C<--set defuser='Homer Simpson'>. + +=back + +=back + +=head1 Actions + +With no arguments, shows a list of existing engines. Several actions are +available to perform operations on the engines. + +=head2 C + +Add an engine named C<< >> for the database at C<< >>. The +C<--set> option specifies engine-specific properties. A new plan file and +new script script directories will be created if they don't already exist. + +=head2 C + +Alter an engine named C<< >>. The C<--set> option specifies +engine-specific properties to set. New script script directories will be +created if they don't already exist. + +=head2 C, C + +Remove the engine named C<< >> from the configuration. The plan file +and script directories will not be affected. + +=head2 C + +Gives some information about the engine C<< >>, including the +associated properties. Specify multiple engine names to see information for +each. + +=head2 C + +Update the configuration from a configuration file that predates the addition +of the C command to Sqitch. + +=head1 Configuration Variables + +The engines are stored in the configuration file, but the command itself +currently relies on no configuration variables. + +=head1 Sqitch + +Part of the L suite. diff --git a/lib/sqitch-environment.pod b/lib/sqitch-environment.pod new file mode 100644 index 00000000..58c14e83 --- /dev/null +++ b/lib/sqitch-environment.pod @@ -0,0 +1,343 @@ +=encoding UTF-8 + +=head1 Name + +sqitch-environment - Environment variables recognized by Sqitch + +=head1 Description + +Sqitch supports a number of environment variables that affect its +functionality. This document lists them all, along with brief descriptions of +their purposes and pointers to relevant documentation. + +=head2 Sqitch Environment + +=over + +=item C + +Path to the project configuration file. Overrides the default, which is +F<./sqitch.conf>. See L for details. + +=item C + +Path to the user's configuration file. Overrides the default, which is +F<./.sqitch/sqitch.conf>. See L for details. + +=item C + +Path to the system's configuration file. Overrides the default, which is a +file named C in the directory identified by C. See +L for details. + +=item C + +The name or URI of the database target to connect to. Overrides values stored +in the configuration, but not command-line options or arguments. + +=item C + +Username to use when connecting to a database, for those database engines that +support authentication. Overrides values stored in a target URI or the +configuration. See L for details. + +=item C + +Password to use when connecting to a database, for those database engines that +support authentication. Overrides values stored in a target URI or the +configuration. See L for details. + +=item C + +Full name of the current user. Used to identify the user adding a change to a +plan file or deploying a change. Supersedes the L +variable. + +=item C + +Email address of the current user. Used to identify the user adding a change to +a plan file or deploying a change. Supersedes the C L +variable. + +=item C + +Username from the original system. Intended for use by scripts that run Sqitch +from another host, where the originating host username should be passed to the +execution host, such as +L. + +=item C + +Full name of the original system user. Intended for use by scripts that run +Sqitch from another host, where the originating host user's identity should be +passed to the execution host, such as +L. +This value will be used only when neither the C<$SQITCH_FULLNAME> nor the +C L variable is set. + +=item C + +Email address of the original user. Intended for use by scripts that run +Sqitch on a separate host, where the originating host user's identity should +be passed to the execution host, such as +L. +This value will be used only when neither the C<$SQITCH_EMAIL> nor the +C L variable is set. + +=item C + +The editor that Sqitch will launch when the user needs to edit some text (a +change note, for example). If unset, the C configuration variable +will be used. If it's not set, C<$VISUAL> or C<$EDITOR> will be consulted (in +that order). Finally, if none of these are set, Sqitch will invoke +C on Windows and C elsewhere. + +=item C + +The pager program that Sqitch will use when a command (like C) +produces multi-page output. If unset, the C configuration +variable will be used. If this is also not set, the C environment +variable will be used. Finally, if none of these are set, Sqitch will attempt +to find and use one of the commonly used pager programs like C and +C. + +=back + +=head2 Engine Environments + +In addition to Sqitch's environment variables, some of the database engines +support environment variables of their own. These are not comprehensive for +all variables supported by a database engine, but document those supported by +Sqitch's implementation for each engine. + +=head3 PostgreSQL + +All the usual +L +should be implicitly used. However, the following variables are explicitly +recognized by Sqitch: + +=over + +=item C + +The username to use to connect to the server. Superseded by +C<$SQITCH_USERNAME> and the target URI username. + +=item C + +The password to use to connect to the server. Superseded by +C<$SQITCH_PASSWORD> and the target URI password. + +=item C + +The PostgreSQL server host to connect to. Superseded by the target URI host +name. + +=item C + +The PostgreSQL server port to connect to. Superseded by the target URI port. + +=item C + +The name of the database to connect to. Superseded by the target URI database +name. + +=back + +=head3 SQLite + +SQLite provides no environment variable support. + +=head3 MySQL + +Sqitch recognizes and takes advantage of the following +L: + +=over + +=item C + +The password to use to connect to the server. Superseded by +C<$SQITCH_PASSWORD> and the target URI password. + +=item C + +The MySQL server host to connect to. Superseded by the target URI host +name. + +=item C + +The MySQL server port to connect to. Superseded by the target URI port. + +=back + +=head3 Oracle + +Sqitch's Oracle engine supports a few environment variables: + +=over + +=item C + +Required to point to the Oracle home directory, and contain both the SQL*Plus +client and the shared libraries with which the Perl Oracle driver was +compiled. + +=item C + +The directory in which the Oracle networking interface will find its configuration +files, notably F. Defaults to C<$ORACLE HOME/network/admin> if not +set. + +=item C + +The name of the Oracle database to connect to. Superseded by the target URI. + +=item C + +The name of the Oracle database to connect to. Windows only. Superseded by the +target URI. + +=item C + +The System Identifier (SID) representing the Oracle database to connect to. +Superseded by the target URI, C and C on Windows. + +=back + +In addition, the Oracle engine in Sqitch explicitly overrides the C +and C environment variables. The former is set to +C to ensure that all database connections use the +UTF-8 encoding. The latter is set to an empty string, to prevent SQL*Plus +executing SQL scripts unexpectedly. + +=head3 Firebird + +The Sqitch Firebird engine supports the following environment variables: + +=over + +=item C + +The username to use to connect to Firebird. Superseded by +C<$SQITCH_USERNAME> and the target URI username. + +=item C + +The password to use to connect to Firebird. Superseded by C<$SQITCH_PASSWORD> +and the target URI password. + +=back + +=head3 Vertica + +Sqitch provides explicit support for the following +L: + +=over + +=item C + +The username to use to connect to the server. Superseded by +C<$SQITCH_USERNAME> and the target URI username. + +=item C + +The password to use to connect to the server. Superseded by +C<$SQITCH_PASSWORD> and the target URI password. + +=item C + +The PostgreSQL server host to connect to. Superseded by the target URI host +name. + +=item C + +The PostgreSQL server port to connect to. Superseded by the target URI port. + +=item C + +The name of the database to connect to. Superseded by the target URI database +name. + +=back + +=head3 Exasol + +The Sqitch Exasol engine supports no special environment variables. It does, +however, override THE C environment variable, to prevent EXAplus +executing SQL scripts unexpectedly. + +=head3 Snowflake + +Sqitch provides explicit support for the following +L: + +=over + +=item C + +The name assigned to the snowflake account. Superseded by the target URI host +name. + +=item C + +The username to use to connect to the server. Superseded by +C<$SQITCH_USERNAME> and the target URI username. + +=item C + +The password to use to connect to the server. Superseded by +C<$SQITCH_PASSWORD> and the target URI password. + +=item C + +The passphrase for the private key file when using key pair authentication. +See L for details. + +=item C + +The role to use when connecting to the server. Superseded by the target URI +database C query parameter. + +=item C + +The PostgreSQL server host to connect to. Superseded by the target URI host +name. + +=item C + +The PostgreSQL server port to connect to. Superseded by the target URI port. + +=item C + +The name of the database to connect to. Superseded by the target URI database +name. + +=item C + +The snowflake region. Superseded by the target URI host name. + +=item C + +The warehouse to use. Superseded by the target URI database C query +parameter. + +=back + +=head1 See Also + +=over + +=item * L + +=item * L + +=item * L + +=back + +=head1 Sqitch + +Part of the L suite. diff --git a/lib/sqitch-help-usage.pod b/lib/sqitch-help-usage.pod new file mode 100644 index 00000000..05f9b3df --- /dev/null +++ b/lib/sqitch-help-usage.pod @@ -0,0 +1,12 @@ +=head1 Name + +sqitch-help-usage - Sqitch help usage statement + +=head1 Usage + + sqitch help [--guide] [] [] + +=head1 Options + + -g --guide Print list of guides + diff --git a/lib/sqitch-help.pod b/lib/sqitch-help.pod new file mode 100644 index 00000000..20556399 --- /dev/null +++ b/lib/sqitch-help.pod @@ -0,0 +1,35 @@ +=head1 Name + +sqitch-help - Display help for Sqitch and Sqitch commands + +=head1 Synopsis + + sqitch help [COMMAND] + sqitch help --guide + +=head1 Description + +With no options and no C given, the synopsis of the C options +and a list of the most commonly used Sqitch commands will be displayed. + +If the option C<--guide> or C<-g> is given, then a list of Sqitch guides will +be displayed + +If a Sqitch command is named, the documentation for that command will be +displayed. + +=head1 Options + +=over + +=item C<-g> + +=item C<--guide> + +Prints a list of available Sqitch guides. + +=back + +=head1 Sqitch + +Part of the L suite. diff --git a/lib/sqitch-init-usage.pod b/lib/sqitch-init-usage.pod new file mode 100644 index 00000000..3d39fa4e --- /dev/null +++ b/lib/sqitch-init-usage.pod @@ -0,0 +1,21 @@ +=head1 Name + +sqitch-init-usage - Sqitch init usage statement + +=head1 Usage + + sqitch init + sqitch init --uri + +=head1 Options + + --uri associate a URI with the project plan + --engine database engine + --top-dir path to directory with plan and scripts + -f --plan-file path to deployment plan file + --target database target + --registry registry schema or database + --client path to engine command-line client + --extension change script file name extension + --dir = path to named directory + -s --set set a database client variable diff --git a/lib/sqitch-init.pod b/lib/sqitch-init.pod new file mode 100644 index 00000000..3863b488 --- /dev/null +++ b/lib/sqitch-init.pod @@ -0,0 +1,256 @@ +=head1 Name + +sqitch-init - Create a new Sqitch project + +=head1 Synopsis + + sqitch init + sqitch init --uri + +=head1 Description + +This command creates an new Sqitch project -- basically a F file, +a F file, and F, F, and F subdirectories. + +Running sqitch init in an existing project is safe. It will not overwrite +things that are already there. + +=head1 Options + +=over + +=item C<--uri> + + sqitch init widgets --uri https://github.com/me/wigets + +Optional URI to associate with the project. If present, the URI will be +written to the project plan and used for added uniqueness in hashed object +IDs, as well as to prevent the deployment of another project with the same +name but different URI. + +=item C<--engine> + + sqitch init widgets --engine pg + +Specifies the default database engine to use in the project. Supported engines +include: + +=over + +=item * C - L and L + +=item * C - L + +=item * C - L + +=item * C - L and L + +=item * C - L + +=item * C - L + +=item * C - L + +=item * C - L + +=back + +=item C<--top-dir> + + sqitch init widgets --top-dir sql + +Specifies the top directory to use for the project. Typically contains the +deployment plan file and the change script directories. + +=item C<--plan-file> + +=item C<-f> + + sqitch init widgets --plan-file my.plan + +Specifies the path to the deployment plan file. Defaults to +C<$top_dir/sqitch.plan>. + +=item C<--extension> + + sqitch init widgets --extension ddl + +Specifies the file name extension to use for change script file names. +Defaults to C. + +=item C<--dir> + + sqitch init widgets --dir deploy=dep --dir revert=rev --dir verify=tst + +Sets the path to a script directory. May be specified multiple times. +Supported keys are: + +=over + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=item * C + +=back + +=item C<--target> + + sqitch init widgets --target db:pg:widgets + +Specifies the name or L of the default +target database. If specified as a name, the default URI for the target will +be C. + +=item C<--registry> + + sqitch init widgets --registry meta + +Specifies the name of the database object where Sqitch's state and history +data is stored. Typically a schema name (as in PostgreSQL and Oracle) or a +database name (as in SQLite and MySQL). Defaults to C. + +=item C<--client> + + sqitch init widgets --client /usr/local/pgsql/bin/psql + +Specifies the path to the command-line client for the database engine. +Defaults to a client in the current path named appropriately for the specified +engine. + +=back + +=head1 Configuration + +The most important thing C does is create the project plan file, +F. The options determine what gets written to the file: + +=over + +=item C<--engine> + +Sets the C configuration variable. + +=item C<--top-dir> + +Sets the C configuration variable. + +=item C<--plan-file> + +=item C<-f> + +Sets the C configuration variable. + +=item C<--extension> + +Sets the C configuration variable. + +=item C<--dir> + +Sets the following configuration variables: + +=over + +=item * C sets C + +=item * C sets C + +=item * C sets C + +=item * C sets C + +=item * C sets C + +=item * C sets C + +=item * C sets C + +=back + +=item C<--target> + +Sets the C configuration variable if C<--engine> is +also passed and, if it's a target name, C + +=item C<--registry> + +Sets the C configuration variable if C<--engine> is also +passed. + +=item C<--client> + +Sets the C configuration variable if C<--engine> is +also passed. + +=item C<-s> + +=item C<--set> + +Set a variable name and value for use by the database engine client, if it +supports variables. The format must be C, e.g., +C<--set defuser='Homer Simpson'>. Variables are set in C. + +=back + +As a general rule, you likely won't need any of these options except for +C<--engine>, since many commands need to know what engine to use, and +specifying it on the command-line forever after would be annoying. + +These variables will only be written if their corresponding options are +specified. Otherwise, core options get written as comments with user or system +configuration settings, or, failing any values from those locations, from +their default values. If no defaults are specified, they will still be +written, commented out, with a bar C<=> and no value. This allows one to know +what sorts of things are available to edit. + +=head1 Examples + +Start a new Sqitch project named "quack" using the SQLite engine, setting the +top directory for the project to F: + + sqitch init --engine sqlite --top-dir sqlite quack + +Start a new Sqitch project named "bey" using the PostgreSQL engine, setting +the top directory to F, script extension to C, reworked +directory to C and a version-specific client: + + sqitch init --engine pg \ + --top-dir postgres \ + --client /opt/pgsql-9.1/bin/psql \ + --extension ddl --dir reworked=reworked \ + bey + +=head1 See Also + +=over + +=item L + +Describes how Sqitch hierarchical engine and target configuration works. + +=item L + +Command to manage database engine configuration. + +=item L + +Command to manage target database configuration. + +=item L + +Command to manage all Sqitch configuration. + +=back + +=head1 Sqitch + +Part of the L suite. diff --git a/lib/sqitch-log-usage.pod b/lib/sqitch-log-usage.pod new file mode 100644 index 00000000..e3651890 --- /dev/null +++ b/lib/sqitch-log-usage.pod @@ -0,0 +1,40 @@ +=head1 Name + +sqitch-log-usage - Sqitch log usage statement + +=head1 Usage + + sqitch log [options] [] + +=head1 Options + +Search options: + + -t --target database to which to connect + --event type of event + --change-pattern --change match regex against committer names + -n --max-count show only specified number of events + --skip skip the specified number of events + --reverse show events in reverse order + --no-reverse don't show events in reverse order + +Formatting: + + -f --format show events in the specified format + --date-format --date show dates in the specified format + --color use ANSI colors + --no-color never use ANSI colors + --abbrev abbreviate change IDs + --oneline shorthand for --format oneline --abbrev 6 + --headers show headers for the target + --no-headers don't show target headers + +Connection: + + --registry registry schema or database + --db-client path to the engine command-line client + -d --db-name database name + -u --db-user database user name + -h --db-host database server host name + -p --db-port database server port number diff --git a/lib/sqitch-log.pod b/lib/sqitch-log.pod new file mode 100644 index 00000000..b04fe382 --- /dev/null +++ b/lib/sqitch-log.pod @@ -0,0 +1,502 @@ +=head1 Name + +sqitch-log - Show Sqitch change deployment logs + +=head1 Synopsis + + sqitch log [options] [] + +=head1 Description + +Sqitch keeps a record of the deployment, failed deployment, or reversion of +all changes in a target database. Even after a change has been reverted, a log +of its earlier deployment is retained. The C command is your key to +accessing it. You can simply list all the events, search for events matching +regular expressions, and limit the results. + +The C<< >> parameter specifies the database to which to connect, +and may also be specified as the C<--target> option. It can be target name, +a URI, an engine name, or plan file path. + +=head1 Options + +=over + +=item C<-t> + +=item C<--target> + +The target database to which to connect. This option can be either a URI or +the name of a target in the configuration. + +=item C<--event> + +Filter by event type. May be specified more than once. Allowed values are: + +=over + +=item * C + +=item * C + +=item * C + +=back + +=item C<--change-pattern> + +=item C<--change> + +A regular expression to match against change names. + +=item C<--project-pattern> + +=item C<--project> + +A regular expression to match against project names. + +=item C<--committer-pattern> + +=item C<--committer> + +A regular expression to match against committer names. + +=item C<--format> + +=item C<-f> + +The format to use. May be one of: + +=over + +=item C + +=item C + +=item C + +=item C + +=item C + +=item C + +=item C<< format: >> + +=back + +See L for details on each format. Defaults to C. + +=item C<--date-format> + +=item C<--date> + +Format to use for timestamps. Defaults to C. Allowed values: + +=over + +=item C + +=item C + +Shows timestamps in ISO-8601 format. + +=item C + +=item C + +Show timestamps in RFC-2822 format. + +=item C + +=item C + +=item C + +=item C + +Show timestamps in the specified format length, using the system locale's +C category. + +=item C + +Show timestamps in raw format, which is strict ISO-8601 in the UTC time zone. + +=item C + +Show timestamps using an arbitrary C pattern. See +L for comprehensive documentation of supported +patterns. + +=item C + +Show timestamps using an arbitrary C pattern. See +L for comprehensive documentation of supported +patterns. + +=back + +=item C<--max-count> + +=item C<-n> + +Limit the number of events to output. + +=item C<--skip> + +Skip the specified number events before starting to show the event output. + +=item C<--reverse> + +Output the events in reverse order. + +=item C<--no-reverse> + +Do not output the events in reverse order. + +=item C<--headers> + +Output headers describing target. Enabled by default. + +=item C<--no-headers> + +Do not output headers describing target. + +=item C<--color> + +Show colored output. The value may be one of: + +=over + +=item C (the default) + +=item C + +=item C + +=back + +=item C<--no-color> + +Turn off colored output. It is the same as C<--color never>. + +=item C<--abbrev> + +Instead of showing the full 40-byte hexadecimal change ID, show only a partial +prefix the specified number of characters long. + +=item C<--oneline> + +Shorthand for C<--format oneline --abbrev 6>. + +=item C<--registry> + + sqitch log --registry registry + +The name of the Sqitch registry schema or database in which sqitch stores its +own data. + +=item C<--db-client> + +=item C<--client> + + sqitch log --client /usr/local/pgsql/bin/psql + +Path to the command-line client for the database engine. Defaults to a client +in the current path named appropriately for the database engine. + +=item C<-d> + +=item C<--db-name> + + sqitch log --db-name widgets + sqitch log -d bricolage + +Name of the database. In general, L and URIs are +preferred, but this option can be used to override the database name in a +target. + +=item C<-u> + +=item C<--db-user> + +=item C<--db-username> + + sqitch log --db-username root + sqitch log --db-user postgres + sqitch log -u Mom + +User name to use when connecting to the database. Does not apply to all +engines. In general, L and URIs are preferred, but this +option can be used to override the user name in a target. + +=item C<-h> + +=item C<--db-host> + + sqitch log --db-host db.example.com + sqitch log -h appdb.example.net + +Host name to use when connecting to the database. Does not apply to all +engines. In general, L and URIs are preferred, but this +option can be used to override the host name in a target. + +=item C<-p> + +=item C<--db-port> + + sqitch log --db-port 7654 + sqitch log -p 5431 + +Port number to connect to. Does not apply to all engines. In general, +L and URIs are preferred, but this option can be used +to override the port in a target. + +=back + +=head1 Configuration Variables + +=over + +=item C + +Output format to use. Supports the same values as C<--format>. + +=item C + +Format to use for timestamps. Supports the same values as the C<--date-format> +option. + +=item C + +Output colors. Supports the same values as the C<--color> option. + +=back + +=head1 Formats + +There are several built-in formats, and you can emit data in a custom format +C<< format: >> format. Here are the details of the built-in formats: + +=over + +=item C + + : + +This is designed to be as compact as possible. + +=item C<short> + + <event type> <change id> + Name: <change name> + Committer: <committer> + + <title line> + +=item C<medium> + + <event type> <change id> + Name: <change name> + Committer: <committer> + Date: <commit date> + + <full change note> + +=item C<long> + + <event type> <change id> <tags> + Name: <change name> + Project: <change name> + Planner: <planner> + Committer: <committer> + + <full change note> + +=item C<full> + + <event type> <change id> <tags> + Event: <event type> + Name: <change name> + Project: <change name> + Requires: <required changes> + Conflicts: <conflicting changes> + Planner: <planner> + Planned: <plan date> + Committer: <committer> + Committed: <commit date> + + <full change note> + +=item C<raw> + + <event type> <change id> <tags> + name <change name> + project <project name> + requires <required changes> + conflicts <conflicting changes> + planner <planner> + planned <raw plan date> + committer <committer> + committed <raw commit date> + + <full change note> + +Suitable for parsing: the change ID is displayed in full, without regard to +the value of C<--abbrev>; dates are formatted raw (strict ISO-8601 format in +the UTC time zone); and all labels are lowercased and unlocalized. + +=item C<< format:<string> >> + +The C<< format:<string> >> format allows you to specify which information you +want to show. It works a little bit like C<printf> format and a little like +Git log format. For example, this format: + + format:The committer of %h was %{name}c%vThe title was >>%s<<%v + +Would show something like this: + + The committer of f26a3s was Tom Lane + The title was >>We really need to get this right.<< + +The placeholders are: + +=over + +=item * C<%H>: Event change ID + +=item * C<%h>: Event change ID (respects C<--abbrev>) + +=item * C<%n>: Event change name + +=item * C<%o>: Event change project name + +=item * C<%($len)h>: abbreviated change of length C<$len> + +=item * C<%e>: Event type (deploy, revert, fail) + +=item * C<%l>: Localized lowercase event type label + +=item * C<%L>: Localized title case event type label + +=item * C<%c>: Event committer name and email address + +=item * C<%{name}c>: Event committer name + +=item * C<%{email}c>: Event committer email address + +=item * C<%{date}c>: commit date (respects C<--date-format>) + +=item * C<%{date:rfc}c>: commit date, RFC2822 format + +=item * C<%{date:iso}c>: commit date, ISO-8601 format + +=item * C<%{date:full}c>: commit date, full format + +=item * C<%{date:long}c>: commit date, long format + +=item * C<%{date:medium}c>: commit date, medium format + +=item * C<%{date:short}c>: commit date, short format + +=item * C<%{date:cldr:$pattern}c>: commit date, formatted with custom L<CLDR pattern|DateTime/CLDR Patterns> + +=item * C<%{date:strftime:$pattern}c>: commit date, formatted with custom L<strftime pattern|DateTime/strftime Patterns> + +=item * C<%c>: Change planner name and email address + +=item * C<%{name}p>: Change planner name + +=item * C<%{email}p>: Change planner email address + +=item * C<%{date}p>: plan date (respects C<--date-format>) + +=item * C<%{date:rfc}p>: plan date, RFC2822 format + +=item * C<%{date:iso}p>: plan date, ISO-8601 format + +=item * C<%{date:full}p>: plan date, full format + +=item * C<%{date:long}p>: plan date, long format + +=item * C<%{date:medium}p>: plan date, medium format + +=item * C<%{date:short}p>: plan date, short format + +=item * C<%{date:cldr:$pattern}p>: plan date, formatted with custom L<CLDR pattern|DateTime/CLDR Patterns> + +=item * C<%{date:strftime:$pattern}p>: plan date, formatted with custom L<strftime pattern|DateTime/strftime Patterns> + +=item * C<%t>: Comma-delimited list of tags + +=item * C<%{$sep}t>: list of tags delimited by C<$sep> + +=item * C<%T>: Parenthesized list of comma-delimited tags + +=item * C<%{$sep}T>: Parenthesized list of tags delimited by C<$sep> + +=item * C<%s>: Subject (a.k.a. title line) + +=item * C<%r>: Comma-delimited list of required changes + +=item * C<%{$sep}r>: list of required changes delimited by C<$sep> + +=item * C<%R>: Localized label and list of comma-delimited required changes + +=item * C<%{$sep}R>: Localized label and list of required changes delimited by C<$sep> + +=item * C<%x>: Comma-delimited list of conflicting changes + +=item * C<%{$sep}x>: list of conflicting changes delimited by C<$sep> + +=item * C<%X>: Localized label and list of comma-delimited conflicting changes + +=item * C<%{$sep}X>: Localized label and list of conflicting changes delimited by C<$sep> + +=item * C<%b>: Body + +=item * C<%B>: Raw body (unwrapped subject and body) + +=item * C<%{$prefix}>B: Raw body with C<$prefix> prefixed to every line + +=item * C<%{event}_> Localized label for "event" + +=item * C<%{change}_> Localized label for "change" + +=item * C<%{committer}_> Localized label for "committer" + +=item * C<%{planner}_> Localized label for "planner" + +=item * C<%{by}_> Localized label for "by" + +=item * C<%{date}_> Localized label for "date" + +=item * C<%{committed}_> Localized label for "committed" + +=item * C<%{planned}_> Localized label for "planned" + +=item * C<%{name}_> Localized label for "name" + +=item * C<%{project}_> Localized label for "project" + +=item * C<%{email}_> Localized label for "email" + +=item * C<%{requires}_> Localized label for "requires" + +=item * C<%{conflicts}_> Localized label for "conflicts" + +=item * C<%v> vertical space (newline) + +=item * C<%{$color}C>: An ANSI color: black, red, green, yellow, reset, etc. + +=item * C<%{:event}C>: An ANSI color based on event type (green deploy, blue revert, red fail) + +=item * C<%{$attribute}a>: The raw attribute name and value, if it exists and has a value + +=back + +=back + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch-passwords.pod b/lib/sqitch-passwords.pod new file mode 100644 index 00000000..0673fd47 --- /dev/null +++ b/lib/sqitch-passwords.pod @@ -0,0 +1,13 @@ +=encoding UTF-8 + +=head1 Name + +sqitch-passwords - Guide to using database passwords with Sqitch + +=head1 Description + +This guide has been moved to L<sqitch-authentication>. + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch-plan-usage.pod b/lib/sqitch-plan-usage.pod new file mode 100644 index 00000000..c567c190 --- /dev/null +++ b/lib/sqitch-plan-usage.pod @@ -0,0 +1,32 @@ +=head1 Name + +sqitch-plan-usage - Sqitch plan usage statement + +=head1 Usage + + sqitch plan [options] [<database>] + +=head1 Options + +Search options: + + -t --target <target> database target specifying the plan + --event type of event + --change-pattern --change match regex against change names + --planner-pattern --planner match regex against committer names + -n --max-count show only specified number of events + --skip skip the specified number of events + --reverse show events in reverse order + --no-reverse don't show events in reverse order + +Formatting + + -f --format show events in the specified format + --date-format --date show dates in the specified format + --color use ANSI colors + --no-color never use ANSI colors + --abbrev abbreviate change IDs + --oneline shorthand for --format oneline --abbrev 6 + --headers show headers for plan file and project + --no-headers don't show plan headers + diff --git a/lib/sqitch-plan.pod b/lib/sqitch-plan.pod new file mode 100644 index 00000000..542c42b1 --- /dev/null +++ b/lib/sqitch-plan.pod @@ -0,0 +1,397 @@ +=head1 Name + +sqitch-plan - Show planned database changes + +=head1 Synopsis + + sqitch plan [options] [<database>] + +=head1 Description + +The C<plan> command displays information about planned changes for a database +target. By default, it will show all the changes for the plan, but you can +also search for changes matching regular expressions, and limit the results. +Of course you could just C<cat> your plan file, but this is more fun. + +The C<< <database> >> parameter specifies the database for which the plan +should be read, and may also be specified as the C<--target> option. It can be +target name, a URI, an engine name, or plan file path. + +=head1 Options + +=over + +=item C<-t> + +=item C<--target> + +The target database, the plan for which should be read. This option should be +the name of a target in the configuration. + +=item C<--event> + +Filter by event type. May be specified more than once. Allowed values are: + +=over + +=item * C<deploy> + +=item * C<revert> + +=back + +=item C<--change-pattern> + +=item C<--change> + +A regular expression to match against change names. + +=item C<--planner-pattern> + +=item C<--planner> + +A regular expression to match against planner names. + +=item C<--format> + +=item C<-f> + +The format to use. May be one of: + +=over + +=item C<full> + +=item C<long> + +=item C<medium> + +=item C<short> + +=item C<oneline> + +=item C<raw> + +=item C<< format:<string> >> + +=back + +See L</Formats> for details on each format. Defaults to C<medium>. + +=item C<--date-format> + +=item C<--date> + +Format to use for timestamps. Defaults to C<iso>. Allowed values: + +=over + +=item C<iso> + +=item C<iso8601> + +Shows timestamps in ISO-8601 format. + +=item C<rfc> + +=item C<rfc2822> + +Show timestamps in RFC-2822 format. + +=item C<full> + +=item C<long> + +=item C<medium> + +=item C<short> + +Show timestamps in the specified format length, using the system locale's +C<LC_TIME> category. + +=item C<raw> + +Show timestamps in raw format, which is strict ISO-8601 in the UTC time zone. + +=item C<strftime:$string> + +Show timestamps using an arbitrary C<strftime> pattern. See +L<DateTime/strftime Paterns> for comprehensive documentation of supported +patterns. + +=item C<cldr:$pattern> + +Show timestamps using an arbitrary C<cldr> pattern. See +L<DateTime/CLDR Paterns> for comprehensive documentation of supported +patterns. + +=back + +=item C<--max-count> + +=item C<-n> + +Limit the number of changes to output. + +=item C<--skip> + +Skip the specified number changes before starting to show the output. + +=item C<--reverse> + +Output the changes in reverse order. + +=item C<--no-reverse> + +Do not output the changes in reverse order. + +=item C<--headers> + +Output headers describing the project and plan file. Enabled by default. + +=item C<--no-headers> + +Do not output headers describing the project and plan file. + +=item C<--color> + +Show colored output. The value may be one of: + +=over + +=item C<auto> (the default) + +=item C<always> + +=item C<never> + +=back + +=item C<--no-color> + +Turn off colored output. It is the same as C<--color never>. + +=item C<--abbrev> + +Instead of showing the full 40-byte hexadecimal change ID, show only a partial +prefix the specified number of characters long. + +=item C<--oneline> + +Shorthand for C<--format oneline --abbrev 6>. + +=back + +=head1 Configuration Variables + +=over + +=item C<plan.format> + +Output format to use. Supports the same values as C<--format>. + +=item C<plan.date_format> + +Format to use for timestamps. Supports the same values as the C<--date-format> +option. + +=item C<plan.color> + +Output colors. Supports the same values as the C<--color> option. + +=back + +=head1 Formats + +There are several built-in formats, and you can emit data in a custom format +C<< format:<string> >> format. Here are the details of the built-in formats: + +=over + +=item C<oneline> + + <change id> <event type> <change name> <title line> <tags> + +This is designed to be as compact as possible. + +=item C<short> + + <event type> <change id> + Name: <change name> + Planner: <planner> + + <title line> + +=item C<medium> + + <event type> <change id> + Name: <change name> + Planner: <planner> + Date: <commit date> + + <full change note> + +=item C<long> + + <event type> <change id> <tags> + Name: <change name> + Project: <change name> + Planner: <planner> + + <full change note> + +=item C<full> + + <event type> <change id> <tags> + Event: <event type> + Name: <change name> + Project: <change name> + Requires: <required changes> + Conflicts: <conflicting changes> + Planner: <planner> + Planned: <plan date> + + <full change note> + +=item C<raw> + + <event type> <change id> <tags> + name <change name> + project <project name> + requires <required changes> + conflicts <conflicting changes> + planner <planner> + planned <raw plan date> + + <full change note> + +Suitable for parsing: the change ID is displayed in full, without regard to +the value of C<--abbrev>; dates are formatted raw (strict ISO-8601 format in +the UTC time zone); and all labels are lowercased and unlocalized. + +=item C<< format:<string> >> + +The C<< format:<string> >> format allows you to specify which information you +want to show. It works a little bit like C<printf> format and a little like +Git plan format. For example, this format: + + format:The planner of %h was %{name}p%vThe title was >>%s<<%v + +Would show something like this: + + The planner of f26a3s was Tom Lane + The title was >>We really need to get this right.<< + +The placeholders are: + +=over + +=item * C<%H>: Event change ID + +=item * C<%h>: Event change ID (respects C<--abbrev>) + +=item * C<%n>: Event change name + +=item * C<%o>: Event change project name + +=item * C<%($len)h>: abbreviated change of length C<$len> + +=item * C<%e>: Event type (deploy, revert, fail) + +=item * C<%l>: Localized lowercase event type label + +=item * C<%L>: Localized title case event type label + +=item * C<%c>: Change planner name and email address + +=item * C<%{name}p>: Change planner name + +=item * C<%{email}p>: Change planner email address + +=item * C<%{date}p>: plan date (respects C<--date-format>) + +=item * C<%{date:rfc}p>: plan date, RFC2822 format + +=item * C<%{date:iso}p>: plan date, ISO-8601 format + +=item * C<%{date:full}p>: plan date, full format + +=item * C<%{date:long}p>: plan date, long format + +=item * C<%{date:medium}p>: plan date, medium format + +=item * C<%{date:short}p>: plan date, short format + +=item * C<%{date:cldr:$pattern}p>: plan date, formatted with custom L<CLDR pattern|DateTime/CLDR Patterns> + +=item * C<%{date:strftime:$pattern}p>: plan date, formatted with custom L<strftime pattern|DateTime/strftime Patterns> + +=item * C<%t>: Comma-delimited list of tags + +=item * C<%{$sep}t>: list of tags delimited by C<$sep> + +=item * C<%T>: Parenthesized list of comma-delimited tags + +=item * C<%{$sep}T>: Parenthesized list of tags delimited by C<$sep> + +=item * C<%s>: Subject (a.k.a. title line) + +=item * C<%r>: Comma-delimited list of required changes + +=item * C<%{$sep}r>: list of required changes delimited by C<$sep> + +=item * C<%R>: Localized label and list of comma-delimited required changes + +=item * C<%{$sep}R>: Localized label and list of required changes delimited by C<$sep> + +=item * C<%x>: Comma-delimited list of conflicting changes + +=item * C<%{$sep}x>: list of conflicting changes delimited by C<$sep> + +=item * C<%X>: Localized label and list of comma-delimited conflicting changes + +=item * C<%{$sep}X>: Localized label and list of conflicting changes delimited by C<$sep> + +=item * C<%b>: Body + +=item * C<%B>: Raw body (unwrapped subject and body) + +=item * C<%{$prefix}>B: Raw body with C<$prefix> prefixed to every line + +=item * C<%{event}_> Localized label for "event" + +=item * C<%{change}_> Localized label for "change" + +=item * C<%{planner}_> Localized label for "planner" + +=item * C<%{by}_> Localized label for "by" + +=item * C<%{date}_> Localized label for "date" + +=item * C<%{planned}_> Localized label for "planned" + +=item * C<%{name}_> Localized label for "name" + +=item * C<%{project}_> Localized label for "project" + +=item * C<%{email}_> Localized label for "email" + +=item * C<%{requires}_> Localized label for "requires" + +=item * C<%{conflicts}_> Localized label for "conflicts" + +=item * C<%v> vertical space (newline) + +=item * C<%{$color}C>: An ANSI color: black, red, green, yellow, reset, etc. + +=item * C<%{:event}C>: An ANSI color based on event type (green deploy, blue revert, red fail) + +=item * C<%{$attribute}a>: The raw attribute name and value, if it exists and has a value + +=back + +=back + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch-rebase-usage.pod b/lib/sqitch-rebase-usage.pod new file mode 100644 index 00000000..9ef9aee8 --- /dev/null +++ b/lib/sqitch-rebase-usage.pod @@ -0,0 +1,28 @@ +=head1 Name + +sqitch-rebase-usage - Sqitch rebase usage statement + +=head1 Usage + + sqitch rebase [options] [revert-change options] [deploy-change options]] [<database>] + +=head1 Options + + -t --target <target> database to which to connect + --onto --onto-change <change> revert to change + --upto --upto-change <change> deploy to change + --mode <mode> deploy reversion mode (all, tag, change) + --verify run verify scripts after each change + --no-verify do not run verify scripts + -s --set <key=value> set a database client variable + -r --set-revert <key=value> set a database client revert variable + -e --set-deploy <key=value> set a database client deploy variable + --log-only log changes without running them + -y disable the prompt before reverting + --registry <registry> registry schema or database + --db-client <path> path to the engine command-line client + -d --db-name <name> database name + -u --db-user <user> database user name + -h --db-host <host> database server host name + -p --db-port <port> database server port number + -f --plan-file <file> path to a deployment plan file diff --git a/lib/sqitch-rebase.pod b/lib/sqitch-rebase.pod new file mode 100644 index 00000000..2493b9ab --- /dev/null +++ b/lib/sqitch-rebase.pod @@ -0,0 +1,305 @@ +=head1 Name + +sqitch-rebase - Revert and redeploy database changes + +=head1 Synopsis + + sqitch rebase [options] [<database>] + sqitch rebase [options] [<database>] --onto-change <change> + sqitch rebase [options] [<database>] --onto-change <change> --upto-change <change> + sqitch rebase [options] [<database>] <change> + sqitch rebase [options] [<database>] <change> --upto-change <change> + sqitch rebase [options] [<database>] <change> <change> + +=head1 Description + +Revert and redeploy changes to the database. It's effectively a shortcut for +running L<C<sqitch revert>|sqitch-revert> and L<C<sqitch deploy>|sqitch-deploy> +in succession. + +More specifically, starting from the current deployment state, changes will be +reverted in reverse the order of application. All changes will be reverted +unless a change is specified, either via C<--onto> or with no option flag, in +which case changes will be reverted back to that change. If nothing needs to +be reverted, a message will be emitted explaining why and nothing will be +reverted. + +Once the revert finishes, changes will be deployed starting from the deployed +state through the rest of the deployment plan. They will run to the latest +change in the plan, unless a change is specified, either via C<--upto> or with +no option flag, in which case changes will be deployed up-to and including +that change. + +If the database has not been deployed to, or its state already matches the +specified change, no reverts will be run. And if, at that point, the database +is up-to-date, no deploys will be run. + +The C<< <database> >> parameter specifies the database to which to connect, +and may also be specified as the C<--target> option. It can be target name, +a URI, an engine name, or plan file path. + +=head1 Options + +=over + +=item C<-t> + +=item C<--target> + +The target database to which to connect. This option can be either a URI or +the name of a target in the configuration. + +=item C<--onto-change> + +=item C<--onto> + +Specify the reversion change. Defaults to reverting all changes. See +L<sqitchchanges> for the various ways in which changes can be specified. + +=item C<--upto-change> + +=item C<--upto> + +Specify the deployment change. Defaults to the last point in the plan. See +L<sqitchchanges> for the various ways in which changes can be specified. + +=item C<--mode> + +Specify the reversion mode to use in case of deploy failure. Possible values +are: + +=over + +=item C<all> + +In the event of failure, revert all deployed changes, back to +C<--onto-change>. This is the default. + +=item C<tag> + +In the event of failure, revert all deployed changes to the last +successfully-applied tag. If no tags were applied, all changes will be +reverted to C<--onto-change>. + +=item C<change> + +In the event of failure, no changes will be reverted. This is on the +assumption that a change is atomic, and thus may may be deployed again. + +=back + +=item C<--verify> + +Verify each change by running its verify script, if there is one, immediate +after deploying it. If a verify test fails, the deploy will be considered to +have failed and the appropriate reversion will be carried out, depending on +the value of C<--mode>. + +=item C<--no-verify> + +Don't verify each change. This is the default. + +=item C<-s> + +=item C<--set> + +Set a variable name and value for use by the database engine client, if it +supports variables. The format must be C<name=value>, e.g., +C<--set defuser='Homer Simpson'>. Overrides any values loaded from +L</configuration Variables>. + +=item C<-e> + +=item C<--set-deploy> + +Set a variable name and value for use by the database engine client when +deploying, if it supports variables. The format must be C<name=value>, e.g., +C<--set defuser='Homer Simpson'>. Overrides any values from C<--set> or values +loaded from L</configuration Variables>. + +=item C<-r> + +=item C<--set-revert> + +Sets a variable name to be used by the database engine client during when +reverting, if it supports variables. The format must be C<name=value>, e.g., +C<--set defuser='Homer Simpson'>. Overrides any values from C<--set> or values +loaded from L</configuration Variables>. + +=item C<--log-only> + +Log the changes as if they were deployed and reverted, but without actually +running the deploy and revert scripts. + +=item C<-y> + +Disable the prompt that normally asks whether or not to execute the revert. + +=item C<--registry> + + sqitch rebase --registry registry + +The name of the Sqitch registry schema or database in which sqitch stores its +own data. + +=item C<--db-client> + +=item C<--client> + + sqitch rebase --client /usr/local/pgsql/bin/psql + +Path to the command-line client for the database engine. Defaults to a client +in the current path named appropriately for the database engine. + +=item C<-d> + +=item C<--db-name> + + sqitch rebase --db-name widgets + sqitch rebase -d bricolage + +Name of the database. In general, L<targets|sqitch-target> and URIs are +preferred, but this option can be used to override the database name in a +target. + +=item C<-u> + +=item C<--db-user> + +=item C<--db-username> + + sqitch rebase --db-username root + sqitch rebase --db-user postgres + sqitch rebase -u Mom + +User name to use when connecting to the database. Does not apply to all +engines. In general, L<targets|sqitch-target> and URIs are preferred, but this +option can be used to override the user name in a target. + +=item C<-h> + +=item C<--db-host> + + sqitch rebase --db-host db.example.com + sqitch rebase -h appdb.example.net + +Host name to use when connecting to the database. Does not apply to all +engines. In general, L<targets|sqitch-target> and URIs are preferred, but this +option can be used to override the host name in a target. + +=item C<-p> + +=item C<--db-port> + + sqitch rebase --db-port 7654 + sqitch rebase -p 5431 + +Port number to connect to. Does not apply to all engines. In general, +L<targets|sqitch-target> and URIs are preferred, but this option can be used +to override the port in a target. + +=item C<--plan-file> + +=item C<-f> + + sqitch rebase --plan-file my.plan + +Path to the deployment plan file. Overrides target, engine, and core +configuration values. Defaults to F<$top_dir/sqitch.plan>. + +=back + +=head1 Configuration Variables + +=over + +=item C<[deploy.variables]> + +=item C<[revert.variables]> + +A section defining database client variables. These variables are useful if +your database engine supports variables in scripts, such as PostgreSQL's +L<C<psql> +variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>, +Vertica's L<C<vsql> +variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm>, +MySQL's L<user +variables|https://dev.mysql.com/doc/refman/5.6/en/user-variables.html>, +SQL*Plus's L<C<DEFINE> +variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>, +and Snowflake's L<SnowSQL +variables|https://docs.snowflake.net/manuals/user-guide/snowsql-use.html#using-variables>. + +May be overridden by C<--set>, C<--set-deploy>, C<--set-revert>, or target and +engine configuration. Variables are merged in the following priority order: + +=over + +=item C<--set-revert> + +Used only while reverting changes. + +=item C<--set-deploy> + +Used only while deploying changes. + +=item C<--set> + +Used while reverting and deploying changes. + +=item C<target.$target.variables> + +Used while reverting and deploying changes. + +=item C<engine.$engine.variables> + +Used while reverting and deploying changes. + +=item C<revert.variables> + +Used only while reverting changes. + +=item C<deploy.variables> + +Used while reverting and deploying changes. + +=item C<core.variables> + +Used while reverting and deploying changes. + +=back + +=item C<rebase.verify> + +=item C<deploy.verify> + +Boolean indicating whether or not to verify each change after deploying it. + +=item C<rebase.mode> + +=item C<deploy.mode> + +Deploy mode. The supported values are the same as for the C<--mode> option. + +=item C<[rebase.no_prompt]> + +=item C<[revert.no_prompt]> + +A boolean value indicating whether or not to disable the prompt before +executing the revert. The C<rebase.no_prompt> variable takes precedence over +C<revert.no_prompt>, and both may of course be overridden by C<-y>. + +=item C<[rebase.prompt_accept]> + +=item C<[revert.prompt_accept]> + +A boolean value indicating whether default reply to the prompt before +executing the revert should be "yes" or "no". The C<rebase.prompt_accept> +variable takes precedence over C<revert.prompt_accept>, and both default to +true, meaning to accept the revert by default. + +=back + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch-revert-usage.pod b/lib/sqitch-revert-usage.pod new file mode 100644 index 00000000..f37a0430 --- /dev/null +++ b/lib/sqitch-revert-usage.pod @@ -0,0 +1,22 @@ +=head1 Name + +sqitch-revert-usage - Sqitch revert usage statement + +=head1 Usage + + sqitch revert [options] [<database>] + +=head1 Options + + -t --target <target> database to which to connect + --to-change <change> revert to change + -s --set <key=value> set a database client variable + --log-only log changes without running them + -y disable the prompt before reverting + --registry <registry> registry schema or database + --db-client <path> path to the engine command-line client + -d --db-name <name> database name + -u --db-user <user> database user name + -h --db-host <host> database server host name + -p --db-port <port> database server port number + -f --plan-file <file> path to a deployment plan file diff --git a/lib/sqitch-revert.pod b/lib/sqitch-revert.pod new file mode 100644 index 00000000..fa69b697 --- /dev/null +++ b/lib/sqitch-revert.pod @@ -0,0 +1,208 @@ +=head1 Name + +sqitch-revert - Revert changes to a database + +=head1 Synopsis + + sqitch revert [options] [<database>] + sqitch revert [options] [<database>] <change> + sqitch revert [options] [<database>] --to-change <change> + +=head1 Description + +Revert changes to the database. Starting from the current deployment state, +changes will be reverted in reverse the order of application. All changes will +be reverted unless a change is specified, either via C<--to> or with no option +flag, in which case changes will be reverted back to that change. + +If the database has not been deployed to, or its state already matches the +specified change, no changes will be made. If the change appears later in the +plan than the currently-deployed state, an error will be returned, along with +a suggestion to instead use L<sqitch-deploy>. + +The C<< <database> >> parameter specifies the database to which to connect, +and may also be specified as the C<--target> option. It can be target name, +a URI, an engine name, or plan file path. + +=head3 Attention Git Users + +If you're a git user thinking this is like C<git revert>, it's not. +C<sqitch revert> is more like time travel. It takes your database back to the +state it had just after applying the target change. It feels like magic, but +it's actually all the time you spent writing revert scripts that finally pays +off. Starting from the last change currently deployed, C<sqitch revert> runs +each revert script in turn until the target change is reached and becomes the +last change deployed. + +=head1 Options + +=over + +=item C<-t> + +=item C<--target> + +The target database to which to connect. This option can be either a URI or +the name of a target in the configuration. + +=item C<--to-change> + +=item C<--change> + +=item C<--to> + +Specify the reversion change. Defaults to reverting all changes. See +L<sqitchchanges> for the various ways in which changes can be specified. + +=item C<-s> + +=item C<--set> + +Set a variable name and value for use by the database engine client, if it +supports variables. The format must be C<name=value>, e.g., +C<--set defuser='Homer Simpson'>. Overrides any values loaded from +L</configuration Variables>. + +=item C<--log-only> + +Log the changes as if they were reverted, but without actually running the +revert scripts. + +=item C<-y> + +Disable the prompt that normally asks whether or not to execute the revert. + +=item C<--registry> + + sqitch revert --registry registry + +The name of the Sqitch registry schema or database in which sqitch stores its +own data. + +=item C<--db-client> + +=item C<--client> + + sqitch revert --client /usr/local/pgsql/bin/psql + +Path to the command-line client for the database engine. Defaults to a client +in the current path named appropriately for the database engine. + +=item C<-d> + +=item C<--db-name> + + sqitch revert --db-name widgets + sqitch revert -d bricolage + +Name of the database. In general, L<targets|sqitch-target> and URIs are +preferred, but this option can be used to override the database name in a +target. + +=item C<-u> + +=item C<--db-user> + +=item C<--db-username> + + sqitch revert --db-username root + sqitch revert --db-user postgres + sqitch revert -u Mom + +User name to use when connecting to the database. Does not apply to all +engines. In general, L<targets|sqitch-target> and URIs are preferred, but this +option can be used to override the user name in a target. + +=item C<-h> + +=item C<--db-host> + + sqitch revert --db-host db.example.com + sqitch revert -h appdb.example.net + +Host name to use when connecting to the database. Does not apply to all +engines. In general, L<targets|sqitch-target> and URIs are preferred, but this +option can be used to override the host name in a target. + +=item C<-p> + +=item C<--db-port> + + sqitch revert --db-port 7654 + sqitch revert -p 5431 + +Port number to connect to. Does not apply to all engines. In general, +L<targets|sqitch-target> and URIs are preferred, but this option can be used +to override the port in a target. + +=item C<--plan-file> + +=item C<-f> + + sqitch revert --plan-file my.plan + +Path to the deployment plan file. Overrides target, engine, and core +configuration values. Defaults to F<$top_dir/sqitch.plan>. + +=back + +=head1 Configuration Variables + +=over + +=item C<[deploy.variables]> + +=item C<[revert.variables]> + +A section defining database client variables. The C<deploy.variables> +configuration is read from the C<deploy> command configuration, on the +assumption that the values will generally be the same on revert. If they're +not, use C<revert.variables> to override C<deploy.variables>. + +These variables are useful if your database engine supports variables in +scripts, such as PostgreSQL's +L<C<psql> variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>, +Vertica's +L<C<vsql> variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm>, +MySQL's +L<user variables|https://dev.mysql.com/doc/refman/5.6/en/user-variables.html>, +SQL*Plus's +L<C<DEFINE> variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>, +and Snowflake's +L<SnowSQL variables|https://docs.snowflake.net/manuals/user-guide/snowsql-use.html#using-variables>. + +May be overridden by C<--set> or target and engine configuration. Variables +are merged in the following priority order: + +=over + +=item C<--set> + +=item C<target.$target.variables> + +=item C<engine.$engine.variables> + +=item C<revert.variables> + +=item C<deploy.variables> + +=item C<core.variables> + +=back + +=item C<[revert.no_prompt]> + +A boolean value indicating whether or not to disable the prompt before +executing the revert. May be overridden by C<-y>. + +=item C<[revert.prompt_accept]> + +A boolean value indicating whether default reply to the prompt before +executing the revert should be "yes" or "no". Defaults to true, meaning to +accept the revert. + +=back + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch-rework-usage.pod b/lib/sqitch-rework-usage.pod new file mode 100644 index 00000000..7001b255 --- /dev/null +++ b/lib/sqitch-rework-usage.pod @@ -0,0 +1,19 @@ +=head1 Name + +sqitch-rework-usage - Sqitch rework usage statement + +=head1 Usage + + sqitch rework [options] changename + +=head1 Options + + -c --change --change-name <name> name of the change to rework + -r --requires <change> require change + -x --conflicts <change> declare conflicting change + -n --note <note> a note describing the change + -a --all rework the change in all plans in the project + -e --edit --open-editor open change scripts in an editor + --no-edit --no-open-editor do not open the change scripts in an editor + -f --plan-file <file> path to a deployment plan file + diff --git a/lib/sqitch-rework.pod b/lib/sqitch-rework.pod new file mode 100644 index 00000000..b07bef11 --- /dev/null +++ b/lib/sqitch-rework.pod @@ -0,0 +1,184 @@ +=head1 Name + +sqitch-rework - Rework a database change + +=head1 Synopsis + + sqitch rework [options] [<dependency-options>] name + +=head1 Description + +This command allows for the reworking of an existing database change. It is +best used only under the following circumstances: + +=over + +=item * + +There are production deployments, so that you cannot revert to before the +change, modify it, and then re-deploy. Just reverting, modifying, and +re-deploying is the thing to do while developing the database, but once it +has been released and deployed to production, you must not change previous +change scripts. + +=item * + +The modifications will be L<idempotent|https://en.wikipedia.org/wiki/Idempotence>. +In other words, either the earlier instance of the change or the new, reworked +instance can be run any number of times, and the outcome of each will be the same. +They must not break each other in case one needs to deploy and revert changes. + +=item * + +A tag must have been applied to the plan since the previous instance of the +change. This is required so that Sqitch can disambiguate the two instances of +the change. It's a good idea to always tag a release anyway. If you haven't, +see L<sqitch-tag>. + +=back + +If all of these hold, then feel free to rework an existing change. + +In effect, reworking a change is similar to L<adding one|sqitch-add>. However, +rather than writing new files for the change, the C<rework> command copies the +files for the existing change. The new files are named with the tag that comes +between the changes, and serves as the file for the original change. This +leaves you free to edit the existing files. + +By default, the C<rework> command will rework the change in the default plan +and the scripts to any top directories for that plan, as defined by the core +configuration and command-line options. This works well for projects in which +there is a single plan with separate top directories for each engine, for +example. Pass the C<--all> option to have it iterate over all known plans and +top directories (as specified for engines and targets) and rework the change +to them all. Of course, the a change by that name must exist in all the plans +of the reworking will fail. + +To specify which plans to in which to rework the change, pass the target, +engine, or plan file names to tag as arguments. Use C<--change> to +disambiguate the and change name from the other parameters if necessary (or +preferable). See L</Examples> for examples. + +=head1 Options + +=over + +=item C<-c> + +=item C<--change> + +=item C<--change-name> + +The name of the change to rework. The name can be specified with or without +this option, but the option can be useful for disambiguating the change name +from other arguments. + +=item C<-r> + +=item C<--requires> + +Name of a change that is required by the new change. May be specified multiple +times. See L<sqitchchanges> for the various ways in which changes can be +specified. + +=item C<-x> + +=item C<--conflicts> + +Name of a change that conflicts with the new change. May be specified multiple +times. See L<sqitchchanges> for the various ways in which changes can be +specified. + +=item C<-a> + +=item C<--all> + +Rework the change in all plans in the project. Cannot be mixed with target, +engine, or plan file name arguments; doing so will result in an error. Useful +for multi-plan projects in which changes should be kept in sync. Overrides the +value of the C<add.all> configuration; use C<--no-all> to override a true +C<add.all> configuration. + +=item C<-n> + +=item C<--note> + +A brief note describing the purpose of the reworking. The note will be +attached to the change as a comment. Multiple invocations will be concatenated +together as separate paragraphs. + +For you Git folks out there, C<-m> also works. + +=item C<-e> + +=item C<--edit> + +=item C<--open-editor> + +Open the generated change scripts in an editor. + +=item C<--no-edit> + +=item C<--no-open-editor> + +Do not open the change scripts in an editor. Useful when L<C<rework.open_editor>> +is true. + +=item C<--plan-file> + +=item C<-f> + +Path to the deployment plan file. Overrides target, engine, and core +configuration values. Defaults to F<$top_dir/sqitch.plan>. + +=back + +=head1 Examples + +Rework a change in a project and be prompted for a note. + + sqitch rework widgets + +Rework a change and specify the note. + + sqitch rework sprockets --note 'Reworks the sprockets view.' + +Rework a change that requires the C<users> change from earlier in the plan. + + sqitch rework contacts --requires users -n 'Reworks the contacts view.' + +Rework a change that requires multiple changes, including the change named +C<extract> from a completely different Sqitch project named C<utilities>: + + sqitch rework coffee -r users -r utilities:extract -n 'Mmmmm...coffee!' + +Rework a change only to the plan used by the C<vertica> engine in a project: + + sqitch rework --change logs vertica -n 'Reworks the logs view in Vertica.' + +Rework a change in two plans in a project, and generate the scripts only for +those plans: + + sqitch rework -a coolfunctions sqlite.plan pg.plan -n 'Reworks functions.' + +=head1 Configuration Variables + +=over + +=item C<rework.all> + +Rework the change to all the plans in the project. Useful for multi-plan projects +in which changes should be kept in sync. May be overridden by C<--all>, +C<--no-all>, or target, engine, and plan file name arguments. + +=item C<rework.open_editor> + +Boolean indicating if the rework command should spawn an editor after +generating change scripts. When true, equivalent to passing C<--edit>. +Defaults off. + +=back + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch-show-usage.pod b/lib/sqitch-show-usage.pod new file mode 100644 index 00000000..a4ce057a --- /dev/null +++ b/lib/sqitch-show-usage.pod @@ -0,0 +1,15 @@ +=head1 Name + +sqitch-show-usage - Sqitch show usage statement + +=head1 Usage + + sqitch show [options] <type> <object> + +C<< <type> >> can be one of: change, tag, deploy, revert, verify + +=head1 Options + + -t --target <target> database target specifying the plan + -e --exists suppress output, exit 0 when object exists + -f --plan-file <file> path to a deployment plan file diff --git a/lib/sqitch-show.pod b/lib/sqitch-show.pod new file mode 100644 index 00000000..804e9015 --- /dev/null +++ b/lib/sqitch-show.pod @@ -0,0 +1,96 @@ +=head1 Name + +sqitch-show - Show object information or script contents + +=head1 Synopsis + + sqitch show [options] <type> <object> + +=head1 Description + +Shows information about Sqitch objects. The first argument must be the type of +object to show, and the second must be a key identifier for the object in the +plan. The second argument must be a a change name or tag as specified in +L<sqitchchanges>. The supported types include: + +=over + +=item C<change> + +A change object. Outputs the text used to generate the change SHA1 ID. + +=item C<tag> + +A tag. Outputs the text used to generate the tag SHA1 ID. + +=item C<deploy> + +A change deploy script. + +=item C<revert> + +A change revert script. + +=item C<verify> + +A change verify script. + +=back + +=head1 Options + +=over + +=item C<-t> + +=item C<--target> + +The target database, the plan for which should be read before deciding what +object to show. This option should be the name of a target in the +configuration. + +=item C<-e> + +=item C<--exists> + +Suppress all output; instead exit with zero status if C<< <object> >> exists +and is a valid object. + +=item C<--plan-file> + +=item C<-f> + +Path to the deployment plan file. Overrides target, engine, and core +configuration values. Defaults to F<$top_dir/sqitch.plan>. + +=back + +=head2 Examples + +=over + +=item * Show information about a specific change: + + sqitch show change add_users_table + +=item * Show information about a change by ID: + + sqitch show change be7cd00571d7151eacb0691e825dfc8980cc14ff + +=item * Show the most recent change info: + + sqitch show change @HEAD + +=item * Show information about a tag: + + sqitch show tag @beta1 + +=item * Show the contents of a deploy file: + + sqitch show deploy add_users_table@HEAD + +=back + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch-status-usage.pod b/lib/sqitch-status-usage.pod new file mode 100644 index 00000000..90d7ffb3 --- /dev/null +++ b/lib/sqitch-status-usage.pod @@ -0,0 +1,22 @@ +=head1 Name + +sqitch-status-usage - Sqitch status usage statement + +=head1 Usage + + sqitch status [options] [<database>] + +=head1 Options + + -t --target <target> database to which to connect + --project <project> project for which to retrieve the status + --show-changes include a list of deployed changes + --show-tags include a list of applied tags + --date-format <format> display dates using the specified format + --registry <registry> registry schema or database + --db-client <path> path to the engine command-line client + -d --db-name <name> database name + -u --db-user <user> database user name + -h --db-host <host> database server host name + -p --db-port <port> database server port number + -f --plan-file <file> path to a deployment plan file diff --git a/lib/sqitch-status.pod b/lib/sqitch-status.pod new file mode 100644 index 00000000..14783c27 --- /dev/null +++ b/lib/sqitch-status.pod @@ -0,0 +1,189 @@ +=head1 Name + +sqitch-status - Show the current deployment status of a database + +=head1 Synopsis + + sqitch status [options] [<database>] + +=head1 Description + +Displays information about the current deployment status of a database. The +most recently deployed change information is displayed, as well as any related +tags. If there are undeployed changes in the plan, they will be listed. +Otherwise, a message will indicate that the database is up-to-date. + +The C<< <database> >> parameter specifies the database to which to connect, +and may also be specified as the C<--target> option. It can be target name, +a URI, an engine name, or plan file path. + +=head1 Options + +=over + +=item C<-t> + +=item C<--target> + +The target database to which to connect. This option can be either a URI or +the name of a target in the configuration. + +=item C<--project> + +Project for which to retrieve the status. Defaults to the status of the +current project, if a plan can be found. + +=item C<--show-changes> + +Also display a list of deployed changes. + +=item C<--show-tags> + +Also display a list of applied tags. + +=item C<--date-format> + +=item C<--date> + +Format to use for timestamps. Defaults to C<iso>. Allowed values: + +=over + +=item C<iso> + +=item C<iso8601> + +Shows timestamps in ISO-8601 format. + +=item C<rfc> + +=item C<rfc2822> + +Show timestamps in RFC-2822 format. + +=item C<full> + +=item C<long> + +=item C<medium> + +=item C<short> + +Show timestamps in the specified format length, using the system locale's +C<LC_TIME> category. + +=item C<raw> + +Show timestamps in raw format, which is strict ISO-8601 in the UTC time zone. + +=item C<strftime:$string> + +Show timestamps using an arbitrary C<strftime> pattern. See +L<DateTime/strftime Paterns> for comprehensive documentation of supported +patterns. + +=item C<cldr:$string> + +Show timestamps using an arbitrary C<cldr> pattern. See L<DateTime/CLDR +Paterns> for comprehensive documentation of supported patterns. + +=back + +=item C<--registry> + + sqitch status --registry registry + +The name of the Sqitch registry schema or database in which sqitch stores its +own data. + +=item C<--db-client> + +=item C<--client> + + sqitch status --client /usr/local/pgsql/bin/psql + +Path to the command-line client for the database engine. Defaults to a client +in the current path named appropriately for the database engine. + +=item C<-d> + +=item C<--db-name> + + sqitch status --db-name widgets + sqitch status -d bricolage + +Name of the database. In general, L<targets|sqitch-target> and URIs are +preferred, but this option can be used to override the database name in a +target. + +=item C<-u> + +=item C<--db-user> + +=item C<--db-username> + + sqitch status --db-username root + sqitch status --db-user postgres + sqitch status -u Mom + +User name to use when connecting to the database. Does not apply to all +engines. In general, L<targets|sqitch-target> and URIs are preferred, but this +option can be used to override the user name in a target. + +=item C<-h> + +=item C<--db-host> + + sqitch status --db-host db.example.com + sqitch status -h appdb.example.net + +Host name to use when connecting to the database. Does not apply to all +engines. In general, L<targets|sqitch-target> and URIs are preferred, but this +option can be used to override the host name in a target. + +=item C<-p> + +=item C<--db-port> + + sqitch status --db-port 7654 + sqitch status -p 5431 + +Port number to connect to. Does not apply to all engines. In general, +L<targets|sqitch-target> and URIs are preferred, but this option can be used +to override the port in a target. + +=item C<--plan-file> + +=item C<-f> + + sqitch status --plan-file my.plan + +Path to the deployment plan file. Overrides target, engine, and core +configuration values. Defaults to F<$top_dir/sqitch.plan>. + +=back + +=head1 Configuration Variables + +=over + +=item C<status.show_changes> + +Boolean value indicates whether or not to display changes in the output. +Defaults to false. + +=item C<status.show_tags> + +Boolean value indicates whether or not to display tags in the output. Defaults +to false. + +=item C<status.date_format> + +Format to use for timestamps. Supports the same values as the C<--date-format> +option. + +=back + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch-tag-usage.pod b/lib/sqitch-tag-usage.pod new file mode 100644 index 00000000..ecea9a2d --- /dev/null +++ b/lib/sqitch-tag-usage.pod @@ -0,0 +1,15 @@ +=head1 Name + +sqitch-tag-usage - Sqitch tag usage statement + +=head1 Usage + + sqitch tag [options] <tagname> [<change>] [<target>] + +=head1 Options + + -t --tag <tag> name of the tag to add + -c --change <change> name of the change to tag + -a --all tag all project plans + -n --note <note> a note describing the tag + -f --plan-file <file> path to a deployment plan file diff --git a/lib/sqitch-tag.pod b/lib/sqitch-tag.pod new file mode 100644 index 00000000..07a06310 --- /dev/null +++ b/lib/sqitch-tag.pod @@ -0,0 +1,152 @@ +=head1 Name + +sqitch-tag - Create or list tag objects + +=head1 Synopsis + + sqitch tag [options] + sqitch tag <name> + sqitch tag <name> change --note <note> + sqitch tag --tag <name> --change <change> + sqitch tag <name> --all + +=head1 Description + +Tags a change or outputs a list of existing tags in one or more project plans. +Tagging is useful for preparing for a release. Tags are also required in order +to rework a change. + +To specify a change, use a change specification as documented in +L<sqitchchanges>. If called with a tag name but no change, the most recent +change in each plan will be tagged. If called with no name specified, a list +of the current tags will be output. + +Note that the name of the new tag must adhere to the rules as defined in +L<sqitchchanges>. + +By default, the C<tag> command will add a new tag to the project's default +plan, as defined by the core configuration and command-line options. Pass the +C<--all> option to have it iterate over all known targets and list tags or add +a tag to all the plans. This works well to keep tags in sync in all plan +files. + +To specify which plans to tag, pass the target, engine, or plan file names to +tag as arguments. Use C<--tag> and C<--change> to disambiguate the tag and +change names from the other parameters if necessary (or preferable). See +L</Examples> for examples. + +=head1 Options + +=over + +=item C<-t> + +=item C<--tag> + +=item C<--tag-name> + +The name of the tag to add. The name can be specified with or without this +option, but the option can be useful for disambiguating the tag name from +other arguments. + +=item C<-c> + +=item C<--change> + +=item C<--change-name> + +The name of the change to tag. The name can be specified with or without this +option, but the option can be useful for disambiguating the change name from +other arguments. + +=item C<-a> + +=item C<--all> + +List the tags or add the new tag to all the plans in a project. Cannot be +mixed with target, engine, or plan file name arguments; doing so will result +in an error. Useful for multi-plan projects in which tags should be kept in +sync. Overrides the value of the C<tag.all> configuration; use C<--no-all> to +override a true C<tag.all> configuration. + +=item C<-n> + +=item C<--note> + +A brief note describing the tag. The note will be attached to the tag as a +comment. Multiple invocations will be concatenated together as separate +paragraphs. + +For you Git folks out there, C<-m> also works. + +=item C<--plan-file> + +=item C<-f> + +Path to the deployment plan file. Overrides target, engine, and core +configuration values. Defaults to F<$top_dir/sqitch.plan>. + +=back + +=head1 Configuration Variables + +=over + +=item C<tag.all> + +List the tags or add the new tag to all the plans in a project. Useful for +multi-plan projects in which tags should be kept in sync. May be overridden by +C<--all>, C<--no-all>, or target, engine, and plan file name arguments. + +=back + +=head1 Examples + +Get a list of tags in the default project plan: + + sqitch tag + +Get a list of all tags in the project: + + sqitch tag --all + +Get a list of the tags in the plan used by the C<pg> engine: + + sqitch tag pg + +Get a list of the tags in two specific plans: + + sqitch tag sqlite.plan pg.plan + +Tag the latest change in the default project plan and be prompted for a note. + + sqitch tag alpha1 + +Tag the latest change in all project plans and be prompted for a note. + + sqitch tag alpha1 --all + +Tag the latest change in the default project plan and and specify the note. + + sqitch tag alpha2 -n 'Tag @alpha2.' + +Tag change C<users> in the default plan: + + sqitch tag --tag alpha3 --change users + +Tag the latest change change in the project plan used by the C<vertica> +engine: + + sqitch tag --tag beta1 vertica -n 'Tag the Vertica with @beta1.' + +Tag the latest change in two plans in a project: + + sqitch tag -t v1.0.1 sqlite.plan pg.plan -n 'Tag @v1.0.1.' + +=head1 Configuration Variables + +None currently. + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch-target-usage.pod b/lib/sqitch-target-usage.pod new file mode 100644 index 00000000..eab6c85e --- /dev/null +++ b/lib/sqitch-target-usage.pod @@ -0,0 +1,25 @@ +=head1 Name + +sqitch-target-usage - Sqitch target usage statement + +=head1 Usage + + sqitch target + sqitch target [-v | --verbose] + sqitch target add <name> <uri> [target-options] + sqitch target alter <name> [target-options] + sqitch target remove <name> + sqitch target rename <old> <new> + sqitch target show <name> + +=head1 Options + + -v, --verbose be verbose; must be placed before an action + --uri <uri> database URI + --registry <registry> registry schema or database + --client <path> path to engine command-line client + -f --plan-file <file> path to deployment plan file + --top-dir <dir> path to directory with plan and scripts + --extension <ext> change script file name extension + --dir <name>=<path> path to named directory + -s --set <key=value> set a database client variable diff --git a/lib/sqitch-target.pod b/lib/sqitch-target.pod new file mode 100644 index 00000000..bfab8e54 --- /dev/null +++ b/lib/sqitch-target.pod @@ -0,0 +1,260 @@ +=head1 Name + +sqitch-target - Manage target database configuration + +=head1 Synopsis + + sqitch target + sqitch target [-v | --verbose] + sqitch target add <name> <uri> [-s <property>=<value> ...] + sqitch target alter <name> [-s <property>=<value> ...] + sqitch target remove <name> + sqitch target rename <old> <new> + sqitch target show <name> [...] + +=head1 Description + +Manage the set of databases ("targets") you deploy to. Each target may have a +number of properties: + +=over + +=item C<uri> + +The L<database connection URI|URI::db> for the target. Required. Its format is: + + db:engine:[dbname] + db:engine:[//[user[:password]@][host][:port]/][dbname][?params][#fragment] + +Some examples: + +=over + +=item C<db:sqlite:widgets.db> + +=item C<db:pg://dba@example.net/blanket> + +=item C<db:mysql://db.example.com/> + +=item C<db:firebird://localhost//tmp/test.fdb> + +=back + +See the L<DB URI Draft|https://github.com/libwww-perl/uri-db> for details. + +=item C<registry> + +The name of the registry schema or database. The default is C<sqitch>. + +=item C<client> + +The command-line client to use. If not specified, each engine looks in the OS +Path for an appropriate client. + +=item C<top_dir> + +The path to the top directory for the target. This directory generally +contains the plan file and subdirectories for deploy, revert, and verify +scripts, as well as reworked instances of those scripts. The default is F<.>, +the current directory. + +=item C<plan_file> + +The plan file to use for this target. The default is C<$top_dir/sqitch.plan>. + +=item C<deploy_dir> + +The path to the deploy directory for the target. This directory contains all +of the deploy scripts referenced by changes in the C<plan_file>. The default +is C<$top_dir/deploy>. + +=item C<revert_dir> + +The path to the revert directory for the target. This directory contains all +of the revert scripts referenced by changes in the C<plan_file>. The default +is C<$top_dir/revert>. + +=item C<verify_dir> + +The path to the verify directory for the target. This directory contains all +of the verify scripts referenced by changes in the C<plan_file>. The default +is C<$top_dir/verify>. + +=item C<reworked_dir> + +The path to the reworked directory for the target. This directory contains all +subdirectories for all reworked scripts referenced by changes in the +C<plan_file>. The default is C<$top_dir>. + +=item C<reworked_deploy_dir> + +The path to the reworked deploy directory for the target. This directory +contains all of the reworked deploy scripts referenced by changes in the +C<plan_file>. The default is C<$reworked_dir/deploy>. + +=item C<reworked_revert_dir> + +The path to the reworked revert directory for the target. This directory +contains all of the reworked revert scripts referenced by changes in the +C<plan_file>. The default is C<$reworked_dir/revert>. + +=item C<reworked_verify_dir> + +The path to the reworked verify directory for the target. This directory +contains all of the reworked verify scripts referenced by changes in the +C<plan_file>. The default is C<$reworked_dir/verify>. + +=item C<extension> + +The file name extension to append to change names to create script file names. +The default is C<sql>. + +=back + +Each of these overrides the corresponding engine-specific configuration +managed by L<engine|sqitch-engine>. + +=head1 Options + +=over + +=item List Option + +=over + +=item C<-v> + +=item C<--verbose> + + sqitch engine --verbose + +Be more verbose when listing engines. + +=back + +=item Add and Alter Options + +=over + +=item C<--uri> + + sqitch target add devwidgets --uri db:pg:widgets + +Specifies the L<URI|https://github.com/libwww-perl/uri-db/> of the target database. + +=item C<--top-dir> + + sqitch target add devwidgets --top-dir sql + +Specifies the top directory to use for the target. Typically contains the +deployment plan file and the change script directories. + +=item C<--plan-file> + +=item C<-f> + + sqitch target add devwidgets --plan-file my.plan + +Specifies the path to the deployment plan file. Defaults to +C<$top_dir/sqitch.plan>. + +=item C<--extension> + + sqitch target add devwidgets --extension ddl + +Specifies the file name extension to use for change script file names. +Defaults to C<sql>. + +=item C<--dir> + + sqitch target add devwidgets --dir deploy=dep --dir revert=rev --dir verify=tst + +Sets the path to a script directory. May be specified multiple times. +Supported keys are: + +=over + +=item * C<deploy> + +=item * C<revert> + +=item * C<verify> + +=item * C<reworked> + +=item * C<reworked_deploy> + +=item * C<reworked_revert> + +=item * C<reworked_verify> + +=back + +=item C<--registry> + + sqitch target add devwidgets --registry meta + +Specifies the name of the database object where Sqitch's state and history +data is stored. Typically a schema name (as in PostgreSQL and Oracle) or a +database name (as in SQLite and MySQL). Defaults to C<sqitch>. + +=item C<--client> + + sqitch target add devwidgets --client /usr/local/pgsql/bin/psql + +Specifies the path to the command-line client for the target. Defaults to a +client in the current path named appropriately for the engine specified by the +URI. + +=item C<-s> + +=item C<--set> + +Set a variable name and value for use by the database engine client, if it +supports variables. The format must be C<name=value>, e.g., +C<--set defuser='Homer Simpson'>. + +=back + +=back + +=head1 Actions + +With no arguments, shows a list of existing targets. Several actions are +available to perform operations on the targets. + +=head2 C<add> + +Add a target named C<< <name> >> for the database at C<< <uri> >>. The +C<--set> option specifies target-specific properties. A new plan file and new +script script directories will be created if they don't already exist. + +=head2 C<alter> + +Alter target named C<< <name> >>. The C<--set> option specifies +engine-specific properties to set. New script script directories will be +created if they don't already exist. + +=head2 C<remove>, C<rm> + +Remove the target named C<< <name> >>. The plan file and script directories +will not be affected. + +=head2 C<rename> + +Rename the target named C<< <old> >> to C<< <new> >>. + +=head2 C<show> + +Gives some information about the target C<< <name> >>, including the +associated properties. Specify multiple target names to see information for +each. + +=head1 Configuration Variables + +The targets are stored in the configuration file, but the command itself +currently relies on no configuration variables. + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch-upgrade-usage.pod b/lib/sqitch-upgrade-usage.pod new file mode 100644 index 00000000..edf45a38 --- /dev/null +++ b/lib/sqitch-upgrade-usage.pod @@ -0,0 +1,17 @@ +=head1 Name + +sqitch-upgrade-usage - Sqitch upgrade usage statement + +=head1 Usage + + sqitch upgrade [options] [<database>] + +=head1 Options + + -t --target <target> database to which to connect + --registry <registry> registry schema or database + --db-client <path> path to the engine command-line client + -d --db-name <name> database name + -u --db-user <user> database user name + -h --db-host <host> database server host name + -p --db-port <port> database server port number diff --git a/lib/sqitch-upgrade.pod b/lib/sqitch-upgrade.pod new file mode 100644 index 00000000..5a63a5b7 --- /dev/null +++ b/lib/sqitch-upgrade.pod @@ -0,0 +1,98 @@ +=head1 Name + +sqitch-upgrade - Upgrade the registry to the current version + +=head1 Synopsis + + sqitch upgrade [options] [<database>] + +=head1 Description + +Upgrades the Sqitch registry for a database. That is, it makes sure that the +schema the Sqitch uses for its registry is up-to-date. This will occasionally +be necessary when new features are added to Sqitch that require registry +schema changes. + +The C<< <database> >> parameter specifies the database to which to connect, +and may also be specified as the C<--target> option. It can be target name, +a URI, an engine name, or plan file path. + +=head1 Options + +=over + +=item C<-t> + +=item C<--target> + +The target database to which to connect. This option can be either a URI or +the name of a target in the configuration. + +=item C<--registry> + + sqitch upgrade --registry registry + +The name of the Sqitch registry schema or database in which sqitch stores its +own data. + +=item C<--db-client> + +=item C<--client> + + sqitch upgrade --client /usr/local/pgsql/bin/psql + +Path to the command-line client for the database engine. Defaults to a client +in the current path named appropriately for the database engine. + +=item C<-d> + +=item C<--db-name> + + sqitch upgrade --db-name widgets + sqitch upgrade -d bricolage + +Name of the database. In general, L<targets|sqitch-target> and URIs are +preferred, but this option can be used to override the database name in a +target. + +=item C<-u> + +=item C<--db-user> + +=item C<--db-username> + + sqitch upgrade --db-username root + sqitch upgrade --db-user postgres + sqitch upgrade -u Mom + +User name to use when connecting to the database. Does not apply to all +engines. In general, L<targets|sqitch-target> and URIs are preferred, but this +option can be used to override the user name in a target. + +=item C<-h> + +=item C<--db-host> + + sqitch upgrade --db-host db.example.com + sqitch upgrade -h appdb.example.net + +Host name to use when connecting to the database. Does not apply to all +engines. In general, L<targets|sqitch-target> and URIs are preferred, but this +option can be used to override the host name in a target. + +=item C<-p> + +=item C<--db-port> + + sqitch upgrade --db-port 7654 + sqitch upgrade -p 5431 + +Port number to connect to. Does not apply to all engines. In general, +L<targets|sqitch-target> and URIs are preferred, but this option can be used +to override the port in a target. + +=back + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch-verify-usage.pod b/lib/sqitch-verify-usage.pod new file mode 100644 index 00000000..5d84e2bc --- /dev/null +++ b/lib/sqitch-verify-usage.pod @@ -0,0 +1,21 @@ +=head1 Name + +sqitch-verify-usage - Sqitch verify usage statement + +=head1 Usage + + sqitch verify [options] [<database>] + +=head1 Options + + -t --target <target> database to which to connect + --from-change <change> verify from change + --to-change <change> verify to change + -s --set <key=value> set a database client variable + --registry <registry> registry schema or database + --db-client <path> path to the engine command-line client + -d --db-name <name> database name + -u --db-user <user> database user name + -h --db-host <host> database server host name + -p --db-port <port> database server port number + -f --plan-file <file> path to a deployment plan file diff --git a/lib/sqitch-verify.pod b/lib/sqitch-verify.pod new file mode 100644 index 00000000..a6ad0307 --- /dev/null +++ b/lib/sqitch-verify.pod @@ -0,0 +1,215 @@ +=head1 Name + +sqitch-verify - Verify deployed database changes + +=head1 Synopsis + + sqitch verify [options] [<database>] + sqitch verify [options] --from-change <change> + sqitch verify [options] --to-change <change> + sqitch verify [options] --from-change <change> --to-change <change> + +=head1 Description + +Verify that a database is valid relative to the plan and the verification +scripts for each deployed change. + +More specifically, C<verify> iterates over all deployed and planned changes +(or the subset identified by C<--from-change> and/or C<--to-change>) and +checks that each: + +=over + +=item * + +Is deployed. + +=item * + +Is present in the plan. + +=item * + +Was deployed in the proper order. + +=item * + +Passes its verify test, if one exists and the change has not been reworked. + +=back + +The C<< <database> >> parameter specifies the database to which to connect, +and may also be specified as the C<--target> option. It can be target name, +a URI, an engine name, or plan file path. + +Verify tests are scripts that may be associated with each change. If a change +has no verify script, a warning is emitted, but it is not considered a +failure. If a change has been reworked, only the most recent reworking will +have its verify script executed. + +Verify scripts should make no assumptions about the contents of the database, +as unit tests might. Rather, their job is to ensure that the state of a +database is correct after a deploy script has completed. Verify scripts are +run through the database engine command-line client, just like deploy and +revert scripts. They should cause the client to exit with a non-zero exit code +if they fail. + +=head1 Options + +=over + +=item C<-t> + +=item C<--target> + +The target database to which to connect. This option can be either a URI or +the name of a target in the configuration. + +=item C<--from-change> + +=item C<--from> + +Specify the change with which to start the verification. Defaults to the +earliest deployed change. See L<sqitchchanges> for the various ways in which +changes can be specified. + +=item C<--to-change> + +=item C<--to> + +Specify the change with which to complete the verification. Defaults to the +last deployed change. See L<sqitchchanges> for the various ways in which +changes can be specified. + +=item C<-s> + +=item C<--set> + +Set a variable name and value for use by the database engine client, if it +supports variables. The format must be C<name=value>, e.g., +C<--set defuser='Homer Simpson'>. Overrides any values loaded from +L</configuration Variables>. + +=item C<--registry> + + sqitch verify --registry registry + +The name of the Sqitch registry schema or database in which sqitch stores its +own data. + +=item C<--db-client> + +=item C<--client> + + sqitch verify --client /usr/local/pgsql/bin/psql + +Path to the command-line client for the database engine. Defaults to a client +in the current path named appropriately for the database engine. + +=item C<-d> + +=item C<--db-name> + + sqitch verify --db-name widgets + sqitch verify -d bricolage + +Name of the database. In general, L<targets|sqitch-target> and URIs are +preferred, but this option can be used to override the database name in a +target. + +=item C<-u> + +=item C<--db-user> + +=item C<--db-username> + + sqitch verify --db-username root + sqitch verify --db-user postgres + sqitch verify -u Mom + +User name to use when connecting to the database. Does not apply to all +engines. In general, L<targets|sqitch-target> and URIs are preferred, but this +option can be used to override the user name in a target. + +=item C<-h> + +=item C<--db-host> + + sqitch verify --db-host db.example.com + sqitch verify -h appdb.example.net + +Host name to use when connecting to the database. Does not apply to all +engines. In general, L<targets|sqitch-target> and URIs are preferred, but this +option can be used to override the host name in a target. + +=item C<-p> + +=item C<--db-port> + + sqitch verify --db-port 7654 + sqitch verify -p 5431 + +Port number to connect to. Does not apply to all engines. In general, +L<targets|sqitch-target> and URIs are preferred, but this option can be used +to override the port in a target. + +=item C<--plan-file> + +=item C<-f> + + sqitch verify --plan-file my.plan + +Path to the deployment plan file. Overrides target, engine, and core +configuration values. Defaults to F<$top_dir/sqitch.plan>. + +=back + +=head1 Configuration Variables + +=over + +=item C<[deploy.variables]> + +=item C<[verify.variables]> + +A section defining database client variables. The C<deploy.variables> +configuration is read from the C<deploy> command configuration, on the +assumption that the values will generally be the same on verify. If they're +not, use C<verify.variables> to override C<deploy.variables>. + +These variables are useful if your database engine supports variables in +scripts, such as PostgreSQL's +L<C<psql> variables|https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-INTERPOLATION>, +Vertica's +L<C<vsql> variables|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/vsql/Variables.htm>, +MySQL's +L<user variables|https://dev.mysql.com/doc/refman/5.6/en/user-variables.html>, +SQL*Plus's +L<C<DEFINE> variables|https://docs.oracle.com/cd/B19306_01/server.102/b14357/ch12017.htm>, +and Snowflake's +L<SnowSQL variables|https://docs.snowflake.net/manuals/user-guide/snowsql-use.html#using-variables>. + +May be overridden by C<--set> or target and engine configuration. Variables +are merged in the following priority order: + +=over + +=item C<--set> + +=item C<target.$target.variables> + +=item C<engine.$engine.variables> + +=item C<verify.variables> + +=item C<deploy.variables> + +=item C<core.variables> + +=back + +=back + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitch.pod b/lib/sqitch.pod new file mode 100644 index 00000000..5e9d07e4 --- /dev/null +++ b/lib/sqitch.pod @@ -0,0 +1,490 @@ +=encoding UTF-8 + +=head1 Name + +sqitch - Sensible database change management + +=head1 Synopsis + + sqitch <command> [options] [command-options] [args] + +=head1 Description + +Sqitch is a database change management application. What makes it different +from your typical L<migration|Module::Build::DB>-L<style|DBIx::Migration> +approaches? A few things: + +=over + +=item No opinions + +Sqitch is not tied to any framework, ORM, or platform. Rather, it is a +standalone change management system with no opinions about your database +engine, application framework, or development environment. + +=item Native scripting + +Changes are implemented as scripts native to your selected database engine. +Writing a L<PostgreSQL|https://postgresql.org/> application? Write SQL scripts +for L<C<psql>|https://www.postgresql.org/docs/current/static/app-psql.html>. +Writing an L<Oracle|https://www.oracle.com/us/products/database/>-backed app? +Write SQL scripts for L<SQL*Plus|https://www.orafaq.com/wiki/SQL*Plus>. + +=begin comment + +=item VCS integration + +Sqitch likes to use your VCS history to determine in what order to execute +changes. No need to keep track of execution order; your VCS already tracks +information sufficient for Sqitch to figure it out for you. + +=end comment + +=item Dependency resolution + +Database changes may declare dependencies on other changes -- even on changes +from other Sqitch projects. This ensures proper order of execution, even when +you've committed changes to your VCS out-of-order. + +=item Deployment integrity + +Sqitch manages changes and dependencies via a plan file, and employs a +L<Merkle tree|https://en.wikipedia.org/wiki/Merkle_tree> pattern similar to +L<Git|https://stackoverflow.com/a/18589734/> and +L<Blockchain|https://medium.com/byzantine-studio/blockchain-fundamentals-what-is-a-merkle-tree-d44c529391d7> +to ensure deployment integrity. As such, there is no need to number your +changes, although you can if you want. Sqitch doesn't much care how you name +your changes. + + +=item Iterative Development + +Up until you L<tag|sqitch-tag> and L<release|sqitch-bundle> your project, you +can modify your change deployment scripts as often as you like. They're not +locked in just because they've been committed to your VCS. This allows you to +take an iterative approach to developing your database schema. Or, better, you +can do test-driven database development. + +=begin comment + +=item Bundling + +Rely on your VCS history for deployment but have Sqitch bundle up changes for +distribution. Sqitch can read your VCS history and write out a plan file along +with the appropriate deployment and reversion scripts. Once the bundle is +installed on a new system, Sqitch can use the plan file to deploy or the +changes in the proper order. + +=item Reduced Duplication + +If you're using a VCS to track your changes, you don't have to duplicate +entire change scripts for simple changes. As long as the changes are +L<idempotent|https://en.wikipedia.org/wiki/Idempotence>, you can change +your code directly, and Sqitch will know it needs to be updated. + +=end comment + +=back + +Ready to get started? Here's where: + +=over + +=item Sqitch Tutorials + +Detailed tutorials demonstrating the creation, development, and maintenance +of a database with Sqitch. + +=over + +=item * L<PostgreSQL Tutorial|sqitchtutorial> + +=item * L<SQLite Tutorial|sqitchtutorial-sqlite> + +=item * L<MySQL Tutorial|sqitchtutorial-mysql> + +=item * L<Oracle Tutorial|sqitchtutorial-oracle> + +=item * L<Firebird Tutorial|sqitchtutorial-firebird> + +=item * L<Vertica Tutorial|sqitchtutorial-vertica> + +=item * L<Exasol Tutorial|sqitchtutorial-exasol> + +=item * L<Snowflake Tutorial|sqitchtutorial-snowflake> + +=back + +=item L<PDX.pm Presentation|https://speakerdeck.com/theory/sane-database-change-management-with-sqitch> + +Slides from "Sane Database Management with Sqitch", presented to the Portland +Perl Mongers in January, 2013. + +=item L<PDXPUG Presentation|https://vimeo.com/50104469> + +Movie of "Sane Database Management with Sqitch", presented to the Portland +PostgreSQL Users Group in September, 2012. + +=item L<Agile Database Development|https://speakerdeck.com/theory/agile-database-development-2ed> + +Three-hour tutorial session on using L<Git|https://git-scm.org/>, test-driven +development with L<pgTAP|https://pgtap.org>, and change management with Sqitch. + +=back + +=begin comment + +Eventually move to L<sqitchtutorial> or L<sqitchintro> or some such. + +=end comment + +=head2 Terminology + +=over + +=item C<change> + +A named unit of change. A change name must be used in the file names of its +deploy and a revert scripts. It may also be used in a verify script file +name. + +=item C<tag> + +A known deployment state, pointing to a single change, typically corresponding +to a release. Think of it is a version number or VCS revision. A given point +in the plan may have any number of tags. + +=item C<state> + +The current state of the database. This is represented by the most +recently-deployed change. If the state of the database is the same as the most +recent change, then it is considered "up-to-date". + +=item C<plan> + +A list of one or more changes and their dependencies that define the order of +deployment execution. The plan is stored in a "plan file," usually named +F<sqitch.plan>. Sqitch reads the plan file to determine what changes to +execute to change the database from one state to another. + +=item C<target> + +A named database to which to deploy changes. Always has an associated +connection URI, and may also have an associated command-line client and +registry name. + +=item C<registry> + +The name of the database object where Sqitch's state and history data is +stored. Typically a schema name (as in PostgreSQL and Oracle) or a database +name (as in SQLite and MySQL). + +=item C<add> + +The act of adding a change to the plan. Sqitch will generate scripts for the +change, which you then may modify with the necessary code (typically DDLs) to +actually deploy, revert, and verify the change. + +=item C<deploy> + +The act of deploying changes to a database. Sqitch reads the plan, checks the +current state of the database, and applies all the changes necessary to either +bring the database up-to-date or to a requested state (a change name or tag). + +=item C<revert> + +The act of reverting database changes to reach an earlier deployment state. +Sqitch reads the list of deployed changes from the database and reverts +them in the reverse of the order in which they were applied. All changes +may be reverted, or changes may be reverted to a requested state (a change +name or tag). + +=item C<committer> + +User who commits or reverts changes to a database. + +=item C<planner> + +User who adds a change to the plan. + +=back + +=head1 Options + + -C --chdir --cd DIR Change to directory before performing any actions. + --etc-path Print path to etc directory and exit. + --no-pager Do not pipe output into a pager. + --quiet Quiet mode with non-error output suppressed. + -V --verbose Increment verbosity. + --version Print version number and exit. + --help Show a list of commands and exit. + --man Print introductory documentation and exit. + +=head1 Options Details + +=over + +=item C<--chdir> + +=item C<--cd> + +=item C<-C> + + sqitch --chdir dbproject + sqitch --cd /usr/local/somedb + sqitch -C dbcheckout + +Change to the specified directory before performing any actions. Effectively the +same as: + + (cd somedir && sqitch ...) + +But a bit friendlier when managing multiple projects. + +=item C<--etc-path> + + sqitch --etc-path + +Print out the path to the Sqitch F<etc> directory and exit. This is the +directory where the system-wide configuration file lives, as well as change +script templates. + +=item C<--no-pager> + + sqitch --no-pager + +Do not pipe Sqitch output into a pager. Currently limited to the C<log> and +C<plan> commands. + +=item C<--quiet> + + sqitch --quiet + +Suppress normal output messages. Error messages will still be emitted to +C<STDERR>. Overrides any value specified by C<--verbose>. + +=item C<-V> + +=item C<--verbose> + + sqitch --verbose + sqitch -VVV + +Pass multiple times to specify a value between 0 and 3 to determine how +verbose Sqitch should be. Unless C<--quiet> is specified, the default is 1, +meaning that Sqitch will output basic status messages as it does its thing. +Values of 2 and 3 each cause greater verbosity. Ignored if C<--quiet> is +specified. + +=item C<--help> + + sqitch --help + +Outputs a brief description all known Sqitch commands and exits. + +=item C<--man> + + sqitch --man + +Outputs this documentation and exits. + +=item C<--version> + + sqitch --version + +Outputs the program name and version and exits. + +=back + +=head1 Sqitch Commands + +=over + +=item L<C<init>|sqitch-init> + +Create the plan file and directories for deploy, revert, and verify scripts if +they do not already exist. This command is useful for starting a new Sqitch +project. + +=item L<C<status>|sqitch-status> + +Output information about the current deployment state of a database, including +the name of the last deployed change, as well as any tags applied to it. If +any changes in the plan have not been deployed, they will be listed +separately. + +=item L<C<log>|sqitch-log> + +Search and Output the complete change history of a database. Provides +information about when changes were deployed, reverted, or failed, as well as +who planned and committed the changes, and when. + +=item L<C<add>|sqitch-add> + +Add a new change. + +=item L<C<tag>|sqitch-tag> + +List tags or tag the latest change. + +=item L<C<rework>|sqitch-rework> + +Rework an existing change. + +=item L<C<target>|sqitch-target> + +Manage target databases. + +=item L<C<deploy>|sqitch-deploy> + +Deploy changes to a database + +=item L<C<revert>|sqitch-revert> + +Revert changes from a database. + +=item L<C<verify>|sqitch-verify> + +Verify changes deployed to a database. + +=item L<C<config>|sqitch-config> + +Get and set project, user, or system Sqitch options. + +=item L<C<bundle>|sqitch-bundle> + +Bundle a Sqitch project for distribution. This command copies the Sqitch +configuration, plan, and deploy, revert, and verify scripts to a directory, so +that it can be packaged up for distribution, such as in an RPM or tarball. + +=item L<C<help>|sqitch-help> + +Show help for a specific command or, if no command is specified, show the same +documentation as C<--help>. + +=back + +=head1 Configuration + +Sqitch configuration can be set up on a project, user, or system-wide basis. +The format of the configuration file, named F<sqitch.conf>, is the same as for +L<git>. + +Here's an example of a configuration file that might be useful checked into a +VCS for a project that deploys to PostgreSQL and stores its deployment scripts +with the extension F<ddl> under the C<migrations> directory. It also wants +bundle to be created in the F<_build/sql> directory, and to deploy starting +with the "gamma" tag: + + [core] + engine = pg + top_dir = migrations + extension = ddl + + [engine "pg"] + target = widgetopolis + + [revert] + to = gamma + + [bundle] + from = gamma + tags_only = yes + dest_dir = _build/sql + + [target "widgetopolis"] + uri = db:pg:widgetopolis + +And here's an example of useful configuration in F<~/.sqitch/sqitch.conf>, to +point to system-specific engine information: + + [user] + name = Marge N. O’Vera + email = marge@example.com + + [engine "pg"] + client = /usr/local/pgsql/bin/psql + + [engine "mysql"] + client = /usr/local/mysql/bin/mysql + + [engine "oracle"] + client = /usr/local/instantclient_11_2/sqlplus + + [engine "sqlite"] + client = /usr/local/bin/sqlite3 + +Various commands read from the configuration file and adjust their operation +accordingly. See L<sqitch-config> for a list. + +=head1 See Also + +The original design for Sqitch was sketched out in a number of blog posts: + +=over + +=item * + +L<Simple SQL Change Management|https://justatheory.com/computers/databases/simple-sql-change-management.html> + +=item * + +L<VCS-Enabled SQL Change Management|https://justatheory.com/computers/databases/vcs-sql-change-management.html> + +=item * + +L<SQL Change Management Sans Duplication|https://justatheory.com/computers/databases/sql-change-management-sans-redundancy.html> + +=back + +Other tools that do database change management include: + +=over + +=item L<Rails migrations|https://guides.rubyonrails.org/migrations.html> + +Numbered migrations for L<Ruby on Rails|https://rubyonrails.org/>. + +=item L<Module::Build::DB> + +Numbered changes in pure SQL, integrated with Perl's L<Module::Build> build +system. Does not support reversion. + +=item L<DBIx::Migration> + +Numbered migrations in pure SQL. + +=item L<Versioning|https://www.depesz.com/2010/08/22/versioning/> + +PostgreSQL-specific dependency-tracking solution by +L<depesz|https://www.depesz.com/>. + +=back + +=head1 Author + +David E. Wheeler <david@justatheory.com> + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/sqitchchanges.pod b/lib/sqitchchanges.pod new file mode 100644 index 00000000..55021ddd --- /dev/null +++ b/lib/sqitchchanges.pod @@ -0,0 +1,197 @@ +=encoding UTF-8 + +=head1 Name + +sqitchchanges - Specifying changes for Sqitch + +=head1 Description + +Many Sqitch commands take change parameters as arguments. Depending on the +command, they denote a specific change or, for commands which walk change +history or the change plan (such as L<C<sqitch log>|sqitch-log>), all changes +which can be reached from that change. Most commands search the plan for the +relevant change, though some, such as L<C<sqitch revert>|sqitch-revert> and +L<C<sqitch log>|sqitch-log>, search the database for the change. + +=head2 Change Names + +A change name, such as that passed to L<C<sqitch add>|sqitch-add> and written +to the plan file has a few limitations on the characters it may contain. The +same limitations apply to tag names. The rules are: + +=over + +=item * + +Must be at least one character. + +=item * + +Must contain no blank characters. + +=item * + +The first character may not be punctuation. + +=item * + +Last letter may not be punctuation. + +=item * + +Must not end in "~", "^", "/", "=", or "%" followed by digits. + +=item * + +All other characters may be any UTF-8 character other than ":", "@", and "#". + +=back + +Note that "_" (underscore) is never considered punctuation. Some examples of +valid names: + +=over + +=item C<foo> + +=item C<12> + +=item C<t> + +=item C<6> + +=item C<阱阪阬> + +=item C<阱阪阬92> + +=item C<foo/bar> + +=item C<beta1> + +=item C<foo_> + +=item C<_foo> + +=item C<v1.0-1b> + +=item C<v1.2-1> + +=item C<v1.2+1> + +=item C<v1.2_1> + +=back + +Some examples of invalid names: + +=over + +=item C<^foo> + +=item C<foo^> + +=item C<foo^6> + +=item C<foo^666> + +=item C<%hi> + +=item C<hi!> + +=item C<foo@bar> + +=item C<foo:bar> + +=item C<+foo> + +=item C<-foo> + +=item C<@foo> + +=back + +=head1 Specifying Changes + +A change parameter names a change object. It uses what is called an extended +SHA1 syntax. Here are various ways to spell change names: + +=over + +=item C<< <change_name> >>, e.g., C<users_table> + +The name of a change itself, as it was added to the plan via +L<C<sqitch add>|sqitch-add>. + +=item C<< @<tag_name> >>, e.g., C<@rc1> + +The change as of the named tag. Tags can be added to the plan via +L<C<sqitch tag>|sqitch-tag>. + +=item C<< <change_name>@<tag_name> >>, e.g., C<users_table@beta1> + +The named change as of a tag, also known as a tag-qualified change name. For +change iteration commands (such as L<C<sqitch log>|sqitch-log>), this means +the instance of a change with that name before the specified tag. For +dependency parameters (such as in L<C<sqitch add>|sqitch-add>), this means any +instance of a change just before that tag, or at any time after the tag. + +=item C<< <sha1> >>, e.g., C<40763784148fa190d75bad036730ef44d1c2eac6> + +The change full SHA1 ID (40-byte hexadecimal string). In some cases, such as +L<C<sqitch add>|sqitch-add>, the ID may refer to a change in another Sqitch +project. + +=item C<< <project>:<change_name> >>, e.g., C<mybase:users_table> + +The name of a change in a specific project. Non-SHA1 change parameters without +a project prefix are assumed to belong to the current project. Most useful for +declaring a dependency on a change from another project in +L<C<sqitch add>|sqitch-add>. + +=item C<< <project>:@<tag_name> >>, e.g., C<mybase:@rc1> + +The name of a tag in an the named project. + +=item C<< <project>:<change_name>@<tag_name> >>, e.g., C<project:users_table@beta1> + +A tag-qualified named change in the named project. + +=item C<< <project>:<sha1> >>, e.g., C<mybase:40763784148fa190d75bad036730ef44d1c2eac6> + +The full SHA1 ID from another project. Probably redundant, since the SHA1 I +should itself be sufficient. But useful for declaring dependencies in the +current project so that L<C<sqitch add>|sqitch-add> or +L<C<sqitch rework>|sqitch-rework> will validate that the specified change is in +the current project. + +=item C<@HEAD> + +=item C<HEAD> + +Special symbolic name for the last change in the plan. + +=item C<@ROOT> + +=item C<ROOT> + +Special symbolic name for the first change in the plan. + +=item C<< <change>^ >>, e.g., C<@HEAD^^>, C<@HEAD^3>, C<@beta^2> + +A suffix C<^> to a symbolic or actual name means the change I<prior> to that +change. Two C<^>s indicate the second prior change. Additional prior changes +can be specified as C<< ^<n> >>, where C<< <n> >> represents the number of +changes to go back. + +=item C<< <change>~ >>, e.g., C<@ROOT~>, C<@ROOT~~>, C<@bar~4> + +A suffix C<~> to a symbolic or actual name means the change I<after> that +change. Two C<~>s indicate the second following change. Additional following +changes can be specified as C<< ~<n> >>, where C<< <n> >> represents the +number of changes to go forward. + +=back + +=head1 Sqitch + +Part of the L<sqitch> suite. diff --git a/lib/sqitchcommands.pod b/lib/sqitchcommands.pod new file mode 100644 index 00000000..9fc1bb48 --- /dev/null +++ b/lib/sqitchcommands.pod @@ -0,0 +1,44 @@ +=begin private + +Keep private so it's not displayed, but will still be indexed by the CPAN +toolchain. + +=head1 Name + +sqitchcommands - List of common sqitch commands + +=end private + +=head1 Usage + + sqitch [--etc-path | --help | --man | --version] + sqitch <command> [--chdir <path>] [--no-pager] [--quiet] [--verbose] + [<command-options>] [<args>] + +=head1 Common Commands + +The most commonly used sqitch commands are: + + add Add a new change to the plan + bundle Bundle a Sqitch project for distribution + checkout Revert, checkout another VCS branch, and re-deploy changes + config Get and set local, user, or system options + deploy Deploy changes to a database + engine Manage database engine configuration + help Display help information about Sqitch commands + init Initialize a project + log Show change logs for a database + plan Show the contents of a plan + rebase Revert and redeploy database changes + revert Revert changes from a database + rework Duplicate a change in the plan and revise its scripts + show Show information about changes and tags, or change script contents + status Show the current deployment status of a database + tag Add or list tags in the plan + target Manage target database configuration + upgrade Upgrade the registry to the current version + verify Verify changes to a database + +See C<< sqitch help <command> >> or C<< sqitch help <concept> >> to read about +a specific command or concept. See C<< sqitch help --guide >> for a list of +conceptual guides. diff --git a/lib/sqitchguides.pod b/lib/sqitchguides.pod new file mode 100644 index 00000000..8b195388 --- /dev/null +++ b/lib/sqitchguides.pod @@ -0,0 +1,28 @@ +=begin private + +Keep private so it's not displayed, but will still be indexed by the CPAN +toolchain. + +=head1 Name + +sqitchguides - List of common Sqitch guides + +=end private + +The common Sqitch guides are: + + changes Specifying changes for Sqitch + configuration Hierarchical engine and target configuration + environment Environment variables + authentication Specifying target authentication credentials + tutorial PostgreSQL Tutorial + tutorial-firebird Firebird Tutorial + tutorial-mysql MySQL Tutorial + tutorial-oracle Oracle Tutorial + tutorial-sqlite SQLite Tutorial + tutorial-vertica Vertica Tutorial + tutorial-exasol Exasol Tutorial + tutorial-snowflake Snowflake Tutorial + +See C<< sqitch help <command> >> or C<< sqitch help <concept> >> to read about +a specific command or concept. diff --git a/lib/sqitchtutorial-exasol.pod b/lib/sqitchtutorial-exasol.pod new file mode 100644 index 00000000..e6672fe7 --- /dev/null +++ b/lib/sqitchtutorial-exasol.pod @@ -0,0 +1,1407 @@ +=encoding UTF-8 + +=head1 Name + +sqitchtutorial-exasol - A tutorial introduction to Sqitch change management on Exasol + +=head1 Synopsis + + sqitch * + +=head1 Description + +This tutorial explains how to create a sqitch-enabled Exasol project, use a +VCS for deployment planning, and work with other developers to make sure +changes remain in sync and in the proper order. + +We'll start by creating a new project from scratch, a fictional antisocial +networking site called Flipr. All examples use L<Git|https://git-scm.com/> as +the VCS and L<Exasol|https://www.exasol.com/> as the storage engine, but for +the most part you can substitute other VCSes and database engines in the +examples as appropriate. + +If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>. + +If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>. + +If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>. + +If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>. + +If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>. + +If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>. + +If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>. + +=head2 Connection Configuration + +Sqitch requires ODBC to connect to the Exasol database. As such, you'll need +to make sure that the Exasol ODBC driver is properly configured. At its +simplest, on Unix-like systems, name the driver "Exasol" by adding this entry +to C<odbcinst.ini> (usually found in C</etc>, C</usr/etc>, or +C</usr/local/etc>): + + [Exasol] + Description = ODBC for Exasol + Driver = /opt/EXASOL_ODBC-6.0.4/lib/linux/x86_64/libexaodbc-uo2214lv2.so + +Note that you'll need to adjust the path depending on the version of the ODBC +driver, and where you installed it. + +You might also consider naming your database connection by putting an entry in +C<~/.odbc.ini>, like so (assuming that Exasol is running on your local host): + + [flipr_test] + Driver = Exasol + EXAHOST = 127.0.0.1:8563 + EXAUID = sys + EXAPWD = exasol + +Putting user and password information here is optional, but probably safer than +other available options as long as the file is protected (mode 0600) so that +only you can read it. + +Normally, of course, you'd have a separate user per project (and a separate +ODBC connection defined); the above example is taken from the simplified +tutorial setup we're using. + +See the +L<Exasol downloads|https://www.exasol.com/portal/display/DOWNLOAD/6.0> for +ODBC drivers, documentation, etc. + +=head2 Database Setup + +While installing and configuring an Exasol instance is beyond the scope of this +tutorial, if you just want to follow along, you can start a Docker instance: + + > docker run --detach --privileged --stop-timeout 120 -p 127.0.0.1:8563:8888 exasol/docker-db:latest + +This will make Exasol available at 127.0.0.1 (localhost) on port 8563, as if +you were running it directly on your machine. You may have to wait for a minute +until the database finished initializing; you can try connecting with `exaplus` +to check status. + +See the corresponding L<GitHub repository|https://github.com/EXASOL/docker-db> +for more information about how to run Exasol using Docker, for example if you +want your data to persist beyond the lifetime of the container, etc. + +=head1 Starting a New Project + +Usually the first thing to do when starting a new project is to create a +source code repository. So let's do that with Git: + + > mkdir flipr + > cd flipr + > git init . + Initialized empty Git repository in /flipr/.git/ + > touch README.md + > git add . + > git commit -am 'Initialize project, add README.' + +If you're a Git user and want to follow along the history, the repository +used in these examples is +L<on GitHub|https://github.com/sqitchers/sqitch-exasol-intro>. + +Now that we have a repository, let's get started with Sqitch. Every Sqitch +project must have a name associated with it, and, optionally, a unique URI. We +recommend including the URI, as it increases the uniqueness of object +identifiers internally, and will prevent the deployment of a different project +with the same name. So let's specify one when we initialize Sqitch: + + > sqitch init flipr --uri https://github.com/sqitchers/sqitch-exasol-intro/ --engine exasol + Created sqitch.conf + Created sqitch.plan + Created deploy/ + Created revert/ + Created verify/ + +Let's have a look at F<sqitch.conf>: + + > cat sqitch.conf + [core] + engine = exasol + # plan_file = sqitch.plan + # top_dir = . + # [engine "exasol"] + # target = db:exasol: + # registry = sqitch + # client = exaplus + +Good, it picked up on the fact that we're creating changes for the Exasol +engine, thanks to the C<--engine exasol> option, and saved it to the +file. Furthermore, it wrote a commented-out C<[engine "exasol"]> section with +all the available Exasol engine-specific settings commented out and ready to +be edited as appropriate. + +By default, Sqitch will read F<sqitch.conf> in the current directory for +settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific +settings. Since Exasol's C<exaplus> client is not in the path on my system, +let's go ahead an tell it where to find the client on our computer: + + > sqitch config --user engine.exasol.client /opt/EXAplus/exaplus + +And let's also tell it who we are, since this data will be used in all +of our projects: + + > sqitch config --user user.name 'Marge N. O’Vera' + > sqitch config --user user.email 'marge@example.com' + +Have a look at F<~/.sqitch/sqitch.conf> and you'll see this: + + > cat ~/.sqitch/sqitch.conf + [engine "exasol"] + client = /opt/EXAplus/exaplus + [user] + name = Marge N. O’Vera + email = marge@example.com + +Which means that Sqitch should be able to find C<exaplus> for any project, and +that it will always properly identify us when planning and committing changes. + +Back to the repository. Have a look at the plan file, F<sqitch.plan>: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-exasol-intro/ + + +Note that it has picked up on the name and URI of the app we're building. +Sqitch uses this data to manage cross-project dependencies. The +C<%syntax-version> pragma is always set by Sqitch, so that it always knows how +to parse the plan, even if the format changes in the future. + +Let's commit these changes and start creating the database changes. + + > git add . + > git commit -am 'Initialize Sqitch configuration.' + [master a42564d] Initialize Sqitch configuration. + 2 files changed, 16 insertions(+), 0 deletions(-) + create mode 100644 sqitch.conf + create mode 100644 sqitch.plan + +=head1 Our First Change + +First, our project will need a schema. This creates a nice namespace for all +of the objects that will be part of the flipr app. Run this command: + + > sqitch add appschema -n 'Add schema for all flipr objects.' + Created deploy/appschema.sql + Created revert/appschema.sql + Created verify/appschema.sql + Added "appschema" to sqitch.plan + +The L<C<add>|sqitch-add> command adds a database change to the plan and writes +deploy, revert, and verify scripts that represent the change. Now we edit +these files. The C<deploy> script's job is to create the schema. So we add +this to F<deploy/appschema.sql>: + + CREATE SCHEMA flipr; + +The C<revert> script's job is to precisely revert the change to the deploy +script, so we add this to F<revert/appschema.sql>: + + DROP SCHEMA flipr; + +Now we can try deploying this change. We tell Sqitch where to send the change +via a L<database URI|https://github.com/libwww-perl/uri-db/>, assuming the default +C<sys> user and an ODBC driver named C<Exasol> (see +L</Connection Configuration> for details): + + > sqitch deploy 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + Adding registry tables to db:exasol://sys:@localhost:8563/?Driver=Exasol + Deploying changes to db:exasol://sys:@localhost:8563/?Driver=Exasol + + appschema .. ok + +First Sqitch created registry tables used to track database changes. The +structure and name of the registry varies between databases (Exasol uses a +schema to namespace its registry, while SQLite and MySQL use separate +databases). Next, Sqitch deploys changes. We only have one so far; the C<+> +reinforces the idea that the change is being C<added> to the database. + +With this change deployed, if you connect to the database, you'll be able to +see the schema: + + > exaplus -q -u sys -p exasol -c localhost:8563 -sql "select schema_name from exa_schemas;" + + SCHEMA_NAME + -------------------------------------------------------------------------------------------------------------------------------- + SQITCH + FLIPR + +=head2 Trust, But Verify + +But that's too much work. Do you really want to do something like that after +every deploy? + +Here's where the C<verify> script comes in. Its job is to test that the deploy +did was it was supposed to. It should do so without regard to any data that +might be in the database, and should throw an error if the deploy was not +successful. In Exasol, the simplest way to do so for schema is probably to +simply create an object in the schema. Put this SQL into +F<verify/appschema.sql>: + + CREATE TABLE flipr.verify__ (id int); + DROP TABLE flipr.verify__; + +In truth, you can use I<any> query that generates an SQL error if the schema +doesn't exist. Another handy way to do that is to divide by zero if an object +doesn't exist. For example, to throw an error when the C<flipr> schema does +not exist, you could do something like this: + + SELECT 1/COUNT(*) FROM exa_schemas WHERE schema_name = 'FLIPR'; + +Either way, run the C<verify> script with the L<C<verify>|sqitch-verify> +command: + + > sqitch verify 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + Verifying db:exasol://sys:@localhost:8563/?Driver=Exasol + * appschema .. ok + Verify successful + +Looks good! If you want to make sure that the verify script correctly dies if +the schema doesn't exist, temporarily change the schema name in the script to +something that doesn't exist, something like: + + CREATE TABLE nonesuch.verify__ (id int); + +Then L<C<verify>|sqitch-verify> again: + + > sqitch verify 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + Verifying db:exasol://sys:@localhost:8563/?Driver=Exasol + * appschema .. Error: [42000] schema NONESUCH not found [line 1, column 40] (Session: 1582884049218108749) + + # Verify script "verify/appschema.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +It's even nice enough to tell us what the problem is. Or, for the +divide-by-zero example, change the schema name: + + SELECT 1/COUNT(*) FROM exa_schemas WHERE schema_name = 'nonesuch'; + +Then the verify will look something like: + + > sqitch verify 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + Verifying db:exasol://sys:@localhost:8563/?Driver=Exasol + * appschema .. Error: [22012] data exception - division by zero (Session: 1582884446489810101) + + # Verify script "verify/appschema.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +Less useful error output, but enough to alert us that something has gone +wrong. + +Don't forget to change the schema name back before continuing! + +=head2 Status, Revert, Log, Repeat + +For purely informational purposes, we can always see how a deployment was +recorded via the L<C<status>|sqitch-status> command, which reads the registry +tables from the database: + + > sqitch status 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + # On database db:exasol://sys:@localhost:8563/?Driver=Exasol + # Project: flipr + # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764 + # Name: appschema + # Deployed: 2014-09-04 15:26:28 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Let's make sure that we can revert the change: + + > sqitch revert 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + Revert all changes from db:exasol://sys:@localhost:8563/?Driver=Exasol? [Yes] + - appschema .. ok + +The L<C<revert>|sqitch-revert> command first prompts to make sure that we +really do want to revert. This is to prevent unnecessary accidents. You can +pass the C<-y> option to disable the prompt. Also, notice the C<-> before the +change name in the output, which reinforces that the change is being +I<removed> from the database. And now the schema should be gone: + +> exaplus -q -u sys -p exasol -c localhost:8563 -sql "select schema_name from exa_schemas;" + + SCHEMA_NAME + -------------------------------------------------------------------------------------------------------------------------------- + SQITCH + +And the status message should reflect as much: + + > sqitch status 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + # On database db:exasol://sys:@localhost:8563/?Driver=Exasol + No changes deployed + +Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command +has nothing to verify: + + > sqitch verify 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + Verifying db:exasol://sys:@localhost:8563/?Driver=Exasol + No changes deployed + +However, we still have a record that the change happened, visible via the +L<C<log>|sqitch-log> command: + + > sqitch log 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + On database db:exasol://sys:@localhost:8563/?Driver=Exasol + Revert f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764 + Name: appschema + Committer: Marge N. O’Vera <marge@example.com> + Date: 2014-09-04 16:33:02 -0700 + + Add schema for all flipr objects. + + Deploy f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764 + Name: appschema + Committer: Marge N. O’Vera <marge@example.com> + Date: 2014-09-04 15:26:28 -0700 + + Add schema for all flipr objects. + +Note that the actions we took are shown in reverse chronological order, with +the revert first and then the deploy. + +Cool. Now let's commit it. + + > git add . + > git commit -m 'Add flipr schema.' + [master 9bee4bd] Add flipr schema. + 5 files changed, 197 insertions(+), 0 deletions(-) + create mode 100644 deploy/appschema.sql + create mode 100644 revert/appschema.sql + create mode 100644 sqitch.sql + create mode 100644 verify/appschema.sql + +And then deploy again. This time, let's use the C<--verify> option, so that +the C<verify> script is applied when the change is deployed: + + > sqitch deploy --verify 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + Deploying changes to db:exasol://sys:@localhost:8563/?Driver=Exasol + + appschema .. ok + +And now the schema should be back: + + > exaplus -q -u sys -p exasol -c localhost:8563 -sql "select schema_name from exa_schemas;" + + SCHEMA_NAME + -------------------------------------------------------------------------------------------------------------------------------- + SQITCH + FLIPR + +When we look at the status, the deployment will be there: + + > sqitch status 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + # On database db:exasol://sys:@localhost:8563/?Driver=Exasol + # Project: flipr + # Change: fef4c2911ae68aee8f6ea164293a32923dc13b67 + # Name: appschema + # Deployed: 2014-09-04 16:37:38 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 On Target + +I'm getting a little tired of always having to type +C<db:exasol://sys:exasol@localhost:8563/?Driver=Exasol>, aren't +you? This L<database connection URI|https://github.com/libwww-perl/uri-db/> tells +Sqitch how to connect to the deployment target, but we don't have to keep +using the URI. We can name the target: + + > sqitch target add flipr_test 'db:exasol://sys:exasol@localhost:8563/?Driver=Exasol' + +The L<C<target>|sqitch-target> command, inspired by +L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one +or more named deployment targets. We've just added a target named +C<flipr_test>, which means we can use the string C<flipr_test> for the target, +rather than the URI. But since we're doing so much testing, we can also tell +Sqitch to deploy to the C<flipr_test> target by default: + + > sqitch engine add exasol flipr_test + +Now we can omit the target argument altogether, unless we need to deploy to +another database. Which we will, eventually, but at least our examples will be +simpler from here on in, e.g.: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764 + # Name: appschema + # Deployed: 2014-09-04 16:37:38 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Yay, that allows things to be a little more concise. Let's also make sure that +changes are verified after deploying them: + + > sqitch config --bool deploy.verify true + > sqitch config --bool rebase.verify true + +We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime, +let's commit the new configuration and and make some more changes! + + > git commit -am 'Set default deployment target and always verify.' + [master 469779a] Set default deployment target and always verify. + 1 files changed, 8 insertions(+), 0 deletions(-) + +=head1 Deploy with Dependency + +Let's add another change, this time to create a table. Our app will need +users, of course, so we'll create a table for them. First, add the new change: + + > sqitch add users --requires appschema -n 'Creates table to track our users.' + Created deploy/users.sql + Created revert/users.sql + Created verify/users.sql + Added "users [appschema]" to sqitch.plan + +Note that we're requiring the C<appschema> change as a dependency of the new +C<users> change. Although that change has already been added to the plan and +therefore should always be applied before the C<users> change, it's a good +idea to be explicit about dependencies. + +Now edit the scripts. When you're done, F<deploy/users.sql> should look like +this: + + -- Deploy flipr:users to exasol + -- requires: appschema + + CREATE TABLE flipr.users ( + nickname VARCHAR(64) PRIMARY KEY, + password VARCHAR(256) NOT NULL, + fullname VARCHAR(256) NOT NULL, + twitter VARCHAR(256) NOT NULL, + ts TIMESTAMP WITH LOCAL TIME ZONE DEFAULT NOW() NOT NULL + ); + + COMMIT; + +A few things to notice here. On the second line, the dependence on the +C<appschema> change has been listed. This doesn't do anything, but the default +C<deploy> Exasol template lists it here for your reference while editing +the file. Useful, right? + +The table itself will be created in the C<flipr> schema. This is why we need +to require the C<appschema> change. + +Now for the verify script. The simplest way to check that the table was +created and has the expected columns without touching the data? Just select +from the table with a false C<WHERE> clause. Add this to F<verify/users.sql>: + + SELECT nickname, password, fullname, twitter, ts + FROM flipr.users + WHERE FALSE; + +Now for the revert script: all we have to do is drop the table. Add this to +F<revert/users.sql>: + + DROP TABLE flipr.users; + +Couldn't be much simpler, right? Let's deploy this bad boy: + + > sqitch deploy + Deploying changes to flipr_test + + users .. ok + +We know, since verification is enabled, that the table must have been created. +But for the purposes of visibility, let's have a quick look: + + > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.users;" + + COLUMN_NAME SQL_TYPE NULLABLE DISTRIBUTION_KEY + -------------------------------------------------------------------------------------------------------------------------------- ---------------------------------------- -------- ---------------- + NICKNAME VARCHAR(64) UTF8 FALSE FALSE + PASSWORD VARCHAR(256) UTF8 FALSE FALSE + FULLNAME VARCHAR(256) UTF8 FALSE FALSE + TWITTER VARCHAR(256) UTF8 FALSE FALSE + TS TIMESTAMP WITH LOCAL TIME ZONE FALSE FALSE + +We can also verify all currently deployed changes with the +L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appschema .. ok + * users ...... ok + Verify successful + +Now have a look at the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 794a6c78816543909d592e2e9f5c0fade5b47406 + # Name: users + # Deployed: 2017-11-02 11:02:40 +0100 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Success! Let's make sure we can revert the change, as well: + + > sqitch revert --to @HEAD^ -y + Reverting changes to appschema from flipr_test + - users .. ok + +Note that we've used the C<--to> option to specify the change to revert to. +And what do we revert to? The symbolic tag C<@HEAD>, when passed to +L<C<revert>|sqitch-revert>, always refers to the last change deployed to the +database. (For other commands, it refers to the last change in the plan.) +Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the +last deployed change. So we revert to C<appschema>, the penultimate change. +The other potentially useful symbolic tag is C<@ROOT>, which refers to the +first change deployed to the database (or in the plan, depending on the +command). + +Back to the database. The C<users> table should be gone but the C<flipr> schema +should still be around: + + > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.users;" + Error: [42000] table or view FLIPR.USERS not found [line 1, column 10] (Session: 1582958508294847446) + +The L<C<status>|sqitch-status> command politely informs us that we have +undeployed changes: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764 + # Name: appschema + # Deployed: 2014-09-04 16:37:38 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Undeployed change: + * users + +As does the L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appschema .. ok + Undeployed change: + * users + Verify successful + +Note that the verify is successful, because all currently-deployed changes are +verified. The list of undeployed changes (just "users" here) reminds us about +the current state. + +Okay, let's commit and deploy again: + + > git add . + > git commit -am 'Add users table.' + [master c7c24c5] Add users table. + 4 files changed, 18 insertions(+), 0 deletions(-) + create mode 100644 deploy/users.sql + create mode 100644 revert/users.sql + create mode 100644 verify/users.sql + > sqitch deploy + Deploying changes to flipr_test + + users .. ok + +Looks good. Check the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: d647ac8c130a7e0b12c9049789e46afb4a4f6e53 + # Name: users + # Deployed: 2014-09-04 17:42:53 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Excellent. Let's do some more! + +=head1 Add Two at Once + +Let's add a couple more changes. Our app will need to store status messages +from users. Let's call them -- and the table to store them -- "flips". And +we'll also need a view that lists user names with their flips. Let's add +changes for them both: + + > sqitch add flips -r appschema -r users -n 'Adds table for storing flips.' + Created deploy/flips.sql + Created revert/flips.sql + Created verify/flips.sql + Added "flips [appschema users]" to sqitch.plan + + > sqitch add userflips -r appschema -r users -r flips \ + -n 'Creates the userflips view.' + Created deploy/userflips.sql + Created revert/userflips.sql + Created verify/userflips.sql + Added "userflips [appschema users flips]" to sqitch.plan + +Now might be a good time to have a look at the deployment plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-exasol-intro/ + + appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + +Each change appears on a single line with the name of the change, a bracketed +list of dependencies, a timestamp, the name and email address of the user who +planned the change, and a note. + +Let's write the code for the new changes. Here's what F<deploy/flips.sql> +should look like: + + -- Deploy flipr:flips to exasol + -- requires: appschema + -- requires: users + + CREATE TABLE flipr.flips ( + id INTEGER IDENTITY PRIMARY KEY, + nickname VARCHAR(64) NOT NULL REFERENCES flipr.users(nickname), + body VARCHAR(180) DEFAULT '' NOT NULL, + ts TIMESTAMP WITH LOCAL TIME ZONE DEFAULT NOW() NOT NULL + ); + + COMMIT; + +Here's what F<verify/flips.sql> might look like: + + -- Verify flipr:flips on exasol + + SELECT id, nickname, body, ts + FROM flipr.flips + WHERE FALSE; + + ROLLBACK; + +And F<revert/flips.sql> should look something like this: + + -- Revert flipr:flips from exasol + + DROP TABLE flipr.flips; + + COMMIT; + +Now for C<userflips>; F<deploy/userflips.sql> might look like this: + + -- Deploy flipr:userflips to exasol + -- requires: appschema + -- requires: users + -- requires: flips + + CREATE OR REPLACE VIEW flipr.userflips AS + SELECT f.id, u.nickname, u.fullname, f.body, f.ts + FROM flipr.users u + JOIN flipr.flips f ON u.nickname = f.nickname; + + COMMIT; + +Use a C<SELECT> statement in F<verify/userflips.sql> again: + + -- Verify flipr:userflips on exasol + + SELECT id, nickname, fullname, body, ts + FROM flipr.userflips + WHERE FALSE; + + ROLLBACK; + +And of course, its C<revert> script, F<revert/userflips.sql>, should look +something like: + + -- Revert flipr:userflips from exasol + + DROP VIEW flipr.userflips; + + COMMIT; + +Try em out! + + > sqitch deploy + Deploying changes to flipr_test + + flips ...... ok + + userflips .. ok + +Do we have the new table and view? Of course we do, they were verified. Still, +have a look: + + > exaplus -q -u sys -p exasol -c localhost:8563 -sql "select table_name from exa_all_tables where table_schema = 'FLIPR';" + + TABLE_NAME + -------------------------------------------------------------------------------------------------------------------------------- + USERS + FLIPS + + > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.userflips;" + + COLUMN_NAME SQL_TYPE NULLABLE DISTRIBUTION_KEY + -------------------------------------------------------------------------------------------------------------------------------- ---------------------------------------- -------- ---------------- + ID DECIMAL(18,0) + NICKNAME VARCHAR(64) UTF8 + FULLNAME VARCHAR(256) UTF8 + BODY VARCHAR(180) UTF8 + TS TIMESTAMP WITH LOCAL TIME ZONE + +And what's the status? + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: d1f998618fb863d93049a724fd0d2b49a29add86 + # Name: userflips + # Deployed: 2014-09-04 17:51:21 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Looks good. Let's make sure revert works: + + > sqitch revert -y --to @HEAD^^ + Reverting changes to users from flipr_test + - userflips .. ok + - flips ...... ok + > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.flips;" + Error: [42000] table or view FLIPR.FLIPS not found [line 1, column 10] (Session: 1582955242708359302) + > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.userflips;" + Error: [42000] table or view FLIPR.USERFLIPS not found [line 1, column 10] (Session: 1582955248116468907) + +Note the use of C<@HEAD^^> to specify that the revert be to two changes prior +the last deployed change. Looks good. Let's do the commit and re-deploy dance: + + > git add . + > git commit -m 'Add flips table and userflips view.' + [master c40f23f] Add flips table and userflips view. + 7 files changed, 41 insertions(+), 0 deletions(-) + create mode 100644 deploy/flips.sql + create mode 100644 deploy/userflips.sql + create mode 100644 revert/flips.sql + create mode 100644 revert/userflips.sql + create mode 100644 verify/flips.sql + create mode 100644 verify/userflips.sql + + > sqitch deploy + Deploying changes to flipr_test + + flips ...... ok + + userflips .. ok + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: d1f998618fb863d93049a724fd0d2b49a29add86 + # Name: userflips + # Deployed: 2014-09-04 17:59:34 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + + > sqitch verify + Verifying flipr_test + * appschema .. ok + * users ...... ok + * flips ...... ok + * userflips .. ok + Verify successful + +Great, we're fully up-to-date! + +=head1 Ship It! + +Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want +to have it go out with deployments tied to the release, let's tag it: + + > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.' + Tagged "userflips" with @v1.0.0-dev1 + > git commit -am 'Tag the database with v1.0.0-dev1.' + [master b07ce3d] Tag the database with v1.0.0-dev1. + 1 files changed, 1 insertions(+), 0 deletions(-) + > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1' + +We can try deploying to make sure the tag gets picked up like so: + + > sqitch deploy + Nothing to deploy (up-to-date) + > sqitch status + # On database flipr_test + # Project: flipr + # Change: d1f998618fb863d93049a724fd0d2b49a29add86 + # Name: userflips + # Tag: @v1.0.0-dev1 + # Deployed: 2014-09-04 17:59:34 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the new "Tag" line in the output of C<sqitch status>: no new changes +needed to be deployed, but Sqitch did deploy the tag on the C<userflips> +change. Now let's bundle everything up for release: + + > sqitch bundle + Bundling into bundle + Writing config + Writing plan + Writing scripts + + appschema + + users + + flips + + userflips @v1.0.0-dev1 + +Now we can package the F<bundle> directory and distribute it. When it gets +installed somewhere, users can use Sqitch to deploy to the database. Let's try +deploying it to another database. First, start up a new Docker instance on a +different port: + + > docker run --detach --privileged --stop-timeout 120 -p 127.0.0.1:9999:8888 exasol/docker-db:latest + +Now you should be able to deploy to the new database: + + > cd bundle + > sqitch deploy 'db:exasol://sys:exasol@localhost:9999/?Driver=Exasol' + Adding registry tables to db:exasol://sys:@localhost:9999/?Driver=Exasol + Deploying changes to db:exasol://sys:@localhost:9999/?Driver=Exasol + + appschema ............... ok + + users ................... ok + + flips ................... ok + + userflips @v1.0.0-dev1 .. ok + +Notice how the tag on C<userflips> now appears in the deploy output. Nice, eh? +Now, package it up and ship it! + + > cd .. + > mv bundle flipr-v1.0.0-dev1 + > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1 + +=head1 Making a Hash of Things + +Now that we've got the basics of the app done, let's add a feature. Gotta +track the hashtags associated with flips, right? Let's add a table for them. +But since other folks are working on other tasks in the repository, we'll work +on a branch, so we can all stay out of each other's way. So let's branch: + + > git checkout -b hashtags + Switched to a new branch 'hashtags' + +Now we can add a new change to create a table for hashtags. + + > sqitch add hashtags --requires flips -n 'Adds table for storing hashtags.' + Created deploy/hashtags.sql + Created revert/hashtags.sql + Created verify/hashtags.sql + Added "hashtags [appschema flips]" to sqitch.plan + +You know the drill by now. Add this to F<deploy/hashtags.sql> + + CREATE TABLE flipr.hashtags ( + flip_id INTEGER NOT NULL REFERENCES flipr.flips(id), + hashtag VARCHAR(128) NOT NULL, + PRIMARY KEY (flip_id, hashtag) + ); + +Again, select from the table in F<verify/hashtags.sql>: + + SELECT flip_id, hashtag FROM flipr.hashtags WHERE FALSE; + +And drop it in F<revert/hashtags.sql> + + DROP TABLE flipr.hashtags; + +And give it a whirl: + + > sqitch deploy + Deploying changes to flipr_test + + hashtags .. ok + +Look good? + + > sqitch status --show-tags + # On database flipr_test + # Project: flipr + # Change: fda6daef73e0ac12252bf6af5f259ccb207d4197 + # Name: hashtags + # Deployed: 2014-09-05 10:46:20 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + # Tag: + # @v1.0.0-dev1 - 2014-09-05 09:09:38 -0700 - Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the use of C<--show-tags> to show all the deployed tags. Make sure we can +revert, too: + + > sqitch rebase -y --onto @HEAD^ + Reverting changes to userflips @v1.0.0-dev1 from flipr_test + - hashtags .. ok + > sqitch deploy + Deploying changes to flipr_test + + hashtags .. ok + +Great! Now make it so: + + > git add . + > git commit -m 'Add hashtags table.' + [hashtags d893e9c] Add hashtags table. + 4 files changed, 18 insertions(+), 0 deletions(-) + create mode 100644 deploy/hashtags.sql + create mode 100644 revert/hashtags.sql + create mode 100644 verify/hashtags.sql + +Good, we've finished this feature. Time to merge back into C<master>. + +=head2 Emergency + +Let's do it: + + > git checkout master + Switched to branch 'master' + > git pull + Updating b07ce3d..05d3e5d + Fast-forward + deploy/lists.sql | 10 ++++++++++ + revert/lists.sql | 3 +++ + sqitch.plan | 2 ++ + verify/lists.sql | 5 +++++ + 4 files changed, 20 insertions(+), 0 deletions(-) + create mode 100644 deploy/lists.sql + create mode 100644 revert/lists.sql + create mode 100644 verify/lists.sql + +Hrm, that's interesting. Looks like someone made some changes to C<master>. +They added list support. Well, let's see what happens when we merge our +changes. + + > git merge --no-ff hashtags + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Automatic merge failed; fix conflicts and then commit the result. + +Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged +C<lists> branch and our C<hashtags> branch added changes to the plan. Let's +try a different approach. + +The truth is, we got lazy. Those changes when we pulled master from the origin +should have raised a red flag. It's considered a bad practice not to look at +what's changed in C<master> before merging in a branch. What one I<should> do +is either: + +=over + +=item * + +Rebase the F<hashtags> branch from master before merging. This "rewinds" the +branch changes, pulls from C<master>, and then replays the changes back on top +of the pulled changes. + +=item * + +Create a patch and apply I<that> to master. This is the sort of thing you +might have to do if you're sending changes to another user, especially if the +VCS is not Git. + +=back + +So let's restore things to how they were at master: + + > git reset --hard HEAD + HEAD is now at 05d3e5d Merge branch 'lists' + +That throws out our botched merge. Now let's go back to our branch and rebase +it on C<master>: + + > git checkout hashtags + Switched to branch 'hashtags' + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add hashtags table. + Using index info to reconstruct a base tree... + <stdin>:16: new blank line at EOF. + + + warning: 1 line adds whitespace errors. + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Failed to merge in the changes. + Patch failed at 0001 Add hashtags table. + + When you have resolved this problem run "git rebase --continue". + If you would prefer to skip this patch, instead run "git rebase --skip". + To restore the original branch and stop rebasing run "git rebase --abort". + +Oy, that's kind of a pain. It seems like no matter what we do, we'll need to +resolve conflicts in that file. Except in Git. Fortunately for us, we can tell +Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever +append lines to the file, we can have it use the "union" merge driver, which, +according to +L<its docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>: + +=over + +Run 3-way file level merge for text files, but take lines from both versions, +instead of leaving conflict markers. This tends to leave the added lines in +the resulting file in random order and the user should verify the result. Do +not use this if you do not understand the implications. + +=back + +This has the effect of appending lines from all the merging files, which is +exactly what we need. So let's give it a try. First, back out the botched +rebase: + + > git rebase --abort + HEAD is now at d893e9c Add hashtags table. + +Now add the union merge driver to F<.gitattributes> for F<sqitch.plan> +and rebase again: + + > echo sqitch.plan merge=union > .gitattributes + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add hashtags table. + Using index info to reconstruct a base tree... + <stdin>:16: new blank line at EOF. + + + warning: 1 line adds whitespace errors. + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + +Ah, that looks a bit better. Let's have a look at the plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-exasol-intro/ + + appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + @v1.0.0-dev1 2014-09-05T16:04:48Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appschema users] 2014-09-05T17:33:43Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + hashtags [appschema flips] 2014-09-05T17:39:53Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags. + +Note that it has appended the changes from the merged "lists" branch, and then +merged the changes from our "hashtags" branch. Test it to make sure it works +as expected: + + > sqitch rebase -y + Reverting all changes from flipr_test + - hashtags ................ ok + - userflips @v1.0.0-dev1 .. ok + - flips ................... ok + - users ................... ok + - appschema ............... ok + Deploying changes to flipr_test + + appschema ............... ok + + users ................... ok + + flips ................... ok + + userflips @v1.0.0-dev1 .. ok + + lists ................... ok + + hashtags ................ ok + +Note the use of L<C<rebase>|sqitch-rebase>, which combines a +L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single +command. Handy, right? It correctly reverted our changes, and then deployed +them all again in the proper order. So let's commit F<.gitattributes>; seems +worthwhile to keep that change: + + > git add . + > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.' + [hashtags 2f065a3] Add `.gitattributes` with union merge for `sqitch.plan`. + 1 files changed, 1 insertions(+), 0 deletions(-) + create mode 100644 .gitattributes + +=head2 Merges Mastered + +And now, finally, we can merge into C<master>: + + > git checkout master + Switched to branch 'master' + > git merge --no-ff hashtags -m "Merge branch 'hashtags'" + Merge made by recursive. + .gitattributes | 1 + + deploy/hashtags.sql | 10 ++++++++++ + revert/hashtags.sql | 3 +++ + sqitch.plan | 1 + + verify/hashtags.sql | 3 +++ + 5 files changed, 18 insertions(+), 0 deletions(-) + create mode 100644 .gitattributes + create mode 100644 deploy/hashtags.sql + create mode 100644 revert/hashtags.sql + create mode 100644 verify/hashtags.sql + +And double-check our work: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-exasol-intro/ + + appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + @v1.0.0-dev1 2014-09-05T16:04:48Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appschema users] 2014-09-05T17:33:43Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + hashtags [appschema flips] 2014-09-05T17:39:53Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags. + +Much much better, a nice clean master now. And because it is now identical to +the "hashtags" branch, we can just carry on. Go ahead and tag it, bundle, and +release: + + > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.' + Tagged "hashtags" with @v1.0.0-dev2 + > git commit -am 'Tag the database with v1.0.0-dev2.' + [master 8a6a73b] Tag the database with v1.0.0-dev2. + 1 files changed, 1 insertions(+), 0 deletions(-) + > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2' + > sqitch bundle --dest-dir flipr-1.0.0-dev2 + Bundling into flipr-1.0.0-dev2 + Writing config + Writing plan + Writing scripts + + appschema + + users + + flips + + userflips @v1.0.0-dev1 + + lists + + hashtags @v1.0.0-dev2 + +Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way +to create the top-level directory name so we don't have to rename it from +F<bundle>. + +=head1 In Place Changes + +Well, some folks have been testing the C<1.0.0-dev2> release and have demanded +that Twitter user links be added to Flipr pages. Why anyone would want to +include social network links in an anti-social networking app is beyond us +programmers, but we're just the plumbers, right? Gotta go with what Product +demands. The upshot is that we need to update the C<userflips> view, which is +used for the feature in question, to include the Twitter user names. + +Normally, modifying views in database changes is a +L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make +changes like these: + +=over + +=item 1. + +Copy F<deploy/userflips.sql> to F<deploy/userflips_twitter.sql>. + +=item 2. + +Edit F<deploy/userflips_twitter.sql> to drop and re-create the view with the +C<twitter> column to the view. + +=item 3. + +Copy F<deploy/userflips.sql> to F<revert/userflips_twitter.sql>. +Yes, copy the original change script to the new revert change. + +=item 4. + +Add a C<DROP VIEW> statement to F<revert/userflips_twitter.sql>. + +=item 5. + +Copy F<verify/userflips.sql> to F<verify/userflips_twitter.sql>. + +=item 6. + +Modify F<verify/userflips_twitter.sql> to include a check for the C<twiter> +column. + +=item 7. + +Test the changes to make sure you can deploy and revert the +C<userflips_twitter> change. + +=back + +But you can have Sqitch do most of the work for you. The only requirement is +that a tag appear between the two instances of a change we want to modify. In +general, you're going to make a change like this after a release, which you've +tagged anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous +section. With that, we can let Sqitch do most of the hard work for us, thanks +to the L<C<rework>|sqitch-rework> command, which is similar to +L<C<add>|sqitch-add>: + + > sqitch rework userflips -n 'Adds userflips.twitter.' + Added "userflips [userflips@v1.0.0-dev2]" to sqitch.plan. + Modify these files as appropriate: + * deploy/userflips.sql + * revert/userflips.sql + * verify/userflips.sql + +Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in +point of fact, it has copied the files to stand in for the previous instance +of the C<userflips> change, which we can see via C<git status>: + + > git status + # On branch master + # Changed but not updated: + # (use "git add <file>..." to update what will be committed) + # (use "git checkout -- <file>..." to discard changes in working directory) + # + # modified: revert/userflips.sql + # modified: sqitch.plan + # + # Untracked files: + # (use "git add <file>..." to include in what will be committed) + # + # deploy/userflips@v1.0.0-dev2.sql + # revert/userflips@v1.0.0-dev2.sql + # verify/userflips@v1.0.0-dev2.sql + no changes added to commit (use "git add" and/or "git commit -a") + +The "untracked files" part of the output is the first thing to notice. They're +all named C<userflips@v1.0.0-dev2.sql>. What that means is: "the C<userflips> +change as it was implemented as of the C<@v1.0.0-dev2> tag." These are copies +of the original scripts, and thereafter Sqitch will find them when it needs to +run scripts for the first instance of the C<userflips> change. As such, it's +important not to change them again. But hey, if you're reworking the change, +you shouldn't need to. + +The other thing to notice is that F<revert/userflips.sql> has changed. Sqitch +replaced it with the original deploy script. As of now, +F<deploy/userflips.sql> and F<revert/userflips.sql> are identical. This is on +the assumption that the deploy script will be changed (we're reworking it, +remember?), and that the revert script should actually change things back to +how they were before. Of course, the original deploy script may not be +L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be +applied multiple times without changing the result beyond the initial +application. If it's not, you will likely need to modify it so that it +properly restores things to how they were after the original deploy script was +deployed. Or, more simply, it should revert changes back to how they were +as-of the deployment of F<deploy/userflips@v1.0.0-dev2.sql>. + +Fortunately, our view deploy scripts are already idempotent, thanks to the +use of the C<OR REPLACE> expression. No matter how many times a deployment +script is run, the end result will be the same instance of the view, with +no duplicates or errors. + +As a result, there is no need to explicitly add changes. So go ahead. Modify +the script to add the C<twitter> column to the view. Make this change to +F<deploy/userflips.sql>: + + @@ -4,6 +4,6 @@ + -- requires: flips + + CREATE OR REPLACE VIEW flipr.userflips AS + -SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp + +SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp + FROM flipr.users u + JOIN flipr.flips f ON u.nickname = f.nickname; + +Next, modify F<verify/userflips.sql> to check for the C<twitter> column. +Here's the diff: + + @@ -1,6 +1,6 @@ + -- Verify flipr:userflips on exasol + + -SELECT id, nickname, fullname, body, timestamp + +SELECT id, nickname, fullname, twitter, body, timestamp + FROM flipr.userflips + WHERE FALSE; + +Now try a deployment: + + > sqitch deploy + Deploying changes to flipr_test + + userflips .. ok + +So, are the changes deployed? + + > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.userflips;" + + COLUMN_NAME SQL_TYPE NULLABLE DISTRIBUTION_KEY + -------------------------------------------------------------------------------------------------------------------------------- ---------------------------------------- -------- ---------------- + ID DECIMAL(18,0) + NICKNAME VARCHAR(64) UTF8 + FULLNAME VARCHAR(256) UTF8 + TWITTER VARCHAR(256) UTF8 + BODY VARCHAR(180) UTF8 + TS TIMESTAMP WITH LOCAL TIME ZONE + +Awesome, the view now includes the C<twitter> column. But can we revert? + + > sqitch revert --to @HEAD^ -y + Reverting changes to hashtags @v1.0.0-dev2 from flipr_test + - userflips .. ok + +Did that work, is the C<twitter> column gone? + + > exaplus -q -u sys -p exasol -c localhost:8563 -sql "describe flipr.userflips;" + + COLUMN_NAME SQL_TYPE NULLABLE DISTRIBUTION_KEY + -------------------------------------------------------------------------------------------------------------------------------- ---------------------------------------- -------- ---------------- + ID DECIMAL(18,0) + NICKNAME VARCHAR(64) UTF8 + FULLNAME VARCHAR(256) UTF8 + BODY VARCHAR(180) UTF8 + TS TIMESTAMP WITH LOCAL TIME ZONE + +Yes, it works! Sqitch properly finds the original instances of these changes +in the new script files that include tags. + +Excellent. Let's go ahead and commit these changes: + + > git add . + > git commit -m 'Add the twitter column to the userflips view.' + [master 95d6dd0] Add the twitter column to the userflips view. + 7 files changed, 30 insertions(+), 4 deletions(-) + create mode 100644 deploy/userflips@v1.0.0-dev2.sql + create mode 100644 revert/userflips@v1.0.0-dev2.sql + create mode 100644 verify/userflips@v1.0.0-dev2.sql + +=head1 More to Come + +Sqitch is a work in progress. Better integration with version control systems +is planned to make managing idempotent reworkings even easier. Stay tuned. + +=head1 Author + +David E. Wheeler <david@justatheory.com> + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/sqitchtutorial-firebird.pod b/lib/sqitchtutorial-firebird.pod new file mode 100644 index 00000000..706bae26 --- /dev/null +++ b/lib/sqitchtutorial-firebird.pod @@ -0,0 +1,1264 @@ +=encoding UTF-8 + +=head1 Name + +sqitchtutorial-firebird - A tutorial introduction to Sqitch change management on Firebird + +=head1 Synopsis + + sqitch * + +=head1 Description + +This tutorial explains how to create a sqitch-enabled Firebird project, use a +VCS for deployment planning, and work with other developers to make sure +changes remain in sync and in the proper order. + +We'll start by creating new project from scratch, a fictional antisocial +networking site called Flipr. All examples use L<Git|https://git-scm.com/> as +the VCS and L<Firebird|https://www.firebirdsql.org/> as the storage engine. + +If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>. + +If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>. + +If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>. + +If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>. + +If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>. + +If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>. + +If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>. + +=head1 Starting a New Project + +Usually the first thing to do when starting a new project is to create a +source code repository. So let's do that with Git: + + > mkdir flipr + > cd flipr + > git init . + Initialized empty Git repository in /flipr/.git/ + > touch README.md + > git add . + > git commit -am 'Initialize project, add README.' + [master (root-commit) 761ffcd] Initialize project, add README. + 1 files changed, 39 insertions(+), 0 deletions(-) + create mode 100644 README.md + +If you're a Git user and want to follow along the history, the +repository used in these examples is +L<on GitHub|https://github.com/sqitchers/sqitch-firebird-intro>. + +Now that we have a repository, let's get started with Sqitch. Every Sqitch +project must have a name associated with it, and, optionally, a unique URI. We +recommend including the URI, as it increases the uniqueness of object +identifiers internally, and will prevent the deployment of a different project +with the same name. So let's specify one when we initialize Sqitch: + + > sqitch init flipr --uri https://github.com/sqitchers/sqitch-firebird-intro/ --engine firebird + Created sqitch.conf + Created sqitch.plan + Created deploy/ + Created revert/ + Created verify/ + +Let's have a look at F<sqitch.conf>: + + > cat sqitch.conf + [core] + engine = firebird + # plan_file = sqitch.plan + # top_dir = . + # [engine "firebird"] + # target = db:firebird: + # registry = sqitch + # client = isql-fb + +Good, it picked up on the fact that we're creating changes for the Firebird +engine, thanks to the C<--engine firebird> option, and saved it to the +file. Furthermore, it wrote a commented-out C<[engine "firebird"]> section +with all the available Firebird engine-specific settings commented out and +ready to be edited as appropriate. + +By default, Sqitch will read F<sqitch.conf> in the current directory for +settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific +settings. + +The current implementation of the engine will try to find Firebird's +L<C<firebird> client|https://www.firebirdsql.org/manual/isql-commands.html> +(implemented for GNU/Linux and Microsoft Windows). This might fail, +so we go ahead an tell it where to find the client on our computer, +for example on GNU/Linux with the standard location of the Firebird +installation the command is: + + > sqitch config --user engine.firebird.client /opt/firebird/bin/isql + +Note: On some GNU/Linux distributions the firebird client is renamed +to C<isql-fb>, for example in Debian and Fedora, or C<fbsql> in +Gentoo. + +And let's also tell it who we are, since this data will be used in all +of our projects: + + > sqitch config --user user.name 'Marge N. O’Vera' + > sqitch config --user user.email 'marge@example.com' + +Have a look at F<~/.sqitch/sqitch.conf> and you'll see something like +this: + + > cat ~/.sqitch/sqitch.conf + [engine "firebird"] + client = /opt/local/bin/isql + [user] + name = Marge N. O’Vera + email = marge@example.com + +Which means that Sqitch should be able to find C<isql> for any project, and +that it will always properly identify us when planning and committing changes. + +Back to the repository. Have a look at the plan file, F<sqitch.plan>: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-firebird-intro/ + + +Note that it has picked up on the name and URI of the app we're building. +Sqitch uses this data to manage cross-project dependencies. The +C<%syntax-version> pragma is always set by Sqitch, so that it always knows how +to parse the plan, even if the format changes in the future. + +Let's commit these changes and start creating the database changes. + + > git add . + > git commit -am 'Initialize Sqitch configuration.' + [master 2177ce4] Initialize Sqitch configuration. + 2 files changed, 19 insertions(+), 0 deletions(-) + create mode 100644 sqitch.conf + create mode 100644 sqitch.plan + +Let's create our flipr test database using C<isql>: + + > sudo -u firebird mkdir /tmp/flipr_test + > echo "CREATE DATABASE 'localhost:/tmp/flipr_test/flipr.fdb'; exit;" \ + | isql-fb -q -u SYSDBA -p masterkey + +=head1 Our First Change + +Let's create a table. Our app will need users, of course, so we'll create a +table for them. Run this command: + + > sqitch add users -n 'Creates table to track our users.' + Created deploy/users.sql + Created revert/users.sql + Created verify/users.sql + Added "users" to sqitch.plan + +The L<C<add>|sqitch-add> command adds a database change to the plan and writes +deploy, revert, and verify scripts that represent the change. Now we edit +these files. The C<deploy> script's job is to create the table. By default, +the F<deploy/users.sql> file looks like this: + + -- Deploy flipr:users to firebird + + -- XXX Add DDLs here. + + COMMIT; + +What we want to do is to replace the C<XXX> comment with the C<CREATE TABLE> +statement, like so: + + -- Deploy flipr:users to firebird + + CREATE TABLE users ( + nickname VARCHAR(50) PRIMARY KEY, + password VARCHAR(512) NOT NULL, + fullname VARCHAR(512) NOT NULL, + twitter VARCHAR(512) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL + ); + + COMMIT; + +The C<revert> script's job is to precisely revert the change to the deploy +script, so we edit this to F<revert/users.sql> to look like this: + + -- Revert flipr:users from firebird + + DROP TABLE users; + + COMMIT; + +Now we can try deploying this change. We tell Sqitch where to send the change +via a L<database URI|https://github.com/libwww-perl/uri-db/>. Here we've +specified a database file, F</tmp/flipr_test/flipr.fdb>: + + > sqitch deploy db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb + Adding registry tables to db:firebird://sysdba:@localhost//tmp/flipr_test/sqitch.fdb + Deploying changes to db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb + + users .. ok + +First Sqitch created the registry database and tables used to track database +changes. The registry is separate from the database to which the C<users> +change was deployed; by default, its name is C<sqitch.$suffix>, where +C<$suffix> is the same as the suffix on the target database, if any. It lives +in the same directory as the target database, which means that one registry +database is used for all the databases with the same suffix in a single +directory. In this case, we should end up with two databases: + +=over + +=item * F</tmp/flipr_test/sqitch.fdb> + +The Sqitch registry database. + +=item * F</tmp/flipr_test/flipr.fdb> + +The database Sqitch manages. + +=back + +Next, Sqitch deploys changes to the target database. We only have one change +so far; the C<+> reinforces the idea that the change is being I<added> to the +database. + +If you'd like it to have a different name for the registry database, use +C<sqitch engine add firebird $name> to configure it (or via the +L<C<target> command|sqitch-target>; more L<below|/On Target>). This will be +useful if you don't want to use the same registry database to manage multiple +databases, or if you do, but they live in different directories. + +Next, Sqitch deploys changes to the target database, which we specified on the +command-line. We only have one so far; the C<+> reinforces the idea that the +change is being I<added> to the database. + +With this change deployed, if you connect to the database, you'll be able to +see the C<users> table: + + > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW TABLES; quit;" \ + | isql-fb -q -u SYSDBA -p masterkey + USERS + +=head2 Trust, But Verify + +But that's too much work. do you really want to do something like that after +every deploy? + +Here's where the C<verify> script comes in. Its job is to test that the deploy +did was it was supposed to. It should do so without regard to any data that +might be in the database, and should throw an error if the deploy was not +successful. The easiest way to do that with a table is to simply C<SELECT> +from it. Put this query into F<verify/users.sql>: + + SELECT nickname, password, fullname, twitter + FROM users + WHERE 1=2; + +Now you can run the C<verify> script with the L<C<verify>|sqitch-verify> +command: + + > sqitch verify db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb + Verifying db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb + * users .. ok + Verify successful + +Looks good! If you want to make sure that the verify script correctly dies if +the table doesn't exist, temporarily change the table name in the script to +something that doesn't exist, something like: + + SELECT nickname, password, fullname, twitter, created_at + FROM users_nonesuch + WHERE 1=2; + +Then L<C<verify>|sqitch-verify> again: + + > sqitch verify db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb + Verifying db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb + * users .. Statement failed, SQLSTATE = 42S02 + Dynamic SQL Error + -SQL error code = -204 + -Table unknown + -USERS_NONESUCH + -At line 3, column 2 + At line 3 in file verify/users.sql + # Verify script "verify/users.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +Firebird is kind enough to tell us what the problem is. Don't forget to change +the table name back before continuing! + +=head2 Status, Revert, Log, Repeat + +For purely informational purposes, we can always see how a deployment was +recorded via the L<C<status>|sqitch-status> command, which reads the tables +from the registry database: + + > sqitch status db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb + # On database db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb + # Project: flipr + # Change: 2cde9cc8c19161e9837de57741502243b2ad380e + # Name: users + # Deployed: 2014-01-05 14:05:22 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Let's make sure that we can revert the change: + + > sqitch revert db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb + Revert all changes from db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb? [Yes] + - users .. ok + +The L<C<revert>|sqitch-revert> command first prompts to make sure that we +really do want to revert. This is to prevent unnecessary accidents. You can +pass the C<-y> option to disable the prompt. Also, notice the C<-> before the +change name in the output, which reinforces that the change is being +I<removed> from the database. And now the C<users> table should be gone: + + > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW TABLES; quit;" \ + | isql-fb -q -u SYSDBA -p masterkey + There are no tables in this database + +And the status message should reflect as much: + + > sqitch status db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb + # On database db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb + No changes deployed + +Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command +has nothing to verify: + + > sqitch verify db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb + Verifying db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb + No changes deployed + +However, we still have a record that the change happened, visible via the +L<C<log>|sqitch-log> command: + + > sqitch log db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb + On database db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb + Revert 2cde9cc8c19161e9837de57741502243b2ad380e + Name: users + Committer: Marge N. O’Vera <marge@example.com> + Date: 2014-01-05 14:06:59 -0800 + + Creates table to track our users. + + Deploy 2cde9cc8c19161e9837de57741502243b2ad380e + Name: users + Committer: Marge N. O’Vera <marge@example.com> + Date: 2014-01-05 14:05:22 -0800 + + Creates table to track our users. + + +Note that the actions we took are shown in reverse chronological order, with +the revert first and then the deploy. + +Cool. Now let's commit it. + + > git add . + > git commit -m 'Add users table.' + [master ec72105] Add users table. + 4 files changed, 24 insertions(+), 0 deletions(-) + create mode 100644 deploy/users.sql + create mode 100644 revert/users.sql + create mode 100644 verify/users.sql + +And then deploy again. This time, let's use the C<--verify> option, so that +the C<verify> script is applied when the change is deployed: + + > sqitch deploy db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb --verify + Deploying changes to db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb + + users .. ok + +And now the C<users> table should be back: + + > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW TABLES; quit;" \ + | isql-fb -q -u SYSDBA -p masterkey + USERS + +When we look at the status, the deployment will be there: + + > sqitch status db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb + # On database db:firebird://sysdba:@localhost//tmp/flipr_test/flipr.fdb + # Project: flipr + # Change: 2cde9cc8c19161e9837de57741502243b2ad380e + # Name: users + # Deployed: 2014-01-05 14:19:32 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 On Target + +I'm getting a little tired of always having to type +C<db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb>, aren't +you? This L<database connection URI|https://github.com/libwww-perl/uri-db/> tells +Sqitch how to connect to the deployment target, but we don't have to keep +using the URI. We can name the target: + + > sqitch target add flipr_test db:firebird://sysdba:masterkey@localhost//tmp/flipr_test/flipr.fdb + +The L<C<target>|sqitch-target> command, inspired by +L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one +or more named deployment targets. We've just added a target named +C<flipr_test>, which means we can use the string C<flipr_test> for the target, +rather than the URI. But since we're doing so much testing, we can also tell +Sqitch to deploy to the C<flipr_test> target by default: + + > sqitch engine add firebird target flipr_test + +Now we can omit the target argument altogether, unless we need to deploy to +another database. Which we will, eventually, but at least our examples will be +simpler from here on in, e.g.: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 2cde9cc8c19161e9837de57741502243b2ad380e + # Name: users + # Deployed: 2014-01-05 14:19:32 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Yay, that allows things to be a little more concise. Let's also make sure that +changes are verified after deploying them: + + > sqitch config --bool deploy.verify true + > sqitch config --bool rebase.verify true + +We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime, +let's commit the new configuration and and make some more changes! + + > git commit -am 'Set default target and always verify.' + [master cfc9fea] Set default target and always verify. + 1 files changed, 8 insertions(+), 0 deletions(-) + +=head1 Deploy with Dependency + +Let's add another change. Our app will need to store status messages from +users. Let's call them -- and the table to store them -- "flips". First, add +the new change: + + > sqitch add flips --requires users -n 'Adds table for storing flips.' + Created deploy/flips.sql + Created revert/flips.sql + Created verify/flips.sql + Added "flips [users]" to sqitch.plan + +Note that we're requiring the C<users> change as a dependency of the new +C<flips> change. Although that change has already been added to the plan and +therefore should always be applied before the C<flips> change, it's a good +idea to be explicit about dependencies. + +Now edit the scripts. When you're done, F<deploy/flips.sql> should look like +this: + + -- Deploy flipr:flips to firebird + -- requires: users + + CREATE TABLE flips ( + id INTEGER NOT NULL PRIMARY KEY, + nickname VARCHAR(50) NOT NULL REFERENCES users(nickname), + body VARCHAR(512) DEFAULT '' NOT NULL CHECK ( char_length(body) <= 180 ), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL + ); + + COMMMIT; + +A couple things to notice here. On the second line, the dependence on the +C<users> change has been listed. This doesn't do anything, but the default +C<deploy> template lists it here for your reference while editing the file. +Useful, right? + +The C<users.nickname> column references the C<users> table. This is why we +need to require the C<users> change. + +Now for the verify script. Again, all we need to do is C<SELECT> from the +table. I recommend selecting each column by name, too, to be sure that no +column is missing. Here's the F<verify/flips.sql>: + + -- Verify flipr:flips on firebird + + SELECT id, nickname, body, created_at + FROM flips + WHERE 1=2; + +Now for the revert script: all we have to do is drop the table. Add this to +F<revert/flips.sql>: + + -- Revert flipr:flips from firebird + + DROP TABLE flips; + + COMMIT; + +Couldn't be much simpler, right? Let's deploy this bad boy: + + > sqitch deploy + Deploying changes to flipr_test + + flips .. ok + +We know, since verification is enabled, that the table must have been created. +But for the purposes of visibility, let's have a quick look: + + > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW TABLES; quit;" \ + | isql-fb -q -u SYSDBA -p masterkey + FLIPS USERS + +We can also verify all currently deployed changes with the +L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test.db + * users .. ok + * flips .. ok + Verify successful + +Now have a look at the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: dfe72351c686bd36017a2b586042b5336301e809 + # Name: flips + # Deployed: 2014-01-05 14:22:33 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Success! Let's make sure we can revert the change, as well: + + > sqitch revert --to @HEAD^ -y + Reverting changes to users from flipr_test + - flips .. ok + +Note that we've used the C<--to> option to specify the change to revert to. +And what do we revert to? The symbolic tag C<@HEAD>, when passed to +L<C<revert>|sqitch-revert>, always refers to the last change deployed to the +database. (For other commands, it refers to the last change in the plan.) +Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the +last deployed change. So we revert to C<users>, the penultimate change. The +other potentially useful symbolic tag is C<@ROOT>, which refers to the first +change deployed to the database (or in the plan, depending on the command). + +Back to the database. The C<flips> table should be gone but the +C<users> table should still be around: + + > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW TABLES; quit;" \ + | isql-fb -q -u SYSDBA -p masterkey + USERS + +The L<C<status>|sqitch-status> command politely informs us that we have +undeployed changes: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 2cde9cc8c19161e9837de57741502243b2ad380e + # Name: users + # Deployed: 2014-01-05 14:19:32 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Undeployed change: + * flips + +As does the L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * users .. ok + Undeployed change: + * flips + Verify successful + +Note that the verify is successful, because all currently-deployed changes are +verified. The list of undeployed changes (just "flips" here) reminds us about +the current state. + +Okay, let's commit and deploy again: + + > git add . + > git commit -am 'Add flips table.' + [master 09c636c] Add flips table. + 4 files changed, 24 insertions(+), 0 deletions(-) + create mode 100644 deploy/flips.sql + create mode 100644 revert/flips.sql + create mode 100644 verify/flips.sql + > sqitch deploy + Deploying changes to flipr_test + + flips .. ok + +Looks good. Check the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: dfe72351c686bd36017a2b586042b5336301e809 + # Name: flips + # Deployed: 2014-01-05 14:24:06 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 View to a Thrill + +One more thing to add before we are ready to ship a first beta release. Let's +create a view that lists user names with their flips. + + > sqitch add userflips --requires users --requires flips \ + -n 'Creates the userflips view.' + Created deploy/userflips.sql + Created revert/userflips.sql + Created verify/userflips.sql + Added "userflips [users flips]" to sqitch.plan + +Now add this SQL to F<deploy/userflips.sql>: + + CREATE OR ALTER VIEW userflips AS + SELECT f.id, u.nickname, u.fullname, f.body, f.created_at + FROM users u + JOIN flips f ON u.nickname = f.nickname; + +Add this SQL to F<verify/userflips.sql> + + SELECT id, nickname, fullname, body, created_at + FROM userflips + WHERE 1=2; + +And add the C<DROP VIEW> statement to F<revert/userflips.sql>: + + DROP VIEW userflips; + +Now Try it out! + + > sqitch deploy + Deploying changes to flipr_test + + userflips .. ok + > sqitch revert -y + Reverting all changes from flipr_test + - userflips .. ok + - flips ...... ok + - users ...... ok + > sqitch deploy + Deploying changes to flipr_test + + users ...... ok + + flips ...... ok + + userflips .. ok + +Looks good! Commit it. + + > git add . + > git commit -m 'Add the userflips view.' + [master 28ffa63] Add the userflips view. + 4 files changed, 23 insertions(+), 0 deletions(-) + create mode 100644 deploy/userflips.sql + create mode 100644 revert/userflips.sql + create mode 100644 verify/userflips.sql + +=head1 Ship It! + +Now we're ready for the first development release of our app. Let's call it +C<1.0.0-dev1> Since we want to have it go out with deployments tied to the +release, let's tag it: + + > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.' + Tagged "userflips" with @v1.0.0-dev1 + > git commit -am 'Tag the database with v1.0.0-dev1.' + [master 696a891] Tag the database with v1.0.0-dev1. + 1 files changed, 1 insertions(+), 0 deletions(-) + > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1' + +We can try deploying to make sure the tag gets picked up like so: + + > sudo -u firebird mkdir /tmp/flipr_dev + > echo "CREATE DATABASE 'localhost:/tmp/flipr_dev/flipr.fdb'; exit;" \ + | isql-fb -q -u SYSDBA -p masterkey + > sqitch deploy db:firebird://sysdba:masterkey@localhost//tmp/flipr_dev/flipr.fdb + Adding registry tables to db:firebird://sysdba:@localhost//tmp/flipr_dev/sqitch.fdb + Deploying changes to db:firebird://sysdba:@localhost//tmp/flipr_dev/flipr.fdb + + users ................... ok + + flips ................... ok + + userflips @v1.0.0-dev1 .. ok + +Great, both changes were deployed and C<userflips> was tagged with +C<@v1.0.0-dev1>. Let's have a look at the status: + + > sqitch status db:firebird://sysdba:masterkey@localhost//tmp/flipr_dev/flipr.fdb + # On database db:firebird://sysdba:@localhost//tmp/flipr_dev/flipr.fdb + # Project: flipr + # Change: 785a0d14a5e26b2ae24882a137db45d34f71b5ff + # Name: userflips + # Tag: @v1.0.0-dev1 + # Deployed: 2014-01-05 14:43:28 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the listing of the tag as part of the status message. Now let's bundle +everything up for release: + + > sqitch bundle + Bundling into bundle + Writing config + Writing plan + Writing scripts + + users + + flips + + userflips @v1.0.0-dev1 + +Now we can package the F<bundle> directory and distribute it. When it gets +installed somewhere, users can use Sqitch to deploy to the database. Let's try +deploying it: + + > cd bundle + > sudo -u firebird mkdir /tmp/flipr_prod + > echo "CREATE DATABASE 'localhost:/tmp/flipr_prod/flipr.fdb'; exit;" \ + | isql-fb -q -u SYSDBA -p masterkey + > sqitch deploy db:firebird://sysdba:masterkey@localhost//tmp/flipr_prod/flipr.fdb + Adding registry tables to db:firebird://sysdba:@localhost//tmp/flipr_prod/sqitch.fdb + Deploying changes to db:firebird://sysdba:@localhost//tmp/flipr_prod/flipr.fdb + + users ................... ok + + flips ................... ok + + userflips @v1.0.0-dev1 .. ok + +Looks much the same as before, eh? Package it up and ship it! + + > cd .. + > mv bundle flipr-v1.0.0-dev1 + > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1 + +=head1 Making a Hash of Things + +Now that we've got the basics of the app done, let's add a feature. Gotta +track the hashtags associated with flips, right? Let's add a table for them. +But since other folks are working on other tasks in the repository, we'll work +on a branch, so we can all stay out of each other's way. So let's branch: + + > git checkout -b hashtags + Switched to a new branch 'hashtags' + +Now we can add a new change to create a table for hashtags. + + > sqitch add hashtags --requires flips -n 'Adds table for storing hashtags.' + Created deploy/hashtags.sql + Created revert/hashtags.sql + Created verify/hashtags.sql + Added "hashtags [flips]" to sqitch.plan + +You know the drill by now. Add this to F<deploy/hashtags.sql> + + CREATE TABLE hashtags ( + flip_id INTEGER NOT NULL REFERENCES flips(id), + hashtag VARCHAR(512) NOT NULL CHECK(char_length(hashtag) > 0), + PRIMARY KEY (flip_id, hashtag) + ); + +Again, select from the table in F<verify/hashtags.sql>: + + SELECT flip_id, hashtag FROM hashtags WHERE 1=2; + +And drop it in F<revert/hashtags.sql> + + DROP TABLE hashtags; + +And give it a whirl: + + > sqitch deploy + Deploying changes to flipr_test + + hashtags .. ok + +Look good? + + > sqitch status --show-tags + # On database flipr_test + # Project: flipr + # Change: 9474af3b057294633ccf81b9e8d7771a9588ac67 + # Name: hashtags + # Deployed: 2014-01-05 14:55:56 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + # Tag: + # @v1.0.0-dev1 - 2014-01-05 14:49:56 -0800 - Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the use of C<--show-tags> to show all the deployed tags. Now make it so: + + > rm -rf flipr-v1.0.0-dev1* + > git add . + > git commit -am 'Add hashtags table.' + [hashtags 9c40bf5] Add hashtags table. + 4 files changed, 22 insertions(+), 0 deletions(-) + create mode 100644 deploy/hashtags.sql + create mode 100644 revert/hashtags.sql + create mode 100644 verify/hashtags.sql + +Good, we've finished this feature. Time to merge back into C<master>. + +=head2 Emergency + +Let's do it: + + > git checkout master + Switched to branch 'master' + > git pull + Updating 696a891..9af80a1 + Fast-forward + deploy/lists.sql | 11 +++++++++++ + revert/lists.sql | 5 +++++ + sqitch.plan | 2 ++ + verify/lists.sql | 7 +++++++ + 4 files changed, 25 insertions(+), 0 deletions(-) + create mode 100644 deploy/lists.sql + create mode 100644 revert/lists.sql + create mode 100644 verify/lists.sql + +Hrm, that's interesting. Looks like someone made some changes to C<master>. +They added list support. Well, let's see what happens when we merge our +changes. + + > git merge --no-ff hashtags + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Automatic merge failed; fix conflicts and then commit the result. + +Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged +C<lists> branch and our C<hashtags> branch added changes to the plan. Let's +try a different approach. + +The truth is, we got lazy. Those changes when we pulled master from the origin +should have raised a red flag. It's considered a bad practice not to look at +what's changed in C<master> before merging in a branch. What one I<should> do +is either: + +=over + +=item * + +Rebase the F<hashtags> branch from master before merging. This "rewinds" the +branch changes, pulls from C<master>, and then replays the changes back on top +of the pulled changes. + +=item * + +Create a patch and apply I<that> to master. This is the sort of thing you +might have to do if you're sending changes to another user, especially if the +VCS is not Git. + +=back + +So let's restore things to how they were at master: + + > git reset --hard HEAD + HEAD is now at d5e7e86 Merge branch 'lists' + +That throws out our botched merge. Now let's go back to our branch and rebase +it on C<master>: + + > git checkout hashtags + Switched to branch 'hashtags' + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add hashtags table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Failed to merge in the changes. + Patch failed at 0001 Add hashtags table. + The copy of the patch that failed is found in: + .git/rebase-apply/patch + + When you have resolved this problem, run "git rebase --continue". + If you prefer to skip this patch, run "git rebase --skip" instead. + To check out the original branch and stop rebasing, run "git rebase --abort". + +Oy, that's kind of a pain. It seems like no matter what we do, we'll need to +resolve conflicts in that file. Except in Git. Fortunately for us, we can tell +Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever +append lines to the file, we can have it use the "union" merge driver, which, +according to L<its +docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>: + +=over + +Run 3-way file level merge for text files, but take lines from both versions, +instead of leaving conflict markers. This tends to leave the added lines in +the resulting file in random order and the user should verify the result. Do +not use this if you do not understand the implications. + +=back + +This has the effect of appending lines from all the merging files, which is +exactly what we need. So let's give it a try. First, back out the botched +rebase: + + > git rebase --abort + +Now add the union merge driver to F<.gitattributes> for F<sqitch.plan> +and rebase again: + + > echo sqitch.plan merge=union > .gitattributes + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add hashtags table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + +Ah, that looks a bit better. Let's have a look at the plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-firebird-intro/ + + users 2014-01-05T22:01:30Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [users] 2014-01-05T22:21:24Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [users flips] 2014-01-05T22:40:29Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + @v1.0.0-dev1 2014-01-05T22:42:36Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [flips] 2014-01-05T22:44:41Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + hashtags [flips] 2014-01-05T22:54:27Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags. + +Note that it has appended the changes from the merged "lists" branch, and then +merged the changes from our "hashtags" branch. Test it to make sure it works +as expected: + + > sqitch rebase -y + Reverting all changes from flipr_test + - hashtags ................ ok + - userflips @v1.0.0-dev1 .. ok + - flips ................... ok + - users ................... ok + Deploying changes to flipr_test + + users ................... ok + + flips ................... ok + + userflips @v1.0.0-dev1 .. ok + + lists ................... ok + + hashtags ................ ok + +Note the use of L<C<rebase>|sqitch-rebase>, which combines a +L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single +command. Handy, right? It correctly reverted our changes, and then deployed +them all again in the proper order. So let's commit F<.gitattributes>; seems +worthwhile to keep that change: + + > git add . + > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.' + [hashtags 52ed9a2] Add `.gitattributes` with union merge for `sqitch.plan`. + 1 files changed, 1 insertions(+), 0 deletions(-) + create mode 100644 .gitattributes + +=head2 Merges Mastered + +And now, finally, we can merge into C<master>: + + > git checkout master + Switched to branch 'master' + > git merge --no-ff hashtags -m "Merge branch 'hashtags'" + Merge made by recursive. + .gitattributes | 1 + + deploy/hashtags.sql | 10 ++++++++++ + revert/hashtags.sql | 5 +++++ + sqitch.plan | 1 + + verify/hashtags.sql | 5 +++++ + 5 files changed, 22 insertions(+), 0 deletions(-) + create mode 100644 .gitattributes + create mode 100644 deploy/hashtags.sql + create mode 100644 revert/hashtags.sql + create mode 100644 verify/hashtags.sql + +And double-check our work: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-firebird-intro/ + + users 2014-01-05T22:01:30Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [users] 2014-01-05T22:21:24Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [users flips] 2014-01-05T22:40:29Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + @v1.0.0-dev1 2014-01-05T22:42:36Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [flips] 2014-01-05T22:44:41Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + hashtags [flips] 2014-01-05T22:54:27Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags. + +Much much better, a nice clean master now. And because it is now identical to +the "hashtags" branch, we can just carry on. Go ahead and tag it, bundle, and +release: + + > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.' + Tagged "hashtags" with @v1.0.0-dev2 + > git commit -am 'Tag the database with v1.0.0-dev2.' + [master 7d07ee3] Tag the database with v1.0.0-dev2. + 1 file changed, 1 insertion(+) + > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2' + > sqitch bundle --dest-dir flipr-1.0.0-dev2 + Bundling into flipr-1.0.0-dev2 + Writing config + Writing plan + Writing scripts + + users + + flips + + userflips @v1.0.0-dev1 + + lists + + hashtags @v1.0.0-dev2 + +Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way +to create the top-level directory name so we don't have to rename it from +F<bundle>. + +=head1 In Place Changes + +Well, some folks have been testing the C<1.0.0-dev2> release and have demanded +that Twitter user links be added to Flipr pages. Why anyone would want to +include social network links in an anti-social networking app is beyond us +programmers, but we're just the plumbers, right? Gotta go with what Marketing +demands. The upshot is that we need to update the C<userflips> view, which is +used for the feature in question, to include the Twitter user names. + +Normally, modifying views in database changes is a +L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make +changes like these: + +=over + +=item 1. + +Copy F<deploy/userflips.sql> to F<deploy/userflips_twitter.sql>. + +=item 2. + +Edit F<deploy/userflips_twitter.sql> to re-create the view with the +new C<twitter> column added to the view. + +=item 3. + +Copy F<deploy/userflips.sql> to F<revert/userflips_twitter.sql>. +Yes, copy the original change script to the new revert change. + +=item 4. + +Add a C<DROP VIEW> statement to F<revert/userflips_twitter.sql>. + +=item 5. + +Copy F<verify/userflips.sql> to F<verify/userflips_twitter.sql>. + +=item 6. + +Modify F<verify/userflips_twitter.sql> to include a check for the C<twiter> +column. + +=item 7. + +Test the changes to make sure you can deploy and revert the +C<userflips_twitter> change. + +=back + +But you can have Sqitch do most of the work for you. The only requirement is +that a tag appear between the two instances of a change we want to modify. In +general, you're going to make a change like this after a release, which you've +tagged anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous +section. With that, we can let Sqitch do most of the hard work for us, thanks +to the L<C<rework>|sqitch-rework> command, which is similar to +L<C<add>|sqitch-add>: + + > sqitch rework userflips -n 'Adds userflips.twitter.' + Added "userflips [userflips@v1.0.0-dev2]" to sqitch.plan. + Modify these files as appropriate: + * deploy/userflips.sql + * revert/userflips.sql + * verify/userflips.sql + +Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in +point of fact, it has copied the files to stand in for the previous instance +of the C<userflips> change, which we can see via C<git status>: + + > git status + # On branch master + # Your branch is ahead of 'origin/master' by 4 commits. + # (use "git push" to publish your local commits) + # + # Changes not staged for commit: + # (use "git add <file>..." to update what will be committed) + # (use "git checkout -- <file>..." to discard changes in working directory) + # + # modified: revert/userflips.sql + # modified: sqitch.plan + # + # Untracked files: + # (use "git add <file>..." to include in what will be committed) + # + # deploy/userflips@v1.0.0-dev2.sql + # revert/userflips@v1.0.0-dev2.sql + # verify/userflips@v1.0.0-dev2.sql + no changes added to commit (use "git add" and/or "git commit -a") + +The "untracked files" part of the output is the first thing to notice. They +are all named C<userflips@v1.0.0-dev2.sql>. What that means is: "the +C<userflips> change as it was implemented as of the C<@v1.0.0-dev2> tag." +These are copies of the original scripts, and thereafter Sqitch will find them +when it needs to run scripts for the first instance of the C<userflips> +change. As such, it's important not to change them again. But hey, if you're +reworking the change, you shouldn't need to. + +The other thing to notice is that F<revert/userflips.sql> has changed. Sqitch +replaced it with the original deploy script. As of now, +F<deploy/userflips.sql> and F<revert/userflips.sql> are identical. This is on +the assumption that the deploy script will be changed (we're reworking it, +remember?), and that the revert script should actually change things back to +how they were before. + +Fortunately, our view deploy scripts are already almost +L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be +applied multiple times without changing the result beyond the initial +application. If it's not, you will likely need to modify it so that it +properly restores things to how they were after the original deploy script was +deployed. Or, more simply, it should revert changes back to how they were +as-of the deployment of F<deploy/userflips@v1.0.0-dev2.sql>. + +Fortunately, our view deploy scripts are already idempotent, thanks to the +use of the C<OR ALTER> expression. No matter how many times a deployment +script is run, the end result will be the same instance of the view, with +no duplicates or errors. + +As a result, there is no need to explicitly add changes. So go ahead. Modify +F<deploy/userflips.sql> to add the C<twitter> column. + + @@ -3,7 +3,7 @@ + -- requires: flips + + CREATE OR ALTER VIEW userflips AS + -SELECT f.id, u.nickname, u.fullname, f.body, f.created_at + +SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.created_at + FROM users u + JOIN flips f ON u.nickname = f.nickname; + + +Next, modify F<verify/userflips.sql> to check for the C<twitter> column. +Here's the diff: + + @@ -1,6 +1,6 @@ + -- Verify flipr:userflips on firebird + + -SELECT id, nickname, fullname, body, created_at + +SELECT id, nickname, twitter, fullname, body, created_at + FROM userflips + WHERE 1=2; + + +Now try a deployment: + + > sqitch deploy + Deploying changes to flipr_test + + userflips .. ok + +So, are the changes deployed? + + > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW VIEW userflips; quit;" \ + | isql-fb -q -u SYSDBA -p masterkey + ID INTEGER Not Null + NICKNAME VARCHAR(50) Not Null + FULLNAME VARCHAR(512) Not Null + TWITTER VARCHAR(512) Not Null + BODY VARCHAR(512) Not Null + CREATED_AT TIMESTAMP Not Null + View Source: + ==== ====== + + SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.created_at + FROM users u + JOIN flips f ON u.nickname = f.nickname + +Awesome, the view now includes the C<twitter> column. But can we revert? + + > sqitch revert --to @HEAD^ -y + Reverting changes to hashtags @v1.0.0-dev2 from flipr_test + - userflips .. ok + +Did that work, is the C<twitter> column gone? + + > echo "CONNECT 'localhost:/tmp/flipr_test/flipr.fdb'; SHOW VIEW userflips; quit;" \ + | isql-fb -q -u SYSDBA -p masterkey + ID INTEGER Not Null + NICKNAME VARCHAR(50) Not Null + FULLNAME VARCHAR(512) Not Null + BODY VARCHAR(512) Not Null + CREATED_AT TIMESTAMP Not Null + View Source: + ==== ====== + + SELECT f.id, u.nickname, u.fullname, f.body, f.created_at + FROM users u + JOIN flips f ON u.nickname = f.nickname + +Yes, it works! Sqitch properly finds the original instances of these changes +in the new script files that include tags. + +Excellent. Let's go ahead and commit these changes: + + > rm -rf flipr-1.0.0-dev2 + > git add . + > git commit -m 'Add the twitter column to the userflips view.' + [master f530359] Add the twitter column to the userflips view. + 7 files changed, 32 insertions(+), 4 deletions(-) + create mode 100644 deploy/userflips@v1.0.0-dev2.sql + create mode 100644 revert/userflips@v1.0.0-dev2.sql + create mode 100644 verify/userflips@v1.0.0-dev2.sql + +=head1 More to Come + +Sqitch is a work in progress. Better integration with version control systems +is planned to make managing idempotent reworkings even easier. Stay tuned. + +=head1 Authors + +=over + +=item * Ștefan Suciu <stefbv70@gmail.com> + +=item * David E. Wheeler <david@justatheory.com> + +=back + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/sqitchtutorial-mysql.pod b/lib/sqitchtutorial-mysql.pod new file mode 100644 index 00000000..8959d0fe --- /dev/null +++ b/lib/sqitchtutorial-mysql.pod @@ -0,0 +1,1724 @@ +=encoding UTF-8 + +=head1 Name + +sqitchtutorial-mysql - A tutorial introduction to Sqitch change management on MySQL + +=head1 Synopsis + + sqitch * + +=head1 Description + +This tutorial explains how to create a sqitch-enabled MySQL project, use a VCS +for deployment planning, and work with other developers to make sure changes +remain in sync and in the proper order. + +We'll start by creating new project from scratch, a fictional antisocial +networking site called Flipr. All examples use L<Git|https://git-scm.com/> as +the VCS and L<MySQL|https://dev.mysql.com/> as the storage engine. + +If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>. + +If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>. + +If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>. + +If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>. + +If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>. + +If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>. + +If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>. + +=head1 Starting a New Project + +Usually the first thing to do when starting a new project is to create a +source code repository. So let's do that with Git: + + > mkdir flipr + > cd flipr + > git init . + Initialized empty Git repository in /flipr/.git/ + > touch README.md + > git add . + > git commit -am 'Initialize project, add README.' + [master (root-commit) fdf2a40] Initialize project, add README. + 1 file changed, 38 insertions(+) + create mode 100644 README.md + +If you're a Git user and want to follow along the history, the repository used +in these examples is L<on GitHub|https://github.com/sqitchers/sqitch-mysql-intro>. + +Now that we have a repository, let's get started with Sqitch. Every Sqitch +project must have a name associated with it, and, optionally, a unique URI. We +recommend including the URI, as it increases the uniqueness of object +identifiers internally, and will prevent the deployment of a different project +with the same name. So let's specify one when we initialize Sqitch: + + > sqitch init flipr --uri https://github.com/sqitchers/sqitch-mysql-intro/ --engine mysql + Created sqitch.conf + Created sqitch.plan + Created deploy/ + Created revert/ + Created verify/ + +Let's have a look at F<sqitch.conf>: + + > cat sqitch.conf + [core] + engine = mysql + # plan_file = sqitch.plan + # top_dir = . + # [engine "mysql"] + # target = db:mysql: + # registry = sqitch + # client = mysql + +Good, it picked up on the fact that we're creating changes for the MySQL +engine, thanks to the C<--engine mysql> option, and saved it to the file. +Furthermore, it wrote a commented-out C<[engine "mysql"]> section with all the +available MySQL engine-specific settings commented out and ready to be edited +as appropriate. + +By default, Sqitch will read F<sqitch.conf> in the current directory for +settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific +settings. Since MySQL's +L<C<mysql> client|https://dev.mysql.com/doc/refman/5.6/en/mysql.html> is not +in the path on my system, let's go ahead an tell it where to find the client +on our computer: + + > sqitch config --user engine.mysql.client /usr/local/mysql/bin/mysql + +And let's also tell it who we are, since this data will be used in all +of our projects: + + > sqitch config --user user.name 'Marge N. O’Vera' + > sqitch config --user user.email 'marge@example.com' + +Have a look at F<~/.sqitch/sqitch.conf> and you'll see this: + + > cat ~/.sqitch/sqitch.conf + [engine "mysql"] + client = /usr/local/mysql/bin/mysql + [user] + name = Marge N. O’Vera + email = marge@example.com + +Which means that Sqitch should be able to find C<mysql> for any project, and +that it will always properly identify us when planning and committing changes. + +Back to the repository. Have a look at the plan file, F<sqitch.plan>: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-mysql-intro/ + + +Note that it has picked up on the name and URI of the app we're building. +Sqitch uses this data to manage cross-project dependencies. The +C<%syntax-version> pragma is always set by Sqitch, so that it always knows how +to parse the plan, even if the format changes in the future. + +Let's commit these changes and start creating the database changes. + + > git add . + > git commit -am 'Initialize Sqitch configuration.' + [master 79fe2cc] Initialize Sqitch configuration. + 2 files changed, 19 insertions(+) + create mode 100644 sqitch.conf + create mode 100644 sqitch.plan + +=head1 Our First Change + +First, our app will need a database user, so let's create one. Run this +command: + + > sqitch add appuser -n 'Creates a an application user.' + Created deploy/appuser.sql + Created revert/appuser.sql + Created verify/appuser.sql + Added "appuser" to sqitch.plan + +The L<C<add>|sqitch-add> command adds a database change to the plan and writes +deploy, revert, and verify scripts that represent the change. Now we edit +these files. The C<deploy> script's job is to create the table. By default, +the F<deploy/appuser.sql> file looks like this: + + -- Deploy flipr:appuser to mysql + + BEGIN; + + -- XXX Add DDLs here. + + COMMIT; + +What we want to do is to replace the C<XXX> comment with the C<CREATE USER> +statement, like so: + + -- Deploy flipr:users to mysql + + BEGIN; + + CREATE USER flipr; + + COMMIT; + +The C<revert> script's job is to precisely revert the change to the deploy +script, so we edit this to F<revert/appuser.sql> to look like this: + + -- Revert flipr:users from mysql + + BEGIN; + + DROP USER flipr; + + COMMIT; + +Now we can try deploying this change. First, we need to create a database +to deploy to: + + > mysql -u root --execute 'CREATE DATABASE flipr_test' + +Now we tell Sqitch where to send the change via a +L<database URI|https://github.com/libwww-perl/uri-db/>: + + > sqitch deploy db:mysql://root@/flipr_test + Deploying changes to db:mysql://root@/flipr_test + + appuser .. ok + +First Sqitch created the registry database and tables used to track database +changes. The registry database is separate from the database to which the +C<appuser> change was deployed; by default, its name is C<sqitch>, and will be +used to manage I<all> projects on a single MySQL server. Ideally, only Sqitch +data will be stored in this database, so it probably makes the most sense to +create a superuser named C<sqitch> or something similar and use it to deploy +changes. + +If you'd like it to use a different database as the registry database, use +C<sqitch engine add mysql $name> to configure it (or via the +L<C<target> command|sqitch-target>; more L<below|/On Target>). This will be +useful if you don't want to use the same registry database to manage multiple +databases on the same server. + +Next, Sqitch deploys changes to the target database, which we specified on the +command-line. We only have one change so far; the C<+> reinforces the idea +that the change is being I<added> to the database. + +With this change deployed, if you connect to the database, you'll be able to +see the user: + + > mysql -u root --execute "SELECT user from mysql.user WHERE user = 'flipr';" + +-------+ + | User | + +-------+ + | flipr | + +-------+ + +=head2 Trust, But Verify + +But that's too much work. do you really want to do something like that after +every deploy? + +Here's where the C<verify> script comes in. Its job is to test that the deploy +did was it was supposed to. It should do so without regard to any data that +might be in the database, and should throw an error if the deploy was not +successful. The simplest way to see if a user exists is to check the +C<mysql.user> table. However, throwing an error in the event that the user +does not exist is tricky in MySQL. To simplify things, on MySQL 5.5.0 and +higher, Sqitch provides a custom function you can use in your tests, +C<checkit()>. It works kind of like a C<CHECK> constraint in other databases: +pass an expression as the first argument, and an error message as the second. +If the expression evaluates to false, an exception will be thrown with the +error message. + +Give it a try. Put this query into F<verify/appuser.sql>: + + SELECT sqitch.checkit(COUNT(*), 'User "flipr" does not exist') + FROM mysql.user WHERE user = 'flipr'; + +This will work well as long as we know that the registry database is named +C<sqitch>. If you've set C<engine.mysql.registry> to a different value, you +will need to make sure you specify the correct database name in the script. + +Now you can run the C<verify> script with the L<C<verify>|sqitch-verify> +command: + + > sqitch verify db:mysql://root@/flipr_test + Verifying flipr_test + * appuser .. ok + Verify successful + +Looks good! If you want to make sure that the verify script correctly dies if +the table doesn't exist, temporarily change the user name in the script to +something that doesn't exist, something like: + + SELECT sqitch.checkit(COUNT(*), 'User "flipr" does not exist') + FROM mysql.user WHERE user = 'nonesuch'; + +Then L<C<verify>|sqitch-verify> again: + + > sqitch verify db:mysql://root@/flipr_test + Verifying db:mysql://root@/flipr_test + * appuser .. ERROR 1644 (ERR0R) at line 5 in file: 'verify/appuser.sql': User "flipr" does not exist + # Verify script "verify/appuser.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +The C<checkit()> function is kind enough to use the error message to tell us +what the problem is. Don't forget to change the table name back before +continuing! + +=head2 Status, Revert, Log, Repeat + +For purely informational purposes, we can always see how a deployment was +recorded via the L<C<status>|sqitch-status> command, which reads the tables +from the registry database: + + > sqitch status db:mysql://root@/flipr_test + # On database db:mysql://root@/flipr_test + # Project: flipr + # Change: f56dd1a1ab029f398cec2cebb2ecc527fa0332c2 + # Name: appuser + # Deployed: 2013-12-31 13:13:17 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Let's make sure that we can revert the change: + + > sqitch revert db:mysql://root@/flipr_test + Revert all changes from db:mysql://root@/flipr_test? [Yes] + - appuser .. ok + +The L<C<revert>|sqitch-revert> command first prompts to make sure that we +really do want to revert. This is to prevent unnecessary accidents. You can +pass the C<-y> option to disable the prompt. Also, notice the C<-> before the +change name in the output, which reinforces that the change is being +I<removed> from the database. And now the schema should be gone: + + > mysql -u root --execute "SELECT user from mysql.user WHERE user = 'flipr';" + +And the status message should reflect as much: + + > sqitch status db:mysql://root@/flipr_test + # On database db:mysql://root@/flipr_test + No changes deployed + +Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command +has nothing to verify: + + > sqitch verify db:mysql://root@/flipr_test + Verifying db:mysql://root@/flipr_test + No changes deployed + +However, we still have a record that the change happened, visible via the +L<C<log>|sqitch-log> command: + + > sqitch log db:mysql://root@/flipr_test + On database db:mysql://root@/flipr_test + Revert f56dd1a1ab029f398cec2cebb2ecc527fa0332c2 + Name: appuser + Committer: Marge N. O’Vera <marge@example.com> + Date: 2013-12-31 13:26:39 -0800 + + Creates a an application user. + + Deploy f56dd1a1ab029f398cec2cebb2ecc527fa0332c2 + Name: appuser + Committer: Marge N. O’Vera <marge@example.com> + Date: 2013-12-31 13:13:17 -0800 + + Creates a an application user. + +Note that the actions we took are shown in reverse chronological order, with +the revert first and then the deploy. + +Cool. Now let's commit it. + + > git add . + > git commit -m 'Add the "flipr" user.' + [master c63acb9] Add the "flipr" user. + 4 files changed, 23 insertions(+) + create mode 100644 deploy/appuser.sql + create mode 100644 revert/appuser.sql + create mode 100644 verify/appuser.sql + +And then deploy again. This time, let's use the C<--verify> option, so that +the C<verify> script is applied when the change is deployed: + + > sqitch deploy --verify db:mysql://root@/flipr_test + Deploying changes to db:mysql://root@/flipr_test + + appuser .. ok + +And now the C<flipr> user should be back: + + > mysql -u root --execute "SELECT user from mysql.user WHERE user = 'flipr';" + +-------+ + | user | + +-------+ + | flipr | + +-------+ + +When we look at the status, the deployment will be there: + + > sqitch status db:mysql://root@/flipr_test + # On database db:mysql://root@/flipr_test + # Project: flipr + # Change: f56dd1a1ab029f398cec2cebb2ecc527fa0332c2 + # Name: appuser + # Deployed: 2013-12-31 13:28:23 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 On Target + +I'm getting a little tired of always having to type +C<db:mysql://root@/flipr_test>, aren't you? This +L<database connection URI|https://github.com/libwww-perl/uri-db/> tells Sqitch how +to connect to the deployment target, but we don't have to keep using the URI. +We can name the target: + + > sqitch target add flipr_test db:mysql://root@/flipr_test + +The L<C<target>|sqitch-target> command, inspired by +L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one +or more named deployment targets. We've just added a target named +C<flipr_test>, which means we can use the string C<flipr_test> for the target, +rather than the URI. But since we're doing so much testing, we can also tell +Sqitch to deploy to the C<flipr_test> target by default: + + > sqitch engine add mysql flipr_test + +Now we can omit the target argument altogether, unless we need to deploy to +another database. Which we will, eventually, but at least our examples will be +simpler from here on in, e.g.: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: f56dd1a1ab029f398cec2cebb2ecc527fa0332c2 + # Name: appuser + # Deployed: 2013-12-31 13:28:23 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Yay, that allows things to be a little more concise. Let's also make sure that +changes are verified after deploying them: + + > sqitch config --bool deploy.verify true + > sqitch config --bool rebase.verify true + +We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime, +let's commit the new configuration and and make some more changes! + + > git commit -am 'Set default target and always verify.' + [master c793050] Set default target and always verify. + 1 file changed, 8 insertions(+) + +=head1 Deploy with Dependency + +Let's add another change, this time to create a table. Our app will need +users, of course, so we'll create a table for them. First, add the new change: + + > sqitch add users --requires appuser -n 'Creates table to track our users.' + Created deploy/users.sql + Created revert/users.sql + Created verify/users.sql + Added "users [appuser]" to sqitch.plan + +Note that we're requiring the C<appuser> change as a dependency of the new +C<users> change. Although that change has already been added to the plan and +therefore should always be applied before the C<users> change, it's a good +idea to be explicit about dependencies. + +Now edit the scripts. When you're done, F<deploy/users.sql> should look like +this: + + -- Deploy flipr:users to mysql + -- requires: appuser + + BEGIN; + + CREATE TABLE users ( + nickname VARCHAR(512) PRIMARY KEY, + password VARCHAR(512) NOT NULL, + timestamp DATETIME(6) NOT NULL + ); + + GRANT SELECT ON TABLE users TO flipr; + + COMMIT; + +A few things to notice here. On the second line, the dependence on the +C<appuser> change has been listed. This doesn't do anything, but the default +MySQL C<deploy> template lists it here for your reference while editing the +file. Useful, right? + +The C<flipr> user has been granted C<SELECT> access to the table. The app +needs to read the data, right? This is why we need to require the C<appuser> +change. + +Now for the verify script. The simplest way to check that the table was +created and has the expected columns without touching the data? Just select +from the table with a false C<WHERE> clause. Add this to F<verify/users.sql>: + + SELECT nickname, password, timestamp + FROM users + WHERE 0; + +Now for the revert script: all we have to do is drop the table. Add this to +F<revert/users.sql>: + + DROP TABLE users; + +Couldn't be much simpler, right? Let's deploy this bad boy: + + > sqitch deploy + Deploying changes to flipr_test + + users .. ok + +We know, since verification is enabled, that the table must have been created. +But for the purposes of visibility, let's have a quick look: + + > mysql -u root -D flipr_test --execute 'SHOW TABLES' + +----------------------+ + | Tables_in_flipr_test | + +----------------------+ + | users | + +----------------------+ + +We can also verify all currently deployed changes with the +L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appuser .. ok + * users .... ok + Verify successful + +Now have a look at the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 2bd1190fdb324c2609f0c7f0cef73d8cb434ba0e + # Name: users + # Deployed: 2013-12-31 13:34:25 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Success! Let's make sure we can revert the change, as well: + + > sqitch revert --to @HEAD^ -y + Reverting changes to appuser from flipr_test + - users .. ok + +Note that we've used the C<--to> option to specify the change to revert to. +And what do we revert to? The symbolic tag C<@HEAD>, when passed to +L<C<revert>|sqitch-revert>, always refers to the last change deployed to the +database. (For other commands, it refers to the last change in the plan.) +Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the +last deployed change. So we revert to C<appuser>, the penultimate change. The +other potentially useful symbolic tag is C<@ROOT>, which refers to the first +change deployed to the database (or in the plan, depending on the command). + +Back to the database. The C<users> table should be gone but the C<flipr> user +should still be around: + + > mysql -u root -D flipr_test --execute 'SHOW TABLES' + > mysql -u root --execute "SELECT user from mysql.user WHERE user = 'flipr';" + +-------+ + | User | + +-------+ + | flipr | + +-------+ + +The L<C<status>|sqitch-status> command politely informs us that we have +undeployed changes: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: f56dd1a1ab029f398cec2cebb2ecc527fa0332c2 + # Name: appuser + # Deployed: 2013-12-31 13:28:23 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Undeployed change: + * users + +As does the L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appuser .. ok + Undeployed change: + * users + Verify successful + +Note that the verify is successful, because all currently-deployed changes are +verified. The list of undeployed changes (just "users" here) reminds us about +the current state. + +Okay, let's commit and deploy again: + + > git add . + > git commit -am 'Add users table.' + [master 7c99fb0] Add users table. + 4 files changed, 31 insertions(+) + create mode 100644 deploy/users.sql + create mode 100644 revert/users.sql + create mode 100644 verify/users.sql + > sqitch deploy + Deploying changes to flipr_test + + users .. ok + +Looks good. Check the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 2bd1190fdb324c2609f0c7f0cef73d8cb434ba0e + # Name: users + # Deployed: 2013-12-31 13:37:02 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Excellent. Let's do some more! + +=head1 Add Two at Once + +Let's add a couple more changes to add functions for managing users. + + > sqitch add insert_user --requires users --requires appuser \ + -n 'Creates a function to insert a user.' + Created deploy/insert_user.sql + Created revert/insert_user.sql + Created verify/insert_user.sql + Added "insert_user [users appuser]" to sqitch.plan + + > sqitch add change_pass --requires users --requires appuser \ + -n 'Creates a function to change a user password.' + Created deploy/change_pass.sql + Created revert/change_pass.sql + Created verify/change_pass.sql + Added "change_pass [users appuser]" to sqitch.plan + +Now might be a good time to have a look at the deployment plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-mysql-intro/ + + appuser 2013-12-31T21:04:04Z Marge N. O’Vera <marge@example.com> # Creates a an application user. + users [appuser] 2013-12-31T21:32:48Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + insert_user [users appuser] 2013-12-31T21:37:29Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user. + change_pass [users appuser] 2013-12-31T21:37:36Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password. + +Each change appears on a single line with the name of the change, a bracketed +list of dependencies, a timestamp, the name and email address of the user who +planned the change, and a note. + +Let's write the code for the new changes. Here's what +F<deploy/insert_user.sql> should look like: + + -- Deploy flipr:insert_user to mysql + -- requires: users + -- requires: appuser + + BEGIN; + + DELIMITER // + + CREATE PROCEDURE insert_user( + nickname VARCHAR(512), + password VARCHAR(512) + ) SQL SECURITY DEFINER + BEGIN + INSERT INTO users (nickname, password, timestamp) + VALUES (nickname, md5(password), UTC_TIMESTAMP(6)); + END + // + + DELIMITER ; + + GRANT EXECUTE ON PROCEDURE insert_user to flipr; + + COMMIT; + +Here's what F<verify/insert_user.sql> might look like, using the Sqitch +C<checkit()> function again: + + -- Verify flipr:insert_user on mysql + + BEGIN; + + SELECT sqitch.checkit(COUNT(*), 'Procedure "insert_user" does not exist') + FROM mysql.proc + WHERE db = database() + AND specific_name = 'insert_user'; + + ROLLBACK; + +We simply take advantage of the fact that the new procedure should be listed +in the C<mysql.proc> table and throw an exception if it does not exist. + +And F<revert/insert_user.sql> should look something like this: + + -- Revert flipr:insert_user from mysql + BEGIN; + DROP PROCEDURE insert_user; + COMMIT; + +Now for C<change_pass>; F<deploy/change_pass.sql> might look like this: + + -- Deploy flipr:change_pass to mysql + -- requires: users + -- requires: appuser + + BEGIN; + + DELIMITER // + + CREATE FUNCTION change_pass( + nickname VARCHAR(512), + oldpass VARCHAR(512), + newpass VARCHAR(512) + ) RETURNS INTEGER SQL SECURITY DEFINER + BEGIN + UPDATE users + SET password = md5(newpass) + WHERE nickname = nickname + AND password = md5(oldpass); + RETURN ROW_COUNT(); + END; + // + + DELIMITER ; + + GRANT EXECUTE ON FUNCTION change_pass to flipr; + + COMMIT; + +Use C<checkit()> in F<verify/change_pass.sql> again: + + BEGIN; + SELECT sqitch.checkit(COUNT(*), 'Procedure "change_pass" does not exist') + FROM mysql.proc + WHERE db = database() + AND specific_name = 'change_pass'; + COMMIT; + +And of course, its C<revert> script, F<revert/change_pass.sql>, should look +something like: + + -- Revert flipr:change_pass from mysql + BEGIN; + DROP FUNCTION change_pass; + COMMIT; + +Try em out! + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. ok + + change_pass .. ok + +Do we have the functions? Of course we do, they were verified. Still, have a +look: + + > mysql -u root --execute "SELECT name FROM mysql.proc WHERE db = 'flipr_test'" + +-------------+ + | name | + +-------------+ + | change_pass | + | insert_user | + +-------------+ + +And what's the status? + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: b0a598b91ce97cf1b95ded97a6452bf03231a2cd + # Name: change_pass + # Deployed: 2013-12-31 13:39:49 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Looks good. Let's make sure revert works: + + > sqitch revert -y --to @HEAD^^ + Reverting changes to users from flipr_test + - change_pass .. ok + - insert_user .. ok + > mysql -u root --execute "SELECT name FROM mysql.proc WHERE db = 'flipr_test'" + +Note the use of C<@HEAD^^> to specify that the revert be to two changes prior +the last deployed change. Looks good. Let's do the commit and re-deploy dance: + + > git add . + > git commit -m 'Add `insert_user()` and `change_pass()`.' + [master 0f95e13] Add `insert_user()` and `change_pass()`. + 7 files changed, 86 insertions(+) + create mode 100644 deploy/change_pass.sql + create mode 100644 deploy/insert_user.sql + create mode 100644 revert/change_pass.sql + create mode 100644 revert/insert_user.sql + create mode 100644 verify/change_pass.sql + create mode 100644 verify/insert_user.sql + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. ok + + change_pass .. ok + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: b0a598b91ce97cf1b95ded97a6452bf03231a2cd + # Name: change_pass + # Deployed: 2013-12-31 13:40:40 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + + > sqitch verify + Verifying flipr_test + * appuser ...... ok + * users ........ ok + * insert_user .. ok + * change_pass .. ok + Verify successful + +Great, we're fully up-to-date! + +=head1 Ship It! + +Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want +to have it go out with deployments tied to the release, let's tag it: + + > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.' + Tagged "change_pass" with @v1.0.0-dev1 + > git commit -am 'Tag the database with v1.0.0-dev1.' + [master 0595297] Tag the database with v1.0.0-dev1. + 1 file changed, 1 insertion(+) + > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1' + +Now let's bundle everything up for release: + + > sqitch bundle + Bundling into bundle/ + Writing config + Writing plan + Writing scripts + + appuser + + users + + insert_user + + change_pass @v1.0.0-dev1 + +Now we can package the F<bundle> directory and distribute it. When it gets +installed somewhere, users can use Sqitch to deploy to the database. We ought +to try deploying it, but first we'll need to revert our existing databases, as +a single Sqitch project cannot be deployed to two databases on the same server +unless it uses a different registry database and the C<checkit()> function is +not used in verify scripts. We have used C<checkit()> quite a bit, so we need +to keep the Sqitch database name just where it is. Fortunately, it's easy to +build the database again, so let's just revert it. + + > sqitch revert -y + Reverting all changes from flipr_test + - change_pass .. ok + - insert_user .. ok + - users ........ ok + - appuser ...... ok + +Now we can try deploying the bundle: + + > cd bundle + > mysql -u root --execute 'CREATE DATABASE flipr_dev' + > sqitch deploy db:mysql://root@/flipr_dev + Deploying changes to db:mysql://root@/flipr_dev + + appuser ................... ok + + users ..................... ok + + insert_user ............... ok + + change_pass @v1.0.0-dev1 .. ok + +Great, all four changes were deployed and C<change_pass> was tagged with +C<@v1.0.0-dev1>. Let's have a look at the status: + + > sqitch status db:mysql://root@/flipr_dev + # On database db:mysql://root@/flipr_dev + # Project: flipr + # Change: b0a598b91ce97cf1b95ded97a6452bf03231a2cd + # Name: change_pass + # Tag: @v1.0.0-dev1 + # Deployed: 2013-12-31 13:44:04 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Looks good, eh? Go ahead and revert it: + + > sqitch revert -y db:mysql://root@/flipr_dev + Reverting all changes from db:mysql://root@/flipr_dev + - change_pass @v1.0.0-dev1 .. ok + - insert_user ............... ok + - users ..................... ok + - appuser ................... ok + +Now package it up and ship it! + + > cd .. + > mv bundle flipr-v1.0.0-dev1 + > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1 + +=head1 Flip Out + +Now that we've got the basics of user management done, let's get to work on +the core of our product, the "flip." Since other folks are working on other +tasks in the repository, we'll work on a branch, so we can all stay out of +each other's way. So let's branch: + + > git checkout -b flips + Switched to a new branch 'flips' + +Now we can add a new change to create a table for our flips. + + > sqitch add flips -r appuser -r users -n 'Adds table for storing flips.' + Created deploy/flips.sql + Created revert/flips.sql + Created verify/flips.sql + Added "flips [appuser users]" to sqitch.plan + +You know the drill by now. Edit F<deploy/flips.sql>: + + -- Deploy flipr:flips to mysql + -- requires: appuser + -- requires: users + + BEGIN; + + CREATE TABLE flips ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + nickname VARCHAR(512) NOT NULL REFERENCES users(nickname), + body VARCHAR(180) NOT NULL, + timestamp DATETIME(6) NOT NULL + ); + + GRANT SELECT ON TABLE flips TO flipr; + + COMMIT; + +Edit F<verify/flips.sql>: + + -- Verify flipr:flips on mysql + + BEGIN; + + SELECT id + , nickname + , body + , timestamp + FROM flipr.flips + WHERE 0; + + ROLLBACK; + +And edit F<revert/flips.sql>: + + -- Revert flipr:flips from mysql + + BEGIN; + + DROP TABLE flips; + + COMMIT; + +And give it a whirl: + + > sqitch deploy + Deploying changes to flipr_test + + appuser ................... ok + + users ..................... ok + + insert_user ............... ok + + change_pass @v1.0.0-dev1 .. ok + +Look good? + + > sqitch status --show-tags + # On database flipr_test + # Project: flipr + # Change: b3ccd37da58ac232c23edfa0adaf2d6f483842fd + # Name: flips + # Deployed: 2013-12-31 13:55:04 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + # Tag: + # @v1.0.0-dev1 - 2013-12-31 13:55:04 -0800 - Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the use of C<--show-tags> to show all the deployed tags. Now make it so: + + > git add . + > git commit -am 'Add flips table.' + [flips ce1b53d] Add flips table. + 4 files changed, 37 insertions(+) + create mode 100644 deploy/flips.sql + create mode 100644 revert/flips.sql + create mode 100644 verify/flips.sql + +=head1 Wash, Rinse, Repeat + +Now comes the time to add functions to manage flips. I'm sure you have things +nailed down now. Go ahead and add C<insert_flip> and C<delete_flip> changes +and commit them. The C<insert_flip> deploy script might look something like: + + -- Deploy flipr:insert_flip to mysql + -- requires: flips + -- requires: appuser + + BEGIN; + + DELIMITER // + + CREATE FUNCTION insert_flip( + nickname VARCHAR(512), + body VARCHAR(180) + ) RETURNS BIGINT SQL SECURITY DEFINER + BEGIN + INSERT INTO flips (nickname, body) + VALUES (nickname, body); + RETURN LAST_INSERT_ID(); + END; + // + + DELIMITER ; + + GRANT EXECUTE ON FUNCTION insert_flip to flipr; + + COMMIT; + +And the C<delete_flip> deploy script might look something like: + + -- Deploy flipr:delete_flip to mysql + -- requires: flips + -- requires: appuser + + BEGIN; + + DELIMITER // + + CREATE FUNCTION delete_flip( + flip_id BIGINT + ) RETURNS INTEGER SQL SECURITY DEFINER + BEGIN + DELETE FROM flips WHERE id = flip_id; + RETURN ROW_COUNT(); + END; + // + + DELIMITER ; + + GRANT EXECUTE ON FUNCTION delete_flip to flipr; + + COMMIT; + +The C<verify> scripts are: + + -- Verify flipr:insert_flip on mysql + + BEGIN; + + SELECT sqitch.checkit(COUNT(*), 'Function "insert_flip" does not exist') + FROM mysql.proc + WHERE db = database() + AND specific_name = 'insert_flip'; + + ROLLBACK; + +And: + + -- Verify flipr:delete_flip on mysql + + BEGIN; + + SELECT sqitch.checkit(COUNT(*), 'Function "delete_flip" does not exist') + FROM mysql.proc + WHERE db = database() + AND specific_name = 'delete_flip'; + + ROLLBACK; + +The C<revert> scripts are: + + -- Revert flipr:insert_flip from mysql + + BEGIN; + + DROP FUNCTION insert_flip; + + COMMIT; + +And: + + -- Revert flipr:delete_flip from mysql + + BEGIN; + + DROP FUNCTION delete_flip; + + COMMIT; + +Check the L<example git repository|https://github.com/sqitchers/sqitch-intro> for +the complete details. Test L<C<deploy>|sqitch-deploy> and +L<C<revert>|sqitch-revert>, then commit it to the repository. The status +should end up looking something like this: + + > sqitch status --show-tags + # On database flipr_test + # Project: flipr + # Change: 7bf30e6b7b0a4e61f30dd4148f5b837bdddae086 + # Name: delete_flip + # Deployed: 2013-12-31 13:58:54 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + # Tag: + # @v1.0.0-dev1 - 2013-12-31 13:55:04 -0800 - Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Good, we've finished this feature. Time to merge back into C<master>. + +=head2 Emergency + +Let's do it: + + > git checkout master + Switched to branch 'master' + > git pull + Updating 0595297..5a58089 + Fast-forward + deploy/delete_list.sql | 22 ++++++++++++++++++++++ + deploy/insert_list.sql | 25 +++++++++++++++++++++++++ + deploy/lists.sql | 17 +++++++++++++++++ + revert/delete_list.sql | 7 +++++++ + revert/insert_list.sql | 7 +++++++ + revert/lists.sql | 7 +++++++ + sqitch.plan | 4 ++++ + verify/delete_list.sql | 10 ++++++++++ + verify/insert_list.sql | 10 ++++++++++ + verify/lists.sql | 8 ++++++++ + 10 files changed, 117 insertions(+) + create mode 100644 deploy/delete_list.sql + create mode 100644 deploy/insert_list.sql + create mode 100644 deploy/lists.sql + create mode 100644 revert/delete_list.sql + create mode 100644 revert/insert_list.sql + create mode 100644 revert/lists.sql + create mode 100644 verify/delete_list.sql + create mode 100644 verify/insert_list.sql + create mode 100644 verify/lists.sql + +Hrm, that's interesting. Looks like someone made some changes to C<master>. +They added list support. Well, let's see what happens when we merge our +changes. + + > git merge --no-ff flips + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Automatic merge failed; fix conflicts and then commit the result. + +Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged +C<lists> branch and our C<flips> branch added changes to the plan. Let's try a +different approach. + +The truth is, we got lazy. Those changes when we pulled master from the origin +should have raised a red flag. It's considered a bad practice not to look at +what's changed in C<master> before merging in a branch. What one I<should> do +is either: + +=over + +=item * + +Rebase the F<flips> branch from master before merging. This "rewinds" the +branch changes, pulls from C<master>, and then replays the changes back on top +of the pulled changes. + +=item * + +Create a patch and apply I<that> to master. This is the sort of thing you +might have to do if you're sending changes to another user, especially if the +VCS is not Git. + +=back + +So let's restore things to how they were at master: + + > git reset --hard HEAD + HEAD is now at 5a58089 Merge branch 'lists' + +That throws out our botched merge. Now let's go back to our branch and rebase +it on C<master>: + + > git checkout flips + Switched to branch 'flips' + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add flips table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Failed to merge in the changes. + Patch failed at 0001 Add flips table. + The copy of the patch that failed is found in: + .git/rebase-apply/patch + + When you have resolved this problem, run "git rebase --continue". + If you prefer to skip this patch, run "git rebase --skip" instead. + To check out the original branch and stop rebasing, run "git rebase --abort". + +Oy, that's kind of a pain. It seems like no matter what we do, we'll need to +resolve conflicts in that file. Except in Git. Fortunately for us, we can tell +Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever +append lines to the file, we can have it use the "union" merge driver, which, +according to L<its +docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>: + +=over + +Run 3-way file level merge for text files, but take lines from both versions, +instead of leaving conflict markers. This tends to leave the added lines in +the resulting file in random order and the user should verify the result. Do +not use this if you do not understand the implications. + +=back + +This has the effect of appending lines from all the merging files, which is +exactly what we need. So let's give it a try. First, back out the botched +rebase: + + > git rebase --abort + +Now add the union merge driver to F<.gitattributes> for F<sqitch.plan> +and rebase again: + + > echo sqitch.plan merge=union > .gitattributes + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add flips table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + Applying: Add functions to insert and delete flips. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + +Ah, that looks a bit better. Let's have a look at the plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-mysql-intro/ + + appuser 2013-12-31T21:04:04Z Marge N. O’Vera <marge@example.com> # Creates a an application user. + users [appuser] 2013-12-31T21:32:48Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + insert_user [users appuser] 2013-12-31T21:37:29Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user. + change_pass [users appuser] 2013-12-31T21:37:36Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password. + @v1.0.0-dev1 2013-12-31T21:41:08Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appuser users] 2013-12-31T21:46:22Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + insert_list [lists appuser] 2013-12-31T21:48:14Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list. + delete_list [lists appuser] 2013-12-31T21:49:41Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list. + flips [appuser users] 2013-12-31T21:53:03Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + insert_flip [flips appuser] 2013-12-31T21:56:12Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip. + delete_flip [flips appuser] 2013-12-31T21:56:22Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip. + +Note that it has appended the changes from the merged "lists" branch, and then +merged the changes from our "flips" branch. Test it to make sure it works as +expected: + + > sqitch rebase -y + Reverting all changes from flipr_test + - delete_flip ............... ok + - insert_flip ............... ok + - flips ..................... ok + - change_pass @v1.0.0-dev1 .. ok + - insert_user ............... ok + - users ..................... ok + - appuser ................... ok + Deploying changes to flipr_test + + appuser ................... ok + + users ..................... ok + + insert_user ............... ok + + change_pass @v1.0.0-dev1 .. ok + + lists ..................... ok + + insert_list ............... ok + + delete_list ............... ok + + flips ..................... ok + + insert_flip ............... ok + + delete_flip ............... ok + +Note the use of L<C<rebase>|sqitch-rebase>, which combines a +L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single +command. Handy, right? It correctly reverted our changes, and then deployed +them all again in the proper order. So let's commit F<.gitattributes>; seems +worthwhile to keep that change: + + > git add . + > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.' + [flips d813f7c] Add `.gitattributes` with union merge for `sqitch.plan`. + 1 file changed, 1 insertion(+) + create mode 100644 .gitattributes + +=head2 Merges Mastered + +And now, finally, we can merge into C<master>: + + > git checkout master + Switched to branch 'master' + > git merge --no-ff flips -m "Merge branch 'flips'" + Merge made by the 'recursive' strategy. + .gitattributes | 1 + + deploy/delete_flip.sql | 22 ++++++++++++++++++++++ + deploy/flips.sql | 16 ++++++++++++++++ + deploy/insert_flip.sql | 24 ++++++++++++++++++++++++ + revert/delete_flip.sql | 7 +++++++ + revert/flips.sql | 7 +++++++ + revert/insert_flip.sql | 7 +++++++ + sqitch.plan | 3 +++ + verify/delete_flip.sql | 10 ++++++++++ + verify/flips.sql | 12 ++++++++++++ + verify/insert_flip.sql | 10 ++++++++++ + 11 files changed, 119 insertions(+) + create mode 100644 .gitattributes + create mode 100644 deploy/delete_flip.sql + create mode 100644 deploy/flips.sql + create mode 100644 deploy/insert_flip.sql + create mode 100644 revert/delete_flip.sql + create mode 100644 revert/flips.sql + create mode 100644 revert/insert_flip.sql + create mode 100644 verify/delete_flip.sql + create mode 100644 verify/flips.sql + create mode 100644 verify/insert_flip.sql + +And double-check our work: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-mysql-intro/ + + appuser 2013-12-31T21:04:04Z Marge N. O’Vera <marge@example.com> # Creates a an application user. + users [appuser] 2013-12-31T21:32:48Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + insert_user [users appuser] 2013-12-31T21:37:29Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user. + change_pass [users appuser] 2013-12-31T21:37:36Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password. + @v1.0.0-dev1 2013-12-31T21:41:08Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appuser users] 2013-12-31T21:46:22Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + insert_list [lists appuser] 2013-12-31T21:48:14Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list. + delete_list [lists appuser] 2013-12-31T21:49:41Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list. + flips [appuser users] 2013-12-31T21:53:03Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + insert_flip [flips appuser] 2013-12-31T21:56:12Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip. + delete_flip [flips appuser] 2013-12-31T21:56:22Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip. + +Much much better, a nice clean master now. And because it is now identical to +the "flips" branch, we can just carry on. Go ahead and tag it, bundle, and +release: + + > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.' + Tagged "delete_flip" with @v1.0.0-dev2 + > git commit -am 'Tag the database with v1.0.0-dev2.' + [master 76d6e15] Tag the database with v1.0.0-dev2. + 1 file changed, 1 insertion(+) + > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2' + > sqitch bundle --dest-dir flipr-1.0.0-dev2 + Bundling into flipr-1.0.0-dev2 + Writing config + Writing plan + Writing scripts + + appuser + + users + + insert_user + + change_pass @v1.0.0-dev1 + + lists + + insert_list + + delete_list + + flips + + insert_flip + + delete_flip @v1.0.0-dev2 + +Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way +to create the top-level directory name so we don't have to rename it from +F<bundle>. + +=head1 In Place Changes + +Uh-oh, someone just noticed that MD5 hashing is not particularly secure. Why? +Have a look at this: + + > mysql -u root -D flipr_test --execute " + CALL insert_user('foo', 'secr3t'); + CALL insert_user('bar', 'secr3t'); + SELECT * FROM users; + " + +----------+----------------------------------+----------------------------+ + | nickname | password | timestamp | + +----------+----------------------------------+----------------------------+ + | bar | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 22:06:28.359118 | + | foo | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 22:06:28.358789 | + +----------+----------------------------------+----------------------------+ + +If user "foo" ever got access to the database, she could quickly discover that +user "bar" has the same password and thus be able to exploit the account. Not +a great idea. So we need to modify the C<insert_user()> and C<change_pass()> +functions to fix that. How? + +We can use MySQL's +L<C<ENCRYPT()>|https://dev.mysql.com/doc/refman/5.5/en/encryption-functions.html#function_encrypt> +function to encrypt passwords with a salt, so that they're all unique. But how +to deploy the changes to C<insert_user()> and C<change_pass()>? + +Normally, modifying functions in database changes is a +L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make +changes like these: + +=over + +=item 1. + +Copy F<deploy/insert_user.sql> to F<deploy/insert_user_encrypt.sql>. + +=item 2. + +Edit F<deploy/insert_user_encrypt.sql> to switch from C<MD5()> to C<ENCRYPT()>. + +=item 3. + +Copy F<deploy/insert_user.sql> to F<revert/insert_user_encrypt.sql>. +Yes, copy the original change script to the new revert change. + +=item 4. + +Copy F<verify/insert_user.sql> to F<verify/insert_user_encrypt.sql>. + +=item 5. + +Edit F<verify/insert_user_encrypt.sql> to test that the function now properly +uses C<ENCRYPT()>. + +=item 6. + +Test the changes to make sure you can deploy and revert the +C<insert_user_encrypt> change. + +=item 7. + +Now do the same for the C<change_pass> scripts. + +=back + +But you can have Sqitch do it for you. The only requirement is that a tag +appear between the two instances of a change we want to modify. In general, +you're going to make a change like this after a release, which you've tagged +anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous +section. With that, we can let Sqitch do most of the hard work for us, thanks +to the L<C<rework>|sqitch-rework> command, which is similar to +L<C<add>|sqitch-add>: + + > sqitch rework insert_user -n 'Change insert_user to use encyrpt().' + Added "insert_user [insert_user@v1.0.0-dev2]" to sqitch.plan. + Modify these files as appropriate: + * deploy/insert_user.sql + * revert/insert_user.sql + * verify/insert_user.sql + +Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in +point of fact, it has copied the files to stand in for the previous instance +of the C<insert_user> change, which we can see via C<git status>: + + > git status + # On branch master + # Your branch is ahead of 'origin/master' by 5 commits. + # (use "git push" to publish your local commits) + # + # Changes not staged for commit: + # (use "git add <file>..." to update what will be committed) + # (use "git checkout -- <file>..." to discard changes in working directory) + # + # modified: revert/insert_user.sql + # modified: sqitch.plan + # + # Untracked files: + # (use "git add <file>..." to include in what will be committed) + # + # deploy/insert_user@v1.0.0-dev2.sql + # revert/insert_user@v1.0.0-dev2.sql + # verify/insert_user@v1.0.0-dev2.sql + no changes added to commit (use "git add" and/or "git commit -a") + +The "untracked files" part of the output is the first thing to notice. They +are all named C<insert_user@v1.0.0-dev2.sql>. What that means is: "the +C<insert_user> change as it was implemented as of the C<@v1.0.0-dev2> tag." +These are copies of the original scripts, and thereafter Sqitch will find them +when it needs to run scripts for the first instance of the C<insert_user> +change. As such, it's important not to change them again. But hey, if you're +reworking the change, you shouldn't need to. + +The other thing to notice is that F<revert/insert_user.sql> has changed. +Sqitch replaced it with the original deploy script. As of now, +F<deploy/insert_user.sql> and F<revert/insert_user.sql> are identical. This is +on the assumption that the deploy script will be changed (we're reworking it, +remember?), and that the revert script should actually change things back to +how they were before. Of course, the original deploy script may not be +L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be +applied multiple times without changing the result beyond the initial +application. If it's not, you will likely need to modify it so that it +properly restores things to how they were after the original deploy script was +deployed. Or, more simply, it should revert changes back to how they were +as-of the deployment of F<deploy/insert_user@v1.0.0-dev2.sql>. + +Had MySQL supported an C<OR REPLACE> expression on C<CREATE FUNCTION> and we +had used it, our function deploy scripts would already idempotent. No matter +how many times they were run, the end results would be the same instance of +the function, with no duplicates or errors. + +Alas, such is not the case for MySQL, so we will have to modify the scripts to +drop the function before re-creating it. So let's do it. We'll modify the +scripts drop and re-create the functions with to use C<ENCRYPT()>. Make this +change to F<deploy/insert_user.sql>: + + @@ -6,13 +6,14 @@ BEGIN; + + DELIMITER // + + +DROP PROCEDURE insert_user; + CREATE PROCEDURE insert_user( + nickname VARCHAR(512), + password VARCHAR(512) + ) SQL SECURITY DEFINER + BEGIN + INSERT INTO users (nickname, password, timestamp) + - VALUES (nickname, md5(password), UTC_TIMESTAMP(6)); + + VALUES (nickname, ENCRYPT(md5(password), md5(FLOOR(RAND() * 0xFFFFFFFF))), UTC_TIMESTAMP(6)); + END + // + +We just need to add the C<DROP> statement to the revert script, +F<revert/insert_user.sql>: + + @@ -6,6 +6,7 @@ BEGIN; + + DELIMITER // + + +DROP PROCEDURE insert_user; + CREATE PROCEDURE insert_user( + nickname VARCHAR(512), + password VARCHAR(512) + +Go ahead and rework the C<change_pass> change, too: + + > sqitch rework change_pass -n 'Change change_pass to use encyrpt().' + Added "change_pass [change_pass@v1.0.0-dev2]" to sqitch.plan. + Modify these files as appropriate: + * deploy/change_pass.sql + * revert/change_pass.sql + * verify/change_pass.sql + +And make this change to F<deploy/change_pass.sql>: + + @@ -6,6 +6,7 @@ BEGIN; + + DELIMITER // + + +DROP FUNCTION change_pass; + CREATE FUNCTION change_pass( + nickname VARCHAR(512), + oldpass VARCHAR(512), + @@ -13,9 +14,9 @@ CREATE FUNCTION change_pass( + ) RETURNS INTEGER SQL SECURITY DEFINER + BEGIN + UPDATE users + - SET password = md5(newpass) + + SET password = ENCRYPT(md5(newpass), md5(FLOOR(RAND() * 0xFFFFFFFF))) + WHERE nickname = nickname + - AND password = md5(oldpass); + + AND password = ENCRYPT(md5(oldpass), password); + RETURN ROW_COUNT(); + END; + // + +And add the C<DROP FUNCTION> statement to its revert script, too: + + @@ -6,6 +6,7 @@ BEGIN; + + DELIMITER // + + +DROP FUNCTION change_pass; + CREATE FUNCTION change_pass( + nickname VARCHAR(512), + oldpass VARCHAR(512), + +And now we're ready to try a deployment: + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. ok + + change_pass .. ok + +So, are the changes deployed? + + > mysql -u root -D flipr_test --execute " + DELETE FROM users; + CALL insert_user('foo', 'secr3t'); + CALL insert_user('bar', 'secr3t'); + SELECT * FROM users; + " + +----------+---------------+----------------------------+ + | nickname | password | timestamp | + +----------+---------------+----------------------------+ + | bar | 0aasvM1.AzY0Y | 2013-12-31 22:14:45.554942 | + | foo | 80v1DpnRrqbwo | 2013-12-31 22:14:45.554457 | + +----------+---------------+----------------------------+ + +Awesome, the stored passwords are different now. But can we revert, even +though we haven't written any reversion scripts? + + > sqitch revert --to @HEAD^^ -y + Reverting changes to delete_flip @v1.0.0-dev2 from flipr_test + - change_pass .. ok + - insert_user .. ok + +Did that work, are the C<MD5()> passwords back? + + > mysql -u root -D flipr_test --execute " + DELETE FROM users; + CALL insert_user('foo', 'secr3t'); + CALL insert_user('bar', 'secr3t'); + SELECT * FROM users; + " + +----------+----------------------------------+----------------------------+ + | nickname | password | timestamp | + +----------+----------------------------------+----------------------------+ + | bar | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 22:15:29.843140 | + | foo | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 22:15:29.842700 | + +----------+----------------------------------+----------------------------+ + +Yes, it works! Sqitch properly finds the original instances of these changes +in the new script files that include tags. + +But what about the verify script? How can we verify that the functions have +been modified to use C<ENCRYPT()>? I think the simplest thing to do is to +examine the body of the function as returned by +L<C<INFORMATION_SCHEMA.ROUTINES>|https://dev.mysql.com/doc/refman/5.6/en/routines-table.html> +So the C<insert_user> verify script looks like this: + + -- Verify flipr:insert_user on mysql + + BEGIN; + + SELECT sqitch.checkit(COUNT(*), 'Procedure "insert_user" does not exist or is not up-to-date') + FROM mysql.proc + WHERE db = database() + AND specific_name = 'insert_user' + AND body_utf8 LIKE '%ENCRYPT(md5(password), md5(FLOOR(RAND() * 0xFFFFFFFF))%'; + + ROLLBACK; + +And the C<change_pass> verify script looks like this: + + -- Verify flipr:change_pass on mysql + + BEGIN; + + SELECT sqitch.checkit(COUNT(*), 'Procedure "change_pass" does not exist or is not up-to-date') + FROM mysql.proc + WHERE db = database() + AND specific_name = 'change_pass' + AND body_utf8 LIKE '%ENCRYPT(md5(oldpass), password)%'; + + ROLLBACK; + +Make sure these pass by re-deploying: + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. ok + + change_pass .. ok + +Excellent. Let's go ahead and commit these changes: + + > git add . + > git commit -m 'Use encrypt() to encrypt passwords.' + [master abcce73] Use encrypt() to encrypt passwords. + 13 files changed, 137 insertions(+), 9 deletions(-) + create mode 100644 deploy/change_pass@v1.0.0-dev2.sql + create mode 100644 deploy/insert_user@v1.0.0-dev2.sql + create mode 100644 revert/change_pass@v1.0.0-dev2.sql + create mode 100644 revert/insert_user@v1.0.0-dev2.sql + create mode 100644 verify/change_pass@v1.0.0-dev2.sql + create mode 100644 verify/insert_user@v1.0.0-dev2.sql + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 6f2e1cd4b1c031a66930811328cfcdb0389d8320 + # Name: change_pass + # Deployed: 2013-12-31 14:16:45 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 More to Come + +Sqitch is a work in progress. Better integration with version control systems +is planned to make managing idempotent reworkings even easier. Stay tuned. + +=head1 Author + +David E. Wheeler <david@justatheory.com> + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/sqitchtutorial-oracle.pod b/lib/sqitchtutorial-oracle.pod new file mode 100644 index 00000000..98d990b6 --- /dev/null +++ b/lib/sqitchtutorial-oracle.pod @@ -0,0 +1,1870 @@ +=encoding UTF-8 + +=head1 Name + +sqitchtutorial-oracle - A tutorial introduction to Sqitch change management on Oracle + +=head1 Synopsis + + sqitch * + +=head1 Description + +This tutorial explains how to create a sqitch-enabled Oracle project, use a +VCS for deployment planning, and work with other developers to make sure +changes remain in sync and in the proper order. + +We'll start by creating new project from scratch, a fictional antisocial +networking site called Flipr. All examples use L<Git|https://git-scm.com/> as +the VCS and L<Oracle|https://www.oracle.com/us/products/database/> as the +storage engine. Note that you will need to set +L<C<$ORACLE_HOME>|https://www.orafaq.com/wiki/ORACLE_HOME> so that all the +database connections will work. + +If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>. + +If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>. + +If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>. + +If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>. + +If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>. + +If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>. + +If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>. + +=head2 VM Configuration + +Some instructions for setting up a VM for following along in this tutorial. + +=over + +=item * + +See F<t/oracle.t> for instructions on downloading, installing, and configuring +the Oracle developer days VM. + +=item * + +Connect as the DBA via SQL*Plus: + + sqlplus sys/oracle@localhost/ORCL as sysdba + +=item * + +Give user C<scott> the access it needs: + + ALTER USER scott IDENTIFIED BY tiger; + GRANT ALL PRIVILEGES TO scott; + +=item * + +Add this entry to F<tnsnames.ora>: + + FLIPR_TEST = + (DESCRIPTION = + (ADDRESS = (PROTOCOL = TCP)(HOST = localhost)(PORT = 1521)) + (CONNECT_DATA = + (SERVER = DEDICATED) + (SERVICE_NAME = orcl) + ) + ) + +=back + +=head1 Starting a New Project + +Usually the first thing to do when starting a new project is to create a +source code repository. So let's do that with Git: + + > mkdir flipr + > cd flipr + > git init . + Initialized empty Git repository in /flipr/.git/ + > touch README.md + > git add . + > git commit -am 'Initialize project, add README.' + [master (root-commit) 1bd134b] Initialize project, add README. + 1 file changed, 38 insertions(+) + create mode 100644 README.md + +If you're a Git user and want to follow along the history, the repository used +in these examples is L<on GitHub|https://github.com/sqitchers/sqitch-oracle-intro>. + +Now that we have a repository, let's get started with Sqitch. Every Sqitch +project must have a name associated with it, and, optionally, a unique URI. We +recommend including the URI, as it increases the uniqueness of object +identifiers internally, so let's specify one when we initialize Sqitch: +identifiers internally, and will prevent the deployment of a different project +with the same name. So let's specify one when we initialize Sqitch: + + Created sqitch.conf + Created sqitch.plan + Created deploy/ + Created revert/ + Created verify/ + +Let's have a look at F<sqitch.conf>: + + > cat sqitch.conf + [core] + engine = oracle + # plan_file = sqitch.plan + # top_dir = . + # [engine "oracle"] + # target = db:oracle: + # registry = + # client = sqlplus + +Good, it picked up on the fact that we're creating changes for the Oracle +engine, thanks to the C<--engine oracle> option, and saved it to the file. +Furthermore, it wrote a commented-out C<[engine "oracle"]> section with all +the available Oracle engine-specific settings commented out and ready to be +edited as appropriate. This includes the path to +L<SQL*Plus|https://www.orafaq.com/wiki/SQL*Plus> in my C<$ORACLE_HOME>. + +By default, Sqitch will read F<sqitch.conf> in the current directory for +settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific +settings. Let's tell it who we are, since this data will be used in all of our +projects: + + > sqitch config --user user.name 'Marge N. O’Vera' + > sqitch config --user user.email 'marge@example.com' + +Have a look at F<~/.sqitch/sqitch.conf> and you'll see this: + + > cat ~/.sqitch/sqitch.conf + [user] + name = Marge N. O’Vera + email = marge@example.com + +Which means that Sqitch will always properly identify us when planning and +committing changes. Back to the repository. Have a look at the plan file, +F<sqitch.plan>: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-oracle-intro/ + + +Note that it has picked up on the name and URI of the app we're building. +Sqitch uses this data to manage cross-project dependencies. The +C<%syntax-version> pragma is always set by Sqitch, so that it always knows how +to parse the plan, even if the format changes in the future. + +Let's commit these changes and start creating the database changes. + + > git add . + > git commit -am 'Initialize Sqitch configuration.' + [master bd82f41] Initialize Sqitch configuration. + 2 files changed, 19 insertions(+) + create mode 100644 sqitch.conf + create mode 100644 sqitch.plan + +=head1 Our First Change + +First, our project will need an Oracle user and accompanying schema. This +creates a nice namespace for all of the objects that will be part of the flipr +app. Run this command: + + > sqitch add appschema -n 'App user and schema for all flipr objects.' + Created deploy/appschema.sql + Created revert/appschema.sql + Created verify/appschema.sql + Added "appschema" to sqitch.plan + +The L<C<add>|sqitch-add> command adds a database change to the plan and writes +deploy, revert, and verify scripts that represent the change. Now we edit +these files. The C<deploy> script's job is to create the user. So we add +this to F<deploy/appschema.sql>: + + CREATE USER flipr IDENTIFIED BY whatever; + +The C<revert> script's job is to precisely revert the change to the deploy +script, so we add this to F<revert/appschema.sql>: + + DROP USER flipr; + +Now we can try deploying this change. Before going any further, you might +need to +L<create the database|https://docs.oracle.com/cd/B28359_01/server.111/b28310/create001.htm#ADMIN11068> +and configure the SID. Assuming you have an Oracle SID named C<flipr_test> set +up in your C<F<TNSNAMES.ORA>|https://www.orafaq.com/wiki/Tnsnames.ora> file, +tell Sqitch where to send the change via a +L<database URI|https://github.com/libwww-perl/uri-db/>: + + + > sqitch deploy db:oracle://scott:tiger@/flipr_test + Adding registry tables to db:oracle://scott:@/flipr_test + Deploying changes to db:oracle://scott:@/flipr_test + + appschema .. ok + +First Sqitch created the registry tables used to track database changes. The +structure and name of the registry varies between databases, but in Oracle +they are simply stored in the current schema -- that is, the schema with the +same name as the user you've connected as. In this example, that schema is +C<scott>. Ideally, only Sqitch data will be stored in this schema, so it +probably makes the most sense to create a superuser named C<sqitch> or +something similar and use it to deploy changes. + +If you'd like it to use a different database as the registry database, use +C<sqitch engine add oracle $name> to configure it (or via the +L<C<target> command|sqitch-target>; more L<below|/On Target>). This will be +useful if you don't want to use the same registry database to manage multiple +databases on the same server. + +Next, Sqitch deploys changes to the target database, which we specified on the +command-line. We only have one change so far; the C<+> reinforces the idea +that the change is being I<added> to the database. + +With this change deployed, if you connect to the database, you'll be able to +see the schema: + + > echo "SELECT username FROM all_users WHERE username = 'FLIPR';" \ + | sqlplus -S scott/tiger@flipr_test + USERNAME + ------------------------------ + FLIPR + +=head2 Trust, But Verify + +But that's too much work. Do you really want to do something like that after +every deploy? + +Here's where the C<verify> script comes in. Its job is to test that the deploy +did was it was supposed to. It should do so without regard to any data that +might be in the database, and should throw an error if the deploy was not +successful. In Oracle, the simplest way to do so for schema is probably to +simply create an object in the schema. Put this SQL into +F<verify/appschema.sql>: + + CREATE TABLE flipr.verify__ (id int); + DROP TABLE flipr.verify__; + +In truth, you can use I<any> query that generates an SQL error if the schema +doesn't exist. This works because Sqitch configures SQL*Plus so that SQL +errors cause it to exit with the error code (more on that below). Another +handy way to do that is to divide by zero if an object doesn't exist. For +example, to throw an error when the C<flipr> schema does not exist, you could +do something like this: + + SELECT 1/COUNT(*) FROM sys.all_users WHERE username = 'FLIPR'; + +Either way, run the C<verify> script with the L<C<verify>|sqitch-verify> +command: + + > sqitch verify db:oracle://scott:tiger@/flipr_test + Verifying db:oracle://scott:@/flipr_test + * appschema .. ok + Verify successful + +Looks good! If you want to make sure that the verify script correctly dies if +the schema doesn't exist, temporarily change the schema name in the script to +something that doesn't exist, something like: + + CREATE TABLE nonesuch.verify__ (id int); + +Then L<C<verify>|sqitch-verify> again: + + > sqitch verify db:oracle://scott:tiger@/flipr_test + Verifying db:oracle://scott:@/flipr_test + * appschema .. CREATE TABLE nonesuch.verify__ (id int) + * + ERROR at line 1: + ORA-01918: user 'NONESUCH' does not exist + + + + # Verify script "verify/appschema.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +It's even nice enough to tell us what the problem is. Or, for the +divide-by-zero example, change the schema name: + + SELECT 1/COUNT(*) FROM sys.all_users WHERE username = 'NONESUCH'; + +Then the verify will look something like: + + > sqitch verify db:oracle://scott:tiger@/flipr_test + Verifying db:oracle://scott:@/flipr_test + * appschema .. SELECT 1/COUNT(*) FROM sys.all_users WHERE username = 'NONESUCH' + * + ERROR at line 1: + ORA-01476: divisor is equal to zero + + + + # Verify script "verify/appschema.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +Less useful error output, but enough to alert us that something has gone +wrong. + +Don't forget to change the schema name back before continuing! + +=head2 Status, Revert, Log, Repeat + +For purely informational purposes, we can always see how a deployment was +recorded via the L<C<status>|sqitch-status> command, which reads the registry +tables from the database: + + > sqitch status db:oracle://scott:tiger@/flipr_test + # On database db:oracle://scott:@/flipr_test + # Project: flipr + # Change: c59e700589fc03568e8f35f592c0d9b7c638cbdd + # Name: appschema + # Deployed: 2013-12-31 15:25:23 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Let's make sure that we can revert the change: + + > sqitch revert db:oracle://scott:tiger@/flipr_test + Revert all changes from db:oracle://scott:@/flipr_test? [Yes] + - appschema .. ok + +The L<C<revert>|sqitch-revert> command first prompts to make sure that we +really do want to revert. This is to prevent unnecessary accidents. You can +pass the C<-y> option to disable the prompt. Also, notice the C<-> before the +change name in the output, which reinforces that the change is being +I<removed> from the database. And now the schema should be gone: + + > echo "SELECT username FROM all_users WHERE username = 'FLIPR';" \ + | sqlplus -S scott/tiger@flipr_test + no rows selected + +And the status message should reflect as much: + + > sqitch status db:oracle://scott:tiger@/flipr_test + # On database db:oracle://scott:@/flipr_test + No changes deployed + +Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command +has nothing to verify: + + > sqitch verify db:oracle://scott:tiger@/flipr_test + Verifying db:oracle://scott:@/flipr_test + No changes deployed + +However, we still have a record that the change happened, visible via the +L<C<log>|sqitch-log> command: + + > sqitch log db:oracle://scott:tiger@/flipr_test + On database db:oracle://scott:@/flipr_test + Revert c59e700589fc03568e8f35f592c0d9b7c638cbdd + Name: appschema + Committer: Marge N. O’Vera <marge@example.com> + Date: 2013-12-31 16:19:38 -0800 + + App user and schema for all flipr objects. + + Deploy c59e700589fc03568e8f35f592c0d9b7c638cbdd + Name: appschema + Committer: Marge N. O’Vera <marge@example.com> + Date: 2013-12-31 15:25:23 -0800 + + App user and schema for all flipr objects. + +Note that the actions we took are shown in reverse chronological order, with +the revert first and then the deploy. + +Cool. Now let's commit it. + + > git add . + > git commit -m 'Add flipr schema.' + [master e0e0b11] Add flipr schema. + 4 files changed, 11 insertions(+) + create mode 100644 deploy/appschema.sql + create mode 100644 revert/appschema.sql + create mode 100644 verify/appschema.sql + +And then deploy again. This time, let's use the C<--verify> option, so that +the C<verify> script is applied when the change is deployed: + + > sqitch deploy --verify db:oracle://scott:tiger@/flipr_test + Deploying changes to db:oracle://scott:@/flipr_test + + appschema .. ok + +And now the schema should be back: + + > echo "SELECT username FROM all_users WHERE username = 'FLIPR';" \ + | sqlplus -S scott/tiger@flipr_test + USERNAME + ------------------------------ + FLIPR + +When we look at the status, the deployment will be there: + + > sqitch status db:oracle://scott:tiger@/flipr_test + # On database db:oracle://scott:@/flipr_test + # Project: flipr + # Change: c59e700589fc03568e8f35f592c0d9b7c638cbdd + # Name: appschema + # Deployed: 2013-12-31 16:22:01 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 On Target + +I'm getting a little tired of always having to type +C<db:oracle://scott:tiger@/flipr_test>, aren't you? This +L<database connection URI|https://github.com/libwww-perl/uri-db/> tells Sqitch how +to connect to the deployment target, but we don't have to keep using the URI. +We can name the target: + + > sqitch target add flipr_test db:oracle://scott:tiger@/flipr_test + +The L<C<target>|sqitch-target> command, inspired by +L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one +or more named deployment targets. We've just added a target named +C<flipr_test>, which means we can use the string C<flipr_test> for the target, +rather than the URI. But since we're doing so much testing, we can also tell +Sqitch to deploy to the C<flipr_test> target by default: + + > sqitch engine add oracle flipr_test + +Now we can omit the target argument altogether, unless we need to deploy to +another database. Which we will, eventually, but at least our examples will be +simpler from here on in, e.g.: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: c59e700589fc03568e8f35f592c0d9b7c638cbdd + # Name: appschema + # Deployed: 2013-12-31 16:22:01 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Yay, that allows things to be a little more concise. Let's also make sure that +changes are verified after deploying them: + + > sqitch config --bool deploy.verify true + > sqitch config --bool rebase.verify true + +We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime, +let's commit the new configuration and make some more changes! + + > git commit -am 'Set default target and always verify.' + [master c4a308a] Set default target and always verify. + 1 file changed, 8 insertions(+) + +=head1 Deploy with Dependency + +Let's add another change, this time to create a table. Our app will need +users, of course, so we'll create a table for them. First, add the new change: + + > sqitch add users --requires appschema -n 'Creates table to track our users.' + Created deploy/users.sql + Created revert/users.sql + Created verify/users.sql + Added "users [appschema]" to sqitch.plan + +Note that we're requiring the C<appschema> change as a dependency of the new +C<users> change. Although that change has already been added to the plan and +therefore should always be applied before the C<users> change, it's a good +idea to be explicit about dependencies. + +Now edit the scripts. When you're done, F<deploy/users.sql> should look like +this: + + -- Deploy flipr:users to oracle + -- requires: appschema + + CREATE TABLE flipr.users ( + nickname VARCHAR2(512 CHAR) PRIMARY KEY, + password VARCHAR2(512 CHAR) NOT NULL, + timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL + ); + +A few things to notice here. On the second line, the dependence on the +C<appschema> change has been listed in a comment. This doesn't do anything, +but the default Oracle C<deploy> template lists it here for your reference +while editing the file. Useful, right? + +The table itself will been created in the C<flipr> schema. This is why we need +to require the C<appschema> change. + +Notice that we've done nothing about error handling. Sqitch needs SQL*Plus +to return failure when a script experiences an error, so one might expect that +each script would need to start with lines like these: + + WHENEVER OSERROR EXIT 9 + WHENEVER SQLERROR EXIT SQL.SQLCODE + +However, Sqitch always sets these error handling parameters before it executes +your scripts, so you don't have to. + +Now for the verify script. The simplest way to check that the table was +created and has the expected columns without touching the data? Just select +from the table with a false C<WHERE> clause. Add this to F<verify/users.sql>: + + SELECT nickname, password, timestamp + FROM flipr.users + WHERE 0 = 1; + +Now for the revert script: all we have to do is drop the table. Add this to +F<revert/users.sql>: + + DROP TABLE flipr.users; + +Couldn't be much simpler, right? Let's deploy this bad boy: + + > sqitch deploy + Deploying changes to flipr_test + + users .. ok + +We know, since verification is enabled, that the table must have been created. +But for the purposes of visibility, let's have a quick look: + + > echo "DESCRIBE flipr.users;" | sqlplus -S scott/tiger@flipr_test + + Name Null? Type + ----------------------------------------- -------- ---------------------------- + NICKNAME NOT NULL VARCHAR2(512 CHAR) + PASSWORD NOT NULL VARCHAR2(512 CHAR) + TIMESTAMP NOT NULL TIMESTAMP(6) WITH TIME ZONE + +We can also verify all currently deployed changes with the +L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appschema .. ok + * users ...... ok + Verify successful + +Now have a look at the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 6840dc13beb0cd716b8bd3979b03a259c1e94405 + # Name: users + # Deployed: 2013-12-31 16:32:31 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Success! Let's make sure we can revert the change, as well: + + > sqitch revert --to @HEAD^ -y + Reverting changes to appschema from flipr_test + - users .. ok + +Note that we've used the C<--to> option to specify the change to revert to. +And what do we revert to? The symbolic tag C<@HEAD>, when passed to +L<C<revert>|sqitch-revert>, always refers to the last change deployed to the +database. (For other commands, it refers to the last change in the plan.) +Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the +last deployed change. So we revert to C<appschema>, the penultimate change. +The other potentially useful symbolic tag is C<@ROOT>, which refers to the +first change deployed to the database (or in the plan, depending on the +command). + +Back to the database. The C<users> table should be gone but the C<flipr> schema +should still be around: + + > echo "DESCRIBE flipr.users;" | sqlplus -S scott/tiger@flipr_test + + ERROR: + ORA-04043: object flipr.users does not exist + +The L<C<status>|sqitch-status> command politely informs us that we have +undeployed changes: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: c59e700589fc03568e8f35f592c0d9b7c638cbdd + # Name: appschema + # Deployed: 2013-12-31 16:22:01 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Undeployed change: + * users + +As does the L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appschema .. ok + Undeployed change: + * users + Verify successful + +Note that the verify is successful, because all currently-deployed changes are +verified. The list of undeployed changes (just "users" here) reminds us about +the current state. + +Okay, let's commit and deploy again: + + > git add . + > git commit -am 'Add users table.' + [master 2506312] Add users table. + 4 files changed, 17 insertions(+) + create mode 100644 deploy/users.sql + create mode 100644 revert/users.sql + create mode 100644 verify/users.sql + > sqitch deploy + Deploying changes to flipr_test + + users .. ok + +Looks good. Check the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 6840dc13beb0cd716b8bd3979b03a259c1e94405 + # Name: users + # Deployed: 2013-12-31 16:34:28 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Excellent. Let's do some more! + +=head1 Add Two at Once + +Let's add a couple more changes to add functions for managing users. + + > sqitch add insert_user --requires users --requires appschema \ + -n 'Creates a function to insert a user.' + Created deploy/insert_user.sql + Created revert/insert_user.sql + Created verify/insert_user.sql + Added "insert_user [users appschema]" to sqitch.plan + + > sqitch add change_pass --requires users --requires appschema \ + -n 'Creates a function to change a user password.' + Created deploy/change_pass.sql + Created revert/change_pass.sql + Created verify/change_pass.sql + Added "change_pass [users appschema]" to sqitch.plan + +Now might be a good time to have a look at the deployment plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-oracle-intro/ + + appschema 2013-12-31T22:34:42Z Marge N. O’Vera <marge@example.com> # App user and schema for all flipr objects. + users [appschema] 2014-01-01T00:31:20Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + insert_user [users appschema] 2014-01-01T00:35:21Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user. + change_pass [users appschema] 2014-01-01T00:35:28Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password. + +Each change appears on a single line with the name of the change, a bracketed +list of dependencies, a timestamp, the name and email address of the user who +planned the change, and a note. + +Let's write the code for the new changes. Here's what +F<deploy/insert_user.sql> should look like: + + -- Deploy flipr:insert_user to oracle + -- requires: users + -- requires: appschema + + CREATE OR REPLACE PROCEDURE flipr.insert_user( + nickname VARCHAR2, + password VARCHAR2 + ) AS + BEGIN + INSERT INTO flipr.users VALUES( + nickname, + LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW( + sys.dbms_obfuscation_toolkit.md5(input_string => password) + ) ) ), + DEFAULT + ); + END; + / + + SHOW ERRORS; + + -- Drop and die on error. + DECLARE + l_err_count INTEGER; + BEGIN + SELECT COUNT(*) + INTO l_err_count + FROM all_errors + WHERE owner = 'FLIPR' + AND name = 'INSERT_USER'; + + IF l_err_count > 0 THEN + EXECUTE IMMEDIATE 'DROP PROCEDURE flipr.insert_user'; + raise_application_error(-20001, 'Errors in FLIPR.INSERT_USER'); + END IF; + END; + / + +The C<DECLARE> PL/SQL block is to catch compilation warnings, which are not +normally fatal. It's admittedly +L<a bit convoluted|https://stackoverflow.com/a/16429231/79202>, but ensures that +errors propagate and a broken function get dropped. + +Here's what F<verify/insert_user.sql> might look like: + + -- Verify flipr:insert_user on oracle + DESCRIBE flipr.insert_user; + +We simply take advantage of the fact that C<DESCRIBE> throws an exception if +the specified function does not exist. + +And F<revert/insert_user.sql> should look something like this: + + -- Revert flipr:insert_user from oracle + DROP PROCEDURE flipr.insert_user; + +Now for C<change_pass>; F<deploy/change_pass.sql> might look like this: + + -- Deploy flipr:change_pass to oracle + -- requires: users + -- requires: appschema + + CREATE OR REPLACE PROCEDURE flipr.change_pass( + nick VARCHAR2, + oldpass VARCHAR2, + newpass VARCHAR2 + ) IS + flipr_auth_failed EXCEPTION; + BEGIN + UPDATE flipr.users + SET password = LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW( + sys.dbms_obfuscation_toolkit.md5(input_string => newpass) + ) ) ) + WHERE nickname = nick + AND password = LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW( + sys.dbms_obfuscation_toolkit.md5(input_string => oldpass) + ) ) ); + IF SQL%ROWCOUNT = 0 THEN RAISE flipr_auth_failed; END IF; + END; + / + + SHOW ERRORS; + + -- Drop and die on error. + DECLARE + l_err_count INTEGER; + BEGIN + SELECT COUNT(*) + INTO l_err_count + FROM all_errors + WHERE owner = 'FLIPR' + AND name = 'CHANGE_PASS'; + + IF l_err_count > 0 THEN + EXECUTE IMMEDIATE 'DROP PROCEDURE flipr.CHANGE_PASS'; + raise_application_error(-20001, 'Errors in FLIPR.CHANGE_PASS'); + END IF; + END; + / + +We again need the C<DECLARE> PL/SQL block to detect compilation warnings and +make the script die. Use C<DESCRIBE> in F<verify/change_pass.sql> again: + + -- Verify flipr:change_pass on oracle + DESCRIBE flipr.change_pass; + +And of course, its C<revert> script, F<revert/change_pass.sql>, should look +something like: + + -- Revert flipr:change_pass from oracle + DROP PROCEDURE flipr.change_pass; + +Try em out! + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. No errors. + ok + + change_pass .. No errors. + ok + +Looks good. The "No errors" notices come from the C<SHOW ERRORS> SQL*Plus +command. It's not very useful here, but very useful if there are compilation +errors. If it bothers you, you can drop the C<SHOW ERRORS> line and select the +error for display in the C<DECLARE> block, instead. + +Now, do we have the functions? Of course we do, they were verified. Still, +have a look: + + > echo "DESCRIBE flipr.insert_user;\nDESCRIBE flipr.change_pass;" \ + | sqlplus -S scott/tiger@flipr_test + + PROCEDURE flipr.insert_user + Argument Name Type In/Out Default? + ------------------------------ ----------------------- ------ -------- + NICKNAME VARCHAR2 IN + PASSWORD VARCHAR2 IN + + PROCEDURE flipr.change_pass + Argument Name Type In/Out Default? + ------------------------------ ----------------------- ------ -------- + NICK VARCHAR2 IN + OLDPASS VARCHAR2 IN + NEWPASS VARCHAR2 IN + +And what's the status? + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: e1c9df6a95da835769eb560790588c16174f78df + # Name: change_pass + # Deployed: 2013-12-31 16:37:22 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Looks good. Let's make sure revert works: + + > sqitch revert -y --to @HEAD^^ + Reverting changes to users from flipr_test + - change_pass .. ok + - insert_user .. ok + > echo "DESCRIBE flipr.insert_user;\nDESCRIBE flipr.change_pass;" \ + | sqlplus -S dwheeler/dwheeler@flipr_test + ERROR: + ORA-04043: object flipr.insert_user does not exist + + ERROR: + ORA-04043: object flipr.change_pass does not exist + +Note the use of C<@HEAD^^> to specify that the revert be to two changes prior +the last deployed change. Looks good. Let's do the commit and re-deploy dance: + + > git add . + > git commit -m 'Add `insert_user()` and `change_pass()`.' + [master 6b6797e] Add `insert_user()` and `change_pass()`. + 7 files changed, 92 insertions(+) + create mode 100644 deploy/change_pass.sql + create mode 100644 deploy/insert_user.sql + create mode 100644 revert/change_pass.sql + create mode 100644 revert/insert_user.sql + create mode 100644 verify/change_pass.sql + create mode 100644 verify/insert_user.sql + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. No errors. + ok + + change_pass .. No errors. + ok + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: e1c9df6a95da835769eb560790588c16174f78df + # Name: change_pass + # Deployed: 2013-12-31 16:38:46 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + + > sqitch verify + Verifying flipr_test + * appschema .... ok + * users ........ ok + * insert_user .. ok + * change_pass .. ok + Verify successful + +Great, we're fully up-to-date! + +=head1 Ship It! + +Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want +to have it go out with deployments tied to the release, let's tag it: + + > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.' + Tagged "change_pass" with @v1.0.0-dev1 + > git commit -am 'Tag the database with v1.0.0-dev1.' + [master eae5f71] Tag the database with v1.0.0-dev1. + 1 file changed, 1 insertion(+) + > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1' + +We can try deploying to make sure the tag gets picked up by deploying to a new +database, like so (assuming you have an Oracle SID named C<flipr_dev> that +points to a different database): + + > sqitch deploy db:oracle://scott:tiger@/flipr_dev + Adding registry tables to db:oracle://scott:@/flipr_dev + Deploying changes to db:oracle://scott:@/flipr_dev + + appschema ................. ok + + users ..................... ok + + insert_user ............... No errors. + ok + + change_pass @v1.0.0-dev1 .. No errors. + ok + +Great, all four changes were deployed and C<change_pass> was tagged with +C<@v1.0.0-dev1>. Let's have a look at the status: + + > sqitch status db:oracle://scott:tiger@/flipr_dev + # On database db:oracle://scott:tiger@/flipr_dev + # Project: flipr + # Change: e1c9df6a95da835769eb560790588c16174f78df + # Name: change_pass + # Tag: @v1.0.0-dev1 + # Deployed: 2013-12-31 16:40:02 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the listing of the tag as part of the status message. Now let's bundle +everything up for release: + + > sqitch bundle + Bundling into bundle/ + Writing config + Writing plan + Writing scripts + + appschema + + users + + insert_user + + change_pass @v1.0.0-dev1 + +Now we can package the F<bundle> directory and distribute it. When it gets +installed somewhere, users can use Sqitch to deploy to the database. Let's try +deploying it to yet another database (again, assuming you have a SID named +C<flipr_prod>: + + > cd bundle + > sqitch deploy db:oracle://scott:tiger@/flipr_prod + Adding registry tables to db:oracle://scott:@/flipr_prod + Deploying changes to flipr_prod + + appschema ................. ok + + users ..................... ok + + insert_user ............... ok + + change_pass @v1.0.0-dev1 .. ok + +Looks much the same as before, eh? Package it up and ship it! + + > cd .. + > mv bundle flipr-v1.0.0-dev1 + > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1 + +=head1 Flip Out + +Now that we've got the basics of user management done, let's get to work on +the core of our product, the "flip." Since other folks are working on other +tasks in the repository, we'll work on a branch, so we can all stay out of +each other's way. So let's branch: + + > git checkout -b flips + Switched to a new branch 'flips' + +Now we can add a new change to create a table for our flips. + + > sqitch add flips -r appschema -r users -n 'Adds table for storing flips.' + Created deploy/flips.sql + Created revert/flips.sql + Created verify/flips.sql + Added "flips [appschema users]" to sqitch.plan + +You know the drill by now. Edit F<deploy/flips.sql>: + + -- Deploy flipr:flips to oracle + -- requires: appschema + -- requires: users + + CREATE TABLE flipr.flips ( + id INTEGER PRIMARY KEY, + nickname VARCHAR2(512 CHAR) NOT NULL REFERENCES flipr.users(nickname), + body VARCHAR2(180 CHAR) NOT NULL, + timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL + ); + + CREATE SEQUENCE flipr.flip_id_seq START WITH 1 INCREMENT BY 1 NOCACHE; + + CREATE OR REPLACE TRIGGER flipr.flip_pk BEFORE INSERT ON flipr.flips + FOR EACH ROW WHEN (NEW.id IS NULL) + DECLARE + v_id flipr.flips.id%TYPE; + BEGIN + SELECT flipr.flip_id_seq.nextval INTO v_id FROM DUAL; + :new.id := v_id; + END; + / + +Edit F<verify/flips.sql>: + + -- Verify flipr:flips on oracle + DESCRIBE flipr.flips; + +And edit F<revert/flips.sql>: + + -- Revert flipr:flips from oracle + DROP TRIGGER flipr.flip_pk; + DROP SEQUENCE flipr.flip_id_seq; + DROP TABLE flipr.flips; + +And give it a whirl: + + > sqitch deploy + Deploying changes to flipr_test + + flips .. ok + +Look good? + + > sqitch status --show-tags + # On database flipr_test + # Project: flipr + # Change: 8e1573bb5ce5dfc239d5370c33d6e10820234aad + # Name: flips + # Deployed: 2013-12-31 16:51:54 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + # Tag: + # @v1.0.0-dev1 - 2013-12-31 16:44:00 -0800 - Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the use of C<--show-tags> to show all the deployed tags. Now make it so: + + > git add . + > git commit -am 'Add flips table.' + [flips bbea131] Add flips table. + 4 files changed, 32 insertions(+) + create mode 100644 deploy/flips.sql + create mode 100644 revert/flips.sql + create mode 100644 verify/flips.sql + +=head1 Wash, Rinse, Repeat + +Now comes the time to add functions to manage flips. I'm sure you have things +nailed down now. Go ahead and add C<insert_flip> and C<delete_flip> changes +and commit them. The C<insert_flip> deploy script might look something like: + + -- Deploy flipr:insert_flip to oracle + -- requires: flips + -- requires: appschema + + CREATE OR REPLACE PROCEDURE flipr.insert_flip( + nickname VARCHAR2, + body VARCHAR2 + ) AS + BEGIN + INSERT INTO flipr.flips (nickname, body) + VALUES (nickname, body); + END; + / + + SHOW ERRORS; + + -- Drop and die on error. + DECLARE + l_err_count INTEGER; + BEGIN + SELECT COUNT(*) + INTO l_err_count + FROM all_errors + WHERE owner = 'FLIPR' + AND name = 'INSERT_FLIP'; + + IF l_err_count > 0 THEN + EXECUTE IMMEDIATE 'DROP PROCEDURE flipr.insert_flip'; + raise_application_error(-20001, 'Errors in FLIPR.INSERT_FLIP'); + END IF; + END; + / + +And the C<delete_flip> deploy script might look something like: + + -- Deploy flipr:delete_flip to oracle + -- requires: flips + -- requires: appschema + + CREATE OR REPLACE PROCEDURE flipr.delete_flip( + flip_id INTEGER + ) IS + flipr_flip_delete_failed EXCEPTION; + BEGIN + DELETE FROM flipr.flips WHERE id = flip_id; + IF SQL%ROWCOUNT = 0 THEN RAISE flipr_flip_delete_failed; END IF; + END; + / + + SHOW ERRORS; + + -- Drop and die on error. + DECLARE + l_err_count INTEGER; + BEGIN + SELECT COUNT(*) + INTO l_err_count + FROM all_errors + WHERE owner = 'FLIPR' + AND name = 'DELETE_FLIP'; + + IF l_err_count > 0 THEN + EXECUTE IMMEDIATE 'DROP PROCEDURE flipr.delete_flip'; + raise_application_error(-20001, 'Errors in FLIPR.DELETE_FLIP'); + END IF; + END; + / + +The C<verify> scripts are: + + -- Verify flipr:insert_flip on oracle + DESCRIBE flipr.insert_flip; + +And: + + -- Verify flipr:delete_flip on oracle + DESCRIBE flipr.delete_flip; + +The C<revert> scripts are: + + -- Revert flipr:insert_flip from oracle + DROP PROCEDURE flipr.insert_flip; + +And: + + -- Revert flipr:delete_flip from oracle + DROP PROCEDURE flipr.delete_flip; + +Check the L<example git repository|https://github.com/sqitchers/sqitch-oracle-intro> for +the complete details. Test L<C<deploy>|sqitch-deploy> and +L<C<revert>|sqitch-revert>, then commit it to the repository. The status +should end up looking something like this: + + > sqitch status --show-tags + # On database flipr_test + # Project: flipr + # Change: a47be5a474eaad1a28546666eadeb0eba3ac12dc + # Name: delete_flip + # Deployed: 2013-12-31 16:54:31 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + # Tag: + # @v1.0.0-dev1 - 2013-12-31 16:44:00 -0800 - Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Good, we've finished this feature. Time to merge back into C<master>. + +=head2 Emergency + +Let's do it: + + > git checkout master + Switched to branch 'master' + > git pull + Updating eae5f71..a16f97c + Fast-forward + deploy/delete_list.sql | 35 +++++++++++++++++++++++++++++++++++ + deploy/insert_list.sql | 33 +++++++++++++++++++++++++++++++++ + deploy/lists.sql | 10 ++++++++++ + revert/delete_list.sql | 3 +++ + revert/insert_list.sql | 3 +++ + revert/lists.sql | 3 +++ + sqitch.plan | 4 ++++ + verify/delete_list.sql | 3 +++ + verify/insert_list.sql | 3 +++ + verify/lists.sql | 5 +++++ + 10 files changed, 102 insertions(+) + create mode 100644 deploy/delete_list.sql + create mode 100644 deploy/insert_list.sql + create mode 100644 deploy/lists.sql + create mode 100644 revert/delete_list.sql + create mode 100644 revert/insert_list.sql + create mode 100644 revert/lists.sql + create mode 100644 verify/delete_list.sql + create mode 100644 verify/insert_list.sql + create mode 100644 verify/lists.sql + +Hrm, that's interesting. Looks like someone made some changes to C<master>. +They added list support. Well, let's see what happens when we merge our +changes. + + > git merge --no-ff flips + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Automatic merge failed; fix conflicts and then commit the result. + +Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged +C<lists> branch and our C<flips> branch added changes to the plan. Let's try a +different approach. + +The truth is, we got lazy. Those changes when we pulled master from the origin +should have raised a red flag. It's considered a bad practice not to look at +what's changed in C<master> before merging in a branch. What one I<should> do +is either: + +=over + +=item * + +Rebase the F<flips> branch from master before merging. This "rewinds" the +branch changes, pulls from C<master>, and then replays the changes back on top +of the pulled changes. + +=item * + +Create a patch and apply I<that> to master. This is the sort of thing you +might have to do if you're sending changes to another user, especially if the +VCS is not Git. + +=back + +So let's restore things to how they were at master: + + > git reset --hard HEAD + HEAD is now at a16f97c Merge branch 'lists' + +That throws out our botched merge. Now let's go back to our branch and rebase +it on C<master>: + + > git checkout flips + Switched to branch 'flips' + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add flips table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Failed to merge in the changes. + Patch failed at 0001 Add flips table. + The copy of the patch that failed is found in: + .git/rebase-apply/patch + + When you have resolved this problem, run "git rebase --continue". + If you prefer to skip this patch, run "git rebase --skip" instead. + To check out the original branch and stop rebasing, run "git rebase --abort". + +Oy, that's kind of a pain. It seems like no matter what we do, we'll need to +resolve conflicts in that file. Except in Git. Fortunately for us, we can tell +Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever +append lines to the file, we can have it use the "union" merge driver, which, +according to L<its +docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>: + +=over + +Run 3-way file level merge for text files, but take lines from both versions, +instead of leaving conflict markers. This tends to leave the added lines in +the resulting file in random order and the user should verify the result. Do +not use this if you do not understand the implications. + +=back + +This has the effect of appending lines from all the merging files, which is +exactly what we need. So let's give it a try. First, back out the botched +rebase: + + > git rebase --abort + +Now add the union merge driver to F<.gitattributes> for F<sqitch.plan> +and rebase again: + + > echo sqitch.plan merge=union > .gitattributes + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add flips table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + Applying: Add functions to insert and delete flips. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + +Ah, that looks a bit better. Let's have a look at the plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-oracle-intro/ + + appschema 2013-12-31T22:34:42Z Marge N. O’Vera <marge@example.com> # App user and schema for all flipr objects. + users [appschema] 2014-01-01T00:31:20Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + insert_user [users appschema] 2014-01-01T00:35:21Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user. + change_pass [users appschema] 2014-01-01T00:35:28Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password. + @v1.0.0-dev1 2014-01-01T00:39:35Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appschema users] 2014-01-01T00:43:46Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + insert_list [lists appschema] 2014-01-01T00:45:24Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list. + delete_list [lists appschema] 2014-01-01T00:45:43Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a list. + flips [appschema users] 2014-01-01T00:51:15Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + insert_flip [flips appschema] 2014-01-01T00:53:00Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip. + delete_flip [flips appschema] 2014-01-01T00:53:16Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip. + +Note that it has appended the changes from the merged "lists" branch, and then +merged the changes from our "flips" branch. Test it to make sure it works as +expected: + + > sqitch rebase -y + Reverting all changes from flipr_test + - delete_flip ............... ok + - insert_flip ............... ok + - flips ..................... ok + - change_pass @v1.0.0-dev1 .. ok + - insert_user ............... ok + - users ..................... ok + - appschema ................. ok + Deploying changes to flipr_test + + appschema ................. ok + + users ..................... ok + + insert_user ............... No errors. + ok + + change_pass @v1.0.0-dev1 .. No errors. + ok + + lists ..................... ok + + insert_list ............... No errors. + ok + + delete_list ............... No errors. + ok + + flips ..................... ok + + insert_flip ............... No errors. + ok + + delete_flip ............... No errors. + ok + +Note the use of L<C<rebase>|sqitch-rebase>, which combines a +L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single +command. Handy, right? It correctly reverted our changes, and then deployed +them all again in the proper order. So let's commit F<.gitattributes>; seems +worthwhile to keep that change: + + > git add . + > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.' + [flips 383691f] Add `.gitattributes` with union merge for `sqitch.plan`. + 1 file changed, 1 insertion(+) + create mode 100644 .gitattributes + +=head2 Merges Mastered + +And now, finally, we can merge into C<master>: + + > git checkout master + Switched to branch 'master' + > git merge --no-ff flips -m "Merge branch 'flips'" + Merge made by the 'recursive' strategy. + .gitattributes | 1 + + deploy/delete_flip.sql | 32 ++++++++++++++++++++++++++++++++ + deploy/flips.sql | 22 ++++++++++++++++++++++ + deploy/insert_flip.sql | 32 ++++++++++++++++++++++++++++++++ + revert/delete_flip.sql | 3 +++ + revert/flips.sql | 5 +++++ + revert/insert_flip.sql | 3 +++ + sqitch.plan | 3 +++ + verify/delete_flip.sql | 3 +++ + verify/flips.sql | 3 +++ + verify/insert_flip.sql | 3 +++ + 11 files changed, 110 insertions(+) + create mode 100644 .gitattributes + create mode 100644 deploy/delete_flip.sql + create mode 100644 deploy/flips.sql + create mode 100644 deploy/insert_flip.sql + create mode 100644 revert/delete_flip.sql + create mode 100644 revert/flips.sql + create mode 100644 revert/insert_flip.sql + create mode 100644 verify/delete_flip.sql + create mode 100644 verify/flips.sql + create mode 100644 verify/insert_flip.sql + +And double-check our work: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-oracle-intro/ + + appschema 2013-12-31T22:34:42Z Marge N. O’Vera <marge@example.com> # App user and schema for all flipr objects. + users [appschema] 2014-01-01T00:31:20Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + insert_user [users appschema] 2014-01-01T00:35:21Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user. + change_pass [users appschema] 2014-01-01T00:35:28Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password. + @v1.0.0-dev1 2014-01-01T00:39:35Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appschema users] 2014-01-01T00:43:46Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + insert_list [lists appschema] 2014-01-01T00:45:24Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list. + delete_list [lists appschema] 2014-01-01T00:45:43Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a list. + flips [appschema users] 2014-01-01T00:51:15Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + insert_flip [flips appschema] 2014-01-01T00:53:00Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip. + delete_flip [flips appschema] 2014-01-01T00:53:16Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip. + +Much much better, a nice clean master now. And because it is now identical to +the "flips" branch, we can just carry on. Go ahead and tag it, bundle, and +release: + + > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.' + Tagged "delete_flip" with @v1.0.0-dev2 + > git commit -am 'Tag the database with v1.0.0-dev2.' + [master 5427456] Tag the database with v1.0.0-dev2. + 1 file changed, 1 insertion(+) + > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2' + > sqitch bundle --dest-dir flipr-1.0.0-dev2 + Bundling into flipr-1.0.0-dev2 + Writing config + Writing plan + Writing scripts + + appschema + + users + + insert_user + + change_pass @v1.0.0-dev1 + + lists + + insert_list + + delete_list + + flips + + insert_flip + + delete_flip @v1.0.0-dev2 + +Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way +to create the top-level directory name so we don't have to rename it from +F<bundle>. + +=head1 In Place Changes + +Uh-oh, someone just noticed that MD5 hashing is not particularly secure. Why? +Have a look at this: + +=begin comment + +If you get this error: + + ORA-01950: no privileges on tablespace 'USERS' + +Then connect as sysdba and grant unlimited quota to flipr: + + ALTER USER flipr QUOTA UNLIMITED ON USERS; + +=end comment + + > echo " + DELETE FROM flipr.users; + EXECUTE flipr.insert_user('foo', 's3cr3t'); + EXECUTE flipr.insert_user('bar', 's3cr3t'); + SELECT nickname, password FROM flipr.users; + " | sqlplus -S scott/tiger@flipr_test + + PL/SQL procedure successfully completed. + + + PL/SQL procedure successfully completed. + + + NICKNAME + -------------------------------------------------------------------------------- + PASSWORD + -------------------------------------------------------------------------------- + foo + a4d80eac9ab26a4a2da04125bc2c096a + + bar + a4d80eac9ab26a4a2da04125bc2c096a + +If user "foo" ever got access to the database, she could quickly discover that +user "bar" has the same password and thus be able to exploit the account. Not +a great idea. So we need to modify the C<insert_user()> and C<change_pass()> +functions to fix that. How? + +We'll create a function that encrypts passwords using a +L<cryptographic salt|https://en.wikipedia.org/wiki/Salt_(cryptography)>. This +will allow the password hashes to be stored with random hashing. So we'll need +to add the function. The deploy script should be: + + -- Deploy flipr:crypt to oracle + -- requires: appschema + + CREATE OR REPLACE FUNCTION flipr.crypt( + password VARCHAR2, + salt VARCHAR2 + ) RETURN VARCHAR2 IS + salted CHAR(10) := SUBSTR(salt, 0, 10); + BEGIN + RETURN salted || LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW( + sys.dbms_obfuscation_toolkit.md5(input_string => password || salted) + ) ) ); + END; + / + + SHOW ERRORS; + + -- Drop and die on error. + DECLARE + l_err_count INTEGER; + BEGIN + SELECT COUNT(*) + INTO l_err_count + FROM all_errors + WHERE owner = 'FLIPR' + AND name = 'CRYPT'; + + IF l_err_count > 0 THEN + EXECUTE IMMEDIATE 'DROP PROCEDURE flipr.crypt'; + raise_application_error(-20001, 'Errors in FLIPR.CRYPT'); + END IF; + END; + / + +And the revert script should be: + + -- Revert flipr:crypt. from oracle + DROP FUNCTION flipr.crypt; + +And, as usual, the verify script should just use C<DESCRIBE>: + + -- Verify flipr:crypt on oracle + DESCRIBE flipr.crypt; + +With that change in place and committed, we're ready to make use of the +improved encryption. But how to deploy the changes to C<insert_user()> and +C<change_pass()>? + +Normally, modifying functions in database changes is a +L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make +changes like these: + +=over + +=item 1. + +Copy F<deploy/insert_user.sql> to F<deploy/insert_user_crypt.sql>. + +=item 2. + +Edit F<deploy/insert_user_crypt.sql> to switch from +C<sys.dbms_obfuscation_toolkit.md5()> to C<flipr.crypt()> and to add a +dependency on the C<crypt> change. + +=item 3. + +Copy F<deploy/insert_user.sql> to F<revert/insert_user_crypt.sql>. +Yes, copy the original change script to the new revert change. + +=item 4. + +Copy F<verify/insert_user.sql> to F<verify/insert_user_crypt.sql>. + +=item 5. + +Edit F<verify/insert_user_crypt.sql> to test that the function now properly +uses C<flipr.crypt()>. + +=item 6. + +Test the changes to make sure you can deploy and revert the +C<insert_user_crypt> change. + +=item 7. + +Now do the same for the C<change_pass> scripts. + +=back + +But you can have Sqitch do it for you. The only requirement is that a tag +appear between the two instances of a change we want to modify. In general, +you're going to make a change like this after a release, which you've tagged +anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous +section. With that, we can let Sqitch do most of the hard work for us, thanks +to the L<C<rework>|sqitch-rework> command, which is similar to +L<C<add>|sqitch-add>, including support for the C<--requires> option: + + > sqitch rework insert_user --requires crypt -n 'Change insert_user to use crypt.' + Added "insert_user [insert_user@v1.0.0-dev2 crypt]" to sqitch.plan. + Modify these files as appropriate: + * deploy/insert_user.sql + * revert/insert_user.sql + * verify/insert_user.sql + +Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in +point of fact, it has copied the files to stand in for the previous instance +of the C<insert_user> change, which we can see via C<git status>: + + > git status + # On branch master + # Your branch is ahead of 'origin/master' by 2 commits. + # (use "git push" to publish your local commits) + # + # Changes not staged for commit: + # (use "git add <file>..." to update what will be committed) + # (use "git checkout -- <file>..." to discard changes in working directory) + # + # modified: revert/insert_user.sql + # modified: sqitch.plan + # + # Untracked files: + # (use "git add <file>..." to include in what will be committed) + # + # deploy/insert_user@v1.0.0-dev2.sql + # revert/insert_user@v1.0.0-dev2.sql + # verify/insert_user@v1.0.0-dev2.sql + no changes added to commit (use "git add" and/or "git commit -a") + +The "untracked files" part of the output is the first thing to notice. They +are all named C<insert_user@v1.0.0-dev2.sql>. What that means is: "the +C<insert_user> change as it was implemented as of the C<@v1.0.0-dev2> tag." +These are copies of the original scripts, and thereafter Sqitch will find them +when it needs to run scripts for the first instance of the C<insert_user> +change. As such, it's important not to change them again. But hey, if you're +reworking the change, you shouldn't need to. + +The other thing to notice is that F<revert/insert_user.sql> has changed. +Sqitch replaced it with the original deploy script. As of now, +F<deploy/insert_user.sql> and F<revert/insert_user.sql> are identical. This is +on the assumption that the deploy script will be changed (we're reworking it, +remember?), and that the revert script should actually change things back to +how they were before. Of course, the original deploy script may not be +L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be +applied multiple times without changing the result beyond the initial +application. If it's not, you will likely need to modify it so that it +properly restores things to how they were after the original deploy script was +deployed. Or, more simply, it should revert changes back to how they were +as-of the deployment of F<deploy/insert_user@v1.0.0-dev2.sql>. + +Fortunately, our function deploy scripts are already idempotent, thanks to the +use of the C<OR REPLACE> expression. No matter how many times a deployment +script is run, the end result will be the same instance of the function, with +no duplicates or errors. + +As a result, there is no need to explicitly add changes. So go ahead. Modify the +script to switch to C<crypt()>. Make this change to +F<deploy/insert_user.sql>: + + @@ -1,6 +1,7 @@ + -- Deploy flipr:insert_user to oracle + -- requires: users + -- requires: appschema + +-- requires: crypt + + CREATE OR REPLACE PROCEDURE flipr.insert_user( + nickname VARCHAR2, + @@ -9,9 +10,7 @@ CREATE OR REPLACE PROCEDURE flipr.insert_user( + BEGIN + INSERT INTO flipr.users VALUES( + nickname, + - LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW( + - sys.dbms_obfuscation_toolkit.md5(input_string => password) + - ) ) ), + + flipr.crypt(password, DBMS_RANDOM.STRING('p', 10)), + DEFAULT + ); + END; + +Go ahead and rework the C<change_pass> change, too: + + > sqitch rework change_pass --requires crypt -n 'Change change_pass to use crypt.' + Added "change_pass [change_pass@v1.0.0-dev2 crypt]" to sqitch.plan. + Modify these files as appropriate: + * deploy/change_pass.sql + * revert/change_pass.sql + * verify/change_pass.sql + +And make this change to F<deploy/change_pass.sql>: + + @@ -1,6 +1,7 @@ + -- Deploy flipr:change_pass to oracle + -- requires: users + -- requires: appschema + +-- requires: crypt + + CREATE OR REPLACE PROCEDURE flipr.change_pass( + nick VARCHAR2, + @@ -10,13 +11,9 @@ CREATE OR REPLACE PROCEDURE flipr.change_pass( + flipr_auth_failed EXCEPTION; + BEGIN + UPDATE flipr.users + - SET password = LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW( + - sys.dbms_obfuscation_toolkit.md5(input_string => newpass) + - ) ) ) + + SET password = flipr.crypt(newpass, DBMS_RANDOM.STRING('p', 10)) + WHERE nickname = nick + - AND password = LOWER( RAWTOHEX( UTL_RAW.CAST_TO_RAW( + - sys.dbms_obfuscation_toolkit.md5(input_string => oldpass) + - ) ) ); + + AND password = flipr.crypt(oldpass, password); + IF SQL%ROWCOUNT = 0 THEN RAISE flipr_auth_failed; END IF; + END; + / + +And then try a deployment: + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. No errors. + ok + + change_pass .. No errors. + ok + +So, are the changes deployed? + + > echo " + DELETE FROM flipr.users; + EXECUTE flipr.insert_user('foo', 's3cr3t'); + EXECUTE flipr.insert_user('bar', 's3cr3t'); + SELECT nickname, password FROM flipr.users; + " | sqlplus -S scott/tiger@flipr_test + + PL/SQL procedure successfully completed. + + + PL/SQL procedure successfully completed. + + + NICKNAME + -------------------------------------------------------------------------------- + PASSWORD + -------------------------------------------------------------------------------- + foo + cP?.eR!V[pf3d91ce9b7dcfe9260c6f4bb94ed0b22 + + bar + Z+l"_W_JiSefb62b789c0ff114cddcccc69c422e78 + +Awesome, the stored passwords are different now. But can we revert, even +though we haven't written any reversion scripts? + + > sqitch revert --to @HEAD^^ -y + Reverting changes to crypt from flipr_test + - change_pass .. No errors. + ok + - insert_user .. No errors. + ok + +Did that work, are the MD5 passwords back? + + > echo " + DELETE FROM flipr.users; + EXECUTE flipr.insert_user('foo', 's3cr3t'); + EXECUTE flipr.insert_user('bar', 's3cr3t'); + SELECT nickname, password FROM flipr.users; + " | sqlplus -S scott/tiger@flipr_test + + PL/SQL procedure successfully completed. + + + PL/SQL procedure successfully completed. + + + NICKNAME + -------------------------------------------------------------------------------- + PASSWORD + -------------------------------------------------------------------------------- + foo + a4d80eac9ab26a4a2da04125bc2c096a + + bar + a4d80eac9ab26a4a2da04125bc2c096a + +Yes, it works! Sqitch properly finds the original instances of these changes +in the new script files that include tags. + +But what about the verify script? How can we verify that the functions have +been modified to use C<crypt()>? I think the simplest thing to do is to +examine the body of the function by querying the +L<C<all_source>|https://docs.oracle.com/cd/B19306_01/server.102/b14237/statviews_2063.htm> +view. So the C<insert_user> verify script looks like this: + + -- Verify flipr:insert_user on oracle + + DESCRIBE flipr.insert_user; + + SELECT 1/COUNT(*) + FROM all_source + WHERE type = 'PROCEDURE' + AND name = 'INSERT_USER' + AND text LIKE '%flipr.crypt(password, DBMS_RANDOM.STRING(''p'', 10))%'; + +And the C<change_pass> verify script looks like this: + + -- Verify flipr:change_pass on oracle + + DESCRIBE flipr.change_pass; + + SELECT 1/COUNT(*) + FROM all_source + WHERE type = 'PROCEDURE' + AND name = 'CHANGE_PASS' + AND text LIKE '%password = flipr.crypt(newpass, DBMS_RANDOM.STRING(''p'', 10))%'; + +Make sure these pass by re-deploying: + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. No errors. + ok + + change_pass .. No errors. + ok + +Excellent. Let's go ahead and commit these changes: + + > git add . + > git commit -m 'Use crypt to encrypt passwords.' + [master be46175] Use crypt to encrypt passwords. + 13 files changed, 181 insertions(+), 15 deletions(-) + create mode 100644 deploy/change_pass@v1.0.0-dev2.sql + create mode 100644 deploy/insert_user@v1.0.0-dev2.sql + rewrite revert/change_pass.sql (98%) + rename revert/{change_pass.sql => change_pass@v1.0.0-dev2.sql} (100%) + rewrite revert/insert_user.sql (98%) + rename revert/{insert_user.sql => insert_user@v1.0.0-dev2.sql} (100%) + create mode 100644 verify/change_pass@v1.0.0-dev2.sql + create mode 100644 verify/insert_user@v1.0.0-dev2.sql + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 8367dc3bff7a563ec27f145421a1ffdf724cb6de + # Name: change_pass + # Deployed: 2013-12-31 17:18:28 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 More to Come + +Sqitch is a work in progress. Better integration with version control systems +is planned to make managing idempotent reworkings even easier. Stay tuned. + +=head1 Author + +David E. Wheeler <david@justatheory.com> + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/sqitchtutorial-snowflake.pod b/lib/sqitchtutorial-snowflake.pod new file mode 100644 index 00000000..7117c531 --- /dev/null +++ b/lib/sqitchtutorial-snowflake.pod @@ -0,0 +1,1419 @@ +=encoding UTF-8 + +=head1 Name + +sqitchtutorial-snowflake - A tutorial introduction to Sqitch change management on Snowflake + +=head1 Synopsis + + sqitch * + +=head1 Description + +This tutorial explains how to create a sqitch-enabled Snowflake project, use a +VCS for deployment planning, and work with other developers to make sure +changes remain in sync and in the proper order. + +We'll start by creating a new project from scratch, a fictional antisocial +networking site called Flipr. All examples use L<Git|https://git-scm.com/> as +the VCS and L<Snowflake|https://www.snowflake.net/> as the storage engine, but +for the most part you can substitute other VCSes and database engines in the +examples as appropriate. + +If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>. + +If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>. + +If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>. + +If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>. + +If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>. + +If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>. + +If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>. + +=head2 Connection Configuration + +Sqitch requires ODBC to connect to the Snowflake database. As such, you'll +need to make sure that the +L<Snowflake ODBC driver|https://docs.snowflake.net/manuals/user-guide/odbc.html> +is installed and properly configured. At its simplest, on Unix-like systems, +name the driver "Snowflake" by adding this entry to C<odbcinst.ini> (usually +found in C</etc>, C</usr/etc>, or C</usr/local/etc>): + + [Snowflake] + Description = ODBC for Snowflake + Driver = /usr/lib64/snowflake/odbc/lib/libSnowflake.so + +Note that you'll need to adjust the path depending on the version of the ODBC +driver, and where you installed it. + +See the L<Snowflake ODBC documentation|https://docs.snowflake.net/manuals/user-guide/odbc.html> +for details on downloading, installing, and configuring ODBC for your +platform. + +=head1 Starting a New Project + +Usually the first thing to do when starting a new project is to create a +source code repository. So let's do that with Git: + + > mkdir flipr + > cd flipr + > git init . + Initialized empty Git repository in /flipr/.git/ + > touch README.md + > git add . + > git commit -am 'Initialize project, add README.' + +If you're a Git user and want to follow along the history, the repository +used in these examples is +L<on GitHub|https://github.com/sqitchers/sqitch-snowflake-intro>. + +Now that we have a repository, let's get started with Sqitch. Every Sqitch +project must have a name associated with it, and, optionally, a unique URI. We +recommend including the URI, as it increases the uniqueness of object +identifiers internally, and will prevent the deployment of a different project +with the same name. So let's specify one when we initialize Sqitch: + + > sqitch init flipr --uri https://github.com/sqitchers/sqitch-snowflake-intro/ --engine snowflake + Created sqitch.conf + Created sqitch.plan + Created deploy/ + Created revert/ + Created verify/ + +Let's have a look at F<sqitch.conf>: + + > cat sqitch.conf + [core] + engine = snowflake + # plan_file = sqitch.plan + # top_dir = . + # [engine "snowflake"] + # target = db:snowflake: + # registry = sqitch + # client = snowsql + +Good, it picked up on the fact that we're creating changes for the Snowflake +engine, thanks to the C<--engine snowflake> option, and saved it to the +file. Furthermore, it wrote a commented-out C<[engine "snowflake"]> section with +all the available Snowflake engine-specific settings commented out and ready to +be edited as appropriate. + +By default, Sqitch will read F<sqitch.conf> in the current directory for +settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific +settings. Since Snowflake's C<snowsql> client is not in the path on my system, +let's go ahead an tell it where to find the client on our computer: + + > sqitch config --user engine.snowflake.client /Applications/SnowSQL.app/Contents/MacOS/snowsql + +And let's also tell it who we are, since this data will be used in all +of our projects: + + > sqitch config --user user.name 'Marge N. O’Vera' + > sqitch config --user user.email 'marge@example.com' + +Have a look at F<~/.sqitch/sqitch.conf> and you'll see this: + + > cat ~/.sqitch/sqitch.conf + [engine "snowflake"] + client = /Applications/SnowSQL.app/Contents/MacOS/snowsql + [user] + name = Marge N. O’Vera + email = marge@example.com + +Which means that Sqitch should be able to find C<snowsql> for any project, and +that it will always properly identify us when planning and committing changes. + +Back to the repository. Have a look at the plan file, F<sqitch.plan>: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-snowflake-intro/ + +Note that it has picked up on the name and URI of the app we're building. +Sqitch uses this data to manage cross-project dependencies. The +C<%syntax-version> pragma is always set by Sqitch, so that it always knows how +to parse the plan, even if the format changes in the future. + +Let's commit these changes and start creating the database changes. + + > git add . + > git commit -am 'Initialize Sqitch configuration.' + [master b731cc3] Initialize Sqitch configuration. + 2 files changed, 15 insertions(+) + create mode 100644 sqitch.conf + create mode 100644 sqitch.plan + +=head1 Our First Change + +First, our project will need a schema. This creates a nice namespace for all +of the objects that will be part of the flipr app. Run this command: + + > sqitch add appschema -n 'Add schema for all flipr objects.' + Created deploy/appschema.sql + Created revert/appschema.sql + Created verify/appschema.sql + Added "appschema" to sqitch.plan + +The L<C<add>|sqitch-add> command adds a database change to the plan and writes +deploy, revert, and verify scripts that represent the change. Now we edit +these files. The C<deploy> script's job is to create the schema. So we add +this to F<deploy/appschema.sql>: + + CREATE SCHEMA flipr; + +The C<revert> script's job is to precisely revert the change to the deploy +script, so we add this to F<revert/appschema.sql>: + + DROP SCHEMA flipr; + +Now we can try deploying this change. We tell Sqitch where to send the change +via a L<database URI|https://github.com/libwww-perl/uri-db/>. Let's say we're +using the account name C<example>, username C<movera>, database C<flipr>, and +warehouse C<sqitch>, and an ODBC driver named C<Snowflake> (see +L</Connection Configuration> for details). The URI would be structured like +this: + + db:snowflake://movera@example/flipr?Driver=Snowflake;warehouse=sqitch + +Note that Sqitch requires a C<warehouse> parameter in order to record its work +in the registry. The default warehouse is named C<sqitch>, so you can omit it +from the URI if that's the warehouse you want Sqitch to use (we'll omit it for +the remainder of this tutorial). Otherwise, specify it in the URI. Snowflake +also requires a password, which could also be included in the URI, but it's +best to put it in the C<connections> section of the +L<F<.snowsql/config> file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#configuring-default-connection-settings>. +See L<sqitch-authentication> for details. + +We just tell Sqitch to use that URI to deploy the change: + + > sqitch deploy 'db:snowflake://movera@example/flipr?Driver=Snowflake' + Adding registry tables to db:snowflake://movera@example/flipr?Driver=Snowflake + Deploying changes to db:snowflake://movera@example/flipr?Driver=Snowflake + + appschema .. ok + +First Sqitch created registry tables used to track database changes. The +structure and name of the registry varies between databases (Snowflake uses a +schema to namespace its registry, while SQLite and MySQL use separate +databases). Next, Sqitch deploys changes. We only have one so far; the C<+> +reinforces the idea that the change is being C<added> to the database. + +Note that this process can take quite a bit of time. Sqitch connects to the +database via ODBC and retains the connection throughout, but the creation of +the registry and all change scripts run through individual runs of C<snowsql>. +These connections can be quite slow. So if Sqitch seems hung, just wait; it's +most likely waiting on Snowflake. + +With this change deployed, if you connect to the database, you'll be able to +see the schema: + + > snowsql --accountname example --username movera --dbname flipr -o friendly=false \ + --query "SHOW TERSE SCHEMAS LIKE 'flipr'" + +-------------------------------+-------+------+---------------+-------------+ + | created_on | name | kind | database_name | schema_name | + |-------------------------------+-------+------+---------------+-------------| + | 2018-07-27 14:47:22.614 +0000 | FLIPR | NULL | DWHEELER | NULL | + +-------------------------------+-------+------+---------------+-------------+ + 1 Row(s) produced. Time Elapsed: 0.283s + +=head2 Trust, But Verify + +But that's too much work. Do you really want to do something like that after +every deploy? + +Here's where the C<verify> script comes in. Its job is to test that the deploy +did was it was supposed to. It should do so without regard to any data that +might be in the database, and should throw an error if the deploy was not +successful. In Snowflake, the simplest way to do so for schema is probably to +simply create an object in the schema. Put this SQL into +F<verify/appschema.sql>: + + CREATE TEMPORARY TABLE flipr.verify__ (id INT); + +In truth, you can use I<any> query that generates an SQL error if the schema +doesn't exist. Another handy way to do that is to divide by zero if an object +doesn't exist. For example, to throw an error when the C<flipr> schema does +not exist, you could do something like this: + + USE WAREHOUSE &warehouse; + SELECT 1/COUNT(*) FROM information_schema.schemata WHERE schema_name = 'FLIPR'; + +Note the C<USE WAREHOUSE> statement which is provided in the default Snowflake +change script templates. For scripts that execute queries requiring compute +resources (typically DML and C<SELECT> statements), we'll need to use a +L<virtual warehouse|https://docs.snowflake.net/manuals/user-guide/warehouses.html>. +This statement lets the script use the warehouse that Sqitch itself uses for +its registry, which should be a reasonable default, since Sqitch is +already using this warehouse. You can always change it to a different +warehouse if need be. If not, Sqitch always sets this variable (as well as +C<®istry> containing the name of the Sqitch registry schema) for all +deploy, revert, and verify script executions. + +Now run the C<verify> script with the L<C<verify>|sqitch-verify> command: + + > sqitch verify 'db:snowflake://movera@example/flipr?Driver=Snowflake' + Verifying db:snowflake://movera@example/flipr?Driver=Snowflake + * appschema .. ok + Verify successful + +Looks good! If you want to make sure that the verify script correctly dies if +the schema doesn't exist, temporarily change the schema name in the script to +something that doesn't exist, something like: + + CREATE TEMPORARY TABLE nonesuch.verify__ (id INT); + +Then L<C<verify>|sqitch-verify> again: + + > sqitch verify 'db:snowflake://movera@example/flipr?Driver=Snowflake' + Verifying db:snowflake://movera@example/flipr?Driver=Snowflake + * appschema .. + 002003 (02000): SQL compilation error: + Schema 'FLIPR.NONESUCH' does not exist. + # Verify script "verify/appschema.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +It's even nice enough to tell us what the problem is. Or, for the +divide-by-zero example, change the schema name: + + USE WAREHOUSE &warehouse; + SELECT 1/COUNT(*) FROM information_schema.schemata WHERE schema_name = 'NONESUCH'; + +Then the verify will look something like: + + > sqitch verify 'db:snowflake://movera@example/flipr?Driver=Snowflake' + Verifying db:snowflake://movera@example/flipr?Driver=Snowflake + * appschema .. + 100051 (22012): Division by zero + # Verify script "verify/appschema.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +Less useful error output, but enough to alert us that something has gone +wrong. + +Don't forget to change the schema name back before continuing! + +=head2 Status, Revert, Log, Repeat + +For purely informational purposes, we can always see how a deployment was +recorded via the L<C<status>|sqitch-status> command, which reads the registry +tables from the database: + + > sqitch status 'db:snowflake://movera@example/flipr?Driver=Snowflake' + # On database db:snowflake://movera@example/flipr?Driver=Snowflake + # Project: flipr + # Change: 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a + # Name: appschema + # Deployed: 2018-07-27 10:47:23 -0400 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Let's make sure that we can revert the change: + + > sqitch revert 'db:snowflake://movera@example/flipr?Driver=Snowflake' + Revert all changes from db:snowflake://movera@example/flipr?Driver=Snowflake? [Yes] + - appschema .. ok + +The L<C<revert>|sqitch-revert> command first prompts to make sure that we +really do want to revert. This is to prevent unnecessary accidents. You can +pass the C<-y> option to disable the prompt. Also, notice the C<-> before the +change name in the output, which reinforces that the change is being +I<removed> from the database. And now the schema should be gone: + + > snowsql --accountname example --username movera --dbname flipr -o friendly=false \ + --query "SHOW TERSE SCHEMAS LIKE 'flipr'" + +------------+------+------+---------------+-------------+ + | created_on | name | kind | database_name | schema_name | + |------------+------+------+---------------+-------------| + +------------+------+------+---------------+-------------+ + 0 Row(s) produced. Time Elapsed: 0.204s + +And the status message should reflect as much: + + > sqitch status 'db:snowflake://movera@example/flipr?Driver=Snowflake' + # On database db:snowflake://movera@example/flipr?Driver=Snowflake + No changes deployed + +Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command +has nothing to verify: + + > sqitch verify 'db:snowflake://movera@example/flipr?Driver=Snowflake' + Verifying db:snowflake://movera@example/flipr?Driver=Snowflake + No changes deployed + +However, we still have a record that the change happened, visible via the +L<C<log>|sqitch-log> command: + + > sqitch log 'db:snowflake://movera@example/flipr?Driver=Snowflake' + On database db:snowflake://movera@example/flipr?Driver=Snowflake + Revert 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a + Name: appschema + Committer: Marge N. O’Vera <marge@example.com> + Date: 2018-07-27 10:48:48 -0400 + + Add schema for all flipr objects. + + Deploy 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a + Name: appschema + Committer: Marge N. O’Vera <marge@example.com> + Date: 2018-07-27 10:47:24 -0400 + + Add schema for all flipr objects. + +Note that the actions we took are shown in reverse chronological order, with +the revert first and then the deploy. + +Cool. Now let's commit it. + + > git add . + > git commit -m 'Add flipr schema.' + [master 7fd5ace] Add flipr schema. + 4 files changed, 10 insertions(+) + create mode 100644 deploy/appschema.sql + create mode 100644 revert/appschema.sql + create mode 100644 verify/appschema.sql + +And then deploy again. This time, let's use the C<--verify> option, so that +the C<verify> script is applied when the change is deployed: + + > sqitch deploy --verify 'db:snowflake://movera@example/flipr?Driver=Snowflake' + Deploying changes to db:snowflake://movera@example/flipr?Driver=Snowflake + + appschema .. ok + +And now the schema should be back: + + > snowsql --accountname example --username movera --dbname flipr -o friendly=false \ + --query "SHOW TERSE SCHEMAS LIKE 'flipr'" + +-------------------------------+-------+------+---------------+-------------+ + | created_on | name | kind | database_name | schema_name | + |-------------------------------+-------+------+---------------+-------------| + | 2018-07-27 14:52:50.116 +0000 | FLIPR | NULL | DWHEELER | NULL | + +-------------------------------+-------+------+---------------+-------------+ + 1 Row(s) produced. Time Elapsed: 0.283s + +When we look at the status, the deployment will be there: + + > sqitch status 'db:snowflake://movera@example/flipr?Driver=Snowflake' + # On database db:snowflake://movera@example/flipr?Driver=Snowflake + # Project: flipr + # Change: 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a + # Name: appschema + # Deployed: 2018-07-27 10:52:54 -0400 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 On Target + +I'm getting a little tired of always having to type +C<db:snowflake://movera@example/flipr?Driver=Snowflake>, aren't you? +This L<database connection URI|https://github.com/libwww-perl/uri-db/> tells +Sqitch how to connect to the deployment target, but we don't have to keep +using the URI. We can name the target: + + > sqitch target add flipr_test 'db:snowflake://movera@example/flipr?Driver=Snowflake' + +The L<C<target>|sqitch-target> command, inspired by +L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one +or more named deployment targets. We've just added a target named +C<flipr_test>, which means we can use the string C<flipr_test> for the target, +rather than the URI. But since we're doing so much testing, we can also tell +Sqitch to deploy to the C<flipr_test> target by default: + + > sqitch engine add snowflake flipr_test + +Now we can omit the target argument altogether, unless we need to deploy to +another database. Which we will, eventually, but at least our examples will be +simpler from here on in, e.g.: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a + # Name: appschema + # Deployed: 2018-07-27 10:52:54 -0400 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Yay, that allows things to be a little more concise. Let's also make sure that +changes are verified after deploying them: + + > sqitch config --bool deploy.verify true + > sqitch config --bool rebase.verify true + +We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime, +let's commit the new configuration and and make some more changes! + + > git commit -am 'Set default deployment target and always verify.' + [master 3834a8d] Set default deployment target and always verify. + 1 files changed, 8 insertions(+), 0 deletions(-) + +=head1 Deploy with Dependency + +Let's add another change, this time to create a table. Our app will need +users, of course, so we'll create a table for them. First, add the new change: + + > sqitch add users --requires appschema -n 'Creates table to track our users.' + Created deploy/users.sql + Created revert/users.sql + Created verify/users.sql + Added "users [appschema]" to sqitch.plan + +Note that we're requiring the C<appschema> change as a dependency of the new +C<users> change. Although that change has already been added to the plan and +therefore should always be applied before the C<users> change, it's a good +idea to be explicit about dependencies. + +Now edit the scripts. When you're done, F<deploy/users.sql> should look like +this: + + -- Deploy flipr:users to snowflake + -- requires: appschema + + USE WAREHOUSE &warehouse; + CREATE TABLE flipr.users ( + nickname TEXT PRIMARY KEY, + password TEXT NOT NULL, + fullname TEXT NOT NULL, + twitter TEXT NOT NULL, + timestamp TIMESTAMP_TZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + +A few things to notice here. On the second line, the dependence on the +C<appschema> change has been listed. This doesn't do anything, but the default +C<deploy> Snowflake template lists it here for your reference while editing +the file. Useful, right? + +The table itself will be created in the C<flipr> schema. This is why we need +to require the C<appschema> change. + +On the fourth line, the C<USE WAREHOUSE> statement was inserted by the default +Snowflake template. We don't actually need it to create a table, but there's +no harm in leaving it here. + +Now for the verify script. The simplest way to check that the table was +created and has the expected columns without touching the data? Just select +from the table with a false C<WHERE> clause. Here the C<USE WAREHOUSE> +statement is required so that the C<SELECT> statement can actually execute. +Probably easiest just to leave the default, which uses the warehouse that +Sqitch uses to maintain its registry. Edit F<verify/users.sql> to look like +this: + + USE WAREHOUSE &warehouse; + SELECT nickname, password, fullname, twitter, timestamp + FROM flipr.users + WHERE FALSE; + +Now for the revert script: all we have to do is drop the table. Add this to +F<revert/users.sql>: + + DROP TABLE flipr.users; + +Couldn't be much simpler, right? Let's deploy this bad boy: + + > sqitch deploy + Deploying changes to flipr_test + + users .. ok + +We know, since verification is enabled, that the table must have been created. +But for the purposes of visibility, let's have a quick look: + + > snowsql --accountname example --username movera --dbname flipr -o friendly=false \ + --query "SHOW TERSE TABLES LIKE 'users' IN flipr" + +-------------------------------+-------+-------+---------------+-------------+ + | created_on | name | kind | database_name | schema_name | + |-------------------------------+-------+-------+---------------+-------------| + | 2018-07-27 15:13:21.767 +0000 | USERS | TABLE | DWHEELER | FLIPR | + +-------------------------------+-------+-------+---------------+-------------+ + 1 Row(s) produced. Time Elapsed: 0.318s + +We can also verify all currently deployed changes with the +L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appschema .. ok + * users ...... ok + Verify successful + +Now have a look at the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: d251b2c9b4bc46a4b4db6b7a8a637951484e6f6b + # Name: users + # Deployed: 2018-07-27 11:09:12 -0400 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Success! Let's make sure we can revert the change, as well: + + > sqitch revert --to @HEAD^ -y + Reverting changes to appschema from flipr_test + - users .. ok + +Note that we've used the C<--to> option to specify the change to revert to. +And what do we revert to? The symbolic tag C<@HEAD>, when passed to +L<C<revert>|sqitch-revert>, always refers to the last change deployed to the +database. (For other commands, it refers to the last change in the plan.) +Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the +last deployed change. So we revert to C<appschema>, the penultimate change. +The other potentially useful symbolic tag is C<@ROOT>, which refers to the +first change deployed to the database (or in the plan, depending on the +command). + +Back to the database. The C<users> table should be gone but the C<flipr> schema +should still be around: + + > snowsql --accountname example --username movera --dbname flipr -o friendly=false \ + --query "SHOW TERSE TABLES LIKE 'users' IN flipr" + +------------+------+------+---------------+-------------+ + | created_on | name | kind | database_name | schema_name | + |------------+------+------+---------------+-------------| + +------------+------+------+---------------+-------------+ + 0 Row(s) produced. Time Elapsed: 0.367s + +The L<C<status>|sqitch-status> command politely informs us that we have +undeployed changes: + + # On database flipr_test + # Project: flipr + # Change: 5a2ac4ae6801bfe392483ee5912b4e3592cdd57a + # Name: appschema + # Deployed: 2018-07-27 10:52:54 -0400 + # By: Marge N. O’Vera <marge@example.com> + # + Undeployed change: + * users + +As does the L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appschema .. ok + Undeployed change: + * users + Verify successful + +Note that the verify is successful, because all currently-deployed changes are +verified. The list of undeployed changes (just "users" here) reminds us about +the current state. + +Okay, let's commit and deploy again: + + > git add . + > git commit -am 'Add users table.' + [master 8c16c09] Add users table. + 4 files changed, 22 insertions(+) + create mode 100644 deploy/users.sql + create mode 100644 revert/users.sql + create mode 100644 verify/users.sql + > sqitch deploy + Deploying changes to flipr_test + + users .. ok + +Looks good. Check the status: + + > sqitch status + # Project: flipr + # Change: d251b2c9b4bc46a4b4db6b7a8a637951484e6f6b + # Name: users + # Deployed: 2018-07-27 11:19:30 -0400 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Excellent. Let's do some more! + +=head1 Add Two at Once + +Let's add a couple more changes. Our app will need to store status messages +from users. Let's call them -- and the table to store them -- "flips". And +we'll also need a view that lists user names with their flips. Let's add +changes for them both: + + > sqitch add flips -r appschema -r users -n 'Adds table for storing flips.' + Created deploy/flips.sql + Created revert/flips.sql + Created verify/flips.sql + Added "flips [appschema users]" to sqitch.plan + + > sqitch add userflips -r appschema -r users -r flips \ + -n 'Creates the userflips view.' + Created deploy/userflips.sql + Created revert/userflips.sql + Created verify/userflips.sql + Added "userflips [appschema users flips]" to sqitch.plan + +Now might be a good time to have a look at the deployment plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-snowflake-intro/ + + appschema 2018-07-27T14:27:24Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2018-07-27T15:03:56Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [appschema users] 2018-07-27T15:23:41Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [appschema users flips] 2018-07-27T15:23:50Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + +Each change appears on a single line with the name of the change, a bracketed +list of dependencies, a timestamp, the name and email address of the user who +planned the change, and a note. + +Let's write the code for the new changes. Here's what F<deploy/flips.sql> +should look like: + + -- Deploy flipr:flips to snowflake + -- requires: appschema + -- requires: users + + USE WAREHOUSE &warehouse; + CREATE TABLE flipr.flips ( + id INTEGER PRIMARY KEY, + nickname TEXT NOT NULL REFERENCES flipr.users(nickname), + body VARCHAR(180) NOT NULL DEFAULT '', + timestamp TIMESTAMP_TZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + +Here's what F<verify/flips.sql> might look like: + + -- Verify flipr:flips on snowflake + + USE WAREHOUSE &warehouse; + SELECT id, nickname, body, timestamp + FROM flipr.flips + WHERE FALSE; + +And F<revert/flips.sql> should look something like this: + + -- Revert flipr:flips from snowflake + + USE WAREHOUSE &warehouse; + DROP TABLE flipr.flips; + +Now for C<userflips>; F<deploy/userflips.sql> might look like this: + + -- Deploy flipr:userflips to snowflake + -- requires: appschema + -- requires: users + -- requires: flips + + USE WAREHOUSE &warehouse; + CREATE OR REPLACE VIEW flipr.userflips AS + SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp + FROM flipr.users u + JOIN flipr.flips f ON u.nickname = f.nickname; + +Use a C<SELECT> statement in F<verify/userflips.sql> again: + + -- Verify flipr:userflips on snowflake + + USE WAREHOUSE &warehouse; + SELECT id, nickname, fullname, body, timestamp + FROM flipr.userflips + WHERE FALSE; + +And of course, its C<revert> script, F<revert/userflips.sql>, should look +something like: + + -- Revert flipr:userflips from snowflake + + USE WAREHOUSE &warehouse; + DROP VIEW flipr.userflips; + +Try em out! + + > sqitch deploy + Deploying changes to flipr_test + + flips ...... ok + + userflips .. ok + +Do we have the new table and view? Of course we do, they were verified. Still, +have a look: + + > snowsql --accountname example --username movera --dbname flipr -o friendly=false \ + --query "SHOW TERSE TABLES LIKE 'flips' IN flipr" + +-------------------------------+-------+-------+---------------+-------------+ + | created_on | name | kind | database_name | schema_name | + |-------------------------------+-------+-------+---------------+-------------| + | 2018-07-27 15:31:07.137 +0000 | FLIPS | TABLE | DWHEELER | FLIPR | + +-------------------------------+-------+-------+---------------+-------------+ + 1 Row(s) produced. Time Elapsed: 0.225s + + > snowsql --accountname example --username movera --dbname flipr -o friendly=false \ + --query "SHOW TERSE VIEWS LIKE 'userflips' IN flipr" + +-------------------------------+-----------+------+---------------+-------------+ + | created_on | name | kind | database_name | schema_name | + |-------------------------------+-----------+------+---------------+-------------| + | 2018-07-27 15:29:25.733 +0000 | USERFLIPS | VIEW | DWHEELER | FLIPR | + +-------------------------------+-----------+------+---------------+-------------+ + 1 Row(s) produced. Time Elapsed: 0.299s + +And what's the status? + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 73cd50c99de2a8b3eab206c73514afbeb952023c + # Name: userflips + # Deployed: 2018-07-27 11:31:24 -0400 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Looks good. Let's make sure revert works: + + > sqitch revert -y --to @HEAD^^ + Reverting changes to users from flipr_test + - userflips .. ok + - flips ...... ok + + > snowsql --accountname example --username movera --dbname flipr -o friendly=false \ + --query "SHOW TERSE TABLES LIKE 'flips' IN flipr" + +------------+------+------+---------------+-------------+ + | created_on | name | kind | database_name | schema_name | + |------------+------+------+---------------+-------------| + +------------+------+------+---------------+-------------+ + 0 Row(s) produced. Time Elapsed: 0.306s + +Note the use of C<@HEAD^^> to specify that the revert be to two changes prior +the last deployed change. Looks good. Let's do the commit and re-deploy dance: + + > git add . + > git commit -m 'Add flips table and userflips view.' + [master b36f48b] Add flips table and userflips view. + 7 files changed, 43 insertions(+) + create mode 100644 deploy/flips.sql + create mode 100644 deploy/userflips.sql + create mode 100644 revert/flips.sql + create mode 100644 revert/userflips.sql + create mode 100644 verify/flips.sql + create mode 100644 verify/userflips.sql + + > sqitch deploy + Deploying changes to flipr_test + + flips ...... ok + + userflips .. ok + + > sqitch status + # Project: flipr + # Change: 73cd50c99de2a8b3eab206c73514afbeb952023c + # Name: userflips + # Deployed: 2018-07-27 11:38:02 -0400 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + + > sqitch verify + Verifying flipr_test + * appschema .. ok + * users ...... ok + * flips ...... ok + * userflips .. ok + Verify successful + +Great, we're fully up-to-date! + +=head1 Ship It! + +Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want +to have it go out with deployments tied to the release, let's tag it: + + > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.' + Tagged "userflips" with @v1.0.0-dev1 + > git commit -am 'Tag the database with v1.0.0-dev1.' + [master 84ed9db] Tag the database with v1.0.0-dev1. + 1 files changed, 1 insertion(+) + > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1' + +We can try deploying to make sure the tag gets picked up like so: + + > sqitch deploy + Nothing to deploy (up-to-date) + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 73cd50c99de2a8b3eab206c73514afbeb952023c + # Name: userflips + # Tag: @v1.0.0-dev1 + # Deployed: 2018-07-27 11:38:02 -0400 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the new "Tag" line in the output of C<sqitch status>: no new changes +needed to be deployed, but Sqitch did deploy the tag on the C<userflips> +change. Now let's bundle everything up for release: + + > sqitch bundle + Bundling into bundle + Writing config + Writing plan + Writing scripts + + appschema + + users + + flips + + userflips @v1.0.0-dev1 + +Now we can package the F<bundle> directory and distribute it. When it gets +installed somewhere, users can use Sqitch to deploy to the database. Let's try +deploying it to another database, C<flipr_prod>: + + > cd bundle + > sqitch deploy 'db:snowflake://movera@example/flipr_prod?Driver=Snowflake' + Adding registry tables to db:snowflake://movera@example/flipr_prod?Driver=Snowflake' + Deploying changes to db:snowflake://movera@example/flipr_prod?Driver=Snowflake' + + appschema ............... ok + + users ................... ok + + flips ................... ok + + userflips @v1.0.0-dev1 .. ok + +Notice how the tag on C<userflips> now appears in the deploy output. Nice, eh? +Now, package it up and ship it! + + > cd .. + > mv bundle flipr-v1.0.0-dev1 + > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1 + +=head1 Making a Hash of Things + +Now that we've got the basics of the app done, let's add a feature. Gotta +track the hashtags associated with flips, right? Let's add a table for them. +But since other folks are working on other tasks in the repository, we'll work +on a branch, so we can all stay out of each other's way. So let's branch: + + > git checkout -b hashtags + Switched to a new branch 'hashtags' + +Now we can add a new change to create a table for hashtags. + + > sqitch add hashtags --requires flips -n 'Adds table for storing hashtags.' + Created deploy/hashtags.sql + Created revert/hashtags.sql + Created verify/hashtags.sql + Added "hashtags [appschema flips]" to sqitch.plan + +You know the drill by now. Add this to F<deploy/hashtags.sql> + + CREATE TABLE flipr.hashtags ( + flip_id INTEGER NOT NULL REFERENCES flipr.flips(id), + hashtag VARCHAR(128) NOT NULL, + PRIMARY KEY (flip_id, hashtag) + ); + +Again, select from the table in F<verify/hashtags.sql>: + + SELECT flip_id, hashtag FROM flipr.hashtags WHERE FALSE; + +And drop it in F<revert/hashtags.sql> + + DROP TABLE flipr.hashtags; + +And give it a whirl: + + > sqitch deploy + Deploying changes to flipr_test + + hashtags .. ok + +Look good? + + > sqitch status --show-tags + # On database flipr_test + # Project: flipr + # Change: d750cbeec487841c45715115a31297739fbb4046 + # Name: hashtags + # Deployed: 2018-07-27 11:53:02 -0400 + # By: Marge N. O’Vera <marge@example.com> + # + # Tag: + # @v1.0.0-dev1 - 2018-07-27 11:41:13 -0400 - Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the use of C<--show-tags> to show all the deployed tags. Make sure we can +revert, too: + + > sqitch revert -y --onto @HEAD^ + Reverting changes to userflips @v1.0.0-dev1 from flipr_test + - hashtags .. ok + > sqitch deploy + Deploying changes to flipr_test + + hashtags .. ok + +Great! Now make it so: + + > git add . + > git commit -m 'Add hashtags table.' + [hashtags 06a0bf4] Add hashtags table. + 4 files changed, 19 insertions(+) + create mode 100644 deploy/hashtags.sql + create mode 100644 revert/hashtags.sql + create mode 100644 verify/hashtags.sql + +Good, we've finished this feature. Time to merge back into C<master>. + +=head2 Emergency + +Let's do it: + + > git checkout master + Switched to branch 'master' + > git pull + Updating 84ed9db..31d026c + Fast-forward + deploy/lists.sql | 11 +++++++++++ + revert/lists.sql | 4 ++++ + sqitch.plan | 2 ++ + verify/lists.sql | 6 ++++++ + 4 files changed, 23 insertions(+) + create mode 100644 deploy/lists.sql + create mode 100644 revert/lists.sql + create mode 100644 verify/lists.sql + +Hrm, that's interesting. Looks like someone made some changes to C<master>. +They added list support. Well, let's see what happens when we merge our +changes. + + > git merge --no-ff hashtags + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Automatic merge failed; fix conflicts and then commit the result. + +Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged +C<lists> branch and our C<hashtags> branch added changes to the plan. Let's +try a different approach. + +The truth is, we got lazy. Those changes when we pulled master from the origin +should have raised a red flag. It's considered a bad practice not to look at +what's changed in C<master> before merging in a branch. What one I<should> do +is either: + +=over + +=item * + +Rebase the F<hashtags> branch from master before merging. This "rewinds" the +branch changes, pulls from C<master>, and then replays the changes back on top +of the pulled changes. + +=item * + +Create a patch and apply I<that> to master. This is the sort of thing you +might have to do if you're sending changes to another user, especially if the +VCS is not Git. + +=back + +So let's restore things to how they were at master: + + > git reset --hard HEAD + HEAD is now at 31d026c Merge branch 'lists' + +That throws out our botched merge. Now let's go back to our branch and rebase +it on C<master>: + + > git checkout hashtags + Switched to branch 'hashtags' + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add hashtags table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + error: Failed to merge in the changes. + Patch failed at 0001 Add hashtags table. + Use 'git am --show-current-patch' to see the failed patch + + Resolve all conflicts manually, mark them as resolved with + "git add/rm <conflicted_files>", then run "git rebase --continue". + You can instead skip this commit: run "git rebase --skip". + To abort and get back to the state before "git rebase", run "git rebase --abort". + +Oy, that's kind of a pain. It seems like no matter what we do, we'll need to +resolve conflicts in that file. Except in Git. Fortunately for us, we can tell +Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever +append lines to the file, we can have it use the "union" merge driver, which, +according to +L<its docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>: + +=over + +Run 3-way file level merge for text files, but take lines from both versions, +instead of leaving conflict markers. This tends to leave the added lines in +the resulting file in random order and the user should verify the result. Do +not use this if you do not understand the implications. + +=back + +This has the effect of appending lines from all the merging files, which is +exactly what we need. So let's give it a try. First, back out the botched +rebase: + + > git rebase --abort + HEAD is now at 06a0bf4 Add hashtags table. + +Now add the union merge driver to F<.gitattributes> for F<sqitch.plan> +and rebase again: + + > echo sqitch.plan merge=union > .gitattributes + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add hashtags table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + +Ah, that looks a bit better. Let's have a look at the plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-snowflake-intro/ + + appschema 2018-07-27T14:27:24Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2018-07-27T15:03:56Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [appschema users] 2018-07-27T15:23:41Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [appschema users flips] 2018-07-27T15:23:50Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + @v1.0.0-dev1 2018-07-27T15:40:25Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appschema flips] 2018-07-27T16:00:00Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + hashtags [flips] 2018-07-27T15:51:16Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags. + +Note that it has appended the changes from the merged "lists" branch, and then +merged the changes from our "hashtags" branch. Test it to make sure it works +as expected: + + > sqitch rebase -y + Reverting all changes from flipr_test + - hashtags ................ ok + - userflips @v1.0.0-dev1 .. ok + - flips ................... ok + - users ................... ok + - appschema ............... ok + Deploying changes to flipr_test + + appschema ............... ok + + users ................... ok + + flips ................... ok + + userflips @v1.0.0-dev1 .. ok + + lists ................... ok + + hashtags ................ ok + +Note the use of L<C<rebase>|sqitch-rebase>, which combines a +L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single +command. Handy, right? It correctly reverted our changes, and then deployed +them all again in the proper order. So let's commit F<.gitattributes>; seems +worthwhile to keep that change: + + > git add . + > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.' + [hashtags 86596a9] Add `.gitattributes` with union merge for `sqitch.plan`. + 1 files changed, 1 insertions(+), 0 deletions(-) + create mode 100644 .gitattributes + +=head2 Merges Mastered + +And now, finally, we can merge into C<master>: + + > git checkout master + Switched to branch 'master' + > git merge --no-ff hashtags -m "Merge branch 'hashtags'" + Merge made by the 'recursive' strategy. + .gitattributes | 1 + + deploy/hashtags.sql | 9 ++++++++++ + revert/hashtags.sql | 4 ++++ + sqitch.plan | 1 + + verify/hashtags.sql | 4 ++++ + 5 files changed, 19 insertions(+) + create mode 100644 .gitattributes + create mode 100644 deploy/hashtags.sql + create mode 100644 revert/hashtags.sql + create mode 100644 verify/hashtags.sql + +And double-check our work: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-snowflake-intro/ + + appschema 2018-07-27T14:27:24Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2018-07-27T15:03:56Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [appschema users] 2018-07-27T15:23:41Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [appschema users flips] 2018-07-27T15:23:50Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + @v1.0.0-dev1 2018-07-27T15:40:25Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appschema flips] 2018-07-27T16:00:00Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + hashtags [flips] 2018-07-27T15:51:16Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags. + +Much much better, a nice clean master now. And because it is now identical to +the "hashtags" branch, we can just carry on. Go ahead and tag it, bundle, and +release: + + > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.' + Tagged "hashtags" with @v1.0.0-dev2 + > git commit -am 'Tag the database with v1.0.0-dev2.' + [master 1c67e0d] Tag the database with v1.0.0-dev2. + 1 files changed, 1 insertion(+) + > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2' + > sqitch bundle --dest-dir flipr-1.0.0-dev2 + Bundling into flipr-1.0.0-dev2 + Writing config + Writing plan + Writing scripts + + appschema + + users + + flips + + userflips @v1.0.0-dev1 + + lists + + hashtags @v1.0.0-dev2 + +Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way +to create the top-level directory name so we don't have to rename it from +F<bundle>. + +=head1 In Place Changes + +Well, some folks have been testing the C<1.0.0-dev2> release and have demanded +that Twitter user links be added to Flipr pages. Why anyone would want to +include social network links in an anti-social networking app is beyond us +programmers, but we're just the plumbers, right? Gotta go with what Product +demands. The upshot is that we need to update the C<userflips> view, which is +used for the feature in question, to include the Twitter user names. + +Normally, modifying views in database changes is a +L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make +changes like these: + +=over + +=item 1. + +Copy F<deploy/userflips.sql> to F<deploy/userflips_twitter.sql>. + +=item 2. + +Edit F<deploy/userflips_twitter.sql> to drop and re-create the view with the +C<twitter> column to the view. + +=item 3. + +Copy F<deploy/userflips.sql> to F<revert/userflips_twitter.sql>. +Yes, copy the original change script to the new revert change. + +=item 4. + +Add a C<DROP VIEW> statement to F<revert/userflips_twitter.sql>. + +=item 5. + +Copy F<verify/userflips.sql> to F<verify/userflips_twitter.sql>. + +=item 6. + +Modify F<verify/userflips_twitter.sql> to include a check for the C<twiter> +column. + +=item 7. + +Test the changes to make sure you can deploy and revert the +C<userflips_twitter> change. + +=back + +But you can have Sqitch do most of the work for you. The only requirement is +that a tag appear between the two instances of a change we want to modify. In +general, you're going to make a change like this after a release, which you've +tagged anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous +section. With that, we can let Sqitch do most of the hard work for us, thanks +to the L<C<rework>|sqitch-rework> command, which is similar to +L<C<add>|sqitch-add>: + + > sqitch rework userflips -n 'Adds userflips.twitter.' + Added "userflips [userflips@v1.0.0-dev2]" to sqitch.plan. + Modify these files as appropriate: + * deploy/userflips.sql + * revert/userflips.sql + * verify/userflips.sql + +Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in +point of fact, it has copied the files to stand in for the previous instance +of the C<userflips> change, which we can see via C<git status>: + + > git status + On branch master + Your branch is up to date with 'origin/master'. + + Changes not staged for commit: + (use "git add <file>..." to update what will be committed) + (use "git checkout -- <file>..." to discard changes in working directory) + + modified: revert/userflips.sql + modified: sqitch.plan + + Untracked files: + (use "git add <file>..." to include in what will be committed) + + deploy/userflips@v1.0.0-dev2.sql + revert/userflips@v1.0.0-dev2.sql + verify/userflips@v1.0.0-dev2.sql + + no changes added to commit (use "git add" and/or "git commit -a") + +The "Untracked files" part of the output is the first thing to notice. They're +all named C<userflips@v1.0.0-dev2.sql>. What that means is: "the C<userflips> +change as it was implemented as of the C<@v1.0.0-dev2> tag." These are copies +of the original scripts, and thereafter Sqitch will find them when it needs to +run scripts for the first instance of the C<userflips> change. As such, it's +important not to change them again. But hey, if you're reworking the change, +you shouldn't need to. + +The other thing to notice is that F<revert/userflips.sql> has changed. Sqitch +replaced it with the original deploy script. As of now, +F<deploy/userflips.sql> and F<revert/userflips.sql> are identical. This is on +the assumption that the deploy script will be changed (we're reworking it, +remember?), and that the revert script should actually change things back to +how they were before. Of course, the original deploy script may not be +L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be +applied multiple times without changing the result beyond the initial +application. If it's not, you will likely need to modify it so that it +properly restores things to how they were after the original deploy script was +deployed. Or, more simply, it should revert changes back to how they were +as-of the deployment of F<deploy/userflips@v1.0.0-dev2.sql>. + +Fortunately, our view deploy scripts are already idempotent, thanks to the +use of the C<OR REPLACE> expression. No matter how many times a deployment +script is run, the end result will be the same instance of the view, with +no duplicates or errors. + +As a result, there is no need to explicitly add changes. So go ahead. Modify +the script to add the C<twitter> column to the view. Make this change to +F<deploy/userflips.sql>: + + @@ -5,6 +5,6 @@ + + USE WAREHOUSE &warehouse; + CREATE OR REPLACE VIEW flipr.userflips AS + -SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp + +SELECT SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp + FROM flipr.users u + JOIN flipr.flips f ON u.nickname = f.nickname; + +Next, modify F<verify/userflips.sql> to check for the C<twitter> column. +Here's the diff: + + @@ -1,6 +1,6 @@ + -- Verify flipr:userflips on snowflake + + -SELECT id, nickname, fullname, body, timestamp + +SELECT id, nickname, fullname, twitter, body, timestamp + FROM flipr.userflips + WHERE FALSE; + +Now try a deployment: + + > sqitch deploy + Deploying changes to flipr_test + + userflips .. ok + +So, are the changes deployed? + + > snowsql --accountname example --username movera --dbname flipr -o friendly=false \ + --query "SHOW VIEWS LIKE 'userflips' IN flipr" + +-------------------------------+-----------+----------+---------------+-------------+--------+---------+---------------------------------------------------------------------+-----------+ + | created_on | name | reserved | database_name | schema_name | owner | comment | text | is_secure | + |-------------------------------+-----------+----------+---------------+-------------+--------+---------+---------------------------------------------------------------------+-----------| + | 2018-07-27 18:19:29.818 +0000 | USERFLIPS | | DWHEELER | FLIPR | SQITCH | | CREATE OR REPLACE VIEW flipr.userflips AS | false | + | | | | | | | | SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp | | + | | | | | | | | FROM flipr.users u | | + | | | | | | | | JOIN flipr.flips f ON u.nickname = f.nickname; | | + +-------------------------------+-----------+----------+---------------+-------------+--------+---------+---------------------------------------------------------------------+-----------+ + 1 Row(s) produced. Time Elapsed: 0.413s + +Awesome, the view now includes the C<twitter> column. But can we revert? + + > sqitch revert --to @HEAD^ -y + Reverting changes to hashtags @v1.0.0-dev2 from flipr_test + - userflips .. ok + +Did that work, is the C<twitter> column gone? + + > snowsql --accountname example --username movera --dbname flipr -o friendly=false \ + --query "SHOW VIEWS LIKE 'userflips' IN flipr" + +-------------------------------+-----------+----------+---------------+-------------+--------+---------+----------------------------------------------------------+-----------+ + | created_on | name | reserved | database_name | schema_name | owner | comment | text | is_secure | + |-------------------------------+-----------+----------+---------------+-------------+--------+---------+----------------------------------------------------------+-----------| + | 2018-07-27 18:50:52.064 +0000 | USERFLIPS | | DWHEELER | FLIPR | SQITCH | | CREATE OR REPLACE VIEW flipr.userflips AS | false | + | | | | | | | | SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp | | + | | | | | | | | FROM flipr.users u | | + | | | | | | | | JOIN flipr.flips f ON u.nickname = f.nickname; | | + +-------------------------------+-----------+----------+---------------+-------------+--------+---------+----------------------------------------------------------+-----------+ + 1 Row(s) produced. Time Elapsed: 0.362s + +Yes, it works! Sqitch properly finds the original instances of these changes +in the new script files that include tags. + +Excellent. Let's go ahead and commit these changes: + + > git add . + > git commit -m 'Add the twitter column to the userflips view.' + [master c004445] Add the twitter column to the userflips view. + 7 files changed, 31 insertions(+), 4 deletions(-) + create mode 100644 deploy/userflips@v1.0.0-dev2.sql + create mode 100644 revert/userflips@v1.0.0-dev2.sql + create mode 100644 verify/userflips@v1.0.0-dev2.sql + +=head1 More to Come + +Sqitch is a work in progress. Better integration with version control systems +is planned to make managing idempotent reworkings even easier. Stay tuned. + +=head1 Author + +David E. Wheeler <david@justatheory.com> + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/sqitchtutorial-sqlite.pod b/lib/sqitchtutorial-sqlite.pod new file mode 100644 index 00000000..30fb1c13 --- /dev/null +++ b/lib/sqitchtutorial-sqlite.pod @@ -0,0 +1,1240 @@ +=encoding UTF-8 + +=head1 Name + +sqitchtutorial-sqlite - A tutorial introduction to Sqitch change management on SQLite + +=head1 Synopsis + + sqitch * + +=head1 Description + +This tutorial explains how to create a sqitch-enabled SQLite project, use a +VCS for deployment planning, and work with other developers to make sure +changes remain in sync and in the proper order. + +We'll start by creating new project from scratch, a fictional antisocial +networking site called Flipr. All examples use L<Git|https://git-scm.com/> as +the VCS and L<SQLite|https://www.sqlite.org/> as the storage engine. + +If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>. + +If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>. + +If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>. + +If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>. + +If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>. + +If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>. + +If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>. + +=head1 Starting a New Project + +Usually the first thing to do when starting a new project is to create a +source code repository. So let's do that with Git: + + > mkdir flipr + > cd flipr + > git init . + Initialized empty Git repository in /flipr/.git/ + > touch README.md + > git add . + > git commit -m 'Initialize project, add README.' + [master (root-commit) 253542e] Initialize project, add README. + 1 file changed, 37 insertions(+) + create mode 100644 README.md + +If you're a Git user and want to follow along the history, the repository used +in these examples is L<on GitHub|https://github.com/sqitchers/sqitch-sqlite-intro>. + +Now that we have a repository, let's get started with Sqitch. Every Sqitch +project must have a name associated with it, and, optionally, a unique URI. We +recommend including the URI, as it increases the uniqueness of object +identifiers internally, and will prevent the deployment of a different project +with the same name. So let's specify one when we initialize Sqitch: + + > sqitch init flipr --uri https://github.com/sqitchers/sqitch-sqlite-intro/ --engine sqlite + Created sqitch.conf + Created sqitch.plan + Created deploy/ + Created revert/ + Created verify/ + +Let's have a look at F<sqitch.conf>: + + > cat sqitch.conf + [core] + engine = sqlite + # plan_file = sqitch.plan + # top_dir = . + # [engine "sqlite"] + # target = db:sqlite: + # registry = sqitch + # client = sqlite3 + +Good, it picked up on the fact that we're creating changes for the SQLite +engine, thanks to the C<--engine sqlite> option, and saved it to the file. +Furthermore, it wrote a commented-out C<[engine "sqlite"]> section with all +the available SQLite engine-specific settings commented out and ready to be +edited as appropriate. + +By default, Sqitch will read F<sqitch.conf> in the current directory for +settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific +settings. Since SQLite's C<sqlite3> client is not in the path on my system, +let's go ahead an tell it where to find the client on our computer. + + > sqitch config --user engine.sqlite.client /opt/local/bin/sqlite3 + +And let's also tell it who we are, since this data will be used in all +of our projects: + + > sqitch config --user user.name 'Marge N. O’Vera' + > sqitch config --user user.email 'marge@example.com' + +Have a look at F<~/.sqitch/sqitch.conf> and you'll see this: + + > cat ~/.sqitch/sqitch.conf + [engine "sqlite"] + client = /opt/local/bin/sqlite3 + [user] + name = Marge N. O’Vera + email = marge@example.com + +Which means that Sqitch should be able to find C<sqlite3> for any project, and +that it will always properly identify us when planning and committing changes. + +Back to the repository. Have a look at the plan file, F<sqitch.plan>: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-sqlite-intro/ + + +Note that it has picked up on the name and URI of the app we're building. +Sqitch uses this data to manage cross-project dependencies. The +C<%syntax-version> pragma is always set by Sqitch, so that it always knows how +to parse the plan, even if the format changes in the future. + +Let's commit these changes and start creating the database changes. + + > git add . + > git commit -m 'Initialize Sqitch configuration.' + [master 91e2f0d] Initialize Sqitch configuration. + 2 files changed, 19 insertions(+) + create mode 100644 sqitch.conf + create mode 100644 sqitch.plan + +=head1 Our First Change + +Let's create a table. Our app will need users, of course, so we'll create a +table for them. Run this command: + + > sqitch add users -n 'Creates table to track our users.' + Created deploy/users.sql + Created revert/users.sql + Created verify/users.sql + Added "users" to sqitch.plan + +The L<C<add>|sqitch-add> command adds a database change to the plan and writes +deploy, revert, and verify scripts that represent the change. Now we edit +these files. The C<deploy> script's job is to create the table. By default, +the F<deploy/users.sql> file looks like this: + + -- Deploy flipr:users to sqlite + + BEGIN; + + -- XXX Add DDLs here. + + COMMIT; + +What we want to do is to replace the C<XXX> comment with the C<CREATE TABLE> +statement, like so: + + -- Deploy flipr:users to sqlite + + BEGIN; + + CREATE TABLE users ( + nickname TEXT PRIMARY KEY, + password TEXT NOT NULL, + fullname TEXT NOT NULL, + twitter TEXT NOT NULL, + timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + COMMIT; + +The C<revert> script's job is to precisely revert the change to the deploy +script, so we edit this to F<revert/users.sql> to look like this: + + -- Revert flipr:users from sqlite + + BEGIN; + + DROP TABLE users; + + COMMIT; + +Now we can try deploying this change. We tell Sqitch where to send the change +via a L<database URI|https://github.com/libwww-perl/uri-db/>. Here we've +specified a database file, F<flipr_test.db>: + + > sqitch deploy db:sqlite:flipr_test.db + Adding registry tables to db:sqlite:sqitch.db + Deploying changes to db:sqlite:flipr_test.db + + users .. ok + +First Sqitch created the registry database and tables used to track database +changes. The registry is separate from the database to which the C<users> +change was deployed; by default, its name is C<sqitch.$suffix>, where +C<$suffix> is the same as the suffix on the target database, if any. It lives +in the same directory as the target database. This will be useful if you use +the SQLite L<C<ATTACHDATABASE>|https://www.sqlite.org/lang_attach.html> +command to manage multiple database files in a single project. In that case, +you will want to use the same file for all the databases. Keep them all in the +same directory with the same suffix and you get just that with the default +sqitch database. In this case, we should end up with two databases: + +=over + +=item * F<sqitch.db> + +The Sqitch registry database. + +=item * F<flipr_test.b> + +The database Sqitch manages. + +=back + +If you'd like it to have a different name for the registry database, use +C<sqitch engine add sqlite $name> to configure it (or via the +L<C<target> command|sqitch-target>; more L<below|/On Target>). This will be +useful if you don't want to use the same registry database to manage multiple +databases, or if you do, but they live in different directories. + +Next, Sqitch deploys changes to the target database, which we specified on the +command-line. We only have one so far; the C<+> reinforces the idea that the +change is being I<added> to the database. + +With this change deployed, if you connect to the database, you'll be able to +see the schema: + + > sqlite3 flipr_test.db '.tables' + users + +=head2 Trust, But Verify + +But that's too much work. do you really want to do something like that after +every deploy? + +Here's where the C<verify> script comes in. Its job is to test that the deploy +did was it was supposed to. It should do so without regard to any data that +might be in the database, and should throw an error if the deploy was not +successful. The easiest way to do that with a table is to simply C<SELECT> +from it. Put this query into F<verify/users.sql>: + + SELECT nickname, password, fullname, twitter + FROM users + WHERE 0; + +Now you can run the C<verify> script with the L<C<verify>|sqitch-verify> +command: + + > sqitch verify db:sqlite:flipr_test.db + Verifying db:sqlite:flipr_test.db + * users .. ok + Verify successful + +Looks good! If you want to make sure that the verify script correctly dies if +the table doesn't exist, temporarily change the table name in the script to +something that doesn't exist, something like: + + SELECT nickname, password, timestamp + FROM users_nonesuch + WHERE 0; + +Then L<C<verify>|sqitch-verify> again: + + > sqitch verify db:sqlite:flipr_test.db + Verifying db:sqlite:flipr_test.db + * users .. Error: near line 5: no such table: users_nonesuch + # Verify script "verify/users.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +SQLite is kind enough to tell us what the problem is. Don't forget to change +the table name back before continuing! + +=head2 Status, Revert, Log, Repeat + +For purely informational purposes, we can always see how a deployment was +recorded via the L<C<status>|sqitch-status> command, which reads the tables +from the registry database: + + > sqitch status db:sqlite:flipr_test.db + # On database db:sqlite:flipr_test.db + # Project: flipr + # Change: f30fe47f5f99501fb8d481e910d9112c5ac0a676 + # Name: users + # Deployed: 2013-12-31 10:26:59 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Let's make sure that we can revert the change: + + > sqitch revert db:sqlite:flipr_test.db + Revert all changes from db:sqlite:flipr_test.db? [Yes] + - users .. ok + +The L<C<revert>|sqitch-revert> command first prompts to make sure that we +really do want to revert. This is to prevent unnecessary accidents. You can +pass the C<-y> option to disable the prompt. Also, notice the C<-> before the +change name in the output, which reinforces that the change is being +I<removed> from the database. And now the schema should be gone: + + > sqlite3 flipr_test.db '.tables' + +And the status message should reflect as much: + + > sqitch status db:sqlite:flipr_test.db + # On database db:sqlite:flipr_test.db + No changes deployed + +Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command +has nothing to verify: + + > sqitch verify db:sqlite:flipr_test.db + Verifying db:sqlite:flipr_test.db + No changes deployed + +However, we still have a record that the change happened, visible via the +L<C<log>|sqitch-log> command: + + > sqitch log db:sqlite:flipr_test.db + On database db:sqlite:flipr_test.db + Revert f30fe47f5f99501fb8d481e910d9112c5ac0a676 + Name: users + Committer: Marge N. O’Vera <marge@example.com> + Date: 2013-12-31 10:53:25 -0800 + + Creates table to track our users. + + Deploy f30fe47f5f99501fb8d481e910d9112c5ac0a676 + Name: users + Committer: Marge N. O’Vera <marge@example.com> + Date: 2013-12-31 10:26:59 -0800 + + Creates table to track our users. + +Note that the actions we took are shown in reverse chronological order, with +the revert first and then the deploy. + +Cool. Let's tell Git to ignore F<*.db> files and then commit it. + + > echo '*.db' > .gitignore + > git add . + > git commit -m 'Add users table.' + [master 6725454] Add users table. + 5 files changed, 31 insertions(+) + create mode 100644 .gitignore + create mode 100644 deploy/users.sql + create mode 100644 revert/users.sql + create mode 100644 verify/users.sql + +And then deploy again. This time, let's use the C<--verify> option, so that +the C<verify> script is applied when the change is deployed: + + > sqitch deploy db:sqlite:flipr_test.db --verify + Deploying changes to db:sqlite:flipr_test.db + + users .. ok + +And now the C<users> table should be back: + + > sqlite3 flipr_test.db '.tables' + users + +When we look at the status, the deployment will be there: + + > sqitch status db:sqlite:flipr_test.db + # On database db:sqlite:flipr_test.db + # Project: flipr + # Change: f30fe47f5f99501fb8d481e910d9112c5ac0a676 + # Name: users + # Deployed: 2013-12-31 10:57:55 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 On Target + +I'm getting a little tired of always having to type +C<db:sqlite:flipr_test.db>, aren't you? This +L<database connection URI|https://github.com/libwww-perl/uri-db/> tells Sqitch how +to connect to the deployment target, but we don't have to keep using the URI. +We can name the target: + + > sqitch target add flipr_test db:sqlite:flipr_test.db + +The L<C<target>|sqitch-target> command, inspired by +L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one +or more named deployment targets. We've just added a target named +C<flipr_test>, which means we can use the string C<flipr_test> for the target, +rather than the URI. But since we're doing so much testing, we can also tell +Sqitch to deploy to the C<flipr_test> target by default: + + > sqitch engine add sqlite flipr_test + +Now we can omit the target argument altogether, unless we need to deploy to +another database. Which we will, eventually, but at least our examples will be +simpler from here on in, e.g.: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: f30fe47f5f99501fb8d481e910d9112c5ac0a676 + # Name: users + # Deployed: 2013-12-31 10:57:55 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Yay, that allows things to be a little more concise. Let's also make sure that +changes are verified after deploying them: + + > sqitch config --bool deploy.verify true + > sqitch config --bool rebase.verify true + +We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime, +let's commit the new configuration and and make some more changes! + + > git commit -am 'Set default target and always verify.' + [master 5fb57ec] Set default target and always verify. + 1 file changed, 8 insertions(+) + +=head1 Deploy with Dependency + +Let's add another change. Our app will need to store status messages from +users. Let's call them -- and the table to store them -- "flips". First, add +the new change: + + > sqitch add flips --requires users -n 'Adds table for storing flips.' + Created deploy/flips.sql + Created revert/flips.sql + Created verify/flips.sql + Added "flips [users]" to sqitch.plan + +Note that we're requiring the C<users> change as a dependency of the new +C<flips> change. Although that change has already been added to the plan and +therefore should always be applied before the C<flips> change, it's a good +idea to be explicit about dependencies. + +Now edit the scripts. When you're done, F<deploy/flips.sql> should look like +this: + + -- Deploy flipr:flips to sqlite + -- requires: users + + BEGIN; + + CREATE TABLE flips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nickname TEXT NOT NULL REFERENCES users(nickname), + body TEXT NOT NULL DEFAULT '' CHECK ( length(body) <= 180 ), + timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + COMMIT; + +A couple things to notice here. On the second line, the dependence on the +C<users> change has been listed. This doesn't do anything, but the default +C<deploy> template lists it here for your reference while editing the file. +Useful, right? + +The C<users.nickname> column references the C<users> table. This is why we +need to require the C<users> change. + +Now for the verify script. Again, all we need to do is C<SELECT> from the +table. I recommend selecting each column by name, too, to be sure that no +column is missing. Here's the F<verify/flips.sql>: + + -- Verify flipr:flips on sqlite + + BEGIN; + + SELECT id, nickname, body, timestamp + FROM flips + WHERE 0; + + ROLLBACK; + +Now for the revert script: all we have to do is drop the table. Add this to +F<revert/flips.sql>: + + -- Revert flipr:flips from sqlite + + BEGIN; + + DROP TABLE flips; + + COMMIT; + +Couldn't be much simpler, right? Let's deploy this bad boy: + + > sqitch deploy + Deploying changes to flipr_test + + flips .. ok + +We know, since verification is enabled, that the table must have been created. +But for the purposes of visibility, let's have a quick look: + + > sqlite3 flipr_test.db '.tables' + flips users + +We can also verify all currently deployed changes with the +L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * users .. ok + * flips .. ok + Verify successful + +Now have a look at the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 32ee57069c0d7fec52b6f86f453dc0c16bc1090a + # Name: flips + # Deployed: 2013-12-31 11:02:51 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Success! Let's make sure we can revert the change, as well: + + > sqitch revert --to @HEAD^ -y + Reverting changes to users from flipr_test + - flips .. ok + +Note that we've used the C<--to> option to specify the change to revert to. +And what do we revert to? The symbolic tag C<@HEAD>, when passed to +L<C<revert>|sqitch-revert>, always refers to the last change deployed to the +database. (For other commands, it refers to the last change in the plan.) +Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the +last deployed change. So we revert to C<users>, the penultimate change. The +other potentially useful symbolic tag is C<@ROOT>, which refers to the first +change deployed to the database (or in the plan, depending on the command). + +Back to the database. The C<flips> table should be gone but the C<users> table +should still be around: + + > sqlite3 flipr_test.db '.tables' + users + +The L<C<status>|sqitch-status> command politely informs us that we have +undeployed changes: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: f30fe47f5f99501fb8d481e910d9112c5ac0a676 + # Name: users + # Deployed: 2013-12-31 10:57:55 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Undeployed change: + * flips + +As does the L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * users .. ok + Undeployed change: + * flips + Verify successful + +Note that the verify is successful, because all currently-deployed changes are +verified. The list of undeployed changes (just "flips" here) reminds us about +the current state. + +Okay, let's commit and deploy again: + + > git add . + > git commit -am 'Add flips table.' + [master 21cba95] Add flips table. + 4 files changed, 30 insertions(+) + create mode 100644 deploy/flips.sql + create mode 100644 revert/flips.sql + create mode 100644 verify/flips.sql + > sqitch deploy + Deploying changes to flipr_test + + flips .. ok + +Looks good. Check the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 32ee57069c0d7fec52b6f86f453dc0c16bc1090a + # Name: flips + # Deployed: 2013-12-31 11:05:44 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 View to a Thrill + +One more thing to add before we are ready to ship a first beta release. Let's +create a view that lists user names with their flips. + + > sqitch add userflips --requires users --requires flips \ + -n 'Creates the userflips view.' + Created deploy/userflips.sql + Created revert/userflips.sql + Created verify/userflips.sql + Added "userflips [users flips]" to sqitch.plan + +Now add this SQL to F<deploy/userflips.sql>: + + CREATE VIEW userflips AS + SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp + FROM users u + JOIN flips f ON u.nickname = f.nickname; + +Add this SQL to F<verify/userflips.sql> + + SELECT id, nickname, fullname, body, timestamp + FROM userflips + WHERE 0; + +And add the C<DROP VIEW> statement to F<revert/userflips.sql>: + + DROP VIEW userflips; + +Now Try it out! + + > sqitch deploy + Deploying changes to flipr_test + + userflips .. ok + > sqitch revert -y + Reverting all changes from flipr_test + - userflips .. ok + - flips ...... ok + - users ...... ok + > sqitch deploy + Deploying changes to flipr_test + + users ...... ok + + flips ...... ok + + userflips .. ok + +Looks good! Commit it. + + > git add . + > git commit -m 'Add the userflips view.' + [master c74bfb4] Add the userflips view. + 4 files changed, 29 insertions(+) + create mode 100644 deploy/userflips.sql + create mode 100644 revert/userflips.sql + create mode 100644 verify/userflips.sql + +=head1 Ship It! + +Now we're ready for the first development release of our app. Let's call it +C<1.0.0-dev1> Since we want to have it go out with deployments tied to the +release, let's tag it: + + > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.' + Tagged "userflips" with @v1.0.0-dev1 + > git commit -am 'Tag the database with v1.0.0-dev1.' + [master 7a479fd] Tag the database with v1.0.0-dev1. + 1 file changed, 1 insertion(+) + > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1' + +We can try deploying to make sure the tag gets picked up like so: + + > mkdir dev + > sqitch deploy db:sqlite:dev/flipr.db + Adding registry tables to db:sqlite:dev/sqitch.db + Deploying changes to db:sqlite:dev/flipr.db + + users ................... ok + + flips ................... ok + +Great, both changes were deployed and C<userflips> was tagged with +C<@v1.0.0-dev1>. Let's have a look at the status: + + > sqitch status db:sqlite:dev/flipr_dev.db + # On database db:sqlite:dev/flipr_dev.db + # Project: flipr + # Change: 60ee3aba0445bf3287f9dc1dd97b1877523fa139 + # Name: userflips + # Tag: @v1.0.0-dev1 + # Deployed: 2013-12-31 11:19:15 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the listing of the tag as part of the status message. Now let's bundle +everything up for release: + + > rm -rf dev + > sqitch bundle + Bundling into bundle + Writing config + Writing plan + Writing scripts + + users + + flips + + userflips @v1.0.0-dev1 + +Now we can package the F<bundle> directory and distribute it. When it gets +installed somewhere, users can use Sqitch to deploy to the database. Let's try +deploying it: + + > cd bundle + > sqitch deploy db:sqlite:flipr_prod.db + Adding registry tables to db:sqlite:sqitch.db + Deploying changes to db:sqlite:flipr_prod.db + + users ................... ok + + flips ................... ok + + userflips @v1.0.0-dev1 .. ok + +Looks much the same as before, eh? Package it up and ship it! + + > rm *.db + > cd .. + > mv bundle flipr-v1.0.0-dev1 + > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1 + +=head1 Making a Hash of Things + +Now that we've got the basics of the app done, let's add a feature. Gotta +track the hashtags associated with flips, right? Let's add a table for them. +But since other folks are working on other tasks in the repository, we'll work +on a branch, so we can all stay out of each other's way. So let's branch: + + > git checkout -b hashtags + Switched to a new branch 'hashtags' + +Now we can add a new change to create a table for hashtags. + + > sqitch add hashtags --requires flips -n 'Adds table for storing hashtags.' + Created deploy/hashtags.sql + Created revert/hashtags.sql + Created verify/hashtags.sql + Added "hashtags [flips]" to sqitch.plan + +You know the drill by now. Add this to F<deploy/hashtags.sql> + + CREATE TABLE hashtags ( + flip_id INTEGER NOT NULL REFERENCES flips(id), + hashtag TEXT NOT NULL CHECK ( length(hashtag) > 0 ), + PRIMARY KEY (flip_id, hashtag) + ); + +Again, select from the table in F<verify/hashtags.sql>: + + SELECT flip_id, hashtag FROM hashtags WHERE 0; + +And drop it in F<revert/hashtags.sql> + + DROP TABLE hashtags; + +And give it a whirl: + + > sqitch deploy + Deploying changes to flipr_test + + hashtags .. ok + +Look good? + + > sqitch status --show-tags + # On database flipr_test + # Project: flipr + # Change: 1352464e8b5f3d5eeac76a1986379f07de43bffd + # Name: hashtags + # Deployed: 2013-12-31 11:30:53 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + # Tag: + # @v1.0.0-dev1 - 2013-12-31 11:13:49 -0800 - Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the use of C<--show-tags> to show all the deployed tags. Make sure we can +revert, too: + + > sqitch revert --to @HEAD^ -y + Reverting changes to userflips @v1.0.0-dev1 from flipr_test + - hashtags .. ok + > sqitch deploy + Deploying changes to flipr_test + + hashtags .. ok + +Great! Now make it so: + + > git add . + > git commit -m 'Add hashtags table.' + [hashtags 94f02b8] Add hashtags table. + 4 files changed, 28 insertions(+) + create mode 100644 deploy/hashtags.sql + create mode 100644 revert/hashtags.sql + create mode 100644 verify/hashtags.sql + +Good, we've finished this feature. Time to merge back into C<master>. + +=head2 Emergency + +Let's do it: + + > git checkout master + Switched to branch 'master' + > git pull + Updating 7a479fd..47a4107 + Fast-forward + deploy/lists.sql | 13 +++++++++++++ + revert/lists.sql | 7 +++++++ + sqitch.plan | 2 ++ + verify/lists.sql | 9 +++++++++ + 4 files changed, 31 insertions(+) + create mode 100644 deploy/lists.sql + create mode 100644 revert/lists.sql + create mode 100644 verify/lists.sql + +Hrm, that's interesting. Looks like someone made some changes to C<master>. +They added list support. Well, let's see what happens when we merge our +changes. + + > git merge --no-ff hashtags + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Automatic merge failed; fix conflicts and then commit the result. + +Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged +C<lists> branch and our C<hashtags> branch added changes to the plan. Let's +try a different approach. + +The truth is, we got lazy. Those changes when we pulled master from the origin +should have raised a red flag. It's considered a bad practice not to look at +what's changed in C<master> before merging in a branch. What one I<should> do +is either: + +=over + +=item * + +Rebase the F<hashtags> branch from master before merging. This "rewinds" the +branch changes, pulls from C<master>, and then replays the changes back on top +of the pulled changes. + +=item * + +Create a patch and apply I<that> to master. This is the sort of thing you +might have to do if you're sending changes to another user, especially if the +VCS is not Git. + +=back + +So let's restore things to how they were at master: + + > git reset --hard HEAD + HEAD is now at 47a4107 Merge branch 'lists' + +That throws out our botched merge. Now let's go back to our branch and rebase +it on C<master>: + + > git checkout hashtags + Switched to branch 'hashtags' + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add hashtags table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Failed to merge in the changes. + Patch failed at 0001 Add hashtags table. + The copy of the patch that failed is found in: + .git/rebase-apply/patch + + When you have resolved this problem, run "git rebase --continue". + If you prefer to skip this patch, run "git rebase --skip" instead. + To check out the original branch and stop rebasing, run "git rebase --abort". + +Oy, that's kind of a pain. It seems like no matter what we do, we'll need to +resolve conflicts in that file. Except in Git. Fortunately for us, we can tell +Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever +append lines to the file, we can have it use the "union" merge driver, which, +according to L<its +docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>: + +=over + +Run 3-way file level merge for text files, but take lines from both versions, +instead of leaving conflict markers. This tends to leave the added lines in +the resulting file in random order and the user should verify the result. Do +not use this if you do not understand the implications. + +=back + +This has the effect of appending lines from all the merging files, which is +exactly what we need. So let's give it a try. First, back out the botched +rebase: + + > git rebase --abort + +Now add the union merge driver to F<.gitattributes> for F<sqitch.plan> +and rebase again: + + > echo sqitch.plan merge=union > .gitattributes + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add hashtags table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + +Ah, that looks a bit better. Let's have a look at the plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-sqlite-intro/ + + users 2013-12-31T18:06:04Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [users] 2013-12-31T19:01:40Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [users flips] 2013-12-31T19:11:11Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + @v1.0.0-dev1 2013-12-31T19:13:02Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [users] 2013-12-31T19:28:05Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + hashtags [flips] 2013-12-31T19:30:13Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags. + +Note that it has appended the changes from the merged "lists" branch, and then +merged the changes from our "hashtags" branch. Test it to make sure it works +as expected: + + > sqitch rebase -y + Reverting all changes from flipr_test + - hashtags ................ ok + - userflips @v1.0.0-dev1 .. ok + - flips ................... ok + - users ................... ok + Deploying changes to flipr_test + + users ................... ok + + flips ................... ok + + userflips @v1.0.0-dev1 .. ok + + lists ................... ok + + hashtags ................ ok + +Note the use of L<C<rebase>|sqitch-rebase>, which combines a +L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single +command. Handy, right? It correctly reverted our changes, and then deployed +them all again in the proper order. So let's commit F<.gitattributes>; seems +worthwhile to keep that change: + + > git add . + > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.' + [hashtags 4f93ac4] Add `.gitattributes` with union merge for `sqitch.plan`. + 1 file changed, 1 insertion(+) + create mode 100644 .gitattributes + +=head2 Merges Mastered + +And now, finally, we can merge into C<master>: + + > git checkout master + Switched to branch 'master' + > git merge --no-ff hashtags -m "Merge branch 'hashtags'" + Merge made by the 'recursive' strategy. + .gitattributes | 1 + + deploy/hashtags.sql | 12 ++++++++++++ + revert/hashtags.sql | 7 +++++++ + sqitch.plan | 1 + + verify/hashtags.sql | 7 +++++++ + 5 files changed, 28 insertions(+) + create mode 100644 .gitattributes + create mode 100644 deploy/hashtags.sql + create mode 100644 revert/hashtags.sql + create mode 100644 verify/hashtags.sql + +And double-check our work: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-sqlite-intro/ + + users 2013-12-31T18:06:04Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [users] 2013-12-31T19:01:40Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [users flips] 2013-12-31T19:11:11Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + @v1.0.0-dev1 2013-12-31T19:13:02Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [users] 2013-12-31T19:28:05Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + hashtags [flips] 2013-12-31T19:30:13Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags. + +Much much better, a nice clean master now. And because it is now identical to +the "hashtags" branch, we can just carry on. Go ahead and tag it, bundle, and +release: + + > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.' + Tagged "hashtags" with @v1.0.0-dev2 + > git commit -am 'Tag the database with v1.0.0-dev2.' + [master 7abfd9b] Tag the database with v1.0.0-dev2. + 1 file changed, 1 insertion(+) + > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2' + > sqitch bundle --dest-dir flipr-1.0.0-dev2 + Bundling into flipr-1.0.0-dev2 + Writing config + Writing plan + Writing scripts + + users + + flips + + userflips @v1.0.0-dev1 + + lists + + hashtags @v1.0.0-dev2 + +Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way +to create the top-level directory name so we don't have to rename it from +F<bundle>. + +=head1 In Place Changes + +Well, some folks have been testing the C<1.0.0-dev2> release and have demanded +that Twitter user links be added to Flipr pages. Why anyone would want to +include social network links in an anti-social networking app is beyond us +programmers, but we're just the plumbers, right? Gotta go with what Product +demands. The upshot is that we need to update the C<userflips> view, which is +used for the feature in question, to include the Twitter user names. + +Normally, modifying views in database changes is a +L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make +changes like these: + +=over + +=item 1. + +Copy F<deploy/userflips.sql> to F<deploy/userflips_twitter.sql>. + +=item 2. + +Edit F<deploy/userflips_twitter.sql> to drop and re-create the view with the +C<twitter> column to the view. + +=item 3. + +Copy F<deploy/userflips.sql> to F<revert/userflips_twitter.sql>. +Yes, copy the original change script to the new revert change. + +=item 4. + +Add a C<DROP VIEW> statement to F<revert/userflips_twitter.sql>. + +=item 5. + +Copy F<verify/userflips.sql> to F<verify/userflips_twitter.sql>. + +=item 6. + +Modify F<verify/userflips_twitter.sql> to include a check for the C<twiter> +column. + +=item 7. + +Test the changes to make sure you can deploy and revert the +C<userflips_twitter> change. + +=back + +But you can have Sqitch do most of the work for you. The only requirement is +that a tag appear between the two instances of a change we want to modify. In +general, you're going to make a change like this after a release, which you've +tagged anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous +section. With that, we can let Sqitch do most of the hard work for us, thanks +to the L<C<rework>|sqitch-rework> command, which is similar to +L<C<add>|sqitch-add>: + + > sqitch rework userflips -n 'Adds userflips.twitter.' + Added "userflips [userflips@v1.0.0-dev2]" to sqitch.plan. + Modify these files as appropriate: + * deploy/userflips.sql + * revert/userflips.sql + * verify/userflips.sql + +Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in +point of fact, it has copied the files to stand in for the previous instance +of the C<userflips> change, which we can see via C<git status>: + + > git status + # On branch master + # Your branch is ahead of 'origin/master' by 4 commits. + # (use "git push" to publish your local commits) + # + # Changes not staged for commit: + # (use "git add <file>..." to update what will be committed) + # (use "git checkout -- <file>..." to discard changes in working directory) + # + # modified: revert/userflips.sql + # modified: sqitch.plan + # + # Untracked files: + # (use "git add <file>..." to include in what will be committed) + # + # deploy/userflips@v1.0.0-dev2.sql + # revert/userflips@v1.0.0-dev2.sql + # verify/userflips@v1.0.0-dev2.sql + no changes added to commit (use "git add" and/or "git commit -a") + +The "untracked files" part of the output is the first thing to notice. They +are all named C<userflips@v1.0.0-dev2.sql>. What that means is: "the +C<userflips> change as it was implemented as of the C<@v1.0.0-dev2> tag." +These are copies of the original scripts, and thereafter Sqitch will find them +when it needs to run scripts for the first instance of the C<userflips> +change. As such, it's important not to change them again. But hey, if you're +reworking the change, you shouldn't need to. + +The other thing to notice is that F<revert/userflips.sql> has changed. Sqitch +replaced it with the original deploy script. As of now, +F<deploy/userflips.sql> and F<revert/userflips.sql> are identical. This is on +the assumption that the deploy script will be changed (we're reworking it, +remember?), and that the revert script should actually change things back to +how they were before. Of course, the original deploy script won't be +L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be +applied multiple times without changing the result beyond the initial +application. It could be if SQLite supported C<CREATE OR REPLACE VIEW>, but +since it doesn't, we will have to edit the script to drop the view before +creating it. Or, more simply, it needs to be updated to revert changes back to +how they were as-of the deployment of F<deploy/userflips@v1.0.0-dev2.sql>. + +Modify F<deploy/userflips.sql> to add the C<twitter> column; in fact, let's +also add a C<DROP VIEW IF EXISTS> statement, in case we need to rework this +change again in the future: + + @@ -4,8 +4,9 @@ + + BEGIN; + + +DROP VIEW IF EXISTS userflips; + CREATE VIEW userflips AS + -SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp + +SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp + FROM users u + JOIN flips f ON u.nickname = f.nickname; + + +Next, modify F<verify/userflips.sql> to check for the C<twitter> column. +Here's the diff: + + @@ -2,7 +2,7 @@ + + BEGIN; + + -SELECT id, nickname, fullname, body, timestamp + +SELECT id, nickname, fullname, twitter, body, timestamp + FROM userflips + WHERE 0; + +And finally, modify F<revert/userflips@v1.0.0-dev2.sql> to drop the view +before creating it: + + @@ -4,6 +4,7 @@ + + BEGIN; + + +DROP VIEW IF EXISTS userflips; + CREATE VIEW userflips AS + SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp + FROM users u + +Note that if we had included that statement when we originally created the +C<userflips> change, we wouldn't have to change this file at all. + +Now try a deployment: + + > sqitch deploy + Deploying changes to flipr_test + + userflips .. ok + +So, are the changes deployed? + + > sqlite3 flipr_test.db '.schema userflips' + CREATE VIEW userflips AS + SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp + FROM users u + JOIN flips f ON u.nickname = f.nickname; + +Awesome, the view now includes the C<twitter> column. But can we revert? + + > sqitch revert --to @HEAD^ -y + Reverting changes to hashtags @v1.0.0-dev2 from flipr_test + - userflips .. ok + +Did that work, is the C<twitter> column gone? + + > sqlite3 flipr_test.db '.schema userflips' + CREATE VIEW userflips AS + SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp + FROM users u + JOIN flips f ON u.nickname = f.nickname; + +Yes, it works! Sqitch properly finds the original instances of these changes +in the new script files that include tags. + +Excellent. Let's go ahead and commit these changes: + + > git add . + > git commit -m 'Add the twitter column to the userflips view.' + [master 3eb96d9] Add the twitter column to the userflips view. + 7 files changed, 40 insertions(+), 4 deletions(-) + create mode 100644 deploy/userflips@v1.0.0-dev2.sql + create mode 100644 revert/userflips@v1.0.0-dev2.sql + create mode 100644 verify/userflips@v1.0.0-dev2.sql + +=head1 More to Come + +Sqitch is a work in progress. Better integration with version control systems +is planned to make managing idempotent reworkings even easier. Stay tuned. + +=head1 Author + +David E. Wheeler <david@justatheory.com> + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/sqitchtutorial-vertica.pod b/lib/sqitchtutorial-vertica.pod new file mode 100644 index 00000000..ef6ebdec --- /dev/null +++ b/lib/sqitchtutorial-vertica.pod @@ -0,0 +1,1390 @@ +=encoding UTF-8 + +=head1 Name + +sqitchtutorial-vertica - A tutorial introduction to Sqitch change management on Vertica + +=head1 Synopsis + + sqitch * + +=head1 Description + +This tutorial explains how to create a sqitch-enabled Vertica project, use a +VCS for deployment planning, and work with other developers to make sure +changes remain in sync and in the proper order. + +We'll start by creating a new project from scratch, a fictional antisocial +networking site called Flipr. All examples use L<Git|https://git-scm.com/> as +the VCS and L<Vertica|https://my.vertica.com/> as the storage engine, but for +the most part you can substitute other VCSes and database engines in the +examples as appropriate. + +If you'd like to manage a PostgreSQL database, see L<sqitchtutorial>. + +If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>. + +If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>. + +If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>. + +If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>. + +If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>. + +If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>. + +=head2 Connection Configuration + +Sqitch requires ODBC to connect to the Vertica database. As such, you'll need +to make sure that the Vertica ODBC driver is properly configured. At its +simplest, on Unix-like systems, name the driver "Vertica" by adding this entry +to C<odbcinst.ini> (usually found in C</etc>, C</usr/etc>, or +C</usr/local/etc>): + + [Vertica] + Description = ODBC for Vertica + Driver = /opt/vertica/lib64/libverticaodbc.so + +And also creating a C<vertica.ini> file in the same directory that contains: + + [Driver] + DriverManagerEncoding=UTF-16 + ODBCInstLib=/usr/lib64/libodbcinst.so + ErrorMessagesPath=/opt/vertica/lib64 + +You might also consider naming your database connection by putting an entry in +C<odbc.ini> (same directory), like so (assuming that Vertica is running on +your local host): + + [dbadmin] + Description = Vertica dbadmin connection + Driver = Vertica + Database = dbadmin + Servername = localhost + UserName = dbadmin + Password = password + Port = 5433 + Locale = en_US + +See the +L<Vertica ODBC Documentation|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/InstallingDrivers/CreatingAnODBCDataSourceNameDSN.htm> +for details. Specific links: + +=over + +=item * L<Unix ODBC Configuration|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/InstallingDrivers/CreatingAnODBCDSNForLinuxSolarisAIXAndHP-UX.htm> + +=item * L<Additional Linux ODBC Configuration (C<vertica.ini>)|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/ClientODBC/AdditionalODBCDriverConfigurationSettings.htm> + +=item * L<Windows ODBC Configuration|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/InstallingDrivers/CreatingAnODBCDSNForWindowsClients.htm> + +=item * L<Mac OS X ODBC Configuration|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/ConnectingToHPVertica/InstallingDrivers/CreatingAnODBCDSNForMacintoshOSXClients.htm> + +=back + +=head1 Starting a New Project + +Usually the first thing to do when starting a new project is to create a +source code repository. So let's do that with Git: + + > mkdir flipr + > cd flipr + > git init . + Initialized empty Git repository in /flipr/.git/ + > touch README.md + > git add . + > git commit -am 'Initialize project, add README.' + +If you're a Git user and want to follow along the history, the repository +used in these examples is +L<on GitHub|https://github.com/sqitchers/sqitch-vertica-intro>. + +Now that we have a repository, let's get started with Sqitch. Every Sqitch +project must have a name associated with it, and, optionally, a unique URI. We +recommend including the URI, as it increases the uniqueness of object +identifiers internally, and will prevent the deployment of a different project +with the same name. So let's specify one when we initialize Sqitch: + + > sqitch init flipr --uri https://github.com/sqitchers/sqitch-vertica-intro/ --engine vertica + Created sqitch.conf + Created sqitch.plan + Created deploy/ + Created revert/ + Created verify/ + +Let's have a look at F<sqitch.conf>: + + > cat sqitch.conf + [core] + engine = vertica + # plan_file = sqitch.plan + # top_dir = . + # [engine "vertica"] + # target = db:vertica: + # registry = sqitch + # client = vsql + +Good, it picked up on the fact that we're creating changes for the Vertica +engine, thanks to the C<--engine vertica> option, and saved it to the +file. Furthermore, it wrote a commented-out C<[engine "vertica"]> section with +all the available Vertica engine-specific settings commented out and ready to +be edited as appropriate. + +By default, Sqitch will read F<sqitch.conf> in the current directory for +settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific +settings. Since Vertica's C<vsql> client is not in the path on my system, +let's go ahead an tell it where to find the client on our computer: + + > sqitch config --user engine.vertica.client /opt/vertica/bin/vsql + +And let's also tell it who we are, since this data will be used in all +of our projects: + + > sqitch config --user user.name 'Marge N. O’Vera' + > sqitch config --user user.email 'marge@example.com' + +Have a look at F<~/.sqitch/sqitch.conf> and you'll see this: + + > cat ~/.sqitch/sqitch.conf + [engine "vertica"] + client = /opt/vertica/bin/vsql + [user] + name = Marge N. O’Vera + email = marge@example.com + +Which means that Sqitch should be able to find C<vsql> for any project, and +that it will always properly identify us when planning and committing changes. + +Back to the repository. Have a look at the plan file, F<sqitch.plan>: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-vertica-intro/ + + +Note that it has picked up on the name and URI of the app we're building. +Sqitch uses this data to manage cross-project dependencies. The +C<%syntax-version> pragma is always set by Sqitch, so that it always knows how +to parse the plan, even if the format changes in the future. + +Let's commit these changes and start creating the database changes. + + > git add . + > git commit -am 'Initialize Sqitch configuration.' + [master a42564d] Initialize Sqitch configuration. + 2 files changed, 16 insertions(+), 0 deletions(-) + create mode 100644 sqitch.conf + create mode 100644 sqitch.plan + +=head1 Our First Change + +First, our project will need a schema. This creates a nice namespace for all +of the objects that will be part of the flipr app. Run this command: + + > sqitch add appschema -n 'Add schema for all flipr objects.' + Created deploy/appschema.sql + Created revert/appschema.sql + Created verify/appschema.sql + Added "appschema" to sqitch.plan + +The L<C<add>|sqitch-add> command adds a database change to the plan and writes +deploy, revert, and verify scripts that represent the change. Now we edit +these files. The C<deploy> script's job is to create the schema. So we add +this to F<deploy/appschema.sql>: + + CREATE SCHEMA flipr; + +The C<revert> script's job is to precisely revert the change to the deploy +script, so we add this to F<revert/appschema.sql>: + + DROP SCHEMA flipr; + +Now we can try deploying this change. We tell Sqitch where to send the change +via a L<database URI|https://github.com/libwww-perl/uri-db/>, assuming the default +C<dbadmin> database and user and an ODBC driver named C<Vertica> (see +L</Connection Configuration> for details). If you want to first +L<create a database|https://www.vertica.com/docs/8.1.x/HTML/index.htm#Authoring/InstallationGuide/AfterYouInstall/CreatingADatabase.htm>, +simply use its name in place of C<dbadmin>: + + > sqitch deploy 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + Adding registry tables to db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica + Deploying changes to db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica + + appschema .. ok + +First Sqitch created registry tables used to track database changes. The +structure and name of the registry varies between databases (Vertica uses a +schema to namespace its registry, while SQLite and MySQL use separate +databases). Next, Sqitch deploys changes. We only have one so far; the C<+> +reinforces the idea that the change is being C<added> to the database. + +With this change deployed, if you connect to the database, you'll be able to +see the schema: + + > vsql -U dbadmin -c '\dn flipr' + List of schemas + Name | Owner | Comment + -------+---------+--------- + flipr | dbadmin | + +=head2 Trust, But Verify + +But that's too much work. Do you really want to do something like that after +every deploy? + +Here's where the C<verify> script comes in. Its job is to test that the deploy +did was it was supposed to. It should do so without regard to any data that +might be in the database, and should throw an error if the deploy was not +successful. In Vertica, the simplest way to do so for schema is probably to +simply create an object in the schema. Put this SQL into +F<verify/appschema.sql>: + + CREATE TABLE flipr.verify__ (id int); + DROP TABLE flipr.verify__; + +In truth, you can use I<any> query that generates an SQL error if the schema +doesn't exist. Another handy way to do that is to divide by zero if an object +doesn't exist. For example, to throw an error when the C<flipr> schema does +not exist, you could do something like this: + + SELECT 1/COUNT(*) FROM v_catalog.schemata WHERE schema_name = 'flipr'; + +Either way, run the C<verify> script with the L<C<verify>|sqitch-verify> +command: + + > sqitch verify 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + Verifying db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica + * appschema .. ok + Verify successful + +Looks good! If you want to make sure that the verify script correctly dies if +the schema doesn't exist, temporarily change the schema name in the script to +something that doesn't exist, something like: + + CREATE TABLE nonesuch.verify__ (id int); + +Then L<C<verify>|sqitch-verify> again: + + > sqitch verify 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + Verifying db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica + * appschema .. vsql:verify/appschema.sql:5: ROLLBACK 4650: Schema "nonesuch" does not exist + # Verify script "verify/appschema.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +It's even nice enough to tell us what the problem is. Or, for the +divide-by-zero example, change the schema name: + + SELECT 1/COUNT(*) FROM v_catalog.schemata WHERE schema_name = 'nonesuch'; + +Then the verify will look something like: + + > sqitch verify 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + Verifying db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica + * appschema .. vsql:verify/appschema.sql:5: ERROR 2005: division by zero + # Verify script "verify/appschema.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +Less useful error output, but enough to alert us that something has gone +wrong. + +Don't forget to change the schema name back before continuing! + +=head2 Status, Revert, Log, Repeat + +For purely informational purposes, we can always see how a deployment was +recorded via the L<C<status>|sqitch-status> command, which reads the registry +tables from the database: + + > sqitch status 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + # On database db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica + # Project: flipr + # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764 + # Name: appschema + # Deployed: 2014-09-04 15:26:28 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Let's make sure that we can revert the change: + + > sqitch revert 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + Revert all changes from db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica? [Yes] + - appschema .. ok + +The L<C<revert>|sqitch-revert> command first prompts to make sure that we +really do want to revert. This is to prevent unnecessary accidents. You can +pass the C<-y> option to disable the prompt. Also, notice the C<-> before the +change name in the output, which reinforces that the change is being +I<removed> from the database. And now the schema should be gone: + + > vsql -U dbadmin -c '\dn flipr' + List of schemas + Name | Owner | Comment + ------+-------+--------- + (0 rows) + +And the status message should reflect as much: + + > sqitch status 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + # On database db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica + No changes deployed + +Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command +has nothing to verify: + + > sqitch verify 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + Verifying db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica + No changes deployed + +However, we still have a record that the change happened, visible via the +L<C<log>|sqitch-log> command: + + > sqitch log 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + On database db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica + Revert f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764 + Name: appschema + Committer: Marge N. O’Vera <marge@example.com> + Date: 2014-09-04 16:33:02 -0700 + + Add schema for all flipr objects. + + Deploy f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764 + Name: appschema + Committer: Marge N. O’Vera <marge@example.com> + Date: 2014-09-04 15:26:28 -0700 + + Add schema for all flipr objects. + +Note that the actions we took are shown in reverse chronological order, with +the revert first and then the deploy. + +Cool. Now let's commit it. + + > git add . + > git commit -m 'Add flipr schema.' + [master 9bee4bd] Add flipr schema. + 5 files changed, 197 insertions(+), 0 deletions(-) + create mode 100644 deploy/appschema.sql + create mode 100644 revert/appschema.sql + create mode 100644 sqitch.sql + create mode 100644 verify/appschema.sql + +And then deploy again. This time, let's use the C<--verify> option, so that +the C<verify> script is applied when the change is deployed: + + > sqitch deploy --verify 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + Deploying changes to db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica + + appschema .. ok + +And now the schema should be back: + + > vsql -U dbadmin -c '\dn flipr' + List of schemas + Name | Owner | Comment + -------+---------+--------- + flipr | dbadmin | + +When we look at the status, the deployment will be there: + + > sqitch status 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + # On database db:vertica://dbadmin:@localhost:5433/dbadmin?Driver=Vertica + # Project: flipr + # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764 + # Name: appschema + # Deployed: 2014-09-04 16:37:38 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 On Target + +I'm getting a little tired of always having to type +C<'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica'>, aren't +you? This L<database connection URI|https://github.com/libwww-perl/uri-db/> tells +Sqitch how to connect to the deployment target, but we don't have to keep +using the URI. We can name the target: + + > sqitch target add flipr_test 'db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica' + +The L<C<target>|sqitch-target> command, inspired by +L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one +or more named deployment targets. We've just added a target named +C<flipr_test>, which means we can use the string C<flipr_test> for the target, +rather than the URI. But since we're doing so much testing, we can also tell +Sqitch to deploy to the C<flipr_test> target by default: + + > sqitch engine add vertica flipr_test + +Now we can omit the target argument altogether, unless we need to deploy to +another database. Which we will, eventually, but at least our examples will be +simpler from here on in, e.g.: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764 + # Name: appschema + # Deployed: 2014-09-04 16:37:38 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Yay, that allows things to be a little more concise. Let's also make sure that +changes are verified after deploying them: + + > sqitch config --bool deploy.verify true + > sqitch config --bool rebase.verify true + +We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime, +let's commit the new configuration and and make some more changes! + + > git commit -am 'Set default deployment target and always verify.' + [master 469779a] Set default deployment target and always verify. + 1 files changed, 8 insertions(+), 0 deletions(-) + +=head1 Deploy with Dependency + +Let's add another change, this time to create a table. Our app will need +users, of course, so we'll create a table for them. First, add the new change: + + > sqitch add users --requires appschema -n 'Creates table to track our users.' + Created deploy/users.sql + Created revert/users.sql + Created verify/users.sql + Added "users [appschema]" to sqitch.plan + +Note that we're requiring the C<appschema> change as a dependency of the new +C<users> change. Although that change has already been added to the plan and +therefore should always be applied before the C<users> change, it's a good +idea to be explicit about dependencies. + +Now edit the scripts. When you're done, F<deploy/users.sql> should look like +this: + + -- Deploy flipr:users to vertica + -- requires: appschema + + CREATE TABLE flipr.users ( + nickname VARCHAR PRIMARY KEY, + password VARCHAR NOT NULL, + fullname VARCHAR(256) NOT NULL, + twitter VARCHAR NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + +A few things to notice here. On the second line, the dependence on the +C<appschema> change has been listed. This doesn't do anything, but the default +C<deploy> Vertica template lists it here for your reference while editing +the file. Useful, right? + +The table itself will be created in the C<flipr> schema. This is why we need +to require the C<appschema> change. + +Now for the verify script. The simplest way to check that the table was +created and has the expected columns without touching the data? Just select +from the table with a false C<WHERE> clause. Add this to F<verify/users.sql>: + + SELECT nickname, password, fullname, twitter, timestamp + FROM flipr.users + WHERE FALSE; + +Now for the revert script: all we have to do is drop the table. Add this to +F<revert/users.sql>: + + DROP TABLE flipr.users; + +Couldn't be much simpler, right? Let's deploy this bad boy: + + > sqitch deploy + Deploying changes to flipr_test + + users .. ok + +We know, since verification is enabled, that the table must have been created. +But for the purposes of visibility, let's have a quick look: + + > vsql -U dbadmin -c '\d flipr.users' + List of Fields by Tables + Schema | Table | Column | Type | Size | Default | Not Null | Primary Key | Foreign Key + --------+-------+-------------+-------------+------+---------+----------+-------------+------------- + flipr | users | nickname | varchar(80) | 80 | | t | t | + flipr | users | password | varchar(80) | 80 | | t | f | + flipr | users | "timestamp" | timestamptz | 8 | now() | t | f | + +We can also verify all currently deployed changes with the +L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appschema .. ok + * users ...... ok + Verify successful + +Now have a look at the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: d647ac8c130a7e0b12c9049789e46afb4a4f6e53 + # Name: users + # Deployed: 2014-09-04 16:42:45 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Success! Let's make sure we can revert the change, as well: + + > sqitch revert --to @HEAD^ -y + Reverting changes to appschema from flipr_test + - users .. ok + +Note that we've used the C<--to> option to specify the change to revert to. +And what do we revert to? The symbolic tag C<@HEAD>, when passed to +L<C<revert>|sqitch-revert>, always refers to the last change deployed to the +database. (For other commands, it refers to the last change in the plan.) +Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the +last deployed change. So we revert to C<appschema>, the penultimate change. +The other potentially useful symbolic tag is C<@ROOT>, which refers to the +first change deployed to the database (or in the plan, depending on the +command). + +Back to the database. The C<users> table should be gone but the C<flipr> schema +should still be around: + + > vsql -U dbadmin -c '\d flipr.users' + Did not find any relation. + +The L<C<status>|sqitch-status> command politely informs us that we have +undeployed changes: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: f9759f0ed77964b6a3b6c7aa3b6058b4bb7db764 + # Name: appschema + # Deployed: 2014-09-04 16:37:38 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Undeployed change: + * users + +As does the L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appschema .. ok + Undeployed change: + * users + Verify successful + +Note that the verify is successful, because all currently-deployed changes are +verified. The list of undeployed changes (just "users" here) reminds us about +the current state. + +Okay, let's commit and deploy again: + + > git add . + > git commit -am 'Add users table.' + [master c7c24c5] Add users table. + 4 files changed, 18 insertions(+), 0 deletions(-) + create mode 100644 deploy/users.sql + create mode 100644 revert/users.sql + create mode 100644 verify/users.sql + Deploying changes to flipr_test + + users .. ok + +Looks good. Check the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: d647ac8c130a7e0b12c9049789e46afb4a4f6e53 + # Name: users + # Deployed: 2014-09-04 17:42:53 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Excellent. Let's do some more! + +=head1 Add Two at Once + +Let's add a couple more changes. Our app will need to store status messages +from users. Let's call them -- and the table to store them -- "flips". And +we'll also need a view that lists user names with their flips. Let's add +changes for them both: + + > sqitch add flips -r appschema -r users -n 'Adds table for storing flips.' + Created deploy/flips.sql + Created revert/flips.sql + Created verify/flips.sql + Added "flips [appschema users]" to sqitch.plan + + > sqitch add userflips -r appschema -r users -r flips \ + -n 'Creates the userflips view.' + Created deploy/userflips.sql + Created revert/userflips.sql + Created verify/userflips.sql + Added "userflips [appschema users flips]" to sqitch.plan + +Now might be a good time to have a look at the deployment plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-vertica-intro/ + + appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + +Each change appears on a single line with the name of the change, a bracketed +list of dependencies, a timestamp, the name and email address of the user who +planned the change, and a note. + +Let's write the code for the new changes. Here's what F<deploy/flips.sql> +should look like: + + -- Deploy flipr:flips to vertica + -- requires: appschema + -- requires: users + + CREATE TABLE flipr.flips ( + id AUTO_INCREMENT PRIMARY KEY , + nickname VARCHAR NOT NULL REFERENCES flipr.users(nickname), + body VARCHAR(180) NOT NULL DEFAULT '', + timestamp TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() + ); + +Here's what F<verify/flips.sql> might look like: + + -- Verify flipr:flips on vertica + SELECT id, nickname, body, timestamp + FROM flipr.flips + WHERE FALSE; + +We simply take advantage of the fact that C<has_function_privilege()> throws +an exception if the specified function does not exist. + +And F<revert/flips.sql> should look something like this: + + -- Revert flipr:flips from vertica + DROP TABLE flipr.flips; + +Now for C<userflips>; F<deploy/userflips.sql> might look like this: + + -- Deploy flipr:userflips to vertica + -- requires: appschema + -- requires: users + -- requires: flips + + CREATE OR REPLACE VIEW flipr.userflips AS + SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp + FROM flipr.users u + JOIN flipr.flips f ON u.nickname = f.nickname; + +Use a C<SELECT> statement in F<verify/userflips.sql> again: + + -- Verify flipr:userflips on vertica + SELECT id, nickname, fullname, body, timestamp + FROM flipr.userflips + WHERE FALSE; + +And of course, its C<revert> script, F<revert/userflips.sql>, should look +something like: + + -- Revert flipr:userflips from vertica + DROP VIEW flipr.userflips; + +Try em out! + + > sqitch deploy + Deploying changes to flipr_test + + flips ...... ok + + userflips .. ok + +Do we have the new table and view? Of course we do, they were verified. Still, +have a look: + + > vsql -U dbadmin -c '\dt flipr.flips' + List of tables + Schema | Name | Kind | Owner | Comment + --------+-------+-------+---------+--------- + flipr | flips | table | dbadmin | + + > vsql -U dbadmin -c '\dv flipr.userflips' + List of View Fields + Schema | View | Column | Type | Size + --------+-----------+-------------+--------------+------ + flipr | userflips | id | int | 8 + flipr | userflips | nickname | varchar(80) | 80 + flipr | userflips | fullname | varchar(256) | 256 + flipr | userflips | body | varchar(180) | 180 + flipr | userflips | "timestamp" | timestamptz | 8 + +And what's the status? + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: d1f998618fb863d93049a724fd0d2b49a29add86 + # Name: userflips + # Deployed: 2014-09-04 17:51:21 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Looks good. Let's make sure revert works: + + > sqitch revert -y --to @HEAD^ + Reverting changes to users from flipr_test + - userflips .. ok + - flips ...... ok + > vsql -U dbadmin -c '\d flipr.flips' + Did not find any relation. + > vsql -U dbadmin -c '\dv flipr.userflips' + No matching relations found. + +Note the use of C<@HEAD^^> to specify that the revert be to two changes prior +the last deployed change. Looks good. Let's do the commit and re-deploy dance: + + > git add . + > git commit -m 'Add flips table and userflips view.' + [master c40f23f] Add flips table and userflips view. + 7 files changed, 41 insertions(+), 0 deletions(-) + create mode 100644 deploy/flips.sql + create mode 100644 deploy/userflips.sql + create mode 100644 revert/flips.sql + create mode 100644 revert/userflips.sql + create mode 100644 verify/flips.sql + create mode 100644 verify/userflips.sql + + > sqitch deploy + Deploying changes to flipr_test + + flips ...... ok + + userflips .. ok + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: d1f998618fb863d93049a724fd0d2b49a29add86 + # Name: userflips + # Deployed: 2014-09-04 17:59:34 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + + > sqitch verify + Verifying flipr_test + * appschema .. ok + * users ...... ok + * flips ...... ok + * userflips .. ok + Verify successful + +Great, we're fully up-to-date! + +=head1 Ship It! + +Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want +to have it go out with deployments tied to the release, let's tag it: + + > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.' + Tagged "userflips" with @v1.0.0-dev1 + > git commit -am 'Tag the database with v1.0.0-dev1.' + [master b07ce3d] Tag the database with v1.0.0-dev1. + 1 files changed, 1 insertions(+), 0 deletions(-) + > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1' + +We can try deploying to make sure the tag gets picked up like so: + + > sqitch deploy + Nothing to deploy (up-to-date) + > sqitch status + # On database flipr_test + # Project: flipr + # Change: d1f998618fb863d93049a724fd0d2b49a29add86 + # Name: userflips + # Tag: @v1.0.0-dev1 + # Deployed: 2014-09-04 17:59:34 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the new "Tag" line in the output of C<sqitch status>: no new changes +needed to be deployed, but Sqitch did deploy the tag on the C<userflips> +change. Now let's bundle everything up for release: + + > sqitch bundle + Bundling into bundle + Writing config + Writing plan + Writing scripts + + appschema + + users + + flips + + userflips @v1.0.0-dev1 + +Now we can package the F<bundle> directory and distribute it. When it gets +installed somewhere, users can use Sqitch to deploy to the database. Let's try +deploying it to another database: + + > cd bundle + > sqitch deploy db:vertica://dbadmin:password@db.example.com:5433/flipr?Driver=Vertica + Adding registry tables to db:vertica://dbadmin:@db.example.com:5433/flipr?Driver=Vertica + Deploying changes to db:vertica://dbadmin:@db.example.com:5433/flipr?Driver=Vertica + + appschema ............... ok + + users ................... ok + + flips ................... ok + + userflips @v1.0.0-dev1 .. ok + +Notice how the tag on C<userflips> now appears in the deploy output. Nice, eh? +Now, package it up and ship it! + + > cd .. + > mv bundle flipr-v1.0.0-dev1 + > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1 + +=head1 Making a Hash of Things + +Now that we've got the basics of the app done, let's add a feature. Gotta +track the hashtags associated with flips, right? Let's add a table for them. +But since other folks are working on other tasks in the repository, we'll work +on a branch, so we can all stay out of each other's way. So let's branch: + +=head1 Making a Hash of Things + +Now that we've got the basics of the app done, let's add a feature. Gotta +track the hashtags associated with flips, right? Let's add a table for them. +But since other folks are working on other tasks in the repository, we'll work +on a branch, so we can all stay out of each other's way. So let's branch: + + > git checkout -b hashtags + Switched to a new branch 'hashtags' + +Now we can add a new change to create a table for hashtags. + + > sqitch add hashtags --requires flips -n 'Adds table for storing hashtags.' + Created deploy/hashtags.sql + Created revert/hashtags.sql + Created verify/hashtags.sql + Added "hashtags [appschema flips]" to sqitch.plan + +You know the drill by now. Add this to F<deploy/hashtags.sql> + + CREATE TABLE flipr.hashtags ( + flip_id BIGINT NOT NULL REFERENCES flipr.Flips(id), + hashtag VARCHAR(128) NOT NULL, + PRIMARY KEY (flip_id, hashtag) + ); + +Again, select from the table in F<verify/hashtags.sql>: + + SELECT flip_id, hashtag FROM flipr.hashtags WHERE FALSE; + +And drop it in F<revert/hashtags.sql> + + DROP TABLE flipr.hashtags; + +And give it a whirl: + + > sqitch deploy + Deploying changes to flipr_test + + hashtags .. ok + +Look good? + + > sqitch status --show-tags + # On database flipr_test + # Project: flipr + # Change: fda6daef73e0ac12252bf6af5f259ccb207d4197 + # Name: hashtags + # Deployed: 2014-09-05 10:46:20 -0700 + # By: Marge N. O’Vera <marge@example.com> + # + # Tag: + # @v1.0.0-dev1 - 2014-09-05 09:09:38 -0700 - Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the use of C<--show-tags> to show all the deployed tags. Make sure we can +revert, too: + + > sqitch rebase -y --onto @HEAD^ + Reverting changes to userflips @v1.0.0-dev1 from flipr_test + - hashtags .. ok + > sqitch deploy + Deploying changes to flipr_test + + hashtags .. ok + +Great! Now make it so: + + > git add . + > git commit -m 'Add hashtags table.' + [hashtags d893e9c] Add hashtags table. + 4 files changed, 18 insertions(+), 0 deletions(-) + create mode 100644 deploy/hashtags.sql + create mode 100644 revert/hashtags.sql + create mode 100644 verify/hashtags.sql + +Good, we've finished this feature. Time to merge back into C<master>. + +=head2 Emergency + +Let's do it: + + > git checkout master + Switched to branch 'master' + > git pull + Updating b07ce3d..05d3e5d + Fast-forward + deploy/lists.sql | 10 ++++++++++ + revert/lists.sql | 3 +++ + sqitch.plan | 2 ++ + verify/lists.sql | 5 +++++ + 4 files changed, 20 insertions(+), 0 deletions(-) + create mode 100644 deploy/lists.sql + create mode 100644 revert/lists.sql + create mode 100644 verify/lists.sql + +Hrm, that's interesting. Looks like someone made some changes to C<master>. +They added list support. Well, let's see what happens when we merge our +changes. + + > git merge --no-ff hashtags + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Automatic merge failed; fix conflicts and then commit the result. + +Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged +C<lists> branch and our C<hashtags> branch added changes to the plan. Let's +try a different approach. + +The truth is, we got lazy. Those changes when we pulled master from the origin +should have raised a red flag. It's considered a bad practice not to look at +what's changed in C<master> before merging in a branch. What one I<should> do +is either: + +=over + +=item * + +Rebase the F<hashtags> branch from master before merging. This "rewinds" the +branch changes, pulls from C<master>, and then replays the changes back on top +of the pulled changes. + +=item * + +Create a patch and apply I<that> to master. This is the sort of thing you +might have to do if you're sending changes to another user, especially if the +VCS is not Git. + +=back + +So let's restore things to how they were at master: + + > git reset --hard HEAD + HEAD is now at 05d3e5d Merge branch 'lists' + +That throws out our botched merge. Now let's go back to our branch and rebase +it on C<master>: + + > git checkout hashtags + Switched to branch 'hashtags' + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add hashtags table. + Using index info to reconstruct a base tree... + <stdin>:16: new blank line at EOF. + + + warning: 1 line adds whitespace errors. + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Failed to merge in the changes. + Patch failed at 0001 Add hashtags table. + + When you have resolved this problem run "git rebase --continue". + If you would prefer to skip this patch, instead run "git rebase --skip". + To restore the original branch and stop rebasing run "git rebase --abort". + +Oy, that's kind of a pain. It seems like no matter what we do, we'll need to +resolve conflicts in that file. Except in Git. Fortunately for us, we can tell +Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever +append lines to the file, we can have it use the "union" merge driver, which, +according to +L<its docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>: + +=over + +Run 3-way file level merge for text files, but take lines from both versions, +instead of leaving conflict markers. This tends to leave the added lines in +the resulting file in random order and the user should verify the result. Do +not use this if you do not understand the implications. + +=back + +This has the effect of appending lines from all the merging files, which is +exactly what we need. So let's give it a try. First, back out the botched +rebase: + + > git rebase --abort + HEAD is now at d893e9c Add hashtags table. + +Now add the union merge driver to F<.gitattributes> for F<sqitch.plan> +and rebase again: + + > echo sqitch.plan merge=union > .gitattributes + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add hashtags table. + Using index info to reconstruct a base tree... + <stdin>:16: new blank line at EOF. + + + warning: 1 line adds whitespace errors. + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + +Ah, that looks a bit better. Let's have a look at the plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-vertica-intro/ + + appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + @v1.0.0-dev1 2014-09-05T16:04:48Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appschema users] 2014-09-05T17:33:43Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + hashtags [appschema flips] 2014-09-05T17:39:53Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags. + +Note that it has appended the changes from the merged "lists" branch, and then +merged the changes from our "hashtags" branch. Test it to make sure it works +as expected: + + > sqitch rebase -y + Reverting all changes from flipr_test + - hashtags ................ ok + - userflips @v1.0.0-dev1 .. ok + - flips ................... ok + - users ................... ok + - appschema ............... ok + Deploying changes to flipr_test + + appschema ............... ok + + users ................... ok + + flips ................... ok + + userflips @v1.0.0-dev1 .. ok + + lists ................... ok + + hashtags ................ ok + +Note the use of L<C<rebase>|sqitch-rebase>, which combines a +L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single +command. Handy, right? It correctly reverted our changes, and then deployed +them all again in the proper order. So let's commit F<.gitattributes>; seems +worthwhile to keep that change: + + > git add . + > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.' + [hashtags 2f065a3] Add `.gitattributes` with union merge for `sqitch.plan`. + 1 files changed, 1 insertions(+), 0 deletions(-) + create mode 100644 .gitattributes + +=head2 Merges Mastered + +And now, finally, we can merge into C<master>: + + > git checkout master + Switched to branch 'master' + > git merge --no-ff hashtags -m "Merge branch 'hashtags'" + Merge made by recursive. + .gitattributes | 1 + + deploy/hashtags.sql | 10 ++++++++++ + revert/hashtags.sql | 3 +++ + sqitch.plan | 1 + + verify/hashtags.sql | 3 +++ + 5 files changed, 18 insertions(+), 0 deletions(-) + create mode 100644 .gitattributes + create mode 100644 deploy/hashtags.sql + create mode 100644 revert/hashtags.sql + create mode 100644 verify/hashtags.sql + +And double-check our work: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-vertica-intro/ + + appschema 2014-09-04T18:40:34Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2014-09-04T23:40:15Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + flips [appschema users] 2014-09-05T00:16:58Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + userflips [appschema users flips] 2014-09-05T00:18:43Z Marge N. O’Vera <marge@example.com> # Creates the userflips view. + @v1.0.0-dev1 2014-09-05T16:04:48Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appschema users] 2014-09-05T17:33:43Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + hashtags [appschema flips] 2014-09-05T17:39:53Z Marge N. O’Vera <marge@example.com> # Adds table for storing hashtags. + +Much much better, a nice clean master now. And because it is now identical to +the "hashtags" branch, we can just carry on. Go ahead and tag it, bundle, and +release: + + > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.' + Tagged "hashtags" with @v1.0.0-dev2 + > git commit -am 'Tag the database with v1.0.0-dev2.' + [master 8a6a73b] Tag the database with v1.0.0-dev2. + 1 files changed, 1 insertions(+), 0 deletions(-) + > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2' + > sqitch bundle --dest-dir flipr-1.0.0-dev2 + Bundling into flipr-1.0.0-dev2 + Writing config + Writing plan + Writing scripts + + appschema + + users + + flips + + userflips @v1.0.0-dev1 + + lists + + hashtags @v1.0.0-dev2 + +Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way +to create the top-level directory name so we don't have to rename it from +F<bundle>. + +=head1 In Place Changes + +Well, some folks have been testing the C<1.0.0-dev2> release and have demanded +that Twitter user links be added to Flipr pages. Why anyone would want to +include social network links in an anti-social networking app is beyond us +programmers, but we're just the plumbers, right? Gotta go with what Product +demands. The upshot is that we need to update the C<userflips> view, which is +used for the feature in question, to include the Twitter user names. + +Normally, modifying views in database changes is a +L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make +changes like these: + +=over + +=item 1. + +Copy F<deploy/userflips.sql> to F<deploy/userflips_twitter.sql>. + +=item 2. + +Edit F<deploy/userflips_twitter.sql> to drop and re-create the view with the +C<twitter> column to the view. + +=item 3. + +Copy F<deploy/userflips.sql> to F<revert/userflips_twitter.sql>. +Yes, copy the original change script to the new revert change. + +=item 4. + +Add a C<DROP VIEW> statement to F<revert/userflips_twitter.sql>. + +=item 5. + +Copy F<verify/userflips.sql> to F<verify/userflips_twitter.sql>. + +=item 6. + +Modify F<verify/userflips_twitter.sql> to include a check for the C<twiter> +column. + +=item 7. + +Test the changes to make sure you can deploy and revert the +C<userflips_twitter> change. + +=back + +But you can have Sqitch do most of the work for you. The only requirement is +that a tag appear between the two instances of a change we want to modify. In +general, you're going to make a change like this after a release, which you've +tagged anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous +section. With that, we can let Sqitch do most of the hard work for us, thanks +to the L<C<rework>|sqitch-rework> command, which is similar to +L<C<add>|sqitch-add>: + + > sqitch rework userflips -n 'Adds userflips.twitter.' + Added "userflips [userflips@v1.0.0-dev2]" to sqitch.plan. + Modify these files as appropriate: + * deploy/userflips.sql + * revert/userflips.sql + * verify/userflips.sql + +Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in +point of fact, it has copied the files to stand in for the previous instance +of the C<userflips> change, which we can see via C<git status>: + + > git status + # On branch master + # Changed but not updated: + # (use "git add <file>..." to update what will be committed) + # (use "git checkout -- <file>..." to discard changes in working directory) + # + # modified: revert/userflips.sql + # modified: sqitch.plan + # + # Untracked files: + # (use "git add <file>..." to include in what will be committed) + # + # deploy/userflips@v1.0.0-dev2.sql + # revert/userflips@v1.0.0-dev2.sql + # verify/userflips@v1.0.0-dev2.sql + no changes added to commit (use "git add" and/or "git commit -a") + +The "untracked files" part of the output is the first thing to notice. They're +all named C<userflips@v1.0.0-dev2.sql>. What that means is: "the C<userflips> +change as it was implemented as of the C<@v1.0.0-dev2> tag." These are copies +of the original scripts, and thereafter Sqitch will find them when it needs to +run scripts for the first instance of the C<userflips> change. As such, it's +important not to change them again. But hey, if you're reworking the change, +you shouldn't need to. + +The other thing to notice is that F<revert/userflips.sql> has changed. Sqitch +replaced it with the original deploy script. As of now, +F<deploy/userflips.sql> and F<revert/userflips.sql> are identical. This is on +the assumption that the deploy script will be changed (we're reworking it, +remember?), and that the revert script should actually change things back to +how they were before. Of course, the original deploy script may not be +L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be +applied multiple times without changing the result beyond the initial +application. If it's not, you will likely need to modify it so that it +properly restores things to how they were after the original deploy script was +deployed. Or, more simply, it should revert changes back to how they were +as-of the deployment of F<deploy/userflips@v1.0.0-dev2.sql>. + +Fortunately, our function deploy scripts are already idempotent, thanks to the +use of the C<OR REPLACE> expression. No matter how many times a deployment +script is run, the end result will be the same instance of the function, with +no duplicates or errors. + +As a result, there is no need to explicitly add changes. So go ahead. Modify +the script to add the C<twitter> column to the view. Make this change to +F<deploy/userflips.sql>: + + @@ -4,8 +4,9 @@ + + BEGIN; + + @@ -4,6 +4,6 @@ + -- requires: flips + + CREATE OR REPLACE VIEW flipr.userflips AS + -SELECT f.id, u.nickname, u.fullname, f.body, f.timestamp + +SELECT f.id, u.nickname, u.fullname, u.twitter, f.body, f.timestamp + FROM flipr.users u + JOIN flipr.flips f ON u.nickname = f.nickname; + +Next, modify F<verify/userflips.sql> to check for the C<twitter> column. +Here's the diff: + + @@ -1,6 +1,6 @@ + -- Verify flipr:userflips on vertica + + -SELECT id, nickname, fullname, body, timestamp + +SELECT id, nickname, fullname, twitter, body, timestamp + FROM flipr.userflips + WHERE FALSE; + +Now try a deployment: + + > sqitch deploy + Deploying changes to flipr_test + + userflips .. ok + +So, are the changes deployed? + + > vsql -U dbadmin -c '\dv flipr.userflips' + List of View Fields + Schema | View | Column | Type | Size + --------+-----------+-------------+--------------+------ + flipr | userflips | id | int | 8 + flipr | userflips | nickname | varchar(80) | 80 + flipr | userflips | fullname | varchar(256) | 256 + flipr | userflips | twitter | varchar(80) | 80 + flipr | userflips | body | varchar(180) | 180 + flipr | userflips | "timestamp" | timestamptz | 8 + +Awesome, the view now includes the C<twitter> column. But can we revert? + + > sqitch revert --to @HEAD^ -y + Reverting changes to hashtags @v1.0.0-dev2 from flipr_test + - userflips .. ok + +Did that work, is the C<twitter> column gone? + + > vsql -U dbadmin -c '\dv flipr.userflips' + List of View Fields + Schema | View | Column | Type | Size + --------+-----------+-------------+--------------+------ + flipr | userflips | id | int | 8 + flipr | userflips | nickname | varchar(80) | 80 + flipr | userflips | fullname | varchar(256) | 256 + flipr | userflips | twitter | varchar(80) | 80 + flipr | userflips | body | varchar(180) | 180 + flipr | userflips | "timestamp" | timestamptz | 8 + +Yes, it works! Sqitch properly finds the original instances of these changes +in the new script files that include tags. + +Excellent. Let's go ahead and commit these changes: + + > git add . + > git commit -m 'Add the twitter column to the userflips view.' + [master 95d6dd0] Add the twitter column to the userflips view. + 7 files changed, 30 insertions(+), 4 deletions(-) + create mode 100644 deploy/userflips@v1.0.0-dev2.sql + create mode 100644 revert/userflips@v1.0.0-dev2.sql + create mode 100644 verify/userflips@v1.0.0-dev2.sql + +=head1 More to Come + +Sqitch is a work in progress. Better integration with version control systems +is planned to make managing idempotent reworkings even easier. Stay tuned. + +=head1 Author + +David E. Wheeler <david@justatheory.com> + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/sqitchtutorial.pod b/lib/sqitchtutorial.pod new file mode 100644 index 00000000..0eee1f19 --- /dev/null +++ b/lib/sqitchtutorial.pod @@ -0,0 +1,1686 @@ +=encoding UTF-8 + +=head1 Name + +sqitchtutorial - A tutorial introduction to Sqitch change management on PostgreSQL + +=head1 Synopsis + + sqitch * + +=head1 Description + +This tutorial explains how to create a sqitch-enabled PostgreSQL project, use +a VCS for deployment planning, and work with other developers to make sure +changes remain in sync and in the proper order. + +We'll start by creating a new project from scratch, a fictional antisocial +networking site called Flipr. All examples use L<Git|https://git-scm.com/> as +the VCS and L<PostgreSQL|https://www.postgresql.org/> as the storage engine, +but for the most part you can substitute other VCSes and database engines in +the examples as appropriate. + +If you'd like to manage an SQLite database, see L<sqitchtutorial-sqlite>. + +If you'd like to manage an Oracle database, see L<sqitchtutorial-oracle>. + +If you'd like to manage a MySQL database, see L<sqitchtutorial-mysql>. + +If you'd like to manage a Firebird database, see L<sqitchtutorial-firebird>. + +If you'd like to manage a Vertica database, see L<sqitchtutorial-vertica>. + +If you'd like to manage an Exasol database, see L<sqitchtutorial-exasol>. + +If you'd like to manage a Snowflake database, see L<sqitchtutorial-snowflake>. + +=head1 Starting a New Project + +Usually the first thing to do when starting a new project is to create a +source code repository. So let's do that with Git: + + > mkdir flipr + > cd flipr + > git init . + Initialized empty Git repository in /flipr/.git/ + > touch README.md + > git add . + > git commit -am 'Initialize project, add README.' + +If you're a Git user and want to follow along the history, the repository +used in these examples is L<on GitHub|https://github.com/sqitchers/sqitch-intro>. + +Now that we have a repository, let's get started with Sqitch. Every Sqitch +project must have a name associated with it, and, optionally, a unique URI. We +recommend including the URI, as it increases the uniqueness of object +identifiers internally, and will prevent the deployment of a different project +with the same name. So let's specify one when we initialize Sqitch: + + > sqitch init flipr --uri https://github.com/sqitchers/sqitch-intro/ --engine pg + Created sqitch.conf + Created sqitch.plan + Created deploy/ + Created revert/ + Created verify/ + +Let's have a look at F<sqitch.conf>: + + > cat sqitch.conf + [core] + engine = pg + # plan_file = sqitch.plan + # top_dir = . + # [engine "pg"] + # target = db:pg: + # registry = sqitch + # client = psql + +Good, it picked up on the fact that we're creating changes for the PostgreSQL +engine, thanks to the C<--engine pg> option, and saved it to the file. +Furthermore, it wrote a commented-out C<[engine "pg"]> section with all the +available PostgreSQL engine-specific settings commented out and ready to be +edited as appropriate. + +By default, Sqitch will read F<sqitch.conf> in the current directory for +settings. But it will also read F<~/.sqitch/sqitch.conf> for user-specific +settings. Since PostgreSQL's C<psql> client is not in the path on my system, +let's go ahead and tell it where to find the client on our computer: + + > sqitch config --user engine.pg.client /opt/local/pgsql/bin/psql + +And let's also tell it who we are, since this data will be used in all +of our projects: + + > sqitch config --user user.name 'Marge N. O’Vera' + > sqitch config --user user.email 'marge@example.com' + +Have a look at F<~/.sqitch/sqitch.conf> and you'll see this: + + > cat ~/.sqitch/sqitch.conf + [engine "pg"] + client = /opt/local/pgsql/bin/psql + [user] + name = Marge N. O’Vera + email = marge@example.com + +Which means that Sqitch should be able to find C<psql> for any project, and +that it will always properly identify us when planning and committing changes. + +Back to the repository. Have a look at the plan file, F<sqitch.plan>: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-intro/ + + +Note that it has picked up on the name and URI of the app we're building. +Sqitch uses this data to manage cross-project dependencies. The +C<%syntax-version> pragma is always set by Sqitch, so that it always knows how +to parse the plan, even if the format changes in the future. + +Let's commit these changes and start creating the database changes. + + > git add . + > git commit -am 'Initialize Sqitch configuration.' + [master 85e8d7c] Initialize Sqitch configuration. + 2 files changed, 19 insertions(+) + create mode 100644 sqitch.conf + create mode 100644 sqitch.plan + +=head1 Our First Change + +First, our project will need a schema. This creates a nice namespace for all +of the objects that will be part of the flipr app. Run this command: + + > sqitch add appschema -n 'Add schema for all flipr objects.' + Created deploy/appschema.sql + Created revert/appschema.sql + Created verify/appschema.sql + Added "appschema" to sqitch.plan + +The L<C<add>|sqitch-add> command adds a database change to the plan and writes +deploy, revert, and verify scripts that represent the change. Now we edit +these files. The C<deploy> script's job is to create the schema. So we add +this to F<deploy/appschema.sql>: + + CREATE SCHEMA flipr; + +The C<revert> script's job is to precisely revert the change to the deploy +script, so we add this to F<revert/appschema.sql>: + + DROP SCHEMA flipr; + +Now we can try deploying this change. First, we need to create a database +to deploy to: + + > createdb flipr_test + +Now we tell Sqitch where to send the change via a +L<database URI|https://github.com/libwww-perl/uri-db/>: + + > sqitch deploy db:pg:flipr_test + Adding registry tables to db:pg:flipr_test + Deploying to db:pg:flipr_test + + appschema .. ok + +First Sqitch created registry tables used to track database changes. The +structure and name of the registry varies between databases (PostgreSQL uses a +schema to namespace its registry, while SQLite and MySQL use separate +databases). Next, Sqitch deploys changes. We only have one so far; the C<+> +reinforces the idea that the change is being C<added> to the database. + +With this change deployed, if you connect to the database, you'll be able to +see the schema: + + > psql -d flipr_test -c '\dn flipr' + List of schemas + Name | Owner + -------+------- + flipr | marge + +=head2 Trust, But Verify + +But that's too much work. Do you really want to do something like that after +every deploy? + +Here's where the C<verify> script comes in. Its job is to test that the deploy +did what it was supposed to. It should do so without regard to any data that +might be in the database, and should throw an error if the deploy was not +successful. In PostgreSQL, the simplest way to do so for non-queryable objects +such as schemas is to take advantage the +L<access privilege inquiry functions|https://www.postgresql.org/docs/current/static/functions-info.html#FUNCTIONS-INFO-ACCESS-TABLE>. +These functions conveniently throw exceptions if the object being inquired +does not exist. For our new schema, C<has_schema_privilege()> will do very +nicely. Put this query into F<verify/appschema.sql>: + + SELECT pg_catalog.has_schema_privilege('flipr', 'usage'); + +B<Important!> This query isn't verifying that the user has C<usage> privilege +on schema C<flipr>. The verification will pass even if the current user +has no usage rights. + +B<Important!> Both C<SELECT false;> and C<SELECT true;> queries will successfully +pass C<verify> step. Only queries that raise an exception will fail. + +Such functionality may not be available to other databases, but you can use +I<any> query that will throw an exception if the schema doesn't exist. One +handy way to do that is to divide by zero if an object doesn't exist. So for +other databases, assuming division by zero is fatal, you could do something +like this: + + SELECT 1/COUNT(*) FROM information_schema.schemata WHERE schema_name = 'flipr'; + +In Postgres 9.5+ you can use C<PL/pgSQL> anonymous functions with +C<ASSERT> / C<RAISE> statements. + + DO $$ + BEGIN + ASSERT (SELECT has_schema_privilege('flipr', 'usage')); + END $$; + +You can use variables to perform more complex checks: + + DO $$ + DECLARE + result varchar; + BEGIN + result := (SELECT name FROM flipr.pipelines WHERE id = 1); + ASSERT result = 'Example'; + END $$; + +This example ensures the record with C<id=1> in C<pipelines> table +has C<name> field equals C<'Example'>. + +Either way, run the C<verify> script with the L<C<verify>|sqitch-verify> +command: + + > sqitch verify db:pg:flipr_test + Verifying db:pg:flipr_test + * appschema .. ok + Verify successful + +Looks good! If you want to make sure that the verify script correctly dies if +the schema doesn't exist, temporarily change the schema name in the script to +something that doesn't exist, something like: + + SELECT pg_catalog.has_schema_privilege('nonesuch', 'usage'); + +Then L<C<verify>|sqitch-verify> again: + + > sqitch verify db:pg:flipr_test + Verifying db:pg:flipr_test + * appschema .. psql:verify/appschema.sql:5: ERROR: schema "nonesuch" does not exist + # Verify script "verify/appschema.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +It's even nice enough to tell us what the problem is. Or, for the +divide-by-zero example, change the schema name: + + SELECT 1/COUNT(*) FROM information_schema.schemata WHERE schema_name = 'nonesuch'; + +Then the verify will look something like: + + > sqitch verify db:pg:flipr_test + Verifying db:pg:flipr_test + * appschema .. psql:verify/appschema.sql:5: ERROR: division by zero + # Verify script "verify/appschema.sql" failed. + not ok + + Verify Summary Report + --------------------- + Changes: 1 + Errors: 1 + Verify failed + +Less useful error output, but enough to alert us that something has gone +wrong. + +Don't forget to change the schema name back before continuing! + +=head2 Status, Revert, Log, Repeat + +For purely informational purposes, we can always see how a deployment was +recorded via the L<C<status>|sqitch-status> command, which reads the registry +tables from the database: + + > sqitch status db:pg:flipr_test + # On database db:pg:flipr_test + # Project: flipr + # Change: c7981df861183412b01be706889e508a63d445ca + # Name: appschema + # Deployed: 2013-12-30 15:27:15 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Let's make sure that we can revert the change: + + > sqitch revert db:pg:flipr_test + Revert all changes from db:pg:flipr_test? [Yes] + - appschema .. ok + +The L<C<revert>|sqitch-revert> command first prompts to make sure that we +really do want to revert. This is to prevent unnecessary accidents. You can +pass the C<-y> option to disable the prompt. Also, notice the C<-> before the +change name in the output, which reinforces that the change is being +I<removed> from the database. And now the schema should be gone: + + > psql -d flipr_test -c '\dn flipr' + List of schemas + Name | Owner + ------+------- + +And the status message should reflect as much: + + > sqitch status db:pg:flipr_test + # On database db:pg:flipr_test + No changes deployed + +Of course, since nothing is deployed, the L<C<verify>|sqitch-verify> command +has nothing to verify: + + > sqitch verify db:pg:flipr_test + Verifying db:pg:flipr_test + No changes deployed + +However, we still have a record that the change happened, visible via the +L<C<log>|sqitch-log> command: + + > sqitch log db:pg:flipr_test + On database db:pg:flipr_test + Revert c7981df861183412b01be706889e508a63d445ca + Name: appschema + Committer: Marge N. O’Vera <marge@example.com> + Date: 2013-12-30 15:38:17 -0800 + + Add schema for all flipr objects. + + Deploy c7981df861183412b01be706889e508a63d445ca + Name: appschema + Committer: Marge N. O’Vera <marge@example.com> + Date: 2013-12-30 15:27:15 -0800 + + Add schema for all flipr objects. + +Note that the actions we took are shown in reverse chronological order, with +the revert first and then the deploy. + +Cool. Now let's commit it. + + > git add . + > git commit -m 'Add flipr schema.' + [master d812132] Add flipr schema. + 4 files changed, 22 insertions(+) + create mode 100644 deploy/appschema.sql + create mode 100644 revert/appschema.sql + create mode 100644 verify/appschema.sql + +And then deploy again. This time, let's use the C<--verify> option, so that +the C<verify> script is applied when the change is deployed: + + > sqitch deploy --verify db:pg:flipr_test + Deploying changes to db:pg:flipr_test + + appschema .. ok + +And now the schema should be back: + + > psql -d flipr_test -c '\dn flipr' + List of schemas + Name | Owner + -------+------- + flipr | marge + +When we look at the status, the deployment will be there: + + > sqitch status db:pg:flipr_test + # On database db:pg:flipr_test + # Project: flipr + # Change: c7981df861183412b01be706889e508a63d445ca + # Name: appschema + # Deployed: 2013-12-30 15:40:53 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 On Target + +I'm getting a little tired of always having to type C<db:pg:flipr_test>, +aren't you? This L<database connection URI|https://github.com/libwww-perl/uri-db/> +tells Sqitch how to connect to the deployment target, but we don't have +to keep using the URI. We can name the target: + + > sqitch target add flipr_test db:pg:flipr_test + +The L<C<target>|sqitch-target> command, inspired by +L<C<git-remote>|https://git-scm.com/docs/git-remote>, allows management of one +or more named deployment targets. We've just added a target named +C<flipr_test>, which means we can use the string C<flipr_test> for the target, +rather than the URI. But since we're doing so much testing, we can also use +the L<C<engine>|sqitch-engine> command to tell Sqitch to deploy to the +C<flipr_test> target by default: + + > sqitch engine add pg flipr_test + +Now we can omit the target argument altogether, unless we need to deploy to +another database. Which we will, eventually, but at least our examples will be +simpler from here on in, e.g.: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: c7981df861183412b01be706889e508a63d445ca + # Name: appschema + # Deployed: 2013-12-30 15:40:53 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Yay, that allows things to be a little more concise. Let's also make sure that +changes are verified after deploying them: + + > sqitch config --bool deploy.verify true + > sqitch config --bool rebase.verify true + +We'll see the L<C<rebase>|sqitch-rebase> command a bit later. In the meantime, +let's commit the new configuration and and make some more changes! + + > git commit -am 'Set default deployment target and always verify.' + [master a6267d3] Set default deployment target and always verify. + 1 file changed, 8 insertions(+) + +=head1 Deploy with Dependency + +Let's add another change, this time to create a table. Our app will need +users, of course, so we'll create a table for them. First, add the new change: + + > sqitch add users --requires appschema -n 'Creates table to track our users.' + Created deploy/users.sql + Created revert/users.sql + Created verify/users.sql + Added "users [appschema]" to sqitch.plan + +Note that we're requiring the C<appschema> change as a dependency of the new +C<users> change. Although that change has already been added to the plan and +therefore should always be applied before the C<users> change, it's a good +idea to be explicit about dependencies. + +Now edit the scripts. When you're done, F<deploy/users.sql> should look like +this: + + -- Deploy flipr:users to pg + -- requires: appschema + + BEGIN; + + SET client_min_messages = 'warning'; + + CREATE TABLE flipr.users ( + nickname TEXT PRIMARY KEY, + password TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + COMMIT; + +A few things to notice here. On the second line, the dependence on the +C<appschema> change has been listed. This doesn't do anything, but the default +C<deploy> PostgreSQL template lists it here for your reference while editing +the file. Useful, right? + +Notice that all of the SQL code is wrapped in a transaction. This is handy for +PostgreSQL deployments, because PostgreSQL DDLs are transactional. The upshot +is that if any part of this deploy script fails, the whole change fails. Such +may work less-well for database engines that don't support transactional DDLs. + +The table itself will be created in the C<flipr> schema. This is why we need +to require the C<appschema> change. + +Now for the verify script. The simplest way to check that the table was +created and has the expected columns without touching the data? Just select +from the table with a false C<WHERE> clause. Add this to F<verify/users.sql>: + + SELECT nickname, password, timestamp + FROM flipr.users + WHERE FALSE; + +Now for the revert script: all we have to do is drop the table. Add this to +F<revert/users.sql>: + + DROP TABLE flipr.users; + +Couldn't be much simpler, right? Let's deploy this bad boy: + + > sqitch deploy + Deploying changes to flipr_test + + users .. ok + +We know, since verification is enabled, that the table must have been created. +But for the purposes of visibility, let's have a quick look: + + > psql -d flipr_test -c '\d flipr.users' + Table "flipr.users" + Column | Type | Modifiers + -----------+--------------------------+------------------------ + nickname | text | not null + password | text | not null + timestamp | timestamp with time zone | not null default now() + Indexes: + "users_pkey" PRIMARY KEY, btree (nickname) + +We can also verify all currently deployed changes with the +L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appschema .. ok + * users ...... ok + Verify successful + +Now have a look at the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 77398e1dbc5fbce58b05eb67d201f15774718727 + # Name: users + # Deployed: 2013-12-30 15:51:09 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Success! Let's make sure we can revert the change, as well: + + > sqitch revert --to @HEAD^ -y + Reverting changes to appschema from flipr_test + - users .. ok + +Note that we've used the C<--to> option to specify the change to revert to. +And what do we revert to? The symbolic tag C<@HEAD>, when passed to +L<C<revert>|sqitch-revert>, always refers to the last change deployed to the +database. (For other commands, it refers to the last change in the plan.) +Appending the caret (C<^>) tells Sqitch to select the change I<prior> to the +last deployed change. So we revert to C<appschema>, the penultimate change. +The other potentially useful symbolic tag is C<@ROOT>, which refers to the +first change deployed to the database (or in the plan, depending on the +command). + +Back to the database. The C<users> table should be gone but the C<flipr> schema +should still be around: + + > psql -d flipr_test -c '\d flipr.users' + Did not find any relation named "flipr.users". + +The L<C<status>|sqitch-status> command politely informs us that we have +undeployed changes: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: c7981df861183412b01be706889e508a63d445ca + # Name: appschema + # Deployed: 2013-12-30 15:40:53 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Undeployed change: + * users + +As does the L<C<verify>|sqitch-verify> command: + + > sqitch verify + Verifying flipr_test + * appschema .. ok + Undeployed change: + * users + Verify successful + +Note that the verify is successful, because all currently-deployed changes are +verified. The list of undeployed changes (just "users" here) reminds us about +the current state. + +Okay, let's commit and deploy again: + + > git add . + > git commit -am 'Add users table.' + [master d58ea2f] Add users table. + 4 files changed, 31 insertions(+) + create mode 100644 deploy/users.sql + create mode 100644 revert/users.sql + create mode 100644 verify/users.sql + > sqitch deploy + Deploying changes to flipr_test + + users .. ok + +Looks good. Check the status: + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 77398e1dbc5fbce58b05eb67d201f15774718727 + # Name: users + # Deployed: 2013-12-30 15:57:14 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Excellent. Let's do some more! + +=head1 Add Two at Once + +Let's add a couple more changes to add functions for managing users. + + > sqitch add insert_user --requires users --requires appschema \ + -n 'Creates a function to insert a user.' + Created deploy/insert_user.sql + Created revert/insert_user.sql + Created verify/insert_user.sql + Added "insert_user [users appschema]" to sqitch.plan + + > sqitch add change_pass --requires users --requires appschema \ + -n 'Creates a function to change a user password.' + Created deploy/change_pass.sql + Created revert/change_pass.sql + Created verify/change_pass.sql + Added "change_pass [users appschema]" to sqitch.plan + +Now might be a good time to have a look at the deployment plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-intro/ + + appschema 2013-12-30T23:19:45Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2013-12-30T23:49:00Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + insert_user [users appschema] 2013-12-30T23:57:36Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user. + change_pass [users appschema] 2013-12-30T23:57:45Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password. + +Each change appears on a single line with the name of the change, a bracketed +list of dependencies, a timestamp, the name and email address of the user who +planned the change, and a note. + +Let's write the code for the new changes. Here's what +F<deploy/insert_user.sql> should look like: + + -- Deploy flipr:insert_user to pg + -- requires: users + -- requires: appschema + + BEGIN; + + CREATE OR REPLACE FUNCTION flipr.insert_user( + nickname TEXT, + password TEXT + ) RETURNS VOID LANGUAGE SQL SECURITY DEFINER AS $$ + INSERT INTO flipr.users VALUES($1, md5($2)); + $$; + + COMMIT; + +Here's what F<verify/insert_user.sql> might look like: + + BEGIN; + SELECT has_function_privilege('flipr.insert_user(text, text)', 'execute'); + ROLLBACK; + +We simply take advantage of the fact that C<has_function_privilege()> throws +an exception if the specified function does not exist. + +And F<revert/insert_user.sql> should look something like this: + + -- Revert flipr:insert_user from pg + BEGIN; + DROP FUNCTION flipr.insert_user(TEXT, TEXT); + COMMIT; + +Now for C<change_pass>; F<deploy/change_pass.sql> might look like this: + + -- Deploy flipr:change_pass to pg + -- requires: users + -- requires: appschema + + BEGIN; + + CREATE OR REPLACE FUNCTION flipr.change_pass( + nick TEXT, + oldpass TEXT, + newpass TEXT + ) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER AS $$ + BEGIN + UPDATE flipr.users + SET password = md5($3) + WHERE nickname = $1 + AND password = md5($2); + RETURN FOUND; + END; + $$; + + COMMIT; + +Use C<has_function_privilege()> in F<verify/change_pass.sql> again: + + BEGIN; + SELECT has_function_privilege('flipr.change_pass(text, text, text)', 'execute'); + ROLLBACK; + +And of course, its C<revert> script, F<revert/change_pass.sql>, should look +something like: + + -- Revert flipr:change_pass from pg + BEGIN; + DROP FUNCTION flipr.change_pass(TEXT, TEXT, TEXT); + COMMIT; + +Try em out! + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. ok + + change_pass .. ok + +Do we have the functions? Of course we do, they were verified. Still, have a +look: + + > psql -d flipr_test -c '\df flipr.*' + List of functions + Schema | Name | Result data type | Argument data types | Type + --------+-------------+------------------+---------------------------------------+-------- + flipr | change_pass | boolean | nick text, oldpass text, newpass text | normal + flipr | insert_user | void | nickname text, password text | normal + +And what's the status? + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 01a4f6964b89284525cb5877d222df8be70d1647 + # Name: change_pass + # Deployed: 2013-12-30 15:59:44 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Looks good. Let's make sure revert works: + + > sqitch revert -y --to @HEAD^^ + Reverting changes to users from flipr_test + - change_pass .. ok + - insert_user .. ok + > psql -d flipr_test -c '\df flipr.*' + List of functions + Schema | Name | Result data type | Argument data types | Type + --------+------+------------------+---------------------+------ + +Note the use of C<@HEAD^^> to specify that the revert be to two changes prior +the last deployed change. Looks good. Let's do the commit and re-deploy dance: + + > git add . + > git commit -m 'Add `insert_user()` and `change_pass()`.' + [master c9b4d68] Add `insert_user()` and `change_pass()`. + 7 files changed, 65 insertions(+) + create mode 100644 deploy/change_pass.sql + create mode 100644 deploy/insert_user.sql + create mode 100644 revert/change_pass.sql + create mode 100644 revert/insert_user.sql + create mode 100644 verify/change_pass.sql + create mode 100644 verify/insert_user.sql + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. ok + + change_pass .. ok + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: 01a4f6964b89284525cb5877d222df8be70d1647 + # Name: change_pass + # Deployed: 2013-12-30 16:00:50 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + + > sqitch verify + Verifying flipr_test + * appschema .... ok + * users ........ ok + * insert_user .. ok + * change_pass .. ok + Verify successful + +Great, we're fully up-to-date! + +=head1 Ship It! + +Let's do a first release of our app. Let's call it C<1.0.0-dev1> Since we want +to have it go out with deployments tied to the release, let's tag it: + + > sqitch tag v1.0.0-dev1 -n 'Tag v1.0.0-dev1.' + Tagged "change_pass" with @v1.0.0-dev1 + > git commit -am 'Tag the database with v1.0.0-dev1.' + [master 0acef3e] Tag the database with v1.0.0-dev1. + 1 file changed, 1 insertion(+) + > git tag v1.0.0-dev1 -am 'Tag v1.0.0-dev1' + +We can try deploying to make sure the tag gets picked up like so: + + > createdb flipr_dev + > sqitch deploy db:pg:flipr_dev + Adding registry tables to db:pg:flipr_dev + Deploying changes to db:pg:flipr_dev + + appschema ................. ok + + users ..................... ok + + insert_user ............... ok + + change_pass @v1.0.0-dev1 .. ok + +Great, all four changes were deployed and C<change_pass> was tagged with +C<@v1.0.0-dev1>. Let's have a look at the status: + + > sqitch status db:pg:flipr_dev + # On database db:pg:flipr_dev + # Project: flipr + # Change: 01a4f6964b89284525cb5877d222df8be70d1647 + # Name: change_pass + # Tag: @v1.0.0-dev1 + # Deployed: 2013-12-30 16:02:19 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + + +Note the listing of the tag as part of the status message. Now let's bundle +everything up for release: + + > sqitch bundle + Bundling into bundle/ + Writing config + Writing plan + Writing scripts + + appschema + + users + + insert_user + + change_pass @v1.0.0-dev1 + +Now we can package the F<bundle> directory and distribute it. When it gets +installed somewhere, users can use Sqitch to deploy to the database. Let's try +deploying it: + + > cd bundle + > createdb flipr_prod + > sqitch deploy db:pg:flipr_prod + Adding registry tables to db:pg:flipr_prod + Deploying changes to db:pg:flipr_prod + + appschema ................. ok + + users ..................... ok + + insert_user ............... ok + + change_pass @v1.0.0-dev1 .. ok + +Looks much the same as before, eh? Package it up and ship it! + + > cd .. + > mv bundle flipr-v1.0.0-dev1 + > tar -czf flipr-v1.0.0-dev1.tgz flipr-v1.0.0-dev1 + +=head1 Flip Out + +Now that we've got the basics of user management done, let's get to work on +the core of our product, the "flip." Since other folks are working on other +tasks in the repository, we'll work on a branch, so we can all stay out of +each other's way. So let's branch: + + > git checkout -b flips + Switched to a new branch 'flips' + +Now we can add a new change to create a table for our flips. + + > sqitch add flips -r appschema -r users -n 'Adds table for storing flips.' + Created deploy/flips.sql + Created revert/flips.sql + Created verify/flips.sql + Added "flips [appschema users]" to sqitch.plan + +You know the drill by now. Edit F<deploy/flips.sql>: + + -- Deploy flipr:flips to pg + -- requires: appschema + -- requires: users + + BEGIN; + + SET client_min_messages = 'warning'; + + CREATE TABLE flipr.flips ( + id BIGSERIAL PRIMARY KEY, + nickname TEXT NOT NULL REFERENCES flipr.users(nickname), + body TEXT NOT NULL DEFAULT '' CHECK ( length(body) <= 180 ), + timestamp TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() + ); + + COMMIT; + +Edit F<verify/flips.sql>: + + -- Verify flipr:flips on pg + + BEGIN; + + SELECT id + , nickname + , body + , timestamp + FROM flipr.flips + WHERE FALSE; + + ROLLBACK; + +And edit F<revert/flips.sql>: + + -- Revert flipr:flips from pg + + BEGIN; + + DROP TABLE flipr.flips; + + COMMIT; + +And give it a whirl: + + > sqitch deploy + Deploying changes to flipr_test + + flips .. ok + +Look good? + + > sqitch status --show-tags + # On database flipr_test + # Project: flipr + # Change: 4d164ef5986450f00a565735518b1d126f8ee69d + # Name: flips + # Deployed: 2013-12-30 16:34:38 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + # Tag: + # @v1.0.0-dev1 - 2013-12-30 16:34:38 -0800 - Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Note the use of C<--show-tags> to show all the deployed tags. Now make it so: + + > git add . + [flips e8f4655] Add flips table. + > git commit -am 'Add flips table.' + 4 files changed, 37 insertions(+) + create mode 100644 deploy/flips.sql + create mode 100644 revert/flips.sql + create mode 100644 verify/flips.sql + +=head1 Wash, Rinse, Repeat + +Now comes the time to add functions to manage flips. I'm sure you have things +nailed down now. Go ahead and add C<insert_flip> and C<delete_flip> changes +and commit them. The C<insert_flip> deploy script might look something like: + + -- Deploy flipr:insert_flip to pg + -- requires: flips + -- requires: appschema + -- requires: users + + BEGIN; + + CREATE OR REPLACE FUNCTION flipr.insert_flip( + nickname TEXT, + body TEXT + ) RETURNS BIGINT LANGUAGE sql SECURITY DEFINER AS $$ + INSERT INTO flipr.flips (nickname, body) + VALUES ($1, $2) + RETURNING id; + $$; + + COMMIT; + +And the C<delete_flip> deploy script might look something like: + + -- Deploy flipr:delete_flip to pg + -- requires: flips + -- requires: appschema + -- requires: users + + BEGIN; + + CREATE OR REPLACE FUNCTION flipr.delete_flip( + flip_id BIGINT + ) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER AS $$ + BEGIN + DELETE FROM flipr.flips WHERE id = flip_id; + RETURN FOUND; + END; + $$; + + COMMIT; + +The C<verify> scripts are: + + -- Verify flipr:insert_flip on pg + + BEGIN; + + SELECT has_function_privilege('flipr.insert_flip(text, text)', 'execute'); + + ROLLBACK; + +And: + + -- Verify flipr:delete_flip on pg + + BEGIN; + + SELECT has_function_privilege('flipr.delete_flip(bigint)', 'execute'); + + ROLLBACK; + +The C<revert> scripts are: + + -- Revert flipr:insert_flip from pg + + BEGIN; + + DROP FUNCTION flipr.insert_flip(TEXT, TEXT); + + COMMIT; + +And: + + -- Revert flipr:delete_flip from pg + + BEGIN; + + DROP FUNCTION flipr.delete_flip(BIGINT); + + COMMIT; + +Check the L<example git repository|https://github.com/sqitchers/sqitch-intro> for +the complete details. Test L<C<deploy>|sqitch-deploy> and +L<C<revert>|sqitch-revert>, then commit it to the repository. The status +should end up looking something like this: + + > sqitch status --show-tags + # On database flipr_test + # Project: flipr + # Change: 9a645034b35fa46df37a3725c480982628cc64ec + # Name: delete_flip + # Deployed: 2013-12-30 16:37:51 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + # Tag: + # @v1.0.0-dev1 - 2013-12-30 16:34:38 -0800 - Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +Good, we've finished this feature. Time to merge back into C<master>. + +=head2 Emergency + +Let's do it: + + > git checkout master + Switched to branch 'master' + > git pull + Updating 0acef3e..d4cbd7d + Fast-forward + deploy/delete_list.sql | 20 ++++++++++++++++++++ + deploy/insert_list.sql | 17 +++++++++++++++++ + deploy/lists.sql | 16 ++++++++++++++++ + revert/delete_list.sql | 7 +++++++ + revert/insert_list.sql | 7 +++++++ + revert/lists.sql | 7 +++++++ + sqitch.plan | 4 ++++ + verify/delete_list.sql | 7 +++++++ + verify/insert_list.sql | 7 +++++++ + verify/lists.sql | 9 +++++++++ + 10 files changed, 101 insertions(+) + create mode 100644 deploy/delete_list.sql + create mode 100644 deploy/insert_list.sql + create mode 100644 deploy/lists.sql + create mode 100644 revert/delete_list.sql + create mode 100644 revert/insert_list.sql + create mode 100644 revert/lists.sql + create mode 100644 verify/delete_list.sql + create mode 100644 verify/insert_list.sql + create mode 100644 verify/lists.sql + +Hrm, that's interesting. Looks like someone made some changes to C<master>. +They added list support. Well, let's see what happens when we merge our +changes. + + > git merge --no-ff flips + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Automatic merge failed; fix conflicts and then commit the result. + +Oh, a conflict in F<sqitch.plan>. Not too surprising, since both the merged +C<lists> branch and our C<flips> branch added changes to the plan. Let's try a +different approach. + +The truth is, we got lazy. Those changes when we pulled master from the origin +should have raised a red flag. It's considered a bad practice not to look at +what's changed in C<master> before merging in a branch. What one I<should> do +is either: + +=over + +=item * + +Rebase the F<flips> branch from master before merging. This "rewinds" the +branch changes, pulls from C<master>, and then replays the changes back on top +of the pulled changes. + +=item * + +Create a patch and apply I<that> to master. This is the sort of thing you +might have to do if you're sending changes to another user, especially if the +VCS is not Git. + +=back + +So let's restore things to how they were at master: + + > git reset --hard HEAD + HEAD is now at ff60b9b Merge branch 'lists' + +That throws out our botched merge. Now let's go back to our branch and rebase +it on C<master>: + + > git checkout flips + Switched to branch 'flips' + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add flips table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + CONFLICT (content): Merge conflict in sqitch.plan + Failed to merge in the changes. + Patch failed at 0001 Add flips table. + The copy of the patch that failed is found in: + .git/rebase-apply/patch + + When you have resolved this problem, run "git rebase --continue". + If you prefer to skip this patch, run "git rebase --skip" instead. + To check out the original branch and stop rebasing, run "git rebase --abort". + +Oy, that's kind of a pain. It seems like no matter what we do, we'll need to +resolve conflicts in that file. Except in Git. Fortunately for us, we can tell +Git to resolve conflicts in F<sqitch.plan> differently. Because we only ever +append lines to the file, we can have it use the "union" merge driver, which, +according to L<its +docs|https://git-scm.com/docs/gitattributes#_built-in_merge_drivers>: + +=over + +Run 3-way file level merge for text files, but take lines from both versions, +instead of leaving conflict markers. This tends to leave the added lines in +the resulting file in random order and the user should verify the result. Do +not use this if you do not understand the implications. + +=back + +This has the effect of appending lines from all the merging files, which is +exactly what we need. So let's give it a try. First, back out the botched +rebase: + + > git rebase --abort + +Now add the union merge driver to F<.gitattributes> for F<sqitch.plan> +and rebase again: + + > echo sqitch.plan merge=union > .gitattributes + > git rebase master + First, rewinding head to replay your work on top of it... + Applying: Add flips table. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + Applying: Add functions to insert and delete flips. + Using index info to reconstruct a base tree... + M sqitch.plan + Falling back to patching base and 3-way merge... + Auto-merging sqitch.plan + +Ah, that looks a bit better. Let's have a look at the plan: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-intro/ + + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-intro/ + + appschema 2013-12-30T23:19:45Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2013-12-30T23:49:00Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + insert_user [users appschema] 2013-12-30T23:57:36Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user. + change_pass [users appschema] 2013-12-30T23:57:45Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password. + @v1.0.0-dev1 2013-12-31T00:01:22Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appschema users] 2013-12-31T00:39:40Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + insert_list [lists appschema users] 2013-12-31T00:41:29Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list. + delete_list [lists appschema users] 2013-12-31T00:41:37Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a list. + flips [appschema users] 2013-12-31T00:32:39Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + insert_flip [flips appschema users] 2013-12-31T00:35:59Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip. + delete_flip [flips appschema users] 2013-12-31T00:36:34Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip. + +Note that it has appended the changes from the merged "lists" branch, and then +merged the changes from our "flips" branch. Test it to make sure it works as +expected: + + > sqitch rebase -y + Reverting all changes from flipr_test + - delete_flip ............... ok + - insert_flip ............... ok + - flips ..................... ok + - change_pass @v1.0.0-dev1 .. ok + - insert_user ............... ok + - users ..................... ok + - appschema ................. ok + Deploying changes to flipr_test + + appschema ................. ok + + users ..................... ok + + insert_user ............... ok + + change_pass @v1.0.0-dev1 .. ok + + lists ..................... ok + + insert_list ............... ok + + delete_list ............... ok + + flips ..................... ok + + insert_flip ............... ok + + delete_flip ............... ok + +Note the use of L<C<rebase>|sqitch-rebase>, which combines a +L<C<revert>|sqitch-revert> and a L<C<deploy>|sqitch-deploy> into a single +command. Handy, right? It correctly reverted our changes, and then deployed +them all again in the proper order. So let's commit F<.gitattributes>; seems +worthwhile to keep that change: + + > git add . + > git commit -m 'Add `.gitattributes` with union merge for `sqitch.plan`.' + [flips f5ad242] Add `.gitattributes` with union merge for `sqitch.plan`. + 1 file changed, 1 insertion(+) + create mode 100644 .gitattributes + +=head2 Merges Mastered + +And now, finally, we can merge into C<master>: + + > git checkout master + Switched to branch 'master' + > git merge --no-ff flips + Merge made by the 'recursive' strategy. + .gitattributes | 1 + + deploy/delete_flip.sql | 17 +++++++++++++++++ + deploy/flips.sql | 16 ++++++++++++++++ + deploy/insert_flip.sql | 17 +++++++++++++++++ + revert/delete_flip.sql | 7 +++++++ + revert/flips.sql | 7 +++++++ + revert/insert_flip.sql | 7 +++++++ + sqitch.plan | 3 +++ + verify/delete_flip.sql | 7 +++++++ + verify/flips.sql | 12 ++++++++++++ + verify/insert_flip.sql | 7 +++++++ + 11 files changed, 101 insertions(+) + create mode 100644 .gitattributes + create mode 100644 deploy/delete_flip.sql + create mode 100644 deploy/flips.sql + create mode 100644 deploy/insert_flip.sql + create mode 100644 revert/delete_flip.sql + create mode 100644 revert/flips.sql + create mode 100644 revert/insert_flip.sql + create mode 100644 verify/delete_flip.sql + create mode 100644 verify/flips.sql + create mode 100644 verify/insert_flip.sql + +And double-check our work: + + > cat sqitch.plan + %syntax-version=1.0.0 + %project=flipr + %uri=https://github.com/sqitchers/sqitch-intro/ + + appschema 2013-12-30T23:19:45Z Marge N. O’Vera <marge@example.com> # Add schema for all flipr objects. + users [appschema] 2013-12-30T23:49:00Z Marge N. O’Vera <marge@example.com> # Creates table to track our users. + insert_user [users appschema] 2013-12-30T23:57:36Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a user. + change_pass [users appschema] 2013-12-30T23:57:45Z Marge N. O’Vera <marge@example.com> # Creates a function to change a user password. + @v1.0.0-dev1 2013-12-31T00:01:22Z Marge N. O’Vera <marge@example.com> # Tag v1.0.0-dev1. + + lists [appschema users] 2013-12-31T00:39:40Z Marge N. O’Vera <marge@example.com> # Adds table for storing lists. + insert_list [lists appschema users] 2013-12-31T00:41:29Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a list. + delete_list [lists appschema users] 2013-12-31T00:41:37Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a list. + flips [appschema users] 2013-12-31T00:32:39Z Marge N. O’Vera <marge@example.com> # Adds table for storing flips. + insert_flip [flips appschema users] 2013-12-31T00:35:59Z Marge N. O’Vera <marge@example.com> # Creates a function to insert a flip. + delete_flip [flips appschema users] 2013-12-31T00:36:34Z Marge N. O’Vera <marge@example.com> # Creates a function to delete a flip. + +Much much better, a nice clean master now. And because it is now identical to +the "flips" branch, we can just carry on. Go ahead and tag it, bundle, and +release: + + > sqitch tag v1.0.0-dev2 -n 'Tag v1.0.0-dev2.' + Tagged "delete_flip" with @v1.0.0-dev2 + > git commit -am 'Tag the database with v1.0.0-dev2.' + [master 230603b] Tag the database with v1.0.0-dev2. + 1 file changed, 1 insertion(+) + > git tag v1.0.0-dev2 -am 'Tag v1.0.0-dev2' + > sqitch bundle --dest-dir flipr-1.0.0-dev2 + Bundling into flipr-1.0.0-dev2 + Writing config + Writing plan + Writing scripts + + appschema + + users + + insert_user + + change_pass @v1.0.0-dev1 + + lists + + insert_list + + delete_list + + flips + + insert_flip + + delete_flip @v1.0.0-dev2 + +Note the use of the C<--dest-dir> option to C<sqitch bundle>. Just a nicer way +to create the top-level directory name so we don't have to rename it from +F<bundle>. + +=head1 In Place Changes + +Uh-oh, someone just noticed that MD5 hashing is not particularly secure. Why? +Have a look at this: + + > psql -d flipr_test -c " + SELECT flipr.insert_user('foo', 'secr3t'), flipr.insert_user('bar', 'secr3t'); + SELECT * FROM flipr.users; + " + nickname | password | timestamp + ----------+----------------------------------+------------------------------- + foo | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 00:56:20.240481+00 + bar | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 00:56:20.240481+00 + +If user "foo" ever got access to the database, she could quickly discover that +user "bar" has the same password and thus be able to exploit the account. Not +a great idea. So we need to modify the C<insert_user()> and C<change_pass()> +functions to fix that. How? + +We'll use +L<C<pgcrypto>|https://www.postgresql.org/docs/current/static/pgcrypto.html>'s +C<crypt()> function to encrypt passwords with a salt, so that they're all +unique. We just add a change to add C<pgcrypto> to the database, and then we +can use it. The deploy script should be: + + CREATE EXTENSION pgcrypto; + +And the revert script should be: + + DROP EXTENSION pgcrypto; + +=over + +If you're on PostgreSQL 9.0 or lower, you won't be able to deploy C<pgcrypto> +with a Sqitch change, alas. You'll have to install it manually, like so: + + psql -d flipr_test -f /path/to/pgsql/share/contrib/pgcrypto.sql + +Don't forget to do this with your staging and production databases, too. Or +consider upgrading to PostgreSQL 9.1 or higher; the SQL-level extension +support is amazingly useful. + +=back + +We're going to use the C<crypt()> and C<gen_salt()> functions, so in the +C<verify> script, let's make sure that the extension exists I<and> that both +those functions exist: + + SELECT 1/count(*) FROM pg_extension WHERE extname = 'pgcrypto'; + SELECT has_function_privilege('crypt(text, text)', 'execute'); + SELECT has_function_privilege('gen_salt(text)', 'execute'); + +Now we can use C<pgcrypto>. But how to deploy the changes to C<insert_user()> +and C<change_pass()>? + +Normally, modifying functions in database changes is a +L<PITA|https://www.urbandictionary.com/define.php?term=pita>. You have to make +changes like these: + +=over + +=item 1. + +Copy F<deploy/insert_user.sql> to F<deploy/insert_user_crypt.sql>. + +=item 2. + +Edit F<deploy/insert_user_crypt.sql> to switch from C<MD5()> to C<crypt()> +and to add a dependency on the C<pgcrypto> change. + +=item 3. + +Copy F<deploy/insert_user.sql> to F<revert/insert_user_crypt.sql>. +Yes, copy the original change script to the new revert change. + +=item 4. + +Copy F<verify/insert_user.sql> to F<verify/insert_user_crypt.sql>. + +=item 5. + +Edit F<verify/insert_user_crypt.sql> to test that the function now properly +uses C<crypt()>. + +=item 6. + +Test the changes to make sure you can deploy and revert the +C<insert_user_crypt> change. + +=item 7. + +Now do the same for the C<change_pass> scripts. + +=back + +But you can have Sqitch do it for you. The only requirement is that a tag +appear between the two instances of a change we want to modify. In general, +you're going to make a change like this after a release, which you've tagged +anyway, right? Well we have, with C<@v1.0.0-dev2> added in the previous +section. With that, we can let Sqitch do most of the hard work for us, thanks +to the L<C<rework>|sqitch-rework> command, which is similar to +L<C<add>|sqitch-add>, including support for the C<--requires> option: + + > sqitch rework insert_user --requires pgcrypto -n 'Change insert_user to use pgcrypto.' + Added "insert_user [insert_user@v1.0.0-dev2 pgcrypto]" to sqitch.plan. + Modify these files as appropriate: + * deploy/insert_user.sql + * revert/insert_user.sql + * verify/insert_user.sql + +Oh, so we can edit those files in place. Nice! How does Sqitch do it? Well, in +point of fact, it has copied the files to stand in for the previous instance +of the C<insert_user> change, which we can see via C<git status>: + + > git status + # On branch master + # Changes not staged for commit: + # (use "git add <file>..." to update what will be committed) + # (use "git checkout -- <file>..." to discard changes in working directory) + # + # modified: revert/insert_user.sql + # modified: sqitch.plan + # + # Untracked files: + # (use "git add <file>..." to include in what will be committed) + # + # deploy/insert_user@v1.0.0-dev2.sql + # revert/insert_user@v1.0.0-dev2.sql + # verify/insert_user@v1.0.0-dev2.sql + no changes added to commit (use "git add" and/or "git commit -a") + +The "untracked files" part of the output is the first thing to notice. They +are all named C<insert_user@v1.0.0-dev2.sql>. What that means is: "the +C<insert_user> change as it was implemented as of the C<@v1.0.0-dev2> tag." +These are copies of the original scripts, and thereafter Sqitch will find them +when it needs to run scripts for the first instance of the C<insert_user> +change. As such, it's important not to change them again. But hey, if you're +reworking the change, you shouldn't need to. + +The other thing to notice is that F<revert/insert_user.sql> has changed. +Sqitch replaced it with the original deploy script. As of now, +F<deploy/insert_user.sql> and F<revert/insert_user.sql> are identical. This is +on the assumption that the deploy script will be changed (we're reworking it, +remember?), and that the revert script should actually change things back to +how they were before. Of course, the original deploy script may not be +L<idempotent|https://en.wikipedia.org/wiki/Idempotence> -- that is, able to be +applied multiple times without changing the result beyond the initial +application. If it's not, you will likely need to modify it so that it +properly restores things to how they were after the original deploy script was +deployed. Or, more simply, it should revert changes back to how they were +as-of the deployment of F<deploy/insert_user@v1.0.0-dev2.sql>. + +Fortunately, our function deploy scripts are already idempotent, thanks to the +use of the C<OR REPLACE> expression. No matter how many times a deployment +script is run, the end result will be the same instance of the function, with +no duplicates or errors. + +As a result, there is no need to explicitly add changes. So go ahead. Modify the +script to switch to C<crypt()>. Make this change to +F<deploy/insert_user.sql>: + + @@ -1,6 +1,7 @@ + -- Deploy flipr:insert_user to pg + -- requires: users + -- requires: appschema + +-- requires: pgcrypto + + BEGIN; + + @@ -8,7 +9,7 @@ CREATE OR REPLACE FUNCTION flipr.insert_user( + nickname TEXT, + password TEXT + ) RETURNS VOID LANGUAGE SQL SECURITY DEFINER AS $$ + - INSERT INTO flipr.users VALUES($1, md5($2)); + + INSERT INTO flipr.users values($1, crypt($2, gen_salt('md5'))); + $$; + + COMMIT; + +Go ahead and rework the C<change_pass> change, too: + + > sqitch rework change_pass --requires pgcrypto -n 'Change change_pass to use pgcrypto.' + Added "change_pass [change_pass@v1.0.0-dev2 pgcrypto]" to sqitch.plan. + Modify these files as appropriate: + * deploy/change_pass.sql + * revert/change_pass.sql + * verify/change_pass.sql + +And make this change to F<deploy/change_pass.sql>: + + @@ -1,6 +1,7 @@ + -- Deploy flipr:change_pass to pg + -- requires: users + -- requires: appschema + +-- requires: pgcrypto + + BEGIN; + + @@ -11,9 +12,9 @@ CREATE OR REPLACE FUNCTION flipr.change_pass( + ) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER AS $$ + BEGIN + UPDATE flipr.users + - SET password = md5($3) + + SET password = crypt($3, gen_salt('md5')) + WHERE nickname = $1 + - AND password = md5($2); + + AND password = crypt($2, password); + RETURN FOUND; + END; + $$; + +And then try a deployment: + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. ok + + change_pass .. ok + +So, are the changes deployed? + + > psql -d flipr_test -c " + DELETE FROM flipr.users; + SELECT flipr.insert_user('foo', 'secr3t'), flipr.insert_user('bar', 'secr3t'); + SELECT * FROM flipr.users; + " + nickname | password | timestamp + ----------+------------------------------------+------------------------------- + foo | $1$pRNfJjI9$CdcEXJ9xCoJPD.R5Z/7.R1 | 2013-12-31 01:03:15.398572+00 + bar | $1$Nf1LcU.p$B9sKzdu8vMgu5oxbimo5P1 | 2013-12-31 01:03:15.398572+00 + +Awesome, the stored passwords are different now. But can we revert, even +though we haven't written any reversion scripts? + + > sqitch revert --to @HEAD^^ -y + Reverting changes to pgcrypto from flipr_test + - change_pass .. ok + - insert_user .. ok + +Did that work, are the C<MD5()> passwords back? + + > psql -d flipr_test -c " + DELETE FROM flipr.users; + SELECT flipr.insert_user('foo', 'secr3t'), flipr.insert_user('bar', 'secr3t'); + SELECT * FROM flipr.users; + " + nickname | password | timestamp + ----------+----------------------------------+------------------------------- + foo | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 01:03:57.263583+00 + bar | 9695da4dd567a19f9b92065f240c6725 | 2013-12-31 01:03:57.263583+00 + +Yes, it works! Sqitch properly finds the original instances of these changes +in the new script files that include tags. + +But what about the verify script? How can we verify that the functions have +been modified to use C<crypt()>? I think the simplest thing to do is to +examine the body of the function, using +L<C<pg_get_functiondef()>|https://www.postgresql.org/docs/9.2/static/functions-info.html#FUNCTIONS-INFO-CATALOG-TABLE>. So the C<insert_user> verify script looks like this: + + -- Verify flipr:insert_user on pg + + BEGIN; + + SELECT has_function_privilege('flipr.insert_user(text, text)', 'execute'); + + SELECT 1/COUNT(*) + FROM pg_catalog.pg_proc + WHERE proname = 'insert_user' + AND pg_get_functiondef(oid) LIKE $$%crypt($2, gen_salt('md5'))%$$; + + ROLLBACK; + +And the C<change_pass> verify script looks like this: + + -- Verify flipr:change_pass on pg + + BEGIN; + + SELECT has_function_privilege('flipr.change_pass(text, text, text)', 'execute'); + + SELECT 1/COUNT(*) + FROM pg_catalog.pg_proc + WHERE proname = 'change_pass' + AND pg_get_functiondef(oid) LIKE $$%crypt($3, gen_salt('md5'))%$$; + + ROLLBACK; + +Make sure these pass by re-deploying: + + > sqitch deploy + Deploying changes to flipr_test + + insert_user .. ok + + change_pass .. ok + +Excellent. Let's go ahead and commit these changes: + + > git add . + > git commit -m 'Use pgcrypto to encrypt passwords.' + [master 4257ae6] Use pgcrypto to encrypt passwords. + 13 files changed, 107 insertions(+), 9 deletions(-) + create mode 100644 deploy/change_pass@v1.0.0-dev2.sql + create mode 100644 deploy/insert_user@v1.0.0-dev2.sql + create mode 100644 revert/change_pass@v1.0.0-dev2.sql + create mode 100644 revert/insert_user@v1.0.0-dev2.sql + create mode 100644 verify/change_pass@v1.0.0-dev2.sql + create mode 100644 verify/insert_user@v1.0.0-dev2.sql + + > sqitch status + # On database flipr_test + # Project: flipr + # Change: d3ffa30b72abaf9619ae1f0e726026667612f2b1 + # Name: change_pass + # Deployed: 2013-12-30 17:05:08 -0800 + # By: Marge N. O’Vera <marge@example.com> + # + Nothing to deploy (up-to-date) + +=head1 More to Come + +Sqitch is a work in progress. Better integration with version control systems +is planned to make managing idempotent reworkings even easier. Stay tuned. + +=head1 Author + +David E. Wheeler <david@justatheory.com> + +=head1 License + +Copyright (c) 2012-2018 iovation Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +=cut diff --git a/lib/sqitchusage.pod b/lib/sqitchusage.pod new file mode 100644 index 00000000..4955f113 --- /dev/null +++ b/lib/sqitchusage.pod @@ -0,0 +1,25 @@ +=begin private + +Keep private so it's not displayed, but will still be indexed by the CPAN +toolchain. + +=head1 Name + +sqitchusage - Sqitch usage statement + +=end private + +=head1 Usage + + sqitch <command> [options] [command-options] [args] + +=head1 Options + + -C --chdir --cd <dir> Change to directory before performing any actions + --etc-path Print the path to the etc directory and exit + --no-pager Do not pipe output into a pager + --quiet Quiet mode with non-error output suppressed + -V --verbose Increment verbosity + --version Print the version number and exit + --help Show a list of commands and exit + --man Print the introductory documentation and exit diff --git a/t/add.t b/t/add.t new file mode 100644 index 00000000..858458b4 --- /dev/null +++ b/t/add.t @@ -0,0 +1,1010 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 236; +#use Test::More 'no_plan'; +use App::Sqitch; +use App::Sqitch::Target; +use Locale::TextDomain qw(App-Sqitch); +use Path::Class; +use Test::Exception; +use Test::Warn; +use Test::Dir; +use File::Temp 'tempdir'; +use Test::File qw(file_not_exists_ok file_exists_ok); +use Test::File::Contents 0.05; +use File::Path qw(make_path remove_tree); +use Test::NoWarnings 0.083; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::add'; + +my $config = TestConfig->new( + 'core.engine' => 'pg', + 'core.top_dir' => dir('test-add')->stringify, +); +ok my $sqitch = App::Sqitch->new(config => $config), + 'Load a sqitch sqitch object'; + +isa_ok my $add = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'add', + config => $config, + args => [], +}), $CLASS, 'add command'; +my $target = $add->default_target; + +sub dep($$) { + my $dep = App::Sqitch::Plan::Depend->new( + %{ App::Sqitch::Plan::Depend->parse( $_[1] ) }, + plan => $add->default_target->plan, + conflicts => $_[0], + ); + $dep->project; + return $dep; +} + +can_ok $CLASS, qw( + options + requires + conflicts + variables + template_name + template_directory + with_scripts + templates + open_editor + configure + execute + _config_templates + all_templates + _slurp + _add + does +); + +ok $CLASS->does("App::Sqitch::Role::ContextCommand"), + "$CLASS does ContextCommand"; + +is_deeply [$CLASS->options], [qw( + change-name|change|c=s + requires|r=s@ + conflicts|x=s@ + note|n|m=s@ + all|a! + template-name|template|t=s + template-directory=s + with=s@ + without=s@ + use=s% + open-editor|edit|e! + plan-file|f=s + top-dir=s +)], 'Options should be set up'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +sub contents_of ($) { + my $file = shift; + open my $fh, "<:utf8_strict", $file or die "cannot open $file: $!"; + local $/; + return <$fh>; +} + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure($config, {}, $sqitch), { + requires => [], + conflicts => [], + note => [], + _cx => [], +}, 'Should have default configuration with no config or opts'; + +is_deeply $CLASS->configure($config, { + requires => [qw(foo bar)], + conflicts => ['baz'], + note => [qw(hellow there)], +}), { + requires => [qw(foo bar)], + conflicts => ['baz'], + note => [qw(hellow there)], + _cx => [], +}, 'Should have get requires and conflicts options'; + +is_deeply $CLASS->configure($config, { template_directory => 't' }), { + requires => [], + conflicts => [], + note => [], + _cx => [], + template_directory => dir('t'), +}, 'Should set up template directory option'; + +is_deeply $CLASS->configure($config, { change_name => 'blog' }), { + requires => [], + conflicts => [], + note => [], + _cx => [], + change_name => 'blog', +}, 'Should set up change name option'; + +throws_ok { + $CLASS->configure($config, { template_directory => '__nonexistent__' }); +} 'App::Sqitch::X', 'Should die if --template-directory does not exist'; +is $@->ident, 'add', 'Missing directory ident should be "add"'; +is $@->message, __x( + 'Directory "{dir}" does not exist', + dir => '__nonexistent__', +), 'Missing directory error message should be correct'; + +throws_ok { + $CLASS->configure($config, { template_directory => 'README.md' }); +} 'App::Sqitch::X', 'Should die if --template-directory does is not a dir'; +is $@->ident, 'add', 'In alid directory ident should be "add"'; +is $@->message, __x( + '"{dir}" is not a directory', + dir => 'README.md', +), 'Invalid directory error message should be correct'; + +is_deeply $CLASS->configure($config, { template_name => 'foo' }), { + requires => [], + conflicts => [], + note => [], + _cx => [], + template_name => 'foo', +}, 'Should set up template name option'; + +is_deeply $CLASS->configure($config, { + all => 1, + with_scripts => { deploy => 1, revert => 1, verify => 0 }, + use => { + deploy => 'etc/templates/deploy/pg.tmpl', + revert => 'etc/templates/revert/pg.tmpl', + verify => 'etc/templates/verify/pg.tmpl', + whatev => 'etc/templates/verify/pg.tmpl', + }, +}), { + all => 1, + requires => [], + conflicts => [], + note => [], + _cx => [], + with_scripts => { deploy => 1, revert => 1, verify => 0 }, + templates => { + deploy => file('etc/templates/deploy/pg.tmpl'), + revert => file('etc/templates/revert/pg.tmpl'), + verify => file('etc/templates/verify/pg.tmpl'), + whatev => file('etc/templates/verify/pg.tmpl'), + } +}, 'Should have get template options'; + +# Test variable configuration. +CONFIG: { + my $config = TestConfig->from( + local => File::Spec->catfile(qw(t add_change.conf)) + ); + my $dir = dir 't'; + is_deeply $CLASS->configure($config, {}), { + template_directory => $dir, + template_name => 'hi', + requires => [], + conflicts => [], + note => [], + _cx => [], + }, 'Variables should by default not be loaded from config'; + + is_deeply $CLASS->configure($config, {set => { yo => 'dawg' }}), { + template_directory => $dir, + template_name => 'hi', + requires => [], + conflicts => [], + note => [], + _cx => [], + variables => { + foo => 'bar', + baz => [qw(hi there you)], + yo => 'dawg', + }, + }, '--set should be merged with config variables'; + + is_deeply $CLASS->configure($config, {set => { foo => 'ick' }}), { + template_directory => $dir, + template_name => 'hi', + requires => [], + conflicts => [], + note => [], + _cx => [], + variables => { + foo => 'ick', + baz => [qw(hi there you)], + }, + }, '--set should be override config variables'; +} + +############################################################################## +# Test attributes. +is_deeply $add->requires, [], 'Requires should be an arrayref'; +is_deeply $add->conflicts, [], 'Conflicts should be an arrayref'; +is_deeply $add->note, [], 'Notes should be an arrayref'; +is_deeply $add->variables, {}, 'Varibles should be a hashref'; +is $add->template_directory, undef, 'Default dir should be undef'; +is $add->template_name, undef, 'Default temlate_name should be undef'; +is_deeply $add->with_scripts, { map { $_ => 1} qw(deploy revert verify) }, + 'Default with_scripts should be all true'; +is_deeply $add->templates, {}, 'Default templates should be empty'; + +############################################################################## +# Test _check_script. +isa_ok my $check = $CLASS->can('_check_script'), 'CODE', '_check_script'; +my $tmpl = 'etc/templates/verify/pg.tmpl'; +is $check->($tmpl), file($tmpl), '_check_script should be okay with script'; + +throws_ok { $check->('nonexistent') } 'App::Sqitch::X', + '_check_script should die on nonexistent file'; +is $@->ident, 'add', 'Nonexistent file ident should be "add"'; +is $@->message, __x( + 'Template {template} does not exist', + template => 'nonexistent', +), 'Nonexistent file error message should be correct'; + +throws_ok { $check->('lib') } 'App::Sqitch::X', + '_check_script should die on directory'; +is $@->ident, 'add', 'Directory error ident should be "add"'; +is $@->message, __x( + 'Template {template} is not a file', + template => 'lib', +), 'Directory error message should be correct'; + +############################################################################## +# Test _config_templates. +READCONFIG: { + my $config = TestConfig->from( + local => file('t/templates.conf')->stringify + ); + $config->update('core.top_dir' => dir('test-add')->stringify); + ok my $sqitch = App::Sqitch->new(config => $config), + 'Load another sqitch sqitch object'; + ok $add = $CLASS->new(sqitch => $sqitch), + 'Create add with template config'; + is_deeply $add->_config_templates($config), { + deploy => file('etc/templates/deploy/pg.tmpl'), + revert => file('etc/templates/revert/pg.tmpl'), + test => file('etc/templates/verify/pg.tmpl'), + verify => file('etc/templates/verify/pg.tmpl'), + }, 'Should load the config templates'; +} + +############################################################################## +# Test all_templates(). +my $tmpldir = dir 'etc/templates'; +my $sysdir = dir 'nonexistent'; +my $usrdir = dir 'nonexistent'; +my $mock = TestConfig->mock( + system_dir => sub { $sysdir }, + user_dir => sub { $usrdir }, +); + +# First, specify template directory. +ok $add = $CLASS->new(sqitch => $sqitch, template_directory => $tmpldir), + 'Add object with template directory'; +is $add->template_name, undef, 'Template name should be undef'; +my $tname = $add->template_name || $target->engine_key; +is_deeply $add->all_templates($tname), { + deploy => file('etc/templates/deploy/pg.tmpl'), + revert => file('etc/templates/revert/pg.tmpl'), + verify => file('etc/templates/verify/pg.tmpl'), +}, 'Should find all templates in directory'; + +# Now let it find the templates in the user dir. +$usrdir = dir 'etc'; +ok $add = $CLASS->new(sqitch => $sqitch, template_name => 'sqlite'), + 'Add object with template name'; +is_deeply $add->all_templates($add->template_name), { + deploy => file('etc/templates/deploy/sqlite.tmpl'), + revert => file('etc/templates/revert/sqlite.tmpl'), + verify => file('etc/templates/verify/sqlite.tmpl'), +}, 'Should find all templates in user directory'; + +# And then the system dir. +($usrdir, $sysdir) = ($sysdir, $usrdir); +ok $add = $CLASS->new(sqitch => $sqitch, template_name => 'mysql'), + 'Add object with another template name'; +is_deeply $add->all_templates($add->template_name), { + deploy => file('etc/templates/deploy/mysql.tmpl'), + revert => file('etc/templates/revert/mysql.tmpl'), + verify => file('etc/templates/verify/mysql.tmpl'), +}, 'Should find all templates in systsem directory'; + +# Now make sure it combines directories. +my $tmp_dir = dir tempdir CLEANUP => 1; +for my $script (qw(deploy whatev)) { + my $subdir = $tmp_dir->subdir($script); + $subdir->mkpath; + $subdir->file('pg.tmpl')->touch; +} + +ok $add = $CLASS->new(sqitch => $sqitch, template_directory => $tmp_dir), + 'Add object with temporary template directory'; +is_deeply $add->all_templates($tname), { + deploy => $tmp_dir->file('deploy/pg.tmpl'), + whatev => $tmp_dir->file('whatev/pg.tmpl'), + revert => file('etc/templates/revert/pg.tmpl'), + verify => file('etc/templates/verify/pg.tmpl'), +}, 'Template dir files should override others'; + +# Add in configured files. +ok $add = $CLASS->new( + sqitch => $sqitch, + template_directory => $tmp_dir, + templates => { + foo => file('foo'), + verify => file('verify'), + deploy => file('deploy'), + }, +), 'Add object with configured templates'; + +is_deeply $add->all_templates($tname), { + deploy => file('deploy'), + verify => file('verify'), + foo => file('foo'), + whatev => $tmp_dir->file('whatev/pg.tmpl'), + revert => file('etc/templates/revert/pg.tmpl'), +}, 'Template dir files should override others'; + +# Should die when missing files. +$sysdir = $usrdir; +for my $script (qw(deploy revert verify)) { + ok $add = $CLASS->new( + sqitch => $sqitch, + with_scripts => { deploy => 0, revert => 0, verify => 0, $script => 1 }, + ), "Add object requiring $script template"; + + throws_ok { $add->all_templates($tname) } 'App::Sqitch::X', + "Should get error for missing $script template"; + is $@->ident, 'add', qq{Missing $script template ident should be "add"}; + is $@->message, __x( + 'Cannot find {script} template', + script => $script, + ), "Missing $script template message should be correct"; +} + +############################################################################## +# Test _slurp(). +$tmpl = file(qw(etc templates deploy pg.tmpl)); +is $ { $add->_slurp($tmpl)}, contents_of $tmpl, + '_slurp() should load a reference to file contents'; + +############################################################################## +# Test _add(). +my $test_add = sub { + my $engine = shift; + make_path 'test-add'; + my $fn = $target->plan_file; + open my $fh, '>', $fn or die "Cannot open $fn: $!"; + say $fh "%project=add\n\n"; + close $fh or die "Error closing $fn: $!"; + END { remove_tree 'test-add' }; + my $out = file 'test-add', 'sqitch_change_test.sql'; + file_not_exists_ok $out; + ok my $add = $CLASS->new( + sqitch => $sqitch, + template_directory => $tmpldir, + ), 'Create add command'; + ok $add->_add('sqitch_change_test', $out, $tmpl, 'sqlite', 'add'), + 'Write out a script'; + file_exists_ok $out; + file_contents_is $out, <<EOF, 'The template should have been evaluated'; +-- Deploy add:sqitch_change_test to sqlite + +BEGIN; + +-- XXX Add DDLs here. + +COMMIT; +EOF + is_deeply +MockOutput->get_info, [[__x 'Created {file}', file => $out ]], + 'Info should show $out created'; + unlink $out; + + # Try with requires and conflicts. + ok $add = $CLASS->new( + sqitch => $sqitch, + requires => [qw(foo bar)], + conflicts => ['baz'], + template_directory => $tmpldir, + ), 'Create add cmd with requires and conflicts'; + + $out = file 'test-add', 'another_change_test.sql'; + ok $add->_add('another_change_test', $out, $tmpl, 'sqlite', 'add'), + 'Write out a script with requires and conflicts'; + is_deeply +MockOutput->get_info, [[__x 'Created {file}', file => $out ]], + 'Info should show $out created'; + file_contents_is $out, <<EOF, 'The template should have been evaluated with requires and conflicts'; +-- Deploy add:another_change_test to sqlite +-- requires: foo +-- requires: bar +-- conflicts: baz + +BEGIN; + +-- XXX Add DDLs here. + +COMMIT; +EOF + unlink $out; +}; + +# First, test with Template::Tiny. +unshift @INC => sub { + my ($self, $file) = @_; + return if $file ne 'Template.pm'; + my $i = 0; + return sub { + $_ = 'die "NO ONE HERE";'; + return $i = !$i; + }, 1; +}; + +$test_add->('Template::Tiny'); + +# Test _add() with Template. +shift @INC; +delete $INC{'Template.pm'}; +SKIP: { + skip 'Template Toolkit not installed', 14 unless eval 'use Template; 1'; + $test_add->('Template Toolkit'); + + # Template Toolkit should throw an error on template syntax errors. + ok my $add = $CLASS->new(sqitch => $sqitch, template_directory => $tmpldir), + 'Create add command'; + my $mock_add = Test::MockModule->new($CLASS); + $mock_add->mock(_slurp => sub { \'[% IF foo %]' }); + my $out = file 'test-add', 'sqitch_change_test.sql'; + + throws_ok { $add->_add('sqitch_change_test', $out, $tmpl) } + 'App::Sqitch::X', 'Should get an exception on TT syntax error'; + is $@->ident, 'add', 'TT exception ident should be "add"'; + is $@->message, __x( + 'Error executing {template}: {error}', + template => $tmpl, + error => 'file error - parse error - input text line 1: unexpected end of input', + ), 'TT exception message should include the original error message'; +} + +############################################################################## +# Test execute. +ok $add = $CLASS->new( + sqitch => $sqitch, + template_directory => $tmpldir, +), 'Create another add with template_directory'; + +# Override request_note(). +my $change_mocker = Test::MockModule->new('App::Sqitch::Plan::Change'); +my %request_params; +$change_mocker->mock(request_note => sub { + my $self = shift; + %request_params = @_; + return $self->note; +}); + +# Set up a function to force the reload of the plan. +my $reload = sub { + my $plan = shift; + $plan->_plan( $plan->load); + delete $plan->{$_} for qw(_changes _lines project uri); +}; + +my $deploy_file = file qw(test-add deploy widgets_table.sql); +my $revert_file = file qw(test-add revert widgets_table.sql); +my $verify_file = file qw(test-add verify widgets_table.sql); + +my $plan = $add->default_target->plan; +is $plan->get('widgets_table'), undef, 'Should not have "widgets_table" in plan'; +dir_not_exists_ok +File::Spec->catdir('test-add', $_) for qw(deploy revert verify); +ok $add->execute('widgets_table'), 'Add change "widgets_table"'; + +# Reload the plan. +$reload->($plan); + +# Make sure the change was written to the plan file. +isa_ok my $change = $plan->get('widgets_table'), 'App::Sqitch::Plan::Change', + 'Added change'; +is $change->name, 'widgets_table', 'Change name should be set'; +is_deeply [$change->requires], [], 'It should have no requires'; +is_deeply [$change->conflicts], [], 'It should have no conflicts'; +is_deeply \%request_params, { + for => __ 'add', + scripts => [$change->deploy_file, $change->revert_file, $change->verify_file], +}, 'It should have prompted for a note'; + +file_exists_ok $_ for ($deploy_file, $revert_file, $verify_file); +file_contents_like $deploy_file, qr/^-- Deploy add:widgets_table/, + 'Deploy script should look right'; +file_contents_like $revert_file, qr/^-- Revert add:widgets_table/, + 'Revert script should look right'; +file_contents_like $verify_file, qr/^-- Verify add:widgets_table/, + 'Verify script should look right'; +is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $deploy_file], + [__x 'Created {file}', file => $revert_file], + [__x 'Created {file}', file => $verify_file], + [__x 'Added "{change}" to {file}', + change => 'widgets_table', + file => $target->plan_file, + ], +], 'Info should have reported file creation'; + +# Make sure conflicts are avoided and conflicts and requires are respected. +ok $add = $CLASS->new( + change_name => 'foo_table', + sqitch => $sqitch, + requires => ['widgets_table'], + conflicts => [qw(dr_evil joker)], + note => [qw(hello there)], + with_scripts => { verify => 0 }, + template_directory => $tmpldir, +), 'Create another add with template_directory and no verify script'; + +$deploy_file = file qw(test-add deploy foo_table.sql); +$revert_file = file qw(test-add revert foo_table.sql); +$verify_file = file qw(test-add ferify foo_table.sql); +$deploy_file->touch; + +file_exists_ok $deploy_file; +file_not_exists_ok $_ for ($revert_file, $verify_file); +is $plan->get('foo_table'), undef, 'Should not have "foo_table" in plan'; +ok $add->execute, 'Add change "foo_table"'; +file_exists_ok $_ for ($deploy_file, $revert_file); +file_not_exists_ok $verify_file; +$plan = $add->default_target->plan; +isa_ok $change = $plan->get('foo_table'), 'App::Sqitch::Plan::Change', + '"foo_table" change'; +is_deeply \%request_params, { + for => __ 'add', + scripts => [$change->deploy_file, $change->revert_file], +}, 'It should have prompted for a note'; + +is $change->name, 'foo_table', 'Change name should be set to "foo_table"'; +is_deeply [$change->requires], [dep 0, 'widgets_table'], 'It should have requires'; +is_deeply [$change->conflicts], [map { dep 1, $_ } qw(dr_evil joker)], 'It should have conflicts'; +is $change->note, "hello\n\nthere", 'It should have a comment'; + +is_deeply +MockOutput->get_info, [ + [__x 'Skipped {file}: already exists', file => $deploy_file], + [__x 'Created {file}', file => $revert_file], + [__x 'Added "{change}" to {file}', + change => 'foo_table [widgets_table !dr_evil !joker]', + file => $target->plan_file, + ], +], 'Info should report skipping file and include dependencies'; + +# Make sure we die on an unknown argument. +throws_ok { $add->execute(qw(foo bar)) } 'App::Sqitch::X', + 'Should get an error on unkonwn argument'; +is $@->ident, 'add', 'Unkown argument error ident should be "add"'; +is $@->message, __x( + 'Unknown arguments: {arg}', + arg => 'foo, bar', +), 'Unknown argument error message should be correct'; + +# Make sure we die if the passed name conflicts with a target. +TARGET: { + my $mock_add = Test::MockModule->new($CLASS); + $mock_add->mock(parse_args => sub { + return undef, [$target]; + }); + $mock_add->mock(name => 'blog'); + my $mock_target = Test::MockModule->new('App::Sqitch::Target'); + $mock_target->mock(name => 'blog'); + + throws_ok { $add->execute('blog') } 'App::Sqitch::X', + 'Should get an error for conflict with target name'; + is $@->ident, 'add', 'Conflicting target error ident should be "add"'; + is $@->message, __x( + 'Name "{name}" identifies a target; use "--change {name}" to use it for the change name', + name => 'blog', + ), 'Conflicting target error message should be correct'; +} + +# Make sure we get a usage message when no name specified. +USAGE: { + my @args; + my $mock_add = Test::MockModule->new($CLASS); + $mock_add->mock(usage => sub { @args = @_; die 'USAGE' }); + my $add = $CLASS->new(sqitch => $sqitch); + throws_ok { $add->execute } qr/USAGE/, + 'No name arg or option should yield usage'; + is_deeply \@args, [$add], 'No args should be passed to usage'; + + # Should be true when no engine is specified, either. + $add = $CLASS->new(sqitch => App::Sqitch->new(config => TestConfig->new)); + throws_ok { $add->execute } qr/USAGE/, + 'No name arg or option should yield usage'; + is_deeply \@args, [$add], 'No args should be passed to usage'; +} + +# Make sure --open-editor works +MOCKSHELL: { + my $sqitch_mocker = Test::MockModule->new('App::Sqitch'); + my $shell_cmd; + $sqitch_mocker->mock(shell => sub { $shell_cmd = $_[1] }); + $sqitch_mocker->mock(quote_shell => sub { shift; join ' ' => @_ }); + + ok $add = $CLASS->new( + sqitch => $sqitch, + template_directory => $tmpldir, + note => ['Testing --open-editor'], + open_editor => 1, + ), 'Create another add with open_editor'; + + my $deploy_file = file qw(test-add deploy open_editor.sql); + my $revert_file = file qw(test-add revert open_editor.sql); + my $verify_file = file qw(test-add verify open_editor.sql); + + my $plan = $add->default_target->plan; + is $plan->get('open_editor'), undef, 'Should not have "open_editor" in plan'; + ok $add->execute('open_editor'), 'Add change "open_editor"'; + + # Instantiate fresh target and plan to force the file to be re-read. + $target = App::Sqitch::Target->new(sqitch => $sqitch); + $plan = App::Sqitch::Plan->new( sqitch => $sqitch, target => $target ); + + isa_ok my $change = $plan->get('open_editor'), 'App::Sqitch::Plan::Change', + 'Added change'; + is $change->name, 'open_editor', 'Change name should be set'; + is $shell_cmd, join(' ', $sqitch->editor, $deploy_file, $revert_file, $verify_file), + 'It should have prompted to edit sql files'; + + file_exists_ok $_ for ($deploy_file, $revert_file, $verify_file); + file_contents_like +File::Spec->catfile(qw(test-add deploy open_editor.sql)), + qr/^-- Deploy add:open_editor/, 'Deploy script should look right'; + file_contents_like +File::Spec->catfile(qw(test-add revert open_editor.sql)), + qr/^-- Revert add:open_editor/, 'Revert script should look right'; + file_contents_like +File::Spec->catfile(qw(test-add verify open_editor.sql)), + qr/^-- Verify add:open_editor/, 'Verify script should look right'; + is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $deploy_file], + [__x 'Created {file}', file => $revert_file], + [__x 'Created {file}', file => $verify_file], + [__x 'Added "{change}" to {file}', + change => 'open_editor', + file => $target->plan_file, + ], + ], 'Info should have reported file creation'; +}; + +# Make sure an additional script and an exclusion work properly. +EXTRAS: { + ok my $add = $CLASS->new( + sqitch => $sqitch, + template_directory => $tmpldir, + with_scripts => { verify => 0 }, + templates => { whatev => file(qw(etc templates verify mysql.tmpl)) }, + note => ['Testing custom scripts'], + ), 'Create another add with custom script and no verify'; + + my $deploy_file = file qw(test-add deploy custom_script.sql); + my $revert_file = file qw(test-add revert custom_script.sql); + my $verify_file = file qw(test-add verify custom_script.sql); + my $whatev_file = file qw(test-add whatev custom_script.sql); + + ok $add->execute('custom_script'), 'Add change "custom_script"'; + my $plan = $add->default_target->plan; + isa_ok my $change = $plan->get('custom_script'), 'App::Sqitch::Plan::Change', + 'Added change'; + is $change->name, 'custom_script', 'Change name should be set'; + is_deeply [$change->requires], [], 'It should have no requires'; + is_deeply [$change->conflicts], [], 'It should have no conflicts'; + is_deeply \%request_params, { + for => __ 'add', + scripts => [ map { $change->script_file($_) } qw(deploy revert whatev)] + }, 'It should have prompted for a note'; + + file_exists_ok $_ for ($deploy_file, $revert_file, $whatev_file); + file_not_exists_ok $verify_file; + file_contents_like $deploy_file, qr/^-- Deploy add:custom_script/, + 'Deploy script should look right'; + file_contents_like $revert_file, qr/^-- Revert add:custom_script/, + 'Revert script should look right'; + file_contents_like $whatev_file, qr/^-- Verify add:custom_script/, + 'Whatev script should look right'; + file_contents_unlike $whatev_file, qr/^BEGIN/, + 'Whatev script should be based on the MySQL verify script'; + is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $deploy_file], + [__x 'Created {file}', file => $revert_file], + [__x 'Created {file}', file => $whatev_file], + [__x 'Added "{change}" to {file}', + change => 'custom_script', + file => $target->plan_file, + ], + ], 'Info should have reported file creation'; + + # Relod the plan file to make sure change is written to it. + $reload->($plan); + isa_ok $change = $plan->get('custom_script'), 'App::Sqitch::Plan::Change', + 'Added change in reloaded plan'; +} + +# Make sure a configuration with multiple plans works. +MULTIPLAN: { + make_path 'test-multiadd'; + END { remove_tree 'test-multiadd' }; + chdir 'test-multiadd'; + my $config = TestConfig->new( + 'core.engine' => 'pg', + 'engine.pg.top_dir' => 'pg', + 'engine.sqlite.top_dir' => 'sqlite', + 'engine.mysql.top_dir' => 'mysql', + ); + + # Create plan files and determine the scripts that to be created. + my @scripts = map { + my $dir = dir $_; + $dir->mkpath; + $dir->file('sqitch.plan')->spew("%project=add\n\n"); + map { $dir->file($_, 'widgets.sql') } qw(deploy revert verify); + } qw(pg sqlite mysql); + + # Load up the configuration for this project. + my $sqitch = App::Sqitch->new(config => $config); + ok my $add = $CLASS->new( + sqitch => $sqitch, + note => ['Testing multiple plans'], + all => 1, + template_directory => dir->parent->subdir(qw(etc templates)) + ), 'Create another add with custom multiplan config'; + + my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch); + is @targets, 3, 'Should have three targets'; + + # Make sure the target list matches our script list order (by engine). + # pg always comes first, as primary engine, but the other two are random. + push @targets, splice @targets, 1, 1 if $targets[1]->engine_key ne 'sqlite'; + + # Let's do this thing! + ok $add->execute('widgets'), 'Add change "widgets" to all plans'; + ok $_->plan->get('widgets'), 'Should have "widgets" in ' . $_->engine_key . ' plan' + for @targets; + file_exists_ok $_ for @scripts; + + # Make sure we see the proper output. + my $info = MockOutput->get_info; + my $ekey = $targets[1]->engine_key; + if ($info->[4][0] !~ /$ekey/) { + # Got the targets in a different order. So reorder results to match. + push @{ $info } => splice @{ $info }, 4, 4; + } + is_deeply $info, [ + (map { [__x 'Created {file}', file => $_] } @scripts[0..2]), + [ + __x 'Added "{change}" to {file}', + change => 'widgets', + file => $targets[0]->plan_file, + ], + (map { [__x 'Created {file}', file => $_] } @scripts[3..5]), + [ + __x 'Added "{change}" to {file}', + change => 'widgets', + file => $targets[1]->plan_file, + ], + (map { [__x 'Created {file}', file => $_] } @scripts[6..8]), + [ + __x 'Added "{change}" to {file}', + change => 'widgets', + file => $targets[2]->plan_file, + ], + ], 'Info should have reported all script creations and plan updates'; + + # Make sure we get an error using --all and a target arg. + throws_ok { $add->execute('foo', 'pg' ) } 'App::Sqitch::X', + 'Should get an error for --all and a target arg'; + is $@->ident, 'add', 'Mixed arguments error ident should be "add"'; + is $@->message, __( + 'Cannot specify both --all and engine, target, or plan arugments' + ), 'Mixed arguments error message should be correct'; + + # Now try adding a change to just one engine. Remove --all + ok $add = $CLASS->new( + sqitch => $sqitch, + note => ['Testing multiple plans'], + template_directory => dir->parent->subdir(qw(etc templates)) + ), 'Create yet another add with custom multiplan config'; + + ok $add->execute('choc', 'sqlite'), 'Add change "choc" to the sqlite plan'; + my %targets = map { $_->engine_key => $_ } + App::Sqitch::Target->all_targets(sqitch => $sqitch); + is keys %targets, 3, 'Should still have three targets'; + ok !$targets{pg}->plan->get('choc'), 'Should not have "choc" in the pg plan'; + ok !$targets{mysql}->plan->get('choc'), 'Should not have "choc" in the mysql plan'; + ok $targets{sqlite}->plan->get('choc'), 'Should have "choc" in the sqlite plan'; + + @scripts = map { + my $dir = dir $_; + $dir->mkpath; + map { $dir->file($_, 'choc.sql') } qw(deploy revert verify); + } qw(sqlite pg mysql); + file_exists_ok $_ for @scripts[0..2]; + file_not_exists_ok $_ for @scripts[3..8]; + is_deeply +MockOutput->get_info, [ + (map { [__x 'Created {file}', file => $_] } @scripts[0..2]), + [ + __x 'Added "{change}" to {file}', + change => 'choc', + file => $targets{sqlite}->plan_file, + ], + ], 'Info should have reported sqlite choc script creations and plan updates'; + + chdir File::Spec->updir; +} + +# Make sure we update only one plan but write out multiple target files. +MULTITARGET: { + remove_tree 'test-multiadd'; + make_path 'test-multiadd'; + chdir 'test-multiadd'; + my $config = TestConfig->new( + 'core.engine' => 'pg', + 'core.plan_file' => 'sqitch.plan', + 'engine.pg.top_dir' => 'pg', + 'engine.sqlite.top_dir' => 'sqlite', + 'add.all' => 1, + ); + file('sqitch.plan')->spew("%project=add\n\n"); + + # Create list of scripts to be created. + my @scripts = map { + my $dir = dir $_; + $dir->mkpath; + map { $dir->file($_, 'widgets.sql') } qw(deploy revert verify); + } qw(pg sqlite); + + # Load up the configuration for this project. + my $sqitch = App::Sqitch->new(config => $config); + ok my $add = $CLASS->new( + sqitch => $sqitch, + note => ['Testing multiple targets'], + template_directory => dir->parent->subdir(qw(etc templates)) + ), 'Create another add with single plan, multi-target config'; + + my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch); + is @targets, 2, 'Should have two targets'; + is $targets[0]->plan_file, $targets[1]->plan_file, + 'Targets should use the same plan file'; + + # Let's do this thing! + ok $add->execute('widgets'), 'Add change "widgets" to all plans'; + ok $targets[0]->plan->get('widgets'), 'Should have "widgets" in the plan'; + file_exists_ok $_ for @scripts; + + is_deeply \%request_params, { + for => __ 'add', + scripts => \@scripts, + }, 'Should have the proper files listed in the note promt'; + + is_deeply +MockOutput->get_info, [ + (map { [__x 'Created {file}', file => $_] } @scripts), + [ + __x 'Added "{change}" to {file}', + change => 'widgets', + file => $targets[0]->plan_file, + ], + ], 'Info should have reported all script creations and one plan update'; + chdir File::Spec->updir; +} + +# Make sure we're okay with multiple plans sharing the same top dir. +ONETOP: { + remove_tree 'test-multiadd'; + make_path 'test-multiadd'; + chdir 'test-multiadd'; + my $config = TestConfig->new( + 'core.engine' => 'pg', + 'engine.pg.plan_file' => 'pg.plan', + 'engine.sqlite.plan_file' => 'sqlite.plan', + ); + file("$_.plan")->spew("%project=add\n\n") for qw(pg sqlite); + + # Create list of scripts to be created. + my @scripts = map { file $_, 'widgets.sql' } qw(deploy revert verify); + + # Load up the configuration for this project. + my $sqitch = App::Sqitch->new(config => $config); + ok my $add = $CLASS->new( + sqitch => $sqitch, + note => ['Testing two targets, one top_dir'], + all => 1, + template_directory => dir->parent->subdir(qw(etc templates)) + ), 'Create another add with two targets, one top dir'; + + my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch); + is @targets, 2, 'Should have two targets'; + is $targets[0]->plan_file, file('pg.plan'), + 'First target plan should be in pg.plan'; + is $targets[1]->plan_file, file('sqlite.plan'), + 'Second target plan should be in sqlite.plan'; + + # Let's do this thing! + ok $add->execute('widgets'), 'Add change "widgets" to all plans'; + ok $_->plan->get('widgets'), 'Should have "widgets" in ' . $_->engine_key . ' plan' + for @targets; + file_exists_ok $_ for @scripts; + + is_deeply \%request_params, { + for => __ 'add', + scripts => \@scripts, + }, 'Should have the proper files listed in the note promt'; + + is_deeply my $info = MockOutput->get_info, [ + (map { [__x 'Created {file}', file => $_] } @scripts), + [ + __x 'Added "{change}" to {file}', + change => 'widgets', + file => $targets[0]->plan_file, + ], + (map { [__x 'Skipped {file}: already exists', file => $_] } @scripts), + [ + __x 'Added "{change}" to {file}', + change => 'widgets', + file => $targets[1]->plan_file, + ], + ], 'Info should have script creations and skips'; + + chdir File::Spec->updir; +} + +############################################################################## +# Test options parsing. +can_ok $CLASS, 'options', '_parse_opts'; +ok $add = $CLASS->new({ sqitch => $sqitch }), "Create a $CLASS object again"; +is_deeply $add->_parse_opts([]), + { with_scripts => { map { $_ => 1} qw(deploy revert verify) } }, + 'Base _parse_opts should return the script config'; + +is_deeply $add->_parse_opts([1]), { + with_scripts => { deploy => 1, verify => 1, revert => 1 }, +}, '_parse_opts() hould use options spec'; +my $args = [qw( + --note foo + --template bar + whatever +)]; +is_deeply $add->_parse_opts($args), { + note => ['foo'], + template_name => 'bar', + with_scripts => { deploy => 1, verify => 1, revert => 1 }, +}, '_parse_opts() should parse options spec'; +is_deeply $args, ['whatever'], 'Args array should be cleared of options'; + +# Make sure --set works. +push @{ $args }, '--set' => 'schema=foo', '--set' => 'table=bar'; +is_deeply $add->_parse_opts($args), { + set => { schema => 'foo', table => 'bar' }, + with_scripts => { deploy => 1, verify => 1, revert => 1 }, +}, '_parse_opts() should parse --set options'; +is_deeply $args, ['whatever'], 'Args array should be cleared of options'; + +# make sure --set works with repeating keys. +push @{ $args }, '--set' => 'column=id', '--set' => 'column=name'; +is_deeply $add->_parse_opts($args), { + set => { column => [qw(id name)] }, + with_scripts => { deploy => 1, verify => 1, revert => 1 }, +}, '_parse_opts() should parse --set options with repeting key'; +is_deeply $args, ['whatever'], 'Args array should be cleared of options'; + +# Make sure --with and --use work. +push @{ $args }, qw(--with deploy --without verify --use), + "foo=$tmpl"; +is_deeply $add->_parse_opts($args), { + with_scripts => { deploy => 1, verify => 0, revert => 1 }, + use => { foo => $tmpl } +}, '_parse_opts() should parse --with, --without, and --user'; +is_deeply $args, ['whatever'], 'Args array should be cleared of options'; diff --git a/t/add_change.conf b/t/add_change.conf new file mode 100644 index 00000000..782ee9ae --- /dev/null +++ b/t/add_change.conf @@ -0,0 +1,10 @@ +[add] +template_directory = t +template_name = hi +all = true +[add "variables"] +foo = bar +baz = hi +baz = there +baz = you + diff --git a/t/base.t b/t/base.t new file mode 100644 index 00000000..506864e7 --- /dev/null +++ b/t/base.t @@ -0,0 +1,675 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use Test::More tests => 189; +#use Test::More 'no_plan'; +use Test::MockModule 0.17; +use Path::Class; +use Test::Exception; +use Test::NoWarnings; +use Capture::Tiny 0.12 qw(:all); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X 'hurl'; +use lib 't/lib'; +use TestConfig; + +my $CLASS; +BEGIN { + $CLASS = 'App::Sqitch'; + use_ok $CLASS or die; +} + +can_ok $CLASS, qw( + go + new + options + user_name + user_email + verbosity + prompt + ask_yes_no + ask_y_n +); + +############################################################################## +# Overrides. +my $config = TestConfig->new; +$config->data({'core.verbosity' => 2}); +isa_ok my $sqitch = $CLASS->new({ config => $config, options => {} }), + $CLASS, 'A configured object'; + +is $sqitch->verbosity, 2, 'Configured verbosity should override default'; + +isa_ok $sqitch = $CLASS->new({ config => $config, options => {verbosity => 3} }), + $CLASS, 'A configured object'; + +is $sqitch->verbosity, 3, 'Verbosity option should override configuration'; + +############################################################################## +# Defaults. +$config->replace; +isa_ok $sqitch = $CLASS->new(config => $config), $CLASS, 'A new object'; + +is $sqitch->verbosity, 1, 'Default verbosity should be 1'; +ok $sqitch->sysuser, 'Should have default sysuser from system'; +ok $sqitch->user_name, 'Default user_name should be set from system'; +is $sqitch->user_email, do { + require Sys::Hostname; + $sqitch->sysuser . '@' . Sys::Hostname::hostname(); +}, 'Default user_email should be set from system'; + +############################################################################## +# User environment variables. +ENV: { + # Try originating host variables. + local $ENV{SQITCH_ORIG_SYSUSER} = "__kamala__"; + local $ENV{SQITCH_ORIG_FULLNAME} = 'Kamala Harris'; + local $ENV{SQITCH_ORIG_EMAIL} = 'kamala@whitehouse.gov'; + isa_ok $sqitch = $CLASS->new(config => $config), $CLASS, 'Another new object'; + is $sqitch->sysuser, $ENV{SQITCH_ORIG_SYSUSER}, + "SQITCH_ORIG_SYSUER should override system username"; + is $sqitch->user_name, $ENV{SQITCH_ORIG_FULLNAME}, + "SQITCH_ORIG_FULLNAME should override system user full name"; + is $sqitch->user_email, $ENV{SQITCH_ORIG_EMAIL}, + "SQITCH_ORIG_EMAIL should override system-derived email"; + + # Local variables take precedence over originating host variables. + local $ENV{SQITCH_FULLNAME} = 'Barack Obama'; + local $ENV{SQITCH_EMAIL} = 'barack@whitehouse.gov'; + isa_ok $sqitch = $CLASS->new, $CLASS, 'Another new object'; + is $sqitch->user_name, $ENV{SQITCH_FULLNAME}, + "SQITCH_FULLNAME should override originating host user full name"; + is $sqitch->user_email, $ENV{SQITCH_EMAIL}, + "SQITCH_EMAIL should override originating host email"; +} + +############################################################################## +# Test go(). +GO: { + local $ENV{SQITCH_ORIG_SYSUSER} = "__barack__"; + local $ENV{SQITCH_ORIG_FULLNAME} = 'Barack Obama'; + local $ENV{SQITCH_ORIG_EMAIL} = 'barack@whitehouse.gov'; + + my $mock = Test::MockModule->new('App::Sqitch::Command::help'); + my ($cmd, @params); + my $ret = 1; + $mock->mock(execute => sub { ($cmd, @params) = @_; $ret }); + chdir 't'; + + my $config = TestConfig->from( + local => 'sqitch.conf', + user => 'user.conf', + ); + + my $mocker = Test::MockModule->new('App::Sqitch::Config'); + $mocker->mock(new => $config); + + local @ARGV = qw(help config); + is +App::Sqitch->go, 0, 'Should get 0 from go()'; + + isa_ok $cmd, 'App::Sqitch::Command::help', 'Command'; + is_deeply \@params, ['config'], 'Extra args should be passed to execute'; + + isa_ok my $sqitch = $cmd->sqitch, 'App::Sqitch'; + ok $config = $sqitch->config, 'Get the Sqitch config'; + is $config->get(key => 'engine.pg.client'), '/usr/local/pgsql/bin/psql', + 'Should have local config overriding user'; + is $config->get(key => 'engine.pg.registry'), 'meta', + 'Should fall back on user config'; + is $sqitch->user_name, 'Michael Stonebraker', + 'Should have read user name from configuration'; + is $sqitch->user_email, 'michael@example.com', + 'Should have read user email from configuration'; + is_deeply $sqitch->options, { }, 'Should have no options'; + + # Make sure USER_NAME and USER_EMAIL take precedence over configuration. + local $ENV{SQITCH_FULLNAME} = 'Michelle Obama'; + local $ENV{SQITCH_EMAIL} = 'michelle@whitehouse.gov'; + is +App::Sqitch->go, 0, 'Should get 0 from go() again'; + isa_ok $sqitch = $cmd->sqitch, 'App::Sqitch'; + is $sqitch->user_name, 'Michelle Obama', + 'Should have read user name from environment'; + is $sqitch->user_email, 'michelle@whitehouse.gov', + 'Should have read user email from environment'; + + # Now make it die. + sub puke { App::Sqitch::X->new(@_) } # Ensures we have trace frames. + my $ex = puke(ident => 'ohai', message => 'OMGWTF!'); + $mock->mock(execute => sub { die $ex }); + my $sqitch_mock = Test::MockModule->new($CLASS); + my @vented; + $sqitch_mock->mock(vent => sub { push @vented => $_[1]; }); + my $traced; + $sqitch_mock->mock(trace => sub { $traced = $_[1]; }); + is $sqitch->go, 2, 'Go should return 2 on Sqitch exception'; + is_deeply \@vented, ['OMGWTF!'], 'The error should have been vented'; + is $traced, $ex->stack_trace->as_string, + 'The stack trace should have been sent to trace'; + + # Make it die with a developer exception. + @vented = (); + $traced = undef; + $ex = puke( message => 'OUCH!', exitval => 4 ); + is $sqitch->go, 4, 'Go should return exitval on another exception'; + is_deeply \@vented, ['OUCH!', $ex->stack_trace->as_string], + 'Both the message and the trace should have been vented'; + is $traced, undef, 'Nothing should have been traced'; + + # Make it die without an exception object. + $ex = 'LOLZ'; + @vented = (); + is $sqitch->go, 2, 'Go should return 2 on a third Sqitch exception'; + is @vented, 1, 'Should have one thing vented'; + like $vented[0], qr/^LOLZ\b/, 'And it should include our message'; +} + +############################################################################## +# Test the editor. +EDITOR: { + local $ENV{SQITCH_EDITOR}; + local $ENV{VISUAL}; + + local $ENV{EDITOR} = 'edd'; + my $sqitch = App::Sqitch->new(config => $config); + is $sqitch->editor, 'edd', 'editor should use $EDITOR'; + + local $ENV{VISUAL} = 'gvim'; + $sqitch = App::Sqitch->new(config => $config); + is $sqitch->editor, 'gvim', 'editor should prefer $VISUAL over $EDITOR'; + + my $config = TestConfig->from(local => 'editor.conf'); + $sqitch = App::Sqitch->new(config => $config); + is $sqitch->editor, 'config_specified_editor', 'editor should prefer core.editor over $VISUAL'; + + local $ENV{SQITCH_EDITOR} = 'vimz'; + $sqitch = App::Sqitch->new(config => $config); + is $sqitch->editor, 'vimz', 'editor should prefer $SQITCH_EDITOR over $VISUAL'; + + $sqitch = App::Sqitch->new({editor => 'emacz' }); + is $sqitch->editor, 'emacz', 'editor should use use parameter regardless of environment'; + + delete $ENV{SQITCH_EDITOR}; + delete $ENV{VISUAL}; + delete $ENV{EDITOR}; + $config->replace; + $sqitch = App::Sqitch->new(config => $config); + if (App::Sqitch::ISWIN) { + is $sqitch->editor, 'notepad.exe', 'editor fall back on notepad on Windows'; + } else { + is $sqitch->editor, 'vi', 'editor fall back on vi when not Windows'; + } +} + +############################################################################## +# Test the pager program config. We want to pick up from one of the following +# places, earlier in the list more preferred. +# - SQITCH_PAGER environment variable. +# - core.pager configuration prop. +# - PAGER environment variable. +# +PAGER_PROGRAM: { + # Ignore warnings while loading IO::Pager. + { local $SIG{__WARN__} = sub {}; require IO::Pager } + + # Mock the IO::Pager constructor. + my $mock_pager = Test::MockModule->new('IO::Pager'); + $mock_pager->mock(new => sub { return bless => {} => 'IO::Pager' }); + + # No pager if no TTY. + my $pager_class = -t *STDOUT ? 'IO::Pager' : 'IO::Handle'; + { + local $ENV{SQITCH_PAGER}; + local $ENV{PAGER} = "morez"; + my $sqitch = App::Sqitch->new(config => $config); + is $sqitch->pager_program, "morez", + "pager program should be picked up from PAGER when SQITCH_PAGER and core.pager are not set"; + isa_ok $sqitch->pager, $pager_class, 'morez pager'; + } + + { + local $ENV{SQITCH_PAGER} = "less -myway"; + local $ENV{PAGER} = "morezz"; + + my $sqitch = App::Sqitch->new; + is $sqitch->pager_program, "less -myway", "SQITCH_PAGER should take precedence over PAGER"; + isa_ok $sqitch->pager, $pager_class, 'less -myway'; + } + + { + local $ENV{SQITCH_PAGER}; + local $ENV{PAGER} = "morezz"; + + my $config = TestConfig->from(local => 'sqitch.conf'); + my $sqitch = App::Sqitch->new(config => $config); + is $sqitch->pager_program, "less -r", + "`core.pager' setting should take precedence over PAGER when SQITCH_PAGER is not set."; + isa_ok $sqitch->pager, $pager_class, 'morezz pager'; + } + + { + local $ENV{SQITCH_PAGER} = "less -rules"; + local $ENV{PAGER} = "more -dontcare"; + + # Should always get IO::Handle with --no-pager. + my $config = TestConfig->from(local => 'sqitch.conf'); + my $sqitch = App::Sqitch->new(config => $config, options => {no_pager => 1}); + is $sqitch->pager_program, "less -rules", + "SQITCH_PAGER should take precedence over both PAGER and the `core.pager' setting."; + isa_ok $sqitch->pager, 'IO::Handle', 'less -rules'; + } +} + +############################################################################## +# Test message levels. Start with trace. +$sqitch = $CLASS->new(verbosity => 3); +is capture_stdout { $sqitch->trace('This ', "that\n", 'and the other') }, + "trace: This that\ntrace: and the other\n", + 'trace should work'; +$sqitch = $CLASS->new(verbosity => 2); +is capture_stdout { $sqitch->trace('This ', "that\n", 'and the other') }, + '', 'Should get no trace output for verbosity 2'; + +# Trace literal +$sqitch = $CLASS->new(verbosity => 3); +is capture_stdout { $sqitch->trace_literal('This ', "that\n", 'and the other') }, + "trace: This that\ntrace: and the other", + 'trace_literal should work'; +$sqitch = $CLASS->new(verbosity => 2); +is capture_stdout { $sqitch->trace_literal('This ', "that\n", 'and the other') }, + '', 'Should get no trace_literal output for verbosity 2'; + +# Debug. +$sqitch = $CLASS->new(verbosity => 2); +is capture_stdout { $sqitch->debug('This ', "that\n", 'and the other') }, + "debug: This that\ndebug: and the other\n", + 'debug should work'; +$sqitch = $CLASS->new(verbosity => 1); +is capture_stdout { $sqitch->debug('This ', "that\n", 'and the other') }, + '', 'Should get no debug output for verbosity 1'; + +# Debug literal. +$sqitch = $CLASS->new(verbosity => 2); +is capture_stdout { $sqitch->debug_literal('This ', "that\n", 'and the other') }, + "debug: This that\ndebug: and the other", + 'debug_literal should work'; +$sqitch = $CLASS->new(verbosity => 1); +is capture_stdout { $sqitch->debug_literal('This ', "that\n", 'and the other') }, + '', 'Should get no debug_literal output for verbosity 1'; + +# Info. +$sqitch = $CLASS->new(verbosity => 1); +is capture_stdout { $sqitch->info('This ', "that\n", 'and the other') }, + "This that\nand the other\n", + 'info should work'; +$sqitch = $CLASS->new(verbosity => 0); +is capture_stdout { $sqitch->info('This ', "that\n", 'and the other') }, + '', 'Should get no info output for verbosity 0'; + +# Info literal. +$sqitch = $CLASS->new(verbosity => 1); +is capture_stdout { $sqitch->info_literal('This ', "that\n", 'and the other') }, + "This that\nand the other", + 'info_literal should work'; +$sqitch = $CLASS->new(verbosity => 0); +is capture_stdout { $sqitch->info_literal('This ', "that\n", 'and the other') }, + '', 'Should get no info_literal output for verbosity 0'; + +# Comment. +$sqitch = $CLASS->new(verbosity => 1); +is capture_stdout { $sqitch->comment('This ', "that\n", 'and the other') }, + "# This that\n# and the other\n", + 'comment should work'; +$sqitch = $CLASS->new(verbosity => 0); +is capture_stdout { $sqitch->comment('This ', "that\n", 'and the other') }, + "# This that\n# and the other\n", + 'comment should work with verbosity 0'; + +# Comment literal. +$sqitch = $CLASS->new(verbosity => 1); +is capture_stdout { $sqitch->comment_literal('This ', "that\n", 'and the other') }, + "# This that\n# and the other", + 'comment_literal should work'; +$sqitch = $CLASS->new(verbosity => 0); +is capture_stdout { $sqitch->comment_literal('This ', "that\n", 'and the other') }, + "# This that\n# and the other", + 'comment_literal should work with verbosity 0'; + +# Emit. +is capture_stdout { $sqitch->emit('This ', "that\n", 'and the other') }, + "This that\nand the other\n", + 'emit should work'; +$sqitch = $CLASS->new(verbosity => 0); +is capture_stdout { $sqitch->emit('This ', "that\n", 'and the other') }, + "This that\nand the other\n", + 'emit should work even with verbosity 0'; + +# Emit literal. +is capture_stdout { $sqitch->emit_literal('This ', "that\n", 'and the other') }, + "This that\nand the other", + 'emit_literal should work'; +$sqitch = $CLASS->new(verbosity => 0); +is capture_stdout { $sqitch->emit_literal('This ', "that\n", 'and the other') }, + "This that\nand the other", + 'emit_literal should work even with verbosity 0'; + +# Warn. +is capture_stderr { $sqitch->warn('This ', "that\n", 'and the other') }, + "warning: This that\nwarning: and the other\n", + 'warn should work'; + +# Warn_Literal. +is capture_stderr { $sqitch->warn_literal('This ', "that\n", 'and the other') }, + "warning: This that\nwarning: and the other", + 'warn_literal should work'; + +# Vent. +is capture_stderr { $sqitch->vent('This ', "that\n", 'and the other') }, + "This that\nand the other\n", + 'vent should work'; + +# Vent literal. +is capture_stderr { $sqitch->vent_literal('This ', "that\n", 'and the other') }, + "This that\nand the other", + 'vent_literal should work'; + +############################################################################## +# Test run(). +can_ok $CLASS, 'run'; +my ($stdout, $stderr) = capture { + ok $sqitch->run( + $^X, 'echo.pl', qw(hi there) + ), 'Should get success back from run echo'; +}; + +is $stdout, "hi there\n", 'The echo script should have run'; +is $stderr, '', 'Nothing should have gone to STDERR'; + +($stdout, $stderr) = capture { + throws_ok { + $sqitch->run( $^X, 'die.pl', qw(hi there)) + } qr/unexpectedly returned/, 'run die should, well, die'; +}; + +is $stdout, "hi there\n", 'The die script should have its STDOUT ummolested'; +like $stderr, qr/OMGWTF/, 'The die script should have its STDERR unmolested'; + +############################################################################## +# Test shell(). +can_ok $CLASS, 'shell'; +my $pl = $sqitch->quote_shell($^X); +($stdout, $stderr) = capture { + ok $sqitch->shell( + "$pl echo.pl hi there" + ), 'Should get success back from shell echo'; +}; + +is $stdout, "hi there\n", 'The echo script should have shell'; +is $stderr, '', 'Nothing should have gone to STDERR'; + +($stdout, $stderr) = capture { + throws_ok { + $sqitch->shell( "$pl die.pl hi there" ) + } qr/unexpectedly returned/, 'shell die should, well, die'; +}; + +is $stdout, "hi there\n", 'The die script should have its STDOUT ummolested'; +like $stderr, qr/OMGWTF/, 'The die script should have its STDERR unmolested'; + +############################################################################## +# Test quote_shell(). +my $quoter = do { + if (App::Sqitch::ISWIN) { + require Win32::ShellQuote; + \&Win32::ShellQuote::quote_native; + } else { + require String::ShellQuote; + \&String::ShellQuote::shell_quote; + } +}; + +is $sqitch->quote_shell(qw(foo bar baz), 'hi there'), + $quoter->(qw(foo bar baz), 'hi there'), 'quote_shell should work'; + +############################################################################## +# Test capture(). +can_ok $CLASS, 'capture'; +is $sqitch->capture($^X, 'echo.pl', qw(hi there)), + "hi there\n", 'The echo script output should have been returned'; +like capture_stderr { + throws_ok { $sqitch->capture($^X, 'die.pl', qw(hi there)) } + qr/unexpectedly returned/, + 'Should get an error if the command errors out'; +}, qr/OMGWTF/m, 'The die script STDERR should have passed through'; + +############################################################################## +# Test probe(). +can_ok $CLASS, 'probe'; +is $sqitch->probe($^X, 'echo.pl', qw(hi there), "\nyo"), + "hi there ", 'Should have just chomped first line of output'; + +############################################################################## +# Test spool(). +can_ok $CLASS, 'spool'; +my $data = "hi\nthere\n"; +open my $fh, '<', \$data; +is capture_stdout { + ok $sqitch->spool($fh, $^X, 'read.pl'), 'Spool to read.pl'; +}, $data, 'Data should have been sent to STDOUT by read.pl'; +seek $fh, 0, 0; +open my $fh2, '<', \$CLASS; +is capture_stdout { + ok $sqitch->spool([$fh, $fh2], $^X, 'read.pl'), 'Spool to read.pl'; +}, $data . $CLASS, 'All data should have been sent to STDOUT by read.pl'; + +like capture_stderr { + local $ENV{LANGUAGE} = 'en'; + throws_ok { $sqitch->spool($fh, $^X, 'die.pl') } + 'App::Sqitch::X', 'Should get error when die.pl dies'; + is $@->ident, 'io', 'Error ident should be "io"'; + like $@->message, + qr/\Q$^X\E unexpectedly returned exit value |\QError closing pipe to/, + 'The error message should be one of the I/O messages'; +}, qr/OMGWTF/, 'The die script STDERR should have passed through'; + +throws_ok { + local $ENV{LANGUAGE} = 'en'; + $sqitch->spool($fh, '--nosuchscript.ply--') +} 'App::Sqitch::X', 'Should get an error for a bad command'; +is $@->ident, 'io', 'Error ident should be "io"'; +like $@->message, + qr/\QCannot exec --nosuchscript.ply--:\E|\QError closing pipe to --nosuchscript.ply--:/, + 'Error message should be about inability to exec'; + +############################################################################## +# Test prompt(). +throws_ok { $sqitch->prompt } 'App::Sqitch::X', + 'Should get error for no prompt message'; +is $@->ident, 'DEV', 'No prompt ident should be "DEV"'; +is $@->message, 'prompt() called without a prompt message', + 'No prompt error message should be correct'; + +my $sqitch_mock = Test::MockModule->new($CLASS); +my $input = 'hey'; +$sqitch_mock->mock(_readline => sub { $input }); +my $unattended = 0; +$sqitch_mock->mock(_is_unattended => sub { $unattended }); + +is capture_stdout { + is $sqitch->prompt('hi'), 'hey', 'Prompt should return input'; +}, 'hi ', 'Prompt should prompt'; + +$input = 'how'; +is capture_stdout { + is $sqitch->prompt('hi', 'blah'), 'how', + 'Prompt with default should return input'; +}, 'hi [blah] ', 'Prompt should prompt with default'; +$input = 'hi'; +is capture_stdout { + is $sqitch->prompt('hi', undef), 'hi', + 'Prompt with undef default should return input'; +}, 'hi [] ', 'Prompt should prompt with bracket for undef default'; + +$input = undef; +is capture_stdout { + is $sqitch->prompt('hi', 'yo'), 'yo', + 'Prompt should return default for undef input'; +}, 'hi [yo] ', 'Prompt should show default when undef input'; + +$input = ''; +is capture_stdout { + is $sqitch->prompt('hi', 'yo'), 'yo', + 'Prompt should return input for empty input'; +}, 'hi [yo] ', 'Prompt should show default when empty input'; + +$unattended = 1; +throws_ok { + is capture_stdout { $sqitch->prompt('yo') }, "yo \n", + 'Unattended message should be emitted'; +} 'App::Sqitch::X', 'Should get error when uattended and no default'; +is $@->ident, 'io', 'Unattended error ident should be "io"'; +is $@->message, __( + 'Sqitch seems to be unattended and there is no default value for this question' +), 'Unattended error message should be correct'; + +is capture_stdout { + is $sqitch->prompt('hi', 'yo'), 'yo', 'Prompt should return input'; +}, "hi [yo] yo\n", 'Prompt should show default as selected when unattended'; + +############################################################################## +# Test ask_yes_no(). +throws_ok { $sqitch->ask_yes_no } 'App::Sqitch::X', + 'Should get error for no ask_yes_no message'; +is $@->ident, 'DEV', 'No ask_yes_no ident should be "DEV"'; +is $@->message, 'ask_yes_no() called without a prompt message', + 'No ask_yes_no error message should be correct'; + +my $yes = __ 'Yes'; +my $no = __ 'No'; + +# Test affermation. +for my $variant ($yes, lc $yes, uc $yes, lc substr($yes, 0, 1), substr($yes, 0, 2)) { + $input = $variant; + $unattended = 0; + is capture_stdout { + ok $sqitch->ask_yes_no('hi'), + qq{ask_yes_no() should return true for "$variant" input}; + }, 'hi ', qq{ask_yes_no() should prompt for "$variant"}; +} + +# Test negation. +for my $variant ($no, lc $no, uc $no, lc substr($no, 0, 1), substr($no, 0, 2)) { + $input = $variant; + $unattended = 0; + is capture_stdout { + ok !$sqitch->ask_yes_no('hi'), + qq{ask_yes_no() should return false for "$variant" input}; + }, 'hi ', qq{ask_yes_no() should prompt for "$variant"}; +} + +# Test defaults. +$input = ''; +is capture_stdout { + ok $sqitch->ask_yes_no('whu?', 1), + 'ask_yes_no() should return true for true default' +}, "whu? [$yes] ", 'ask_yes_no() should prompt and show default "Yes"'; +is capture_stdout { + ok !$sqitch->ask_yes_no('whu?', 0), + 'ask_yes_no() should return false for false default' +}, "whu? [$no] ", 'ask_yes_no() should prompt and show default "No"'; + +my $please = __ 'Please answer "y" or "n".'; +$input = 'ha!'; +throws_ok { + is capture_stdout { $sqitch->ask_yes_no('hi') }, + "hi \n$please\nhi \n$please\nhi \n", + 'Should get prompts for repeated bad answers'; +} 'App::Sqitch::X', 'Should get error for bad answers'; +is $@->ident, 'io', 'Bad answers ident should be "IO"'; +is $@->message, __ 'No valid answer after 3 attempts; aborting', + 'Bad answers message should be correct'; + +############################################################################## +# Test ask_y_n(). +my $warning; +$sqitch_mock->mock(warn => sub { shift; $warning = "@_" }); +throws_ok { $sqitch->ask_y_n } 'App::Sqitch::X', + 'Should get error for no ask_y_n message'; +is $@->ident, 'DEV', 'No ask_y_n ident should be "DEV"'; +is $@->message, 'ask_yes_no() called without a prompt message', + 'No ask_y_n error message should be correct'; +is $warning, 'The ask_y_n() method has been deprecated. Use ask_yes_no() instead.', + 'Should get a deprecation warning from ask_y_n'; + +throws_ok { $sqitch->ask_y_n('hi', 'b') } 'App::Sqitch::X', + 'Should get error for invalid ask_y_n default'; +is $@->ident, 'DEV', 'Invalid ask_y_n default ident should be "DEV"'; +is $@->message, 'Invalid default value: ask_y_n() default must be "y" or "n"', + 'Invalid ask_y_n default error message should be correct'; + +$input = lc substr $yes, 0, 1; +$unattended = 0; +is capture_stdout { + ok $sqitch->ask_y_n('hi'), + qq{ask_y_n should return true for "$input" input} +}, 'hi ', 'ask_y_n() should prompt'; + +$input = lc substr $no, 0, 1; +is capture_stdout { + ok !$sqitch->ask_y_n('howdy'), + qq{ask_y_n should return false for "$input" input} +}, 'howdy ', 'ask_y_n() should prompt for no'; + +$input = uc substr $no, 0, 1; +is capture_stdout { + ok !$sqitch->ask_y_n('howdy'), + qq{ask_y_n should return false for "$input" input} +}, 'howdy ', 'ask_y_n() should prompt for no'; + +$input = uc substr $yes, 0, 2; +is capture_stdout { + ok $sqitch->ask_y_n('howdy'), + qq{ask_y_n should return true for "$input" input} +}, 'howdy ', 'ask_y_n() should prompt for yes'; + +$input = ''; +is capture_stdout { + ok $sqitch->ask_y_n('whu?', 'y'), + qq{ask_y_n should return true default "$yes"} +}, "whu? [$yes] ", 'ask_y_n() should prompt and show default "Yes"'; + +is capture_stdout { + ok !$sqitch->ask_y_n('whu?', 'n'), + qq{ask_y_n should return false default "$no"}; +}, "whu? [$no] ", 'ask_y_n() should prompt and show default "No"'; + +$input = 'ha!'; +throws_ok { + is capture_stdout { $sqitch->ask_y_n('hi') }, + "hi \n$please\nhi \n$please\nhi \n", + 'Should get prompts for repeated bad answers'; +} 'App::Sqitch::X', 'Should get error for bad answers'; +is $@->ident, 'io', 'Bad answers ident should be "IO"'; +is $@->message, __ 'No valid answer after 3 attempts; aborting', + 'Bad answers message should be correct'; + +############################################################################## +# Test _readline. +$sqitch_mock->unmock('_readline'); +$input = 'hep'; +open my $stdin, '<', \$input; +*STDIN = $stdin; +is $sqitch->_readline, $input, '_readline should work'; + +$unattended = 1; +is $sqitch->_readline, undef, '_readline should return undef when unattended'; +$sqitch_mock->unmock_all; + +############################################################################## +# Make sure Test::LocaleDomain gives us decoded strings. +for my $lang (qw(en fr)) { + local $ENV{LANGUAGE} = $lang; + my $text = __x 'On database {db}', db => 'foo'; + ok utf8::valid($text), 'Localied string should be valid UTF-8'; + ok utf8::is_utf8($text), 'Localied string should be decoded'; +} diff --git a/t/blank.t b/t/blank.t new file mode 100644 index 00000000..665c480a --- /dev/null +++ b/t/blank.t @@ -0,0 +1,135 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More tests => 34; +#use Test::More 'no_plan'; +use Locale::TextDomain qw(App-Sqitch); +use Test::NoWarnings; +use Test::Exception; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use Test::MockModule; +use Test::File; +use Test::File::Contents 0.20; +use lib 't/lib'; +use TestConfig; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Plan::Blank'; + require_ok $CLASS or die; +} + +can_ok $CLASS, qw( + name + lspace + rspace + note + plan + request_note + note_prompt +); + +my $config = TestConfig->new('core.engine' => 'sqlite'); +my $sqitch = App::Sqitch->new(config => $config); +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target); +isa_ok my $blank = $CLASS->new( + name => 'foo', + plan => $plan, +), $CLASS; +isa_ok $blank, 'App::Sqitch::Plan::Line'; + +is $blank->format_name, '', 'Name should format as ""'; +is $blank->as_string, '', 'should stringify to ""'; + +ok $blank = $CLASS->new( + name => 'howdy', + plan => $plan, + lspace => ' ', + rspace => "\t", + note => 'blah blah blah', +), 'Create tag with more stuff'; + +is $blank->as_string, " \t# blah blah blah", + 'It should stringify correctly'; + +ok $blank = $CLASS->new(plan => $plan, note => "foo\nbar\nbaz\\\n"), + 'Create a blank with newlines and backslashes in the note'; +is $blank->note, "foo\nbar\nbaz\\", + 'The newlines and backslashe should not be escaped'; + +is $blank->format_note, '# foo\\nbar\\nbaz\\\\', + 'The newlines and backslahs should be escaped by format_note'; + +ok $blank = $CLASS->new(plan => $plan, note => "foo\\nbar\\nbaz\\\\\\n"), + 'Create a blank with escapes'; +is $blank->note, "foo\nbar\nbaz\\\n", 'Note shoud be unescaped'; + +for my $spec ( + ["\n\n\nfoo" => 'foo', 'Leading newlines' ], + ["\r\r\rfoo" => 'foo', 'Leading line feeds' ], + ["foo\n\n\n" => 'foo', 'Trailing newlines' ], + ["foo\r\r\r" => 'foo', 'trailing line feeds' ], + ["\r\n\r\n\r\nfoo\n\nbar\r" => "foo\n\nbar", 'Leading and trailing vertical space' ], + ["\n\n\n foo \n" => 'foo', 'Leading and trailing newlines and spaces' ], +) { + is $CLASS->new( + plan => $plan, + note => $spec->[0] + )->note, $spec->[1], "Should trim $spec->[2] from note"; +} + +############################################################################## +# Test note requirement. +is $blank->note_prompt(for => 'add'), __x( + "Write a {command} note.\nLines starting with '#' will be ignored.", + command => 'add' +), 'Should have localized not prompt'; + +my $sqitch_mocker = Test::MockModule->new('App::Sqitch'); +my $note = ''; +my $for = 'add'; +$sqitch_mocker->mock(shell => sub { + my ( $self, $cmd ) = @_; + my $editor = $sqitch->editor; + ok $cmd =~ s/^\Q$editor\E //, 'Shell command should start with editor'; + my $fn = $cmd; + file_exists_ok $fn, 'Temp file should exist'; + + ( my $prompt = $CLASS->note_prompt(for => $for) ) =~ s/^/# /gms; + file_contents_eq $fn, "\n$prompt\n", 'Temp file contents should include prompt', + { encoding => ':raw:utf8_strict' }; + + if ($note) { + open my $fh, '>:utf8_strict', $fn or die "Cannot open $fn: $!"; + print $fh $note, $prompt, "\n"; + close $fh or die "Error closing $fn: $!"; + } +}); + +# Do no actual shell quoting. +$sqitch_mocker->mock(quote_shell => sub { shift; join ' ' => @_ }); + +throws_ok { $CLASS->new(plan => $plan )->request_note(for => $for) } + 'App::Sqitch::X', + 'Should get exception for no note text'; +is $@->ident, 'plan', 'No note error ident should be "plan"'; +is $@->message, __ 'Aborting due to empty note', + 'No note error message should be correct'; +is $@->exitval, 1, 'Exit val should be 1'; + +# Now write a note. +$for = 'rework'; +$note = "This is my awesome note.\n"; +$blank = $CLASS->new(plan => $plan ); +is $blank->request_note(for => $for), 'This is my awesome note.', 'Request note'; +$note = ''; +is $blank->note, 'This is my awesome note.', 'Should have the edited note'; +is $blank->request_note(for => $for), 'This is my awesome note.', + 'The request should not prompt again'; diff --git a/t/bundle.t b/t/bundle.t new file mode 100644 index 00000000..783712e6 --- /dev/null +++ b/t/bundle.t @@ -0,0 +1,565 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 306; +#use Test::More 'no_plan'; +use App::Sqitch; +use Path::Class; +use Test::Exception; +use Test::Dir; +use Test::Warn; +use Test::File qw(file_exists_ok file_not_exists_ok); +use Test::File::Contents; +use Locale::TextDomain qw(App-Sqitch); +use File::Path qw(make_path remove_tree); +use Test::NoWarnings; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::bundle'; + +ok my $sqitch = App::Sqitch->new, 'Load a sqitch object'; +my $config = $sqitch->config; +isa_ok my $bundle = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'bundle', + config => $config, +}), $CLASS, 'bundle command'; + +can_ok $CLASS, qw( + configure + execute + from + to + dest_dir + dest_top_dir + dest_dirs_for + bundle_config + bundle_plan + bundle_scripts + _mkpath + _copy_if_modified + does +); + +ok $CLASS->does("App::Sqitch::Role::ContextCommand"), + "$CLASS does ContextCommand"; + +is_deeply [$CLASS->options], [qw( + dest-dir|dir=s + all|a! + from=s + to=s + plan-file|f=s + top-dir=s +)], 'Should have dest_dir option'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +is $bundle->dest_dir, dir('bundle'), + 'Default dest_dir should be bundle/'; + +is $bundle->dest_top_dir($bundle->default_target), dir('bundle'), + 'Should have dest top dir'; + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure($config, {}), {_cx => []}, + 'Default config should be empty'; +is_deeply $CLASS->configure($config, {dest_dir => 'whu'}), { + dest_dir => dir('whu'), + _cx => [], +}, '--dest_dir should be converted to a path object by configure()'; + +is_deeply $CLASS->configure($config, {from => 'HERE', to => 'THERE'}), { + from => 'HERE', + to => 'THERE', + _cx => [], +}, '--from and --to should be passed through configure'; + +chdir 't'; +$config= TestConfig->from(local => 'sqitch.conf'); +$config->update('core.top_dir' => dir('sql')->stringify); +END { remove_tree 'bundle' if -d 'bundle' } +ok $sqitch = App::Sqitch->new(config => $config), + 'Load a sqitch object with top_dir'; +$config = $sqitch->config; +my $dir = dir qw(_build sql); +is_deeply $CLASS->configure($config, {}), { + dest_dir => $dir, + _cx => [], +}, 'bundle.dest_dir config should be converted to a path object by configure()'; + +############################################################################## +# Load a real project. +isa_ok $bundle = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'bundle', + config => $config, +}), $CLASS, 'another bundle command'; + +is $bundle->dest_dir, $dir, qq{dest_dir should be "$dir"}; +is $bundle->dest_top_dir($bundle->default_target), dir(qw(_build sql sql)), + 'Dest top dir should be _build/sql/sql/'; +my $target = $bundle->default_target; +my $dir_for = $bundle->dest_dirs_for($target); +for my $sub (qw(deploy revert verify)) { + is $dir_for->{$sub}, $dir->subdir('sql', $sub), + "Dest $sub dir should be _build/sql/sql/$sub"; +} + +# Try engine project. +$config->update( + 'core.top_dir' => dir('engine')->stringify, + 'core.reworked_dir' => dir(qw(engine reworked))->stringify, +); +ok $sqitch = App::Sqitch->new(config => $config), + 'Load a sqitch object with engine top_dir'; +isa_ok $bundle = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'bundle', + config => $config, +}), $CLASS, 'engine bundle command'; +$target = $bundle->default_target; + +is $bundle->dest_dir, $dir, qq{dest_dir should again be "$dir"}; +$dir_for = $bundle->dest_dirs_for($target); +for my $sub (qw(deploy revert verify)) { + is $dir_for->{$sub}, $dir->subdir('engine', $sub), + "Dest $sub dir should be _build/sql/engine/$sub"; +} + +############################################################################## +# Test _mkpath. +my $path = dir 'delete.me'; +dir_not_exists_ok $path, "Path $path should not exist"; +END { remove_tree $path->stringify if -e $path } +ok $bundle->_mkpath($path), "Create $path"; +dir_exists_ok $path, "Path $path should now exist"; +is_deeply +MockOutput->get_debug, [[' ', __x 'Created {file}', file => $path]], + 'The mkdir info should have been output'; + +# Create it again. +ok $bundle->_mkpath($path), "Create $path again"; +dir_exists_ok $path, "Path $path should still exist"; +is_deeply +MockOutput->get_debug, [], 'Nothing should have been emitted'; + +# Handle errors. +FSERR: { + # Make mkpath to insert an error. + my $mock = Test::MockModule->new('File::Path'); + $mock->mock( mkpath => sub { + my ($file, $p) = @_; + ${ $p->{error} } = [{ $file => 'Permission denied yo'}]; + return; + }); + + throws_ok { $bundle->_mkpath('foo') } 'App::Sqitch::X', + 'Should fail on permission issue'; + is $@->ident, 'bundle', 'Permission error should have ident "bundle"'; + is $@->message, __x( + 'Error creating {path}: {error}', + path => 'foo', + error => 'Permission denied yo', + ), 'The permission error should be formatted properly'; +} + +############################################################################## +# Test _copy(). +my $file = file qw(sql deploy roles.sql); +my $dest = file $path, qw(deploy roles.sql); +file_not_exists_ok $dest, "File $dest should not exist"; +ok $bundle->_copy_if_modified($file, $dest), "Copy $file to $dest"; +file_exists_ok $dest, "File $dest should now exist"; +file_contents_identical $dest, $file; +is_deeply +MockOutput->get_debug, [ + [' ', __x 'Created {file}', file => $dest->dir], + [' ', __x( + "Copying {source} -> {dest}", + source => $file, + dest => $dest + )], +], 'The mkdir and copy info should have been output'; + +# Copy it again. +ok $bundle->_copy_if_modified($file, $dest), "Copy $file to $dest again"; +file_exists_ok $dest, "File $dest should still exist"; +file_contents_identical $dest, $file; +my $out = MockOutput->get_debug; +is_deeply $out, [], 'Should have no debugging output' or diag explain $out; + +# Make it old and copy it again. +utime 0, $file->stat->mtime - 1, $dest; +ok $bundle->_copy_if_modified($file, $dest), "Copy $file to old $dest"; +file_exists_ok $dest, "File $dest should still be there"; +file_contents_identical $dest, $file; +is_deeply +MockOutput->get_debug, [[' ', __x( + "Copying {source} -> {dest}", + source => $file, + dest => $dest +)]], 'Only copy message should again have been emitted'; + +# Copy a different file. +my $file2 = file qw(sql deploy users.sql); +$dest->remove; +ok $bundle->_copy_if_modified($file2, $dest), "Copy $file2 to $dest"; +file_exists_ok $dest, "File $dest should now exist"; +file_contents_identical $dest, $file2; +is_deeply +MockOutput->get_debug, [[' ', __x( + "Copying {source} -> {dest}", + source => $file2, + dest => $dest +)]], 'Again only Copy message should have been emitted'; + +# Try to copy a nonexistent file. +my $nonfile = file 'nonexistent.txt'; +throws_ok { $bundle->_copy_if_modified($nonfile, $dest) } 'App::Sqitch::X', + 'Should get exception when source file does not exist'; +is $@->ident, 'bundle', 'Nonexistent file error ident should be "bundle"'; +is $@->message, __x( + 'Cannot copy {file}: does not exist', + file => $nonfile, +), 'Nonexistent file error message should be correct'; + +COPYDIE: { + # Make copy die. + $dest->remove; + my $mocker = Test::MockModule->new('File::Copy'); + $mocker->mock(copy => sub { return 0 }); + throws_ok { $bundle->_copy_if_modified($file, $dest) } 'App::Sqitch::X', + 'Should get exception when copy returns false'; + is $@->ident, 'bundle', 'Copy fail ident should be "bundle"'; + is $@->message, __x( + 'Cannot copy "{source}" to "{dest}": {error}', + source => $file, + dest => $dest, + error => $!, + ), 'Copy fail error message should be correct'; +} + +############################################################################## +# Test bundle_config(). +END { + my $to_remove = $dir->parent->stringify; + remove_tree $to_remove if -e $to_remove; +} +$dest = file $dir, qw(sqitch.conf); +file_not_exists_ok $dest; +ok $bundle->bundle_config, 'Bundle the config file'; +file_exists_ok $dest; +file_contents_identical $dest, file('sqitch.conf'); +is_deeply +MockOutput->get_info, [[__ 'Writing config']], + 'Should have config notice'; + +############################################################################## +# Test bundle_plan(). +$dest = file $bundle->dest_top_dir($bundle->default_target), qw(sqitch.plan); +file_not_exists_ok $dest; +ok $bundle->bundle_plan($bundle->default_target), + 'Bundle the default target plan file'; +file_exists_ok $dest; +file_contents_identical $dest, file(qw(engine sqitch.plan)); +is_deeply +MockOutput->get_info, [[__ 'Writing plan']], + 'Should have plan notice'; + +# Make sure that --from works. +isa_ok $bundle = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'bundle', + config => $config, + args => ['--from', 'widgets'], +}), $CLASS, '--from bundle command'; +is $bundle->from, 'widgets', 'From should be "widgets"'; +ok $bundle->bundle_plan($bundle->default_target, 'widgets'), + 'Bundle the default target plan file with from arg'; +my $plan = $bundle->default_target->plan; +is_deeply +MockOutput->get_info, [[__x( + 'Writing plan from {from} to {to}', + from => 'widgets', + to => '@HEAD', +)]], 'Statement of the bits written should have been emitted'; +file_contents_is $dest, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n" + . '%project=engine' . "\n" + . "\n" + . $plan->find('widgets')->as_string . "\n" + . $plan->find('func/add_user')->as_string . "\n" + . $plan->find('users@HEAD')->as_string . "\n", + 'Plan should contain only changes from "widgets" on'; + +# Make sure that --to works. +isa_ok $bundle = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'bundle', + config => $config, + args => ['--to', 'users'], +}), $CLASS, '--to bundle command'; +is $bundle->to, 'users', 'To should be "users"'; +ok $bundle->bundle_plan($bundle->default_target, undef, 'users'), + 'Bundle the default target plan file with to arg'; +is_deeply +MockOutput->get_info, [[__x( + 'Writing plan from {from} to {to}', + from => '@ROOT', + to => 'users', +)]], 'Statement of the bits written should have been emitted'; +file_contents_is $dest, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n" + . '%project=engine' . "\n" + . "\n" + . $plan->find('users')->as_string . "\n" + . join( "\n", map { $_->as_string } $plan->find('users')->tags ) . "\n", + 'Plan should have written only "users" and its tags'; + +############################################################################## +# Test bundle_scripts(). +my @scripts = ( + $dir_for->{reworked_deploy}->file('users@alpha.sql'), + $dir_for->{reworked_revert}->file('users@alpha.sql'), + $dir_for->{deploy}->file('widgets.sql'), + $dir_for->{revert}->file('widgets.sql'), + $dir_for->{deploy}->file(qw(func add_user.sql)), + $dir_for->{revert}->file(qw(func add_user.sql)), + $dir_for->{deploy}->file('users.sql'), + $dir_for->{revert}->file('users.sql'), +); +file_not_exists_ok $_ for @scripts; +$config->update( 'core.extension' => 'sql'); +isa_ok $bundle = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'bundle', + config => $config, +}), $CLASS, 'another bundle command'; +ok $bundle->bundle_scripts($bundle->default_target), + 'Bundle default target scripts'; +file_exists_ok $_ for @scripts; +is_deeply +MockOutput->get_info, [ + [__ 'Writing scripts'], + [' + ', 'users @alpha'], + [' + ', 'widgets'], + [' + ', 'func/add_user'], + [' + ', 'users'], +], 'Should have change notices'; + +# Make sure that --from works. +remove_tree $dir->parent->stringify; +isa_ok $bundle = App::Sqitch::Command::bundle->new( + sqitch => $sqitch, + dest_dir => $bundle->dest_dir, + from => 'widgets', +), $CLASS, 'bundle from "widgets"'; +ok $bundle->bundle_scripts($bundle->default_target, 'widgets'), 'Bundle scripts'; +file_not_exists_ok $_ for @scripts[0,1]; +file_exists_ok $_ for @scripts[2,3]; +is_deeply +MockOutput->get_info, [ + [__ 'Writing scripts'], + [' + ', 'widgets'], + [' + ', 'func/add_user'], + [' + ', 'users'], +], 'Should have changes only from "widets" onward in notices'; + +# Make sure that --to works. +remove_tree $dir->parent->stringify; +isa_ok $bundle = App::Sqitch::Command::bundle->new( + sqitch => $sqitch, + dest_dir => $bundle->dest_dir, + to => 'users@alpha', +), $CLASS, 'bundle to "users"'; +ok $bundle->bundle_scripts($bundle->default_target, undef, 'users@alpha'), 'Bundle scripts'; +file_exists_ok $_ for @scripts[0,1]; +file_not_exists_ok $_ for @scripts[2,3]; +is_deeply +MockOutput->get_info, [ + [__ 'Writing scripts'], + [' + ', 'users @alpha'], +], 'Should have only "users" in change notices'; + +# Should throw exceptions on unknonw changes. +for my $key (qw(from to)) { + my $bundle = $CLASS->new( sqitch => $sqitch, $key => 'nonexistent' ); + throws_ok { + $bundle->bundle_scripts($bundle->default_target, 'nonexistent') + } 'App::Sqitch::X', "Should die on nonexistent $key change"; + is $@->ident, 'bundle', qq{Nonexistent $key change ident should be "bundle"}; + is $@->message, __x( + 'Cannot find change {change}', + change => 'nonexistent', + ), "Nonexistent $key message change should be correct"; +} + +############################################################################## +# Test execute(). +MockOutput->get_debug; +remove_tree $dir->parent->stringify; +@scripts = ( + file($dir, 'sqitch.conf'), + file($bundle->dest_top_dir($bundle->default_target), 'sqitch.plan'), + @scripts, +); +file_not_exists_ok $_ for @scripts; +isa_ok $bundle = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'bundle', + config => $config, +}), $CLASS, 'another bundle command'; +ok $bundle->execute, 'Execute!'; +file_exists_ok $_ for @scripts; +is_deeply +MockOutput->get_info, [ + [__x 'Bundling into {dir}', dir => $bundle->dest_dir ], + [__ 'Writing config'], + [__ 'Writing plan'], + [__ 'Writing scripts'], + [' + ', 'users @alpha'], + [' + ', 'widgets'], + [' + ', 'func/add_user'], + [' + ', 'users'], +], 'Should have all notices'; + +# Try a configuration with multiple plans. +my $multidir = $dir->parent; +END { remove_tree $multidir->stringify } +remove_tree $multidir->stringify; +my @sql = ( + $multidir->file(qw(sql sqitch.plan)), + $multidir->file(qw(sql deploy roles.sql)), + $multidir->file(qw(sql deploy users.sql)), + $multidir->file(qw(sql verify users.sql)), + $multidir->file(qw(sql deploy widgets.sql)), +); +my @engine = ( + $multidir->file(qw(engine sqitch.plan)), + $multidir->file(qw(engine reworked deploy users@alpha.sql)), + $multidir->file(qw(engine reworked revert users@alpha.sql)), + $multidir->file(qw(engine deploy widgets.sql)), + $multidir->file(qw(engine revert widgets.sql)), + $multidir->file(qw(engine deploy func add_user.sql)), + $multidir->file(qw(engine revert func add_user.sql)), + $multidir->file(qw(engine deploy users.sql)), + $multidir->file(qw(engine revert users.sql)), +); +my $conf_file = $multidir->file('multiplan.conf'),; +file_not_exists_ok $_ for ($conf_file, @sql, @engine); + +$config = TestConfig->from(local => 'multiplan.conf'); +$sqitch = App::Sqitch->new(config => $config); +isa_ok $bundle = $CLASS->new( + sqitch => $sqitch, + config => $config, + all => 1, + dest_dir => dir '_build', +), $CLASS, 'all multiplan bundle command'; +ok $bundle->execute, 'Execute multi-target bundle!'; +file_exists_ok $_ for ($conf_file, @sql, @engine); + +# Make sure we get an error with both --all and a specified target. +throws_ok { $bundle->execute('pg' ) } 'App::Sqitch::X', + 'Should get an error for --all and a target arg'; +is $@->ident, 'bundle', 'Mixed arguments error ident should be "bundle"'; +is $@->message, __( + 'Cannot specify both --all and engine, target, or plan arugments' +), 'Mixed arguments error message should be correct'; + +# Try without --all. +isa_ok $bundle = $CLASS->new( + sqitch => $sqitch, + config => $sqitch->config, + dest_dir => dir '_build', +), $CLASS, 'multiplan bundle command'; +remove_tree $multidir->stringify; +ok $bundle->execute, qq{Execute with no arg}; +file_exists_ok $_ for ($conf_file, @engine); +file_not_exists_ok $_ for @sql; + +# Make sure it works with bundle.all set, as well. +$config->update('bundle.all' => 1); +remove_tree $multidir->stringify; +ok $bundle->execute, qq{Execute with bundle.all config}; +file_exists_ok $_ for ($conf_file, @engine, @sql); + +# Try limiting it in various ways. +for my $spec ( + [ + target => 'pg', + { include => \@engine, exclude => \@sql }, + ], + [ + 'plan file' => file(qw(engine sqitch.plan))->stringify, + { include => \@engine, exclude => \@sql }, + ], + [ + target => 'mysql', + { include => \@sql, exclude => \@engine }, + ], + [ + 'plan file' => file(qw(sql sqitch.plan))->stringify, + { include => \@sql, exclude => \@engine }, + ], +) { + my ($type, $arg, $files) = @{ $spec }; + remove_tree $multidir->stringify; + ok $bundle->execute($arg), qq{Execute with $type arg "$arg"}; + file_exists_ok $_ for ($conf_file, @{ $files->{include} }); + file_not_exists_ok $_ for @{ $files->{exclude} }; +} + +# Make sure we handle --to and --from. +isa_ok $bundle = $CLASS->new( + sqitch => $sqitch, + config => $sqitch->config, + from => 'widgets', + to => 'widgets', + dest_dir => dir '_build', +), $CLASS, 'to/from bundle command'; +remove_tree $multidir->stringify; +ok $bundle->execute('pg'), 'Execute to/from bundle!'; +file_exists_ok $_ for ($conf_file, @engine[0,3,4]); +file_not_exists_ok $_ for (@engine[1,2,5..$#engine]); +file_contents_is $engine[0], + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n" + . '%project=engine' . "\n" + . "\n" + . $plan->find('widgets')->as_string . "\n", + 'Plan should have written only "widgets"'; + +# Make sure we handle to and from args. +isa_ok $bundle = $CLASS->new( + sqitch => $sqitch, + config => $sqitch->config, + dest_dir => dir '_build', +), $CLASS, 'another bundle command'; +remove_tree $multidir->stringify; +ok $bundle->execute(qw(pg widgets @HEAD)), 'Execute bundle with to/from args!'; +file_exists_ok $_ for ($conf_file, @engine[0,3..$#engine]); +file_not_exists_ok $_ for (@engine[1,2]); +file_contents_is $engine[0], + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n" + . '%project=engine' . "\n" + . "\n" + . $plan->find('widgets')->as_string . "\n" + . $plan->find('func/add_user')->as_string . "\n" + . $plan->find('users@HEAD')->as_string . "\n", + 'Plan should have written "widgets" and "func/add_user"'; + +# Should die on unknown argument. +throws_ok { $bundle->execute('nonesuch') } 'App::Sqitch::X', + 'Should get an exception for unknown argument'; +is $@->ident, 'bundle', 'Unknown argument error ident shoud be "bundle"'; +is $@->message, __x( + 'Unknown argument "{arg}"', + arg => 'nonesuch', +), 'Unknown argument error message should be correct'; + +# Should handle multiple arguments, too. +throws_ok { $bundle->execute(qw(ba da dum)) } 'App::Sqitch::X', + 'Should get an exception for unknown arguments'; +is $@->ident, 'bundle', 'Unknown arguments error ident shoud be "bundle"'; +is $@->message, __x( + 'Unknown arguments: {arg}', + arg => join ', ', qw(ba da dum) +), 'Unknown arguments error message should be correct'; diff --git a/t/change.t b/t/change.t new file mode 100644 index 00000000..ff3f33b8 --- /dev/null +++ b/t/change.t @@ -0,0 +1,438 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More tests => 92; +#use Test::More 'no_plan'; +use Test::NoWarnings; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use App::Sqitch::Plan::Tag; +use Encode qw(encode_utf8); +use Locale::TextDomain qw(App-Sqitch); +use Test::Exception; +use Path::Class; +use File::Path qw(make_path remove_tree); +use Digest::SHA; +use Test::MockModule; +use URI; +use lib 't/lib'; +use TestConfig; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Plan::Change'; + require_ok $CLASS or die; +} + +can_ok $CLASS, qw( + name + info + id + lspace + rspace + note + parent + since_tag + rework_tags + add_rework_tags + is_reworked + tags + add_tag + plan + deploy_dir + deploy_file + script_hash + revert_dir + revert_file + revert_dir + verify_file + requires + conflicts + timestamp + planner_name + planner_email + format_name + format_dependencies + format_name_with_tags + format_tag_qualified_name + format_name_with_dependencies + format_op_name_dependencies + format_planner + note_prompt +); + +my $sqitch = App::Sqitch->new( + config => TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir('test-change')->stringify, + ), +); +my $target = App::Sqitch::Target->new( + sqitch => $sqitch, + reworked_dir => dir('test-change/reworked'), +); +my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target); +make_path 'test-change'; +END { remove_tree 'test-change' }; +my $fn = $target->plan_file; +open my $fh, '>', $fn or die "Cannot open $fn: $!"; +say $fh "%project=change\n\n"; +close $fh or die "Error closing $fn: $!"; + +isa_ok my $change = $CLASS->new( + name => 'foo', + plan => $plan, +), $CLASS; + +isa_ok $change, 'App::Sqitch::Plan::Line'; +ok $change->is_deploy, 'It should be a deploy change'; +ok !$change->is_revert, 'It should not be a revert change'; +is $change->action, 'deploy', 'And it should say so'; +isa_ok $change->timestamp, 'App::Sqitch::DateTime', 'Timestamp'; + +my $tag = App::Sqitch::Plan::Tag->new( + plan => $plan, + name => 'alpha', + change => $change, +); + +is_deeply [ $change->path_segments ], ['foo.sql'], + 'path_segments should have the file name'; +is $change->deploy_dir, $target->deploy_dir, + 'The deploy dir should be correct'; +is $change->deploy_file, $target->deploy_dir->file('foo.sql'), + 'The deploy file should be correct'; +is $change->revert_dir, $target->revert_dir, + 'The revert dir should be correct'; +is $change->revert_file, $target->revert_dir->file('foo.sql'), + 'The revert file should be correct'; +is $change->verify_dir, $target->verify_dir, + 'The verify dir should be correct'; +is $change->verify_file, $target->verify_dir->file('foo.sql'), + 'The verify file should be correct'; +ok !$change->is_reworked, 'The change should not be reworked'; +is_deeply [ $change->path_segments ], ['foo.sql'], + 'path_segments should not include suffix'; + +# Test script_hash. +is $change->script_hash, undef, + 'Nonexistent deploy script hash should be undef'; +make_path $change->deploy_dir->stringify; +$change->deploy_file->spew(iomode => '>:raw', encode_utf8 "Foo\nBar\nBøz\n亜唖娃阿" ); +$change = $CLASS->new( name => 'foo', plan => $plan ); +is $change->script_hash, 'd48866b846300912570f643c99b2ceec4ba29f5c', + 'Deploy script hash should be correct'; +is $change->format_tag_qualified_name, 'foo@HEAD', + 'Tag-qualified name should be tagged with @HEAD'; + +# Identify it as reworked. +ok $change->add_rework_tags($tag), 'Add a rework tag'; +is_deeply [$change->rework_tags], [$tag], 'Reworked tag should be stored'; +ok $change->is_reworked, 'The change should be reworked'; +$change->deploy_dir->mkpath; +$change->deploy_dir->file('foo@alpha.sql')->touch; +is_deeply [ $change->path_segments ], ['foo@alpha.sql'], + 'path_segments should now include suffix'; + +# Make sure all rework tags are searched. +$change->clear_rework_tags; +ok !$change->is_reworked, 'The change should not be reworked'; + +my $tag2 = App::Sqitch::Plan::Tag->new( + plan => $plan, + name => 'beta', + change => $change, +); +ok $change->add_rework_tags($tag2, $tag), 'Add two rework tags'; +ok $change->is_reworked, 'The change should again be reworked'; +is_deeply [ $change->path_segments ], ['foo@alpha.sql'], + 'path_segments should now include the correct suffixc'; + +is $change->format_name, 'foo', 'Name should format as "foo"'; +is $change->format_name_with_tags, 'foo', + 'Name should format with tags as "foo"'; +is $change->format_tag_qualified_name, 'foo@beta', + 'Tag-qualified Name should format as "foo@beta"'; +is $change->format_dependencies, '', 'Dependencies should format as ""'; +is $change->format_name_with_dependencies, 'foo', + 'Name should format with dependencies as "foo"'; +is $change->format_op_name_dependencies, 'foo', + 'Name should format op without dependencies as "foo"'; +is $change->format_content, 'foo ' . $change->timestamp->as_string + . ' ' . $change->format_planner, + 'Change content should format correctly without dependencies'; + +is $change->planner_name, $sqitch->user_name, + 'Planner name shoudld default to user name'; +is $change->planner_email, $sqitch->user_email, + 'Planner email shoudld default to user email'; +is $change->format_planner, join( + ' ', + $sqitch->user_name, + '<' . $sqitch->user_email . '>' +), 'Planner name and email should format properly'; + +my $ts = $change->timestamp->as_string; +is $change->as_string, "foo $ts " . $change->format_planner, + 'should stringify to "foo" + planner'; +is $change->since_tag, undef, 'Since tag should be undef'; +is $change->parent, undef, 'Parent should be undef'; + +is $change->info, join("\n", + 'project change', + 'change foo', + 'planner ' . $change->format_planner, + 'date ' . $change->timestamp->as_string, +), 'Change info should be correct'; +is $change->id, do { + my $content = encode_utf8 $change->info; + Digest::SHA->new(1)->add( + 'change ' . length($content) . "\0" . $content + )->hexdigest; +},'Change ID should be correct'; + +my $date = App::Sqitch::DateTime->new( + year => 2012, + month => 7, + day => 16, + hour => 17, + minute => 25, + second => 7, + time_zone => 'UTC', +); + +sub dep($) { + App::Sqitch::Plan::Depend->new( + %{ App::Sqitch::Plan::Depend->parse(shift) }, + plan => $target->plan, + project => 'change', + ) +} + +ok my $change2 = $CLASS->new( + name => 'yo/howdy', + plan => $plan, + since_tag => $tag, + parent => $change, + lspace => ' ', + operator => '-', + ropspace => ' ', + rspace => "\t", + suffix => '@beta', + note => 'blah blah blah ', + pspace => ' ', + requires => [map { dep $_ } qw(foo bar @baz)], + conflicts => [dep '!dr_evil'], + timestamp => $date, + planner_name => 'Barack Obama', + planner_email => 'potus@whitehouse.gov', +), 'Create change with more stuff'; + +my $ts2 = '2012-07-16T17:25:07Z'; +is $change2->as_string, " - yo/howdy [foo bar \@baz !dr_evil] " + . "$ts2 Barack Obama <potus\@whitehouse.gov>\t# blah blah blah", + 'It should stringify correctly'; +my $mock_plan = Test::MockModule->new(ref $plan); +$mock_plan->mock(index_of => 0); +my $uri = URI->new('https://github.com/sqitchers/sqitch/'); +$mock_plan->mock( uri => $uri ); + +ok !$change2->is_deploy, 'It should not be a deploy change'; +ok $change2->is_revert, 'It should be a revert change'; +is $change2->action, 'revert', 'It should say so'; +is $change2->since_tag, $tag, 'It should have a since tag'; +is $change2->parent, $change, 'It should have a parent'; + +is $change2->info, join("\n", + 'project change', + 'uri https://github.com/sqitchers/sqitch/', + 'change yo/howdy', + 'parent ' . $change->id, + 'planner Barack Obama <potus@whitehouse.gov>', + 'date 2012-07-16T17:25:07Z', + 'requires', + ' + foo', + ' + bar', + ' + @baz', + 'conflicts', + ' - dr_evil', + '', 'blah blah blah' +), 'Info should include parent and dependencies'; + +# Check tags. +is_deeply [$change2->tags], [], 'Should have no tags'; +ok $change2->add_tag($tag), 'Add a tag'; +is_deeply [$change2->tags], [$tag], 'Should have the tag'; +is $change2->format_name_with_tags, 'yo/howdy @alpha', + 'Should format name with tags'; +is $change2->format_tag_qualified_name, 'yo/howdy@alpha', + 'Should format tag-qualiified name'; + +# Add another tag. +ok $change2->add_tag($tag2), 'Add another tag'; +is_deeply [$change2->tags], [$tag, $tag2], 'Should have both tags'; +is $change2->format_name_with_tags, 'yo/howdy @alpha @beta', + 'Should format name with both tags'; +is $change2->format_tag_qualified_name, 'yo/howdy@alpha', + 'Should format tag-qualified name with first tag'; + +is $change2->format_planner, 'Barack Obama <potus@whitehouse.gov>', + 'Planner name and email should format properly'; +is $change2->format_dependencies, '[foo bar @baz !dr_evil]', + 'Dependencies should format as "[foo bar @baz !dr_evil]"'; +is $change2->format_name_with_dependencies, 'yo/howdy [foo bar @baz !dr_evil]', + 'Name should format with dependencies as "yo/howdy [foo bar @baz !dr_evil]"'; +is $change2->format_op_name_dependencies, '- yo/howdy [foo bar @baz !dr_evil]', + 'Name should format op with dependencies as "yo/howdy [foo bar @baz !dr_evil]"'; +is $change2->format_content, '- yo/howdy [foo bar @baz !dr_evil] ' + . $change2->timestamp->as_string . ' ' . $change2->format_planner, + 'Change content should format correctly with dependencies'; + +# Check file names. +my @fn = ('yo', 'howdy@beta.sql'); +$change2->add_rework_tags($tag2); +is_deeply [ $change2->path_segments ], \@fn, + 'path_segments should include directories'; +is $change2->deploy_dir, $target->reworked_deploy_dir, + 'Deploy dir should be in rworked dir'; +is $change2->deploy_file, $target->reworked_deploy_dir->file(@fn), + 'Deploy file should be in rworked dir and include suffix'; +is $change2->revert_dir, $target->reworked_revert_dir, + 'Revert dir should be in rworked dir'; +is $change2->revert_file, $target->reworked_revert_dir->file(@fn), + 'Revert file should be in rworked dir and include suffix'; +is $change2->verify_dir, $target->reworked_verify_dir, + 'Verify dir should be in rworked dir'; +is $change2->verify_file, $target->reworked_verify_dir->file(@fn), + 'Verify file should be in rworked dir and include suffix'; + +############################################################################## +# Test open_script. +make_path dir(qw(test-change deploy))->stringify; +file(qw(test-change deploy baz.sql))->touch; +my $change2_file = file qw(test-change deploy bar.sql); +$fh = $change2_file->open('>:utf8_strict') or die "Cannot open $change2_file: $!\n"; +$fh->say('-- This is a comment'); +$fh->say('# And so is this'); +$fh->say('; and this, w€€!'); +$fh->say('/* blah blah blah */'); +$fh->close; + +ok $change2 = $CLASS->new( name => 'baz', plan => $plan ), + 'Create change "baz"'; + +ok $change2 = $CLASS->new( name => 'bar', plan => $plan ), + 'Create change "bar"'; + +############################################################################## +# Test file handles. +ok $fh = $change2->deploy_handle, 'Get deploy handle'; +is $fh->getline, "-- This is a comment\n", 'It should be the deploy file'; + +make_path dir(qw(test-change revert))->stringify; +$fh = $change2->revert_file->open('>') + or die "Cannot open " . $change2->revert_file . ": $!\n"; +$fh->say('-- revert it, baby'); +$fh->close; +ok $fh = $change2->revert_handle, 'Get revert handle'; +is $fh->getline, "-- revert it, baby\n", 'It should be the revert file'; + +make_path dir(qw(test-change verify))->stringify; +$fh = $change2->verify_file->open('>') + or die "Cannot open " . $change2->verify_file . ": $!\n"; +$fh->say('-- verify it, baby'); +$fh->close; +ok $fh = $change2->verify_handle, 'Get verify handle'; +is $fh->getline, "-- verify it, baby\n", 'It should be the verify file'; + +############################################################################## +# Test the requires/conflicts params. +my $file = file qw(t plans multi.plan); +my $sqitch2 = App::Sqitch->new( + config => TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir('test-change')->stringify, + 'core.plan_file' => $file->stringify, + ), +); +my $target2 = App::Sqitch::Target->new(sqitch => $sqitch2); +my $plan2 = $target2->plan; +ok $change2 = $CLASS->new( + name => 'whatever', + plan => $plan2, + requires => [dep 'hey', dep 'you'], + conflicts => [dep '!hey-there'], +), 'Create a change with explicit requires and conflicts'; +is_deeply [$change2->requires], [dep 'hey', dep 'you'], 'requires should be set'; +is_deeply [$change2->conflicts], [dep '!hey-there'], 'conflicts should be set'; +is_deeply [$change2->dependencies], [dep 'hey', dep 'you', dep '!hey-there'], + 'Dependencies should include requires and conflicts'; +is_deeply [$change2->requires_changes], [$plan2->get('hey'), $plan2->get('you')], + 'Should find changes for requires'; +is_deeply [$change2->conflicts_changes], [$plan2->get('hey-there')], + 'Should find changes for conflicts'; + +############################################################################## +# Test ID for a change with a UTF-8 name. +ok $change2 = $CLASS->new( + name => '阱阪阬', + plan => $plan2, +), 'Create change with UTF-8 name'; + +is $change2->info, join("\n", + 'project ' . 'multi', + 'uri ' . $uri->canonical, + 'change ' . '阱阪阬', + 'planner ' . $change2->format_planner, + 'date ' . $change2->timestamp->as_string, +), 'The name should be decoded text in info'; + +is $change2->id, do { + my $content = Encode::encode_utf8 $change2->info; + Digest::SHA->new(1)->add( + 'change ' . length($content) . "\0" . $content + )->hexdigest; +},'Change ID should be hashed from encoded UTF-8'; + +############################################################################## +# Test note_prompt(). +is $change->note_prompt( + for => 'add', + scripts => [$change->deploy_file, $change->revert_file, $change->verify_file], +), exp_prompt( + for => 'add', + scripts => [$change->deploy_file, $change->revert_file, $change->verify_file], + name => $change->format_op_name_dependencies, +), 'note_prompt() should work'; + +is $change2->note_prompt( + for => 'add', + scripts => [$change2->deploy_file, $change2->revert_file, $change2->verify_file], +), exp_prompt( + for => 'add', + scripts => [$change2->deploy_file, $change2->revert_file, $change2->verify_file], + name => $change2->format_op_name_dependencies, +), 'note_prompt() should work'; + +sub exp_prompt { + my %p = @_; + join( + '', + __x( + "Please enter a note for your change. Lines starting with '#' will\n" . + "be ignored, and an empty message aborts the {command}.", + command => $p{for}, + ), + "\n", + __x('Change to {command}:', command => $p{for}), + "\n\n", + ' ', $p{name}, + join "\n ", '', @{ $p{scripts} }, + "\n", + ); +} diff --git a/t/changelist.t b/t/changelist.t new file mode 100644 index 00000000..b35510b3 --- /dev/null +++ b/t/changelist.t @@ -0,0 +1,366 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More tests => 248; +#use Test::More 'no_plan'; +use Test::NoWarnings; +use Test::Exception; +use Path::Class; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use Locale::TextDomain qw(App-Sqitch); +use Test::MockModule; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +BEGIN { require_ok 'App::Sqitch::Plan::ChangeList' or die } + +my $sqitch = App::Sqitch->new(config => TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir(qw(t sql))->stringify, +)); +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target); + +my $foo = App::Sqitch::Plan::Change->new(plan => $plan, name => 'foo'); +my $bar = App::Sqitch::Plan::Change->new(plan => $plan, name => 'bar', parent => $foo); +my $baz = App::Sqitch::Plan::Change->new(plan => $plan, name => 'baz', parent => $bar); +my $yo1 = App::Sqitch::Plan::Change->new(plan => $plan, name => 'yo', parent => $baz); +my $yo2 = App::Sqitch::Plan::Change->new(plan => $plan, name => 'yo', parent => $yo1, planner_name => 'Phil' ); + +my $alpha = App::Sqitch::Plan::Tag->new( + plan => $plan, + change => $yo1, + name => 'alpha', +); +$yo1->add_tag($alpha); +my $changes = App::Sqitch::Plan::ChangeList->new( + $foo, + $bar, + $yo1, + $baz, + $yo2, +); + +my ($earliest_id, $latest_id); +my $engine_mocker = Test::MockModule->new('App::Sqitch::Engine::sqlite'); +my $offset = 0; + +$engine_mocker->mock(earliest_change_id => sub { + $offset = $_[1]; + $changes->change_at( $changes->index_of($earliest_id) + $offset )->id; +}); + +$engine_mocker->mock(latest_change_id => sub { + $offset = $_[1]; + $changes->change_at( $changes->index_of($latest_id) - $offset )->id; +}); + +is $changes->count, 5, 'Count should be six'; +is_deeply [$changes->changes], [$foo, $bar, $yo1, $baz, $yo2], + 'Changes should be in order'; +is_deeply [$changes->items], [$changes->changes], + 'Items should be the same as changes'; +is_deeply [$changes->tags], [$alpha], 'Tags should return the one tag'; +is $changes->change_at(0), $foo, 'Should have foo at 0'; +is $changes->change_at(1), $bar, 'Should have bar at 1'; +is $changes->change_at(2), $yo1, 'Should have yo1 at 2'; +is $changes->change_at(3), $baz, 'Should have baz at 4'; +is $changes->change_at(4), $yo2, 'Should have yo2 at 5'; + +is $changes->index_of('non'), undef, 'Should not find "non"'; +is $changes->index_of('@non'), undef, 'Should not find "@non"'; +is $changes->index_of('foo'), 0, 'Should find foo at 0'; +is $changes->index_of($foo->id), 0, 'Should find foo by ID at 0'; +is $changes->index_of('bar'), 1, 'Should find bar at 1'; +is $changes->index_of('bar^'), 0, 'Should find bar^ at 0'; +is $changes->index_of('bar~'), 2, 'Should find bar~ at 2'; +is $changes->index_of('bar~~'), 3, 'Should find bar~~ at 3'; +is $changes->index_of('bar~~~'), undef, 'Should not find bar~~~'; +is $changes->index_of('bar~2'), 3, 'Should find bar~2 at 3'; +is $changes->index_of('bar~3'), 4, 'Should find bar~3 at 4'; +is $changes->index_of($bar->id), 1, 'Should find bar by ID at 1'; +is $changes->index_of('@alpha'), 2, 'Should find @alpha at 2'; +is $changes->index_of('@alpha^'), 1, 'Should find @alpha^ at 1'; +is $changes->index_of('@alpha^^'), 0, 'Should find @alpha^^ at 1'; +is $changes->index_of('@alpha^^^'), undef, 'Should not find @alpha^^^'; +is $changes->index_of($alpha->id), 2, 'Should find @alpha by ID at 2'; +is $changes->index_of('baz'), 3, 'Should find baz at 3'; +is $changes->index_of($baz->id), 3, 'Should find baz by ID at 3'; +is $changes->index_of('baz^^^'), undef, 'Should not find baz^^^'; +is $changes->index_of('baz^3'), 0, 'Should not find baz^3 at 0'; +is $changes->index_of('baz^4'), undef, 'Should not find baz^4'; +is $changes->index_of($baz->id . '^'), 2, 'Should find baz by ID^ at 2'; + +throws_ok { $changes->index_of('yo') } 'App::Sqitch::X', + 'Should get multiple indexes error looking for index of "yo"'; +is $@->ident, 'plan', 'Multiple indexes error ident should be "plan"'; +is $@->message, __ 'Change lookup failed', + 'Multiple indexes message should be correct'; +is_deeply +MockOutput->get_vent, [ + [__x( + 'Change "{change}" is ambiguous. Please specify a tag-qualified change:', + change => 'yo', + )], + [ ' * ', 'yo@HEAD' ], + [ ' * ', 'yo@alpha' ], +], 'Should have output listing tag-qualified changes'; + +throws_ok { $changes->index_of('yo@howdy') } 'App::Sqitch::X', + 'Should unknown tag error for invalid tag'; +is $@->ident, 'plan', 'Unknown tag error ident should be "plan"'; +is $@->message, __x( + 'Unknown tag "{tag}"', + tag => '@howdy', +), 'Unknown taf message should be correct'; + +is $changes->index_of('yo@alpha'), 2, 'Should get 2 for yo@alpha'; +is $changes->index_of('yo@alpha^'), 1, 'Should get 1 for yo@alpha^'; +is $changes->index_of('yo@HEAD'), 4, 'Should get 4 for yo@HEAD'; +is $changes->index_of('yo@HEAD^'), 3, 'Should get 3 for yo@HEAD^'; +is $changes->index_of('yo@HEAD~'), undef, 'Should get undef for yo@HEAD~'; +is $changes->index_of('yo@HEAD~~'), undef, 'Should get undef for yo@HEAD~~'; +is $changes->index_of('foo@alpha'), 0, 'Should get 0 for foo@alpha'; +is $changes->index_of('foo@HEAD'), 0, 'Should get 0 for foo@HEAD'; +is $changes->index_of('foo@ROOT'), 0, 'Should get 0 for foo@ROOT'; +is $changes->index_of('baz@alpha'), undef, 'Should get undef for baz@alpha'; +is $changes->index_of('baz@HEAD'), 3, 'Should get 3 for baz@HEAD'; +is $changes->index_of('@HEAD'), 4, 'Should get 4 for @HEAD'; +is $changes->index_of('@ROOT'), 0, 'Should get 0 for @ROOT'; +is $changes->index_of('@HEAD^'), 3, 'Should get 3 for @HEAD^'; +is $changes->index_of('@HEAD~'), undef, 'Should get undef for @HEAD~'; +is $changes->index_of('@ROOT~'), 1, 'Should get 1 for @ROOT~'; +is $changes->index_of('@ROOT^'), undef, 'Should get undef for @ROOT^'; +is $changes->index_of('HEAD'), 4, 'Should get 4 for HEAD'; +is $changes->index_of('ROOT'), 0, 'Should get 0 for ROOT'; +is $changes->index_of('HEAD^'), 3, 'Should get 3 for HEAD^'; +is $changes->index_of('HEAD~'), undef, 'Should get undef for HEAD~'; +is $changes->index_of('ROOT~'), 1, 'Should get 1 for ROOT~'; +is $changes->index_of('ROOT^'), undef, 'Should get undef for ROOT^'; + +is $changes->get('foo'), $foo, 'Should get foo for "foo"'; +is $changes->get('foo~'), $bar, 'Should get bar for "foo~"'; +is $changes->get($foo->id), $foo, 'Should get foo by ID'; +is $changes->get('bar'), $bar, 'Should get bar for "bar"'; +is $changes->get('bar^'), $foo, 'Should get foo for "bar^"'; +is $changes->get('bar~'), $yo1, 'Should get yo1 for "bar~"'; +is $changes->get('bar~~'), $baz, 'Should get baz for "bar~~"'; +is $changes->get('bar~3'), $yo2, 'Should get yo2 for "bar~3"'; +is $changes->get($bar->id), $bar, 'Should get bar by ID'; +is $changes->get($alpha->id), $yo1, 'Should get "yo" by the @alpha tag ID'; +is $changes->get('baz'), $baz, 'Should get baz for "baz"'; +is $changes->get($baz->id), $baz, 'Should get baz by ID'; +is $changes->get('@HEAD^'), $baz, 'Should get baz for "@HEAD^"'; +is $changes->get('@HEAD^^'), $yo1, 'Should get yo1 for "@HEAD^^"'; +is $changes->get('@HEAD^3'), $bar, 'Should get bar for "@HEAD^3"'; +is $changes->get('@ROOT'), $foo, 'Should get foo for "@ROOT"'; +is $changes->get('HEAD^'), $baz, 'Should get baz for "HEAD^"'; +is $changes->get('HEAD^^'), $yo1, 'Should get yo1 for "HEAD^^"'; +is $changes->get('HEAD^3'), $bar, 'Should get bar for "HEAD^3"'; +is $changes->get('ROOT'), $foo, 'Should get foo for "ROOT"'; + +is $changes->get('yo@alpha'), $yo1, 'Should get yo1 for yo@alpha'; +is $changes->get('yo@HEAD'), $yo2, 'Should get yo2 for yo@HEAD'; +is $changes->get('foo@alpha'), $foo, 'Should get foo for foo@alpha'; +is $changes->get('foo@HEAD'), $foo, 'Should get foo for foo@HEAD'; +is $changes->get('baz@alpha'), undef, 'Should get undef for baz@alpha'; +is $changes->get('baz@HEAD'), $baz, 'Should get baz for baz@HEAD'; +is $changes->get('yo@HEAD'), $yo2, 'Should get yo2 for "yo@HEAD"'; +is $changes->get('foo@ROOT'), $foo, 'Should get foo for "foo@ROOT"'; + +is $changes->find('yo'), $yo1, 'Should find yo1 with "yo"'; +is $changes->find('yo@alpha'), $yo1, 'Should find yo1 with "yo@alpha"'; +is $changes->find('yo@HEAD'), $yo2, 'Should find yo2 with yo@HEAD'; +is $changes->find('foo'), $foo, 'Should find foo for "foo"'; +is $changes->find('foo@alpha'), $foo, 'Should find foo for "foo@alpha"'; +is $changes->find('foo@HEAD'), $foo, 'Should find foo for "foo@HEAD"'; +is $changes->find('yo^'), $bar, 'Should find bar with "yo^"'; +is $changes->find('yo^^'), $foo, 'Should find foo with "yo^^"'; +is $changes->find('yo^2'), $foo, 'Should find foo with "yo^2"'; +is $changes->find('yo~'), $baz, 'Should find baz with "yo~"'; +is $changes->find('yo~~'), $yo2, 'Should find yo2 with "yo~~"'; +is $changes->find('yo~2'), $yo2, 'Should find yo2 with "yo~2"'; +is $changes->find('yo@alpha^'), $bar, 'Should find bar with "yo@alpha^"'; +is $changes->find('yo@alpha~'), $baz, 'Should find baz with "yo@alpha^"'; +is $changes->find('yo@HEAD^'), $baz, 'Should find baz with yo@HEAD^'; +is $changes->find('@HEAD^'), $baz, 'Should find baz with @HEAD^'; +is $changes->find('@ROOT~'), $bar, 'Should find bar with @ROOT~^'; +is $changes->find('HEAD^'), $baz, 'Should find baz with HEAD^'; +is $changes->find('ROOT~'), $bar, 'Should find bar with ROOT~^'; + +ok $changes->contains('yo'), 'Should contain yo1 with "yo"'; +ok $changes->contains('yo@alpha'), 'Should contain yo1 with "yo@alpha"'; +ok $changes->contains('yo@HEAD'), 'Should contain yo2 with yo@HEAD'; +ok $changes->contains('foo'), 'Should contain foo for "foo"'; +ok $changes->contains('foo@alpha'), 'Should contain foo for "foo@alpha"'; +ok $changes->contains('foo@HEAD'), 'Should contain foo for "foo@HEAD"'; +ok $changes->contains('yo^'), 'Should contain bar with "yo^"'; +ok $changes->contains('yo^^'), 'Should contain foo with "yo^^"'; +ok $changes->contains('yo^2'), 'Should contain foo with "yo^2"'; +ok $changes->contains('yo~'), 'Should contain baz with "yo~"'; +ok $changes->contains('yo~~'), 'Should contain yo2 with "yo~~"'; +ok $changes->contains('yo~2'), 'Should contain yo2 with "yo~2"'; +ok $changes->contains('yo@alpha^'), 'Should contain bar with "yo@alpha^"'; +ok $changes->contains('yo@alpha~'), 'Should contain baz with "yo@alpha^"'; +ok $changes->contains('yo@HEAD^'), 'Should contain baz with yo@HEAD^'; +ok $changes->contains('@HEAD^'), 'Should contain baz with @HEAD^'; +ok $changes->contains('@ROOT~'), 'Should contain bar with @ROOT~^'; +ok $changes->contains('HEAD^'), 'Should contain baz with HEAD^'; +ok $changes->contains('ROOT~'), 'Should contain bar with ROOT~^'; + +throws_ok { $changes->get('yo') } 'App::Sqitch::X', + 'Should get multiple indexes error looking for index of "yo"'; +is $@->ident, 'plan', 'Multiple indexes error ident should be "plan"'; +is $@->message, __ 'Change lookup failed', + 'Multiple indexes message should be correct'; +is_deeply +MockOutput->get_vent, [ + [__x( + 'Change "{change}" is ambiguous. Please specify a tag-qualified change:', + change => 'yo', + )], + [ ' * ', 'yo@HEAD' ], + [ ' * ', 'yo@alpha' ], +], 'Should have output listing tag-qualified changes'; + +throws_ok { $changes->get('yo@howdy') } 'App::Sqitch::X', + 'Should unknown tag error for invalid tag'; +is $@->ident, 'plan', 'Unknown tag error ident should be "plan"'; +is $@->message, __x( + 'Unknown tag "{tag}"', + tag => '@howdy', +), 'Unknown taf message should be correct'; + +my $hi = App::Sqitch::Plan::Change->new(plan => $plan, name => 'hi'); +ok $changes->append($hi), 'Push hi'; +is $changes->count, 6, 'Count should now be six'; +is_deeply [$changes->changes], [$foo, $bar, $yo1, $baz, $yo2, $hi], + 'Changes should be in order with $hi at the end'; +is $changes->index_of('hi'), 5, 'Should find "hi" at index 5'; +is $changes->index_of($hi->id), 5, 'Should find "hi" by ID at index 5'; +is $changes->index_of('@ROOT'), 0, 'Index of @ROOT should still be 0'; +is $changes->index_of('@HEAD'), 5, 'Index of @HEAD should now be 5'; +is $changes->index_of('ROOT'), 0, 'Index of ROOT should still be 0'; +is $changes->index_of('HEAD'), 5, 'Index of HEAD should now be 5'; + +# Now try first_index_of(). +is $changes->first_index_of('non'), undef, 'First index of "non" should be undef'; +is $changes->first_index_of('foo'), 0, 'First index of "foo" should be 0'; +is $changes->first_index_of('foo~'), 1, 'First index of "foo~" should be 1'; +is $changes->first_index_of('foo~~'), 2, 'First index of "foo~~" should be 2'; +is $changes->first_index_of('foo~3'), 3, 'First index of "foo~3" should be 3'; +is $changes->first_index_of('foo~~~'), undef, 'Should not find first index of "foo~~~"'; +is $changes->first_index_of('foo', '@ROOT'), undef, 'First index of "foo" since @ROOT should be undef'; +is $changes->first_index_of('bar'), 1, 'First index of "bar" should be 1'; +is $changes->first_index_of('yo'), 2, 'First index of "yo" should be 2'; +is $changes->first_index_of('yo', '@ROOT'), 2, 'First index of "yo" since @ROOT should be 2'; +is $changes->first_index_of('baz'), 3, 'First index of "baz" should be 3'; +is $changes->first_index_of('baz^'), 2, 'First index of "baz^" should be 2'; +is $changes->first_index_of('baz^^'), 1, 'First index of "baz^^" should be 1'; +is $changes->first_index_of('baz^3'), 0, 'First index of "baz^3" should be 0'; +is $changes->first_index_of('baz^^^'), undef, 'Should not find first index of "baz^^^"'; +is $changes->first_index_of('yo', '@alpha'), 4, + 'First index of "yo" since "@alpha" should be 4'; +is $changes->first_index_of('yo', 'baz'), 4, + 'First index of "yo" since "baz" should be 4'; +is $changes->first_index_of('yo^', 'baz'), 3, + 'First index of "yo^" since "baz" should be 4'; +is $changes->first_index_of('yo~', 'baz'), 5, + 'First index of "yo~" since "baz" should be 5'; +throws_ok { $changes->first_index_of('baz', 'nonexistent') } 'App::Sqitch::X', + 'Should get an exception for an unknown change passed to first_index_of()'; +is $@->ident, 'plan', 'Unknown change error ident should be "plan"'; +is $@->message, __x( + 'Unknown change: "{change}"', + change => 'nonexistent', +), 'Unknown change message should be correct'; + +# Try appending a couple more changes. +my $so = App::Sqitch::Plan::Change->new(plan => $plan, name => 'so'); +my $fu = App::Sqitch::Plan::Change->new(plan => $plan, name => 'fu'); +ok $changes->append($so, $fu), 'Push so and fu'; +is $changes->count, 8, 'Count should now be eight'; +is $changes->index_of('@ROOT'), 0, 'Index of @ROOT should remain 0'; +is $changes->index_of('@HEAD'), 7, 'Index of @HEAD should now be 7'; +is $changes->index_of('ROOT'), 0, 'Index of ROOT should remain 0'; +is $changes->index_of('HEAD'), 7, 'Index of HEAD should now be 7'; +is_deeply [$changes->changes], [$foo, $bar, $yo1, $baz, $yo2, $hi, $so, $fu], + 'Changes should be in order with $so and $fu at the end'; + +# Try indexing a tag. +my $beta = App::Sqitch::Plan::Tag->new( + plan => $plan, + change => $yo2, + name => 'beta', +); +$yo2->add_tag($beta); +ok $changes->index_tag(4, $beta), 'Index beta'; +is $changes->index_of('@beta'), 4, 'Should find @beta at index 4'; +is $changes->get('@beta'), $yo2, 'Should find yo2 via @beta'; +is $changes->get($beta->id), $yo2, 'Should find yo2 via @beta ID'; +is_deeply [$changes->tags], [$alpha, $beta], 'Tags should return both tags'; + +############################################################################## +# Test last_tagged(), last_change(), index_of_last_tagged(). +is $changes->index_of_last_tagged, 2, 'Should get 2 for last tagged index'; +is $changes->last_tagged_change, $yo1, 'Should find "yo" as last tagged'; +is $changes->count, 8, 'Should get 8 for count'; +is $changes->last_change, $fu, 'Should find fu as last change'; + +for my $changes ( + [0, $yo1], + [1, $foo, $yo1], + [3, $foo, $bar, $baz, $yo1], + [4, $foo, $bar, $baz, $hi, $yo1], +) { + my $index = shift @{ $changes }; + my $n = App::Sqitch::Plan::ChangeList->new(@{ $changes }); + is $n->index_of_last_tagged, $index, "Should find last tagged index at $index"; + is $n->last_tagged_change, $changes->[$index], "Should find last tagged at $index"; + is $n->count, ($index + 1), "Should get count " . ($index + 1); + is $n->last_change, $changes->[$index], "Should find last change at $index"; +} + +for my $changes ( + [], + [$foo, $baz], + [$foo, $bar, $baz, $hi], +) { + my $n = App::Sqitch::Plan::ChangeList->new(@{ $changes }); + is $n->index_of_last_tagged, undef, + 'Should not find tag index in ' . scalar @{$changes} . ' changes'; + is $n->last_tagged_change, undef, + 'Should not find tag in ' . scalar @{$changes} . ' changes'; + if (!@{ $changes }) { + is $n->last_change, undef, "Should find no change in empty plan"; + } +} + +# Try an empty change list. +isa_ok $changes = App::Sqitch::Plan::ChangeList->new, + 'App::Sqitch::Plan::ChangeList'; +for my $ref (qw( + foo + bar + HEAD + @HEAD + ROOT + @ROOT + alpha + @alpha +)) { + is $changes->index_of($ref), undef, + qq{Should not find index of "$ref" in empty list}; + is $changes->first_index_of($ref), undef, + qq{Should not find first index of "$ref" in empty list}; + is $changes->get($ref), undef, + qq{Should get undef for "$ref" in empty list}; + ok !$changes->contains($ref), + qq{Should not contain "$ref" in empty list}; + is $changes->find($ref), undef, + qq{Should find undef for "$ref" in empty list}; +} diff --git a/t/checkout.t b/t/checkout.t new file mode 100644 index 00000000..9c81b4a1 --- /dev/null +++ b/t/checkout.t @@ -0,0 +1,660 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use Test::More; +use App::Sqitch; +use App::Sqitch::Target; +use utf8; +use Path::Class qw(dir file); +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use Test::MockModule; +use Test::Exception; +use Test::Warn; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::checkout'; +require_ok $CLASS or die; + +isa_ok $CLASS, 'App::Sqitch::Command'; +can_ok $CLASS, qw( + target + options + configure + log_only + execute + deploy_variables + revert_variables + _collect_deploy_vars + _collect_revert_vars + does +); + +ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_" + for qw(RevertDeployCommand ConnectingCommand ContextCommand); + +is_deeply [$CLASS->options], [qw( + plan-file|f=s + top-dir=s + registry=s + client|db-client=s + db-name|d=s + db-user|db-username|u=s + db-host|h=s + db-port|p=i + target|t=s + mode=s + verify! + set|s=s% + set-deploy|e=s% + set-revert|r=s% + log-only + y +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +ok my $sqitch = App::Sqitch->new( + config => TestConfig->new( + 'core.engine' => 'sqlite', + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, + 'core.top_dir' => dir(qw(t sql))->stringify, + ), +), 'Load a sqitch object'; + +my $config = $sqitch->config; + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure($config, {}), { + no_prompt => 0, + prompt_accept => 1, + verify => 0, + mode => 'all', + _params => [], + _cx => [], +}, 'Check default configuration'; + +is_deeply $CLASS->configure($config, { + set => { foo => 'bar' }, +}), { + verify => 0, + no_prompt => 0, + prompt_accept => 1, + mode => 'all', + deploy_variables => { foo => 'bar' }, + revert_variables => { foo => 'bar' }, + _params => [], + _cx => [], +}, 'Should have set option'; + +is_deeply $CLASS->configure($config, { + y => 1, + set_deploy => { foo => 'bar' }, + log_only => 1, + verify => 1, + mode => 'tag', +}), { + mode => 'tag', + no_prompt => 1, + prompt_accept => 1, + deploy_variables => { foo => 'bar' }, + verify => 1, + log_only => 1, + _params => [], + _cx => [], +}, 'Should have mode, deploy_variables, verify, no_prompt, and log_only'; + +is_deeply $CLASS->configure($config, { + y => 0, + set_revert => { foo => 'bar' }, +}), { + mode => 'all', + no_prompt => 0, + prompt_accept => 1, + verify => 0, + revert_variables => { foo => 'bar' }, + _params => [], + _cx => [], +}, 'Should have set_revert option and no_prompt false'; + +is_deeply $CLASS->configure($config, { + set => { foo => 'bar' }, + set_deploy => { foo => 'dep', hi => 'you' }, + set_revert => { foo => 'rev', hi => 'me' }, +}), { + mode => 'all', + no_prompt => 0, + prompt_accept => 1, + verify => 0, + deploy_variables => { foo => 'dep', hi => 'you' }, + revert_variables => { foo => 'rev', hi => 'me' }, + _params => [], + _cx => [], +}, 'set_deploy and set_revert should overrid set'; + +is_deeply $CLASS->configure($config, { + set => { foo => 'bar' }, + set_deploy => { hi => 'you' }, + set_revert => { hi => 'me' }, +}), { + mode => 'all', + no_prompt => 0, + prompt_accept => 1, + verify => 0, + deploy_variables => { foo => 'bar', hi => 'you' }, + revert_variables => { foo => 'bar', hi => 'me' }, + _params => [], + _cx => [], +}, 'set_deploy and set_revert should merge with set'; + +is_deeply $CLASS->configure($config, { + set => { foo => 'bar' }, + set_deploy => { hi => 'you' }, + set_revert => { my => 'yo' }, +}), { + mode => 'all', + no_prompt => 0, + prompt_accept => 1, + verify => 0, + deploy_variables => { foo => 'bar', hi => 'you' }, + revert_variables => { foo => 'bar', my => 'yo' }, + _params => [], + _cx => [], +}, 'set_revert should merge with set_deploy'; + +CONFIG: { + is_deeply $CLASS->configure($config, {}), { + no_prompt => 0, + prompt_accept => 1, + verify => 0, + mode => 'all', + _params => [], + _cx => [], + }, 'Should have deploy configuration'; + + # Try setting variables. + is_deeply $CLASS->configure($config, { + set => { foo => 'yo', yo => 'stellar' }, + }), { + mode => 'all', + no_prompt => 0, + prompt_accept => 1, + verify => 0, + deploy_variables => { foo => 'yo', yo => 'stellar' }, + revert_variables => { foo => 'yo', yo => 'stellar' }, + _params => [], + _cx => [], + }, 'Should have merged variables'; + + # Make sure we can override mode, prompting, and verify. + $config->replace( + 'core.engine' => 'sqlite', + 'revert.no_prompt' => 1, + 'revert.prompt_accept' => 0, + 'deploy.verify' => 1, + 'deploy.mode' => 'tag', + ); + is_deeply $CLASS->configure($config, {}), { + no_prompt => 1, + prompt_accept => 0, + verify => 1, + mode => 'tag', + _params => [], + _cx => [], + }, 'Should have no_prompt and prompt_accept from revert config'; + + # Checkout option takes precendence + $config->update( + 'checkout.no_prompt' => 0, + 'checkout.prompt_accept' => 1, + 'checkout.verify' => 0, + 'checkout.mode' => 'change', + ); + is_deeply $CLASS->configure($config, {}), { + no_prompt => 0, + prompt_accept => 1, + verify => 0, + mode => 'change', + _params => [], + _cx => [], + }, 'Should have false log_only, verify, true prompt_accept from checkout config'; + + $config->update( + 'checkout.no_prompt' => 1, + map { $_ => undef } qw( + revert.no_prompt + revert.prompt_accept + checkout.verify + checkout.mode + ) + ); + is_deeply $CLASS->configure($config, {}), { + no_prompt => 1, + prompt_accept => 1, + verify => 1, + mode => 'tag', + _params => [], + _cx => [], + }, 'Should have log_only, prompt_accept true from checkout and verify from deploy'; + + # But option should override. + is_deeply $CLASS->configure($config, {y => 0, verify => 0, mode => 'all'}), { + no_prompt => 0, + verify => 0, + mode => 'all', + prompt_accept => 1, + _params => [], + _cx => [], + }, 'Should have log_only false and mode all again'; + + $config->update( + 'checkout.no_prompt' => 0, + 'checkout.prompt_accept' => 1, + ); + is_deeply $CLASS->configure($config, {}), { + no_prompt => 0, + prompt_accept => 1, + verify => 1, + mode => 'tag', + _params => [], + _cx => [], + }, 'Should have log_only false for false config'; + + is_deeply $CLASS->configure($config, {y => 1}), { + no_prompt => 1, + prompt_accept => 1, + verify => 1, + mode => 'tag', + _params => [], + _cx => [], + }, 'Should have no_prompt true with -y'; +} + +############################################################################## +# Test _collect_deploy_vars and _collect_revert_vars. +$config->replace( + 'core.engine' => 'sqlite', + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, + 'core.top_dir' => dir(qw(t sql))->stringify, +); +my $checkout = $CLASS->new( sqitch => $sqitch); +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $checkout->_collect_deploy_vars($target) }, {}, + 'Should collect no variables for deploy'; +is_deeply { $checkout->_collect_revert_vars($target) }, {}, + 'Should collect no variables for revert'; + +# Add core variables. +$config->update('core.variables' => { prefix => 'widget', priv => 'SELECT' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $checkout->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'SELECT', +}, 'Should collect core deploy vars for deploy'; +is_deeply { $checkout->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'SELECT', +}, 'Should collect core revert vars for revert'; + +# Add deploy variables. +$config->update('deploy.variables' => { dance => 'salsa', priv => 'UPDATE' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $checkout->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'salsa', +}, 'Should override core vars with deploy vars for deploy'; + +is_deeply { $checkout->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'salsa', +}, 'Should override core vars with deploy vars for revert'; + +# Add revert variables. +$config->update('revert.variables' => { dance => 'disco', lunch => 'pizza' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $checkout->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'salsa', +}, 'Deploy vars should be unaffected by revert vars'; +is_deeply { $checkout->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'pizza', +}, 'Should override deploy vars with revert vars for revert'; + +# Add engine variables. +$config->update('engine.pg.variables' => { lunch => 'burrito', drink => 'whiskey', priv => 'UP' }); +my $uri = URI::db->new('db:pg:'); +$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => $uri); +is_deeply { $checkout->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'whiskey', +}, 'Should override deploy vars with engine vars for deploy'; +is_deeply { $checkout->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'whiskey', +}, 'Should override checkout vars with engine vars for revert'; + +# Add target variables. +$config->update('target.foo.variables' => { drink => 'scotch', status => 'winning' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $checkout->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'scotch', + status => 'winning', +}, 'Should override engine vars with deploy vars for deploy'; +is_deeply { $checkout->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'winning', +}, 'Should override engine vars with target vars for revert'; + +# Add --set variables. +my %opts = ( + set => { status => 'tired', herb => 'oregano' }, +); +$checkout = $CLASS->new( + sqitch => $sqitch, + %{ $CLASS->configure($config, { %opts }) }, +); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $checkout->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'oregano', +}, 'Should override target vars with --set vars for deploy'; +is_deeply { $checkout->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'oregano', +}, 'Should override target vars with --set variables for revert'; + +# Add --set-deploy-vars +$opts{set_deploy} = { herb => 'basil', color => 'black' }; +$checkout = $CLASS->new( + sqitch => $sqitch, + %{ $CLASS->configure($config, { %opts }) }, +); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $checkout->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'basil', + color => 'black', +}, 'Should override --set vars with --set-deploy variables for deploy'; +is_deeply { $checkout->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'oregano', +}, 'Should not override --set vars with --set-deploy variables for revert'; + +# Add --set-revert-vars +$opts{set_revert} = { herb => 'garlic', color => 'red' }; +$checkout = $CLASS->new( + sqitch => $sqitch, + %{ $CLASS->configure($config, { %opts }) }, +); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $checkout->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'basil', + color => 'black', +}, 'Should not override --set vars with --set-revert variables for deploy'; +is_deeply { $checkout->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'garlic', + color => 'red', +}, 'Should override --set vars with --set-revert variables for revert'; + +$config->replace( + 'core.engine' => 'sqlite', + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, + 'core.top_dir' => dir(qw(t sql))->stringify, +); + +############################################################################## +# Test execute(). +my $mock_sqitch = Test::MockModule->new(ref $sqitch); +my (@probe_args, $probed, $orig_method); +$mock_sqitch->mock(probe => sub { shift; @probe_args = @_; $probed }); +my $mock_cmd = Test::MockModule->new($CLASS); +$mock_cmd->mock(parse_args => sub { + my @ret = shift->$orig_method(@_); + $target = $ret[1][0]; + @ret; +}); +$orig_method = $mock_cmd->original('parse_args'); + +my @run_args; +$mock_sqitch->mock(run => sub { shift; @run_args = @_ }); + +# Try rebasing to the current branch. +isa_ok $checkout = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'checkout', + config => $config, +}), $CLASS, 'checkout command'; +my $client = $checkout->client; + +$probed = 'fixdupes'; +throws_ok { $checkout->execute($probed) } 'App::Sqitch::X', + 'Should get an error current branch'; +is $@->ident, 'checkout', 'Current branch error ident should be "checkout"'; +is $@->message, __x('Already on branch {branch}', branch => $probed), + 'Should get proper error for current branch error'; +is_deeply \@probe_args, [$client, qw(rev-parse --abbrev-ref HEAD)], + 'The proper args should have been passed to rev-parse'; +@probe_args = (); + +# Try a plan with nothing in common with the current branch's plan. +my (@capture_args, $captured); +$mock_sqitch->mock(capture => sub { shift; @capture_args = @_; $captured }); +$captured = q{%project=sql + +foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +}; + +throws_ok { $checkout->execute('master') } 'App::Sqitch::X', + 'Should get an error for plans without a common change'; +is $@->ident, 'checkout', + 'The no common change error ident should be "checkout"'; +is $@->message, __x( + 'Branch {branch} has no changes in common with current branch {current}', + branch => 'master', + current => $probed, +), 'The no common change error message should be correct'; + +# Mock the engine interface. +my $mock_engine = Test::MockModule->new('App::Sqitch::Engine::sqlite'); +my (@dep_args, @dep_changes); +$mock_engine->mock(deploy => sub { + @dep_changes = map { $_->name } shift->plan->changes; + @dep_args = @_; +}); + +my (@rev_args, @rev_changes); +$mock_engine->mock(revert => sub { + @rev_changes = map { $_->name } shift->plan->changes; + @rev_args = @_; + }); +my @vars; +$mock_engine->mock(set_variables => sub { shift; push @vars => [@_] }); + +# Load up the plan file without decoding and change the plan. +$captured = file(qw(t sql sqitch.plan))->slurp; +{ + no utf8; + $captured =~ s/widgets/thingíes/; +} + +# Checkout with options. +isa_ok $checkout = $CLASS->new( + log_only => 1, + verify => 1, + sqitch => $sqitch, + mode => 'tag', + deploy_variables => { foo => 'bar', one => 1 }, + revert_variables => { hey => 'there' }, +), $CLASS, 'Object with to and variables'; + +ok $checkout->execute('master'), 'Checkout master'; +is_deeply \@probe_args, [$client, qw(rev-parse --abbrev-ref HEAD)], + 'The proper args should again have been passed to rev-parse'; +is_deeply \@capture_args, [$client, 'show', 'master:' . $checkout->default_target->plan_file ], + + 'Should have requested the plan file contents as of master'; +is_deeply \@run_args, [$client, qw(checkout master)], 'Should have checked out other branch'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +is_deeply +MockOutput->get_info, [[__x( + 'Last change before the branches diverged: {last_change}', + last_change => 'users @alpha', +)]], 'Should have emitted info identifying the last common change'; + +# Did it revert? +is_deeply \@rev_args, [$checkout->default_target->plan->get('users')->id], + '"users" ID and 1 should be passed to the engine revert'; +is_deeply \@rev_changes, [qw(roles users widgets)], + 'Should have had the current changes for revision'; + +# Did it deploy? +is_deeply \@dep_args, [undef, 'tag'], + 'undef, "tag", and 1 should be passed to the engine deploy'; +is_deeply \@dep_changes, [qw(roles users thingíes)], + 'Should have had the other branch changes (decoded) for deploy'; + +ok $target->engine->with_verify, 'Engine should verify'; +ok $target->engine->log_only, 'The engine should be set to log_only'; +is @vars, 2, 'Variables should have been passed to the engine twice'; +is_deeply { @{ $vars[0] } }, { hey => 'there' }, + 'The revert vars should have been passed first'; +is_deeply { @{ $vars[1] } }, { foo => 'bar', one => 1 }, + 'The deploy vars should have been next'; + +# Try passing a target. +@vars = (); +ok $checkout->execute('master', 'db:sqlite:foo'), 'Checkout master with target'; +is $target->name, 'db:sqlite:foo', 'Target should be passed to engine'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# If nothing is deployed, or we are already at the revert target, the revert +# should be skipped. +isa_ok $checkout = $CLASS->new( + target => 'db:sqlite:hello', + log_only => 0, + verify => 0, + sqitch => $sqitch, + mode => 'tag', + deploy_variables => { foo => 'bar', one => 1 }, + revert_variables => { hey => 'there' }, +), $CLASS, 'Object with to and variables'; + +$mock_engine->mock(revert => sub { hurl { ident => 'revert', message => 'foo', exitval => 1 } }); +@dep_args = @rev_args = @vars = (); +ok $checkout->execute('master'), 'Checkout master again'; +is $target->name, 'db:sqlite:hello', 'Target should be passed to engine'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Did it deploy? +ok !$target->engine->log_only, 'The engine should not be set to log_only'; +ok !$target->engine->with_verify, 'The engine should not be set with_verfy'; +is_deeply \@dep_args, [undef, 'tag'], + 'undef, "tag", and 1 should be passed to the engine deploy again'; +is_deeply \@dep_changes, [qw(roles users thingíes)], + 'Should have had the other branch changes (decoded) for deploy again'; +is @vars, 2, 'Variables should again have been passed to the engine twice'; +is_deeply { @{ $vars[0] } }, { hey => 'there' }, + 'The revert vars should again have been passed first'; +is_deeply { @{ $vars[1] } }, { foo => 'bar', one => 1 }, + 'The deploy vars should again have been next'; + +# Should get a warning for two targets. +ok $checkout->execute('master', 'db:sqlite:'), 'Checkout master again with target'; +is $target->name, 'db:sqlite:hello', 'Target should be passed to engine'; +is_deeply +MockOutput->get_warn, [[__x( + 'Too many targets specified; connecting to {target}', + target => 'db:sqlite:hello', +)]], 'Should have warning about two targets'; + +# Make sure we get an exception for unknown args. +throws_ok { $checkout->execute(qw(master greg)) } 'App::Sqitch::X', + 'Should get an exception for unknown arg'; +is $@->ident, 'checkout', 'Unknow arg ident should be "checkout"'; +is $@->message, __x( + 'Unknown argument "{arg}"', + arg => 'greg', +), 'Should get an exeption for two unknown arg'; + +throws_ok { $checkout->execute(qw(master greg widgets)) } 'App::Sqitch::X', + 'Should get an exception for unknown args'; +is $@->ident, 'checkout', 'Unknow args ident should be "checkout"'; +is $@->message, __x( + 'Unknown arguments: {arg}', + arg => 'greg, widgets', +), 'Should get an exeption for two unknown args'; + +# Should die for fatal, unknown, or confirmation errors. +for my $spec ( + [ confirm => App::Sqitch::X->new(ident => 'revert:confirm', message => 'foo', exitval => 1) ], + [ fatal => App::Sqitch::X->new(ident => 'revert', message => 'foo', exitval => 2) ], + [ unknown => bless { } => __PACKAGE__ ], +) { + $mock_engine->mock(revert => sub { die $spec->[1] }); + throws_ok { $checkout->execute('master') } ref $spec->[1], + "Should rethrow $spec->[0] exception"; +} + +done_testing; diff --git a/t/command.t b/t/command.t new file mode 100644 index 00000000..1b2c85ba --- /dev/null +++ b/t/command.t @@ -0,0 +1,725 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More tests => 182; +#use Test::More 'no_plan'; +use Test::NoWarnings; +use List::Util qw(first); +use lib 't/lib'; +use TestConfig; + +my $catch_exit; +BEGIN { + $catch_exit = 0; + # Stub out exit. + *CORE::GLOBAL::exit = sub { + die 'EXITED: ' . (@_ ? shift : 0) if $catch_exit; + CORE::exit(@_); + }; +} + +use App::Sqitch; +use App::Sqitch::Target; +use Test::Exception; +use Test::NoWarnings; +use Test::MockModule; +use Locale::TextDomain qw(App-Sqitch); +use Capture::Tiny 0.12 ':all'; +use Path::Class; +use lib 't/lib'; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Command'; + use_ok $CLASS or die; +} + +can_ok $CLASS, qw( + load + class_for + create + new + options + configure + command + prompt + ask_y_n + parse_args + target_params + default_target +); + +COMMAND: { + # Stub out a couple of commands. + package App::Sqitch::Command::whu; + use Moo; + extends 'App::Sqitch::Command'; + has foo => (is => 'ro'); + has feathers => (is => 'ro'); + $INC{'App/Sqitch/Command/whu.pm'} = __FILE__; + + sub options { + return qw( + foo + hi-there|h + icky-foo! + feathers=s + ); + } + + package App::Sqitch::Command::wah_hoo; + use Moo; + extends 'App::Sqitch::Command'; + $INC{'App/Sqitch/Command/wah_hoo.pm'} = __FILE__; +} + +my $config = TestConfig->new; +ok my $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object'; + +############################################################################## +# Test new(). +throws_ok { $CLASS->new } + qr/\QMissing required arguments: sqitch/, + 'Should get an exception for missing sqitch param'; +my $array = []; +throws_ok { $CLASS->new({ sqitch => $array }) } + qr/\QReference [] did not pass type constraint "Sqitch"/, + 'Should get an exception for array sqitch param'; +throws_ok { $CLASS->new({ sqitch => 'foo' }) } + qr/\QValue "foo" did not pass type constraint "Sqitch"/, + 'Should get an exception for string sqitch param'; + +isa_ok $CLASS->new({sqitch => $sqitch}), $CLASS; + +############################################################################## +# Test configure. +my $subclass = 'App::Sqitch::Command::whu'; +is_deeply $subclass->configure($config, {}), {}, + 'Should get empty hash for no config or options'; +$config->update('whu.foo' => 'hi'); +is_deeply $subclass->configure($config, {}), {foo => 'hi'}, + 'Should get config with no options'; +is_deeply $subclass->configure($config, {foo => 'yo'}), {foo => 'yo'}, + 'Options should override config'; +is_deeply $subclass->configure($config, {'foo_bar' => 'yo'}), + {foo => 'hi', foo_bar => 'yo'}, + 'Options keys should have dashes changed to underscores'; + +############################################################################## +# Test class_for(). +is $CLASS->class_for($sqitch, 'whu'), 'App::Sqitch::Command::whu', + 'Should find class for "whu"'; +is $CLASS->class_for($sqitch, 'wah-hoo'), 'App::Sqitch::Command::wah_hoo', + 'Should find class for "wah-hoo"'; +is $CLASS->class_for($sqitch, 'help'), 'App::Sqitch::Command::help', + 'Should find class for "help"'; + +# Make sure it logs debugging for unkonwn classes. +DEBUG: { + my $smock = Test::MockModule->new('App::Sqitch'); + my $debug; + $smock->mock(debug => sub { $debug = $_[1] }); + is $CLASS->class_for($sqitch, '_nonesuch'), undef, + 'Should find no class for "_nonesush"'; + like $debug, qr{^Can't locate App/Sqitch/Command/_nonesuch\.pm in \@INC}, + 'Should have sent error to debug'; +} + +############################################################################## +# Test load(). +ok $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object'; +ok my $cmd = $CLASS->load({ + command => 'whu', + sqitch => $sqitch, + config => $config, + args => [] +}), 'Load a "whu" command'; +isa_ok $cmd, 'App::Sqitch::Command::whu'; +is $cmd->sqitch, $sqitch, 'The sqitch attribute should be set'; +is $cmd->command, 'whu', 'The command method should return "whu"'; + +$config->update('whu.foo' => 'hi'); +ok $cmd = $CLASS->load({ + command => 'whu', + sqitch => $sqitch, + config => $config, + args => [] +}), 'Load a "whu" command with "foo" config'; +is $cmd->foo, 'hi', 'The "foo" attribute should be set'; + +# Test handling of nonexistent commands. +throws_ok { $CLASS->load({ command => 'nonexistent', sqitch => $sqitch }) } + 'App::Sqitch::X', 'Should exit'; +is $@->ident, 'command', 'Nonexistent command error ident should be "config"'; +is $@->message, __x( + '"{command}" is not a valid command', + command => 'nonexistent', +), 'Should get proper mesage for nonexistent command'; +is $@->exitval, 1, 'Nonexistent command should yield exitval of 1'; + +# Test command that evals to a syntax error. +throws_ok { + local $SIG{__WARN__} = sub { } if $] < 5.11; # Warns on 5.10. + $CLASS->load({ command => 'foo.bar', sqitch => $sqitch }) +} 'App::Sqitch::X', 'Should die on bad command'; +is $@->ident, 'command', 'Bad command error ident should be "config"'; +is $@->message, __x( + '"{command}" is not a valid command', + command => 'foo.bar', +), 'Should get proper mesage for bad command'; +is $@->exitval, 1, 'Bad command should yield exitval of 1'; + +NOCOMMAND: { + # Test handling of no command. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; die 'USAGE' }); + throws_ok { $CLASS->load({ command => '', sqitch => $sqitch }) } + qr/USAGE/, 'No command should yield usage'; + is_deeply \@args, [$CLASS], 'No args should be passed to usage'; +} + +# Test handling a bad command implementation. +throws_ok { $CLASS->load({ command => 'bad', sqitch => $sqitch }) } + 'App::Sqitch::X', 'Should die on broken command module'; +is $@->ident, 'command', 'Broken command error ident should be "config"'; +is $@->message, __x( + '"{command}" is not a valid command', + command => 'bad', +), 'Should get proper mesage for broken command'; +is $@->exitval, 1, 'Broken command should yield exitval of 1'; + +# Test options processing. +$config->update('whu.feathers' => 'yes'); +ok $cmd = $CLASS->load({ + command => 'whu', + sqitch => $sqitch, + config => $config, + args => ['--feathers' => 'no'] +}), 'Load a "whu" command with "--feathers" option'; +is $cmd->feathers, 'no', 'The "feathers" attribute should be set'; + +# Test command with a dash in its name. +ok $cmd = $CLASS->load({ + command => 'wah-hoo', + sqitch => $sqitch, + config => $config, +}), 'Load a "wah-hoo" command'; +isa_ok $cmd, "$CLASS\::wah_hoo", 'It'; +is $cmd->command, 'wah-hoo', 'command() should return hyphenated name'; + +############################################################################## +# Test create(). +my $pkg = $CLASS . '::whu'; +$config->replace; +ok $cmd = $pkg->create({ + sqitch => $sqitch, + config => $config, + args => [] +}), 'Create a "whu" command'; +isa_ok $cmd, 'App::Sqitch::Command::whu'; +is $cmd->sqitch, $sqitch, 'The sqitch attribute should be set'; +is $cmd->command, 'whu', 'The command method should return "whu"'; + +# Test config merging. +$config->update('whu.foo' => 'hi'); +ok $cmd = $pkg->create({ + sqitch => $sqitch, + config => $config, + args => [] +}), 'Create a "whu" command with "foo" config'; +is $cmd->foo, 'hi', 'The "foo" attribute should be set'; + +# Test options processing. +$config->update('whu.feathers' => 'yes'); +ok $cmd = $pkg->create({ + sqitch => $sqitch, + config => $config, + args => ['--feathers' => 'no'] +}), 'Create a "whu" command with "--feathers" option'; +is $cmd->feathers, 'no', 'The "feathers" attribute should be set'; + +############################################################################## +# Test default_target. +ok $cmd = $CLASS->new({ sqitch => $sqitch }), "Create an $CLASS object"; +isa_ok my $target = $cmd->default_target, 'App::Sqitch::Target', + 'default target'; +is $target->name, 'db:', 'Default target name should be "db:"'; +is $target->uri, URI->new('db:'), 'Default target URI should be "db:"'; + +# Track what gets passed to Config->get(). +my (@get_keys, $orig_get); +my $cmock = TestConfig->mock(get => sub { + my ($self, %p) = @_; + push @get_keys => $p{key}; + $orig_get->($self, %p); +}); +$orig_get = $cmock->original('get'); + +# Make sure the core.engine config option gets used. +$config->update('core.engine' => 'sqlite'); +ok $cmd = $CLASS->new({ sqitch => $sqitch }), "Create an $CLASS object"; +isa_ok $target = $cmd->default_target, 'App::Sqitch::Target', + 'default target'; +is $target->name, 'db:sqlite:', 'Default target name should be "db:sqlite:"'; +is $target->uri, URI->new('db:sqlite:'), 'Default target URI should be "db:sqlite:"'; +is_deeply \@get_keys, + [qw(core.engine core.target core.engine engine.sqlite.target)], + 'Should have fetched config stuff'; + +# We should get stuff from the engine section of the config. +$config->update( + 'core.engine' => 'pg', + 'engine.pg.target' => 'db:pg:foo', +); +@get_keys = (); +ok $cmd = $CLASS->new({ sqitch => $sqitch }), "Create an $CLASS object"; +isa_ok $target = $cmd->default_target, 'App::Sqitch::Target', + 'default target'; +is $target->name, 'db:pg:foo', 'Default target name should be "db:pg:foo"'; +is $target->uri, URI->new('db:pg:foo'), 'Default target URI should be "db:pg:foo"'; +is_deeply \@get_keys, + [qw(core.engine core.target core.engine engine.pg.target)], + 'Should have fetched config stuff again'; + +# Cleanup. +$cmock->unmock('get'); + +############################################################################## +# Test command and execute. +can_ok $CLASS, 'execute'; +ok $cmd = $CLASS->new({ sqitch => $sqitch }), "Create an $CLASS object"; +is $CLASS->command, '', 'Base class command should be ""'; +is $cmd->command, '', 'Base object command should be ""'; +throws_ok { $cmd->execute } 'App::Sqitch::X', + 'Should get an error calling execute on command base class'; +is $@->ident, 'DEV', 'Execute exception ident should be "DEV"'; +is $@->message, "The execute() method must be called from a subclass of $CLASS", + 'The execute() error message should be correct'; + +ok $cmd = App::Sqitch::Command::whu->new({sqitch => $sqitch}), + 'Create a subclass command object'; +is $cmd->command, 'whu', 'Subclass oject command should be "whu"'; +is +App::Sqitch::Command::whu->command, 'whu', 'Subclass class command should be "whu"'; +throws_ok { $cmd->execute } 'App::Sqitch::X', + 'Should get an error for un-overridden execute() method'; +is $@->ident, 'DEV', 'Un-overidden execute() exception ident should be "DEV"'; +is $@->message, "The execute() method has not been overridden in $CLASS\::whu", + 'The unoverridden execute() error message should be correct'; + +############################################################################## +# Test options parsing. +can_ok $CLASS, 'options', '_parse_opts'; +ok $cmd = $CLASS->new({ sqitch => $sqitch }), "Create an $CLASS object again"; +is_deeply $cmd->_parse_opts, {}, 'Base _parse_opts should return an empty hash'; + +ok $cmd = App::Sqitch::Command::whu->new({sqitch => $sqitch}), + 'Create a subclass command object again'; +is_deeply $cmd->_parse_opts, {}, 'Subclass should return an empty hash for no args'; + +is_deeply $cmd->_parse_opts([1]), {}, 'Subclass should use options spec'; +my $args = [qw( + --foo + --h + --no-icky-foo + --feathers down + whatever +)]; +is_deeply $cmd->_parse_opts($args), { + 'foo' => 1, + 'hi_there' => 1, + 'icky_foo' => 0, + 'feathers' => 'down', +}, 'Subclass should parse options spec'; +is_deeply $args, ['whatever'], 'Args array should be cleared of options'; + +PARSEOPTSERR: { + # Make sure that invalid options trigger an error. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; }); + my @warn; local $SIG{__WARN__} = sub { @warn = @_ }; + $cmd->_parse_opts(['--dont-do-this']); + is_deeply \@warn, ["Unknown option: dont-do-this\n"], + 'Should get warning for unknown option'; + is_deeply \@args, [$cmd], 'Should call _pod2usage on options parse failure'; + + # Try it with a command with no options. + @args = @warn = (); + isa_ok $cmd = App::Sqitch::Command->load({ + command => 'good', + sqitch => $sqitch, + config => $config, + }), 'App::Sqitch::Command::good', 'Good command object'; + $cmd->_parse_opts(['--dont-do-this']); + is_deeply \@warn, ["Unknown option: dont-do-this\n"], + 'Should get warning for unknown option when there are no options'; + is_deeply \@args, [$cmd], 'Should call _pod2usage on no options parse failure'; +} + +############################################################################## +# Test target_params. +is_deeply [$cmd->target_params], [sqitch => $sqitch], + 'Should get sqitch param from target_params'; + +############################################################################## +# Test argument parsing. +ARGS: { + my $config = TestConfig->from(local => file qw(t local.conf) ); + $config->update( + 'core.engine' => 'sqlite', + 'core.plan_file' => file(qw(t plans multi.plan))->stringify, + 'core.top_dir' => dir(qw(t sql))->stringify + ); + ok $sqitch = App::Sqitch->new(config => $config), + 'Load Sqitch with config and plan'; + + ok my $cmd = $CLASS->load({ + sqitch => $sqitch, + config => $config, + command => 'whu', + }), 'Load cmd with config and plan'; + my $parsem = sub { + my @ret = $cmd->parse_args(@_); + # Targets are always second to last. + $ret[-2] = [ map { $_->name } @{ $ret[-2] } ]; + return \@ret; + }; + + my $msg = sub { + __nx( + 'Unknown argument "{arg}"', + 'Unknown arguments: {arg}', + scalar @_, + arg => join ', ', @_ + ) + }; + + is_deeply $parsem->(), [['devdb'], []], + 'Parsing no args should return default target'; + throws_ok { $parsem->( args => ['foo'] ) } 'App::Sqitch::X', + 'Single unknown arg raise an error'; + is $@->ident, 'whu', 'Unknown error ident should be "whu"'; + is $@->message, $msg->('foo'), 'Unknown error message should be correct'; + is_deeply $parsem->( args => ['hey'] ), [['devdb'], ['hey']], + 'Single change should be recognized as change'; + is_deeply $parsem->( args => ['devdb'] ), [['devdb'], []], + 'Single target should be recognized as target'; + is_deeply $parsem->(args => ['db:pg:']), [['db:pg:'], []], + 'URI target should be recognized as target, too'; + is_deeply $parsem->(args => ['devdb', 'hey']), [['devdb'], ['hey']], + 'Target and change should be recognized'; + is_deeply $parsem->(args => ['hey', 'devdb']), [['devdb'], ['hey']], + 'Change and target should be recognized'; + is_deeply $parsem->(args => ['mydb', 'users']), [['mydb'], ['users']], + 'Alternate Target and change should be recognized'; + is_deeply $parsem->(args => ['hey', 'mydb']), [['mydb'], ['hey']], + 'Change and alternate target should be recognized'; + is_deeply $parsem->(args => ['hey', 'devdb', 'foo'], names => [undef]), + ['foo', ['devdb'], ['hey']], + 'Change, target, and unknown name should be recognized'; + is_deeply $parsem->(args => ['hey', 'devdb', 'foo', 'hey-there'], names => [0]), + ['foo', ['devdb'], ['hey', 'hey-there']], + 'Multiple changes, target, and unknown name should be recognized'; + is_deeply $parsem->(args => ['yuck', 'hey', 'devdb', 'foo'], names => [0, 0]), + ['yuck', 'foo', ['devdb'], ['hey']], + 'Multiple names should be recognized'; + throws_ok { + $parsem->(args => ['yuck', 'hey', 'devdb'], names => ['hi']); + } 'App::Sqitch::X', 'Should get an error with name and unknown'; + is $@->ident, 'whu', 'Unknown error ident should be "whu"'; + is $@->message, $msg->('yuck'), 'Unknown error message should be correct'; + throws_ok { + $parsem->(args => ['yuck', 'hey', 'devdb', 'foo'], names => ['hi']); + } 'App::Sqitch::X', 'Should get an error with name and two unknowns'; + is $@->ident, 'whu', 'Two unknowns error ident should be "whu"'; + is $@->message, $msg->('yuck', 'foo'), + 'Two unknowns error message should be correct'; + + # Make sure changes are found in previously-passed target. + $config->update('core.top_dir' => dir(qw(t sql))->stringify); + ok $sqitch = App::Sqitch->new(config => $config), + 'Load Sqitch with config'; + ok $cmd = $CLASS->load({ + sqitch => $sqitch, + command => 'whu', + config => $config, + }), 'Load cmd with config'; + is_deeply $parsem->(args => ['mydb', 'add_user']), + [['mydb'], ['add_user']], + 'Change following target should be recognized from target plan'; + + # Now pass a target. + is_deeply $parsem->(target => 'devdb'), [['devdb'], []], + 'Passed target should always be returned'; + is_deeply $parsem->(target => 'devdb', args => ['mydb']), + [['devdb', 'mydb'], []], + 'Passed and specified targets should always be returned'; + throws_ok { + $parsem->(target => 'devdb', args => ['users']) + } 'App::Sqitch::X', 'Change unknown to passed target should error'; + is $@->ident, 'whu', 'Change unknown error ident should be "whu"'; + is $@->message, $msg->('users'), + 'Change unknown error message should be correct'; + + $config->update('core.plan_file' => undef); + is_deeply $parsem->(args => ['sqlite', 'widgets', '@beta']), + [['devdb'], ['widgets', '@beta']], + 'Should get known changes from default target (t/sql/sqitch.plan)'; + throws_ok { + $parsem->(args => ['sqlite', 'widgets', 'mydb', 'foo', '@beta']); + } 'App::Sqitch::X', 'Change seen after target should error if not in that target'; + is $@->ident, 'whu', 'Change after target error ident should be "whu"'; + is $@->message, $msg->('foo', '@beta'), + 'Change after target error message should be correct'; + + # Make sure a plan file name is recognized as pointing to a target. + is_deeply $parsem->(args => [file(qw(t plans dependencies.plan))->stringify]), + [['mydb'], []], 'Should resolve plan file to a target'; + + # Should work for default plan file, too. + is_deeply $parsem->(args => [file(qw(t sql sqitch.plan))->stringify]), + [['devdb'], []], 'SHould resolve default plan file to target'; + + # Should also recognize an engine argument. + is_deeply $parsem->(args => ['pg']), [['mydb'], []], + 'Should resolve engine "pg" file to its target'; + + is_deeply $parsem->(args => ['sqlite']), [['devdb'], []], + 'Should resolve engine "sqlite" file to its target'; + + # Try a bad target. + throws_ok { + $parsem->(args => [target => 'db:']); + } 'App::Sqitch::X', 'Bad target should trigger error'; + is $@->ident, 'target', 'Bad target error ident should be "target"'; + is $@->message, __x( + 'No engine specified by URI {uri}; URI must start with "db:$engine:"', + uri => 'db:', + ), 'Should have bad target error message'; + + # Make sure we don't get an error when the default target has no plan file. + NOPLAN: { + my $mock_target = Test::MockModule->new('App::Sqitch::Target'); + $mock_target->mock(plan_file => file 'no-such-file.txt'); + is_deeply $parsem->( args => ['devdb'] ), [['devdb'], []], + 'Should recognize target when default target has no plan file'; + } + + # Make sure we get an error when no engine is specified. + NOENGINE: { + my $config = TestConfig->new( + 'core.plan_file' => file(qw(t plans multi.plan))->stringify, + 'core.top_dir' => dir(qw(t sql))->stringify, + ); + ok $sqitch = App::Sqitch->new(config => $config), + 'Load Sqitch without engine'; + + ok $cmd = $CLASS->load({ + sqitch => $sqitch, + config => $config, + command => 'whu', + }), 'Load cmd without engine'; + throws_ok { $parsem->() } 'App::Sqitch::X', + 'Should have error for no engine or target'; + is $@->ident, 'target', 'Should have target ident'; + is $@->message, __( + 'No project configuration found. Run the "init" command to initialize a project', + ), 'Should have message about no config'; + + # But it should be okay if we pass an engine or valid target. + is_deeply $parsem->(args => ['pg']), + [['db:pg:'], []], + 'Engine arg should override core target error'; + is_deeply $parsem->(args => ['db:sqlite:foo']), + [['db:sqlite:foo'], []], + 'Target arg should override core target error'; + } +} + +############################################################################## +# Test _pod2usage(). +POD2USAGE: { + my $mock = Test::MockModule->new('Pod::Usage'); + my %args; + $mock->mock(pod2usage => sub { %args = @_} ); + $cmd = $CLASS->new({ sqitch => $sqitch }); + ok $cmd->_pod2usage, 'Call _pod2usage on base object'; + is_deeply \%args, { + '-verbose' => 99, + '-sections' => '(?i:(Usage|Synopsis|Options))', + '-exitval' => 2, + '-input' => Pod::Find::pod_where({'-inc' => 1}, 'sqitch'), + }, 'Default params should be passed to Pod::Usage'; + + $cmd = App::Sqitch::Command::whu->new({ sqitch => $sqitch }); + ok $cmd->_pod2usage, 'Call _pod2usage on "whu" command object'; + is_deeply \%args, { + '-verbose' => 99, + '-sections' => '(?i:(Usage|Synopsis|Options))', + '-exitval' => 2, + '-input' => Pod::Find::pod_where({'-inc' => 1}, 'sqitch'), + }, 'Default params should be passed to Pod::Usage'; + + isa_ok $cmd = App::Sqitch::Command->load({ + command => 'config', + sqitch => $sqitch, + config => $config, + }), 'App::Sqitch::Command::config', 'Config command object'; + ok $cmd->_pod2usage, 'Call _pod2usage on "config" command object'; + is_deeply \%args, { + '-verbose' => 99, + '-sections' => '(?i:(Usage|Synopsis|Options))', + '-exitval' => 2, + '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitch-config'), + }, 'Should find sqitch-config docs to pass to Pod::Usage'; + + isa_ok $cmd = App::Sqitch::Command->load({ + command => 'good', + sqitch => $sqitch, + config => $config, + }), 'App::Sqitch::Command::good', 'Good command object'; + ok $cmd->_pod2usage, 'Call _pod2usage on "good" command object'; + is_deeply \%args, { + '-verbose' => 99, + '-sections' => '(?i:(Usage|Synopsis|Options))', + '-exitval' => 2, + '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitch'), + }, 'Should find App::Sqitch::Command::good docs to pass to Pod::Usage'; + + # Test usage(), too. + can_ok $cmd, 'usage'; + $cmd->usage('Hello ', 'gorgeous'); + is_deeply \%args, { + '-verbose' => 99, + '-sections' => '(?i:(Usage|Synopsis|Options))', + '-exitval' => 2, + '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitch'), + '-message' => 'Hello gorgeous', + }, 'Should find App::Sqitch::Command::good docs to pass to Pod::Usage'; +} + +############################################################################## +# Test verbosity. +can_ok $CLASS, 'verbosity'; +is $cmd->verbosity, $sqitch->verbosity, 'Verbosity should be from sqitch'; +$sqitch->{verbosity} = 3; +is $cmd->verbosity, $sqitch->verbosity, 'Verbosity should change with sqitch'; + +############################################################################## +# Test message levels. Start with trace. +$sqitch->{verbosity} = 3; +is capture_stdout { $cmd->trace('This ', "that\n", 'and the other') }, + "trace: This that\ntrace: and the other\n", + 'trace should work'; +$sqitch->{verbosity} = 2; +is capture_stdout { $cmd->trace('This ', "that\n", 'and the other') }, + '', 'Should get no trace output for verbosity 2'; + +# Trace literal. +$sqitch->{verbosity} = 3; +is capture_stdout { $cmd->trace_literal('This ', "that\n", 'and the other') }, + "trace: This that\ntrace: and the other", + 'trace_literal should work'; +$sqitch->{verbosity} = 2; +is capture_stdout { $cmd->trace_literal('This ', "that\n", 'and the other') }, + '', 'Should get no trace_literal output for verbosity 2'; + +# Debug. +$sqitch->{verbosity} = 2; +is capture_stdout { $cmd->debug('This ', "that\n", 'and the other') }, + "debug: This that\ndebug: and the other\n", + 'debug should work'; +$sqitch->{verbosity} = 1; +is capture_stdout { $cmd->debug('This ', "that\n", 'and the other') }, + '', 'Should get no debug output for verbosity 1'; + +# Debug literal. +$sqitch->{verbosity} = 2; +is capture_stdout { $cmd->debug_literal('This ', "that\n", 'and the other') }, + "debug: This that\ndebug: and the other", + 'debug_literal should work'; +$sqitch->{verbosity} = 1; +is capture_stdout { $cmd->debug_literal('This ', "that\n", 'and the other') }, + '', 'Should get no debug_literal output for verbosity 1'; + +# Info. +$sqitch->{verbosity} = 1; +is capture_stdout { $cmd->info('This ', "that\n", 'and the other') }, + "This that\nand the other\n", + 'info should work'; +$sqitch->{verbosity} = 0; +is capture_stdout { $cmd->info('This ', "that\n", 'and the other') }, + '', 'Should get no info output for verbosity 0'; + +# Info literal. +$sqitch->{verbosity} = 1; +is capture_stdout { $cmd->info_literal('This ', "that\n", 'and the other') }, + "This that\nand the other", + 'info_literal should work'; +$sqitch->{verbosity} = 0; +is capture_stdout { $cmd->info_literal('This ', "that\n", 'and the other') }, + '', 'Should get no info_literal output for verbosity 0'; + +# Comment. +$sqitch->{verbosity} = 1; +is capture_stdout { $cmd->comment('This ', "that\n", 'and the other') }, + "# This that\n# and the other\n", + 'comment should work'; +$sqitch->{verbosity} = 0; +is capture_stdout { $sqitch->comment('This ', "that\n", 'and the other') }, + "# This that\n# and the other\n", + 'comment should work with verbosity 0'; + +# Comment literal. +$sqitch->{verbosity} = 1; +is capture_stdout { $cmd->comment_literal('This ', "that\n", 'and the other') }, + "# This that\n# and the other", + 'comment_literal should work'; +$sqitch->{verbosity} = 0; +is capture_stdout { $sqitch->comment_literal('This ', "that\n", 'and the other') }, + "# This that\n# and the other", + 'comment_literal should work with verbosity 0'; + +# Emit. +is capture_stdout { $cmd->emit('This ', "that\n", 'and the other') }, + "This that\nand the other\n", + 'emit should work'; +$sqitch->{verbosity} = 0; +is capture_stdout { $cmd->emit('This ', "that\n", 'and the other') }, + "This that\nand the other\n", + 'emit should work even with verbosity 0'; + +# Emit literal. +is capture_stdout { $cmd->emit_literal('This ', "that\n", 'and the other') }, + "This that\nand the other", + 'emit_literal should work'; +$sqitch->{verbosity} = 0; +is capture_stdout { $cmd->emit_literal('This ', "that\n", 'and the other') }, + "This that\nand the other", + 'emit_literal should work even with verbosity 0'; + +# Warn. +is capture_stderr { $cmd->warn('This ', "that\n", 'and the other') }, + "warning: This that\nwarning: and the other\n", + 'warn should work'; + +# Warn literal. +is capture_stderr { $cmd->warn_literal('This ', "that\n", 'and the other') }, + "warning: This that\nwarning: and the other", + 'warn_literal should work'; + +# Usage. +$catch_exit = 1; +like capture_stderr { + throws_ok { $cmd->usage('Invalid whozit') } qr/EXITED: 2/ +}, qr/Invalid whozit/, 'usage should work'; + +like capture_stderr { + throws_ok { $cmd->usage('Invalid whozit') } qr/EXITED: 2/ +}, qr/\Qsqitch <command> [options] [command-options] [args]/, + 'usage should prefer sqitch-$command-usage'; diff --git a/t/config.t b/t/config.t new file mode 100644 index 00000000..8c45166e --- /dev/null +++ b/t/config.t @@ -0,0 +1,1122 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use Test::More tests => 346; +#use Test::More 'no_plan'; +use File::Spec; +use Test::MockModule; +use Test::Exception; +use Test::NoWarnings; +use Test::Warn; +use Path::Class; +use File::Path qw(remove_tree); +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use lib 't/lib'; +use TestConfig; + +my $CLASS; +BEGIN { + $CLASS = 'App::Sqitch::Command::config'; + use_ok $CLASS or die; +} + +my $config = TestConfig->new; +ok my $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object'; +isa_ok my $cmd = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'config', + config => $config, +}), 'App::Sqitch::Command::config', 'Config command'; + +isa_ok $cmd, 'App::Sqitch::Command', 'Config command'; +can_ok $cmd, qw(file action context get get_all get_regex set add unset unset_all list edit); + +is_deeply [$cmd->options], [qw( + file|config-file|f=s + local + user|global + system + int + bool + bool-or-int + num + get + get-all + get-regex|get-regexp + add + replace-all + unset + unset-all + rename-section + remove-section + list|l + edit|e +)], 'Options should be configured'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +############################################################################## +# Test configure errors. +my $mock = Test::MockModule->new('App::Sqitch::Command::config'); +my @usage; +$mock->mock(usage => sub { shift; @usage = @_; die 'USAGE' }); + +# Test for multiple config file specifications. +throws_ok { $CLASS->configure( $sqitch->config, { + user => 1, + system => 1, +}) } qr/USAGE/, 'Construct with user and system'; +is_deeply \@usage, ['Only one config file at a time.'], + 'Should get error for multiple config files'; + +throws_ok { $CLASS->configure( $sqitch->config, { + user => 1, + local => 1, +}) } qr/USAGE/, 'Construct with user and local'; +is_deeply \@usage, ['Only one config file at a time.'], + 'Should get error for multiple config files'; + +throws_ok { $CLASS->configure( $sqitch->config, { + file => 't/sqitch.ini', + system => 1, +})} qr/USAGE/, 'Construct with file and system'; +is_deeply \@usage, ['Only one config file at a time.'], + 'Should get another error for multiple config files'; + +throws_ok { $CLASS->configure( $sqitch->config, { + file => 't/sqitch.ini', + user => 1, +})} qr/USAGE/, 'Construct with file and user'; +is_deeply \@usage, ['Only one config file at a time.'], + 'Should get a third error for multiple config files'; + +throws_ok { $CLASS->configure( $sqitch->config, { + file => 't/sqitch.ini', + user => 1, + system => 1, +})} qr/USAGE/, 'Construct with file, system, and user'; +is_deeply \@usage, ['Only one config file at a time.'], + 'Should get one last error for multiple config files'; + +# Test for multiple type specifications. +throws_ok { $CLASS->configure( $sqitch->config, { + bool => 1, + num => 1, +}) } qr/USAGE/, 'Construct with bool and num'; +is_deeply \@usage, ['Only one type at a time.'], + 'Should get error for multiple types'; + +throws_ok { $CLASS->configure( $sqitch->config, { + sqitch => $sqitch, + int => 1, + num => 1, +})} qr/USAGE/, 'Construct with int and num'; +is_deeply \@usage, ['Only one type at a time.'], + 'Should get another error for multiple types'; + +throws_ok { $CLASS->configure( $sqitch->config, { + int => 1, + bool => 1, +})} qr/USAGE/, 'Construct with int and bool'; +is_deeply \@usage, ['Only one type at a time.'], + 'Should get a third error for multiple types'; + +throws_ok { $CLASS->configure( $sqitch->config, { + int => 1, + bool => 1, + num => 1, +})} qr/USAGE/, 'Construct with int, num, and bool'; +is_deeply \@usage, ['Only one type at a time.'], + 'Should get one last error for multiple types'; + +# Test for multiple action specifications. +for my $spec ( + [qw(get unset)], + [qw(get unset edit)], + [qw(get unset edit list)], + [qw(unset edit)], + [qw(unset edit list)], + [qw(edit list)], + [qw(edit add list)], + [qw(edit add list get_all)], + [qw(edit add list get_regex)], + [qw(edit add list unset_all)], + [qw(edit add list get_all unset_all)], + [qw(edit list remove_section)], + [qw(edit list remove_section rename_section)], +) { + throws_ok { $CLASS->configure( $sqitch->config, { + map { $_ => 1 } @{ $spec } + })} qr/USAGE/, 'Construct with ' . join ' & ' => @{ $spec }; + is_deeply \@usage, ['Only one action at a time.'], + 'Should get error for multiple actions'; +} + +############################################################################## +# Test context. +is $cmd->file, $sqitch->config->dir_file, + 'Default context should be local context'; +is $cmd->action, undef, 'Default action should be undef'; +is $cmd->context, undef, 'Default context should be undef'; + +# Test local file name. +is_deeply $CLASS->configure( $sqitch->config, { + local => 1, +}), { + context => 'local', +}, 'Local context should be local'; + +# Test user file name. +is_deeply $CLASS->configure( $sqitch->config, { + user => 1, +}), { + context => 'user', +}, 'User context should be user'; + +# Test system file name. +is_deeply $CLASS->configure( $sqitch->config, { + system => 1, +}), { + context => 'system', +}, 'System context should be system'; + +############################################################################## +# Test execute(). +my @fail; +$mock->mock(fail => sub { shift; @fail = @_; die "FAIL @_" }); +my @set; +$mock->mock(set => sub { shift; @set = @_; return 1 }); +my @get; +$mock->mock(get => sub { shift; @get = @_; return 1 }); +my @get_all; +$mock->mock(get_all => sub { shift; @get_all = @_; return 1 }); +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'system', +}), 'Create config set command'; + +ok $cmd->execute(qw(foo bar)), 'Execute the set command'; +is_deeply \@set, [qw(foo bar)], 'The set method should have been called'; +ok $cmd->execute(qw(foo)), 'Execute the get command'; +is_deeply \@get, [qw(foo)], 'The get method should have been called'; + +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get_all', +}), 'Create config get_all command'; +$cmd->execute('boy.howdy'); +is_deeply \@get_all, ['boy.howdy'], + 'An action with a dash should have triggered a method with an underscore'; +$mock->unmock(qw(set get get_all)); + +############################################################################## +# Test get(). +chdir 't'; +$config = TestConfig->from(local => 'sqitch.conf', user => 'user.conf'); +$sqitch = App::Sqitch->new(config => $config); +my @emit; +$mock->mock(emit => sub { shift; push @emit => [@_] }); +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get', +}), 'Create config get command'; + +ok $cmd->execute('core.engine'), 'Get core.engine'; +is_deeply \@emit, [['pg']], 'Should have emitted the merged core.engine'; +@emit = (); + +ok $cmd->execute('engine.pg.registry'), 'Get engine.pg.registry'; +is_deeply \@emit, [['meta']], 'Should have emitted the merged engine.pg.registry'; +@emit = (); + +ok $cmd->execute('engine.pg.client'), 'Get engine.pg.client'; +is_deeply \@emit, [['/usr/local/pgsql/bin/psql']], + 'Should have emitted the merged engine.pg.client'; +@emit = (); + +# Make sure the key is required. +throws_ok { $cmd->get } qr/USAGE/, 'Should get usage for missing get key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the missing get key should trigger a usage message'; +throws_ok { $cmd->get('') } qr/USAGE/, 'Should get usage for invalid get key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the invalid get key should trigger a usage message'; + +# Make sure int data type works. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get', + type => 'int', +}), 'Create config get int command'; + +ok $cmd->execute('revert.count'), 'Get revert.count as int'; +is_deeply \@emit, [[2]], + 'Should have emitted the revert count'; +@emit = (); + +ok $cmd->execute('revert.revision'), 'Get revert.revision as int'; +is_deeply \@emit, [[1]], + 'Should have emitted the revert revision as an int'; +@emit = (); + +throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X', + 'Get bundle.tags_only as an int should fail'; +is $@->ident, 'config', 'Int cast exception ident should be "config"'; + +# Make sure num data type works. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get', + type => 'num', +}), 'Create config get num command'; + +ok $cmd->execute('revert.count'), 'Get revert.count as num'; +is_deeply \@emit, [[2]], + 'Should have emitted the revert count'; +@emit = (); + +ok $cmd->execute('revert.revision'), 'Get revert.revision as num'; +is_deeply \@emit, [[1.1]], + 'Should have emitted the revert revision as an num'; +@emit = (); + +throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X', + 'Get bundle.tags_only as an num should fail'; +is $@->ident, 'config', 'Num cast exception ident should be "config"'; + +# Make sure bool data type works. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get', + type => 'bool', +}), 'Create config get bool command'; + +throws_ok { $cmd->execute('revert.count') } 'App::Sqitch::X', + 'Should get failure for invalid bool int'; +is $@->ident, 'config', 'Bool int cast exception ident should be "config"'; +throws_ok { $cmd->execute('revert.revision') } 'App::Sqitch::X', + 'Should get failure for invalid bool num'; +is $@->ident, 'config', 'Bool num cast exception ident should be "config"'; + +ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool'; +is_deeply \@emit, [['true']], + 'Should have emitted bundle.tags_only as a bool'; +@emit = (); + +# Make sure bool-or-int data type works. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get', + type => 'bool-or-int', +}), 'Create config get bool-or-int command'; + +ok $cmd->execute('revert.count'), 'Get revert.count as bool-or-int'; +is_deeply \@emit, [[2]], + 'Should have emitted the revert count as an int'; +@emit = (); + +ok $cmd->execute('revert.revision'), 'Get revert.revision as bool-or-int'; +is_deeply \@emit, [[1]], + 'Should have emitted the revert revision as an int'; +@emit = (); + +ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool-or-int'; +is_deeply \@emit, [['true']], + 'Should have emitted bundle.tags_only as a bool'; +@emit = (); + +chdir File::Spec->updir; + +CONTEXT: { + my $config = TestConfig->from(system => file qw(t sqitch.conf)); + $sqitch = App::Sqitch->new(config => $config); + ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'system', + action => 'get', + }), 'Create system config get command'; + ok $cmd->execute('core.engine'), 'Get system core.engine'; + is_deeply \@emit, [['pg']], 'Should have emitted the system core.engine'; + @emit = (); + + ok $cmd->execute('engine.pg.client'), 'Get system engine.pg.client'; + is_deeply \@emit, [['/usr/local/pgsql/bin/psql']], + 'Should have emitted the system engine.pg.client'; + @emit = @fail = (); + + throws_ok { $cmd->execute('engine.pg.host') } 'App::Sqitch::X', + 'Attempt to get engine.pg.host should fail'; + is $@->ident, 'config', 'Error ident should be "config"'; + is $@->message, '', 'Error Message should be empty'; + is $@->exitval, 1, 'Error exitval should be 1'; + is_deeply \@emit, [], 'Nothing should have been emitted'; + + $config = TestConfig->from( + system => file(qw(t sqitch.conf)), + user => file(qw(t user.conf)), + ); + $sqitch = App::Sqitch->new(config => $config); + ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'user', + action => 'get', + }), 'Create user config get command'; + @emit = (); + + ok $cmd->execute('engine.pg.registry'), 'Get user engine.pg.registry'; + is_deeply \@emit, [['meta']], 'Should have emitted the user engine.pg.registry'; + @emit = (); + + ok $cmd->execute('engine.pg.client'), 'Get user engine.pg.client'; + is_deeply \@emit, [['/opt/local/pgsql/bin/psql']], + 'Should have emitted the user engine.pg.client'; + @emit = (); + + $config = TestConfig->from( + system => file(qw(t sqitch.conf)), + user => file(qw(t user.conf)), + local => file(qw(t local.conf)), + ); + $sqitch->config->load; + $sqitch = App::Sqitch->new(config => $config); + ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'local', + action => 'get', + }), 'Create local config get command'; + @emit = (); + + ok $cmd->execute('engine.pg.target'), 'Get local engine.pg.target'; + is_deeply \@emit, [['mydb']], 'Should have emitted the local engine.pg.target'; + @emit = (); + + ok $cmd->execute('core.engine'), 'Get local core.engine'; + is_deeply \@emit, [['pg']], 'Should have emitted the local core.engine'; + @emit = (); +} + +CONTEXT: { + # What happens when there is no config file? + my $config = TestConfig->new; + $sqitch = App::Sqitch->new(config => $config); + ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'system', + action => 'get', + }), 'Create another system config get command'; + ok !-f $cmd->file, 'There should be no system config file'; + throws_ok { $cmd->execute('core.engine') } 'App::Sqitch::X', + 'Should fail when no system config file'; + is $@->ident, 'config', 'Error ident should be "config"'; + is $@->message, '', 'Error Message should be empty'; + is $@->exitval, 1, 'Error exitval should be 1'; + + ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'user', + action => 'get', + }), 'Create another user config get command'; + ok !-f $cmd->file, 'There should be no user config file'; + throws_ok { $cmd->execute('core.engine') } 'App::Sqitch::X', + 'Should fail when no user config file'; + is $@->ident, 'config', 'Error ident should be "config"'; + is $@->message, '', 'Error Message should be empty'; + is $@->exitval, 1, 'Error exitval should be 1'; + + ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'local', + action => 'get', + }), 'Create another local config get command'; + ok !-f $cmd->file, 'There should be no local config file'; + throws_ok { $cmd->execute('core.engine') } 'App::Sqitch::X', + 'Should fail when no local config file'; + is $@->ident, 'config', 'Error ident should be "config"'; + is $@->message, '', 'Error Message should be empty'; + is $@->exitval, 1, 'Error exitval should be 1'; +} + +############################################################################## +# Test list(). +$config = TestConfig->from( + system => file(qw(t sqitch.conf)), + user => file(qw(t user.conf)), + local => file(qw(t local.conf)), +); +$sqitch = App::Sqitch->new(config => $config); +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'list', +}), 'Create config list command'; +ok $cmd->execute, 'Execute the list action'; +is_deeply \@emit, [[ + 'bundle.dest_dir=_build/sql +bundle.from=gamma +bundle.tags_only=true +core.engine=pg +core.extension=ddl +core.pager=less -r +core.top_dir=migrations +core.uri=https://github.com/sqitchers/sqitch/ +engine.firebird.client=/opt/firebird/bin/isql +engine.firebird.registry=meta +engine.mysql.client=/opt/local/mysql/bin/mysql +engine.mysql.registry=meta +engine.mysql.variables.prefix=foo_ +engine.pg.client=/opt/local/pgsql/bin/psql +engine.pg.registry=meta +engine.pg.target=mydb +engine.sqlite.client=/opt/local/bin/sqlite3 +engine.sqlite.registry=meta +engine.sqlite.target=devdb +foo.BAR.baz=hello +guess.Yes.No.calico=false +guess.Yes.No.red=true +revert.count=2 +revert.revision=1.1 +revert.to=gamma +target.devdb.uri=db:sqlite: +target.mydb.plan_file=t/plans/dependencies.plan +target.mydb.uri=db:pg:mydb +user.email=michael@example.com +user.name=Michael Stonebraker +' +]], 'Should have emitted the merged config'; +@emit = (); + +CONTEXT: { + $config = TestConfig->from(system => file qw(t sqitch.conf) ); + $sqitch = App::Sqitch->new(config => $config); + ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'system', + action => 'list', + }), 'Create system config list command'; + ok $cmd->execute, 'List the system config'; + is_deeply \@emit, [[ + 'bundle.dest_dir=_build/sql +bundle.from=gamma +bundle.tags_only=true +core.engine=pg +core.extension=ddl +core.pager=less -r +core.top_dir=migrations +core.uri=https://github.com/sqitchers/sqitch/ +engine.pg.client=/usr/local/pgsql/bin/psql +foo.BAR.baz=hello +guess.Yes.No.calico=false +guess.Yes.No.red=true +revert.count=2 +revert.revision=1.1 +revert.to=gamma +' + ]], 'Should have emitted the system config list'; + @emit = (); + + $config = TestConfig->from( + system => file(qw(t sqitch.conf)), + user => file(qw(t user.conf)), + ); + $sqitch = App::Sqitch->new(config => $config); + ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'user', + action => 'list', + }), 'Create user config list command'; + ok $cmd->execute, 'List the user config'; + is_deeply \@emit, [[ + 'engine.firebird.client=/opt/firebird/bin/isql +engine.firebird.registry=meta +engine.mysql.client=/opt/local/mysql/bin/mysql +engine.mysql.registry=meta +engine.mysql.variables.prefix=foo_ +engine.pg.client=/opt/local/pgsql/bin/psql +engine.pg.registry=meta +engine.pg.target=db:pg://postgres@localhost/thingies +engine.sqlite.client=/opt/local/bin/sqlite3 +engine.sqlite.registry=meta +engine.sqlite.target=db:sqlite:my.db +user.email=michael@example.com +user.name=Michael Stonebraker +' + ]], 'Should only have emitted the user config list'; + @emit = (); + + $config = TestConfig->from( + system => file(qw(t sqitch.conf)), + user => file(qw(t user.conf)), + local => file(qw(t local.conf)), + ); + $sqitch = App::Sqitch->new(config => $config); + ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'local', + action => 'list', + }), 'Create local config list command'; + ok $cmd->execute, 'List the local config'; + is_deeply \@emit, [[ + 'core.engine=pg +engine.pg.target=mydb +engine.sqlite.target=devdb +target.devdb.uri=db:sqlite: +target.mydb.plan_file=t/plans/dependencies.plan +target.mydb.uri=db:pg:mydb +' + ]], 'Should only have emitted the local config list'; + @emit = (); +} + +# What happens when there is no config file? +$config = TestConfig->from; +$sqitch = App::Sqitch->new(config => $config); +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'system', + action => 'list', +}), 'Create system config list command with no file'; +ok $cmd->execute, 'List the system config'; +is_deeply \@emit, [], 'Nothing should have been emitted'; + +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + context => 'user', + action => 'list', +}), 'Create user config list command with no file'; +ok $cmd->execute, 'List the user config'; +is_deeply \@emit, [], 'Nothing should have been emitted'; + +############################################################################## +# Test set(). +my $file = 'testconfig.conf'; +$mock->mock(file => $file); +END { unlink $file } + +ok $cmd = $CLASS->new({ + sqitch => $sqitch, +}), 'Create system config set command'; +ok $cmd->execute('core.foo' => 'bar'), 'Write core.foo'; +is_deeply $config->data_from($cmd->file), {'core.foo' => 'bar' }, + 'The property should have been written'; + +# Write another property. +ok $cmd->execute('core.engine' => 'funky'), 'Write core.engine'; +is_deeply $config->data_from($cmd->file), {'core.foo' => 'bar', 'core.engine' => 'funky' }, + 'Both settings should be saved'; + +# Write a sub-propery. +ok $cmd->execute('engine.pg.user' => 'theory'), 'Write engine.pg.user'; +is_deeply $config->data_from($cmd->file), { + 'core.foo' => 'bar', + 'core.engine' => 'funky', + 'engine.pg.user' => 'theory', +}, 'Both sections should be saved'; + +# Make sure the key is required. +throws_ok { $cmd->set } qr/USAGE/, 'Should set usage for missing set key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the missing set key should trigger a usage message'; +throws_ok { $cmd->set('') } qr/USAGE/, 'Should set usage for invalid set key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the invalid set key should trigger a usage message'; + +# Make sure the value is required. +throws_ok { $cmd->set('foo.bar') } qr/USAGE/, 'Should set usage for missing set value'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the missing set value should trigger a usage message'; + +############################################################################## +# Test add(). +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'add', +}), 'Create system config add command'; +ok $cmd->execute('core.foo' => 'baz'), 'Add to core.foo'; +is_deeply $config->data_from($cmd->file), { + 'core.foo' => ['bar', 'baz'], + 'core.engine' => 'funky', + 'engine.pg.user' => 'theory', +}, 'The value should have been added to the property'; + +# Make sure the key is required. +throws_ok { $cmd->add } qr/USAGE/, 'Should add usage for missing add key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the missing add key should trigger a usage message'; +throws_ok { $cmd->add('') } qr/USAGE/, 'Should add usage for invalid add key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the invalid add key should trigger a usage message'; + +# Make sure the value is required. +throws_ok { $cmd->add('foo.bar') } qr/USAGE/, 'Should add usage for missing add value'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the missing add value should trigger a usage message'; + +############################################################################## +# Test get with regex. +$config = TestConfig->from(user => $file); +$sqitch = App::Sqitch->new(config => $config); +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get', +}), 'Create system config add command'; +ok $cmd->execute('core.engine', 'funk'), 'Get core.engine with regex'; +is_deeply \@emit, [['funky']], 'Should have emitted value'; +@emit = (); + +ok $cmd->execute('core.foo', 'z$'), 'Get core.foo with regex'; +is_deeply \@emit, [['baz']], 'Should have emitted value'; +@emit = (); + +throws_ok { $cmd->execute('core.foo', 'x$') } 'App::Sqitch::X', + 'Attempt to get core.foo with non-matching regex should fail'; +is $@->ident, 'config', 'Error ident should be "config"'; +is $@->message, '', 'Error Message should be empty'; +is $@->exitval, 1, 'Error exitval should be 1'; +is_deeply \@emit, [], 'Nothing should have been emitted'; + +############################################################################## +# Test get_all(). +@emit = (); +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get_all', +}), 'Create system config get_all command'; +ok $cmd->execute('core.engine'), 'Call get_all on core.engine'; +is_deeply \@emit, [['funky']], 'The engine should have been emitted'; +@emit = (); + +ok $cmd->execute('core.engine', 'funk'), 'Get all core.engine with regex'; +is_deeply \@emit, [['funky']], 'Should have emitted value'; +@emit = (); + +ok $cmd->execute('core.foo'), 'Call get_all on core.foo'; +is_deeply \@emit, [["bar\nbaz"]], 'Both foos should have been emitted'; +@emit = (); + +ok $cmd->execute('core.foo', '^ba'), 'Call get_all on core.foo with regex'; +is_deeply \@emit, [["bar\nbaz"]], 'Both foos should have been emitted'; +@emit = (); + +ok $cmd->execute('core.foo', 'z$'), 'Call get_all on core.foo with limiting regex'; +is_deeply \@emit, [["baz"]], 'Only the one foo should have been emitted'; +@emit = (); + +throws_ok { $cmd->execute('core.foo', 'x$') } 'App::Sqitch::X', + 'Attempt to get_all core.foo with non-matching regex should fail'; +is $@->ident, 'config', 'Error ident should be "config"'; +is $@->message, '', 'Error Message should be empty'; +is $@->exitval, 1, 'Error exitval should be 1'; +is_deeply \@emit, [], 'Nothing should have been emitted'; + +# Make sure the key is required. +throws_ok { $cmd->get_all } qr/USAGE/, 'Should get_all usage for missing get_all key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the missing get_all key should trigger a usage message'; +throws_ok { $cmd->get_all('') } qr/USAGE/, 'Should get_all usage for invalid get_all key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the invalid get_all key should trigger a usage message'; + +# Make sure int data type works. +$config = TestConfig->from(local => file qw(t sqitch.conf)); +$sqitch = App::Sqitch->new(config => $config); +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get_all', + type => 'int', +}), 'Create config get_all int command'; + +ok $cmd->execute('revert.count'), 'Get revert.count as int'; +is_deeply \@emit, [[2]], + 'Should have emitted the revert count'; +@emit = (); + +ok $cmd->execute('revert.revision'), 'Get revert.revision as int'; +is_deeply \@emit, [[1]], + 'Should have emitted the revert revision as an int'; +@emit = (); + +throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X', + 'Get bundle.tags_only as an int should fail'; +is $@->ident, 'config', 'Int cast exception ident should be "config"'; + +# Make sure num data type works. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get_all', + type => 'num', +}), 'Create config get_all num command'; + +ok $cmd->execute('revert.count'), 'Get revert.count as num'; +is_deeply \@emit, [[2]], + 'Should have emitted the revert count'; +@emit = (); + +ok $cmd->execute('revert.revision'), 'Get revert.revision as num'; +is_deeply \@emit, [[1.1]], + 'Should have emitted the revert revision as an num'; +@emit = (); + +throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X', + 'Get bundle.tags_only as an num should fail'; +is $@->ident, 'config', 'Num cast exception ident should be "config"'; + +# Make sure bool data type works. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get_all', + type => 'bool', +}), 'Create config get_all bool command'; + +throws_ok { $cmd->execute('revert.count') } 'App::Sqitch::X', + 'Should get failure for invalid bool int'; +is $@->ident, 'config', 'Bool int cast exception ident should be "config"'; +throws_ok { $cmd->execute('revert.revision') } 'App::Sqitch::X', + 'Should get failure for invalid bool num'; +is $@->ident, 'config', 'Num int cast exception ident should be "config"'; + +ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool'; +is_deeply \@emit, [['true']], 'Should have emitted bundle.tags_only as a bool'; +@emit = (); + +# Make sure bool-or-int data type works. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get_all', + type => 'bool-or-int', +}), 'Create config get_all bool-or-int command'; + +ok $cmd->execute('revert.count'), 'Get revert.count as bool-or-int'; +is_deeply \@emit, [[2]], + 'Should have emitted the revert count as an int'; +@emit = (); + +ok $cmd->execute('revert.revision'), 'Get revert.revision as bool-or-int'; +is_deeply \@emit, [[1]], + 'Should have emitted the revert revision as an int'; +@emit = (); + +ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool-or-int'; +is_deeply \@emit, [['true']], 'Should have emitted bundle.tags_only as a bool'; +@emit = (); + +############################################################################## +# Test get_regex(). +$config = TestConfig->from(local => $file, user => file qw(t sqitch.conf)); +$sqitch = App::Sqitch->new(config => $config); +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get_regex', +}), 'Create system config get_regex command'; +ok $cmd->execute('core\\..+'), 'Call get_regex on core\\..+'; +is_deeply \@emit, [[q{core.engine=funky +core.extension=ddl +core.foo=[bar, baz] +core.pager=less -r +core.top_dir=migrations +core.uri=https://github.com/sqitchers/sqitch/} +]], 'Should match all core options'; +@emit = (); + +ok $cmd->execute('engine\\.pg\\..+'), 'Call get_regex on engine\\.pg\\..+'; +is_deeply \@emit, [[q{engine.pg.client=/usr/local/pgsql/bin/psql +engine.pg.user=theory} +]], 'Should match all engine.pg options'; +@emit = (); + +ok $cmd->execute('engine\\.pg\\..+', 'theory$'), + 'Call get_regex on engine\\.pg\\..+ and value regex'; +is_deeply \@emit, [[q{engine.pg.user=theory} +]], 'Should match all engine.pg options that match'; +@emit = (); + +throws_ok { $cmd->execute('engine\\.pg\\..+', 'x$') } 'App::Sqitch::X', + 'Attempt to get_regex core.foo with non-matching regex should fail'; +is $@->ident, 'config', 'Error ident should be "config"'; +is $@->message, '', 'Error Message should be empty'; +is $@->exitval, 1, 'Error exitval should be 1'; +is_deeply \@emit, [], 'Nothing should have been emitted'; + +# Make sure the key is required. +throws_ok { $cmd->get_regex } qr/USAGE/, 'Should get_regex usage for missing get_regex key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the missing get_regex key should trigger a usage message'; +throws_ok { $cmd->get_regex('') } qr/USAGE/, 'Should get_regex usage for invalid get_regex key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the invalid get_regex key should trigger a usage message'; + +# Make sure int data type works. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get_regex', + type => 'int', +}), 'Create config get_regex int command'; + +ok $cmd->execute('revert.count'), 'Get revert.count as int'; +is_deeply \@emit, [['revert.count=2']], + 'Should have emitted the revert count'; +@emit = (); + +ok $cmd->execute('revert.revision'), 'Get revert.revision as int'; +is_deeply \@emit, [['revert.revision=1']], + 'Should have emitted the revert revision as an int'; +@emit = (); + +throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X', + 'Get bundle.tags_only as an int should fail'; +is $@->ident, 'config', 'Int cast exception ident should be "config"'; + +# Make sure num data type works. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get_regex', + type => 'num', +}), 'Create config get_regexp num command'; + +ok $cmd->execute('revert.count'), 'Get revert.count as num'; +is_deeply \@emit, [['revert.count=2']], + 'Should have emitted the revert count'; +@emit = (); + +ok $cmd->execute('revert.revision'), 'Get revert.revision as num'; +is_deeply \@emit, [['revert.revision=1.1']], + 'Should have emitted the revert revision as an num'; +@emit = (); + +throws_ok { $cmd->execute('bundle.tags_only') } 'App::Sqitch::X', + 'Get bundle.tags_only as an num should fail'; +is $@->ident, 'config', 'Num cast exception ident should be "config"'; + +# Make sure bool data type works. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get_regex', + type => 'bool', +}), 'Create config get_regex bool command'; + +throws_ok { $cmd->execute('revert.count') } 'App::Sqitch::X', + 'Should get failure for invalid bool int'; +is $@->ident, 'config', 'Bool int cast exception ident should be "config"'; +throws_ok { $cmd->execute('revert.revision') } 'App::Sqitch::X', + 'Should get failure for invalid bool num'; +is $@->ident, 'config', 'Num int cast exception ident should be "config"'; + +ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool'; +is_deeply \@emit, [['bundle.tags_only=true']], + 'Should have emitted bundle.tags_only as a bool'; +@emit = (); + +# Make sure int data type works. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'get_regex', + type => 'bool-or-int', +}), 'Create config get_regex bool-or-int command'; + +ok $cmd->execute('revert.count'), 'Get revert.count as bool-or-int'; +is_deeply \@emit, [['revert.count=2']], + 'Should have emitted the revert count as an int'; +@emit = (); + +ok $cmd->execute('revert.revision'), 'Get revert.revision as bool-or-int'; +is_deeply \@emit, [['revert.revision=1']], + 'Should have emitted the revert revision as an int'; +@emit = (); + +ok $cmd->execute('bundle.tags_only'), 'Get bundle.tags_only as bool-or-int'; +is_deeply \@emit, [['bundle.tags_only=true']], + 'Should have emitted bundle.tags_only as a bool'; +@emit = (); + +############################################################################## +# Test unset(). +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'unset', +}), 'Create system config unset command'; + +ok $cmd->execute('engine.pg.user'), 'Unset engine.pg.user'; +is_deeply $config->data_from($cmd->file), { + 'core.foo' => ['bar', 'baz'], + 'core.engine' => 'funky', +}, 'engine.pg.user should be gone'; +ok $cmd->execute('core.engine'), 'Unset core.engine'; +is_deeply $config->data_from($cmd->file), { + 'core.foo' => ['bar', 'baz'], +}, 'core.engine should have been removed'; + +throws_ok { $cmd->execute('core.foo') } 'App::Sqitch::X', + 'Should get failure trying to delete multivalue key'; +is $@->ident, 'config', 'Multiple value exception ident should be "config"'; +is $@->message, __ 'Cannot unset key with multiple values', + 'And it should have the proper error message'; + +ok $cmd->execute('core.foo', 'z$'), 'Unset core.foo with a regex'; +is_deeply $config->data_from($cmd->file), { + 'core.foo' => 'bar', +}, 'The core.foo "baz" value should have been removed'; + +# Make sure the key is required. +throws_ok { $cmd->unset } qr/USAGE/, 'Should unset usage for missing unset key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the missing unset key should trigger a usage message'; +throws_ok { $cmd->unset('') } qr/USAGE/, 'Should unset usage for invalid unset key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the invalid unset key should trigger a usage message'; + +############################################################################## +# Test unset_all(). +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'unset_all', +}), 'Create system config unset_all command'; + +$cmd->add('core.foo', 'baz'); +ok $cmd->execute('core.foo'), 'unset_all core.foo'; +is_deeply $config->data_from($cmd->file), {}, 'core.foo should have been removed'; + +# Test handling of multiple value. +$cmd->add('core.foo', 'bar'); +$cmd->add('core.foo', 'baz'); +$cmd->add('core.foo', 'yo'); + +ok $cmd->execute('core.foo', '^ba'), 'unset_all core.foo with regex'; +is_deeply $config->data_from($cmd->file), { + 'core.foo' => 'yo', +}, 'core.foo should have one value left'; + +# Make sure the key is required. +throws_ok { $cmd->unset_all } qr/USAGE/, 'Should unset_all usage for missing unset_all key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the missing unset_all key should trigger a usage message'; +throws_ok { $cmd->unset_all('') } qr/USAGE/, 'Should unset_all usage for invalid unset_all key'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'And the invalid unset_all key should trigger a usage message'; + +############################################################################## +# Test replace_all. +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'replace_all', +}), 'Create system config replace_all command'; + +$cmd->add('core.bar', 'bar'); +$cmd->add('core.bar', 'baz'); +$cmd->add('core.bar', 'yo'); + +ok $cmd->execute('core.bar', 'hi'), 'Replace all core.bar'; +is_deeply $config->data_from($cmd->file), { + 'core.bar' => 'hi', + 'core.foo' => 'yo', +}, 'core.bar should have all its values with one value'; + +$cmd->add('core.foo', 'bar'); +$cmd->add('core.foo', 'baz'); +ok $cmd->execute('core.foo', 'ba', '^ba'), 'Replace all core.bar matching /^ba/'; + +is_deeply $config->data_from($cmd->file), { + 'core.bar' => 'hi', + 'core.foo' => ['yo', 'ba'], +}, 'core.foo should have had the matching values replaced'; + +# Clean up. +$cmd->unset_all('core.bar'); +$cmd->unset('core.foo', 'ba'); + +############################################################################## +# Test rename_section(). +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'rename_section', +}), 'Create system config rename_section command'; +ok $cmd->execute('core', 'funk'), 'Rename "core" to "funk"'; +is_deeply $config->data_from($cmd->file), { + 'funk.foo' => 'yo', +}, 'core.foo should have become funk.foo'; + +throws_ok { $cmd->execute('foo') } qr/USAGE/, 'Should fail with no new name'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'Message should be in the usage call'; + +throws_ok { $cmd->execute('', 'bar') } qr/USAGE/, 'Should fail with bad old name'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'Message should be in the usage call'; + +throws_ok { $cmd->execute('baz', '') } qr/USAGE/, 'Should fail with bad new name'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'Message should be in the usage call'; + +throws_ok { $cmd->execute('foo', 'bar') } 'App::Sqitch::X', + 'Should fail with invalid section'; +is $@->ident, 'config', 'Invalid section exception ident should be "config"'; +is $@->message, __ 'No such section!', + 'Invalid section exception message should be set'; + +############################################################################## +# Test remove_section(). +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'remove_section', +}), 'Create system config remove_section command'; +ok $cmd->execute('funk'), 'Remove "func" section'; +is_deeply $config->data_from($cmd->file), {}, + 'The "funk" section should be gone'; + +throws_ok { $cmd->execute() } qr/USAGE/, 'Should fail with no name'; +is_deeply \@usage, ['Wrong number of arguments.'], + 'Message should be in the usage call'; + +throws_ok { $cmd->execute('bar') } 'App::Sqitch::X', + 'Should fail with invalid name'; +is $@->ident, 'config', 'Invalid key name exception ident should be "config"'; +is $@->message, __ 'No such section!', 'And the invalid key message should be set'; + +############################################################################## +# Test errors with multiple values. + +throws_ok { $cmd->get('core.foo', '.') } 'App::Sqitch::X', + 'Should fail fetching multi-value key'; +is $@->ident, 'config', 'Multi-value key exception ident should be "config"'; +is $@->message, __x( + 'More then one value for the key "{key}"', + key => 'core.foo', +), 'The multiple value error should be thrown'; + +$cmd->add('core.foo', 'hi'); +$cmd->add('core.foo', 'bye'); +throws_ok { $cmd->set('core.foo', 'hi') } 'App::Sqitch::X', + 'Should fail setting multi-value key'; +is $@->ident, 'config', 'Mult-valkue key exception ident should be "config"'; +is $@->message, __('Cannot overwrite multiple values with a single value'), + 'The multi-value key error should be thrown'; + +############################################################################## +# Test edit(). +my $shell; +my $ret = 1; +$mock->mock(shell => sub { $shell = $_[1]; return $ret }); +ok $cmd = $CLASS->new({ + sqitch => $sqitch, + action => 'edit', +}), 'Create system config edit command'; +ok $cmd->execute, 'Execute the edit comand'; +is $shell, $sqitch->editor . ' ' . $sqitch->quote_shell($cmd->file), + 'The editor should have been run'; + +############################################################################## +# Make sure we can write to a file in a directory. +my $path = file qw(t config.tmp test.conf); +$mock->mock(file => $path); +END { remove_tree +File::Spec->catdir(qw(t config.tmp)) } +ok $sqitch = App::Sqitch->new, 'Load a new sqitch object'; +ok $cmd = $CLASS->new({ + sqitch => $sqitch, +}), 'Create system config set command with subdirectory config file path'; +ok $cmd->execute('my.foo', 'hi'), 'Set "my.foo" in subdirectory config file'; +is_deeply $config->data_from($cmd->file), {'my.foo' => 'hi' }, + 'The file should have been written'; diff --git a/t/configuration.t b/t/configuration.t new file mode 100644 index 00000000..9dca488d --- /dev/null +++ b/t/configuration.t @@ -0,0 +1,90 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use Test::More tests => 22; +#use Test::More 'no_plan'; +use File::Spec; +use Test::Exception; +use Test::NoWarnings; + +my $CLASS; +BEGIN { + $CLASS = 'App::Sqitch::Config'; + use_ok $CLASS or die; +} + +# protect against user's environment variables +delete @ENV{qw( SQITCH_CONFIG SQITCH_USER_CONFIG SQITCH_SYSTEM_CONFIG )}; + +isa_ok my $config = $CLASS->new, $CLASS, 'New config object'; +is $config->confname, 'sqitch.conf', 'confname should be "sqitch.conf"'; +ok !$config->initialized, 'Should not be initialized'; + +my $hd = $^O eq 'MSWin32' && "$]" < '5.016' ? $ENV{HOME} || $ENV{USERPROFILE} : (glob('~'))[0]; + +SKIP: { + skip 'System dir can be modified at build time', 1 + if $INC{'App/Sqitch/Config.pm'} =~ /\bblib\b/; + is $config->system_dir, File::Spec->catfile( + $Config::Config{prefix}, 'etc', 'sqitch' + ), 'Default system directory should be correct'; +} + +is $config->user_dir, File::Spec->catfile( + $hd, '.sqitch' +), 'Default user directory should be correct'; + +is $config->global_file, File::Spec->catfile( + $config->system_dir, 'sqitch.conf' +), 'Default global file name should be correct'; + +my $file = File::Spec->catfile(qw(FOO BAR)); +$ENV{SQITCH_SYSTEM_CONFIG} = $file; +is $config->global_file, $file, + 'Should preferably get SQITCH_SYSTEM_CONFIG file from global_file'; +is $config->system_file, $config->global_file, 'system_file should alias global_file'; + +is $config->user_file, File::Spec->catfile( + $hd, '.sqitch', 'sqitch.conf' +), 'Default user file name should be correct'; + +$ENV{SQITCH_USER_CONFIG} = $file, +is $config->user_file, $file, + 'Should preferably get SQITCH_USER_CONFIG file from user_file'; + +is $config->local_file, 'sqitch.conf', + 'Local file should be correct'; +is $config->dir_file, $config->local_file, 'dir_file should alias local_file'; + +SQITCH_CONFIG: { + local $ENV{SQITCH_CONFIG} = 'sqitch.ini'; + is $config->local_file, 'sqitch.ini', 'local_file should prefer $SQITCH_CONFIG'; + is $config->dir_file, 'sqitch.ini', 'And so should dir_file'; +} + +chdir 't'; +isa_ok $config = $CLASS->new, $CLASS, 'Another config object'; +ok $config->initialized, 'Should be initialized'; +is_deeply $config->get_section(section => 'core'), { + engine => "pg", + extension => "ddl", + top_dir => "migrations", + uri => 'https://github.com/sqitchers/sqitch/', + pager => "less -r", +}, 'get_section("core") should work'; + +is_deeply $config->get_section(section => 'engine.pg'), { + client => "/usr/local/pgsql/bin/psql", +}, 'get_section("engine.pg") should work'; + +# Make sure it works with irregular casing. +is_deeply $config->get_section(section => 'foo.BAR'), { + baz => 'hello' +}, 'get_section() whould work with capitalized subsection'; + +# Should work with multiple subsections and case-preserved keys. +is_deeply $config->get_section(section => 'guess.Yes.No'), { + red => 'true', + Calico => 'false', +}, 'get_section() whould work with mixed case subsections'; diff --git a/t/conn_cmd_role.t b/t/conn_cmd_role.t new file mode 100644 index 00000000..9924c901 --- /dev/null +++ b/t/conn_cmd_role.t @@ -0,0 +1,112 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More; +use App::Sqitch; +use lib 't/lib'; +use TestConfig; + +my $ROLE; + +BEGIN { + $ROLE = 'App::Sqitch::Role::ConnectingCommand'; + use_ok $ROLE or die; +} + +COMMAND: { + # Stub out a command. + package App::Sqitch::Command::click; + use Moo; + extends 'App::Sqitch::Command'; + with $ROLE; + $INC{'App/Sqitch/Command/click.pm'} = __FILE__; + + sub options { + return qw( + foo + quack|k=s + ); + } +} + +my $CLASS = 'App::Sqitch::Command::click'; +can_ok $CLASS, 'does'; +ok $CLASS->does($ROLE), "$CLASS does $ROLE"; + +is_deeply [$CLASS->options], [qw( + foo + quack|k=s + registry=s + client|db-client=s + db-name|d=s + db-user|db-username|u=s + db-host|h=s + db-port|p=i +)], 'Options should include connection options'; + +############################################################################## +# Test configure. +my $opts = {}; +my $config = TestConfig->new; +my @params; +is_deeply $CLASS->configure($config, $opts), { _params => \@params }, + 'Should get no params for no options'; + +$opts->{db_name} = 'disco'; +push @params => dbname => 'disco'; +is_deeply $CLASS->configure($config, $opts), { _params => \@params }, + 'Should get dbname for --db-name'; + +$opts = { + db_user => '', + db_host => undef, + db_port => 0, + db_name => '', +}; +@params = ( + user => '', + host => undef, + port => 0, + dbname => '', +); + +is_deeply $CLASS->configure($config, $opts), { _params => \@params }, + 'Should collect existing but false params'; + +$opts = { + db_user => 'theory', + db_host => 'justatheory.com', + db_port => 9876, + db_name => 'funk', + registry => 'crickets', + client => '/bin/true', + quack => 'woof', +}; +@params = ( + user => 'theory', + host => 'justatheory.com', + port => 9876, + dbname => 'funk', + registry => 'crickets', + client => '/bin/true', +); +is_deeply $CLASS->configure($config, $opts), + { _params => \@params, quack => 'woof' }, + 'Should collect params'; + +############################################################################## +# Test target_params. +my $sqitch = App::Sqitch->new(config => $config); +isa_ok my $cmd = $CLASS->new( + sqitch => $sqitch, + quack => 'beep', + _params => \@params, +), $CLASS; + +is_deeply [$cmd->target_params], [sqitch => $sqitch, @params], + 'Should get connection params from target_params'; + +done_testing; diff --git a/t/core.conf b/t/core.conf new file mode 100644 index 00000000..822241da --- /dev/null +++ b/t/core.conf @@ -0,0 +1,2 @@ +[core] + engine = pg diff --git a/t/core_target.conf b/t/core_target.conf new file mode 100644 index 00000000..1ece3a7a --- /dev/null +++ b/t/core_target.conf @@ -0,0 +1,2 @@ +[core] + target = db:pg:whatever diff --git a/t/cx_cmd_role.t b/t/cx_cmd_role.t new file mode 100644 index 00000000..a5cfeb96 --- /dev/null +++ b/t/cx_cmd_role.t @@ -0,0 +1,109 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More; +use Path::Class; +use App::Sqitch; +use lib 't/lib'; +use TestConfig; +use Test::MockModule; +use Locale::TextDomain qw(App-Sqitch); # XXX Until deprecation removed below. + +my $ROLE; + +BEGIN { + $ROLE = 'App::Sqitch::Role::ContextCommand'; + use_ok $ROLE or die; +} + +COMMAND: { + # Stub out a command. + package App::Sqitch::Command::click; + use Moo; + extends 'App::Sqitch::Command'; + with $ROLE; + $INC{'App/Sqitch/Command/click.pm'} = __FILE__; + + sub options { + return qw( + foo + quack|k=s + ); + } +} + +my $CLASS = 'App::Sqitch::Command::click'; +can_ok $CLASS, 'does'; +ok $CLASS->does($ROLE), "$CLASS does $ROLE"; + +is_deeply [$CLASS->options], [qw( + foo + quack|k=s + plan-file|f=s + top-dir=s +)], 'Options should include context options'; + + +# Silence warnings. +my $mock = Test::MockModule->new('App::Sqitch'); +my $warning; +$mock->mock(warn => sub { $warning = $_[1] }); + +############################################################################## +# Test configure. +my $opts = {}; +my $config = TestConfig->new; +is_deeply $CLASS->configure($config, $opts), { _cx => [] }, + 'Should get no params for no options'; + +$opts = { + top_dir => '', + plan_file => '0', +}; +is_deeply $CLASS->configure($config, $opts), { _cx => [] }, + 'Should get no params for empty options'; +is $warning, undef, 'Should have no warning'; + +$opts = { top_dir => 't' }; +my @params = ( top_dir => dir 't'); +is_deeply $CLASS->configure($config, $opts), { _cx => \@params }, + 'Should get top_dir'; +is $warning, __x( + " Option --top-dir is deprecated for {command} and other non-configuration commands.\n Use --chdir instead.", + command => $CLASS->command, +), 'Should have --top-dir deprecation warning'; +$warning = undef; + +$opts = { + top_dir => 'lib', + plan_file => 'README.md', + quack => 'woof', +}; +@params = ( + top_dir => dir('lib'), + plan_file => file('README.md'), +); +is_deeply $CLASS->configure($config, $opts), + { _cx => \@params, quack => 'woof' }, + 'Should collect params'; +is $warning, __x( + " Option --top-dir is deprecated for {command} and other non-configuration commands.\n Use --chdir instead.", + command => $CLASS->command, +), 'Should have --top-dir deprecation warning again'; + +############################################################################## +# Test target_params. +my $sqitch = App::Sqitch->new(config => $config); +isa_ok my $cmd = $CLASS->new( + sqitch => $sqitch, + quack => 'beep', + _cx => \@params, +), $CLASS; + +is_deeply [$cmd->target_params], [sqitch => $sqitch, @params], + 'Should get context params from target_params'; + +done_testing; diff --git a/t/datetime.t b/t/datetime.t new file mode 100644 index 00000000..50bea602 --- /dev/null +++ b/t/datetime.t @@ -0,0 +1,95 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 33; +#use Test::More 'no_plan'; +use Locale::TextDomain qw(App-Sqitch); +use Test::NoWarnings; +use Test::Exception; +use Encode; +use lib 't/lib'; +use LC; +use TestConfig; + +my $CLASS = 'App::Sqitch::DateTime'; +require_ok $CLASS; + +ok my $dt = $CLASS->now, 'Construct a datetime object'; +is_deeply [$dt->as_string_formats], [qw( + raw + iso + iso8601 + rfc + rfc2822 + full + long + medium + short +)], 'as_string_formats should be correct'; + +my $rfc = do { + my $clone = $dt->clone; + $clone->set_time_zone('local'); + $clone->set_locale('en_US'); + ( my $rv = $clone->strftime('%a, %d %b %Y %H:%M:%S %z') ) =~ s/\+0000$/-0000/; + $rv; +}; + +my $iso = do { + my $clone = $dt->clone; + $clone->set_time_zone('local'); + join ' ', $clone->ymd('-'), $clone->hms(':'), $clone->strftime('%z') +}; + +my $ldt = do { + my $clone = $dt->clone; + $clone->set_time_zone('local'); + $clone->set_locale($LC::TIME); + $clone; +}; + +my $raw = do { + my $clone = $dt->clone; + $clone->set_time_zone('UTC'); + $clone->iso8601 . 'Z'; +}; + +for my $spec ( + [ full => $ldt->format_cldr( $ldt->locale->datetime_format_full )], + [ long => $ldt->format_cldr( $ldt->locale->datetime_format_long )], + [ medium => $ldt->format_cldr( $ldt->locale->datetime_format_medium )], + [ short => $ldt->format_cldr( $ldt->locale->datetime_format_short )], + [ raw => $raw ], + [ '' => $raw ], + [ iso => $iso ], + [ iso8601 => $iso ], + [ rfc => $rfc ], + [ rfc2822 => $rfc ], + [ q{cldr:HH'h' mm'm'} => $ldt->format_cldr( q{HH'h' mm'm'} ) ], + [ 'strftime:%a at %H:%M:%S' => $ldt->strftime('%a at %H:%M:%S') ], +) { + my $clone = $dt->clone; + $clone->set_time_zone('UTC'); + is $dt->as_string( format => $spec->[0] ), $spec->[1], + sprintf 'Date format "%s" should yield "%s"', $spec->[0], encode_utf8 $spec->[1]; + ok $dt->validate_as_string_format($spec->[0]), + qq{Format "$spec->[0]" should be valid} if $spec->[0]; +} + +throws_ok { $dt->validate_as_string_format('nonesuch') } 'App::Sqitch::X', + 'Should get error for invalid date format'; +is $@->ident, 'datetime', 'Invalid date format error ident should be "datetime"'; +is $@->message, __x( + 'Unknown date format "{format}"', + format => 'nonesuch', +), 'Invalid date format error message should be correct'; + +throws_ok { $dt->as_string( format => 'nonesuch' ) } 'App::Sqitch::X', + 'Should get error for invalid as_string format param'; +is $@->ident, 'datetime', 'Invalid date format error ident should be "datetime"'; +is $@->message, __x( + 'Unknown date format "{format}"', + format => 'nonesuch', +), 'Invalid date format error message should be correct'; diff --git a/t/depend.t b/t/depend.t new file mode 100644 index 00000000..b3395f4b --- /dev/null +++ b/t/depend.t @@ -0,0 +1,224 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More tests => 326; +#use Test::More 'no_plan'; +use Test::Exception; +#use Test::NoWarnings; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use Path::Class; +use Locale::TextDomain qw(App-Sqitch); +use lib 't/lib'; +use TestConfig; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Plan::Depend'; + require_ok $CLASS or die; +} + +ok my $sqitch = App::Sqitch->new( + config => TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir(qw(t sql))->stringify, + ), +), 'Load a sqitch sqitch object'; +my $target = App::Sqitch::Target->new( sqitch => $sqitch ); +my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, project => 'depend', target => $target); + +can_ok $CLASS, qw( + conflicts + project + change + tag + id + resolved_id + key_name + as_string + as_plan_string +); + +my $id = '9ed961ad7902a67fe0804c8e49e8993719fd5065'; +for my $spec( + [ 'foo' => change => 'foo' ], + [ 'bar' => change => 'bar' ], + [ '@bar' => tag => 'bar' ], + [ '!foo' => change => 'foo', conflicts => 1 ], + [ '!@bar' => tag => 'bar', conflicts => 1 ], + [ 'foo@bar' => change => 'foo', tag => 'bar' ], + [ '!foo@bar' => change => 'foo', tag => 'bar', conflicts => 1 ], + [ 'proj:foo' => change => 'foo', project => 'proj' ], + [ '!proj:foo' => change => 'foo', project => 'proj', conflicts => 1 ], + [ 'proj:@foo' => tag => 'foo', project => 'proj' ], + [ '!proj:@foo' => tag => 'foo', project => 'proj', conflicts => 1 ], + [ 'proj:foo@bar' => change => 'foo', tag => 'bar', project => 'proj' ], + [ + '!proj:foo@bar', + change => 'foo', + tag => 'bar', + project => 'proj', + conflicts => 1 + ], + [ $id => id => $id ], + [ "!$id" => id => $id, conflicts => 1 ], + [ "foo:$id" => id => $id, project => 'foo' ], + [ "!foo:$id" => id => $id, project => 'foo', conflicts => 1 ], + [ "$id\@what" => change => $id, tag => 'what' ], + [ "!$id\@what" => change => $id, tag => 'what', conflicts => 1 ], + [ "foo:$id\@what" => change => $id, tag => 'what', project => 'foo' ], +) { + my $exp = shift @{$spec}; + ok my $depend = $CLASS->new( + plan => $plan, + @{$spec}, + ), qq{Construct "$exp"}; + ( my $str = $exp ) =~ s/^!//; + ( my $key = $str ) =~ s/^[^:]+://; + my $proj = $1; + is $depend->as_string, $str, qq{Constructed should stringify as "$str"}; + is $depend->key_name, $key, qq{Constructed should have key name "$key"}; + is $depend->as_plan_string, $exp, qq{Constructed should plan stringify as "$exp"}; + ok $depend = $CLASS->new( + plan => $plan, + %{ $CLASS->parse($exp) }, + ), qq{Parse "$exp"}; + is $depend->as_plan_string, $exp, qq{Parsed should plan stringify as "$exp"}; + + if ($exp =~ /^!/) { + # Conflicting. + ok $depend->conflicts, qq{"$exp" should be conflicting}; + ok !$depend->required, qq{"$exp" should not be required}; + is $depend->type, 'conflict', qq{"$exp" type should be "conflict"}; + } else { + # Required. + ok $depend->required, qq{"$exp" should be required}; + ok !$depend->conflicts, qq{"$exp" should not be conflicting}; + is $depend->type, 'require', qq{"$exp" type should be "require"}; + } + + if ($str =~ /^([^:]+):/) { + # Project specified in spec. + my $prj = $1; + ok $depend->got_project, qq{Should have got project from "$exp"}; + is $depend->project, $prj, qq{Should have project "$prj" for "$exp"}; + if ($prj eq $plan->project) { + ok !$depend->is_external, qq{"$exp" should not be external}; + ok $depend->is_internal, qq{"$exp" should be internal}; + } else { + ok $depend->is_external, qq{"$exp" should be external}; + ok !$depend->is_internal, qq{"$exp" should not be internal}; + } + } else { + ok !$depend->got_project, qq{Should not have got project from "$exp"}; + if ($depend->change || $depend->tag) { + # No ID, default to current project. + my $prj = $plan->project; + is $depend->project, $prj, qq{Should have project "$prj" for "$exp"}; + ok !$depend->is_external, qq{"$exp" should not be external}; + ok $depend->is_internal, qq{"$exp" should be internal}; + } else { + # ID specified, but no project, and ID not in plan, so unknown project. + is $depend->project, undef, qq{Should have undef project for "$exp"}; + ok $depend->is_external, qq{"$exp" should be external}; + ok !$depend->is_internal, qq{"$exp" should not be internal}; + } + } + + if ($exp =~ /\Q$id\E(?![@])/) { + ok $depend->got_id, qq{Should have got ID from "$exp"}; + } else { + ok !$depend->got_id, qq{Should not have got ID from "$exp"}; + } +} + +for my $bad ( 'foo bar', 'foo+@bar', 'foo:+bar', 'foo@bar+', 'proj:foo@bar+', ) +{ + is $CLASS->parse($bad), undef, qq{Should fail to parse "$bad"}; +} + +throws_ok { $CLASS->new( plan => $plan ) } 'App::Sqitch::X', + 'Should get exception for no change or tag'; +is $@->ident, 'DEV', 'No change or tag error ident should be "DEV"'; +is $@->message, + 'Depend object must have either "change", "tag", or "id" defined', + 'No change or tag error message should be correct'; + +for my $params ( + { change => 'foo' }, + { tag => 'bar' }, + { change => 'foo', tag => 'bar' }, +) { + my $keys = join ' and ' => keys %{ $params }; + throws_ok { $CLASS->new( plan => $plan, id => $id, %{ $params} ) } + 'App::Sqitch::X', "Should get an error for ID + $keys"; + is $@->ident, 'DEV', qq{ID + $keys error ident ident should be "DEV"}; + is $@->message, + 'Depend object cannot contain both an ID and a tag or change', + qq{ID + $keys error message should be correct}; +} + +############################################################################## +# Test ID. +ok my $depend = $CLASS->new( + plan => $plan, + %{ $CLASS->parse('roles') }, +), 'Create "roles" dependency'; +is $depend->id, $plan->find('roles')->id, + 'Should find the "roles" ID in the plan'; +ok !$depend->is_external, 'The "roles" change should not be external'; +ok $depend->is_internal, 'The "roles" change should be internal'; + +ok $depend = $CLASS->new( + plan => $plan, + %{ $CLASS->parse('elsewhere:roles') }, +), 'Create "elsewhere:roles" dependency'; +is $depend->id, undef, 'The "elsewhere:roles" id should be undef'; +ok $depend->is_external, 'The "elsewhere:roles" change should be external'; +ok !$depend->is_internal, 'The "elsewhere:roles" change should not be internal'; + +ok $depend = $CLASS->new( + plan => $plan, + id => $id, +), 'Create depend using external ID'; +is $depend->id, $id, 'The external ID should be set'; +ok $depend->is_external, 'The external ID should register as external'; +ok !$depend->is_internal, 'The external ID should not register as internal'; + +$id = $plan->find('roles')->id; +ok $depend = $CLASS->new( + plan => $plan, + id => $id, +), 'Create depend using "roles" ID'; +is $depend->id, $id, 'The "roles" ID should be set'; +ok !$depend->is_external, 'The "roles" ID should not register as external'; +ok $depend->is_internal, 'The "roles" ID should register as internal'; + +ok $depend = $CLASS->new( + plan => $plan, + project => $plan->project, + %{ $CLASS->parse('nonexistent') }, +), 'Create "nonexistent" dependency'; +throws_ok { $depend->id } 'App::Sqitch::X', + 'Should get error for nonexistent change'; +is $@->ident, 'plan', 'Nonexistent change error ident should be "plan"'; +is $@->message, __x( + 'Unable to find change "{change}" in plan {file}', + change => 'nonexistent', + file => $target->plan_file, +), 'Nonexistent change error message should be correct'; + +############################################################################## +# Test resolved_id. +ok $depend = $CLASS->new( plan => $plan, tag => 'foo' ), + 'Create depend without ID'; +is $depend->resolved_id, undef, 'Resolved ID should be undef'; +ok $depend->resolved_id($id), 'Set resolved ID'; +is $depend->resolved_id, $id, 'Resolved ID should be set'; +ok !$depend->resolved_id(undef), 'Unset resolved ID'; +is $depend->resolved_id, undef, 'Resolved ID should be undef again'; diff --git a/t/deploy.t b/t/deploy.t new file mode 100644 index 00000000..b3b87223 --- /dev/null +++ b/t/deploy.t @@ -0,0 +1,327 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use Test::More; +use App::Sqitch; +use App::Sqitch::Target; +use Path::Class qw(dir file); +use Test::MockModule; +use Test::Exception; +use Test::Warn; +use Locale::TextDomain qw(App-Sqitch); +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::deploy'; +require_ok $CLASS or die; + +isa_ok $CLASS, 'App::Sqitch::Command'; +can_ok $CLASS, qw( + target + options + configure + new + to_change + mode + log_only + execute + variables + does + _collect_vars +); + +ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_" + for qw(ContextCommand ConnectingCommand); + +is_deeply [$CLASS->options], [qw( + target|t=s + to-change|to|change=s + mode=s + set|s=s% + log-only + verify! + plan-file|f=s + top-dir=s + registry=s + client|db-client=s + db-name|d=s + db-user|db-username|u=s + db-host|h=s + db-port|p=i +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, + 'core.top_dir' => dir(qw(t sql))->stringify, +); +my $sqitch = App::Sqitch->new(config => $config); + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure($config, {}), { + mode => 'all', + verify => 0, + log_only => 0, + _params => [], + _cx => [], +}, 'Should have default configuration with no config or opts'; + +is_deeply $CLASS->configure($config, { + mode => 'tag', + verify => 1, + log_only => 1, + set => { foo => 'bar' }, + _params => [], + _cx => [], +}), { + mode => 'tag', + verify => 1, + log_only => 1, + variables => { foo => 'bar' }, + _params => [], + _cx => [], +}, 'Should have mode, verify, set, and log-only options'; + +CONFIG: { + my $config = TestConfig->new( + 'deploy.mode' => 'change', + 'deploy.verify' => 1, + 'deploy.variables' => { foo => 'bar', hi => 21 }, + ); + + is_deeply $CLASS->configure($config, {}), { + mode => 'change', + verify => 1, + log_only => 0, + _params => [], + _cx => [], + }, 'Should have mode and verify configuration'; +} + +############################################################################## +# Test construction. +isa_ok my $deploy = $CLASS->new( + sqitch => $sqitch, + target => 'foo', +), $CLASS, 'new deploy with target'; +is $deploy->target, 'foo', 'Should have target "foo"'; + +isa_ok $deploy = $CLASS->new(sqitch => $sqitch), $CLASS; +is $deploy->target, undef, 'Should have undef default target'; +is $deploy->to_change, undef, 'to_change should be undef'; +is $deploy->mode, 'all', 'mode should be "all"'; + +############################################################################## +# Test _collect_vars. +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $deploy->_collect_vars($target) }, {}, 'Should collect no variables'; + +# Add core variables. +$config->update('core.variables' => { prefix => 'widget', priv => 'SELECT' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $deploy->_collect_vars($target) }, { + prefix => 'widget', + priv => 'SELECT', +}, 'Should collect core vars'; + +# Add deploy variables. +$config->update('deploy.variables' => { dance => 'salsa', priv => 'UPDATE' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $deploy->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'salsa', +}, 'Should override core vars with deploy vars'; + +# Add engine variables. +$config->update('engine.pg.variables' => { dance => 'disco', lunch => 'pizza' }); +my $uri = URI::db->new('db:pg:'); +$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => $uri); +is_deeply { $deploy->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'pizza', +}, 'Should override deploy vars with engine vars'; + +# Add target variables. +$config->update('target.foo.variables' => { lunch => 'burrito', drink => 'whiskey' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $deploy->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'burrito', + drink => 'whiskey', +}, 'Should override engine vars with target vars'; + +# Add --set variables. +$deploy = $CLASS->new( + sqitch => $sqitch, + variables => { drink => 'scotch', status => 'winning' }, +); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $deploy->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'winning', +}, 'Should override target vars with --set variables'; + +############################################################################## +# Test execution. +# Mock parse_args() so that we can grab the target it returns. +my $mock_cmd = Test::MockModule->new($CLASS); +my $parser; +$mock_cmd->mock(parse_args => sub { + my @ret = $parser->(@_); + $target = $ret[0][0]; + return @ret; +}); +$parser = $mock_cmd->original('parse_args'); + +# Mock the engine interface. +my $mock_engine = Test::MockModule->new('App::Sqitch::Engine'); +my @args; +$mock_engine->mock(deploy => sub { shift; @args = @_ }); +my @vars; +$mock_engine->mock(set_variables => sub { shift; @vars = @_ }); + +ok $deploy->execute('@alpha'), 'Execute to "@alpha"'; +is_deeply \@args, ['@alpha', 'all'], + '"@alpha" "all", and 0 should be passed to the engine'; +ok $target, 'Should have a target'; +ok !$target->engine->log_only, 'The engine should not be set log_only'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +@args = (); +ok $deploy->execute, 'Execute'; +is_deeply \@args, [undef, 'all'], + 'undef and "all" should be passed to the engine'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Try passing the change. +ok $deploy->execute('widgets'), 'Execute with change'; +is_deeply \@args, ['widgets', 'all'], + '"widgets" and "all" should be passed to the engine'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Try passing the target. +ok $deploy->execute('db:pg:foo'), 'Execute with target'; +is_deeply \@args, [undef, 'all'], + 'undef and "all" should be passed to the engine'; +is $target->name, 'db:pg:foo', 'The target should be as specified'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Pass both! +ok $deploy->execute('db:pg:blah', 'widgets'), 'Execute with change and target'; +is_deeply \@args, ['widgets', 'all'], + '"widgets" and "all" should be passed to the engine'; +is $target->name, 'db:pg:blah', 'The target should be as specified'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Reverse them! +ok $deploy->execute('db:pg:blah', 'widgets'), 'Execute with target and change'; +is_deeply \@args, ['widgets', 'all'], + '"widgets" and "all" should be passed to the engine'; +is $target->name, 'db:pg:blah', 'The target should be as specified'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Now pass a bunch of options. +$config->replace( + 'core.engine' => 'sqlite', + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, + 'core.top_dir' => dir(qw(t sql))->stringify, +); +isa_ok $deploy = $CLASS->new( + sqitch => $sqitch, + to_change => 'foo', + target => 'db:pg:hi', + mode => 'tag', + log_only => 1, + verify => 1, + variables => { foo => 'bar', one => 1 }, +), $CLASS, 'Object with to, mode, log_only, and variables'; + +@args = (); +ok $deploy->execute, 'Execute again'; +ok $target->engine->with_verify, 'Engine should verify'; +ok $target->engine->log_only, 'The engine should be set log_only'; +is_deeply \@args, ['foo', 'tag'], + '"foo", "tag", and 1 should be passed to the engine'; +is_deeply {@vars}, { foo => 'bar', one => 1 }, + 'Vars should have been passed through to the engine'; +is $target->name, 'db:pg:hi', 'The target name should be from the target option'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Try passing the change. +ok $deploy->execute('widgets'), 'Execute with change'; +ok $target->engine->with_verify, 'Engine should verify'; +ok $target->engine->log_only, 'The engine should be set log_only'; +is_deeply \@args, ['foo', 'tag'], + '"foo", "tag", and 1 should be passed to the engine'; +is_deeply {@vars}, { foo => 'bar', one => 1 }, + 'Vars should have been passed through to the engine'; +is_deeply +MockOutput->get_warn, [[__x( + 'Too many changes specified; deploying to "{change}"', + change => 'foo', +)]], 'Should have too many changes warning'; + +# Pass the target. +ok $deploy->execute('db:pg:bye'), 'Execute with target again'; +ok $target->engine->with_verify, 'Engine should verify'; +ok $target->engine->log_only, 'The engine should be set log_only'; +is_deeply \@args, ['foo', 'tag'], + '"foo", "tag", and 1 should be passed to the engine'; +is_deeply {@vars}, { foo => 'bar', one => 1 }, + 'Vars should have been passed through to the engine'; +is $target->name, 'db:pg:hi', 'The target should be from the target option'; +is_deeply +MockOutput->get_warn, [[__x( + 'Too many targets specified; connecting to {target}', + target => 'db:pg:hi', +)]], 'Should have warning about too many targets'; + +# Make sure the mode enum works. +for my $mode (qw(all tag change)) { + ok $CLASS->new( sqitch => $sqitch, mode => $mode ), + qq{"$mode" should be a valid mode}; +} + +for my $bad (qw(foo bad gar)) { + throws_ok { + $CLASS->new( sqitch => $sqitch, mode => $bad ) + } qr/\QValue "$bad" did not pass type constraint "Enum[all,change,tag]/, + qq{"$bad" should not be a valid mode}; +} + +# Make sure we get an exception for unknown args. +throws_ok { $deploy->execute(qw(greg)) } 'App::Sqitch::X', + 'Should get an exception for unknown arg'; +is $@->ident, 'deploy', 'Unknow arg ident should be "deploy"'; +is $@->message, __x( + 'Unknown argument "{arg}"', + arg => 'greg', +), 'Should get an exeption for two unknown arg'; + +throws_ok { $deploy->execute(qw(greg jon)) } 'App::Sqitch::X', + 'Should get an exception for unknown args'; +is $@->ident, 'deploy', 'Unknow args ident should be "deploy"'; +is $@->message, __x( + 'Unknown arguments: {arg}', + arg => 'greg, jon', +), 'Should get an exeption for two unknown args'; + +done_testing; diff --git a/t/die.pl b/t/die.pl new file mode 100644 index 00000000..23d5a941 --- /dev/null +++ b/t/die.pl @@ -0,0 +1,5 @@ +use v5.10; + +say "@ARGV" if @ARGV; +die 'OMGWTF'; + diff --git a/t/echo.pl b/t/echo.pl new file mode 100644 index 00000000..3e8a290f --- /dev/null +++ b/t/echo.pl @@ -0,0 +1,3 @@ +use 5.010; + +say "@ARGV"; diff --git a/t/editor.conf b/t/editor.conf new file mode 100644 index 00000000..240061a3 --- /dev/null +++ b/t/editor.conf @@ -0,0 +1,3 @@ +[core] + engine = pg + editor = config_specified_editor diff --git a/t/engine.conf b/t/engine.conf new file mode 100644 index 00000000..ebf650e1 --- /dev/null +++ b/t/engine.conf @@ -0,0 +1,20 @@ +[core] + engine = pg + +[engine "mysql"] + target = db:mysql://root@/foo + client = /usr/sbin/mysql + +[engine "pg"] + target = db:pg:try + registry = meta + client = /usr/sbin/psql + +[engine "sqlite"] + target = widgets + client = /usr/sbin/sqlite3 + +[target "widgets"] + uri = db:sqlite:widgets.db + plan_file = foo.plan + diff --git a/t/engine.t b/t/engine.t new file mode 100644 index 00000000..3285ae11 --- /dev/null +++ b/t/engine.t @@ -0,0 +1,3089 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More tests => 633; +#use Test::More 'no_plan'; +use App::Sqitch; +use App::Sqitch::Plan; +use App::Sqitch::Target; +use Path::Class; +use Test::Exception; +use Test::NoWarnings; +use Test::MockModule; +use Locale::TextDomain qw(App-Sqitch); +use App::Sqitch::X qw(hurl); +use App::Sqitch::DateTime; +use List::Util qw(max); +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Engine'; + use_ok $CLASS or die; + delete $ENV{PGDATABASE}; + delete $ENV{PGUSER}; + delete $ENV{USER}; +} + +can_ok $CLASS, qw(load new name no_prompt run_deploy run_revert run_verify uri); + +my ($is_deployed_tag, $is_deployed_change) = (0, 0); +my @deployed_changes; +my @deployed_change_ids; +my @resolved; +my @requiring; +my @load_changes; +my $offset_change; +my $die = ''; +my $record_work = 1; +my ( $earliest_change_id, $latest_change_id, $initialized ); +my $registry_version = $CLASS->registry_release; +my $script_hash; +ENGINE: { + # Stub out an engine. + package App::Sqitch::Engine::whu; + use Moo; + use App::Sqitch::X qw(hurl); + extends 'App::Sqitch::Engine'; + $INC{'App/Sqitch/Engine/whu.pm'} = __FILE__; + + my @SEEN; + for my $meth (qw( + run_file + log_deploy_change + log_revert_change + log_fail_change + )) { + no strict 'refs'; + *$meth = sub { + hurl 'AAAH!' if $die eq $meth; + push @SEEN => [ $meth => $_[1] ]; + }; + } + sub is_deployed_tag { push @SEEN => [ is_deployed_tag => $_[1] ]; $is_deployed_tag } + sub is_deployed_change { push @SEEN => [ is_deployed_change => $_[1] ]; $is_deployed_change } + sub are_deployed_changes { shift; push @SEEN => [ are_deployed_changes => [@_] ]; @deployed_change_ids } + sub change_id_for { shift; push @SEEN => [ change_id_for => {@_} ]; shift @resolved } + sub change_offset_from_id { shift; push @SEEN => [ change_offset_from_id => [@_] ]; $offset_change } + sub change_id_offset_from_id { shift; push @SEEN => [ change_id_offset_from_id => [@_] ]; $_[0] } + sub changes_requiring_change { push @SEEN => [ changes_requiring_change => $_[1] ]; @{ shift @requiring } } + sub earliest_change_id { push @SEEN => [ earliest_change_id => $_[1] ]; $earliest_change_id } + sub latest_change_id { push @SEEN => [ latest_change_id => $_[1] ]; $latest_change_id } + sub current_state { push @SEEN => [ current_state => $_[1] ]; $latest_change_id ? { change => 'what', change_id => $latest_change_id, script_hash => $script_hash } : undef } + sub initialized { push @SEEN => 'initialized'; $initialized } + sub initialize { push @SEEN => 'initialize' } + sub register_project { push @SEEN => 'register_project' } + sub deployed_changes { push @SEEN => [ deployed_changes => $_[1] ]; @deployed_changes } + sub load_change { push @SEEN => [ load_change => $_[1] ]; @load_changes } + sub deployed_changes_since { push @SEEN => [ deployed_changes_since => $_[1] ]; @deployed_changes } + sub mock_check_deploy { shift; push @SEEN => [ check_deploy_dependencies => [@_] ] } + sub mock_check_revert { shift; push @SEEN => [ check_revert_dependencies => [@_] ] } + sub begin_work { push @SEEN => ['begin_work'] if $record_work } + sub finish_work { push @SEEN => ['finish_work'] if $record_work } + sub log_new_tags { push @SEEN => [ log_new_tags => $_[1] ]; $_[0] } + sub _update_script_hashes { push @SEEN => ['_update_script_hashes']; $_[0] } + + sub seen { [@SEEN] } + after seen => sub { @SEEN = () }; + + sub name_for_change_id { return 'bugaboo' } + sub registry_version { $registry_version } +} + +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir(qw(t sql))->stringify, + 'core.plan_file' => file(qw(t plans multi.plan))->stringify, +); +ok my $sqitch = App::Sqitch->new(config => $config), + 'Load a sqitch sqitch object'; + +my $mock_engine = Test::MockModule->new($CLASS); + +############################################################################## +# Test new(). +my $target = App::Sqitch::Target->new( sqitch => $sqitch ); +throws_ok { $CLASS->new( sqitch => $sqitch ) } + qr/\QMissing required arguments: target/, + 'Should get an exception for missing sqitch param'; +throws_ok { $CLASS->new( target => $target ) } + qr/\QMissing required arguments: sqitch/, + 'Should get an exception for missing sqitch param'; +my $array = []; +throws_ok { $CLASS->new({ sqitch => $array, target => $target }) } + qr/\QReference [] did not pass type constraint "Sqitch"/, + 'Should get an exception for array sqitch param'; +throws_ok { $CLASS->new({ sqitch => $sqitch, target => $array }) } + qr/\QReference [] did not pass type constraint "Target"/, + 'Should get an exception for array target param'; +throws_ok { $CLASS->new({ sqitch => 'foo', target => $target }) } + qr/\QValue "foo" did not pass type constraint "Sqitch"/, + 'Should get an exception for string sqitch param'; +throws_ok { $CLASS->new({ sqitch => $sqitch, target => 'foo' }) } + qr/\QValue "foo" did not pass type constraint "Target"/, + 'Should get an exception for string target param'; + +isa_ok $CLASS->new({sqitch => $sqitch, target => $target}), $CLASS, 'Engine'; + +############################################################################## +# Test load(). +$config->update('core.engine' => 'whu'); +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +ok my $engine = $CLASS->load({ + sqitch => $sqitch, + target => $target, +}), 'Load an engine'; +isa_ok $engine, 'App::Sqitch::Engine::whu'; +is $engine->sqitch, $sqitch, 'The sqitch attribute should be set'; + +# Test handling of an invalid engine. +my $unknown_target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI::db->new('db:nonexistent:') +); +throws_ok { $CLASS->load({ sqitch => $sqitch, target => $unknown_target }) } + 'App::Sqitch::X', 'Should die on unknown target'; +is $@->message, 'Unable to load App::Sqitch::Engine::nonexistent', + 'Should get load error message'; +like $@->previous_exception, qr/\QCan't locate/, + 'Should have relevant previoius exception'; + +NOENGINE: { + # Test handling of no target. + throws_ok { $CLASS->load({ sqitch => $sqitch }) } 'App::Sqitch::X', + 'No target should die'; + is $@->message, 'Missing "target" parameter to load()', + 'It should be the expected message'; +} + +# Test handling a bad engine implementation. +use lib 't/lib'; +my $bad_target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI::db->new('db:bad:') +); +throws_ok { $CLASS->load({ sqitch => $sqitch, target => $bad_target }) } + 'App::Sqitch::X', 'Should die on bad engine module'; +is $@->message, 'Unable to load App::Sqitch::Engine::bad', + 'Should get another load error message'; +like $@->previous_exception, qr/^LOL BADZ/, + 'Should have relevant previoius exception from the bad module'; + + +############################################################################## +# Test name. +can_ok $CLASS, 'name'; +ok $engine = $CLASS->new({ sqitch => $sqitch, target => $target }), + "Create a $CLASS object"; +throws_ok { $engine->name } 'App::Sqitch::X', + 'Should get error from base engine name'; +is $@->ident, 'engine', 'Name error ident should be "engine"'; +is $@->message, __('No engine specified; specify via target or core.engine'), + 'Name error message should be correct'; + +ok $engine = App::Sqitch::Engine::whu->new({sqitch => $sqitch, target => $target}), + 'Create a subclass name object'; +is $engine->name, 'whu', 'Subclass oject name should be "whu"'; +is +App::Sqitch::Engine::whu->name, 'whu', 'Subclass class name should be "whu"'; + +############################################################################## +# Test config_vars. +can_ok $CLASS, 'config_vars'; +is_deeply [App::Sqitch::Engine->config_vars], [ + target => 'any', + registry => 'any', + client => 'any', +], 'Should have database and client in engine base class'; + +############################################################################## +# Test variables. +can_ok $CLASS, qw(variables set_variables clear_variables); +is_deeply [$engine->variables], [], 'Should have no variables'; +ok $engine->set_variables(foo => 'bar'), 'Add a variable'; +is_deeply [$engine->variables], [foo => 'bar'], 'Should have the variable'; +ok $engine->set_variables(foo => 'baz', whu => 'hi', yo => 'stellar'), + 'Set more variables'; +is_deeply {$engine->variables}, {foo => 'baz', whu => 'hi', yo => 'stellar'}, + 'Should have all of the variables'; +$engine->clear_variables; +is_deeply [$engine->variables], [], 'Should again have no variables'; + +############################################################################## +# Test target. +ok $engine = $CLASS->load({ + sqitch => $sqitch, + target => $target, +}), 'Load engine'; +is $engine->target, $target, 'Target should be as passed'; + +# Make sure password is removed from the target. +ok $engine = $CLASS->load({ + sqitch => $sqitch, + target => $target, + uri => URI->new('db:whu://foo:bar@localhost/blah'), +}), 'Load engine with URI with password'; +isa_ok $engine->target, 'App::Sqitch::Target', 'target attribute'; + +############################################################################## +# Test destination. +ok $engine = $CLASS->load({ + sqitch => $sqitch, + target => $target, +}), 'Load engine'; +is $engine->destination, 'db:whu:', 'Destination should be URI string'; +is $engine->registry_destination, $engine->destination, + 'Rgistry destination should be the same as destination'; + +# Make sure password is removed from the destination. +my $long_target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI->new('db:whu://foo:bar@localhost/blah'), +); +ok $engine = $CLASS->load({ + sqitch => $sqitch, + target => $long_target, +}), 'Load engine with URI with password'; +like $engine->destination, qr{^db:whu://foo:?\@localhost/blah$}, + 'Destination should not include password'; +is $engine->registry_destination, $engine->destination, + 'Registry destination should again be the same as destination'; + +############################################################################## +# Test _check_registry. +can_ok $engine, '_check_registry'; +ok $engine->_check_registry, 'Registry should be fine at current version'; + +# Make the registry non-existent. +$registry_version = 0; +$initialized = 0; +throws_ok { $engine->_check_registry } 'App::Sqitch::X', + 'Should get error for non-existent registry'; +is $@->ident, 'engine', 'Non-existent registry error ident should be "engine"'; +is $@->message, __x( + 'No registry found in {destination}. Have you ever deployed?', + destination => $engine->registry_destination, +), 'Non-existent registry error message should be correct'; +$engine->seen; + +# Make sure it's checked on revert and verify. +for my $meth (qw(revert verify)) { + throws_ok { $engine->$meth } 'App::Sqitch::X', "Should get error from $meth"; + is $@->ident, 'engine', qq{$meth registry error ident should be "engine"}; + is $@->message, __x( + 'No registry found in {destination}. Have you ever deployed?', + destination => $engine->registry_destination, + ), "$meth registry error message should be correct"; + $engine->seen; +} + +# Make the registry out-of-date. +$registry_version = 0.1; +throws_ok { $engine->_check_registry } 'App::Sqitch::X', + 'Should get error for out-of-date registry'; +is $@->ident, 'engine', 'Out-of-date registry error ident should be "engine"'; +is $@->message, __x( + 'Registry is at version {old} but latest is {new}. Please run the "upgrade" command', + old => 0.1, + new => $engine->registry_release, +), 'Out-of-date registry error message should be correct'; + +# Send the registry to the future. +$registry_version = 999.99; +throws_ok { $engine->_check_registry } 'App::Sqitch::X', + 'Should get error for future registry'; +is $@->ident, 'engine', 'Future registry error ident should be "engine"'; +is $@->message, __x( + 'Registry version is {old} but {new} is the latest known. Please upgrade Sqitch', + old => 999.99, + new => $engine->registry_release, +), 'Future registry error message should be correct'; + + +# Restore the registry version. +$registry_version = $CLASS->registry_release; + +############################################################################## +# Test abstract methods. +ok $engine = $CLASS->new({ + sqitch => $sqitch, + target => $target, +}), "Create a $CLASS object again"; +for my $abs (qw( + initialized + initialize + register_project + run_file + run_handle + log_deploy_change + log_fail_change + log_revert_change + log_new_tags + is_deployed_tag + is_deployed_change + are_deployed_changes + change_id_for + changes_requiring_change + earliest_change_id + latest_change_id + deployed_changes + deployed_changes_since + load_change + name_for_change_id + current_state + current_changes + current_tags + search_events + registered_projects + change_offset_from_id + change_id_offset_from_id +)) { + throws_ok { $engine->$abs } qr/\Q$CLASS has not implemented $abs()/, + "Should get an unimplemented exception from $abs()" +} + +############################################################################## +# Test _load_changes(). +can_ok $engine, '_load_changes'; +my $now = App::Sqitch::DateTime->now; +my $plan = $target->plan; + +# Mock App::Sqitch::DateTime so that dbchange tags all have the same +# timestamps. +my $mock_dt = Test::MockModule->new('App::Sqitch::DateTime'); +$mock_dt->mock(now => $now); + + +for my $spec ( + [ 'no change' => [] ], + [ 'undef' => [undef] ], + ['no tags' => [ + { + id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + }, + ]], + ['multiple hashes with no tags' => [ + { + id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + }, + { + id => 'ae5b4397f78dfc6072ccf6d505b17f9624d0e3b0', + name => 'booyah', + project => 'engine', + note => 'Whatever', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + }, + ]], + ['tags' => [ + { + id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + tags => [qw(foo bar)], + }, + ]], + ['tags with leading @' => [ + { + id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + tags => [qw(@foo @bar)], + }, + ]], + ['multiple hashes with tags' => [ + { + id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + tags => [qw(foo bar)], + }, + { + id => 'ae5b4397f78dfc6072ccf6d505b17f9624d0e3b0', + name => 'booyah', + project => 'engine', + note => 'Whatever', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + tags => [qw(@foo @bar)], + }, + ]], + ['reworked change' => [ + { + id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + tags => [qw(foo bar)], + }, + { + id => 'df18b5c9739772b210fcf2c4edae095e2f6a4163', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + rtags => [qw(howdy)], + }, + ]], + ['reworked change & multiple tags' => [ + { + id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + tags => [qw(foo bar)], + }, + { + id => 'ae5b4397f78dfc6072ccf6d505b17f9624d0e3b0', + name => 'booyah', + project => 'engine', + note => 'Whatever', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + tags => [qw(@settle)], + }, + { + id => 'df18b5c9739772b210fcf2c4edae095e2f6a4163', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + rtags => [qw(booyah howdy)], + }, + ]], + ['doubly reworked change' => [ + { + id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + tags => [qw(foo bar)], + }, + { + id => 'df18b5c9739772b210fcf2c4edae095e2f6a4163', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + rtags => [qw(howdy)], + tags => [qw(why)], + }, + { + id => 'f38ceb6efcf2a813104b7bb08cc90667033ddf6b', + name => 'howdy', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + rtags => [qw(howdy)], + }, + ]], +) { + my ($desc, $args) = @{ $spec }; + my %seen; + is_deeply [ $engine->_load_changes(@{ $args }) ], [ map { + my $tags = $_->{tags} || []; + my $rtags = $_->{rtags}; + my $c = App::Sqitch::Plan::Change->new(%{ $_ }, plan => $plan ); + $c->add_tag(App::Sqitch::Plan::Tag->new( + name => $_, + plan => $plan, + change => $c, + timestamp => $now, + )) for map { s/^@//; $_ } @{ $tags }; + if (my $dupe = $seen{ $_->{name} }) { + $dupe->add_rework_tags( map { $seen{$_}->tags } @{ $rtags }); + } + $seen{ $_->{name} } = $c; + $c; + } grep { $_ } @{ $args }], "Should load changes with $desc"; +} + +# Rework a change in the plan. +my $you = $plan->get('you'); +my $this_rocks = $plan->get('this/rocks'); +my $hey_there = $plan->get('hey-there'); +ok my $rev_change = $plan->rework( name => 'you' ), 'Rework change "you"'; +ok $plan->tag( name => '@beta1' ), 'Tag @beta1'; + +# Load changes +for my $spec ( + [ 'Unplanned change' => [ + { + id => 'c8a60f1a4fdab2cf91ee7f6da08f4ac52a732b4d', + name => 'you', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + }, + { + id => 'df18b5c9739772b210fcf2c4edae095e2f6a4163', + name => 'this/rocks', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + }, + ]], + [ 'reworked change without reworked version deployed' => [ + { + id => $you->id, + name => $you->name, + project => $you->project, + note => $you->note, + planner_name => $you->planner_name, + planner_email => $you->planner_email, + timestamp => $you->timestamp, + ptags => [ $hey_there->tags, $you->tags ], + }, + { + id => $this_rocks->id, + name => 'this/rocks', + project => 'engine', + note => 'For realz', + planner_name => 'Barack Obama', + planner_email => 'bo@whitehouse.gov', + timestamp => $now, + }, + ]], + [ 'reworked change with reworked version deployed' => [ + { + id => $you->id, + name => $you->name, + project => $you->project, + note => $you->note, + planner_name => $you->planner_name, + planner_email => $you->planner_email, + timestamp => $you->timestamp, + tags => [qw(@foo @bar)], + ptags => [ $hey_there->tags, $you->tags ], + }, + { + id => $rev_change->id, + name => $rev_change->name, + project => 'engine', + note => $rev_change->note, + planner_name => $rev_change->planner_name, + planner_email => $rev_change->planner_email, + timestamp => $rev_change->timestamp, + }, + ]], +) { + my ($desc, $args) = @{ $spec }; + my %seen; + is_deeply [ $engine->_load_changes(@{ $args }) ], [ map { + my $tags = $_->{tags} || []; + my $rtags = $_->{rtags}; + my $ptags = $_->{ptags}; + my $c = App::Sqitch::Plan::Change->new(%{ $_ }, plan => $plan ); + $c->add_tag(App::Sqitch::Plan::Tag->new( + name => $_, + plan => $plan, + change => $c, + timestamp => $now, + )) for map { s/^@//; $_ } @{ $tags }; + my %seen_tags; + if (@{ $ptags || [] }) { + $c->add_rework_tags( @{ $ptags }); + } + if (my $dupe = $seen{ $_->{name} }) { + $dupe->add_rework_tags( map { $seen{$_}->tags } @{ $rtags }); + } + $seen{ $_->{name} } = $c; + $c; + } grep { $_ } @{ $args }], "Should load changes with $desc"; +} + +############################################################################## +# Test deploy_change and revert_change. +ok $engine = App::Sqitch::Engine::whu->new( sqitch => $sqitch, target => $target ), + 'Create a subclass name object again'; +can_ok $engine, 'deploy_change', 'revert_change'; + +my $change = App::Sqitch::Plan::Change->new( name => 'users', plan => $target->plan ); +$engine->max_name_length(length $change->format_name_with_tags); + +ok $engine->deploy_change($change), 'Deploy a change'; +is_deeply $engine->seen, [ + ['begin_work'], + [run_file => $change->deploy_file ], + [log_deploy_change => $change ], + ['finish_work'], +], 'deploy_change should have called the proper methods'; +is_deeply +MockOutput->get_info_literal, [[ + ' + users ..', '' , ' ' +]], 'Output should reflect the deployment'; +is_deeply +MockOutput->get_info, [[__ 'ok' ]], + 'Output should reflect success'; + +# Have it log only. +$engine->log_only(1); +ok $engine->deploy_change($change), 'Only log a change'; +is_deeply $engine->seen, [ + ['begin_work'], + [log_deploy_change => $change ], + ['finish_work'], +], 'log-only deploy_change should not have called run_file'; +is_deeply +MockOutput->get_info_literal, [[ + ' + users ..', '' , ' ' +]], 'Output should reflect the logging'; +is_deeply +MockOutput->get_info, [[__ 'ok' ]], + 'Output should reflect deploy success'; + +# Have it verify. +ok $engine->with_verify(1), 'Enable verification'; +$engine->log_only(0); +ok $engine->deploy_change($change), 'Deploy a change to be verified'; +is_deeply $engine->seen, [ + ['begin_work'], + [run_file => $change->deploy_file ], + [run_file => $change->verify_file ], + [log_deploy_change => $change ], + ['finish_work'], +], 'deploy_change with verification should run the verify file'; +is_deeply +MockOutput->get_info_literal, [[ + ' + users ..', '' , ' ' +]], 'Output should reflect the logging'; +is_deeply +MockOutput->get_info, [[__ 'ok' ]], + 'Output should reflect deploy success'; + +# Have it verify *and* log-only. +ok $engine->log_only(1), 'Enable log_only'; +ok $engine->deploy_change($change), 'Verify and log a change'; +is_deeply $engine->seen, [ + ['begin_work'], + [run_file => $change->verify_file ], + [log_deploy_change => $change ], + ['finish_work'], +], 'deploy_change with verification and log-only should not run deploy'; +is_deeply +MockOutput->get_info_literal, [[ + ' + users ..', '' , ' ' +]], 'Output should reflect the logging'; +is_deeply +MockOutput->get_info, [[__ 'ok' ]], + 'Output should reflect deploy success'; + +# Make it fail. +$die = 'run_file'; +$engine->log_only(0); +throws_ok { $engine->deploy_change($change) } 'App::Sqitch::X', + 'Deploy change with error'; +is $@->message, 'AAAH!', 'Error should be from run_file'; +is_deeply $engine->seen, [ + ['begin_work'], + [log_fail_change => $change ], + ['finish_work'], +], 'Should have logged change failure'; +$die = ''; +is_deeply +MockOutput->get_info_literal, [[ + ' + users ..', '' , ' ' +]], 'Output should reflect the deployment, even with failure'; +is_deeply +MockOutput->get_info, [[__ 'not ok' ]], + 'Output should reflect deploy failure'; + +# Make the verify fail. +$mock_engine->mock( verify_change => sub { hurl 'WTF!' }); +throws_ok { $engine->deploy_change($change) } 'App::Sqitch::X', + 'Deploy change with failed verification'; +is $@->message, __ 'Deploy failed', 'Error should be from deploy_change'; +is_deeply $engine->seen, [ + ['begin_work'], + [run_file => $change->deploy_file ], + ['begin_work'], + [run_file => $change->revert_file ], + [log_fail_change => $change ], + ['finish_work'], +], 'Should have logged verify failure'; +$die = ''; +is_deeply +MockOutput->get_info_literal, [[ + ' + users ..', '' , ' ' +]], 'Output should reflect the deployment, even with verify failure'; +is_deeply +MockOutput->get_info, [[__ 'not ok' ]], + 'Output should reflect deploy failure'; +is_deeply +MockOutput->get_vent, [['WTF!']], + 'Verify error should have been vented'; + +# Make the verify fail with log only. +ok $engine->log_only(1), 'Enable log_only'; +throws_ok { $engine->deploy_change($change) } 'App::Sqitch::X', + 'Deploy change with log-only and failed verification'; +is $@->message, __ 'Deploy failed', 'Error should be from deploy_change'; +is_deeply $engine->seen, [ + ['begin_work'], + ['begin_work'], + [log_fail_change => $change ], + ['finish_work'], +], 'Should have logged verify failure but not reverted'; +$die = ''; +is_deeply +MockOutput->get_info_literal, [[ + ' + users ..', '' , ' ' +]], 'Output should reflect the deployment, even with verify failure'; +is_deeply +MockOutput->get_info, [[__ 'not ok' ]], + 'Output should reflect deploy failure'; +is_deeply +MockOutput->get_vent, [['WTF!']], + 'Verify error should have been vented'; + +# Try a change with no verify file. +$engine->log_only(0); +$mock_engine->unmock( 'verify_change' ); +$change = App::Sqitch::Plan::Change->new( name => 'foo', plan => $target->plan ); +ok $engine->deploy_change($change), 'Deploy a change with no verify script'; +is_deeply $engine->seen, [ + ['begin_work'], + [run_file => $change->deploy_file ], + [log_deploy_change => $change ], + ['finish_work'], +], 'deploy_change with no verify file should not run it'; +is_deeply +MockOutput->get_info_literal, [[ + ' + foo ..', '..' , ' ' +]], 'Output should reflect the logging'; +is_deeply +MockOutput->get_info, [[__ 'ok' ]], + 'Output should reflect deploy success'; +is_deeply +MockOutput->get_vent, [ + [__x 'Verify script {file} does not exist', file => $change->verify_file], +], 'A warning about no verify file should have been emitted'; + +# Alright, disable verify now. +$engine->with_verify(0); + +ok $engine->revert_change($change), 'Revert a change'; +is_deeply $engine->seen, [ + ['begin_work'], + [run_file => $change->revert_file ], + [log_revert_change => $change ], + ['finish_work'], +], 'revert_change should have called the proper methods'; +is_deeply +MockOutput->get_info_literal, [[ + ' - foo ..', '..', ' ' +]], 'Output should reflect reversion'; +is_deeply +MockOutput->get_info, [[__ 'ok']], + 'Output should acknowldge revert success'; + +# Revert with log-only. +ok $engine->log_only(1), 'Enable log_only'; +ok $engine->revert_change($change), 'Revert a change with log-only'; +is_deeply $engine->seen, [ + ['begin_work'], + [log_revert_change => $change ], + ['finish_work'], +], 'Log-only revert_change should not have run the change script'; +is_deeply +MockOutput->get_info_literal, [[ + ' - foo ..', '..', ' ' +]], 'Output should reflect logged reversion'; +is_deeply +MockOutput->get_info, [[__ 'ok']], + 'Output should acknowldge revert success'; +$record_work = 0; + +############################################################################## +# Test earliest_change() and latest_change(). +chdir 't'; +my $plan_file = file qw(sql sqitch.plan); +my $sqitch_old = $sqitch; # Hang on to this because $change does not retain it. +$config->update( + 'core.top_dir' => 'sql', + 'core.plan_file' => $plan_file->stringify, +); +$sqitch = App::Sqitch->new(config => $config); +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +$change = App::Sqitch::Plan::Change->new( name => 'foo', plan => $target->plan ); +ok $engine = App::Sqitch::Engine::whu->new( sqitch => $sqitch, target => $target ), + 'Engine with sqitch with plan file'; +$plan = $target->plan; +my @changes = $plan->changes; + +$latest_change_id = $changes[0]->id; +is $engine->latest_change, $changes[0], 'Should get proper change from latest_change()'; +is_deeply $engine->seen, [[ latest_change_id => undef ]], + 'Latest change ID should have been called with no arg'; +$latest_change_id = $changes[2]->id; +is $engine->latest_change(2), $changes[2], + 'Should again get proper change from latest_change()'; +is_deeply $engine->seen, [[ latest_change_id => 2 ]], + 'Latest change ID should have been called with offset arg'; +$latest_change_id = undef; + +$earliest_change_id = $changes[0]->id; +is $engine->earliest_change, $changes[0], 'Should get proper change from earliest_change()'; +is_deeply $engine->seen, [[ earliest_change_id => undef ]], + 'Earliest change ID should have been called with no arg'; +$earliest_change_id = $changes[2]->id; +is $engine->earliest_change(4), $changes[2], + 'Should again get proper change from earliest_change()'; +is_deeply $engine->seen, [[ earliest_change_id => 4 ]], + 'Earliest change ID should have been called with offset arg'; +$earliest_change_id = undef; + +############################################################################## +# Test _sync_plan() +can_ok $CLASS, '_sync_plan'; +$engine->seen; + +is $plan->position, -1, 'Plan should start at position -1'; +is $engine->start_at, undef, 'start_at should be undef'; + +ok $engine->_sync_plan, 'Sync the plan'; +is $plan->position, -1, 'Plan should still be at position -1'; +is $engine->start_at, undef, 'start_at should still be undef'; +$plan->position(4); +is_deeply $engine->seen, [['current_state', undef]], + 'Should not have updated IDs or hashes'; + +ok $engine->_sync_plan, 'Sync the plan again'; +is $plan->position, -1, 'Plan should again be at position -1'; +is $engine->start_at, undef, 'start_at should again be undef'; +is_deeply $engine->seen, [['current_state', undef]], + 'Still should not have updated IDs or hashes'; + +# Have latest_item return a tag. +$latest_change_id = $changes[2]->id; +ok $engine->_sync_plan, 'Sync the plan to a tag'; +is $plan->position, 2, 'Plan should now be at position 2'; +is $engine->start_at, 'widgets@beta', 'start_at should now be widgets@beta'; +is_deeply $engine->seen, [ + ['current_state', undef], + ['log_new_tags' => $plan->change_at(2)], +], 'Should have updated IDs'; + +# Have current_state return a script hash. +$script_hash = '550aeeab2ae39cba45840888b12a70820a2d6f83'; +ok $engine->_sync_plan, 'Sync the plan with a random script hash'; +is $plan->position, 2, 'Plan should now be at position 1'; +is $engine->start_at, 'widgets@beta', 'start_at should now be widgets@beta'; +is_deeply $engine->seen, [ + ['current_state', undef], + ['log_new_tags' => $plan->change_at(2)], +], 'Should have updated IDs but not hashes'; + +# Have current_state return the last deployed ID as script_hash. +$script_hash = $latest_change_id; +ok $engine->_sync_plan, 'Sync the plan with a random script hash'; +is $plan->position, 2, 'Plan should now be at position 1'; +is $engine->start_at, 'widgets@beta', 'start_at should now be widgets@beta'; +is_deeply $engine->seen, [ + ['current_state', undef], + ['_update_script_hashes'], + ['log_new_tags' => $plan->change_at(2)], +], 'Should have updated IDs and hashes'; + +# Return no change ID, now. +$script_hash = $latest_change_id = $changes[1]->id; +ok $engine->_sync_plan, 'Sync the plan'; +is $plan->position, 1, 'Plan should be at position 1'; +is $engine->start_at, 'users@alpha', 'start_at should be users@alpha'; +is_deeply $engine->seen, [ + ['current_state', undef], + ['_update_script_hashes'], + ['log_new_tags' => $plan->change_at(1)], +], 'Should have updated hashes but not IDs'; + +############################################################################## +# Test deploy. +can_ok $CLASS, 'deploy'; +$script_hash = undef; +$latest_change_id = undef; +$plan->reset; +$engine->seen; +@changes = $plan->changes; + +# Mock the deploy methods to log which were called. +my $deploy_meth; +for my $meth (qw(_deploy_all _deploy_by_tag _deploy_by_change)) { + my $orig = $CLASS->can($meth); + $mock_engine->mock($meth => sub { + $deploy_meth = $meth; + $orig->(@_); + }); +} + +# Mock dependency checking to add its call to the seen stuff. +$mock_engine->mock( check_deploy_dependencies => sub { + shift->mock_check_deploy(@_); +}); +$mock_engine->mock( check_revert_dependencies => sub { + shift->mock_check_revert(@_); +}); + +ok $engine->deploy('@alpha'), 'Deploy to @alpha'; +is $plan->position, 1, 'Plan should be at position 1'; +is_deeply $engine->seen, [ + [current_state => undef], + 'initialized', + 'initialize', + 'register_project', + [check_deploy_dependencies => [$plan, 1]], + [run_file => $changes[0]->deploy_file], + [log_deploy_change => $changes[0]], + [run_file => $changes[1]->deploy_file], + [log_deploy_change => $changes[1]], +], 'Should have deployed through @alpha'; + +is $deploy_meth, '_deploy_all', 'Should have called _deploy_all()'; +is_deeply +MockOutput->get_info, [ + [__x 'Adding registry tables to {destination}', + destination => $engine->registry_destination, + ], + [__x 'Deploying changes through {change} to {destination}', + destination => $engine->destination, + change => $plan->get('@alpha')->format_name_with_tags, + ], + [__ 'ok'], + [__ 'ok'], +], 'Should have seen the output of the deploy to @alpha'; +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '.......', ' '], + [' + users @alpha ..', '', ' '], +], 'Both change names should be output'; + +# Try with log-only in all modes. +for my $mode (qw(change tag all)) { + ok $engine->log_only(1), 'Enable log_only'; + ok $engine->deploy('@alpha', $mode, 1), 'Log-only deploy in $mode mode to @alpha'; + is $plan->position, 1, 'Plan should be at position 1'; + is_deeply $engine->seen, [ + [current_state => undef], + 'initialized', + 'initialize', + 'register_project', + [check_deploy_dependencies => [$plan, 1]], + [log_deploy_change => $changes[0]], + [log_deploy_change => $changes[1]], + ], 'Should have deployed through @alpha without running files'; + + my $meth = $mode eq 'all' ? 'all' : ('by_' . $mode); + is $deploy_meth, "_deploy_$meth", "Should have called _deploy_$meth()"; + is_deeply +MockOutput->get_info, [ + [ + __x 'Adding registry tables to {destination}', + destination => $engine->registry_destination, + ], + [ + __x 'Deploying changes through {change} to {destination}', + destination => $engine->destination, + change => $plan->get('@alpha')->format_name_with_tags, + ], + [__ 'ok'], + [__ 'ok'], + ], 'Should have seen the output of the deploy to @alpha'; + is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '.......', ' '], + [' + users @alpha ..', '', ' '], + ], 'Both change names should be output'; +} + +# Try with no need to initialize. +$initialized = 1; +$plan->reset; +$engine->log_only(0); +ok $engine->deploy('@alpha', 'tag'), 'Deploy to @alpha with tag mode'; +is $plan->position, 1, 'Plan should again be at position 1'; +is_deeply $engine->seen, [ + [current_state => undef], + 'initialized', + 'register_project', + [check_deploy_dependencies => [$plan, 1]], + [run_file => $changes[0]->deploy_file], + [log_deploy_change => $changes[0]], + [run_file => $changes[1]->deploy_file], + [log_deploy_change => $changes[1]], +], 'Should have deployed through @alpha without initialization'; + +is $deploy_meth, '_deploy_by_tag', 'Should have called _deploy_by_tag()'; +is_deeply +MockOutput->get_info, [ + [__x 'Deploying changes through {change} to {destination}', + destination => $engine->registry_destination, + change => $plan->get('@alpha')->format_name_with_tags, + ], + [__ 'ok'], + [__ 'ok'], +], 'Should have seen the output of the deploy to @alpha'; +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '.......', ' '], + [' + users @alpha ..', '', ' '], +], 'Both change names should be output'; + +# Try a bogus change. +throws_ok { $engine->deploy('nonexistent') } 'App::Sqitch::X', + 'Should get an error for an unknown change'; +is $@->message, __x( + 'Unknown change: "{change}"', + change => 'nonexistent', +), 'The exception should report the unknown change'; +is_deeply $engine->seen, [ + [current_state => undef], +], 'Only latest_item() should have been called'; + +# Start with @alpha. +$latest_change_id = ($changes[1]->tags)[0]->id; +ok $engine->deploy('@alpha'), 'Deploy to alpha thrice'; +is_deeply $engine->seen, [ + [current_state => undef], + ['log_new_tags' => $changes[1]], +], 'Only latest_item() should have been called'; +is_deeply +MockOutput->get_info, [ + [__x 'Nothing to deploy (already at "{change}")', change => '@alpha'], +], 'Should notify user that already at @alpha'; + +# Start with widgets. +$latest_change_id = $changes[2]->id; +throws_ok { $engine->deploy('@alpha') } 'App::Sqitch::X', + 'Should fail changeing older change'; +is $@->ident, 'deploy', 'Should be a "deploy" error'; +is $@->message, __ 'Cannot deploy to an earlier change; use "revert" instead', + 'It should suggest using "revert"'; +is_deeply $engine->seen, [ + [current_state => undef], + ['log_new_tags' => $changes[2]], +], 'Should have called latest_item() and latest_tag()'; + +# Make sure we can deploy everything by change. +$latest_change_id = undef; +$plan->reset; +$plan->add( name => 'lolz', note => 'ha ha' ); +@changes = $plan->changes; +ok $engine->deploy(undef, 'change'), 'Deploy everything by change'; +is $plan->position, 3, 'Plan should be at position 3'; +is_deeply $engine->seen, [ + [current_state => undef], + 'initialized', + 'register_project', + [check_deploy_dependencies => [$plan, 3]], + [run_file => $changes[0]->deploy_file], + [log_deploy_change => $changes[0]], + [run_file => $changes[1]->deploy_file], + [log_deploy_change => $changes[1]], + [run_file => $changes[2]->deploy_file], + [log_deploy_change => $changes[2]], + [run_file => $changes[3]->deploy_file], + [log_deploy_change => $changes[3]], +], 'Should have deployed everything'; + +is $deploy_meth, '_deploy_by_change', 'Should have called _deploy_by_change()'; +is_deeply +MockOutput->get_info, [ + [__x 'Deploying changes to {destination}', destination => $engine->destination ], + [__ 'ok'], + [__ 'ok'], + [__ 'ok'], + [__ 'ok'], +], 'Should have emitted deploy announcement and successes'; + +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '........', ' '], + [' + users @alpha ..', '.', ' '], + [' + widgets @beta ..', '', ' '], + [' + lolz ..', '.........', ' '], +], 'Should have seen the output of the deploy to the end'; + +# If we deploy again, it should be up-to-date. +$latest_change_id = $changes[-1]->id; +ok $engine->deploy, 'Should return success for deploy to up-to-date DB'; +is_deeply +MockOutput->get_info, [ + [__ 'Nothing to deploy (up-to-date)' ], +], 'Should have emitted deploy announcement and successes'; +is_deeply $engine->seen, [ + [current_state => undef], +], 'It should have just fetched the latest change ID'; + +$latest_change_id = undef; + +# Try invalid mode. +throws_ok { $engine->deploy(undef, 'evil_mode') } 'App::Sqitch::X', + 'Should fail on invalid mode'; +is $@->ident, 'deploy', 'Should be a "deploy" error'; +is $@->message, __x('Unknown deployment mode: "{mode}"', mode => 'evil_mode'), + 'And the message should reflect the unknown mode'; +is_deeply $engine->seen, [ + [current_state => undef], + 'initialized', + 'register_project', + [check_deploy_dependencies => [$plan, 3]], +], 'It should have check for initialization'; +is_deeply +MockOutput->get_info, [ + [__x 'Deploying changes to {destination}', destination => $engine->destination ], +], 'Should have announced destination'; + +# Try a plan with no changes. +NOSTEPS: { + my $plan_file = file qw(empty.plan); + my $fh = $plan_file->open('>') or die "Cannot open $plan_file: $!"; + say $fh '%project=empty'; + $fh->close or die "Error closing $plan_file: $!"; + END { $plan_file->remove } + $config->update('core.plan_file' => $plan_file->stringify); + my $sqitch = App::Sqitch->new(config => $config); + my $target = App::Sqitch::Target->new(sqitch => $sqitch ); + ok my $engine = App::Sqitch::Engine::whu->new( + sqitch => $sqitch, + target => $target, + ), 'Engine with sqitch with no file'; + $engine->max_name_length(10); + throws_ok { $engine->deploy } 'App::Sqitch::X', 'Should die with no changes'; + is $@->message, __"Nothing to deploy (empty plan)", + 'Should have the localized message'; + is_deeply $engine->seen, [ + [current_state => undef], + ], 'It should have checked for the latest item'; +} + +############################################################################## +# Test _deploy_by_change() +$engine = App::Sqitch::Engine::whu->new(sqitch => $sqitch, target => $target); +$plan->reset; +$mock_engine->unmock('_deploy_by_change'); +$engine->max_name_length( + max map { + length $_->format_name_with_tags + } $plan->changes +); +ok $engine->_deploy_by_change($plan, 1), 'Deploy changewise to index 1'; +is_deeply $engine->seen, [ + [run_file => $changes[0]->deploy_file], + [log_deploy_change => $changes[0]], + [run_file => $changes[1]->deploy_file], + [log_deploy_change => $changes[1]], +], 'Should changewise deploy to index 2'; +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '........', ' '], + [' + users @alpha ..', '.', ' '], +], 'Should have seen output of each change'; +is_deeply +MockOutput->get_info, [[__ 'ok' ], [__ 'ok']], + 'Output should reflect deploy successes'; + +ok $engine->_deploy_by_change($plan, 3), 'Deploy changewise to index 2'; +is_deeply $engine->seen, [ + [run_file => $changes[2]->deploy_file], + [log_deploy_change => $changes[2]], + [run_file => $changes[3]->deploy_file], + [log_deploy_change => $changes[3]], +], 'Should changewise deploy to from index 2 to index 3'; +is_deeply +MockOutput->get_info_literal, [ + [' + widgets @beta ..', '', ' '], + [' + lolz ..', '.........', ' '], +], 'Should have seen output of changes 2-3'; +is_deeply +MockOutput->get_info, [[__ 'ok' ], [__ 'ok']], + 'Output should reflect deploy successes'; + +# Make it die. +$plan->reset; +$die = 'run_file'; +throws_ok { $engine->_deploy_by_change($plan, 2) } 'App::Sqitch::X', + 'Die in _deploy_by_change'; +is $@->message, 'AAAH!', 'It should have died in run_file'; +is_deeply $engine->seen, [ + [log_fail_change => $changes[0] ], +], 'It should have logged the failure'; +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '........', ' '], +], 'Should have seen output for first change'; +is_deeply +MockOutput->get_info, [[__ 'not ok']], + 'Output should reflect deploy failure'; +$die = ''; + +############################################################################## +# Test _deploy_by_tag(). +$plan->reset; +$mock_engine->unmock('_deploy_by_tag'); +ok $engine->_deploy_by_tag($plan, 1), 'Deploy tagwise to index 1'; + +is_deeply $engine->seen, [ + [run_file => $changes[0]->deploy_file], + [log_deploy_change => $changes[0]], + [run_file => $changes[1]->deploy_file], + [log_deploy_change => $changes[1]], +], 'Should tagwise deploy to index 1'; +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '........', ' '], + [' + users @alpha ..', '.', ' '], +], 'Should have seen output of each change'; +is_deeply +MockOutput->get_info, [[__ 'ok' ], [__ 'ok']], + 'Output should reflect deploy successes'; + +ok $engine->_deploy_by_tag($plan, 3), 'Deploy tagwise to index 3'; +is_deeply $engine->seen, [ + [run_file => $changes[2]->deploy_file], + [log_deploy_change => $changes[2]], + [run_file => $changes[3]->deploy_file], + [log_deploy_change => $changes[3]], +], 'Should tagwise deploy from index 2 to index 3'; +is_deeply +MockOutput->get_info_literal, [ + [' + widgets @beta ..', '', ' '], + [' + lolz ..', '.........', ' '], +], 'Should have seen output of changes 3-3'; +is_deeply +MockOutput->get_info, [[__ 'ok' ], [__ 'ok']], + 'Output should reflect deploy successes'; + +# Add another couple of changes. +$plan->add(name => 'tacos' ); +$plan->add(name => 'curry' ); +@changes = $plan->changes; + +# Make it die. +$plan->position(1); +my $mock_whu = Test::MockModule->new('App::Sqitch::Engine::whu'); +$mock_whu->mock(log_deploy_change => sub { hurl 'ROFL' if $_[1] eq $changes[-1] }); +throws_ok { $engine->_deploy_by_tag($plan, $#changes) } 'App::Sqitch::X', + 'Die in log_deploy_change'; +is $@->message, __('Deploy failed'), 'Should get final deploy failure message'; +is_deeply $engine->seen, [ + [run_file => $changes[2]->deploy_file], + [run_file => $changes[3]->deploy_file], + [run_file => $changes[4]->deploy_file], + [run_file => $changes[5]->deploy_file], + [run_file => $changes[5]->revert_file], + [log_fail_change => $changes[5] ], + [run_file => $changes[4]->revert_file], + [log_revert_change => $changes[4]], + [run_file => $changes[3]->revert_file], + [log_revert_change => $changes[3]], +], 'It should have reverted back to the last deployed tag'; + +is_deeply +MockOutput->get_info_literal, [ + [' + widgets @beta ..', '', ' '], + [' + lolz ..', '.........', ' '], + [' + tacos ..', '........', ' '], + [' + curry ..', '........', ' '], + [' - tacos ..', '........', ' '], + [' - lolz ..', '.........', ' '], +], 'Should have seen deploy and revert messages (excluding curry revert)'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], + [__ 'ok' ], + [__ 'ok' ], + [__ 'not ok' ], + [__ 'ok' ], + [__ 'ok' ], +], 'Output should reflect deploy successes and failure'; +is_deeply +MockOutput->get_vent, [ + ['ROFL'], + [__x 'Reverting to {change}', change => 'widgets @beta'] +], 'The original error should have been vented'; +$mock_whu->unmock('log_deploy_change'); + +# Make it die with log-only. +$plan->position(1); +ok $engine->log_only(1), 'Enable log_only'; +$mock_whu->mock(log_deploy_change => sub { hurl 'ROFL' if $_[1] eq $changes[-1] }); +throws_ok { $engine->_deploy_by_tag($plan, $#changes, 1) } 'App::Sqitch::X', + 'Die in log_deploy_change log-only'; +is $@->message, __('Deploy failed'), 'Should get final deploy failure message'; +is_deeply $engine->seen, [ + [log_fail_change => $changes[5] ], + [log_revert_change => $changes[4]], + [log_revert_change => $changes[3]], +], 'It should have run no deploy or revert scripts'; + +is_deeply +MockOutput->get_info_literal, [ + [' + widgets @beta ..', '', ' '], + [' + lolz ..', '.........', ' '], + [' + tacos ..', '........', ' '], + [' + curry ..', '........', ' '], + [' - tacos ..', '........', ' '], + [' - lolz ..', '.........', ' '], +], 'Should have seen deploy and revert messages (excluding curry revert)'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], + [__ 'ok' ], + [__ 'ok' ], + [__ 'not ok' ], + [__ 'ok' ], + [__ 'ok' ], +], 'Output should reflect deploy successes and failure'; +is_deeply +MockOutput->get_vent, [ + ['ROFL'], + [__x 'Reverting to {change}', change => 'widgets @beta'] +], 'The original error should have been vented'; +$mock_whu->unmock('log_deploy_change'); + +# Now have it fail back to the beginning. +$plan->reset; +$engine->log_only(0); +$mock_whu->mock(run_file => sub { die 'ROFL' if $_[1]->basename eq 'users.sql' }); +throws_ok { $engine->_deploy_by_tag($plan, $plan->count -1 ) } 'App::Sqitch::X', + 'Die in _deploy_by_tag again'; +is $@->message, __('Deploy failed'), 'Should again get final deploy failure message'; +is_deeply $engine->seen, [ + [log_deploy_change => $changes[0]], + [log_fail_change => $changes[1]], + [log_revert_change => $changes[0]], +], 'Should have logged back to the beginning'; +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '........', ' '], + [' + users @alpha ..', '.', ' '], + [' - roles ..', '........', ' '], +], 'Should have seen deploy and revert messages'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], + [__ 'not ok' ], + [__ 'ok' ], +], 'Output should reflect deploy successes and failure'; +my $vented = MockOutput->get_vent; +is @{ $vented }, 2, 'Should have one vented message'; +my $errmsg = shift @{ $vented->[0] }; +like $errmsg, qr/^ROFL\b/, 'And it should be the underlying error'; +is_deeply $vented, [ + [], + [__ 'Reverting all changes'], +], 'And it should had notified that all changes were reverted'; + +# Add a change and deploy to that, to make sure it rolls back any changes since +# last tag. +$plan->add(name => 'dr_evil' ); +@changes = $plan->changes; +$plan->reset; +$mock_whu->mock(run_file => sub { hurl 'ROFL' if $_[1]->basename eq 'dr_evil.sql' }); +throws_ok { $engine->_deploy_by_tag($plan, $plan->count -1 ) } 'App::Sqitch::X', + 'Die in _deploy_by_tag yet again'; +is $@->message, __('Deploy failed'), 'Should die "Deploy failed" again'; +is_deeply $engine->seen, [ + [log_deploy_change => $changes[0]], + [log_deploy_change => $changes[1]], + [log_deploy_change => $changes[2]], + [log_deploy_change => $changes[3]], + [log_deploy_change => $changes[4]], + [log_deploy_change => $changes[5]], + [log_fail_change => $changes[6]], + [log_revert_change => $changes[5] ], + [log_revert_change => $changes[4] ], + [log_revert_change => $changes[3] ], +], 'Should have reverted back to last tag'; + +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '........', ' '], + [' + users @alpha ..', '.', ' '], + [' + widgets @beta ..', '', ' '], + [' + lolz ..', '.........', ' '], + [' + tacos ..', '........', ' '], + [' + curry ..', '........', ' '], + [' + dr_evil ..', '......', ' '], + [' - curry ..', '........', ' '], + [' - tacos ..', '........', ' '], + [' - lolz ..', '.........', ' '], +], 'Should have user change reversion messages'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], + [__ 'ok' ], + [__ 'ok' ], + [__ 'ok' ], + [__ 'ok' ], + [__ 'ok' ], + [__ 'not ok' ], + [__ 'ok' ], + [__ 'ok' ], + [__ 'ok' ], +], 'Output should reflect deploy successes and failure'; +is_deeply +MockOutput->get_vent, [ + ['ROFL'], + [__x 'Reverting to {change}', change => 'widgets @beta'] +], 'Should see underlying error and reversion message'; + +# Make it choke on change reversion. +$mock_whu->unmock_all; +$die = ''; +$plan->reset; +$mock_whu->mock(run_file => sub { + hurl 'ROFL' if $_[1] eq $changes[1]->deploy_file; + hurl 'BARF' if $_[1] eq $changes[0]->revert_file; +}); +$mock_whu->mock(start_at => 'whatever'); +throws_ok { $engine->_deploy_by_tag($plan, $plan->count -1 ) } 'App::Sqitch::X', + 'Die in _deploy_by_tag again'; +is $@->message, __('Deploy failed'), 'Should once again get final deploy failure message'; +is_deeply $engine->seen, [ + [log_deploy_change => $changes[0] ], + [log_fail_change => $changes[1] ], +], 'Should have tried to revert one change'; +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '........', ' '], + [' + users @alpha ..', '.', ' '], + [' - roles ..', '........', ' '], +], 'Should have seen revert message'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], + [__ 'not ok' ], + [__ 'not ok' ], +], 'Output should reflect deploy successes and failure'; +is_deeply +MockOutput->get_vent, [ + ['ROFL'], + [__x 'Reverting to {change}', change => 'whatever'], + ['BARF'], + [__ 'The schema will need to be manually repaired'] +], 'Should get reversion failure message'; +$mock_whu->unmock_all; + +############################################################################## +# Test _deploy_all(). +$plan->reset; +$mock_engine->unmock('_deploy_all'); +ok $engine->_deploy_all($plan, 1), 'Deploy all to index 1'; + +is_deeply $engine->seen, [ + [run_file => $changes[0]->deploy_file], + [log_deploy_change => $changes[0]], + [run_file => $changes[1]->deploy_file], + [log_deploy_change => $changes[1]], +], 'Should tagwise deploy to index 1'; +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '........', ' '], + [' + users @alpha ..', '.', ' '], +], 'Should have seen output of each change'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], + [__ 'ok' ], +], 'Output should reflect deploy successes'; + +ok $engine->_deploy_all($plan, 2), 'Deploy tagwise to index 2'; +is_deeply $engine->seen, [ + [run_file => $changes[2]->deploy_file], + [log_deploy_change => $changes[2]], +], 'Should tagwise deploy to from index 1 to index 2'; +is_deeply +MockOutput->get_info_literal, [ + [' + widgets @beta ..', '', ' '], +], 'Should have seen output of changes 3-4'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], +], 'Output should reflect deploy successe'; + +# Make it die. +$plan->reset; +$mock_whu->mock(log_deploy_change => sub { hurl 'ROFL' if $_[1] eq $changes[2] }); +throws_ok { $engine->_deploy_all($plan, 3) } 'App::Sqitch::X', + 'Die in _deploy_all'; +is $@->message, __('Deploy failed'), 'Should get final deploy failure message'; +$mock_whu->unmock('log_deploy_change'); +is_deeply $engine->seen, [ + [run_file => $changes[0]->deploy_file], + [run_file => $changes[1]->deploy_file], + [run_file => $changes[2]->deploy_file], + [run_file => $changes[2]->revert_file], + [log_fail_change => $changes[2]], + [run_file => $changes[1]->revert_file], + [log_revert_change => $changes[1]], + [run_file => $changes[0]->revert_file], + [log_revert_change => $changes[0]], +], 'It should have logged up to the failure'; + +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '........', ' '], + [' + users @alpha ..', '.', ' '], + [' + widgets @beta ..', '', ' '], + [' - users @alpha ..', '.', ' '], + [' - roles ..', '........', ' '], +], 'Should have seen deploy and revert messages excluding revert for failed logging'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], + [__ 'ok' ], + [__ 'not ok' ], + [__ 'ok' ], + [__ 'ok' ], +], 'Output should reflect deploy successes and failures'; +is_deeply +MockOutput->get_vent, [ + ['ROFL'], + [__ 'Reverting all changes'], +], 'The original error should have been vented'; +$die = ''; + +# Make it die with log-only. +$plan->reset; +ok $engine->log_only(1), 'Enable log_only'; +$mock_whu->mock(log_deploy_change => sub { hurl 'ROFL' if $_[1] eq $changes[2] }); +throws_ok { $engine->_deploy_all($plan, 3, 1) } 'App::Sqitch::X', + 'Die in log-only _deploy_all'; +is $@->message, __('Deploy failed'), 'Should get final deploy failure message'; +$mock_whu->unmock('log_deploy_change'); +is_deeply $engine->seen, [ + [log_fail_change => $changes[2]], + [log_revert_change => $changes[1]], + [log_revert_change => $changes[0]], +], 'It should have run no deploys or reverts'; + +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '........', ' '], + [' + users @alpha ..', '.', ' '], + [' + widgets @beta ..', '', ' '], + [' - users @alpha ..', '.', ' '], + [' - roles ..', '........', ' '], +], 'Should have seen deploy and revert messages excluding revert for failed logging'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], + [__ 'ok' ], + [__ 'not ok' ], + [__ 'ok' ], + [__ 'ok' ], +], 'Output should reflect deploy successes and failures'; +is_deeply +MockOutput->get_vent, [ + ['ROFL'], + [__ 'Reverting all changes'], +], 'The original error should have been vented'; +$die = ''; + +# Now have it fail on a later change, should still go all the way back. +$plan->reset; +$engine->log_only(0); +$mock_whu->mock(run_file => sub { hurl 'ROFL' if $_[1]->basename eq 'widgets.sql' }); +throws_ok { $engine->_deploy_all($plan, $plan->count -1 ) } 'App::Sqitch::X', + 'Die in _deploy_all again'; +is $@->message, __('Deploy failed'), 'Should again get final deploy failure message'; +is_deeply $engine->seen, [ + [log_deploy_change => $changes[0]], + [log_deploy_change => $changes[1]], + [log_fail_change => $changes[2]], + [log_revert_change => $changes[1]], + [log_revert_change => $changes[0]], +], 'Should have reveted all changes and tags'; +is_deeply +MockOutput->get_info_literal, [ + [' + roles ..', '........', ' '], + [' + users @alpha ..', '.', ' '], + [' + widgets @beta ..', '', ' '], + [' - users @alpha ..', '.', ' '], + [' - roles ..', '........', ' '], +], 'Should see all changes revert'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], + [__ 'ok' ], + [__ 'not ok' ], + [__ 'ok' ], + [__ 'ok' ], +], 'Output should reflect deploy successes and failures'; +is_deeply +MockOutput->get_vent, [ + ['ROFL'], + [__ 'Reverting all changes'], +], 'Should notifiy user of error and rollback'; + +# Die when starting from a later point. +$plan->position(2); +$engine->start_at('@alpha'); +$mock_whu->mock(run_file => sub { hurl 'ROFL' if $_[1]->basename eq 'dr_evil.sql' }); +throws_ok { $engine->_deploy_all($plan, $plan->count -1 ) } 'App::Sqitch::X', + 'Die in _deploy_all on the last change'; +is $@->message, __('Deploy failed'), 'Should once again get final deploy failure message'; +is_deeply $engine->seen, [ + [log_deploy_change => $changes[3]], + [log_deploy_change => $changes[4]], + [log_deploy_change => $changes[5]], + [log_fail_change => $changes[6]], + [log_revert_change => $changes[5]], + [log_revert_change => $changes[4]], + [log_revert_change => $changes[3]], +], 'Should have deployed to dr_evil and revered down to @alpha'; + +is_deeply +MockOutput->get_info_literal, [ + [' + lolz ..', '.........', ' '], + [' + tacos ..', '........', ' '], + [' + curry ..', '........', ' '], + [' + dr_evil ..', '......', ' '], + [' - curry ..', '........', ' '], + [' - tacos ..', '........', ' '], + [' - lolz ..', '.........', ' '], +], 'Should see changes revert back to @alpha'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], + [__ 'ok' ], + [__ 'ok' ], + [__ 'not ok' ], + [__ 'ok' ], + [__ 'ok' ], + [__ 'ok' ], +], 'Output should reflect deploy successes and failures'; +is_deeply +MockOutput->get_vent, [ + ['ROFL'], + [__x 'Reverting to {change}', change => '@alpha'], +], 'Should notifiy user of error and rollback to @alpha'; +$mock_whu->unmock_all; + +############################################################################## +# Test is_deployed(). +my $tag = App::Sqitch::Plan::Tag->new( + name => 'foo', + change => $change, + plan => $target->plan, +); +$is_deployed_tag = $is_deployed_change = 1; +ok $engine->is_deployed($tag), 'Test is_deployed(tag)'; +is_deeply $engine->seen, [ + [is_deployed_tag => $tag], +], 'It should have called is_deployed_tag()'; + +ok $engine->is_deployed($change), 'Test is_deployed(change)'; +is_deeply $engine->seen, [ + [is_deployed_change => $change], +], 'It should have called is_deployed_change()'; + +############################################################################## +# Test deploy_change. +can_ok $engine, 'deploy_change'; +ok $engine->deploy_change($change), 'Deploy a change'; +is_deeply $engine->seen, [ + [run_file => $change->deploy_file], + [log_deploy_change => $change], +], 'It should have been deployed'; +is_deeply +MockOutput->get_info_literal, [ + [' + foo ..', '..........', ' '] +], 'Should have shown change name'; +is_deeply +MockOutput->get_info, [ + [__ 'ok' ], +], 'Output should reflect deploy success'; + +my $make_deps = sub { + my $conflicts = shift; + return map { + my $dep = App::Sqitch::Plan::Depend->new( + change => $_, + plan => $plan, + project => $plan->project, + conflicts => $conflicts, + ); + $dep; + } @_; +}; + +DEPLOYDIE: { + my $mock_depend = Test::MockModule->new('App::Sqitch::Plan::Depend'); + $mock_depend->mock(id => sub { undef }); + + # Now make it die on the actual deploy. + $die = 'log_deploy_change'; + my @requires = $make_deps->( 0, qw(foo bar) ); + my @conflicts = $make_deps->( 1, qw(dr_evil) ); + my $change = App::Sqitch::Plan::Change->new( + name => 'foo', + plan => $target->plan, + requires => \@requires, + conflicts => \@conflicts, + ); + throws_ok { $engine->deploy_change($change) } 'App::Sqitch::X', + 'Shuld die on deploy failure'; + is $@->message, __ 'Deploy failed', 'Should be told the deploy failed'; + is_deeply $engine->seen, [ + [run_file => $change->deploy_file], + [run_file => $change->revert_file], + [log_fail_change => $change], + ], 'It should failed to have been deployed'; + is_deeply +MockOutput->get_vent, [ + ['AAAH!'], + ], 'Should have vented the original error'; + is_deeply +MockOutput->get_info_literal, [ + [' + foo ..', '..........', ' '], + ], 'Should have shown change name'; + is_deeply +MockOutput->get_info, [ + [__ 'not ok' ], + ], 'Output should reflect deploy failure'; + $die = ''; +} + +############################################################################## +# Test revert_change(). +can_ok $engine, 'revert_change'; +ok $engine->revert_change($change), 'Revert the change'; +is_deeply $engine->seen, [ + [run_file => $change->revert_file], + [log_revert_change => $change], +], 'It should have been reverted'; +is_deeply +MockOutput->get_info_literal, [ + [' - foo ..', '..........', ' '] +], 'Should have shown reverted change name'; +is_deeply +MockOutput->get_info, [ + [__ 'ok'], +], 'And the revert failure should be "ok"'; + +############################################################################## +# Test revert(). +can_ok $engine, 'revert'; +$engine->plan($plan); + +# Start with no deployed IDs. +@deployed_changes = (); +ok $engine->revert, + 'Should return success for no changes to revert'; +is_deeply +MockOutput->get_info, [ + [__ 'Nothing to revert (nothing deployed)'] +], 'Should have notified that there is nothing to revert'; +is_deeply $engine->seen, [ + [deployed_changes => undef], +], 'It should only have called deployed_changes()'; +is_deeply +MockOutput->get_info, [], 'Nothing should have been output'; + +# Try reverting to an unknown change. +throws_ok { $engine->revert('nonexistent') } 'App::Sqitch::X', + 'Revert should die on unknown change'; +is $@->ident, 'revert', 'Should be another "revert" error'; +is $@->message, __x( + 'Unknown change: "{change}"', + change => 'nonexistent', +), 'The message should mention it is an unknown change'; +is_deeply $engine->seen, [['change_id_for', { + change_id => undef, + change => 'nonexistent', + tag => undef, + project => 'sql', +}]], 'Should have called change_id_for() with change name'; +is_deeply +MockOutput->get_info, [], 'Nothing should have been output'; + +# Try reverting to an unknown change ID. +throws_ok { $engine->revert('8d77c5f588b60bc0f2efcda6369df5cb0177521d') } 'App::Sqitch::X', + 'Revert should die on unknown change ID'; +is $@->ident, 'revert', 'Should be another "revert" error'; +is $@->message, __x( + 'Unknown change: "{change}"', + change => '8d77c5f588b60bc0f2efcda6369df5cb0177521d', +), 'The message should mention it is an unknown change'; +is_deeply $engine->seen, [['change_id_for', { + change_id => '8d77c5f588b60bc0f2efcda6369df5cb0177521d', + change => undef, + tag => undef, + project => 'sql', +}]], 'Should have called change_id_for() with change ID'; +is_deeply +MockOutput->get_info, [], 'Nothing should have been output'; + +# Revert an undeployed change. +throws_ok { $engine->revert('@alpha') } 'App::Sqitch::X', + 'Revert should die on undeployed change'; +is $@->ident, 'revert', 'Should be another "revert" error'; +is $@->message, __x( + 'Change not deployed: "{change}"', + change => '@alpha', +), 'The message should mention that the change is not deployed'; +is_deeply $engine->seen, [['change_id_for', { + change => '', + change_id => undef, + tag => 'alpha', + project => 'sql', +}]], 'change_id_for'; +is_deeply +MockOutput->get_info, [], 'Nothing should have been output'; + +# Revert to a point with no following changes. +$offset_change = $changes[0]; +push @resolved => $offset_change->id; +ok $engine->revert($changes[0]->id), + 'Should return success for revert even with no changes'; +is_deeply +MockOutput->get_info, [ + [__x( + 'No changes deployed since: "{change}"', + change => $changes[0]->id, + )] +], 'No subsequent change error message should be correct'; + +delete $changes[0]->{_rework_tags}; # For deep comparison. +is_deeply $engine->seen, [ + [change_id_for => { + change_id => $changes[0]->id, + change => undef, + tag => undef, + project => 'sql', + }], + [ change_offset_from_id => [$changes[0]->id, 0] ], + [deployed_changes_since => $changes[0]], +], 'Should have called change_id_for and deployed_changes_since'; + +# Revert with nothing deployed. +ok $engine->revert, + 'Should return success for known but undeployed change'; +is_deeply +MockOutput->get_info, [ + [__ 'Nothing to revert (nothing deployed)'] +], 'No changes message should be correct'; + +is_deeply $engine->seen, [ + [deployed_changes => undef], +], 'Should have called deployed_changes'; + +# Now revert from a deployed change. +my @dbchanges; +@deployed_changes = map { + my $plan_change = $_; + my $params = { + id => $plan_change->id, + name => $plan_change->name, + project => $plan_change->project, + note => $plan_change->note, + planner_name => $plan_change->planner_name, + planner_email => $plan_change->planner_email, + timestamp => $plan_change->timestamp, + tags => [ map { $_->name } $plan_change->tags ], + }; + push @dbchanges => my $db_change = App::Sqitch::Plan::Change->new( + plan => $plan, + %{ $params }, + ); + $db_change->add_tag( App::Sqitch::Plan::Tag->new( + name => $_->name, plan => $plan, change => $db_change + ) ) for $plan_change->tags; + $db_change->tags; # Autovivify _tags For changes with no tags. + $params; +} @changes[0..3]; + +MockOutput->ask_yes_no_returns(1); +ok $engine->revert, 'Revert all changes'; +is_deeply $engine->seen, [ + [deployed_changes => undef], + [check_revert_dependencies => [reverse @dbchanges[0..3]] ], + [run_file => $dbchanges[3]->revert_file ], + [log_revert_change => $dbchanges[3] ], + [run_file => $dbchanges[2]->revert_file ], + [log_revert_change => $dbchanges[2] ], + [run_file => $dbchanges[1]->revert_file ], + [log_revert_change => $dbchanges[1] ], + [run_file => $dbchanges[0]->revert_file ], + [log_revert_change => $dbchanges[0] ], +], 'Should have reverted the changes in reverse order'; +is_deeply +MockOutput->get_ask_yes_no, [ + [__x( + 'Revert all changes from {destination}?', + destination => $engine->destination, + ), 1], +], 'Should have prompted to revert all changes'; +is_deeply +MockOutput->get_info_literal, [ + [' - lolz ..', '.........', ' '], + [' - widgets @beta ..', '', ' '], + [' - users @alpha ..', '.', ' '], + [' - roles ..', '........', ' '], +], 'It should have said it was reverting all changes and listed them'; +is_deeply +MockOutput->get_info, [ + [__ 'ok'], + [__ 'ok'], + [__ 'ok'], + [__ 'ok'], +], 'And the revert successes should be emitted'; + +# Try with log-only. +ok $engine->log_only(1), 'Enable log_only'; +ok $engine->revert(undef, 1), 'Revert all changes log-only'; +delete @{ $_ }{qw(_path_segments _rework_tags)} for @dbchanges; # These need to be invisible. +is_deeply $engine->seen, [ + [deployed_changes => undef], + [check_revert_dependencies => [reverse @dbchanges[0..3]] ], + [log_revert_change => $dbchanges[3] ], + [log_revert_change => $dbchanges[2] ], + [log_revert_change => $dbchanges[1] ], + [log_revert_change => $dbchanges[0] ], +], 'Log-only Should have reverted the changes in reverse order'; +is_deeply +MockOutput->get_ask_yes_no, [ + [__x( + 'Revert all changes from {destination}?', + destination => $engine->destination, + ), 1], +], 'Log-only should have prompted to revert all changes'; +is_deeply +MockOutput->get_info_literal, [ + [' - lolz ..', '.........', ' '], + [' - widgets @beta ..', '', ' '], + [' - users @alpha ..', '.', ' '], + [' - roles ..', '........', ' '], +], 'It should have said it was reverting all changes and listed them'; +is_deeply +MockOutput->get_info, [ + [__ 'ok'], + [__ 'ok'], + [__ 'ok'], + [__ 'ok'], +], 'And the revert successes should be emitted'; + +# Should exit if the revert is declined. +MockOutput->ask_yes_no_returns(0); +throws_ok { $engine->revert } 'App::Sqitch::X', 'Should abort declined revert'; +is $@->ident, 'revert', 'Declined revert ident should be "revert"'; +is $@->exitval, 1, 'Should have exited with value 1'; +is $@->message, __ 'Nothing reverted', 'Should have exited with proper message'; +is_deeply $engine->seen, [ + [deployed_changes => undef], +], 'Should have called deployed_changes only'; +is_deeply +MockOutput->get_ask_yes_no, [ + [__x( + 'Revert all changes from {destination}?', + destination => $engine->destination, + ), 1], +], 'Should have prompt to revert all changes'; +is_deeply +MockOutput->get_info, [ +], 'It should have emitted nothing else'; + +# Revert all changes with no prompt. +MockOutput->ask_yes_no_returns(1); +$engine->log_only(0); +$engine->no_prompt(1); +ok $engine->revert, 'Revert all changes with no prompt'; +is_deeply $engine->seen, [ + [deployed_changes => undef], + [check_revert_dependencies => [reverse @dbchanges[0..3]] ], + [run_file => $dbchanges[3]->revert_file ], + [log_revert_change => $dbchanges[3] ], + [run_file => $dbchanges[2]->revert_file ], + [log_revert_change => $dbchanges[2] ], + [run_file => $dbchanges[1]->revert_file ], + [log_revert_change => $dbchanges[1] ], + [run_file => $dbchanges[0]->revert_file ], + [log_revert_change => $dbchanges[0] ], +], 'Should have reverted the changes in reverse order'; +is_deeply +MockOutput->get_ask_yes_no, [], 'Should have no prompt'; + +is_deeply +MockOutput->get_info_literal, [ + [' - lolz ..', '.........', ' '], + [' - widgets @beta ..', '', ' '], + [' - users @alpha ..', '.', ' '], + [' - roles ..', '........', ' '], +], 'It should have said it was reverting all changes and listed them'; +is_deeply +MockOutput->get_info, [ + [__x( + 'Reverting all changes from {destination}', + destination => $engine->destination, + )], + [__ 'ok'], + [__ 'ok'], + [__ 'ok'], + [__ 'ok'], +], 'And the revert successes should be emitted'; + +# Now just revert to an earlier change. +$engine->no_prompt(0); +$offset_change = $dbchanges[1]; +push @resolved => $offset_change->id; +@deployed_changes = @deployed_changes[2..3]; +ok $engine->revert('@alpha'), 'Revert to @alpha'; + +delete $dbchanges[1]->{_rework_tags}; # These need to be invisible. +is_deeply $engine->seen, [ + [change_id_for => { change_id => undef, change => '', tag => 'alpha', project => 'sql' }], + [ change_offset_from_id => [$dbchanges[1]->id, 0] ], + [deployed_changes_since => $dbchanges[1]], + [check_revert_dependencies => [reverse @dbchanges[2..3]] ], + [run_file => $dbchanges[3]->revert_file ], + [log_revert_change => $dbchanges[3] ], + [run_file => $dbchanges[2]->revert_file ], + [log_revert_change => $dbchanges[2] ], +], 'Should have reverted only changes after @alpha'; +is_deeply +MockOutput->get_ask_yes_no, [ + [__x( + 'Revert changes to {change} from {destination}?', + destination => $engine->destination, + change => $dbchanges[1]->format_name_with_tags, + ), 1], +], 'Should have prompt to revert to change'; +is_deeply +MockOutput->get_info_literal, [ + [' - lolz ..', '.........', ' '], + [' - widgets @beta ..', '', ' '], +], 'Output should show what it reverts to'; +is_deeply +MockOutput->get_info, [ + [__ 'ok'], + [__ 'ok'], +], 'And the revert successes should be emitted'; + +MockOutput->ask_yes_no_returns(0); +$offset_change = $dbchanges[1]; +push @resolved => $offset_change->id; +throws_ok { $engine->revert('@alpha') } 'App::Sqitch::X', + 'Should abort declined revert to @alpha'; +is $@->ident, 'revert:confirm', 'Declined revert ident should be "revert:confirm"'; +is $@->exitval, 1, 'Should have exited with value 1'; +is $@->message, __ 'Nothing reverted', 'Should have exited with proper message'; +is_deeply $engine->seen, [ + [change_id_for => { change_id => undef, change => '', tag => 'alpha', project => 'sql' }], + [change_offset_from_id => [$dbchanges[1]->id, 0] ], + [deployed_changes_since => $dbchanges[1]], +], 'Should have called revert methods'; +is_deeply +MockOutput->get_ask_yes_no, [ + [__x( + 'Revert changes to {change} from {destination}?', + change => $dbchanges[1]->format_name_with_tags, + destination => $engine->destination, + ), 1], +], 'Should have prompt to revert to @alpha'; +is_deeply +MockOutput->get_info, [ +], 'It should have emitted nothing else'; + +# Try to revert just the last change with no prompt +MockOutput->ask_yes_no_returns(1); +$engine->no_prompt(1); +my $rev_file = $dbchanges[-1]->revert_file; # Grab before deleting _rework_tags. +my $rtags = delete $dbchanges[-1]->{_rework_tags}; # These need to be invisible. +$offset_change = $dbchanges[-1]; +push @resolved => $offset_change->id; +@deployed_changes = $deployed_changes[-1]; +ok $engine->revert('@HEAD^'), 'Revert to @HEAD^'; +is_deeply $engine->seen, [ + [change_id_for => { change_id => undef, change => '', tag => 'HEAD', project => 'sql' }], + [change_offset_from_id => [$dbchanges[-1]->id, -1] ], + [deployed_changes_since => $dbchanges[-1]], + [check_revert_dependencies => [{ %{ $dbchanges[-1] }, _rework_tags => $rtags }] ], + [run_file => $rev_file ], + [log_revert_change => { %{ $dbchanges[-1] }, _rework_tags => $rtags } ], +], 'Should have reverted one changes for @HEAD^'; +is_deeply +MockOutput->get_ask_yes_no, [], 'Should have no prompt'; +is_deeply +MockOutput->get_info_literal, [ + [' - lolz ..', '', ' '], +], 'Output should show what it reverts to'; +is_deeply +MockOutput->get_info, [ + [__x( + 'Reverting changes to {change} from {destination}', + destination => $engine->destination, + change => $dbchanges[-1]->format_name_with_tags, + )], + [__ 'ok'], +], 'And the header and "ok" should be emitted'; + +############################################################################## +# Test change_id_for_depend(). +can_ok $CLASS, 'change_id_for_depend'; + +$offset_change = $dbchanges[1]; +my ($dep) = $make_deps->( 1, 'foo' ); +throws_ok { $engine->change_id_for_depend( $dep ) } 'App::Sqitch::X', + 'Should get error from change_id_for_depend when change not in plan'; +is $@->ident, 'plan', 'Should get ident "plan" from change_id_for_depend'; +is $@->message, __x( + 'Unable to find change "{change}" in plan {file}', + change => $dep->key_name, + file => $target->plan_file, +), 'Should have proper message from change_id_for_depend error'; + +PLANOK: { + my $mock_depend = Test::MockModule->new('App::Sqitch::Plan::Depend'); + $mock_depend->mock(id => sub { undef }); + $mock_depend->mock(change => sub { undef }); + throws_ok { $engine->change_id_for_depend( $dep ) } 'App::Sqitch::X', + 'Should get error from change_id_for_depend when no ID'; + is $@->ident, 'engine', 'Should get ident "engine" when no ID'; + is $@->message, __x( + 'Invalid dependency: {dependency}', + dependency => $dep->as_string, + ), 'Should have proper messag from change_id_for_depend error'; + + # Let it have the change. + $mock_depend->unmock('change'); + + push @resolved => $changes[1]->id; + is $engine->change_id_for_depend( $dep ), $changes[1]->id, + 'Get a change id'; + is_deeply $engine->seen, [ + [change_id_for => { + change_id => $dep->id, + change => $dep->change, + tag => $dep->tag, + project => $dep->project, + first => 1, + }], + ], 'Should have passed dependency params to change_id_for()'; +} + +############################################################################## +# Test find_change(). +can_ok $CLASS, 'find_change'; +push @resolved => $dbchanges[1]->id; +is $engine->find_change( + change_id => $resolved[0], + change => 'hi', + tag => 'yo', +), $dbchanges[1], 'find_change() should work'; +is_deeply $engine->seen, [ + [change_id_for => { + change_id => $dbchanges[1]->id, + change => 'hi', + tag => 'yo', + project => 'sql', + }], + [change_offset_from_id => [ $dbchanges[1]->id, undef ]], +], 'Its parameters should have been passed to change_id_for and change_offset_from_id'; + +# Pass a project and an ofset. +push @resolved => $dbchanges[1]->id; +is $engine->find_change( + change => 'hi', + offset => 1, + project => 'fred', +), $dbchanges[1], 'find_change() should work'; +is_deeply $engine->seen, [ + [change_id_for => { + change_id => undef, + change => 'hi', + tag => undef, + project => 'fred', + }], + [change_offset_from_id => [ $dbchanges[1]->id, 1 ]], +], 'Project and offset should have been passed off'; + +############################################################################## +# Test find_change_id(). +can_ok $CLASS, 'find_change_id'; +push @resolved => $dbchanges[1]->id; +is $engine->find_change_id( + change_id => $resolved[0], + change => 'hi', + tag => 'yo', +), $dbchanges[1]->id, 'find_change_id() should work'; +is_deeply $engine->seen, [ + [change_id_for => { + change_id => $dbchanges[1]->id, + change => 'hi', + tag => 'yo', + project => 'sql', + }], + [change_id_offset_from_id => [ $dbchanges[1]->id, undef ]], +], 'Its parameters should have been passed to change_id_for and change_offset_from_id'; + +# Pass a project and an ofset. +push @resolved => $dbchanges[1]->id; +is $engine->find_change_id( + change => 'hi', + offset => 1, + project => 'fred', +), $dbchanges[1]->id, 'find_change_id() should work'; +is_deeply $engine->seen, [ + [change_id_for => { + change_id => undef, + change => 'hi', + tag => undef, + project => 'fred', + }], + [change_id_offset_from_id => [ $dbchanges[1]->id, 1 ]], +], 'Project and offset should have been passed off'; + +############################################################################## +# Test verify_change(). +can_ok $CLASS, 'verify_change'; +$change = App::Sqitch::Plan::Change->new( name => 'users', plan => $target->plan ); +ok $engine->verify_change($change), 'Verify a change'; +is_deeply $engine->seen, [ + [run_file => $change->verify_file ], +], 'The change file should have been run'; +is_deeply +MockOutput->get_info, [], 'Should have no info output'; + +# Try a change with no verify script. +$change = App::Sqitch::Plan::Change->new( name => 'roles', plan => $target->plan ); +ok $engine->verify_change($change), 'Verify a change with no verify script.'; +is_deeply $engine->seen, [], 'No abstract methods should be called'; +is_deeply +MockOutput->get_info, [], 'Should have no info output'; +is_deeply +MockOutput->get_vent, [ + [__x 'Verify script {file} does not exist', file => $change->verify_file], +], 'A warning about no verify file should have been emitted'; + +############################################################################## +# Test check_deploy_dependenices(). +$mock_engine->unmock('check_deploy_dependencies'); +can_ok $engine, 'check_deploy_dependencies'; + +CHECK_DEPLOY_DEPEND: { + # Make sure dependencies check out for all the existing changes. + $plan->reset; + ok $engine->check_deploy_dependencies($plan), + 'All planned changes should be okay'; + is_deeply $engine->seen, [ + [ are_deployed_changes => [map { $plan->change_at($_) } 0..$plan->count - 1] ], + ], 'Should have called are_deployed_changes'; + + # Make sure it works when depending on a previous change. + my $change = $plan->change_at(3); + push @{ $change->_requires } => $make_deps->( 0, 'users' ); + ok $engine->check_deploy_dependencies($plan), + 'Dependencies should check out even when within those to be deployed'; + is_deeply [ map { $_->resolved_id } map { $_->requires } $plan->changes ], + [ $plan->change_at(1)->id ], + 'Resolved ID should be populated'; + + # Make sure it fails if there is a conflict within those to be deployed. + push @{ $change->_conflicts } => $make_deps->( 1, 'widgets' ); + throws_ok { $engine->check_deploy_dependencies($plan) } 'App::Sqitch::X', + 'Conflict should throw exception'; + is $@->ident, 'deploy', 'Should be a "deploy" error'; + is $@->message, __nx( + 'Conflicts with previously deployed change: {changes}', + 'Conflicts with previously deployed changes: {changes}', + scalar 1, + changes => 'widgets', + ), 'Should have localized message about the local conflict'; + shift @{ $change->_conflicts }; + + # Now test looking stuff up in the database. + my $mock_depend = Test::MockModule->new('App::Sqitch::Plan::Depend'); + my @depend_ids; + $mock_depend->mock(id => sub { shift @depend_ids }); + + my @conflicts = $make_deps->( 1, qw(foo bar) ); + $change = App::Sqitch::Plan::Change->new( + name => 'foo', + plan => $target->plan, + conflicts => \@conflicts, + ); + $plan->_changes->append($change); + + my $start_from = $plan->count - 1; + $plan->position( $start_from - 1); + push @resolved, '2342', '253245'; + throws_ok { $engine->check_deploy_dependencies($plan, $start_from) } 'App::Sqitch::X', + 'Conflict should throw exception'; + is $@->ident, 'deploy', 'Should be a "deploy" error'; + is $@->message, __nx( + 'Conflicts with previously deployed change: {changes}', + 'Conflicts with previously deployed changes: {changes}', + scalar 2, + changes => 'foo bar', + ), 'Should have localized message about conflicts'; + + is_deeply $engine->seen, [ + [ are_deployed_changes => [map { $plan->change_at($_) } 0..$start_from-1] ], + [ change_id_for => { + change_id => undef, + change => 'foo', + tag => undef, + project => 'sql', + first => 1, + } ], + [ change_id_for => { + change_id => undef, + change => 'bar', + tag => undef, + project => 'sql', + first => 1, + } ], + ], 'Should have called change_id_for() twice'; + is_deeply [ map { $_->resolved_id } @conflicts ], [undef, undef], + 'Conflicting dependencies should have no resolved IDs'; + + # Fail with multiple conflicts. + push @{ $plan->change_at(3)->_conflicts } => $make_deps->( 1, 'widgets' ); + $plan->reset; + push @depend_ids => $plan->change_at(2)->id; + push @resolved, '2342', '253245', '2323434'; + throws_ok { $engine->check_deploy_dependencies($plan) } 'App::Sqitch::X', + 'Conflict should throw another exception'; + is $@->ident, 'deploy', 'Should be a "deploy" error'; + is $@->message, __nx( + 'Conflicts with previously deployed change: {changes}', + 'Conflicts with previously deployed changes: {changes}', + scalar 3, + changes => 'widgets foo bar', + ), 'Should have localized message about all three conflicts'; + + is_deeply $engine->seen, [ + [ change_id_for => { + change_id => undef, + change => 'users', + tag => undef, + project => 'sql', + first => 1, + } ], + [ change_id_for => { + change_id => undef, + change => 'foo', + tag => undef, + project => 'sql', + first => 1, + } ], + [ change_id_for => { + change_id => undef, + change => 'bar', + tag => undef, + project => 'sql', + first => 1, + } ], + ], 'Should have called change_id_for() twice'; + is_deeply [ map { $_->resolved_id } @conflicts ], [undef, undef], + 'Conflicting dependencies should have no resolved IDs'; + + ########################################################################## + # Die on missing dependencies. + my @requires = $make_deps->( 0, qw(foo bar foo) ); + $change = App::Sqitch::Plan::Change->new( + name => 'blah', + plan => $target->plan, + requires => \@requires, + ); + $plan->_changes->append($change); + $start_from = $plan->count - 1; + $plan->position( $start_from - 1); + + push @resolved, undef, undef; + throws_ok { $engine->check_deploy_dependencies($plan, $start_from) } 'App::Sqitch::X', + 'Missing dependencies should throw exception'; + is $@->ident, 'deploy', 'Should be another "deploy" error'; + is $@->message, __nx( + 'Missing required change: {changes}', + 'Missing required changes: {changes}', + scalar 2, + changes => 'foo bar', + ), 'Should have localized message missing dependencies without dupes'; + + is_deeply $engine->seen, [ + [ change_id_for => { + change_id => undef, + change => 'foo', + tag => undef, + project => 'sql', + first => 1, + } ], + [ change_id_for => { + change_id => undef, + change => 'bar', + tag => undef, + project => 'sql', + first => 1, + } ], + [ change_id_for => { + change_id => undef, + change => 'foo', + tag => undef, + project => 'sql', + first => 1, + } ], + ], 'Should have called check_requires'; + is_deeply [ map { $_->resolved_id } @requires ], [undef, undef, undef], + 'Missing requirements should not have resolved'; + + # Make sure we see both conflict and prereq failures. + push @resolved, '2342', '253245', '2323434', undef, undef; + $plan->reset; + + throws_ok { $engine->check_deploy_dependencies($plan, $start_from) } 'App::Sqitch::X', + 'Missing dependencies should throw exception'; + is $@->ident, 'deploy', 'Should be another "deploy" error'; + is $@->message, join( + "\n", + __nx( + 'Conflicts with previously deployed change: {changes}', + 'Conflicts with previously deployed changes: {changes}', + scalar 3, + changes => 'widgets foo', + ), + __nx( + 'Missing required change: {changes}', + 'Missing required changes: {changes}', + scalar 2, + changes => 'foo bar', + ), + ), 'Should have localized conflicts and required error messages'; + + is_deeply $engine->seen, [ + [ change_id_for => { + change_id => undef, + change => 'widgets', + tag => undef, + project => 'sql', + first => 1, + } ], + [ change_id_for => { + change_id => undef, + change => 'users', + tag => undef, + project => 'sql', + first => 1, + } ], + [ change_id_for => { + change_id => undef, + change => 'foo', + tag => undef, + project => 'sql', + first => 1, + } ], + [ change_id_for => { + change_id => undef, + change => 'bar', + tag => undef, + project => 'sql', + first => 1, + } ], + [ change_id_for => { + change_id => undef, + change => 'foo', + tag => undef, + project => 'sql', + first => 1, + } ], + [ change_id_for => { + change_id => undef, + change => 'bar', + tag => undef, + project => 'sql', + first => 1, + } ], + [ change_id_for => { + change_id => undef, + change => 'foo', + tag => undef, + project => 'sql', + first => 1, + } ], + ], 'Should have called check_requires'; + is_deeply [ map { $_->resolved_id } @requires ], [undef, undef, undef], + 'Missing requirements should not have resolved'; +} + +# Test revert dependency-checking. +$mock_engine->unmock('check_revert_dependencies'); +can_ok $engine, 'check_revert_dependencies'; + +CHECK_REVERT_DEPEND: { + my $change = App::Sqitch::Plan::Change->new( + name => 'urfa', + id => '24234234234e', + plan => $plan, + ); + + # Have revert change fail with requiring changes. + my $req = { + change_id => '23234234', + change => 'blah', + asof_tag => undef, + project => $plan->project, + }; + @requiring = [$req]; + + throws_ok { $engine->check_revert_dependencies($change) } 'App::Sqitch::X', + 'Should get error reverting change another depend on'; + is $@->ident, 'revert', 'Dependent error ident should be "revert"'; + is $@->message, __nx( + 'Change "{change}" required by currently deployed change: {changes}', + 'Change "{change}" required by currently deployed changes: {changes}', + 1, + change => 'urfa', + changes => 'blah' + ), 'Dependent error message should be correct'; + is_deeply $engine->seen, [ + [changes_requiring_change => $change ], + ], 'It should have check for requiring changes'; + + # Add a second requiring change. + my $req2 = { + change_id => '99999', + change => 'harhar', + asof_tag => '@foo', + project => 'elsewhere', + }; + @requiring = [$req, $req2]; + + throws_ok { $engine->check_revert_dependencies($change) } 'App::Sqitch::X', + 'Should get error reverting change others depend on'; + is $@->ident, 'revert', 'Dependent error ident should be "revert"'; + is $@->message, __nx( + 'Change "{change}" required by currently deployed change: {changes}', + 'Change "{change}" required by currently deployed changes: {changes}', + 2 , + change => 'urfa', + changes => 'blah elsewhere:harhar@foo' + ), 'Dependent error message should be correct'; + is_deeply $engine->seen, [ + [changes_requiring_change => $change ], + ], 'It should have check for requiring changes'; + + # Try it with two changes. + my $req3 = { + change_id => '94949494', + change => 'frobisher', + project => 'whu', + }; + @requiring = ([$req, $req2], [$req3]); + + my $change2 = App::Sqitch::Plan::Change->new( + name => 'kazane', + id => '8686868686', + plan => $plan, + ); + + throws_ok { $engine->check_revert_dependencies($change, $change2) } 'App::Sqitch::X', + 'Should get error reverting change others depend on'; + is $@->ident, 'revert', 'Dependent error ident should be "revert"'; + is $@->message, join( + "\n", + __nx( + 'Change "{change}" required by currently deployed change: {changes}', + 'Change "{change}" required by currently deployed changes: {changes}', + 2 , + change => 'urfa', + changes => 'blah elsewhere:harhar@foo' + ), + __nx( + 'Change "{change}" required by currently deployed change: {changes}', + 'Change "{change}" required by currently deployed changes: {changes}', + 1, + change => 'kazane', + changes => 'whu:frobisher' + ), + ), 'Dependent error message should be correct'; + is_deeply $engine->seen, [ + [changes_requiring_change => $change ], + [changes_requiring_change => $change2 ], + ], 'It should have checked twice for requiring changes'; +} + +############################################################################## +# Test _trim_to(). +can_ok $engine, '_trim_to'; + +# Should get an error when a change is not in the plan. +throws_ok { $engine->_trim_to( 'foo', 'nonexistent', [] ) } 'App::Sqitch::X', + '_trim_to should complain about a nonexistent change key'; +is $@->ident, 'foo', '_trim_to nonexistent key error ident should be "foo"'; +is $@->message, __x( + 'Cannot find "{change}" in the database or the plan', + change => 'nonexistent', +), '_trim_to nonexistent key error message should be correct'; +is_deeply $engine->seen, [ + [ change_id_for => { + change => 'nonexistent', + change_id => undef, + project => 'sql', + tag => undef, + } ] +], 'It should have passed the change name and ROOT tag to change_id_for'; + +# Should get an error when it's in the plan but not the database. +throws_ok { $engine->_trim_to( 'yep', 'blah', [] ) } 'App::Sqitch::X', + '_trim_to should complain about an undeployed change key'; +is $@->ident, 'yep', '_trim_to undeployed change error ident should be "yep"'; +is $@->message, __x( + 'Change "{change}" has not been deployed', + change => 'blah', +), '_trim_to undeployed change error message should be correct'; +is_deeply $engine->seen, [ + [ change_id_for => { + change => 'blah', + change_id => undef, + project => 'sql', + tag => undef, + } ] +], 'It should have passed change "blah" change_id_for'; + +# Should get an error when it's deployed but not in the plan. +@resolved = ('whatever'); +throws_ok { $engine->_trim_to( 'oop', 'whatever', [] ) } 'App::Sqitch::X', + '_trim_to should complain about an unplanned change key'; +is $@->ident, 'oop', '_trim_to unplanned change error ident should be "oop"'; +is $@->message, __x( + 'Change "{change}" is deployed, but not planned', + change => 'whatever', +), '_trim_to unplanned change error message should be correct'; +is_deeply $engine->seen, [ + [ change_id_for => { + change => 'whatever', + change_id => undef, + project => 'sql', + tag => undef, + } ], + [ change_id_offset_from_id => ['whatever', 0]], +], 'It should have passed "whatever" to change_id_offset_from_id'; + +# Let's mess with changes. Start by shifting nothing. +my $to_trim = [@changes]; +@resolved = ($changes[0]->id); +my $key = $changes[0]->name; +is $engine->_trim_to('foo', $key, $to_trim), 0, + qq{_trim_to should find "$key" at index 0}; +is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes ], + 'Changes should be untrimmed'; +is_deeply $engine->seen, [ + [ change_id_for => { + change => $key, + change_id => undef, + project => 'sql', + tag => undef, + } ], + [ change_id_offset_from_id => [$changes[0]->id, 0]], +], 'It should have passed change 0 ID to change_id_offset_from_id'; + +# Try shifting to the third change. +$to_trim = [@changes]; +@resolved = ($changes[2]->id); +$key = $changes[2]->name; +is $engine->_trim_to('foo', $key, $to_trim), 2, + qq{_trim_to should find "$key" at index 2}; +is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[2..$#changes] ], + 'First two changes should be shifted off'; +is_deeply $engine->seen, [ + [ change_id_for => { + change => $key, + change_id => undef, + project => 'sql', + tag => undef, + } ], + [ change_id_offset_from_id => [$changes[2]->id, 0]], +], 'It should have passed change 2 ID to change_id_offset_from_id'; + +# Try popping nothing. +$to_trim = [@changes]; +@resolved = ($changes[-1]->id); +$key = $changes[-1]->name; +is $engine->_trim_to('foo', $key, $to_trim, 1), $#changes, + qq{_trim_to should find "$key" at last index}; +is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes ], + 'Changes should be untrimmed'; +is_deeply $engine->seen, [ + [ change_id_for => { + change => $key, + change_id => undef, + project => 'sql', + tag => undef, + } ], + [ change_id_offset_from_id => [$changes[-1]->id, 0]], +], 'It should have passed change -1 ID to change_id_offset_from_id'; + +# Try shifting to the third-to-last change. +$to_trim = [@changes]; +@resolved = ($changes[-3]->id); +$key = $changes[-3]->name; +is $engine->_trim_to('foo', $key, $to_trim, 1), 4, + qq{_trim_to should find "$key" at index 4}; +is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[0..$#changes-2] ], + 'Last two changes should be popped off'; +is_deeply $engine->seen, [ + [ change_id_for => { + change => $key, + change_id => undef, + project => 'sql', + tag => undef, + } ], + [ change_id_offset_from_id => [$changes[-3]->id, 0]], +], 'It should have passed change -3 ID to change_id_offset_from_id'; + +# ^ should be handled relative to deployed changes. +$to_trim = [@changes]; +@resolved = ($changes[-3]->id); +$key = $changes[-4]->name; +is $engine->_trim_to('foo', "$key^", $to_trim, 1), 4, + qq{_trim_to should find "$key^" at index 4}; +is_deeply $engine->seen, [ + [ change_id_for => { + change => $key, + change_id => undef, + project => 'sql', + tag => undef, + } ], + [ change_id_offset_from_id => [$changes[-3]->id, -1]], +], 'Should pass change -3 ID and offset -1 to change_id_offset_from_id'; + +# ~ should be handled relative to deployed changes. +$to_trim = [@changes]; +@resolved = ($changes[-3]->id); +$key = $changes[-2]->name; +is $engine->_trim_to('foo', "$key~", $to_trim, 1), 4, + qq{_trim_to should find "$key~" at index 4}; +is_deeply $engine->seen, [ + [ change_id_for => { + change => $key, + change_id => undef, + project => 'sql', + tag => undef, + } ], + [ change_id_offset_from_id => [$changes[-3]->id, 1]], +], 'Should pass change -3 ID and offset 1 to change_id_offset_from_id'; + +# @HEAD and HEAD should be handled relative to deployed changes, not the plan. +$to_trim = [@changes]; +@resolved = ($changes[2]->id); +$key = '@HEAD'; +is $engine->_trim_to('foo', $key, $to_trim), 2, + qq{_trim_to should find "$key" at index 2}; +is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[2..$#changes] ], + 'First two changes should be shifted off'; +is_deeply $engine->seen, [ + [ change_id_for => { + change => '', + change_id => undef, + project => 'sql', + tag => 'HEAD', + } ], + [ change_id_offset_from_id => [$changes[2]->id, 0]], +], 'Should pass tag HEAD to change_id_for'; + +$to_trim = [@changes]; +@resolved = ($changes[2]->id); +$key = 'HEAD'; +is $engine->_trim_to('foo', $key, $to_trim), 2, + qq{_trim_to should find "$key" at index 2}; +is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[2..$#changes] ], + 'First two changes should be shifted off'; +is_deeply $engine->seen, [ + [ change_id_for => { + change => undef, + change_id => undef, + project => 'sql', + tag => 'HEAD', + } ], + [ change_id_offset_from_id => [$changes[2]->id, 0]], +], 'Should pass tag @HEAD to change_id_for'; + +# @ROOT and ROOT should be handled relative to deployed changes, not the plan. +$to_trim = [@changes]; +@resolved = ($changes[2]->id); +$key = '@ROOT'; +is $engine->_trim_to('foo', $key, $to_trim, 1), 2, + qq{_trim_to should find "$key" at index 2}; +is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[0,1,2] ], + 'All but First three changes should be popped off'; +is_deeply $engine->seen, [ + [ change_id_for => { + change => '', + change_id => undef, + project => 'sql', + tag => 'ROOT', + } ], + [ change_id_offset_from_id => [$changes[2]->id, 0]], +], 'Should pass tag ROOT to change_id_for'; + +$to_trim = [@changes]; +@resolved = ($changes[2]->id); +$key = 'ROOT'; +is $engine->_trim_to('foo', $key, $to_trim, 1), 2, + qq{_trim_to should find "$key" at index 2}; +is_deeply [ map { $_->id } @{ $to_trim } ], [ map { $_->id } @changes[0,1,2] ], + 'All but First three changes should be popped off'; +is_deeply $engine->seen, [ + [ change_id_for => { + change => undef, + change_id => undef, + project => 'sql', + tag => 'ROOT', + } ], + [ change_id_offset_from_id => [$changes[2]->id, 0]], +], 'Should pass tag @ROOT to change_id_for'; + +############################################################################## +# Test _verify_changes(). +can_ok $engine, '_verify_changes'; +$engine->seen; + +# Start with a single change with a valid verify script. +is $engine->_verify_changes(1, 1, 0, $changes[1]), 0, + 'Verify of a single change should return errcount 0'; +is_deeply +MockOutput->get_emit_literal, [[ + ' * users @alpha ..', '', ' ', +]], 'Declared output should list the change'; +is_deeply +MockOutput->get_emit, [['ok']], + 'Emitted Output should reflect the verification of the change'; +is_deeply +MockOutput->get_comment, [], 'Should have no comments'; +is_deeply $engine->seen, [ + [run_file => $changes[1]->verify_file ], +], 'The verify script should have been run'; + +# Try a single change with no verify script. +is $engine->_verify_changes(0, 0, 0, $changes[0]), 0, + 'Verify of another single change should return errcount 0'; +is_deeply +MockOutput->get_emit_literal, [[ + ' * roles ..', '', ' ', +]], 'Declared output should list the change'; +is_deeply +MockOutput->get_emit, [['ok']], + 'Emitted Output should reflect the verification of the change'; +is_deeply +MockOutput->get_comment, [], 'Should have no comments'; +is_deeply +MockOutput->get_vent, [ + [__x 'Verify script {file} does not exist', file => $changes[0]->verify_file], +], 'A warning about no verify file should have been emitted'; +is_deeply $engine->seen, [ +], 'The verify script should not have been run'; + +# Try multiple changes. +is $engine->_verify_changes(0, 1, 0, @changes[0,1]), 0, + 'Verify of two changes should return errcount 0'; +is_deeply +MockOutput->get_emit_literal, [ + [' * roles ..', '.......', ' '], + [' * users @alpha ..', '', ' '], +], 'Declared output should list both changes'; +is_deeply +MockOutput->get_emit, [['ok'], ['ok']], + 'Emitted Output should reflect the verification of the changes'; + +is_deeply +MockOutput->get_comment, [], 'Should have no comments'; +is_deeply +MockOutput->get_vent, [ + [__x 'Verify script {file} does not exist', file => $changes[0]->verify_file], +], 'A warning about no verify file should have been emitted'; +is_deeply $engine->seen, [ + [run_file => $changes[1]->verify_file ], +], 'Only one verify script should have been run'; + +# Try multiple changes and show undeployed changes. +my @plan_changes = $plan->changes; +is $engine->_verify_changes(0, 1, 1, @changes[0,1]), 0, + 'Verify of two changes and show pending'; +is_deeply +MockOutput->get_emit_literal, [ + [' * roles ..', '.......', ' '], + [' * users @alpha ..', '', ' '], +], 'Delcared output should list deployed changes'; +is_deeply +MockOutput->get_emit, [ + ['ok'], ['ok'], + [__n 'Undeployed change:', 'Undeployed changes:', 2], + map { [ ' * ', $_->format_name_with_tags] } @plan_changes[2..$#plan_changes] +], 'Emitted output should include list of pending changes'; +is_deeply +MockOutput->get_comment, [], 'Should have no comments'; +is_deeply +MockOutput->get_vent, [ + [__x 'Verify script {file} does not exist', file => $changes[0]->verify_file], +], 'A warning about no verify file should have been emitted'; +is_deeply $engine->seen, [ + [run_file => $changes[1]->verify_file ], +], 'Only one verify script should have been run'; + +# Try a change that is not in the plan. +$change = App::Sqitch::Plan::Change->new( name => 'nonexistent', plan => $plan ); +is $engine->_verify_changes(1, 0, 0, $change), 1, + 'Verify of a change not in the plan should return errcount 1'; +is_deeply +MockOutput->get_emit_literal, [[ + ' * nonexistent ..', '', ' ' +]], 'Declared Output should reflect the verification of the change'; +is_deeply +MockOutput->get_emit, [['not ok']], + 'Emitted Output should reflect the failure of the verify'; +is_deeply +MockOutput->get_comment, [[__ 'Not present in the plan' ]], + 'Should have a comment about the change missing from the plan'; +is_deeply $engine->seen, [], 'No verify script should have been run'; + +# Try a change in the wrong place in the plan. +my $mock_plan = Test::MockModule->new(ref $plan); +$mock_plan->mock(index_of => 5); +is $engine->_verify_changes(1, 0, 0, $changes[1]), 1, + 'Verify of an out-of-order change should return errcount 1'; +is_deeply +MockOutput->get_emit_literal, [ + [' * users @alpha ..', '', ' '], +], 'Declared output should reflect the verification of the change'; +is_deeply +MockOutput->get_emit, [['not ok']], + 'Emitted Output should reflect the failure of the verify'; +is_deeply +MockOutput->get_comment, [[__ 'Out of order' ]], + 'Should have a comment about the out-of-order change'; +is_deeply $engine->seen, [ + [run_file => $changes[1]->verify_file ], +], 'The verify script should have been run'; + +# Make sure that multiple issues add up. +$mock_engine->mock( verify_change => sub { hurl 'WTF!' }); +is $engine->_verify_changes(1, 0, 0, $changes[1]), 2, + 'Verify of a change with 2 issues should return 2'; +is_deeply +MockOutput->get_emit_literal, [ + [' * users @alpha ..', '', ' '], +], 'Declared output should reflect the verification of the change'; +is_deeply +MockOutput->get_emit, [['not ok']], + 'Emitted Output should reflect the failure of the verify'; +is_deeply +MockOutput->get_comment, [ + [__ 'Out of order' ], + ['WTF!'], +], 'Should have comment about the out-of-order change and script failure'; +is_deeply $engine->seen, [], 'No abstract methods should have been called'; + +# Make sure that multiple changes with multiple issues add up. +$mock_engine->mock( verify_change => sub { hurl 'WTF!' }); +is $engine->_verify_changes(0, -1, 0, @changes[0,1]), 4, + 'Verify of 2 changes with 2 issues each should return 4'; +is_deeply +MockOutput->get_emit_literal, [ + [' * roles ..', '.......', ' '], + [' * users @alpha ..', '', ' '], +], 'Declraed output should reflect the verification of both changes'; +is_deeply +MockOutput->get_emit, [['not ok'], ['not ok']], + 'Emitted Output should reflect the failure of both verifies'; +is_deeply +MockOutput->get_comment, [ + [__ 'Out of order' ], + ['WTF!'], + [__ 'Out of order' ], + ['WTF!'], +], 'Should have comment about the out-of-order changes and script failures'; +is_deeply $engine->seen, [], 'No abstract methods should have been called'; + +# Unmock before moving on. +$mock_plan->unmock('index_of'); +$mock_engine->unmock('verify_change'); + +# Now deal with changes in the plan but not in the list. +is $engine->_verify_changes($#changes, $plan->count - 1, 0, $changes[-1]), 2, + '_verify_changes with two undeployed changes should returne 2'; +is_deeply +MockOutput->get_emit_literal, [ + [' * dr_evil ..', '', ' '], + [' * foo ..', '....', ' ' , 'not ok', ' '], + [' * blah ..', '...', ' ' , 'not ok', ' '], +], 'Listed changes should be both deployed and undeployed'; +is_deeply +MockOutput->get_emit, [['ok']], + 'Emitted Output should reflect 1 pass'; +is_deeply +MockOutput->get_comment, [ + [__ 'Not deployed' ], + [__ 'Not deployed' ], +], 'Should have comments for undeployed changes'; +is_deeply $engine->seen, [], 'No abstract methods should have been called'; + +############################################################################## +# Test verify(). +can_ok $engine, 'verify'; +my @verify_changes; +$mock_engine->mock( _load_changes => sub { @verify_changes }); + +# First, test with no changes. +ok $engine->verify, + 'Should return success for no deployed changes'; +is_deeply +MockOutput->get_info, [ + [__x 'Verifying {destination}', destination => $engine->destination], + [__ 'No changes deployed'], +], 'Notification of the verify should be emitted'; + +# Try no changes *and* nothing in the plan. +my $count = 0; +$mock_plan->mock(count => sub { $count }); +ok $engine->verify, + 'Should return success for no changes'; +is_deeply +MockOutput->get_info, [ + [__x 'Verifying {destination}', destination => $engine->destination], + [__ 'Nothing to verify (no planned or deployed changes)'], +], 'Notification of the verify should be emitted'; + +# Now return some changes but have nothing in the plan. +@verify_changes = @changes; +throws_ok { $engine->verify } 'App::Sqitch::X', + 'Should get error for no planned changes'; +is $@->ident, 'verify', 'No planned changes ident should be "verify"'; +is $@->exitval, 2, 'No planned changes exitval should be 2'; +is $@->message, __ 'There are deployed changes, but none planned!', + 'No planned changes message should be correct'; +is_deeply +MockOutput->get_info, [ + [__x 'Verifying {destination}', destination => $engine->destination], +], 'Notification of the verify should be emitted'; + +# Let's do one change and have it pass. +$mock_plan->mock(index_of => 0); +$count = 1; +@verify_changes = ($changes[1]); +undef $@; +ok $engine->verify, 'Verify one change'; +is_deeply +MockOutput->get_info, [ + [__x 'Verifying {destination}', destination => $engine->destination], +], 'Notification of the verify should be emitted'; +is_deeply +MockOutput->get_emit_literal, [ + [' * ' . $changes[1]->format_name_with_tags . ' ..', '', ' ' ], +], 'The one change name should be declared'; +is_deeply +MockOutput->get_emit, [ + ['ok'], + [__ 'Verify successful'], +], 'Success should be emitted'; +is_deeply +MockOutput->get_comment, [], 'Should have no comments'; + +# Verify two changes. +MockOutput->get_vent; +$mock_plan->unmock('index_of'); +@verify_changes = @changes[0,1]; +ok $engine->verify, 'Verify two changes'; +is_deeply +MockOutput->get_info, [ + [__x 'Verifying {destination}', destination => $engine->destination], +], 'Notification of the verify should be emitted'; +is_deeply +MockOutput->get_emit_literal, [ + [' * roles ..', '.......', ' ' ], + [' * users @alpha ..', '', ' ' ], +], 'The two change names should be declared'; +is_deeply +MockOutput->get_emit, [ + ['ok'], ['ok'], + [__ 'Verify successful'], +], 'Both successes should be emitted'; +is_deeply +MockOutput->get_comment, [], 'Should have no comments'; +is_deeply +MockOutput->get_vent, [ + [__x( + 'Verify script {file} does not exist', + file => $changes[0]->verify_file, + )] +], 'Should have warning about missing verify script'; + +# Make sure a reworked change (that is, one with a suffix) is ignored. +my $mock_change = Test::MockModule->new(ref $change); +$mock_change->mock(is_reworked => 1); +@verify_changes = @changes[0,1]; +ok $engine->verify, 'Verify with a reworked change changes'; +is_deeply +MockOutput->get_info, [ + [__x 'Verifying {destination}', destination => $engine->destination], +], 'Notification of the verify should be emitted'; +is_deeply +MockOutput->get_emit_literal, [ + [' * roles ..', '.......', ' ' ], + [' * users @alpha ..', '', ' ' ], +], 'The two change names should be emitted'; +is_deeply +MockOutput->get_emit, [ + ['ok'], ['ok'], + [__ 'Verify successful'], +], 'Both successes should be emitted'; +is_deeply +MockOutput->get_comment, [], 'Should have no comments'; +is_deeply +MockOutput->get_vent, [], 'Should have no warnings'; + +$mock_change->unmock('is_reworked'); + +# Make sure we can trim. +@verify_changes = @changes; +@resolved = map { $_->id } @changes[1,2]; +ok $engine->verify('users', 'widgets'), 'Verify two specific changes'; +is_deeply +MockOutput->get_info, [ + [__x 'Verifying {destination}', destination => $engine->destination], +], 'Notification of the verify should be emitted'; +is_deeply +MockOutput->get_emit_literal, [ + [' * users @alpha ..', '.', ' ' ], + [' * widgets @beta ..', '', ' ' ], +], 'The two change names should be emitted'; +is_deeply +MockOutput->get_emit, [ + ['ok'], ['ok'], + [__ 'Verify successful'], +], 'Both successes should be emitted'; +is_deeply +MockOutput->get_comment, [], 'Should have no comments'; +is_deeply +MockOutput->get_vent, [ + [__x( + 'Verify script {file} does not exist', + file => $changes[2]->verify_file, + )] +], 'Should have warning about missing verify script'; + +# Now fail! +$mock_engine->mock( verify_change => sub { hurl 'WTF!' }); +@verify_changes = @changes; +@resolved = map { $_->id } @changes[1,2]; +throws_ok { $engine->verify('users', 'widgets') } 'App::Sqitch::X', + 'Should get failure for failing verify scripts'; +is $@->ident, 'verify', 'Failed verify ident should be "verify"'; +is $@->exitval, 2, 'Failed verify exitval should be 2'; +is $@->message, __ 'Verify failed', 'Faield verify message should be correct'; +is_deeply +MockOutput->get_info, [ + [__x 'Verifying {destination}', destination => $engine->destination], +], 'Notification of the verify should be emitted'; +my $msg = __ 'Verify Summary Report'; +is_deeply +MockOutput->get_emit_literal, [ + [' * users @alpha ..', '.', ' ' ], + [' * widgets @beta ..', '', ' ' ], +], 'Both change names should be declared'; +is_deeply +MockOutput->get_emit, [ + ['not ok'], ['not ok'], + [ "\n", $msg ], + [ '-' x length $msg ], + [__x 'Changes: {number}', number => 2 ], + [__x 'Errors: {number}', number => 2 ], +], 'Output should include the failure report'; +is_deeply +MockOutput->get_comment, [ + ['WTF!'], + ['WTF!'], +], 'Should have the errors in comments'; +is_deeply +MockOutput->get_vent, [], 'Nothing should have been vented'; + +__END__ +diag $_->format_name_with_tags for @changes; +diag '======'; +diag $_->format_name_with_tags for $plan->changes; diff --git a/t/engine/deploy/func/add_user.sql b/t/engine/deploy/func/add_user.sql new file mode 100644 index 00000000..1587f43f --- /dev/null +++ b/t/engine/deploy/func/add_user.sql @@ -0,0 +1,13 @@ +-- Deploy func/add_user +-- requires: users + +BEGIN; + +CREATE FUNCTION __myapp.add_user( + nick TEXT, + pass TEXT +) RETURNS VOID LANGUAGE SQL AS $$ + INSERT INTO __myapp.users VALUES(nick, MD5(pass)); +$$; + +COMMIT; diff --git a/t/engine/deploy/users.sql b/t/engine/deploy/users.sql new file mode 100644 index 00000000..c9fd6947 --- /dev/null +++ b/t/engine/deploy/users.sql @@ -0,0 +1,6 @@ +SET client_min_messages = warning; +CREATE SCHEMA __myapp; +CREATE TABLE __myapp.users ( + nick TEXT PRIMARY KEY, + name TEXT NOT NULL +); diff --git a/t/engine/deploy/widgets.sql b/t/engine/deploy/widgets.sql new file mode 100644 index 00000000..ade26d08 --- /dev/null +++ b/t/engine/deploy/widgets.sql @@ -0,0 +1,7 @@ +-- requires: users +-- conflicts: dr_evil +SET client_min_messages = warning; +CREATE TABLE __myapp.widgets ( + name TEXT PRIMARY KEY, + owner TEXT NOT NULL REFERENCES __myapp.users(nick) +); diff --git a/t/engine/revert/func/add_user.sql b/t/engine/revert/func/add_user.sql new file mode 100644 index 00000000..8e3f260a --- /dev/null +++ b/t/engine/revert/func/add_user.sql @@ -0,0 +1,7 @@ +-- Revert func/add_user + +BEGIN; + +DROP FUNCTION __myapp.add_user(TEXT, TEXT); + +COMMIT; diff --git a/t/engine/revert/users.sql b/t/engine/revert/users.sql new file mode 100644 index 00000000..f0b7bcfd --- /dev/null +++ b/t/engine/revert/users.sql @@ -0,0 +1,2 @@ +SET client_min_messages = warning; +DROP SCHEMA IF EXISTS __myapp CASCADE; diff --git a/t/engine/revert/widgets.sql b/t/engine/revert/widgets.sql new file mode 100644 index 00000000..a9d15064 --- /dev/null +++ b/t/engine/revert/widgets.sql @@ -0,0 +1,2 @@ +SET client_min_messages = warning; +DROP TABLE IF EXISTS __myapp.widgets; diff --git a/t/engine/reworked/deploy/users@alpha.sql b/t/engine/reworked/deploy/users@alpha.sql new file mode 100644 index 00000000..c9fd6947 --- /dev/null +++ b/t/engine/reworked/deploy/users@alpha.sql @@ -0,0 +1,6 @@ +SET client_min_messages = warning; +CREATE SCHEMA __myapp; +CREATE TABLE __myapp.users ( + nick TEXT PRIMARY KEY, + name TEXT NOT NULL +); diff --git a/t/engine/reworked/revert/users@alpha.sql b/t/engine/reworked/revert/users@alpha.sql new file mode 100644 index 00000000..f0b7bcfd --- /dev/null +++ b/t/engine/reworked/revert/users@alpha.sql @@ -0,0 +1,2 @@ +SET client_min_messages = warning; +DROP SCHEMA IF EXISTS __myapp CASCADE; diff --git a/t/engine/sqitch.plan b/t/engine/sqitch.plan new file mode 100644 index 00000000..eceeb501 --- /dev/null +++ b/t/engine/sqitch.plan @@ -0,0 +1,7 @@ +%project=engine + ++ users 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # User roles +@alpha 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # Good to go! ++ widgets [users !dr_evil] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # All in ++ func/add_user [users] 2012-10-09T18:28:29Z Barack Obama <potus@whitehouse.gov> # Add users. ++ users [users@alpha] 2012-10-09T19:28:29Z Barack Obama <potus@whitehouse.gov> # Add users. diff --git a/t/engine_cmd.t b/t/engine_cmd.t new file mode 100644 index 00000000..f7a94cc1 --- /dev/null +++ b/t/engine_cmd.t @@ -0,0 +1,631 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 201; +#use Test::More 'no_plan'; +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use Test::Exception; +use Test::Warn; +use Test::Dir; +use Test::File qw(file_not_exists_ok file_exists_ok); +use Test::NoWarnings; +use File::Copy; +use Path::Class; +use File::Temp 'tempdir'; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::engine'; + +############################################################################## +# Set up a test directory and config file. +my $tmp_dir = tempdir CLEANUP => 1; + +File::Copy::copy file(qw(t engine.conf))->stringify, "$tmp_dir" + or die "Cannot copy t/engine.conf to $tmp_dir: $!\n"; +File::Copy::copy file(qw(t engine sqitch.plan))->stringify, "$tmp_dir" + or die "Cannot copy t/engine/sqitch.plan to $tmp_dir: $!\n"; +chdir $tmp_dir; +my $config = TestConfig->from(local => 'engine.conf'); +my $psql = 'psql' . (App::Sqitch::ISWIN ? '.exe' : ''); + +############################################################################## +# Load an engine command and test the basics. +ok my $sqitch = App::Sqitch->new(config => $config), + 'Load a sqitch sqitch object'; +isa_ok my $cmd = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'engine', + config => $config, +}), $CLASS, 'Engine command'; +isa_ok $cmd, 'App::Sqitch::Command', 'Engine command'; + +can_ok $cmd, qw( + options + configure + execute + list + add + remove + rm + show + update_config + does +); + +ok $CLASS->does("App::Sqitch::Role::TargetConfigCommand"), + "$CLASS does TargetConfigCommand"; + +is_deeply [$CLASS->options], [qw( + target=s + plan-file|f=s + registry=s + client=s + extension=s + top-dir=s + dir|d=s% + set|s=s% +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure({}, {}), { properties => {}}, + 'Default config should contain empty properties'; + +# Make sure configure ignores config file. +is_deeply $CLASS->configure({ foo => 'bar'}), { properties => {} }, + 'configure() should ignore config file'; + +# Check default property values. +ok my $conf = $CLASS->configure($config, { + top_dir => 'top', + plan_file => 'my.plan', + registry => 'bats', + client => 'cli', + extension => 'ddl', + target => 'db:pg:foo', + dir => { + deploy => 'dep', + revert => 'rev', + verify => 'ver', + reworked => 'wrk', + reworked_deploy => 'rdep', + reworked_revert => 'rrev', + reworked_verify => 'rver', + }, + set => { + foo => 'bar', + prefix => 'x_', + }, +}), 'Get full config'; + +is_deeply $conf->{properties}, { + top_dir => 'top', + plan_file => 'my.plan', + registry => 'bats', + client => 'cli', + extension => 'ddl', + target => 'db:pg:foo', + deploy_dir => 'dep', + revert_dir => 'rev', + verify_dir => 'ver', + reworked_dir => 'wrk', + reworked_deploy_dir => 'rdep', + reworked_revert_dir => 'rrev', + reworked_verify_dir => 'rver', + variables => { + foo => 'bar', + prefix => 'x_', + }, +}, 'Should have properties'; +isa_ok $conf->{properties}{$_}, 'Path::Class::File', "$_ file attribute" for qw( + plan_file +); +isa_ok $conf->{properties}{$_}, 'Path::Class::Dir', "$_ directory attribute" for ( + 'top_dir', + 'reworked_dir', + map { ($_, "reworked_$_") } qw(deploy_dir revert_dir verify_dir) +); + +# Make sure invalid directories are ignored. +throws_ok { $CLASS->new($CLASS->configure({}, { + dir => { foo => 'bar' }, +})) } 'App::Sqitch::X', 'Should fail on invalid directory name'; +is $@->ident, 'engine', 'Invalid directory ident should be "engine"'; +is $@->message, __x( + 'Unknown directory name: {prop}', + prop => 'foo', +), 'The invalid directory messsage should be correct'; + +throws_ok { $CLASS->new($CLASS->configure({}, { + dir => { foo => 'bar', cavort => 'ha' }, +})) } 'App::Sqitch::X', 'Should fail on invalid directory names'; +is $@->ident, 'engine', 'Invalid directories ident should be "engine"'; +is $@->message, __x( + 'Unknown directory names: {props}', + props => 'cavort, foo', +), 'The invalid properties messsage should be correct'; + +############################################################################## +# Test list(). +ok $cmd->list, 'Run list()'; +is_deeply +MockOutput->get_emit, [['mysql'], ['pg'], ['sqlite']], + 'The list of engines should have been output'; + +# Make it verbose. +isa_ok $cmd = $CLASS->new({ + sqitch => App::Sqitch->new( config => $config, options => { verbosity => 1 }) +}), $CLASS, 'Verbose engine'; +ok $cmd->list, 'Run verbose list()'; +is_deeply +MockOutput->get_emit, [ + ["mysql\tdb:mysql://root@/foo"], + ["pg\tdb:pg:try"], + ["sqlite\twidgets"] +], 'The list of engines and their targets should have been output'; + +############################################################################## +# Test add(). +MISSINGARGS: { + # Test handling of no name. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; die 'USAGE' }); + throws_ok { $cmd->add } qr/USAGE/, + 'No name arg to add() should yield usage'; + is_deeply \@args, [$cmd], 'No args should be passed to usage'; +} + +# Should die on existing key. +throws_ok { $cmd->add('pg') } 'App::Sqitch::X', + 'Should get error for existing engine'; +is $@->ident, 'engine', 'Existing engine error ident should be "engine"'; +is $@->message, __x( + 'Engine "{engine}" already exists', + engine => 'pg' +), 'Existing engine error message should be correct'; + +# Now add a new engine. +dir_not_exists_ok $_ for qw(deploy revert verify); +ok $cmd->add('vertica'), 'Add engine "vertica"'; +dir_exists_ok $_ for qw(deploy revert verify); +$config->load; +is $config->get(key => 'engine.vertica.target'), 'db:vertica:', + 'Engine "test" target should have been set'; +for my $key (qw( + client + registry + top_dir + plan_file + deploy_dir + revert_dir + verify_dir + extension +)) { + is $config->get(key => "engine.vertica.$key"), undef, + qq{Engine "vertica" should have no $key set}; +} +is_deeply $config->get_section(section => 'engine.vertica.variables'), {}, + qq{Engine "vertica" should have no variables set}; + +# Should die on target that doesn't match the engine. +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { target => 'db:sqlite:' }, +}), $CLASS, 'Engine with target property'; +throws_ok { $cmd->add('firebird' ) } 'App::Sqitch::X', + 'Should get error for engine/target mismatch'; +is $@->ident, 'engine', 'Target mismatch ident should be "engine"'; +is $@->message, __x( + 'Cannot assign URI using engine "{new}" to engine "{old}"', + new => 'sqlite', + old => 'firebird', +), 'Target mismatch message should be correct'; + +# Try all the properties. +my %props = ( + target => 'db:firebird:foo', + client => 'poo', + registry => 'reg', + top_dir => dir('top'), + plan_file => file('my.plan'), + deploy_dir => dir('dep'), + revert_dir => dir('rev'), + verify_dir => dir('ver'), + reworked_dir => dir('r'), + reworked_deploy_dir => dir('r/d'), + extension => 'ddl', + variables => { ay => 'first', Bee => 'second' }, +); +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { %props }, +}), $CLASS, 'Engine with all properties'; +file_not_exists_ok 'my.plan'; +dir_not_exists_ok dir $_ for qw(top/deploy top/revert top/verify r/d r/revert r/verify); +ok $cmd->add('firebird'), 'Add engine "firebird"'; +dir_exists_ok dir $_ for qw(top/deploy top/revert top/verify r/d r/revert r/verify); +file_exists_ok 'my.plan'; +$config->load; +while (my ($k, $v) = each %props) { + if ($k ne 'variables') { + is $config->get(key => "engine.firebird.$k"), $v, + qq{Engine "firebird" should have $k set}; + } else { + is_deeply $config->get_section(section => "engine.firebird.$k"), $v, + qq{Engine "firebird" should have $k}; + } +} + +############################################################################## +# Test alter(). +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, +}), $CLASS, 'Engine with no properties'; + +MISSINGARGS: { + # Test handling of no name. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; die 'USAGE' }); + throws_ok { $cmd->alter } qr/USAGE/, + 'No name arg to add() should yield usage'; + is_deeply \@args, [$cmd], 'No args should be passed to usage'; +} + +throws_ok { $cmd->alter('nonexistent' ) } 'App::Sqitch::X', + 'Should get error from alter for nonexistent engine'; +is $@->ident, 'engine', 'Nonexistent engine error ident should be "engine"'; +is $@->message, __x( + 'Unknown engine "{engine}"', + engine => 'nonexistent' +), 'Nonexistent engine error message should be correct'; + +# Should die on missing key. +throws_ok { $cmd->alter('oracle') } 'App::Sqitch::X', + 'Should get error for missing engine'; +is $@->ident, 'engine', 'Missing engine error ident should be "engine"'; +is $@->message, __x( + 'Missing Engine "{engine}"; use "{command}" to add it', + engine => 'oracle', + command => 'add oracle db:oracle:', +), 'Missing engine error message should be correct'; + +# Try all the properties. +%props = ( + target => 'db:firebird:bar', + client => 'argh', + registry => 'migrations', + top_dir => dir('fb'), + plan_file => file('fb.plan'), + deploy_dir => dir('fb/dep'), + revert_dir => dir('fb/rev'), + verify_dir => dir('fb/ver'), + reworked_dir => dir('fb/r'), + reworked_deploy_dir => dir('fb/r/d'), + extension => 'fbsql', + variables => { ay => 'x', ceee => 'third' }, +); +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { %props }, +}), $CLASS, 'Engine with more properties'; +ok $cmd->alter('firebird'), 'Alter engine "firebird"'; +$config->load; +while (my ($k, $v) = each %props) { + if ($k ne 'variables') { + is $config->get(key => "engine.firebird.$k"), $v, + qq{Engine "firebird" should have $k set}; + } else { + $v->{Bee} = 'second'; + is_deeply $config->get_section(section => "engine.firebird.$k"), $v, + qq{Engine "firebird" should have $k}; + } +} + +# Try changing the top directory. +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { top_dir => dir 'pg' }, +}), $CLASS, 'Engine with new top_dir property'; +dir_not_exists_ok dir $_ for qw(pg pg/deploy pg/revert pg/verify); +ok $cmd->alter('pg'), 'Alter engine "pg"'; +dir_exists_ok dir $_ for qw(pg pg/deploy pg/revert pg/verify); +$config->load; +is $config->get(key => 'engine.pg.top_dir'), 'pg', + 'The pg top_dir should have been set'; + +# An attempt to alter a missing engine should show the target if in props. +throws_ok { $cmd->alter('oracle') } 'App::Sqitch::X', + 'Should again get error for missing engine'; +is $@->ident, 'engine', 'Missing engine error ident should still be "engine"'; +is $@->message, __x( + 'Missing Engine "{engine}"; use "{command}" to add it', + engine => 'oracle', + command => 'add oracle db:oracle:', +), 'Missing engine error message should include target property'; + +# Should die on target mismatch engine. +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { target => 'db:sqlite:' }, +}), $CLASS, 'Engine with target property'; +throws_ok { $cmd->alter('firebird' ) } 'App::Sqitch::X', + 'Should get error for engine/target mismatch'; +is $@->ident, 'engine', 'Target mismatch ident should be "engine"'; +is $@->message, __x( + 'Cannot assign URI using engine "{new}" to engine "{old}"', + new => 'sqlite', + old => 'firebird', +), 'Target mismatch message should be correct'; + +############################################################################## +# Test remove. +MISSINGARGS: { + # Test handling of no names. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; die 'USAGE' }); + throws_ok { $cmd->remove } qr/USAGE/, + 'No name args to remove() should yield usage'; + is_deeply \@args, [$cmd], 'No args should be passed to usage'; +} + +# Should get an error if the engine does not exist. +throws_ok { $cmd->remove('nonexistent', 'existant' ) } 'App::Sqitch::X', + 'Should get error for nonexistent engine'; +is $@->ident, 'engine', 'Nonexistent engine error ident should be "engine"'; +is $@->message, __x( + 'Unknown engine "{engine}"', + engine => 'nonexistent' +), 'Nonexistent engine error message should be correct'; + +# Remove one that exists. +ok $cmd->remove('mysql'), 'Remove'; +$config->load; +is $config->get(key => "engine.mysql.target"), undef, + qq{Engine "mysql" should now be gone}; +is_deeply $config->get_section(section => "engine.mysql.variables"), {}, + qq{Engine "mysql" should have no variables}; + +# Create it again with variables. +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { variables => { x => 1} }, +}), $CLASS, 'Engein with variables'; +ok $cmd->add('mysql', 'db:mysql:'), 'Add engine "mysql"'; +$config->load; +is $config->get(key => "engine.mysql.target"), 'db:mysql:', + qq{Engine "mysql" should be back}; +is_deeply $config->get_section(section => "engine.mysql.variables"), { x => 1}, + qq{Engine "mysql" should have variables}; + +# Remoce it again. +ok $cmd->remove('mysql'), 'Remove'; +$config->load; +is $config->get(key => "engine.mysql.target"), undef, + qq{Engine "mysql" should be gone again}; +is_deeply $config->get_section(section => "engine.mysql.variables"), {}, + qq{Engine "mysql" should have no variables}; + + +############################################################################## +# Test show. +ok $cmd->show, 'Run show()'; +is_deeply +MockOutput->get_emit, [ + ['firebird'], ['pg'], ['sqlite'], ['vertica'] +], 'Show with no names should emit the list of engines'; + +# Try one engine. +ok $cmd->show('sqlite'), 'Show sqlite'; +is_deeply +MockOutput->get_emit, [ + ['* sqlite'], + [' ', 'Target: ', 'widgets'], + [' ', 'Registry: ', 'sqitch'], + [' ', 'Client: ', '/usr/sbin/sqlite3'], + [' ', 'Top Directory: ', '.'], + [' ', 'Plan File: ', 'foo.plan'], + [' ', 'Extension: ', 'sql'], + [' ', 'Script Directories:'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'Reworked Script Directories:'], + [' ', ' Reworked: ', '.'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'No Variables'], +], 'The full "sqlite" engine should have been shown'; + +# Try multiples. +$config->update('engine.vertica.client' => 'vsql.exe'); +ok $cmd->show(qw(sqlite vertica firebird)), 'Show three engines'; +is_deeply +MockOutput->get_emit, [ + ['* sqlite'], + [' ', 'Target: ', 'widgets'], + [' ', 'Registry: ', 'sqitch'], + [' ', 'Client: ', '/usr/sbin/sqlite3'], + [' ', 'Top Directory: ', '.'], + [' ', 'Plan File: ', 'foo.plan'], + [' ', 'Extension: ', 'sql'], + [' ', 'Script Directories:'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'Reworked Script Directories:'], + [' ', ' Reworked: ', '.'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'No Variables'], + ['* vertica'], + [' ', 'Target: ', 'db:vertica:'], + [' ', 'Registry: ', 'sqitch'], + [' ', 'Client: ', 'vsql.exe'], + [' ', 'Top Directory: ', '.'], + [' ', 'Plan File: ', 'sqitch.plan'], + [' ', 'Extension: ', 'sql'], + [' ', 'Script Directories:'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'Reworked Script Directories:'], + [' ', ' Reworked: ', '.'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'No Variables'], + ['* firebird'], + [' ', 'Target: ', 'db:firebird:bar'], + [' ', 'Registry: ', 'migrations'], + [' ', 'Client: ', 'argh'], + [' ', 'Top Directory: ', 'fb'], + [' ', 'Plan File: ', 'fb.plan'], + [' ', 'Extension: ', 'fbsql'], + [' ', 'Script Directories:'], + [' ', ' Deploy: ', dir 'fb/dep'], + [' ', ' Revert: ', dir 'fb/rev'], + [' ', ' Verify: ', dir 'fb/ver'], + [' ', 'Reworked Script Directories:'], + [' ', ' Reworked: ', dir 'fb/r'], + [' ', ' Deploy: ', dir 'fb/r/d'], + [' ', ' Revert: ', dir 'fb/r/revert'], + [' ', ' Verify: ', dir 'fb/r/verify'], + [' ', 'Variables:'], + [' ay: x'], + [' Bee: second'], + [' ceee: third'], +], 'All three engines should have been shown'; + +############################################################################## +# Test execute(). +isa_ok $cmd = $CLASS->new({ sqitch => $sqitch }), $CLASS, 'Simple engine'; +for my $spec ( + [ undef, 'list' ], + [ 'list' ], + [ 'add' ], + [ 'set-target' ], + [ 'set-registry' ], + [ 'set-client' ], + [ 'remove' ], + [ 'rm', 'remove' ], + [ 'rename' ], + [ 'show' ], +) { + my ($arg, $meth) = @{ $spec }; + $meth //= $arg; + $meth =~ s/-/_/g; + my $mocker = Test::MockModule->new($CLASS); + my @args; + $mocker->mock($meth => sub { @args = @_ }); + ok $cmd->execute($spec->[0]), "Execute " . ($spec->[0] // 'undef'); + is_deeply \@args, [$cmd], "$meth() should have been called"; + + # Make sure args are passed. + ok $cmd->execute($spec->[0], qw(pg db:pg:)), + "Execute " . ($spec->[0] // 'undef') . ' with args'; + is_deeply \@args, [$cmd, qw(pg db:pg:)], + "$meth() should have been passed args"; +} + +# Make sure an invalid action dies with a usage statement. +MISSINGARGS: { + # Test handling of no names. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; die 'USAGE' }); + throws_ok { $cmd->execute('nonexistent') } qr/USAGE/, + 'Should get an exception for a nonexistent action'; + is_deeply \@args, [$cmd, __x( + 'Unknown action "{action}"', + action => 'nonexistent', + )], 'Nonexistent action message should be passed to usage'; +} + +############################################################################## +# Test update_config. +$config->group_set($config->local_file, [ + {key => 'core.mysql.target', value => 'widgets' }, + {key => 'core.mysql.client', value => 'mysql.exe' }, + {key => 'core.mysql.registry', value => 'spliff' }, + {key => 'core.mysql.host', value => 'localhost' }, + {key => 'core.mysql.port', value => 1234 }, + {key => 'core.mysql.username', value => 'fred' }, + {key => 'core.mysql.password', value => 'barb' }, + {key => 'core.mysql.db_name', value => 'ouch' }, +]); +$cmd->sqitch->config->load; +my $core = $cmd->sqitch->config->get_section(section => 'core.mysql'); +ok $cmd->update_config, 'Update the config'; +$cmd->sqitch->config->load; +is_deeply $cmd->sqitch->config->get_section(section => 'core.mysql'), $core, + 'The core.mysql config should still be present'; +is_deeply $cmd->sqitch->config->get_section(section => 'engine.mysql'), { + target => 'widgets', + client => 'mysql.exe', + registry => 'spliff', +}, 'MySQL config should have been rewritten without deprecated keys'; + +# Try with no target. +$config->rename_section( + from => 'engine.mysql', + filename => $config->local_file, +); +$config->group_set($config->local_file, [ + {key => 'core.mysql.target', value => undef }, + {key => 'core.mysql.client', value => 'mysql.exe' }, + {key => 'core.mysql.registry', value => 'spliff' }, + {key => 'core.mysql.host', value => 'localhost' }, + {key => 'core.mysql.port', value => 1234 }, + {key => 'core.mysql.username', value => 'fred' }, + {key => 'core.mysql.password', value => 'barb' }, + {key => 'core.mysql.db_name', value => 'ouch' }, +]); +$cmd->sqitch->config->load; +$core = $cmd->sqitch->config->get_section(section => 'core.mysql'); +ok $cmd->update_config, 'Update the config again'; +$cmd->sqitch->config->load; +is_deeply $cmd->sqitch->config->get_section(section => 'core.mysql'), $core, + 'The core.mysql config should again remain'; +is_deeply $cmd->sqitch->config->get_section(section => 'engine.mysql'), { + target => 'db:mysql://fred:barb@localhost:1234/ouch', + client => 'mysql.exe', + registry => 'spliff', +}, 'MySQL config should have been rewritten with an integrated target'; + +# Try with no deprecated keys. +$config->rename_section( + from => 'engine.mysql', + filename => $config->local_file, +); +$config->group_set($config->local_file, [ + {key => 'core.mysql.client', value => 'mysql.exe' }, + {key => 'core.mysql.registry', value => 'spliff' }, + {key => 'core.mysql.host', value => undef }, + {key => 'core.mysql.port', value => undef }, + {key => 'core.mysql.username', value => undef }, + {key => 'core.mysql.password', value => undef }, + {key => 'core.mysql.db_name', value => undef }, +]); +$cmd->sqitch->config->load; +$core = $cmd->sqitch->config->get_section(section => 'core.mysql'); +ok $cmd->update_config, 'Update the config again'; +$cmd->sqitch->config->load; +is_deeply $cmd->sqitch->config->get_section(section => 'core.mysql'), $core, + 'The core.mysql config should again remain'; +is_deeply $cmd->sqitch->config->get_section(section => 'engine.mysql'), { + target => 'db:mysql:', + client => 'mysql.exe', + registry => 'spliff', +}, 'MySQL config should have been rewritten with a default target'; diff --git a/t/exasol.t b/t/exasol.t new file mode 100644 index 00000000..48671056 --- /dev/null +++ b/t/exasol.t @@ -0,0 +1,381 @@ +#!/usr/bin/perl -w + +# To test against a live Exasol database, you must set the EXA_URI environment variable. +# this is a stanard URI::db URI, and should look something like this: +# +# export EXA_URI=db:exasol://dbadmin:password@localhost:5433/dbadmin?Driver=Exasol +# +# Note that it must include the `?Driver=$driver` bit so that DBD::ODBC loads +# the proper driver. + +use strict; +use warnings; +use 5.010; +use Test::More; +use Test::MockModule; +use Test::Exception; +use Locale::TextDomain qw(App-Sqitch); +use Capture::Tiny 0.12 qw(:all); +use Try::Tiny; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use lib 't/lib'; +use DBIEngineTest; +use TestConfig; + +my $CLASS; + +delete $ENV{"VSQL_$_"} for qw(USER PASSWORD DATABASE HOST PORT); + +BEGIN { + $CLASS = 'App::Sqitch::Engine::exasol'; + require_ok $CLASS or die; +} + +is_deeply [$CLASS->config_vars], [ + target => 'any', + registry => 'any', + client => 'any', +], 'config_vars should return three vars'; + +my $uri = URI::db->new('db:exasol:'); +my $config = TestConfig->new('core.engine' => 'exasol'); +my $sqitch = App::Sqitch->new(config => $config); +my $target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => $uri, +); +isa_ok my $exa = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS; + +is $exa->key, 'exasol', 'Key should be "exasol"'; +is $exa->name, 'Exasol', 'Name should be "Exasol"'; + +my $client = 'exaplus' . (App::Sqitch::ISWIN ? '.exe' : ''); +is $exa->client, $client, 'client should default to exaplus'; +is $exa->registry, 'sqitch', 'registry default should be "sqitch"'; +is $exa->uri, $uri, 'DB URI should be "db:exasol:"'; +my $dest_uri = $uri->clone; +is $exa->destination, $dest_uri->as_string, + 'Destination should default to "db:exasol:"'; +is $exa->registry_destination, $exa->destination, + 'Registry destination should be the same as destination'; + +my @std_opts = ( + '-q', + '-L', + '-pipe', + '-x', + '-autoCompletion' => 'OFF', + '-encoding' => 'UTF8', + '-autocommit' => 'OFF', +); + +is_deeply [$exa->exaplus], [$client, @std_opts], + 'exaplus command should be std opts-only'; + +is $exa->_script, join( "\n" => ( + 'SET FEEDBACK OFF;', + 'SET HEADING OFF;', + 'WHENEVER OSERROR EXIT 9;', + 'WHENEVER SQLERROR EXIT 4;', + $exa->_registry_variable, +) ), '_script should work'; + +ok $exa->set_variables(foo => 'baz', whu => 'hi there', yo => q{'stellar'}), + 'Set some variables'; + +is $exa->_script, join( "\n" => ( + 'SET FEEDBACK OFF;', + 'SET HEADING OFF;', + 'WHENEVER OSERROR EXIT 9;', + 'WHENEVER SQLERROR EXIT 4;', + "DEFINE foo='baz';", + "DEFINE whu='hi there';", + "DEFINE yo='''stellar''';", + $exa->_registry_variable, +) ), '_script should assemble variables'; + +############################################################################## +# Test other configs for the target. +ENV: { + my $mocker = Test::MockModule->new('App::Sqitch'); + $mocker->mock(sysuser => 'sysuser=whatever'); + my $exa = $CLASS->new(sqitch => $sqitch, target => $target); + is $exa->target->name, 'db:exasol:', + 'Target name should NOT fall back on sysuser'; + is $exa->registry_destination, $exa->destination, + 'Registry target should be the same as destination'; +} + +############################################################################## +# Make sure config settings override defaults. +$config->update( + 'engine.exasol.client' => '/path/to/exaplus', + 'engine.exasol.target' => 'db:exasol://me:myself@localhost:4444', + 'engine.exasol.registry' => 'meta', +); + +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +ok $exa = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create another exasol'; +is $exa->client, '/path/to/exaplus', 'client should be as configured'; +is $exa->uri->as_string, 'db:exasol://me:myself@localhost:4444', + 'uri should be as configured'; +is $exa->registry, 'meta', 'registry should be as configured'; +is_deeply [$exa->exaplus], [qw( + /path/to/exaplus + -u me + -p myself + -c localhost:4444 +), @std_opts], 'exaplus command should be configured from URI config'; + +is $exa->_script, join( "\n" => ( + 'SET FEEDBACK OFF;', + 'SET HEADING OFF;', + 'WHENEVER OSERROR EXIT 9;', + 'WHENEVER SQLERROR EXIT 4;', + 'DEFINE registry=meta;', +) ), '_script should use registry from config settings'; + +############################################################################## +# Test _run() and _capture(). +can_ok $exa, qw(_run _capture); +my $mock_sqitch = Test::MockModule->new('App::Sqitch'); +my (@capture, @spool); +$mock_sqitch->mock(spool => sub { shift; @spool = @_ }); +my $mock_run3 = Test::MockModule->new('IPC::Run3'); +$mock_run3->mock(run3 => sub { @capture = @_ }); + +ok $exa->_run(qw(foo bar baz)), 'Call _run'; +my $fh = shift @spool; +is_deeply \@spool, [$exa->exaplus], + 'EXAplus command should be passed to spool()'; + +is join('', <$fh> ), $exa->_script(qw(foo bar baz)), + 'The script should be spooled'; + +ok $exa->_capture(qw(foo bar baz)), 'Call _capture'; +is_deeply \@capture, [ + [$exa->exaplus], \$exa->_script(qw(foo bar baz)), [], [], + { return_if_system_error => 1 }, +], 'Command and script should be passed to run3()'; + +# Let's make sure that IPC::Run3 actually works as expected. +$mock_run3->unmock_all; +my $echo = Path::Class::file(qw(t echo.pl)); +my $mock_exa = Test::MockModule->new($CLASS); +$mock_exa->mock(exaplus => sub { $^X, $echo, qw(hi there) }); + +is join (', ' => $exa->_capture(qw(foo bar baz))), "hi there\n", + '_capture should actually capture'; + +# Make it die. +my $die = Path::Class::file(qw(t die.pl)); +$mock_exa->mock(exaplus => sub { $^X, $die, qw(hi there) }); +like capture_stderr { + throws_ok { + $exa->_capture('whatever'), + } 'App::Sqitch::X', '_capture should die when exaplus dies'; +}, qr/^OMGWTF/m, 'STDERR should be emitted by _capture'; + +############################################################################## +# Test _file_for_script(). +can_ok $exa, '_file_for_script'; +is $exa->_file_for_script(Path::Class::file 'foo'), 'foo', + 'File without special characters should be used directly'; +is $exa->_file_for_script(Path::Class::file '"foo"'), '""foo""', + 'Double quotes should be SQL-escaped'; + +# Get the temp dir used by the engine. +ok my $tmpdir = $exa->tmpdir, 'Get temp dir'; +isa_ok $tmpdir, 'Path::Class::Dir', 'Temp dir'; + +# Make sure a file with @ is aliased. +my $file = $tmpdir->file('foo@bar.sql'); +$file->touch; # File must exist, because on Windows it gets copied. +is $exa->_file_for_script($file), $tmpdir->file('foo_bar.sql'), + 'File with special char should be aliased'; + +# Make sure double-quotes are escaped. +WIN32: { + $file = $tmpdir->file('"foo$bar".sql'); + my $mock_file = Test::MockModule->new(ref $file); + # Windows doesn't like the quotation marks, so prevent it from writing. + $mock_file->mock(copy_to => 1) if App::Sqitch::ISWIN; + is $exa->_file_for_script($file), $tmpdir->file('""foo_bar"".sql'), + 'File with special char and quotes should be aliased'; +} + +############################################################################## +# Test file and handle running. +my @run; +$mock_exa->mock(_capture => sub {shift; @run = @_ }); +ok $exa->run_file('foo/bar.sql'), 'Run foo/bar.sql'; +is_deeply \@run, ['@"foo/bar.sql"'], + 'File should be passed to capture()'; + +ok $exa->run_file('foo/"bar".sql'), 'Run foo/"bar".sql'; +is_deeply \@run, ['@"foo/""bar"".sql"'], + 'Double quotes in file passed to capture() should be escaped'; + +ok $exa->run_handle('FH'), 'Spool a "file handle"'; +my $handles = shift @spool; +is_deeply \@spool, [$exa->exaplus], + 'exaplus command should be passed to spool()'; +isa_ok $handles, 'ARRAY', 'Array ove handles should be passed to spool'; +$fh = $handles->[0]; +is join('', <$fh>), $exa->_script, 'First file handle should be script'; +is $handles->[1], 'FH', 'Second should be the passed handle'; + +# Verify should go to capture unless verosity is > 1. +$mock_exa->mock(_capture => sub {shift; @capture = @_ }); +ok $exa->run_verify('foo/bar.sql'), 'Verify foo/bar.sql'; +is_deeply \@capture, ['@"foo/bar.sql"'], + 'Verify file should be passed to capture()'; + +$mock_sqitch->mock(verbosity => 2); +ok $exa->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again'; + +is_deeply \@capture, ['@"foo/bar.sql"'], + 'Verify file should be passed to run() for high verbosity'; + +$mock_sqitch->unmock_all; +$mock_exa->unmock_all; + +############################################################################## +# Test DateTime formatting stuff. +ok my $ts2char = $CLASS->can('_ts2char_format'), "$CLASS->can('_ts2char_format')"; +is sprintf($ts2char->(), 'foo'), + qq{'year:' || CAST(EXTRACT(YEAR FROM foo) AS SMALLINT) + || ':month:' || CAST(EXTRACT(MONTH FROM foo) AS SMALLINT) + || ':day:' || CAST(EXTRACT(DAY FROM foo) AS SMALLINT) + || ':hour:' || CAST(EXTRACT(HOUR FROM foo) AS SMALLINT) + || ':minute:' || CAST(EXTRACT(MINUTE FROM foo) AS SMALLINT) + || ':second:' || FLOOR(CAST(EXTRACT(SECOND FROM foo) AS NUMERIC(9,4))) + || ':time_zone:UTC'}, + '_ts2char should work'; + +ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')"; +isa_ok my $dt = $dtfunc->( + 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC' +), 'App::Sqitch::DateTime', 'Return value of _dt()'; +is $dt->year, 2012, 'DateTime year should be set'; +is $dt->month, 7, 'DateTime month should be set'; +is $dt->day, 5, 'DateTime day should be set'; +is $dt->hour, 15, 'DateTime hour should be set'; +is $dt->minute, 7, 'DateTime minute should be set'; +is $dt->second, 1, 'DateTime second should be set'; +is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set'; + +$dt = App::Sqitch::DateTime->new( + year => 2017, month => 11, day => 06, + hour => 11, minute => 47, second => 35, time_zone => 'Europe/Stockholm'); +is $exa->_char2ts($dt), '2017-11-06 10:47:35', + '_char2ts should present timestamp at UTC w/o tz identifier'; + +############################################################################## +# Test SQL helpers. +is $exa->_listagg_format, q{GROUP_CONCAT(%s SEPARATOR ' ')}, 'Should have _listagg_format'; +is $exa->_ts_default, 'current_timestamp', 'Should have _ts_default'; +is $exa->_regex_op, 'REGEXP_LIKE', 'Should have _regex_op'; +is $exa->_simple_from, ' FROM dual', 'Should have _simple_from'; +is $exa->_limit_default, '18446744073709551611', 'Should have _limit_default'; + +DBI: { + local *DBI::errstr; + ok !$exa->_no_table_error, 'Should have no table error'; + ok !$exa->_no_column_error, 'Should have no column error'; + $DBI::errstr = 'object foo not found'; + ok $exa->_no_table_error, 'Should now have table error'; + ok $exa->_no_column_error, 'Should now have no column error'; +} + +is_deeply [$exa->_limit_offset(8, 4)], + [['LIMIT 8', 'OFFSET 4'], []], + 'Should get limit and offset'; +is_deeply [$exa->_limit_offset(0, 2)], + [['LIMIT 18446744073709551611', 'OFFSET 2'], []], + 'Should get limit and offset when offset only'; +is_deeply [$exa->_limit_offset(12, 0)], [['LIMIT 12'], []], + 'Should get only limit with 0 offset'; +is_deeply [$exa->_limit_offset(12)], [['LIMIT 12'], []], + 'Should get only limit with noa offset'; +is_deeply [$exa->_limit_offset(0, 0)], [[], []], + 'Should get no limit or offset for 0s'; +is_deeply [$exa->_limit_offset()], [[], []], + 'Should get no limit or offset for no args'; + +is_deeply [$exa->_regex_expr('corn', 'Obama$')], + ['corn REGEXP_LIKE ?', '.*Obama$'], + 'Should use regexp_like and prepend wildcard to regex'; +is_deeply [$exa->_regex_expr('corn', '^Obama')], + ['corn REGEXP_LIKE ?', '^Obama.*'], + 'Should use regexp_like and append wildcard to regex'; +is_deeply [$exa->_regex_expr('corn', '^Obama$')], + ['corn REGEXP_LIKE ?', '^Obama$'], + 'Should not chande regex with both anchors'; +is_deeply [$exa->_regex_expr('corn', 'Obama')], + ['corn REGEXP_LIKE ?', '.*Obama.*'], + 'Should append wildcards to both ends without anchors'; + +############################################################################## +# Can we do live tests? +my $dbh; +END { + return unless $dbh; + $dbh->{Driver}->visit_child_handles(sub { + my $h = shift; + $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh; + }); + + $dbh->{RaiseError} = 0; + $dbh->{PrintError} = 1; + $dbh->do($_) for ( + 'DROP SCHEMA sqitch CASCADE', + 'DROP SCHEMA sqitchtest CASCADE', + ); +} + +$uri = URI->new($ENV{EXA_URI} || 'db:dbadmin:password@localhost/dbadmin'); +my $err = try { + $exa->use_driver; + $dbh = DBI->connect($uri->dbi_dsn, $uri->user, $uri->password, { + PrintError => 0, + RaiseError => 1, + AutoCommit => 1, + }); + undef; +} catch { + eval { $_->message } || $_; +}; + +DBIEngineTest->run( + class => $CLASS, + target_params => [ uri => $uri ], + alt_target_params => [ uri => $uri, registry => 'sqitchtest' ], + skip_unless => sub { + my $self = shift; + die $err if $err; + # Make sure we have exaplus and can connect to the database. + $self->sqitch->probe( $self->client, '-version' ); + $self->_capture('SELECT 1 FROM dual;'); + }, + engine_err_regex => qr/\[EXASOL\]\[EXASolution driver\]syntax error/, + init_error => __x( + 'Sqitch already initialized', + schema => 'sqitchtest', + ), + add_second_format => q{%s + interval '1' second}, + test_dbh => sub { + my $dbh = shift; + # Make sure the sqitch schema is the first in the search path. + is $dbh->selectcol_arrayref('SELECT current_schema')->[0], + 'SQITCHTEST', 'The Sqitch schema should be the current schema'; + }, +); + +done_testing; diff --git a/t/firebird.t b/t/firebird.t new file mode 100644 index 00000000..ac7a474a --- /dev/null +++ b/t/firebird.t @@ -0,0 +1,380 @@ +#!/usr/bin/perl -w +# +# To test against a live Firebird database, you must set the FIREBIRD_URI environment variable. +# this is a stanard URI::db URI, and should look something like this: +# +# export FIREBIRD_URI=db:firebird://sysdba:password@localhost//path/to/test.db +# +# +use strict; +use warnings; +use 5.010; +use Test::More; +use App::Sqitch; +use App::Sqitch::Target; +use Test::MockModule; +use Path::Class; +use Try::Tiny; +use Test::Exception; +use Locale::TextDomain qw(App-Sqitch); +use File::Basename qw(dirname); +use File::Spec::Functions; +use File::Temp 'tempdir'; +use lib 't/lib'; +use DBIEngineTest; +use TestConfig; + +my $CLASS; +my $uri; +my $tmpdir; +my $have_fb_driver = 1; # assume DBD::Firebird is installed and so is Firebird + +# Is DBD::Firebird realy installed? +try { require DBD::Firebird; } catch { $have_fb_driver = 0; }; + +BEGIN { + $CLASS = 'App::Sqitch::Engine::firebird'; + require_ok $CLASS or die; + $uri = URI->new($ENV{FIREBIRD_URI} || do { + my $user = $ENV{ISC_USER} || $ENV{DBI_USER} || 'SYSDBA'; + my $pass = $ENV{ISC_PASSWORD} || $ENV{DBI_PASS} || 'masterkey'; + "db:firebird://$user:$pass@/" + }); + delete $ENV{$_} for qw(ISC_USER ISC_PASSWORD); + $tmpdir = File::Spec->tmpdir(); +} + +is_deeply [$CLASS->config_vars], [ + target => 'any', + registry => 'any', + client => 'any', +], 'config_vars should return three vars'; + +my $config = TestConfig->new('core.engine' => 'firebird'); +my $sqitch = App::Sqitch->new(config => $config); +my $target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI->new('db:firebird:foo.fdb'), +); +isa_ok my $fb = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS; + +is $fb->key, 'firebird', 'Key should be "firebird"'; +is $fb->name, 'Firebird', 'Name should be "Firebird"'; +is $fb->username, $ENV{ISC_USER}, 'Should have username from environment'; +is $fb->password, $ENV{ISC_PASSWORD}, 'Should have password from environment'; + +my $have_fb_client; +if ($have_fb_driver && (my $client = try { $fb->client })) { + $have_fb_client = 1; + like $client, qr/isql|fbsql|isql-fb/, + 'client should default to isql | fbsql | isql-fb'; +} + +is $fb->uri->dbname, file('foo.fdb'), 'dbname should be filled in'; +is $fb->registry_uri->dbname, 'sqitch.fdb', + 'registry dbname should be "sqitch.fdb"'; + +is $fb->registry_destination, $fb->registry_uri->as_string, + 'registry_destination should be the same as registry URI'; + +my @std_opts = ( + '-quiet', + '-bail', + '-sqldialect' => '3', + '-pagelength' => '16384', + '-charset' => 'UTF8', +); + +my $dbname = $fb->connection_string($fb->uri); +is_deeply([$fb->isql], [$fb->client, @std_opts, $dbname], + 'isql command should be std opts-only') if $have_fb_client; + +isa_ok $fb = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS; +ok $fb->set_variables(foo => 'baz', whu => 'hi there', yo => 'stellar'), + 'Set some variables'; + +is_deeply([$fb->isql], [$fb->client, @std_opts, $dbname], + 'isql command should be std opts-only') if $have_fb_client; + +############################################################################## +# Make sure environment variables are read. +ENV: { + local $ENV{ISC_USER} = '__kamala__'; + local $ENV{ISC_PASSWORD} = 'answer the question'; + ok my $fb = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create a firebird with environment variables set'; + is $fb->username, $ENV{ISC_USER}, 'Should have username from environment'; + is $fb->password, $ENV{ISC_PASSWORD}, 'Should have password from environment'; +} + +############################################################################## +# Make sure config settings override defaults. +$config->update( + 'engine.firebird.client' => '/path/to/isql', + 'engine.firebird.target' => 'db:firebird://freddy:s3cr3t@db.example.com:1234/widgets', + 'engine.firebird.registry' => 'meta', +); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +ok $fb = $CLASS->new(sqitch => $sqitch, target => $target), 'Create another firebird'; + +is $fb->client, '/path/to/isql', 'client should be as configured'; +is $fb->uri, URI::db->new('db:firebird://freddy:s3cr3t@db.example.com:1234/widgets'), + 'URI should be as configured'; +like $fb->destination, qr{db:firebird://freddy:?\@db.example.com:1234/widgets}, + 'destination should default to URI without password'; +like $fb->registry_destination, qr{db:firebird://freddy:?\@db.example.com:1234/meta}, + 'registry_destination should be URI with configured registry and no password'; +is_deeply [$fb->isql], [( + '/path/to/isql', + '-user', 'freddy', + '-password', 's3cr3t', +), @std_opts, 'db.example.com/1234:widgets'], 'firebird command should be configured'; + +############################################################################## +# Test connection_string. +can_ok $fb, 'connection_string'; +for my $file (qw( + foo.fdb + /blah/hi.fdb + C:/blah/hi.fdb +)) { + # DB name only. + is $fb->connection_string( URI::db->new("db:firebird:$file") ), + $file, "Connection for db:firebird:$file"; + # DB name and host. + is $fb->connection_string( URI::db->new("db:firebird:foo.com/$file") ), + "foo.com/$file", "Connection for db:firebird:foo.com/$file"; + # DB name, host, and port + is $fb->connection_string( URI::db->new("db:firebird:foo.com:1234/$file") ), + "foo.com:1234/$file", "Connection for db:firebird:foo.com/$file:1234"; +} + +throws_ok { $fb->connection_string( URI::db->new('db:firebird:') ) } + 'App::Sqitch::X', 'Should get an exception for no db name'; +is $@->ident, 'firebird', 'No dbname exception ident should be "firebird"'; +is $@->message, __x( + 'Database name missing in URI {uri}', + uri => 'db:firebird:', +), 'No dbname exception message should be correct'; + + +############################################################################## +# Test _run(), _capture(), and _spool(). +can_ok $fb, qw(_run _capture _spool); +my $mock_sqitch = Test::MockModule->new('App::Sqitch'); +my (@run, $exp_pass); +$mock_sqitch->mock(run => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @run = @_; + if (defined $exp_pass) { + is $ENV{ISC_PASSWORD}, $exp_pass, qq{ISC_PASSWORD should be "$exp_pass"}; + } else { + ok !exists $ENV{ISC_PASSWORD}, 'ISC_PASSWORD should not exist'; + } +}); + +my @capture; +$mock_sqitch->mock(capture => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @capture = @_; + if (defined $exp_pass) { + is $ENV{ISC_PASSWORD}, $exp_pass, qq{ISC_PASSWORD should be "$exp_pass"}; + } else { + ok !exists $ENV{ISC_PASSWORD}, 'ISC_PASSWORD should not exist'; + } +}); + +my @spool; +$mock_sqitch->mock(spool => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @spool = @_; + if (defined $exp_pass) { + is $ENV{ISC_PASSWORD}, $exp_pass, qq{ISC_PASSWORD should be "$exp_pass"}; + } else { + ok !exists $ENV{ISC_PASSWORD}, 'ISC_PASSWORD should not exist'; + } +}); + +$exp_pass = 's3cr3t'; +$target->uri->password($exp_pass); +ok $fb->_run(qw(foo bar baz)), 'Call _run'; +is_deeply \@run, [$fb->isql, qw(foo bar baz)], + 'Command should be passed to run()'; + +ok $fb->_spool('FH'), 'Call _spool'; +is_deeply \@spool, ['FH', $fb->isql], + 'Command should be passed to spool()'; + +ok $fb->_capture(qw(foo bar baz)), 'Call _capture'; +is_deeply \@capture, [$fb->isql, qw(foo bar baz)], + 'Command should be passed to capture()'; + +# Without password. +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +ok $fb = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create a firebird with sqitch with no pw'; +$exp_pass = undef; +$target->uri->password($exp_pass); +ok $fb->_run(qw(foo bar baz)), 'Call _run again'; +is_deeply \@run, [$fb->isql, qw(foo bar baz)], + 'Command should be passed to run() again'; + +ok $fb->_spool('FH'), 'Call _spool again'; +is_deeply \@spool, ['FH', $fb->isql], + 'Command should be passed to spool() again'; + +ok $fb->_capture(qw(foo bar baz)), 'Call _capture again'; +is_deeply \@capture, [$fb->isql, qw(foo bar baz)], + 'Command should be passed to capture() again'; + +############################################################################## +# Test file and handle running. +ok $fb->run_file('foo/bar.sql'), 'Run foo/bar.sql'; +is_deeply \@run, [$fb->isql, '-input', 'foo/bar.sql'], + 'File should be passed to run()'; + +ok $fb->run_handle('FH'), 'Spool a "file handle"'; +is_deeply \@spool, ['FH', $fb->isql], + 'Handle should be passed to spool()'; + +# Verify should go to capture unless verosity is > 1. +ok $fb->run_verify('foo/bar.sql'), 'Verify foo/bar.sql'; +is_deeply \@capture, [$fb->isql, '-input', 'foo/bar.sql'], + 'Verify file should be passed to capture()'; + +$mock_sqitch->mock(verbosity => 2); +ok $fb->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again'; +is_deeply \@run, [$fb->isql, '-input', 'foo/bar.sql'], + 'Verify file should be passed to run() for high verbosity'; + +$mock_sqitch->unmock_all; + +############################################################################## +# Test DateTime formatting stuff. +can_ok $CLASS, '_ts2char_format'; +is sprintf($CLASS->_ts2char_format, 'foo'), + q{'year:' || CAST(EXTRACT(YEAR FROM foo) AS SMALLINT) + || ':month:' || CAST(EXTRACT(MONTH FROM foo) AS SMALLINT) + || ':day:' || CAST(EXTRACT(DAY FROM foo) AS SMALLINT) + || ':hour:' || CAST(EXTRACT(HOUR FROM foo) AS SMALLINT) + || ':minute:' || CAST(EXTRACT(MINUTE FROM foo) AS SMALLINT) + || ':second:' || FLOOR(CAST(EXTRACT(SECOND FROM foo) AS NUMERIC(9,4))) + || ':time_zone:UTC'}, + '_ts2char_format should work'; # WORKS! :) +ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')"; +isa_ok my $dt = $dtfunc->( + 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC' +), 'App::Sqitch::DateTime', 'Return value of _dt()'; +is $dt->year, 2012, 'DateTime year should be set'; +is $dt->month, 7, 'DateTime month should be set'; +is $dt->day, 5, 'DateTime day should be set'; +is $dt->hour, 15, 'DateTime hour should be set'; +is $dt->minute, 7, 'DateTime minute should be set'; +is $dt->second, 1, 'DateTime second should be set'; +is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set'; + +############################################################################## + +# Can we do live tests? +my ($data_dir, $fb_version, @cleanup) = ($tmpdir); +my $err = try { + return unless $have_fb_driver; + if ($uri->dbname) { + $data_dir = dirname $uri->dbname; # Assumes local OS semantics. + } else { + # Assume we're running locally and create the database. + my $dbpath = catfile($tmpdir, '__sqitchtest__'); + $data_dir = $tmpdir; + $uri->dbname($dbpath); + DBD::Firebird->create_database({ + db_path => $dbpath, + user => $uri->user, + password => $uri->password, + character_set => 'UTF8', + page_size => 16384, + }); + @cleanup = ($dbpath); + } + # Try to connect. + my $dbh = DBI->connect($uri->dbi_dsn, $uri->user, $uri->password, { + PrintError => 0, + RaiseError => 1, + AutoCommit => 1, + }); + $fb_version = $dbh->selectcol_arrayref(q{ + SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') + FROM rdb$database + })->[0]; + push @cleanup => map { catfile $data_dir, $_ } qw(__sqitchtest __metasqitch); + return undef; +} catch { + eval { $_->message } || $_; +}; + +END { + return if $ENV{CI}; # No need to clean up under Travis. + foreach my $dbname (@cleanup) { + $uri->dbname($dbname); + my $dsn = $uri->dbi_dsn . q{;ib_dialect=3;ib_charset=UTF8}; + my $dbh = DBI->connect($dsn, $uri->user, $uri->password, { + FetchHashKeyName => 'NAME_lc', + AutoCommit => 1, + RaiseError => 0, + PrintError => 0, + }) or die $DBI::errstr; + + # Disconnect any other database handles. + $dbh->{Driver}->visit_child_handles(sub { + my $h = shift; + $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh; + }); + + # Kill all other connections. + $dbh->do('DELETE FROM MON$ATTACHMENTS WHERE MON$ATTACHMENT_ID <> CURRENT_CONNECTION'); + $dbh->func('ib_drop_database') or diag "Cannot drop '$dbname': $DBI::errstr"; + } +} + +DBIEngineTest->run( + class => $CLASS, + target_params => [ uri => $uri, registry => catfile($data_dir, '__metasqitch') ], + alt_target_params => [ uri => $uri, registry => catfile($data_dir, '__sqitchtest') ], + skip_unless => sub { + my $self = shift; + die $err if $err; + # Make sure we have the right isql and can connect to the + # database. Adapted from the FirebirdMaker.pm module of + # DBD::Firebird. + my $cmd = $self->client; + my $cmd_echo = qx(echo "quit;" | "$cmd" -z -quiet 2>&1 ); + return 0 unless $cmd_echo =~ m{Firebird}ims; + # Skip if no DBD::Firebird. + return 0 unless $have_fb_driver; + say "# Connected to Firebird $fb_version" if $fb_version; + return 1; + }, + engine_err_regex => qr/\QDynamic SQL Error\E/xms, + init_error => __x( + 'Sqitch database {database} already initialized', + database => catfile($data_dir, '__sqitchtest'), + ), + add_second_format => q{dateadd(1 second to %s)}, + test_dbh => sub { + my $dbh = shift; + # Check the session configuration... + # To try: https://www.firebirdsql.org/refdocs/langrefupd21-intfunc-get_context.html + is( + $dbh->selectcol_arrayref(q{ + SELECT rdb$get_context('SYSTEM', 'DB_NAME') + FROM rdb$database + })->[0], + catfile($data_dir, '__sqitchtest'), + 'The Sqitch db should be the current db' + ); + }, +); + +done_testing; diff --git a/t/help.t b/t/help.t new file mode 100644 index 00000000..6fde4b3d --- /dev/null +++ b/t/help.t @@ -0,0 +1,93 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 20; +#use Test::More 'no_plan'; +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use Test::Exception; +use Test::Warn; +use Config; +use File::Spec; +use Test::MockModule; +use Test::NoWarnings; +use lib 't/lib'; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::help'; + +ok my $sqitch = App::Sqitch->new, 'Load a sqitch sqitch object'; +my $config = TestConfig->new; + +isa_ok my $help = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'help', + config => $config, +}), $CLASS, 'Load help command'; +isa_ok $help, 'App::Sqitch::Command', 'Help command'; + +can_ok $help, qw( + options + execute + find_and_show +); + +is_deeply [$CLASS->options], [qw( + guide|g +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +my $mock = Test::MockModule->new($CLASS); +my @args; +$mock->mock(_pod2usage => sub { @args = @_} ); + +ok $help->execute, 'Execute help'; +is_deeply \@args, [ + $help, + '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitchcommands'), + '-verbose' => 2, + '-exitval' => 0, +], 'Should show sqitch app docs'; + +ok $help->execute('config'), 'Execute "config" help'; +is_deeply \@args, [ + $help, + '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitch-config'), + '-verbose' => 2, + '-exitval' => 0, +], 'Should show "config" command docs'; + +ok $help->execute('changes'), 'Execute "changes" help'; +is_deeply \@args, [ + $help, + '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitchchanges'), + '-verbose' => 2, + '-exitval' => 0, +], 'Should show "changes" command docs'; + +ok $help->execute('tutorial'), 'Execute "tutorial" help'; +is_deeply \@args, [ + $help, + '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitchtutorial'), + '-verbose' => 2, + '-exitval' => 0, +], 'Should show "tutorial" command docs'; + +my @fail; +$mock->mock(fail => sub { @fail = @_ }); +throws_ok { $help->execute('nonexistent') } 'App::Sqitch::X', + 'Should get an exception for "nonexistent" help'; +is $@->ident, 'help', 'Exception ident should be "help"'; +is $@->message, __x( + 'No manual entry for {command}', + command => 'sqitch-nonexistent', +), 'Should get failure message for nonexistent command'; +is $@->exitval, 1, 'Exception exit val should be 1'; diff --git a/t/init.t b/t/init.t new file mode 100644 index 00000000..b1775f5a --- /dev/null +++ b/t/init.t @@ -0,0 +1,641 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More tests => 187; +#use Test::More 'no_plan'; +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use Path::Class; +use Test::Dir; +use Test::File qw(file_not_exists_ok file_exists_ok); +use Test::Exception; +use Test::Warn; +use Test::File::Contents; +use Test::NoWarnings; +use File::Path qw(remove_tree make_path); +use URI; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $exe_ext = App::Sqitch::ISWIN ? '.exe' : ''; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Command::init'; + use_ok $CLASS or die; +} + +isa_ok $CLASS, 'App::Sqitch::Command', $CLASS; +chdir 't'; + +############################################################################## +# Test options and configuration. +my $config = TestConfig->new; +my $sqitch = App::Sqitch->new( config => $config); + +isa_ok my $init = $CLASS->new( + sqitch => $sqitch, + properties => { + top_dir => dir('init.mkdir'), + reworked_dir => dir('init.mkdir/reworked'), + }, +), $CLASS, 'Init command'; +isa_ok $init, 'App::Sqitch::Command', 'Init commmand'; + +can_ok $init, qw( + uri + properties + options + configure + does +); + +ok $CLASS->does("App::Sqitch::Role::TargetConfigCommand"), + "$CLASS does TargetConfigCommand"; + +is_deeply [$init->options], [qw( + uri=s + engine=s + target=s + plan-file|f=s + registry=s + client=s + extension=s + top-dir=s + dir|d=s% + set|s=s% +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure({}, {}), { properties => {}}, + 'Default config should contain empty properties'; +is_deeply $CLASS->configure({}, { uri => 'https://example.com' }), { + uri => URI->new('https://example.com'), + properties => {}, +}, 'Should accept a URI in options'; +ok my $conf = $CLASS->configure({}, { + uri => 'https://example.com', + engine => 'pg', + top_dir => 'top', + plan_file => 'my.plan', + registry => 'bats', + client => 'cli', + extension => 'ddl', + target => 'db:pg:foo', + dir => { + deploy => 'dep', + revert => 'rev', + verify => 'ver', + reworked => 'wrk', + reworked_deploy => 'rdep', + reworked_revert => 'rrev', + reworked_verify => 'rver', + }, + set => { + foo => 'bar', + prefix => 'x_', + }, +}), 'Get full config'; + +isa_ok $conf->{uri}, 'URI', 'uri propertiy'; +is_deeply $conf->{properties}, { + engine => 'pg', + top_dir => 'top', + plan_file => 'my.plan', + registry => 'bats', + client => 'cli', + extension => 'ddl', + target => 'db:pg:foo', + deploy_dir => 'dep', + revert_dir => 'rev', + verify_dir => 'ver', + reworked_dir => 'wrk', + reworked_deploy_dir => 'rdep', + reworked_revert_dir => 'rrev', + reworked_verify_dir => 'rver', + variables => { + foo => 'bar', + prefix => 'x_', + }, +}, 'Should have properties'; +isa_ok $conf->{properties}{$_}, 'Path::Class::File', "$_ file attribute" for qw( + plan_file +); +isa_ok $conf->{properties}{$_}, 'Path::Class::Dir', "$_ directory attribute" for ( + 'top_dir', + 'reworked_dir', + map { ($_, "reworked_$_") } qw(deploy_dir revert_dir verify_dir) +); + +# Make sure invalid directories are ignored. +throws_ok { $CLASS->new($CLASS->configure({}, { + dir => { foo => 'bar' }, +})) } 'App::Sqitch::X', 'Should fail on invalid directory name'; +is $@->ident, 'init', 'Invalid directory ident should be "init"'; +is $@->message, __x( + 'Unknown directory name: {prop}', + prop => 'foo', +), 'The invalid directory messsage should be correct'; + +throws_ok { $CLASS->new($CLASS->configure({}, { + dir => { foo => 'bar', cavort => 'ha' }, +})) } 'App::Sqitch::X', 'Should fail on invalid directory names'; +is $@->ident, 'init', 'Invalid directories ident should be "init"'; +is $@->message, __x( + 'Unknown directory names: {props}', + props => 'cavort, foo', +), 'The invalid properties messsage should be correct'; + +isa_ok my $target = $init->config_target, 'App::Sqitch::Target', 'default target'; + +############################################################################## +# Test make_directories_for. +can_ok $init, 'make_directories_for'; +dir_not_exists_ok $target->top_dir; +dir_not_exists_ok $_ for $init->directories_for($target); + +my $top_dir_string = $target->top_dir->stringify; +END { remove_tree $top_dir_string if -e $top_dir_string } + +ok $init->make_directories_for($target), 'Make the directories'; +dir_exists_ok $_ for $init->directories_for($target); + +my $sep = dir('')->stringify; +my $dirs = $init->properties; +is_deeply +MockOutput->get_info, [ + [__x "Created {file}", file => $target->deploy_dir . $sep], + [__x "Created {file}", file => $target->revert_dir . $sep], + [__x "Created {file}", file => $target->verify_dir . $sep], + [__x "Created {file}", file => $dirs->{reworked_dir}->subdir('deploy') . $sep], + [__x "Created {file}", file => $dirs->{reworked_dir}->subdir('revert') . $sep], + [__x "Created {file}", file => $dirs->{reworked_dir}->subdir('verify') . $sep], +], 'Each should have been sent to info'; + +# Do it again. +ok $init->make_directories_for($target), 'Make the directories again'; +is_deeply +MockOutput->get_info, [], 'Nothing should have been sent to info'; + +# Delete one of them. +remove_tree $target->revert_dir->stringify; +ok $init->make_directories_for($target), 'Make the directories once more'; +dir_exists_ok $target->revert_dir, 'revert dir exists again'; +is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $target->revert_dir . $sep], +], 'Should have noted creation of revert dir'; + +remove_tree $top_dir_string; + +# Handle errors. +FSERR: { + # Make mkpath to insert an error. + my $mock = Test::MockModule->new('File::Path'); + $mock->mock( mkpath => sub { + my ($file, $p) = @_; + ${ $p->{error} } = [{ $file => 'Permission denied yo'}]; + return; + }); + + throws_ok { $init->make_directories_for($target) } 'App::Sqitch::X', + 'Should fail on permission issue'; + is $@->ident, 'init', 'Permission error should have ident "init"'; + is $@->message, __x( + 'Error creating {path}: {error}', + path => $target->deploy_dir, + error => 'Permission denied yo', + ), 'The permission error should be formatted properly'; +} + +############################################################################## +# Test write_config(). +$sqitch = App::Sqitch->new(config => $config); +can_ok $init, 'write_config'; + +my $write_dir = 'init.write'; +make_path $write_dir; +END { remove_tree $write_dir } +chdir $write_dir; +END { chdir File::Spec->updir } +my $conf_file = $sqitch->config->local_file; + +my $uri = URI->new('https://github.com/sqitchers/sqitch/'); + +ok $init = $CLASS->new( + sqitch => $sqitch, +), 'Another init object'; +file_not_exists_ok $conf_file; +$target = $init->config_target; + +# Write empty config. +ok $init->write_config, 'Write the config'; +file_exists_ok $conf_file; +is_deeply $config->data_from($conf_file), { +}, 'The configuration file should have no variables'; +is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $conf_file] +], 'The creation should be sent to info'; +my $top_dir = File::Spec->curdir; +my $deploy_dir = File::Spec->catdir(qw(deploy)); +my $revert_dir = File::Spec->catdir(qw(revert)); +my $verify_dir = File::Spec->catdir(qw(verify)); +my $plan_file = $target->top_dir->file('sqitch.plan')->cleanup->stringify; +file_contents_like $conf_file, qr{\Q[core] + # engine = + # plan_file = $plan_file + # top_dir = $top_dir +}m, 'All in core section should be commented-out'; +unlink $conf_file; + +# Set two options. +$sqitch = App::Sqitch->new(config => $config); +ok $init = $CLASS->new( sqitch => $sqitch, properties => { extension => 'foo' } ), + 'Another init object'; +$target = $init->config_target; +ok $init->write_config, 'Write the config'; +file_exists_ok $conf_file; +is_deeply $config->data_from($conf_file), { + 'core.extension' => 'foo', +}, 'The configuration should have been written with the one setting'; +is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $conf_file] +], 'The creation should be sent to info'; + +file_contents_like $conf_file, qr{ + # engine = + # plan_file = $plan_file + # top_dir = $top_dir +}m, 'Other settings should be commented-out'; + +# Go again. +ok $init->write_config, 'Write the config again'; +is_deeply $config->data_from($conf_file), { + 'core.extension' => 'foo', +}, 'The configuration should be unchanged'; +is_deeply +MockOutput->get_info, [ +], 'Nothing should have been sent to info'; + +USERCONF: { + # Delete the file and write with a user config loaded. + unlink $conf_file; + my $config = TestConfig->from( user => file +File::Spec->updir, 'user.conf' ); + my $sqitch = App::Sqitch->new(config => $config); + ok my $init = $CLASS->new( sqitch => $sqitch, properties => { extension => 'foo' }), + 'Make an init object with user config'; + file_not_exists_ok $conf_file; + ok $init->write_config, 'Write the config with a user conf'; + file_exists_ok $conf_file; + is_deeply $config->data_from($conf_file), { + 'core.extension' => 'foo', + }, 'The configuration should just have core.top_dir'; + is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $conf_file] + ], 'The creation should be sent to info again'; + file_contents_like $conf_file, qr{\Q + # engine = + # plan_file = $plan_file + # top_dir = $top_dir +}m, 'Other settings should be commented-out'; +} + +SYSTEMCONF: { + # Delete the file and write with a system config loaded. + unlink $conf_file; + my $config = TestConfig->from( system => file +File::Spec->updir, 'sqitch.conf' ); + my $sqitch = App::Sqitch->new(config => $config); + ok my $init = $CLASS->new( sqitch => $sqitch, properties => { extension => 'foo' } ), + 'Make an init object with system config'; + ok $target = $init->config_target, 'Get target'; + file_not_exists_ok $conf_file; + ok $init->write_config, 'Write the config with a system conf'; + file_exists_ok $conf_file; + is_deeply $config->data_from($conf_file), { + 'core.extension' => 'foo', + 'core.engine' => 'pg', + }, 'The configuration should have local and system config' or diag $conf_file->slurp; + is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $conf_file] + ], 'The creation should be sent to info again'; + + my $plan_file = $target->top_dir->file('sqitch.plan')->stringify; + file_contents_like $conf_file, qr{\Q + # plan_file = $plan_file + # top_dir = migrations +}m, 'Other settings should be commented-out'; +} + +############################################################################## +# Now get it to write a bunch of other stuff. +unlink $conf_file; +$sqitch = App::Sqitch->new(config => $config); + +ok $init = $CLASS->new( + sqitch => $sqitch, + properties => { + engine => 'sqlite', + top_dir => dir('top'), + plan_file => file('my.plan'), + registry => 'bats', + client => 'cli', + target => 'db:sqlite:foo', + extension => 'ddl', + deploy_dir => dir('dep'), + revert_dir => dir('rev'), + verify_dir => dir('tst'), + reworked_deploy_dir => dir('rdep'), + reworked_revert_dir => dir('rrev'), + reworked_verify_dir => dir('rtst'), + variables => { ay => 'first', Bee => 'second' }, + } +), 'Create new init with sqitch non-default attributes'; + +ok $init->write_config, 'Write the config with core attrs'; +is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $conf_file] +], 'The creation should be sent to info once more'; + +is_deeply $config->data_from($conf_file), { + 'core.top_dir' => 'top', + 'core.plan_file' => 'my.plan', + 'core.deploy_dir' => 'dep', + 'core.revert_dir' => 'rev', + 'core.verify_dir' => 'tst', + 'core.reworked_deploy_dir' => 'rdep', + 'core.reworked_revert_dir' => 'rrev', + 'core.reworked_verify_dir' => 'rtst', + 'core.extension' => 'ddl', + 'core.engine' => 'sqlite', + 'core.variables.ay' => 'first', + 'core.variables.bee' => 'second', + 'engine.sqlite.registry' => 'bats', + 'engine.sqlite.client' => 'cli', + 'engine.sqlite.target' => 'db:sqlite:foo', +}, 'The configuration should have been written with core and engine values'; + +############################################################################## +# Try it with no options. +unlink $conf_file; +$sqitch = App::Sqitch->new(config => $config); +ok $init = $CLASS->new( sqitch => $sqitch, properties => { engine => 'sqlite' } ), + 'Create new init with sqitch with default engine attributes'; +ok $init->write_config, 'Write the config with engine attrs'; +is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $conf_file] +], 'The creation should be sent to info again again'; +is_deeply $config->data_from($conf_file), { + 'core.engine' => 'sqlite', +}, 'The configuration should have been written with only the engine var'; + +file_contents_like $conf_file, qr{^\Q# [engine "sqlite"] + # target = db:sqlite: + # registry = sqitch + # client = sqlite3$exe_ext +}m, 'Engine section should be present but commented-out'; + +# Now build it with other config. +USERCONF: { + # Delete the file and write with a user config loaded. + unlink $conf_file; + my $config = TestConfig->from( user => file +File::Spec->updir, 'user.conf' ); + $config->update('core.engine' => 'sqlite'); + my $sqitch = App::Sqitch->new(config => $config); + ok my $init = $CLASS->new( sqitch => $sqitch ), + 'Make an init with sqlite and user config'; + file_not_exists_ok $conf_file; + ok $init->write_config, 'Write the config with sqlite config'; + is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $conf_file] + ], 'The creation should be sent to info once more'; + + is_deeply $config->data_from($conf_file), { + 'core.engine' => 'sqlite', + }, 'New config should have been written with sqlite values'; + + file_contents_like $conf_file, qr{^\t\Q# client = /opt/local/bin/sqlite3\E\n}m, + 'Configured client should be included in a comment'; + file_contents_like $conf_file, qr/^\t# target = db:sqlite:my\.db\n/m, + 'Configured target should be included in a comment'; + file_contents_like $conf_file, qr/^\t# registry = meta\n/m, + 'Configured registry should be included in a comment'; +} + +############################################################################## +# Now get it to write engine.pg stuff. +unlink $conf_file; +$config->replace; +$sqitch = App::Sqitch->new(config => $config); + +ok $init = $CLASS->new( + sqitch => $sqitch, + properties => { engine => 'pg', client => '/to/psql' }, +), 'Create new init with sqitch with more non-default engine attributes'; +ok $init->write_config, 'Write the config with more engine attrs'; +is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $conf_file] +], 'The creation should be sent to info one more time'; + +is_deeply $config->data_from($conf_file), { + 'core.engine' => 'pg', + 'engine.pg.client' => '/to/psql', +}, 'The configuration should have been written with client values' or diag $conf_file->slurp; + +file_contents_like $conf_file, qr/^\t# registry = sqitch\n/m, + 'registry should be included in a comment'; + +# Try it with no config or options. +unlink $conf_file; +$sqitch = App::Sqitch->new(config => $config); +ok $init = $CLASS->new( sqitch => $sqitch, properties => { engine => 'pg' } ), + 'Create new init with sqitch with default engine attributes'; +ok $init->write_config, 'Write the config with engine attrs'; +is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $conf_file] +], 'The creation should be sent to info again again again'; +is_deeply $config->data_from($conf_file), { + 'core.engine' => 'pg', +}, 'The configuration should have been written with only the engine var' or diag $conf_file->slurp; + +file_contents_like $conf_file, qr{^\Q# [engine "pg"] + # target = db:pg: + # registry = sqitch + # client = psql$exe_ext +}m, 'Engine section should be present but commented-out' or diag $conf_file->slurp; + +USERCONF: { + # Delete the file and write with a user config loaded. + unlink $conf_file; + my $config = TestConfig->from( user => file +File::Spec->updir, 'user.conf' ); + $config->update('core.engine' => 'pg'); + my $sqitch = App::Sqitch->new(config => $config); + ok my $init = $CLASS->new( sqitch => $sqitch ), + 'Make an init with pg and user config'; + file_not_exists_ok $conf_file; + ok $init->write_config, 'Write the config with pg config'; + is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $conf_file] + ], 'The pg config creation should be sent to info'; + + is_deeply $config->data_from($conf_file), { + 'core.engine' => 'pg', + }, 'The configuration should have been written with pg options' or diag $conf_file->slurp; + + file_contents_like $conf_file, qr/^\t# registry = meta\n/m, + 'Configured registry should be in a comment'; + file_contents_like $conf_file, + qr{^\t# target = db:pg://postgres\@localhost/thingies\n}m, + 'Configured target should be in a comment'; +} + +############################################################################## +# Test write_plan(). +can_ok $init, 'write_plan'; +$target = $init->config_target; +$plan_file = $target->plan_file; +file_not_exists_ok $plan_file, 'Plan file should not yet exist'; +ok $init->write_plan( project => 'nada' ), 'Write the plan file'; +is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $plan_file] +], 'The plan creation should be sent to info'; +file_exists_ok $plan_file, 'Plan file should now exist'; +file_contents_is $plan_file, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION() . "\n" . + '%project=nada' . "\n\n", + 'The contents should be correct'; + +# Make sure we don't overwrite the file when initializing again. +ok $init->write_plan( project => 'nada' ), 'Write the plan file again'; +file_exists_ok $plan_file, 'Plan file should still exist'; +file_contents_is $plan_file, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION() . "\n" . + '%project=nada' . "\n\n", + 'The contents should be identical'; + +# Make sure we get an error trying to initalize a different plan. +throws_ok { $init->write_plan( project => 'oopsie' ) } 'App::Sqitch::X', + 'Should get an error initialing a different project'; +is $@->ident, 'init', 'Initialization error ident should be "init"'; +is $@->message, __x( + 'Cannot initialize because project "{project}" already initialized in {file}', + project => 'nada', + file => $plan_file, +), 'Initialzation error message should be correct'; + +# Write a different file. +my $fh = $plan_file->open('>:utf8_strict') or die "Cannot open $plan_file: $!\n"; +$fh->say('# testing 1, 2, 3'); +$fh->close; + +# Try writing again. +throws_ok { $init->write_plan( project => 'foofoo' ) } 'App::Sqitch::X', + 'Should get an error initialzing a non-plan file'; +is $@->ident, 'init', 'Non-plan file error ident should be "init"'; +is $@->message, __x( + 'Cannot initialize because {file} already exists and is not a valid plan file', + file => $plan_file, +), 'Non-plan file error message should be correct'; +file_contents_like $plan_file, qr/testing 1, 2, 3/, + 'The file should not be overwritten'; + +# Make sure a URI gets written, if present. +$plan_file->remove; +$sqitch = App::Sqitch->new(config => $config); +END { remove_tree dir('plan.dir')->stringify }; +ok $init = $CLASS->new( + sqitch => $sqitch, + uri => $uri, + properties => { top_dir => dir('plan.dir') }, +), 'Create new init with sqitch with project and URI'; +$target = $init->config_target; +$plan_file = $target->plan_file; +ok $init->write_plan( project => 'howdy', uri => $init->uri ), 'Write the plan file again'; +is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $plan_file->dir . $sep], + [__x 'Created {file}', file => $plan_file] +], 'The plan creation should be sent to info againq'; +file_exists_ok $plan_file, 'Plan file should again exist'; +file_contents_is $plan_file, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION() . "\n" . + '%project=howdy' . "\n" . + '%uri=' . $uri->canonical . "\n\n", + 'The plan should include the project and uri pragmas'; + +############################################################################## +# Test _validate_project(). +can_ok $init, '_validate_project'; +NOPROJ: { + # Test handling of no command. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; die 'USAGE' }); + throws_ok { $CLASS->_validate_project } + qr/USAGE/, 'No project should yield usage'; + is_deeply \@args, [$CLASS], 'No args should be passed to usage'; +} + +# Test invalid project names. +my @bad_names = ( + '^foo', # No leading punctuation + 'foo^', # No trailing punctuation + 'foo^6', # No trailing punctuation+digit + 'foo^666', # No trailing punctuation+digits + '%hi', # No leading punctuation + 'hi!', # No trailing punctuation + 'foo@bar', # No @ allowed at all + 'foo:bar', # No : allowed at all + '+foo', # No leading + + '-foo', # No leading - + '@foo', # No leading @ +); +for my $bad (@bad_names) { + throws_ok { $init->_validate_project($bad) } 'App::Sqitch::X', + qq{Should get error for invalid project name "$bad"}; + is $@->ident, 'init', qq{Bad project "$bad" ident should be "init"}; + is $@->message, __x( + qq{invalid project name "{project}": project names must not } + . 'begin with punctuation, contain "@", ":", "#", or blanks, or end in ' + . 'punctuation or digits following punctuation', + project => $bad + ), qq{Bad project "$bad" error message should be correct}; +} + +############################################################################## +# Bring it all together, yo. +$conf_file->remove; +$plan_file->remove; +ok $init->execute('foofoo'), 'Execute!'; + +# Should have directories. +for my $attr (map { "$_\_dir"} qw(top deploy revert verify)) { + dir_exists_ok $target->$attr; +} + +# Should have config and plan. +file_exists_ok $conf_file; +file_exists_ok $plan_file; + +# Should have the output. +my @dir_messages = map { + [__x 'Created {file}', file => $target->$_ . $sep] +} map { "$_\_dir" } qw(deploy revert verify); +is_deeply +MockOutput->get_info, [ + [__x 'Created {file}', file => $conf_file], + [__x 'Created {file}', file => $plan_file], + @dir_messages, +], 'Should have status messages'; + +file_contents_is $plan_file, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION() . "\n" . + '%project=foofoo' . "\n" . + '%uri=' . $uri->canonical . "\n\n", + 'The plan should have the --project name'; diff --git a/t/item_formatter.t b/t/item_formatter.t new file mode 100644 index 00000000..335737fe --- /dev/null +++ b/t/item_formatter.t @@ -0,0 +1,287 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 158; +#use Test::More 'no_plan'; +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use Test::NoWarnings; +use Test::Exception; +use Path::Class; +use Term::ANSIColor qw(color); +use App::Sqitch::DateTime; +use Encode; +use lib 't/lib'; +use MockOutput; +use TestConfig; +use LC; + +my $CLASS = 'App::Sqitch::ItemFormatter'; +require_ok $CLASS; +can_ok $CLASS => qw( + new + abbrev + date_format + color + formatter + format +); + +isa_ok my $formatter = $CLASS->new, $CLASS, 'Instantiated object'; +ok !$formatter->abbrev, 'Should not be abbreviated by default'; +is $formatter->date_format, 'iso', 'Default date format should be "iso"'; + +############################################################################### +# Test all formatting characters. +my $cdt = App::Sqitch::DateTime->now; +my $pdt = $cdt->clone->subtract(days => 1); +my $local_cdt = $cdt->clone; +$local_cdt->set_time_zone('local'); +my $local_pdt = $pdt->clone; +$local_pdt->set_time_zone('local'); +my $craw = $cdt->as_string( format => 'raw' ); + +my $event = { + event => 'deploy', + project => 'logit', + change_id => '000011112222333444', + change => 'lolz', + tags => [ '@beta', '@gamma' ], + committer_name => 'larry', + committer_email => 'larry@example.com', + committed_at => $cdt, + planner_name => 'damian', + planner_email => 'damian@example.com', + planned_at => $pdt, + note => "For the LOLZ.\n\nYou know, funny stuff and cute kittens, right?", + requires => [qw(foo bar)], + conflicts => [] +}; + +$_->set_locale($LC::TIME) for ($local_cdt, $local_pdt); + +for my $spec ( + ['%e', { event => 'deploy' }, 'deploy' ], + ['%e', { event => 'revert' }, 'revert' ], + ['%e', { event => 'fail' }, 'fail' ], + + ['%L', { event => 'deploy' }, __ 'Deploy' ], + ['%L', { event => 'revert' }, __ 'Revert' ], + ['%L', { event => 'fail' }, __ 'Fail' ], + + ['%l', { event => 'deploy' }, __ 'deploy' ], + ['%l', { event => 'revert' }, __ 'revert' ], + ['%l', { event => 'fail' }, __ 'fail' ], + + ['%{event}_', {}, __ 'Event: ' ], + ['%{change}_', {}, __ 'Change: ' ], + ['%{committer}_', {}, __ 'Committer:' ], + ['%{planner}_', {}, __ 'Planner: ' ], + ['%{by}_', {}, __ 'By: ' ], + ['%{date}_', {}, __ 'Date: ' ], + ['%{committed}_', {}, __ 'Committed:' ], + ['%{planned}_', {}, __ 'Planned: ' ], + ['%{name}_', {}, __ 'Name: ' ], + ['%{email}_', {}, __ 'Email: ' ], + ['%{requires}_', {}, __ 'Requires: ' ], + ['%{conflicts}_', {}, __ 'Conflicts:' ], + + ['%H', { change_id => '123456789' }, '123456789' ], + ['%h', { change_id => '123456789' }, '123456789' ], + ['%{5}h', { change_id => '123456789' }, '12345' ], + ['%{7}h', { change_id => '123456789' }, '1234567' ], + + ['%n', { change => 'foo' }, 'foo'], + ['%n', { change => 'bar' }, 'bar'], + ['%o', { project => 'foo' }, 'foo'], + ['%o', { project => 'bar' }, 'bar'], + + ['%c', { committer_name => 'larry', committer_email => 'larry@example.com' }, 'larry <larry@example.com>'], + ['%{n}c', { committer_name => 'damian' }, 'damian'], + ['%{name}c', { committer_name => 'chip' }, 'chip'], + ['%{e}c', { committer_email => 'larry@example.com' }, 'larry@example.com'], + ['%{email}c', { committer_email => 'damian@example.com' }, 'damian@example.com'], + + ['%{date}c', { committed_at => $cdt }, $cdt->as_string( format => 'iso' ) ], + ['%{date:rfc}c', { committed_at => $cdt }, $cdt->as_string( format => 'rfc' ) ], + ['%{d:long}c', { committed_at => $cdt }, $cdt->as_string( format => 'long' ) ], + ["%{d:cldr:HH'h' mm'm'}c", { committed_at => $cdt }, $local_cdt->format_cldr( q{HH'h' mm'm'} ) ], + ["%{d:strftime:%a at %H:%M:%S}c", { committed_at => $cdt }, $local_cdt->strftime('%a at %H:%M:%S') ], + + ['%p', { planner_name => 'larry', planner_email => 'larry@example.com' }, 'larry <larry@example.com>'], + ['%{n}p', { planner_name => 'damian' }, 'damian'], + ['%{name}p', { planner_name => 'chip' }, 'chip'], + ['%{e}p', { planner_email => 'larry@example.com' }, 'larry@example.com'], + ['%{email}p', { planner_email => 'damian@example.com' }, 'damian@example.com'], + + ['%{date}p', { planned_at => $pdt }, $pdt->as_string( format => 'iso' ) ], + ['%{date:rfc}p', { planned_at => $pdt }, $pdt->as_string( format => 'rfc' ) ], + ['%{d:long}p', { planned_at => $pdt }, $pdt->as_string( format => 'long' ) ], + ["%{d:cldr:HH'h' mm'm'}p", { planned_at => $pdt }, $local_pdt->format_cldr( q{HH'h' mm'm'} ) ], + ["%{d:strftime:%a at %H:%M:%S}p", { planned_at => $pdt }, $local_pdt->strftime('%a at %H:%M:%S') ], + + ['%t', { tags => [] }, '' ], + ['%t', { tags => ['@foo'] }, ' @foo' ], + ['%t', { tags => ['@foo', '@bar'] }, ' @foo, @bar' ], + ['%{|}t', { tags => [] }, '' ], + ['%{|}t', { tags => ['@foo'] }, ' @foo' ], + ['%{|}t', { tags => ['@foo', '@bar'] }, ' @foo|@bar' ], + + ['%T', { tags => [] }, '' ], + ['%T', { tags => ['@foo'] }, ' (@foo)' ], + ['%T', { tags => ['@foo', '@bar'] }, ' (@foo, @bar)' ], + ['%{|}T', { tags => [] }, '' ], + ['%{|}T', { tags => ['@foo'] }, ' (@foo)' ], + ['%{|}T', { tags => ['@foo', '@bar'] }, ' (@foo|@bar)' ], + + ['%r', { requires => [] }, '' ], + ['%r', { requires => ['foo'] }, ' foo' ], + ['%r', { requires => ['foo', 'bar'] }, ' foo, bar' ], + ['%{|}r', { requires => [] }, '' ], + ['%{|}r', { requires => ['foo'] }, ' foo' ], + ['%{|}r', { requires => ['foo', 'bar'] }, ' foo|bar' ], + + ['%R', { requires => [] }, '' ], + ['%R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ], + ['%R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo, bar\n" ], + ['%{|}R', { requires => [] }, '' ], + ['%{|}R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ], + ['%{|}R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo|bar\n" ], + + ['%x', { conflicts => [] }, '' ], + ['%x', { conflicts => ['foo'] }, ' foo' ], + ['%x', { conflicts => ['foo', 'bax'] }, ' foo, bax' ], + ['%{|}x', { conflicts => [] }, '' ], + ['%{|}x', { conflicts => ['foo'] }, ' foo' ], + ['%{|}x', { conflicts => ['foo', 'bax'] }, ' foo|bax' ], + + ['%X', { conflicts => [] }, '' ], + ['%X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ], + ['%X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo, bar\n" ], + ['%{|}X', { conflicts => [] }, '' ], + ['%{|}X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ], + ['%{|}X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo|bar\n" ], + + ['%{yellow}C', {}, '' ], + ['%{:event}C', { event => 'deploy' }, '' ], + ['%v', {}, "\n" ], + ['%%', {}, '%' ], + + ['%s', { note => 'hi there' }, 'hi there' ], + ['%s', { note => "hi there\nyo" }, 'hi there' ], + ['%s', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, 'subject line' ], + ['%{ }s', { note => 'hi there' }, ' hi there' ], + ['%{xx}s', { note => 'hi there' }, 'xxhi there' ], + + ['%b', { note => 'hi there' }, '' ], + ['%b', { note => "hi there\nyo" }, 'yo' ], + ['%b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "first graph\n\nsecond graph\n\n" ], + ['%{ }b', { note => 'hi there' }, '' ], + ['%{xxx }b', { note => "hi there\nyo" }, "xxx yo" ], + ['%{x}b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xfirst graph\nx\nxsecond graph\nx\n" ], + ['%{ }b', { note => "hi there\r\nyo" }, " yo" ], + + ['%B', { note => 'hi there' }, 'hi there' ], + ['%B', { note => "hi there\nyo" }, "hi there\nyo" ], + ['%B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "subject line\n\nfirst graph\n\nsecond graph\n\n" ], + ['%{ }B', { note => 'hi there' }, ' hi there' ], + ['%{xxx }B', { note => "hi there\nyo" }, "xxx hi there\nxxx yo" ], + ['%{x}B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xsubject line\nx\nxfirst graph\nx\nxsecond graph\nx\n" ], + ['%{ }B', { note => "hi there\r\nyo" }, " hi there\r\n yo" ], + + ['%{change}a', $event, "change $event->{change}\n" ], + ['%{change_id}a', $event, "change_id $event->{change_id}\n" ], + ['%{event}a', $event, "event $event->{event}\n" ], + ['%{tags}a', $event, 'tags ' . join(', ', @{ $event->{tags} }) . "\n" ], + ['%{requires}a', $event, 'requires ' . join(', ', @{ $event->{requires} }) . "\n" ], + ['%{conflicts}a', $event, '' ], + ['%{committer_name}a', $event, "committer_name $event->{committer_name}\n" ], + ['%{committed_at}a', $event, "committed_at $craw\n" ], +) { + (my $desc = encode_utf8 $spec->[2]) =~ s/\n/[newline]/g; + local $ENV{ANSI_COLORS_DISABLED} = 1; + is $formatter->format( $spec->[0], $spec->[1] ), $spec->[2], + qq{Format "$spec->[0]" should output "$desc"}; +} + +throws_ok { $formatter->format( '%_', {} ) } 'App::Sqitch::X', + 'Should get exception for format "%_"'; +is $@->ident, 'format', '%_ error ident should be "format"'; +is $@->message, __ 'No label passed to the _ format', + '%_ error message should be correct'; +throws_ok { $formatter->format( '%{foo}_', {} ) } 'App::Sqitch::X', + 'Should get exception for unknown label in format "%_"'; +is $@->ident, 'format', 'Invalid %_ label error ident should be "format"'; +is $@->message, __x( + 'Unknown label "{label}" passed to the _ format', + label => 'foo' +), 'Invalid %_ label error message should be correct'; + +ok $formatter = $CLASS->new( abbrev => 4 ), + 'Instantiate with abbrev => 4'; +is $formatter->format( '%h', { change_id => '123456789' } ), + '1234', '%h should respect abbrev'; +is $formatter->format( '%H', { change_id => '123456789' } ), + '123456789', '%H should not respect abbrev'; + +ok $formatter = $CLASS->new( date_format => 'rfc' ), + 'Instantiate with date_format => "rfc"'; +is $formatter->format( '%{date}c', { committed_at => $cdt } ), + $cdt->as_string( format => 'rfc' ), + '%{date}c should respect the date_format attribute'; +is $formatter->format( '%{d:iso}c', { committed_at => $cdt } ), + $cdt->as_string( format => 'iso' ), + '%{iso}c should override the date_format attribute'; + +throws_ok { $formatter->format( '%{foo}a', {}) } 'App::Sqitch::X', + 'Should get exception for unknown attribute passed to %a'; +is $@->ident, 'format', '%a error ident should be "log"'; +is $@->message, __x( + '{attr} is not a valid change attribute', attr => 'foo' +), '%a error message should be correct'; + +# Test colors. +delete $ENV{ANSI_COLORS_DISABLED}; +ok $formatter = $CLASS->new( color => 'always' ), + 'Construct with color "always"'; +for my $color (qw(yellow red blue cyan magenta)) { + is $formatter->format( "%{$color}C", {} ), color($color), + qq{Format "%{$color}C" should output } + . color($color) . $color . color('reset'); +} + +for my $spec ( + [ ':event', { event => 'deploy' }, 'green', 'deploy' ], + [ ':event', { event => 'revert' }, 'blue', 'revert' ], + [ ':event', { event => 'fail' }, 'red', 'fail' ], +) { + is $formatter->format( "%{$spec->[0]}C", $spec->[1] ), color($spec->[2]), + qq{Format "%{$spec->[0]}C" on "$spec->[3]" should output } + . color($spec->[2]) . $spec->[2] . color('reset'); +} + +throws_ok { $formatter->format( '%{BLUELOLZ}C', {} ) } 'App::Sqitch::X', + 'Should get an error for an invalid color'; +is $@->ident, 'format', 'Invalid color error ident should be "log"'; +is $@->message, __x( + '{color} is not a valid ANSI color', color => 'BLUELOLZ' +), 'Invalid color error message should be correct'; + +# Make sure color "never" works. +ok $formatter = $CLASS->new( color => 'never' ), + 'Construct with color "never"'; +for my $color (qw(yellow red blue cyan magenta)) { + is $formatter->format( "%{$color}C", {} ), '', + qq{Format "%{$color}C" should not output a color}; +} + +# Make sure an unknown format character throws a proper exception. +throws_ok { $formatter->format('%Z', {}) } 'App::Sqitch::X', + 'Should get an exception for a bad format code'; +is $@->ident, 'format', + 'bad format code format error ident should be "log"'; +is $@->message, __x( + 'Unknown format code "{code}"', code => 'Z', +), 'bad format code format error message should be correct'; diff --git a/t/lib/App/Sqitch/Command/bad.pm b/t/lib/App/Sqitch/Command/bad.pm new file mode 100644 index 00000000..b6d16a9f --- /dev/null +++ b/t/lib/App/Sqitch/Command/bad.pm @@ -0,0 +1,3 @@ +package App::Sqitch::Command::bad; +use Moo; +die 'LOL BADZ'; diff --git a/t/lib/App/Sqitch/Command/good.pm b/t/lib/App/Sqitch/Command/good.pm new file mode 100644 index 00000000..31107b5c --- /dev/null +++ b/t/lib/App/Sqitch/Command/good.pm @@ -0,0 +1,20 @@ +package App::Sqitch::Command::good; +use Moo; +extends 'App::Sqitch::Command'; + +1; + +=head1 NAME + +good - Good stuff. + +=head1 SYNOPSIS + + + +=head1 DESCRIPTION + + + +=cut + diff --git a/t/lib/App/Sqitch/Engine/bad.pm b/t/lib/App/Sqitch/Engine/bad.pm new file mode 100644 index 00000000..14918aa8 --- /dev/null +++ b/t/lib/App/Sqitch/Engine/bad.pm @@ -0,0 +1,3 @@ +package App::Sqitch::Engine::bad; + +die 'LOL BADZ'; diff --git a/t/lib/App/Sqitch/Engine/good.pm b/t/lib/App/Sqitch/Engine/good.pm new file mode 100644 index 00000000..44fe05cb --- /dev/null +++ b/t/lib/App/Sqitch/Engine/good.pm @@ -0,0 +1,18 @@ +package App::Sqitch::Engine::good; +extends 'App::Sqitch::Engine'; +1; + +=head1 NAME + +good - Good stuff. + +=head1 SYNOPSIS + + + +=head1 DESCRIPTION + + + +=cut + diff --git a/t/lib/DBIEngineTest.pm b/t/lib/DBIEngineTest.pm new file mode 100644 index 00000000..91bee08f --- /dev/null +++ b/t/lib/DBIEngineTest.pm @@ -0,0 +1,1807 @@ +package DBIEngineTest; +use 5.010; +use strict; +use warnings; +use utf8; +use Try::Tiny; +use Test::More; +use Test::Exception; +use Time::HiRes qw(sleep); +use Path::Class 0.33 qw(file dir); +use Digest::SHA qw(sha1_hex); +use Locale::TextDomain qw(App-Sqitch); +use File::Temp 'tempdir'; + +# Just die on warnings. +use Carp; BEGIN { $SIG{__WARN__} = \&Carp::confess } + +sub run { + my ( $self, %p ) = @_; + + my $class = $p{class}; + my @sqitch_params = @{ $p{sqitch_params} || [] }; + my $user1_name = 'Marge Simpson'; + my $user1_email = 'marge@example.com'; + my $mock_sqitch = Test::MockModule->new('App::Sqitch'); + + # Mock script hashes using lines from the README. + my $mock_change = Test::MockModule->new('App::Sqitch::Plan::Change'); + my @lines = grep { $_ } file('README.md')->slurp( + chomp => 1, + iomode => '<:encoding(UTF-8)' + ); + # Each change should retain its own hash. + my $orig_deploy_hash; + $mock_change->mock(_deploy_hash => sub { + my $self = shift; + $self->$orig_deploy_hash || sha1_hex shift @lines; + }); + $orig_deploy_hash = $mock_change->original('_deploy_hash'); + + can_ok $class, qw( + initialized + initialize + run_file + run_handle + log_deploy_change + log_fail_change + log_revert_change + earliest_change_id + latest_change_id + is_deployed_tag + is_deployed_change + change_id_for + change_id_for_depend + name_for_change_id + change_offset_from_id + change_id_offset_from_id + load_change + ); + + subtest 'live database' => sub { + my $sqitch = App::Sqitch->new( + @sqitch_params, + user_name => $user1_name, + user_email => $user1_email, + config => TestConfig->new( + 'core.engine' => $class->key, + 'core.top_dir' => dir(qw(t engine))->stringify, + 'core.plan_file' => file(qw(t engine sqitch.plan))->stringify, + ) + ); + my $target = App::Sqitch::Target->new( + sqitch => $sqitch, + @{ $p{target_params} || [] }, + ); + my $engine = $class->new( + sqitch => $sqitch, + target => $target, + @{ $p{engine_params} || [] }, + ); + if (my $code = $p{skip_unless}) { + try { + $code->( $engine ) || die 'NO'; + } catch { + (my $msg = eval { $_->message } || $_) =~ s/^/# /g; + plan skip_all => sprintf( + 'Unable to live-test %s engine: %s', + $class->name, + substr($msg, 2), + ) unless $ENV{'LIVE_' . uc $engine->key . '_REQUIRED'}; + fail 'Connect to ' . $class->name; + diag substr $msg, 2; + } or return; + } + if (my $q = $p{version_query}) { + say '# Connected to ', $engine->dbh->selectcol_arrayref($q)->[0]; + } + ok $engine, 'Engine instantiated'; + + ok !$engine->initialized, 'Database should not yet be initialized'; + OLDREG: { + my $mock_file = Test::MockModule->new('Path::Class::File'); + my $dir = file(__FILE__)->dir->subdir('upgradable_registries'); + $mock_file->mock( dir => sub { $dir } ); + ok $engine->initialize, 'Initialize the database'; + }; + ok $engine->initialized, 'Database should now be initialized'; + ok !$engine->needs_upgrade, 'Registry should not need upgrading'; + my $get_releases = sub { + my $releases = $engine->dbh->selectall_arrayref(q{ + SELECT version, installer_name, installer_email + FROM releases + ORDER BY version + }); + $_->[0] = sprintf '%.1f', $_->[0] for @{ $releases }; + return $releases; + }; + is_deeply $get_releases->(), [ + [$engine->registry_release + 0, $sqitch->user_name, $sqitch->user_email] + ], 'The release should be registered'; + + # Let's make sure upgrades work. + $engine->dbh->do('DROP TABLE releases'); + ok $engine->needs_upgrade, 'Registry should need upgrading'; + MOCKINFO: { + my $sqitch_mocker = Test::MockModule->new(ref $sqitch); + my @args; + $sqitch_mocker->mock(info => sub { shift; push @args => @_ }); + ok $engine->upgrade_registry, 'Upgrade the registry'; + is_deeply \@args, [__x( + 'Upgrading the Sqitch registry from {old} to {new}', + old => 0, + new => '1.1', + ), ' * ' . __x( + 'From {old} to {new}', + old => 0, + new => '1.0', + ), ' * ' . __x( + 'From {old} to {new}', + old => '1.0', + new => '1.1', + )], 'Should have info output for upgrade'; + } + ok !$engine->needs_upgrade, 'Registry should no longer need upgrading'; + is_deeply $get_releases->(), [ + [ '1.0', $sqitch->user_name, $sqitch->user_email ], + [ '1.1', $sqitch->user_name, $sqitch->user_email ], + ], 'The release should be registered again'; + + # Try it with a different Sqitch DB. + $target = App::Sqitch::Target->new( + sqitch => $sqitch, + @{ $p{alt_target_params} || [] }, + ); + ok $engine = $class->new( + sqitch => $sqitch, + target => $target, + @{ $p{alt_engine_params} || [] }, + ), 'Create engine with alternate params'; + + is $engine->earliest_change_id, undef, 'No init, earliest change'; + is $engine->latest_change_id, undef, 'No init, no latest change'; + + ok !$engine->initialized, 'Database should no longer seem initialized'; + ok $engine->initialize, 'Initialize the database again'; + ok $engine->initialized, 'Database should be initialized again'; + ok !$engine->needs_upgrade, 'Registry should not need upgrading'; + + is $engine->earliest_change_id, undef, 'Still no earlist change'; + is $engine->latest_change_id, undef, 'Still no latest changes'; + + # Make sure a second attempt to initialize dies. + throws_ok { $engine->initialize } 'App::Sqitch::X', + 'Should die on existing schema'; + is $@->ident, 'engine', 'Mode should be "engine"'; + is $@->message, $p{init_error}, + 'And it should show the proper schema in the error message'; + + throws_ok { $engine->dbh->do('INSERT blah INTO __bar_____') } 'App::Sqitch::X', + 'Database error should be converted to Sqitch exception'; + is $@->ident, $DBI::state, 'Ident should be SQL error state'; + like $@->message, $p{engine_err_regex}, 'The message should be from the engine'; + like $@->previous_exception, qr/DBD::[^:]+::db do failed: /, + 'The DBI error should be in preview_exception'; + + is $engine->current_state, undef, 'Current state should be undef'; + is_deeply all( $engine->current_changes ), [], 'Should have no current changes'; + is_deeply all( $engine->current_tags ), [], 'Should have no current tags'; + is_deeply all( $engine->search_events ), [], 'Should have no events'; + + ########################################################################## + # Test the database connection, if appropriate. + if (my $code = $p{test_dbh}) { + $code->($engine->dbh); + } + + ########################################################################## + # Test register_project(). + can_ok $engine, 'register_project'; + can_ok $engine, 'registered_projects'; + + is_deeply [ $engine->registered_projects ], [], + 'Should have no registered projects'; + + ok $engine->register_project, 'Register the project'; + is_deeply [ $engine->registered_projects ], ['engine'], + 'Should have one registered project, "engine"'; + is_deeply $engine->dbh->selectall_arrayref( + 'SELECT project, uri, creator_name, creator_email FROM projects' + ), [['engine', undef, $sqitch->user_name, $sqitch->user_email]], + 'The project should be registered'; + + # Try to register it again. + ok $engine->register_project, 'Register the project again'; + is_deeply [ $engine->registered_projects ], ['engine'], + 'Should still have one registered project, "engine"'; + is_deeply $engine->dbh->selectall_arrayref( + 'SELECT project, uri, creator_name, creator_email FROM projects' + ), [['engine', undef, $sqitch->user_name, $sqitch->user_email]], + 'The project should still be registered only once'; + + # Register a different project name. + MOCKPROJECT: { + my $plan_mocker = Test::MockModule->new(ref $target->plan ); + $plan_mocker->mock(project => 'groovy'); + $plan_mocker->mock(uri => 'https://example.com/'); + ok $engine->register_project, 'Register a second project'; + } + + is_deeply [ $engine->registered_projects ], ['engine', 'groovy'], + 'Should have both registered projects'; + is_deeply $engine->dbh->selectall_arrayref( + 'SELECT project, uri, creator_name, creator_email FROM projects ORDER BY created_at' + ), [ + ['engine', undef, $sqitch->user_name, $sqitch->user_email], + ['groovy', 'https://example.com/', $sqitch->user_name, $sqitch->user_email], + ], 'Both projects should now be registered'; + + # Try to register with a different URI. + MOCKURI: { + my $plan_mocker = Test::MockModule->new(ref $target->plan ); + my $plan_proj = 'engine'; + my $plan_uri = 'https://example.net/'; + $plan_mocker->mock(project => sub { $plan_proj }); + $plan_mocker->mock(uri => sub { $plan_uri }); + throws_ok { $engine->register_project } 'App::Sqitch::X', + 'Should get an error for defined URI vs NULL registered URI'; + is $@->ident, 'engine', 'Defined URI error ident should be "engine"'; + is $@->message, __x( + 'Cannot register "{project}" with URI {uri}: already exists with NULL URI', + project => 'engine', + uri => $plan_uri, + ), 'Defined URI error message should be correct'; + + # Try it when the registered URI is NULL. + $plan_proj = 'groovy'; + throws_ok { $engine->register_project } 'App::Sqitch::X', + 'Should get an error for different URIs'; + is $@->ident, 'engine', 'Different URI error ident should be "engine"'; + is $@->message, __x( + 'Cannot register "{project}" with URI {uri}: already exists with URI {reg_uri}', + project => 'groovy', + uri => $plan_uri, + reg_uri => 'https://example.com/', + ), 'Different URI error message should be correct'; + + # Try with a NULL project URI. + $plan_uri = undef; + throws_ok { $engine->register_project } 'App::Sqitch::X', + 'Should get an error for NULL plan URI'; + is $@->ident, 'engine', 'NULL plan URI error ident should be "engine"'; + is $@->message, __x( + 'Cannot register "{project}" without URI: already exists with URI {uri}', + project => 'groovy', + uri => 'https://example.com/', + ), 'NULL plan uri error message should be correct'; + + # It should succeed when the name and URI are the same. + $plan_uri = 'https://example.com/'; + ok $engine->register_project, 'Register "groovy" again'; + is_deeply [ $engine->registered_projects ], ['engine', 'groovy'], + 'Should still have two registered projects'; + is_deeply $engine->dbh->selectall_arrayref( + 'SELECT project, uri, creator_name, creator_email FROM projects ORDER BY created_at' + ), [ + ['engine', undef, $sqitch->user_name, $sqitch->user_email], + ['groovy', 'https://example.com/', $sqitch->user_name, $sqitch->user_email], + ], 'Both projects should still be registered'; + + # Now try the same URI but a different name. + $plan_proj = 'bob'; + throws_ok { $engine->register_project } 'App::Sqitch::X', + 'Should get error for an project with the URI'; + is $@->ident, 'engine', 'Existing URI error ident should be "engine"'; + is $@->message, __x( + 'Cannot register "{project}" with URI {uri}: project "{reg_proj}" already using that URI', + project => $plan_proj, + uri => $plan_uri, + reg_proj => 'groovy', + ), 'Exising URI error message should be correct'; + } + + ###################################################################### + # Test log_deploy_change(). + my $plan = $target->plan; + my $change = $plan->change_at(0); + my ($tag) = $change->tags; + is $change->name, 'users', 'Should have "users" change'; + ok !$engine->is_deployed_change($change), 'The change should not be deployed'; + is_deeply [$engine->are_deployed_changes($change)], [], + 'The change should not be deployed'; + + ok $engine->log_deploy_change($change), 'Deploy "users" change'; + ok $engine->is_deployed_change($change), 'The change should now be deployed'; + is_deeply [$engine->are_deployed_changes($change)], [$change->id], + 'The change should now be deployed'; + + is $engine->earliest_change_id, $change->id, 'Should get users ID for earliest change ID'; + is $engine->earliest_change_id(1), undef, 'Should get no change offset 1 from earliest'; + is $engine->latest_change_id, $change->id, 'Should get users ID for latest change ID'; + is $engine->latest_change_id(1), undef, 'Should get no change offset 1 from latest'; + + is_deeply all_changes($engine), [[ + $change->id, 'users', 'engine', 'User roles', $sqitch->user_name, $sqitch->user_email, + $change->planner_name, $change->planner_email, + ]],'A record should have been inserted into the changes table'; + is_deeply get_dependencies($engine, $change->id), [], 'Should have no dependencies'; + is_deeply [ $engine->changes_requiring_change($change) ], [], + 'Change should not be required'; + + + my @event_data = ([ + 'deploy', + $change->id, + 'users', + 'engine', + 'User roles', + $engine->_log_requires_param($change), + $engine->_log_conflicts_param($change), + $engine->_log_tags_param($change), + $sqitch->user_name, + $sqitch->user_email, + $change->planner_name, + $change->planner_email + ]); + + is_deeply all_events($engine), \@event_data, + 'A record should have been inserted into the events table'; + + is_deeply all_tags($engine), [[ + $tag->id, + '@alpha', + $change->id, + 'engine', + 'Good to go!', + $sqitch->user_name, + $sqitch->user_email, + $tag->planner_name, + $tag->planner_email, + ]], 'The tag should have been logged'; + + is $engine->name_for_change_id($change->id), 'users@alpha', + 'name_for_change_id() should return the change name with tag'; + + ok my $state = $engine->current_state, 'Get the current state'; + isa_ok my $dt = delete $state->{committed_at}, 'App::Sqitch::DateTime', + 'committed_at value'; + is $dt->time_zone->name, 'UTC', 'committed_at TZ should be UTC'; + is_deeply $state, { + project => 'engine', + change_id => $change->id, + script_hash => $change->script_hash, + change => 'users', + note => 'User roles', + committer_name => $sqitch->user_name, + committer_email => $sqitch->user_email, + tags => ['@alpha'], + planner_name => $change->planner_name, + planner_email => $change->planner_email, + planned_at => $change->timestamp, + }, 'The rest of the state should look right'; + is_deeply all( $engine->current_changes ), [{ + change_id => $change->id, + script_hash => $change->script_hash, + change => 'users', + committer_name => $sqitch->user_name, + committer_email => $sqitch->user_email, + committed_at => $dt, + planner_name => $change->planner_name, + planner_email => $change->planner_email, + planned_at => $change->timestamp, + }], 'Should have one current change'; + is_deeply all( $engine->current_tags('nonesuch') ), [], + 'Should have no current chnages for nonexistent project'; + is_deeply all( $engine->current_tags ), [{ + tag_id => $tag->id, + tag => '@alpha', + committed_at => dt_for_tag( $engine, $tag->id ), + committer_name => $sqitch->user_name, + committer_email => $sqitch->user_email, + planner_name => $tag->planner_name, + planner_email => $tag->planner_email, + planned_at => $tag->timestamp, + }], 'Should have one current tags'; + is_deeply all( $engine->current_tags('nonesuch') ), [], + 'Should have no current tags for nonexistent project'; + my @events = ({ + event => 'deploy', + project => 'engine', + change_id => $change->id, + change => 'users', + note => 'User roles', + requires => $engine->_log_requires_param($change), + conflicts => $engine->_log_conflicts_param($change), + tags => $engine->_log_tags_param($change), + committer_name => $sqitch->user_name, + committer_email => $sqitch->user_email, + committed_at => dt_for_event($engine, 0), + planned_at => $change->timestamp, + planner_name => $change->planner_name, + planner_email => $change->planner_email, + }); + is_deeply all( $engine->search_events ), \@events, 'Should have one event'; + + ###################################################################### + # Test log_new_tags(). + ok $engine->log_new_tags($change), 'Log new tags for "users" change'; + is_deeply all_tags($engine), [[ + $tag->id, + '@alpha', + $change->id, + 'engine', + 'Good to go!', + $sqitch->user_name, + $sqitch->user_email, + $tag->planner_name, + $tag->planner_email, + ]], 'The tag should be the same'; + + # Delete that tag. + $engine->dbh->do('DELETE FROM tags'); + is_deeply all_tags($engine), [], 'Should now have no tags'; + + # Put it back. + ok $engine->log_new_tags($change), 'Log new tags for "users" change again'; + is_deeply all_tags($engine), [[ + $tag->id, + '@alpha', + $change->id, + 'engine', + 'Good to go!', + $sqitch->user_name, + $sqitch->user_email, + $tag->planner_name, + $tag->planner_email, + ]], 'The tag should be back'; + + ###################################################################### + # Test log_revert_change(). First shift existing event dates. + ok $engine->log_revert_change($change), 'Revert "users" change'; + ok !$engine->is_deployed_change($change), 'The change should no longer be deployed'; + is_deeply [$engine->are_deployed_changes($change)], [], + 'The change should no longer be deployed'; + + is $engine->earliest_change_id, undef, 'Should get undef for earliest change'; + is $engine->latest_change_id, undef, 'Should get undef for latest change'; + + is_deeply all_changes($engine), [], + 'The record should have been deleted from the changes table'; + is_deeply all_tags($engine), [], 'And the tag record should have been removed'; + is_deeply get_dependencies($engine, $change->id), [], 'Should still have no dependencies'; + is_deeply [ $engine->changes_requiring_change($change) ], [], + 'Change should not be required'; + + push @event_data, [ + 'revert', + $change->id, + 'users', + 'engine', + 'User roles', + $engine->_log_requires_param($change), + $engine->_log_conflicts_param($change), + $engine->_log_tags_param($change), + $sqitch->user_name, + $sqitch->user_email, + $change->planner_name, + $change->planner_email + ]; + + is_deeply all_events($engine), \@event_data, + 'The revert event should have been logged'; + + is $engine->name_for_change_id($change->id), undef, + 'name_for_change_id() should no longer return the change name'; + is $engine->current_state, undef, 'Current state should be undef again'; + is_deeply all( $engine->current_changes ), [], + 'Should again have no current changes'; + is_deeply all( $engine->current_tags ), [], 'Should again have no current tags'; + + unshift @events => { + event => 'revert', + project => 'engine', + change_id => $change->id, + change => 'users', + note => 'User roles', + requires => $engine->_log_requires_param($change), + conflicts => $engine->_log_conflicts_param($change), + tags => $engine->_log_tags_param($change), + committer_name => $sqitch->user_name, + committer_email => $sqitch->user_email, + committed_at => dt_for_event($engine, 1), + planned_at => $change->timestamp, + planner_name => $change->planner_name, + planner_email => $change->planner_email, + }; + is_deeply all( $engine->search_events ), \@events, 'Should have two events'; + + ###################################################################### + # Test log_fail_change(). + ok $engine->log_fail_change($change), 'Fail "users" change'; + ok !$engine->is_deployed_change($change), 'The change still should not be deployed'; + is_deeply [$engine->are_deployed_changes($change)], [], + 'The change still should not be deployed'; + is $engine->earliest_change_id, undef, 'Should still get undef for earliest change'; + is $engine->latest_change_id, undef, 'Should still get undef for latest change'; + is_deeply all_changes($engine), [], 'Still should have not changes table record'; + is_deeply all_tags($engine), [], 'Should still have no tag records'; + is_deeply get_dependencies($engine, $change->id), [], 'Should still have no dependencies'; + is_deeply [ $engine->changes_requiring_change($change) ], [], + 'Change should not be required'; + + push @event_data, [ + 'fail', + $change->id, + 'users', + 'engine', + 'User roles', + $engine->_log_requires_param($change), + $engine->_log_conflicts_param($change), + $engine->_log_tags_param($change), + $sqitch->user_name, + $sqitch->user_email, + $change->planner_name, + $change->planner_email + ]; + + is_deeply all_events($engine), \@event_data, 'The fail event should have been logged'; + is $engine->current_state, undef, 'Current state should still be undef'; + is_deeply all( $engine->current_changes ), [], 'Should still have no current changes'; + is_deeply all( $engine->current_tags ), [], 'Should still have no current tags'; + + unshift @events => { + event => 'fail', + project => 'engine', + change_id => $change->id, + change => 'users', + note => 'User roles', + requires => $engine->_log_requires_param($change), + conflicts => $engine->_log_conflicts_param($change), + tags => $engine->_log_tags_param($change), + committer_name => $sqitch->user_name, + committer_email => $sqitch->user_email, + committed_at => dt_for_event($engine, 2), + planned_at => $change->timestamp, + planner_name => $change->planner_name, + planner_email => $change->planner_email, + }; + is_deeply all( $engine->search_events ), \@events, 'Should have 3 events'; + + # From here on in, use a different committer. + my $user2_name = 'Homer Simpson'; + my $user2_email = 'homer@example.com'; + $mock_sqitch->mock( user_name => $user2_name ); + $mock_sqitch->mock( user_email => $user2_email ); + + ###################################################################### + # Test a change with dependencies. + ok $engine->log_deploy_change($change), 'Deploy the change again'; + ok $engine->is_deployed_tag($tag), 'The tag again should be deployed'; + is $engine->earliest_change_id, $change->id, 'Should again get users ID for earliest change ID'; + is $engine->earliest_change_id(1), undef, 'Should still get no change offset 1 from earliest'; + is $engine->latest_change_id, $change->id, 'Should again get users ID for latest change ID'; + is $engine->latest_change_id(1), undef, 'Should still get no change offset 1 from latest'; + + ok my $change2 = $plan->change_at(1), 'Get the second change'; + is_deeply [sort $engine->are_deployed_changes($change, $change2)], [$change->id], + 'Only the first change should be deployed'; + my ($req) = $change2->requires; + ok $req->resolved_id($change->id), 'Set resolved ID in required depend'; + # Send this change back in time. + $engine->dbh->do( + 'UPDATE changes SET committed_at = ?', + undef, '2013-03-30 00:47:47', + ); + ok $engine->log_deploy_change($change2), 'Deploy second change'; + is $engine->earliest_change_id, $change->id, 'Should still get users ID for earliest change ID'; + is $engine->earliest_change_id(1), $change2->id, + 'Should get "widgets" offset 1 from earliest'; + is $engine->earliest_change_id(2), undef, 'Should get no change offset 2 from earliest'; + is $engine->latest_change_id, $change2->id, 'Should get "widgets" ID for latest change ID'; + is $engine->latest_change_id(1), $change->id, + 'Should get "user" offset 1 from earliest'; + is $engine->latest_change_id(2), undef, 'Should get no change offset 2 from latest'; + + is_deeply all_changes($engine), [ + [ + $change->id, + 'users', + 'engine', + 'User roles', + $user2_name, + $user2_email, + $change->planner_name, + $change->planner_email, + ], + [ + $change2->id, + 'widgets', + 'engine', + 'All in', + $user2_name, + $user2_email, + $change2->planner_name, + $change2->planner_email, + ], + ], 'Should have both changes and requires/conflcits deployed'; + is_deeply [sort $engine->are_deployed_changes($change, $change2)], + [sort $change->id, $change2->id], + 'Both changes should be deployed'; + is_deeply get_dependencies($engine, $change->id), [], + 'Should still have no dependencies for "users"'; + is_deeply get_dependencies($engine, $change2->id), [ + [ + $change2->id, + 'conflict', + 'dr_evil', + undef, + ], + [ + $change2->id, + 'require', + 'users', + $change->id, + ], + ], 'Should have both dependencies for "widgets"'; + + is_deeply [ $engine->changes_requiring_change($change) ], [{ + project => 'engine', + change_id => $change2->id, + change => 'widgets', + asof_tag => undef, + }], 'Change "users" should be required by "widgets"'; + is_deeply [ $engine->changes_requiring_change($change2) ], [], + 'Change "widgets" should not be required'; + + push @event_data, [ + 'deploy', + $change->id, + 'users', + 'engine', + 'User roles', + $engine->_log_requires_param($change), + $engine->_log_conflicts_param($change), + $engine->_log_tags_param($change), + $user2_name, + $user2_email, + $change->planner_name, + $change->planner_email, + ], [ + 'deploy', + $change2->id, + 'widgets', + 'engine', + 'All in', + $engine->_log_requires_param($change2), + $engine->_log_conflicts_param($change2), + $engine->_log_tags_param($change2), + $user2_name, + $user2_email, + $change2->planner_name, + $change2->planner_email, + ]; + is_deeply all_events($engine), \@event_data, + 'The new change deploy should have been logged'; + + is $engine->name_for_change_id($change2->id), 'widgets@HEAD', + 'name_for_change_id() should return name with symbolic tag @HEAD'; + + ok $state = $engine->current_state, 'Get the current state again'; + isa_ok $dt = delete $state->{committed_at}, 'App::Sqitch::DateTime', + 'committed_at value'; + is $dt->time_zone->name, 'UTC', 'committed_at TZ should be UTC'; + is_deeply $state, { + project => 'engine', + change_id => $change2->id, + script_hash => $change2->script_hash, + change => 'widgets', + note => 'All in', + committer_name => $user2_name, + committer_email => $user2_email, + planner_name => $change2->planner_name, + planner_email => $change2->planner_email, + planned_at => $change2->timestamp, + tags => [], + }, 'The state should reference new change'; + + my @current_changes = ( + { + change_id => $change2->id, + script_hash => $change2->script_hash, + change => 'widgets', + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_change( $engine, $change2->id ), + planner_name => $change2->planner_name, + planner_email => $change2->planner_email, + planned_at => $change2->timestamp, + }, + { + change_id => $change->id, + script_hash => $change->script_hash, + change => 'users', + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_change( $engine, $change->id ), + planner_name => $change->planner_name, + planner_email => $change->planner_email, + planned_at => $change->timestamp, + }, + ); + + is_deeply all( $engine->current_changes ), \@current_changes, + 'Should have two current changes in reverse chronological order'; + + my @current_tags = ( + { + tag_id => $tag->id, + tag => '@alpha', + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_tag( $engine, $tag->id ), + planner_name => $tag->planner_name, + planner_email => $tag->planner_email, + planned_at => $tag->timestamp, + }, + ); + is_deeply all( $engine->current_tags ), \@current_tags, + 'Should again have one current tags'; + + unshift @events => { + event => 'deploy', + project => 'engine', + change_id => $change2->id, + change => 'widgets', + note => 'All in', + requires => $engine->_log_requires_param($change2), + conflicts => $engine->_log_conflicts_param($change2), + tags => $engine->_log_tags_param($change2), + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_event($engine, 4), + planner_name => $change2->planner_name, + planner_email => $change2->planner_email, + planned_at => $change2->timestamp, + }, { + event => 'deploy', + project => 'engine', + change_id => $change->id, + change => 'users', + note => 'User roles', + requires => $engine->_log_requires_param($change), + conflicts => $engine->_log_conflicts_param($change), + tags => $engine->_log_tags_param($change), + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_event($engine, 3), + planner_name => $change->planner_name, + planner_email => $change->planner_email, + planned_at => $change->timestamp, + }; + is_deeply all( $engine->search_events ), \@events, 'Should have 5 events'; + + ###################################################################### + # Test deployed_changes(), deployed_changes_since(), load_change, and + # change_offset_from_id(), and change_id_offset_from_id() + can_ok $engine, qw( + deployed_changes + deployed_changes_since + load_change + change_offset_from_id + change_id_offset_from_id + ); + my $change_hash = { + id => $change->id, + name => $change->name, + project => $change->project, + note => $change->note, + timestamp => $change->timestamp, + planner_name => $change->planner_name, + planner_email => $change->planner_email, + tags => ['@alpha'], + }; + my $change2_hash = { + id => $change2->id, + name => $change2->name, + project => $change2->project, + note => $change2->note, + timestamp => $change2->timestamp, + planner_name => $change2->planner_name, + planner_email => $change2->planner_email, + tags => [], + }; + + is_deeply [$engine->deployed_changes], [$change_hash, $change2_hash], + 'Should have two deployed changes'; + is_deeply [$engine->deployed_changes_since($change)], [$change2_hash], + 'Should find one deployed since the first one'; + is_deeply [$engine->deployed_changes_since($change2)], [], + 'Should find none deployed since the second one'; + + is_deeply $engine->load_change($change->id), $change_hash, + 'Should load change 1'; + is_deeply $engine->load_change($change2->id), $change2_hash, + 'Should load change 2'; + is_deeply $engine->load_change('whatever'), undef, + 'load() should return undef for uknown change ID'; + + is_deeply $engine->change_offset_from_id($change->id, undef), $change_hash, + 'Should load change with no offset'; + is_deeply $engine->change_offset_from_id($change2->id, 0), $change2_hash, + 'Should load change with offset 0'; + + is_deeply $engine->change_id_offset_from_id($change->id, undef), $change->id, + 'Should get change ID with no offset'; + is_deeply $engine->change_id_offset_from_id($change2->id, 0), $change2->id, + 'Should get change ID with offset 0'; + + # Now try some offsets. + is_deeply $engine->change_offset_from_id($change->id, 1), $change2_hash, + 'Should find change with offset 1'; + is_deeply $engine->change_offset_from_id($change2->id, -1), $change_hash, + 'Should find change with offset -1'; + is_deeply $engine->change_offset_from_id($change->id, 2), undef, + 'Should find undef change with offset 2'; + + is_deeply $engine->change_id_offset_from_id($change->id, 1), $change2->id, + 'Should find change ID with offset 1'; + is_deeply $engine->change_id_offset_from_id($change2->id, -1), $change->id, + 'Should find change ID with offset -1'; + is_deeply $engine->change_id_offset_from_id($change->id, 2), undef, + 'Should find undef change ID with offset 2'; + + # Revert change 2. + ok $engine->log_revert_change($change2), 'Revert "widgets"'; + is_deeply [$engine->deployed_changes], [$change_hash], + 'Should now have one deployed change ID'; + is_deeply [$engine->deployed_changes_since($change)], [], + 'Should find none deployed since that one'; + + # Add another one. + ok $engine->log_deploy_change($change2), 'Log another change'; + is_deeply [$engine->deployed_changes], [$change_hash, $change2_hash], + 'Should have both deployed change IDs'; + is_deeply [$engine->deployed_changes_since($change)], [$change2_hash], + 'Should find only the second after the first'; + is_deeply [$engine->deployed_changes_since($change2)], [], + 'Should find none after the second'; + + ok $state = $engine->current_state, 'Get the current state once more'; + isa_ok $dt = delete $state->{committed_at}, 'App::Sqitch::DateTime', + 'committed_at value'; + is $dt->time_zone->name, 'UTC', 'committed_at TZ should be UTC'; + is_deeply $state, { + project => 'engine', + change_id => $change2->id, + script_hash => $change2->script_hash, + change => 'widgets', + note => 'All in', + committer_name => $sqitch->user_name, + committer_email => $sqitch->user_email, + tags => [], + planner_name => $change2->planner_name, + planner_email => $change2->planner_email, + planned_at => $change2->timestamp, + }, 'The new state should reference latest change'; + + # These were reverted and re-deployed, so might have new timestamps. + $current_changes[0]->{committed_at} = dt_for_change( $engine, $change2->id ); + $current_changes[1]->{committed_at} = dt_for_change( $engine, $change->id ); + is_deeply all( $engine->current_changes ), \@current_changes, + 'Should still have two current changes in reverse chronological order'; + is_deeply all( $engine->current_tags ), \@current_tags, + 'Should still have one current tags'; + + unshift @events => { + event => 'deploy', + project => 'engine', + change_id => $change2->id, + change => 'widgets', + note => 'All in', + requires => $engine->_log_requires_param($change2), + conflicts => $engine->_log_conflicts_param($change2), + tags => $engine->_log_tags_param($change2), + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_event($engine, 6), + planner_name => $change2->planner_name, + planner_email => $change2->planner_email, + planned_at => $change2->timestamp, + }, { + event => 'revert', + project => 'engine', + change_id => $change2->id, + change => 'widgets', + note => 'All in', + requires => $engine->_log_requires_param($change2), + conflicts => $engine->_log_conflicts_param($change2), + tags => $engine->_log_tags_param($change2), + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_event($engine, 5), + planner_name => $change2->planner_name, + planner_email => $change2->planner_email, + planned_at => $change2->timestamp, + }; + is_deeply all( $engine->search_events ), \@events, 'Should have 7 events'; + + ###################################################################### + # Deploy the new changes with two tags. + $plan->add( name => 'fred', note => 'Hello Fred' ); + $plan->add( name => 'barney', note => 'Hello Barney' ); + $plan->tag( name => 'beta', note => 'Note beta' ); + $plan->tag( name => 'gamma', note => 'Note gamma' ); + ok my $fred = $plan->get('fred'), 'Get the "fred" change'; + ok $engine->log_deploy_change($fred), 'Deploy "fred"'; + sleep 0.1; # Give SQLite a little time to tick microseconds. + ok my $barney = $plan->get('barney'), 'Get the "barney" change'; + ok $engine->log_deploy_change($barney), 'Deploy "barney"'; + + is $engine->earliest_change_id, $change->id, 'Earliest change should sill be "users"'; + is $engine->earliest_change_id(1), $change2->id, + 'Should still get "widgets" offset 1 from earliest'; + is $engine->earliest_change_id(2), $fred->id, + 'Should get "fred" offset 2 from earliest'; + is $engine->earliest_change_id(3), $barney->id, + 'Should get "barney" offset 3 from earliest'; + + is $engine->latest_change_id, $barney->id, 'Latest change should be "barney"'; + is $engine->latest_change_id(1), $fred->id, 'Should get "fred" offset 1 from latest'; + is $engine->latest_change_id(2), $change2->id, 'Should get "widgets" offset 2 from latest'; + is $engine->latest_change_id(3), $change->id, 'Should get "users" offset 3 from latest'; + + $state = $engine->current_state; + # MySQL's group_concat() does not by default sort by row order, alas. + $state->{tags} = [ sort @{ $state->{tags} } ] + if $class eq 'App::Sqitch::Engine::mysql'; + is_deeply $state, { + project => 'engine', + change_id => $barney->id, + script_hash => $barney->script_hash, + change => 'barney', + note => 'Hello Barney', + committer_name => $sqitch->user_name, + committer_email => $sqitch->user_email, + committed_at => dt_for_change( $engine,$barney->id), + tags => [qw(@beta @gamma)], + planner_name => $barney->planner_name, + planner_email => $barney->planner_email, + planned_at => $barney->timestamp, + }, 'Barney should be in the current state'; + + unshift @current_changes => { + change_id => $barney->id, + script_hash => $barney->script_hash, + change => 'barney', + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_change( $engine, $barney->id ), + planner_name => $barney->planner_name, + planner_email => $barney->planner_email, + planned_at => $barney->timestamp, + }, { + change_id => $fred->id, + script_hash => $fred->script_hash, + change => 'fred', + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_change( $engine, $fred->id ), + planner_name => $fred->planner_name, + planner_email => $fred->planner_email, + planned_at => $fred->timestamp, + }; + + is_deeply all( $engine->current_changes ), \@current_changes, + 'Should have all four current changes in reverse chron order'; + + my ($beta, $gamma) = $barney->tags; + if (my $format = $p{add_second_format}) { + my $set = sprintf $format, 'committed_at'; + $engine->dbh->do( + "UPDATE tags SET committed_at = $set WHERE tag = '\@gamma'" + ); + } + unshift @current_tags => { + tag_id => $gamma->id, + tag => '@gamma', + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_tag( $engine, $gamma->id ), + planner_name => $gamma->planner_name, + planner_email => $gamma->planner_email, + planned_at => $gamma->timestamp, + }, { + tag_id => $beta->id, + tag => '@beta', + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_tag( $engine, $beta->id ), + planner_name => $beta->planner_name, + planner_email => $beta->planner_email, + planned_at => $beta->timestamp, + }; + + is_deeply all( $engine->current_tags ), \@current_tags, + 'Should now have three current tags in reverse chron order'; + + unshift @events => { + event => 'deploy', + project => 'engine', + change_id => $barney->id, + change => 'barney', + note => 'Hello Barney', + requires => $engine->_log_requires_param($barney), + conflicts => $engine->_log_conflicts_param($barney), + tags => $engine->_log_tags_param($barney), + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_event($engine, 8), + planner_name => $barney->planner_name, + planner_email => $barney->planner_email, + planned_at => $barney->timestamp, + }, { + event => 'deploy', + project => 'engine', + change_id => $fred->id, + change => 'fred', + note => 'Hello Fred', + requires => $engine->_log_requires_param($fred), + conflicts => $engine->_log_conflicts_param($fred), + tags => $engine->_log_tags_param($fred), + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_event($engine, 7), + planner_name => $fred->planner_name, + planner_email => $fred->planner_email, + planned_at => $fred->timestamp, + }; + is_deeply all( $engine->search_events ), \@events, 'Should have 9 events'; + + ###################################################################### + # Test search_events() parameters. + is_deeply all( $engine->search_events(limit => 2) ), [ @events[0..1] ], + 'The limit param to search_events should work'; + + is_deeply all( $engine->search_events(offset => 4) ), [ @events[4..$#events] ], + 'The offset param to search_events should work'; + + is_deeply all( $engine->search_events(limit => 3, offset => 4) ), [ @events[4..6] ], + 'The limit and offset params to search_events should work together'; + + is_deeply all( $engine->search_events( direction => 'DESC' ) ), \@events, + 'Should work to set direction "DESC" in search_events'; + is_deeply all( $engine->search_events( direction => 'desc' ) ), \@events, + 'Should work to set direction "desc" in search_events'; + is_deeply all( $engine->search_events( direction => 'descending' ) ), \@events, + 'Should work to set direction "descending" in search_events'; + + is_deeply all( $engine->search_events( direction => 'ASC' ) ), + [ reverse @events ], + 'Should work to set direction "ASC" in search_events'; + is_deeply all( $engine->search_events( direction => 'asc' ) ), + [ reverse @events ], + 'Should work to set direction "asc" in search_events'; + is_deeply all( $engine->search_events( direction => 'ascending' ) ), + [ reverse @events ], + 'Should work to set direction "ascending" in search_events'; + throws_ok { $engine->search_events( direction => 'foo' ) } 'App::Sqitch::X', + 'Should catch exception for invalid search direction'; + is $@->ident, 'DEV', 'Search direction error ident should be "DEV"'; + is $@->message, 'Search direction must be either "ASC" or "DESC"', + 'Search direction error message should be correct'; + + is_deeply all( $engine->search_events( committer => 'Simpson$' ) ), \@events, + 'The committer param to search_events should work'; + is_deeply all( $engine->search_events( committer => "^Homer" ) ), + [ @events[0..5] ], + 'The committer param to search_events should work as a regex'; + is_deeply all( $engine->search_events( committer => 'Simpsonized$' ) ), [], + qq{Committer regex should fail to match with "Simpsonized\$"}; + + is_deeply all( $engine->search_events( change => 'users' ) ), + [ @events[5..$#events] ], + 'The change param to search_events should work with "users"'; + is_deeply all( $engine->search_events( change => 'widgets' ) ), + [ @events[2..4] ], + 'The change param to search_events should work with "widgets"'; + is_deeply all( $engine->search_events( change => 'fred' ) ), + [ $events[1] ], + 'The change param to search_events should work with "fred"'; + is_deeply all( $engine->search_events( change => 'fre$' ) ), [], + 'The change param to search_events should return nothing for "fre$"'; + is_deeply all( $engine->search_events( change => '(er|re)' ) ), + [@events[1, 5..8]], + 'The change param to search_events should return match "(er|re)"'; + + is_deeply all( $engine->search_events( event => [qw(deploy)] ) ), + [ grep { $_->{event} eq 'deploy' } @events ], + 'The event param should work with "deploy"'; + is_deeply all( $engine->search_events( event => [qw(revert)] ) ), + [ grep { $_->{event} eq 'revert' } @events ], + 'The event param should work with "revert"'; + is_deeply all( $engine->search_events( event => [qw(fail)] ) ), + [ grep { $_->{event} eq 'fail' } @events ], + 'The event param should work with "fail"'; + is_deeply all( $engine->search_events( event => [qw(revert fail)] ) ), + [ grep { $_->{event} ne 'deploy' } @events ], + 'The event param should work with "revert" and "fail"'; + is_deeply all( $engine->search_events( event => [qw(deploy revert fail)] ) ), + \@events, + 'The event param should work with "deploy", "revert", and "fail"'; + is_deeply all( $engine->search_events( event => ['foo'] ) ), [], + 'The event param should return nothing for "foo"'; + + # Add an external project event. + ok my $ext_plan = App::Sqitch::Plan->new( + sqitch => $sqitch, + target => $target, + project => 'groovy', + ), 'Create external plan'; + ok my $ext_change = $ext_plan->add( + plan => $ext_plan, + name => 'crazyman', + note => 'Crazy, right?', + ), "Create external change"; + + # Because we're gonna use a regular expression on events.project to + # get events from multiple projects, we need to make sure that we get + # things in the proper order, such as on MySQL 5.5, where there is no + # datetime precision. So pretend we're about to insert another + # "engine" project record to get the MySQL engine to wait out a clock + # second tick before inserting our "groovy" change. This is purely so + # we get things back in the proper order for the `project => 'g'` test + # below. In reality it shouldn't matter much. + $engine->_prepare_to_log(events => $barney); + + ok $engine->log_deploy_change($ext_change), 'Log the external change'; + my $ext_event = { + event => 'deploy', + project => 'groovy', + change_id => $ext_change->id, + change => $ext_change->name, + note => $ext_change->note, + requires => $engine->_log_requires_param($ext_change), + conflicts => $engine->_log_conflicts_param($ext_change), + tags => $engine->_log_tags_param($ext_change), + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_event($engine, 9), + planner_name => $user2_name, + planner_email => $user2_email, + planned_at => $ext_change->timestamp, + }; + is_deeply all( $engine->search_events( project => '^engine$' ) ), \@events, + 'The project param to search_events should work'; + is_deeply all( $engine->search_events( project => '^groovy$' ) ), [$ext_event], + 'The project param to search_events should work with external project'; + is_deeply all( $engine->search_events( project => 'g' ) ), [$ext_event, @events], + 'The project param to search_events should match across projects'; + is_deeply all( $engine->search_events( project => 'nonexistent' ) ), [], + qq{Project regex should fail to match with "nonexistent"}; + + # Make sure we do not see these changes where we should not. + ok !grep( { $_ eq $ext_change->id } $engine->deployed_changes), + 'deployed_changes should not include external change'; + ok !grep( { $_ eq $ext_change->id } $engine->deployed_changes_since($change)), + 'deployed_changes_since should not include external change'; + + is $engine->earliest_change_id, $change->id, + 'Earliest change should sill be "users"'; + isnt $engine->latest_change_id, $ext_change->id, + 'Latest change ID should not be from external project'; + + throws_ok { $engine->search_events(foo => 1) } 'App::Sqitch::X', + 'Should catch exception for invalid search param'; + is $@->ident, 'DEV', 'Invalid search param error ident should be "DEV"'; + is $@->message, 'Invalid parameters passed to search_events(): foo', + 'Invalid search param error message should be correct'; + + throws_ok { $engine->search_events(foo => 1, bar => 2) } 'App::Sqitch::X', + 'Should catch exception for invalid search params'; + is $@->ident, 'DEV', 'Invalid search params error ident should be "DEV"'; + is $@->message, 'Invalid parameters passed to search_events(): bar, foo', + 'Invalid search params error message should be correct'; + + ###################################################################### + # Now that we have a change from an externa project, get its state. + ok $state = $engine->current_state('groovy'), 'Get the "groovy" state'; + isa_ok $dt = delete $state->{committed_at}, 'App::Sqitch::DateTime', + 'groofy committed_at value'; + is $dt->time_zone->name, 'UTC', 'groovy committed_at TZ should be UTC'; + is_deeply $state, { + project => 'groovy', + change_id => $ext_change->id, + script_hash => $ext_change->script_hash, + change => $ext_change->name, + note => $ext_change->note, + committer_name => $sqitch->user_name, + committer_email => $sqitch->user_email, + tags => [], + planner_name => $ext_change->planner_name, + planner_email => $ext_change->planner_email, + planned_at => $ext_change->timestamp, + }, 'The rest of the state should look right'; + + ###################################################################### + # Test change_id_for(). + for my $spec ( + [ + 'change_id only', + { change_id => $change->id }, + $change->id, + ], + [ + 'change only', + { change => $change->name }, + $change->id, + ], + [ + 'change + tag', + { change => $change->name, tag => 'alpha' }, + $change->id, + ], + [ + 'change@HEAD', + { change => $change->name, tag => 'HEAD' }, + $change->id, + ], + [ + 'tag only', + { tag => 'alpha' }, + $change->id, + ], + [ + 'ROOT', + { tag => 'ROOT' }, + $change->id, + ], + [ + 'HEAD', + { tag => 'HEAD' }, + $barney->id, + ], + [ + 'project:ROOT', + { tag => 'ROOT', project => 'groovy' }, + $ext_change->id, + ], + [ + 'project:HEAD', + { tag => 'HEAD', project => 'groovy' }, + $ext_change->id, + ], + ) { + my ( $desc, $params, $exp_id ) = @{ $spec }; + is $engine->change_id_for(%{ $params }), $exp_id, "Should find id for $desc"; + } + + for my $spec ( + [ + 'unkonwn id', + { change_id => 'whatever' }, + ], + [ + 'unkonwn change', + { change => 'whatever' }, + ], + [ + 'unkonwn tag', + { tag => 'whatever' }, + ], + [ + 'change + unkonwn tag', + { change => $change->name, tag => 'whatever' }, + ], + [ + 'change@ROOT', + { change => $change->name, tag => 'ROOT' }, + ], + [ + 'change + different project', + { change => $change->name, project => 'whatever' }, + ], + [ + 'tag + different project', + { tag => 'alpha', project => 'whatever' }, + ], + ) { + my ( $desc, $params ) = @{ $spec }; + is $engine->change_id_for(%{ $params }), undef, "Should find nothing for $desc"; + } + + ###################################################################### + # Test change_id_for_depend(). + my $id = '4f1e83f409f5f533eeef9d16b8a59e2c0aa91cc1'; + my $i; + + for my $spec ( + [ + 'id only', + { id => $id }, + { id => $id }, + ], + [ + 'change + tag', + { change => 'bart', tag => 'epsilon' }, + { name => 'bart' } + ], + [ + 'change only', + { change => 'lisa' }, + { name => 'lisa' }, + ], + [ + 'tag only', + { tag => 'sigma' }, + { name => 'maggie' }, + ], + ) { + my ( $desc, $dep_params, $chg_params ) = @{ $spec }; + + # Test as an internal dependency. + INTERNAL: { + ok my $change = $plan->add( + name => 'foo' . ++$i, + %{$chg_params}, + ), "Create internal $desc change"; + + # Tag it if necessary. + if (my $tag = $dep_params->{tag}) { + ok $plan->tag(name => $tag), "Add tag internal \@$tag"; + } + + # Should start with unsatisfied dependency. + ok my $dep = App::Sqitch::Plan::Depend->new( + plan => $plan, + project => $plan->project, + %{ $dep_params }, + ), "Create internal $desc dependency"; + is $engine->change_id_for_depend($dep), undef, + "Internal $desc depencency should not be satisfied"; + + # Once deployed, dependency should be satisfied. + ok $engine->log_deploy_change($change), + "Log internal $desc change deployment"; + is $engine->change_id_for_depend($dep), $change->id, + "Internal $desc depencency should now be satisfied"; + + # Revert it and try again. + sleep 0.1; # Give SQLite a little time to tick microseconds. + ok $engine->log_revert_change($change), + "Log internal $desc change reversion"; + is $engine->change_id_for_depend($dep), undef, + "Internal $desc depencency should again be unsatisfied"; + } + + # Now test as an external dependency. + EXTERNAL: { + # Make sure we have unique IDs. + $_->{id} = 'dcb10d16276c9be8956274740d9f332bd71344ed' + for grep { $_->{id} } $dep_params, $chg_params; + + # Make Change and Tag return registered external project "groovy". + $dep_params->{project} = 'groovy'; + my $line_mocker = Test::MockModule->new('App::Sqitch::Plan::Line'); + $line_mocker->mock(project => $dep_params->{project}); + + ok my $change = App::Sqitch::Plan::Change->new( + plan => $plan, + name => 'foo' . ++$i, + %{$chg_params}, + ), "Create external $desc change"; + + # Tag it if necessary. + if (my $tag = $dep_params->{tag}) { + ok $change->add_tag(App::Sqitch::Plan::Tag->new( + plan => $plan, + change => $change, + name => $tag, + ) ), "Add tag external \@$tag"; + } + + # Should start with unsatisfied dependency. + ok my $dep = App::Sqitch::Plan::Depend->new( + plan => $plan, + project => $plan->project, + %{ $dep_params }, + ), "Create external $desc dependency"; + is $engine->change_id_for_depend($dep), undef, + "External $desc depencency should not be satisfied"; + + # Once deployed, dependency should be satisfied. + ok $engine->log_deploy_change($change), + "Log external $desc change deployment"; + + is $engine->change_id_for_depend($dep), $change->id, + "External $desc depencency should now be satisfied"; + + # Revert it and try again. + sleep 0.1; # Give SQLite a little time to tick microseconds. + ok $engine->log_revert_change($change), + "Log external $desc change reversion"; + is $engine->change_id_for_depend($dep), undef, + "External $desc depencency should again be unsatisfied"; + } + } + + ok my $ext_change2 = App::Sqitch::Plan::Change->new( + plan => $ext_plan, + name => 'outside_in', + ), "Create another external change"; + ok $ext_change2->add_tag( my $ext_tag = App::Sqitch::Plan::Tag->new( + plan => $plan, + change => $ext_change2, + name => 'meta', + ) ), 'Add tag external "meta"'; + + ok $engine->log_deploy_change($ext_change2), 'Log the external change with tag'; + + # Make sure name_for_change_id() works properly. + ok $engine->dbh->do(q{DELETE FROM tags WHERE project = 'engine'}), + 'Delete the engine project tags'; + is $engine->name_for_change_id($change2->id), 'widgets@HEAD', + 'name_for_change_id() should return "widgets@HEAD" for its ID'; + is $engine->name_for_change_id($ext_change2->id), 'outside_in@meta', + 'name_for_change_id() should return "outside_in@meta" for its ID'; + + # Make sure current_changes and current_tags are project-scoped. + is_deeply all( $engine->current_changes ), \@current_changes, + 'Should have only the "engine" changes from current_changes'; + is_deeply all( $engine->current_changes('groovy') ), [ + { + change_id => $ext_change2->id, + script_hash => $ext_change2->script_hash, + change => $ext_change2->name, + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_change( $engine, $ext_change2->id ), + planner_name => $ext_change2->planner_name, + planner_email => $ext_change2->planner_email, + planned_at => $ext_change2->timestamp, + }, { + change_id => $ext_change->id, + script_hash => $ext_change->script_hash, + change => $ext_change->name, + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_change( $engine, $ext_change->id ), + planner_name => $ext_change->planner_name, + planner_email => $ext_change->planner_email, + planned_at => $ext_change->timestamp, + } + ], 'Should get only requestd project changes from current_changes'; + is_deeply all( $engine->current_tags ), [], + 'Should no longer have "engine" project tags'; + is_deeply all( $engine->current_tags('groovy') ), [{ + tag_id => $ext_tag->id, + tag => '@meta', + committer_name => $user2_name, + committer_email => $user2_email, + committed_at => dt_for_tag( $engine, $ext_tag->id ), + planner_name => $ext_tag->planner_name, + planner_email => $ext_tag->planner_email, + planned_at => $ext_tag->timestamp, + }], 'Should get groovy tags from current_chages()'; + + ###################################################################### + # Test changes with multiple and cross-project dependencies. + ok my $hyper = $plan->add( + name => 'hypercritical', + requires => ['engine:fred', 'groovy:crazyman'], + ), 'Create change "hypercritial" in current plan'; + $_->resolved_id( $engine->change_id_for_depend($_) ) for $hyper->requires; + ok $engine->log_deploy_change($hyper), 'Log change "hyper"'; + + is_deeply [ $engine->changes_requiring_change($hyper) ], [], + 'No changes should require "hypercritical"'; + is_deeply [ $engine->changes_requiring_change($fred) ], [{ + project => 'engine', + change_id => $hyper->id, + change => $hyper->name, + asof_tag => undef, + }], 'Change "hypercritical" should require "fred"'; + + is_deeply [ $engine->changes_requiring_change($ext_change) ], [{ + project => 'engine', + change_id => $hyper->id, + change => $hyper->name, + asof_tag => undef, + }], 'Change "hypercritical" should require "groovy:crazyman"'; + + # Add another change with more depencencies. + ok my $ext_change3 = App::Sqitch::Plan::Change->new( + plan => $ext_plan, + name => 'elsewise', + requires => [ + App::Sqitch::Plan::Depend->new( + plan => $ext_plan, + project => 'engine', + change => 'fred', + ), + App::Sqitch::Plan::Depend->new( + plan => $ext_plan, + change => 'crazyman', + ), + ] + ), "Create a third external change"; + $_->resolved_id( $engine->change_id_for_depend($_) ) for $ext_change3->requires; + ok $engine->log_deploy_change($ext_change3), 'Log change "elsewise"'; + + is_deeply [ + sort { $b->{change} cmp $a->{change} } + $engine->changes_requiring_change($fred) + ], [ + { + project => 'engine', + change_id => $hyper->id, + change => $hyper->name, + asof_tag => undef, + }, + { + project => 'groovy', + change_id => $ext_change3->id, + change => $ext_change3->name, + asof_tag => undef, + }, + ], 'Change "fred" should be required by changes in two projects'; + + is_deeply [ + sort { $b->{change} cmp $a->{change} } + $engine->changes_requiring_change($ext_change) + ], [ + { + project => 'engine', + change_id => $hyper->id, + change => $hyper->name, + asof_tag => undef, + }, + { + project => 'groovy', + change_id => $ext_change3->id, + change => $ext_change3->name, + asof_tag => undef, + }, + ], 'Change "groovy:crazyman" should be required by changes in two projects'; + + ###################################################################### + # Test begin_work() and finish_work(). + can_ok $engine, qw(begin_work finish_work); + my $mock_dbh = Test::MockModule->new(ref $engine->dbh, no_auto => 1); + my $txn; + $mock_dbh->mock(begin_work => sub { $txn = 1 }); + $mock_dbh->mock(commit => sub { $txn = 0 }); + $mock_dbh->mock(rollback => sub { $txn = -1 }); + my @do; + $mock_dbh->mock(do => sub { + shift; + @do = @_; + }); + ok $engine->begin_work, 'Begin work'; + is $txn, 1, 'Should have started a transaction'; + ok $engine->finish_work, 'Finish work'; + is $txn, 0, 'Should have committed a transaction'; + ok $engine->begin_work, 'Begin work again'; + is $txn, 1, 'Should have started another transaction'; + ok $engine->rollback_work, 'Rollback work'; + is $txn, -1, 'Should have rolled back a transaction'; + $mock_dbh->unmock('do'); + + ###################################################################### + # Revert and re-deploy all the changes. + my @all_changes = ($change, $change2, $fred, $barney, $ext_change, $ext_change2, $hyper, $ext_change3); + ok $engine->log_revert_change($_), + 'Revert "' . $_->name . '" change' for reverse @all_changes; + ok $engine->log_deploy_change($_), + 'Deploy "' . $_->name . '" change' for @all_changes; + + ###################################################################### + # Add a reworked change. + ok my $rev_change = $plan->rework( name => 'users' ), 'Rework change "users"'; + my $deploy_file = $rev_change->deploy_file; + my $tmp_dir = dir( tempdir CLEANUP => 1 ); + $deploy_file->copy_to($tmp_dir); + my $fh = $deploy_file->opena or die "Cannot open $deploy_file: $!\n"; + try { + say $fh '-- Append line to reworked script so it gets a new SHA-1 hash'; + close $fh; + $_->resolved_id( $engine->change_id_for_depend($_) ) for $rev_change->requires; + ok $engine->log_deploy_change($rev_change), 'Deploy the reworked change'; + } finally { + # Restore the reworked script. + $tmp_dir->file( $deploy_file->basename )->copy_to($deploy_file); + }; + + # Make sure that change_id_for() chokes on the dupe. + MOCKVENT: { + my $sqitch_mocker = Test::MockModule->new(ref $sqitch); + my @args; + $sqitch_mocker->mock(vent => sub { shift; push @args => \@_ }); + throws_ok { $engine->change_id_for( change => 'users') } 'App::Sqitch::X', + 'Should die on ambiguous change spec'; + is $@->ident, 'engine', 'Mode should be "engine"'; + is $@->message, __ 'Change Lookup Failed', + 'And it should report change lookup failure'; + is_deeply \@args, [ + [__x( + 'Change "{change}" is ambiguous. Please specify a tag-qualified change:', + change => 'users', + )], + [ ' * ', $rev_change->format_name . '@HEAD' ], + [ ' * ', $change->format_tag_qualified_name ], + ], 'Should have vented output for lookup failure'; + + # But it should work okay if we ask for the first ID. + ok my $id = $engine->change_id_for(change => 'users', first => 1), + 'Should get ID for first of ambiguous change spec'; + is $id, $change->id, 'Should now have first change id'; + } + + is $engine->change_id_for( change => 'users', tag => 'alpha'), $change->id, + 'change_id_for() should find the tag-qualified change ID'; + is $engine->change_id_for( change => 'users', tag => 'HEAD'), $rev_change->id, + 'change_id_for() should find the reworked change ID @HEAD'; + + ###################################################################### + # Tag and Rework the change again. + ok $plan->tag(name => 'theta'), 'Tag the plan "theta"'; + ok $engine->log_new_tags($rev_change), 'Log new tag'; + + ok my $rev_change2 = $plan->rework( name => 'users' ), + 'Rework change "users" again'; + $fh = $deploy_file->opena or die "Cannot open $deploy_file: $!\n"; + try { + say $fh '-- Append another line to reworked script for a new SHA-1 hash'; + close $fh; + $_->resolved_id( $engine->change_id_for_depend($_) ) for $rev_change2->requires; + ok $engine->log_deploy_change($rev_change2), 'Deploy the reworked change'; + } finally { + # Restore the reworked script. + $tmp_dir->file( $deploy_file->basename )->copy_to($deploy_file); + }; + + # make sure that change_id_for is still good with things. + for my $spec ( + [ + 'alpha instance of change', + { change => 'users', tag => 'alpha' }, + $change->id, + ], + [ + 'HEAD instance of change', + { change => 'users', tag => 'HEAD' }, + $rev_change2->id, + ], + [ + 'second instance of change by tag', + { change => 'users', tag => 'theta' }, + $rev_change->id, + ], + ) { + my ( $desc, $params, $exp_id ) = @{ $spec }; + is $engine->change_id_for(%{ $params }), $exp_id, "Should find id for $desc"; + } + + # Unmock everything and call it a day. + $mock_dbh->unmock_all; + $mock_sqitch->unmock_all; + + ###################################################################### + # Let's make sure script_hash upgrades work. + $engine->dbh->do('UPDATE changes SET script_hash = change_id'); + ok $engine->_update_script_hashes, 'Update script hashes'; + + # Make sure they were updated properly. + my $sth = $engine->dbh->prepare( + 'SELECT change_id, script_hash FROM changes WHERE project = ?', + ); + $sth->execute($plan->project); + while (my $row = $sth->fetch) { + my $change = $plan->get($row->[0]); + is $row->[1], $change->script_hash, + 'Should have updated script hash for ' . $change->name; + } + + # Make sure no other projects were updated. + $sth = $engine->dbh->prepare( + 'SELECT change_id, script_hash FROM changes WHERE project <> ?', + ); + $sth->execute($plan->project); + while (my $row = $sth->fetch) { + is $row->[1], $row->[0], + 'Change ID and script hash should be ' . substr $row->[0], 0, 6; + } + + ###################################################################### + # All done. + done_testing; + }; +} + +sub dt_for_change { + my $engine = shift; + my $col = sprintf $engine->_ts2char_format, 'committed_at'; + my $dtfunc = $engine->can('_dt'); + $dtfunc->($engine->dbh->selectcol_arrayref( + "SELECT $col FROM changes WHERE change_id = ?", + undef, shift + )->[0]); +} + +sub dt_for_tag { + my $engine = shift; + my $col = sprintf $engine->_ts2char_format, 'committed_at'; + my $dtfunc = $engine->can('_dt'); + $dtfunc->($engine->dbh->selectcol_arrayref( + "SELECT $col FROM tags WHERE tag_id = ?", + undef, shift + )->[0]); +} + +sub all { + my $iter = shift; + my @res; + while (my $row = $iter->()) { + push @res => $row; + } + return \@res; +} + +sub dt_for_event { + my ($engine, $offset) = @_; + my $col = sprintf $engine->_ts2char_format, 'committed_at'; + my $dtfunc = $engine->can('_dt'); + my $dbh = $engine->dbh; + return $dtfunc->($engine->dbh->selectcol_arrayref(qq{ + SELECT ts FROM ( + SELECT ts, rownum AS rnum FROM ( + SELECT $col AS ts + FROM events + ORDER BY committed_at ASC + ) + ) WHERE rnum = ? + }, undef, $offset + 1)->[0]) if $dbh->{Driver}->{Name} eq 'Oracle'; + return $dtfunc->($engine->dbh->selectcol_arrayref( + "SELECT FIRST 1 SKIP $offset $col FROM events ORDER BY committed_at ASC", + )->[0]) if $dbh->{Driver}->{Name} eq 'Firebird'; + return $dtfunc->($engine->dbh->selectcol_arrayref( + "SELECT $col FROM events ORDER BY committed_at ASC LIMIT 1 OFFSET $offset", + )->[0]); +} + +sub all_changes { + shift->dbh->selectall_arrayref(q{ + SELECT change_id, c.change, project, note, committer_name, committer_email, + planner_name, planner_email + FROM changes c + ORDER BY committed_at + }); +} + +sub all_tags { + shift->dbh->selectall_arrayref(q{ + SELECT tag_id, tag, change_id, project, note, + committer_name, committer_email, planner_name, planner_email + FROM tags + ORDER BY committed_at + }); +} + +sub all_events { + shift->dbh->selectall_arrayref(q{ + SELECT event, change_id, e.change, project, note, requires, conflicts, tags, + committer_name, committer_email, planner_name, planner_email + FROM events e + ORDER BY committed_at + }); +} + +sub get_dependencies { + shift->dbh->selectall_arrayref(q{ + SELECT change_id, type, dependency, dependency_id + FROM dependencies + WHERE change_id = ? + ORDER BY dependency + }, undef, shift); +} + +1; diff --git a/t/lib/LC.pm b/t/lib/LC.pm new file mode 100644 index 00000000..ed95ae76 --- /dev/null +++ b/t/lib/LC.pm @@ -0,0 +1,17 @@ +package LC; + +our $TIME = do { + if ($^O eq 'MSWin32') { + require Win32::Locale; + Win32::Locale::get_locale(); + } else { + require POSIX; + POSIX::setlocale( POSIX::LC_TIME() ); + } +}; + +# https://github.com/sqitchers/sqitch/issues/230#issuecomment-103946451 +# https://rt.cpan.org/Ticket/Display.html?id=104574 +$TIME = 'en_US_POSIX' if $TIME eq 'C.UTF-8'; + +1; diff --git a/t/lib/MockOutput.pm b/t/lib/MockOutput.pm new file mode 100644 index 00000000..5dfe9406 --- /dev/null +++ b/t/lib/MockOutput.pm @@ -0,0 +1,74 @@ +package MockOutput; + +use 5.010; +use strict; +use warnings; +use utf8; +use Test::MockModule 0.05; + +our $MOCK = Test::MockModule->new('App::Sqitch'); + +my @mocked = qw( + trace + trace_literal + debug + debug_literal + info + info_literal + comment + comment_literal + emit + emit_literal + vent + vent_literal + warn + warn_literal + page + page_literal + prompt + ask_yes_no +); + +my $INPUT; +sub prompt_returns { $INPUT = $_[1]; } + +my $Y_N; +sub ask_yes_no_returns { $Y_N = $_[1]; } + +my %CAPTURED; + +__PACKAGE__->clear; + +for my $meth (@mocked) { + $MOCK->mock($meth => sub { + shift; + push @{ $CAPTURED{$meth} } => [@_]; + }); + + my $get = sub { + my $ret = $CAPTURED{$meth}; + $CAPTURED{$meth} = []; + return $ret; + }; + + no strict 'refs'; + *{"get_$meth"} = $get; +} + +$MOCK->mock(prompt => sub { + shift; + push @{ $CAPTURED{prompt} } => [@_]; + return $INPUT; +}); + +$MOCK->mock(ask_yes_no => sub { + shift; + push @{ $CAPTURED{ask_yes_no} } => [@_]; + return $Y_N; +}); + +sub clear { + %CAPTURED = map { $_ => [] } @mocked; +} + +1; diff --git a/t/lib/TestConfig.pm b/t/lib/TestConfig.pm new file mode 100644 index 00000000..1d396697 --- /dev/null +++ b/t/lib/TestConfig.pm @@ -0,0 +1,148 @@ +package TestConfig; +use strict; +use warnings; +use base 'App::Sqitch::Config'; +use Path::Class; + +# Creates and returns a new TestConfig, which inherits from +# App::Sqitch::Config. Sets nonexistent values for the file locations and +# calls update() on remaining args. +# +# my $config = TestConfig->new( +# 'core.engine' => 'sqlite', +# 'add.all' => 1, +# 'deploy.variables' => { _prefix => 'test_', user => 'bob' } +# 'foo.bar' => [qw(one two three)], +# ); +sub new { + my $self = shift->SUPER::new; + $self->{test_local_file} = 'nonexistent.local'; + $self->{test_user_file} = 'nonexistent.user'; + $self->{test_system_file} = 'nonexistent.system'; + $self->update(@_); + return $self; +} + +# Pass in key/value pairs to set the data. Does not clear existing data. Keys +# should be "$section.$name". Values can be scalars, arrays, or hashes. +# Scalars are simply set as-is, unless the value is `undef`, in which case the +# key is deleted. Arrays are set as multiple values for the key. Hashes have +# each of their keys appended as "$section.$name.$key", with the values +# assigned as-is. Existing keys will be replaced with the new values. +# +# my $config->update( +# 'core.engine' => 'sqlite', +# 'add.all' => 1, +# 'deploy.variables' => { _prefix => 'test_', user => 'bob' } +# 'foo.bar' => [qw(one two three)], +# ); +sub update { + my $self = shift; + my %p = @_ or return; + $self->data({}) unless $self->is_loaded; + # Set a unique origin to be sure to override any previous values for each key. + my @args = (origin => ('update_' . ++$self->{__update})); + + while (my ($k, $v) = each %p) { + my $ref = ref $v; + if ($ref eq '') { + if (defined $v) { + $k =~ s/[.]([^.]+)$//; + $self->define(@args, section => $k, name => $1, value => $v); + } else { + $self->set_multiple( $k, 0 ) if $self->is_multiple( $k ); + $k = lc $k; + delete $_->{$k} for ($self->origins, $self->data, $self->casing); + } + } elsif ($ref eq 'HASH') { + $self->define(@args, section => $k, name => $_, value => $v->{$_} ) + for keys %{ $v }; + } elsif ($ref eq 'ARRAY') { + $k =~ s/[.]([^.]+)$//; + $self->define(@args, section => $k, name => $1, value => $_) + for @{ $v }; + } else { + require Carp; + Carp::confess("Cannot set config value of type $ref"); + } + } +} + +# Like update(), but replaces all existing data with new data. +sub replace { + my $self = shift; + $self->data({}); + $self->multiple({}); + $self->origins({}); + $self->casing({}); + $self->config_files([]); + $self->update(@_); +} + +# Creates and returns a new TestConfig, which inherits from +# App::Sqitch::Config. Parameters specify files to load using the keys "local", +# "user", and "system". Any file not specified will be set to a nonexistent +# value. Once the files are set, the data is loaded from the files and the +# TestObject returned. +# +# my $config = TestObject->from( +# local => 'test.conf', +# user => 'user.conf', +# system => 'system.conf', +# ); +sub from { + my ($class, %p) = @_; + my $self = shift->SUPER::new; + for my $level (qw(local user system)) { + $self->{"test_${level}_file"} = $p{$level} || "nonexistent.$level"; + } + $self->load; + return $self; +} + +# Creates and returns a Test::MockModule object that can be used to mock +# methods on the TestConfig class. Pass pairs of parameters to be passed on to +# the mock() method of the Test::MockModule object before returning. +# +# my $sysdir = dir 'nonexistent'; +# my $usrdir = dir 'nonexistent'; +# my $mock = TestConfig->mock( +# system_dir => sub { $sysdir }, +# user_dir => sub { $usrdir }, +# ); +sub mock { + my $class = shift; + require Test::MockModule; + my $mocker = Test::MockModule->new($class); + $mocker->mock(shift, shift) while @_; + return $mocker; +} + +# Returns the test local file. +sub local_file { file $_[0]->{test_local_file} } + +# Returns the test user file. +sub user_file { file $_[0]->{test_user_file} } + +# Returns the test system file. +sub system_file { file $_[0]->{test_system_file} } + +# Overrides the parent implementation to load only the local file, to avoid +# inadvertent loading of configuration files in parent directories. Unlikely +# to be called directly by tests. +sub load_dirs { + my $self = shift; + # Exclude files in parent directories. + $self->load_file($self->local_file); +} + +# Parses the specified configuration file and returns a hash reference. May be +# called as either a class or instance method; in neither case is the data +# stored anywhere other than the returned hash reference. +sub data_from { + my $conf = shift->SUPER::new; + $conf->load_file(shift); + $conf->data; +} + +1; diff --git a/t/lib/upgradable_registries/exasol.sql b/t/lib/upgradable_registries/exasol.sql new file mode 100644 index 00000000..376005e0 --- /dev/null +++ b/t/lib/upgradable_registries/exasol.sql @@ -0,0 +1,139 @@ +CREATE SCHEMA IF NOT EXISTS ®istry; + +COMMENT ON SCHEMA ®istry IS 'Sqitch database deployment metadata v1.0.'; + +CREATE TABLE ®istry..releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL, + installer_name VARCHAR2(512 CHAR) NOT NULL, + installer_email VARCHAR2(512 CHAR) NOT NULL +); + +COMMENT ON TABLE ®istry..releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN ®istry..releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN ®istry..releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN ®istry..releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN ®istry..releases.installer_email IS 'Email address of the user who installed the registry release.'; + +CREATE TABLE ®istry..projects ( + project VARCHAR2(512 CHAR) PRIMARY KEY, + uri VARCHAR2(512 CHAR) NULL, -- UNIQUE should also be used here, but not supported in EXASOL + created_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL, + creator_name VARCHAR2(512 CHAR) NOT NULL, + creator_email VARCHAR2(512 CHAR) NOT NULL +); + +COMMENT ON TABLE ®istry..projects IS 'Sqitch projects deployed to this database.'; +COMMENT ON COLUMN ®istry..projects.project IS 'Unique Name of a project.'; +COMMENT ON COLUMN ®istry..projects.uri IS 'Optional project URI'; +COMMENT ON COLUMN ®istry..projects.created_at IS 'Date the project was added to the database.'; +COMMENT ON COLUMN ®istry..projects.creator_name IS 'Name of the user who added the project.'; +COMMENT ON COLUMN ®istry..projects.creator_email IS 'Email address of the user who added the project.'; + +CREATE TABLE ®istry..changes ( + change_id CHAR(40) PRIMARY KEY, + change VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + note VARCHAR2(4000 CHAR) DEFAULT '', + committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL +); + +COMMENT ON TABLE ®istry..changes IS 'Tracks the changes currently deployed to the database.'; +COMMENT ON COLUMN ®istry..changes.change_id IS 'Change primary key.'; +COMMENT ON COLUMN ®istry..changes.change IS 'Name of a deployed change.'; +COMMENT ON COLUMN ®istry..changes.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry..changes.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry..changes.committed_at IS 'Date the change was deployed.'; +COMMENT ON COLUMN ®istry..changes.committer_name IS 'Name of the user who deployed the change.'; +COMMENT ON COLUMN ®istry..changes.committer_email IS 'Email address of the user who deployed the change.'; +COMMENT ON COLUMN ®istry..changes.planned_at IS 'Date the change was added to the plan.'; +COMMENT ON COLUMN ®istry..changes.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry..changes.planner_email IS 'Email address of the user who planned the change.'; + +CREATE TABLE ®istry..tags ( + tag_id CHAR(40) PRIMARY KEY, + tag VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + change_id CHAR(40) NOT NULL REFERENCES ®istry..changes(change_id), + note VARCHAR2(4000 CHAR) DEFAULT '', + committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL + -- UNIQUE(project, tag) +); + +COMMENT ON TABLE ®istry..tags IS 'Tracks the tags currently applied to the database.'; +COMMENT ON COLUMN ®istry..tags.tag_id IS 'Tag primary key.'; +COMMENT ON COLUMN ®istry..tags.tag IS 'Project-unique tag name.'; +COMMENT ON COLUMN ®istry..tags.project IS 'Name of the Sqitch project to which the tag belongs.'; +COMMENT ON COLUMN ®istry..tags.change_id IS 'ID of last change deployed before the tag was applied.'; +COMMENT ON COLUMN ®istry..tags.note IS 'Description of the tag.'; +COMMENT ON COLUMN ®istry..tags.committed_at IS 'Date the tag was applied to the database.'; +COMMENT ON COLUMN ®istry..tags.committer_name IS 'Name of the user who applied the tag.'; +COMMENT ON COLUMN ®istry..tags.committer_email IS 'Email address of the user who applied the tag.'; +COMMENT ON COLUMN ®istry..tags.planned_at IS 'Date the tag was added to the plan.'; +COMMENT ON COLUMN ®istry..tags.planner_name IS 'Name of the user who planed the tag.'; +COMMENT ON COLUMN ®istry..tags.planner_email IS 'Email address of the user who planned the tag.'; + +CREATE TABLE ®istry..dependencies ( + change_id CHAR(40) NOT NULL REFERENCES ®istry..changes(change_id), -- ON DELETE CASCADE, + type VARCHAR2(8) NOT NULL, + dependency VARCHAR2(1024 CHAR) NOT NULL, + dependency_id CHAR(40) NULL REFERENCES ®istry..changes(change_id), + -- CONSTRAINT dependencies_check CHECK ( + -- (type = 'require' AND dependency_id IS NOT NULL) + -- OR (type = 'conflict' AND dependency_id IS NULL) + -- ), + PRIMARY KEY (change_id, dependency) +); + +COMMENT ON TABLE ®istry..dependencies IS 'Tracks the currently satisfied dependencies.'; +COMMENT ON COLUMN ®istry..dependencies.change_id IS 'ID of the depending change.'; +COMMENT ON COLUMN ®istry..dependencies.type IS 'Type of dependency.'; +COMMENT ON COLUMN ®istry..dependencies.dependency IS 'Dependency name.'; +COMMENT ON COLUMN ®istry..dependencies.dependency_id IS 'Change ID the dependency resolves to.'; + +CREATE TABLE ®istry..events ( + event VARCHAR2(6) NOT NULL, + change_id CHAR(40) NOT NULL, + change VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + note VARCHAR2(4000 CHAR) DEFAULT '', + requires VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL, + conflicts VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL, + tags VARCHAR2(4000 CHAR) DEFAULT '' NOT NULL, + committed_at TIMESTAMP WITH LOCAL TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH LOCAL TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL +); + +-- CREATE INDEX ®istry..events_pkey ON ®istry..events(change_id, committed_at); + +COMMENT ON TABLE ®istry..events IS 'Contains full history of all deployment events.'; +COMMENT ON COLUMN ®istry..events.event IS 'Type of event.'; +COMMENT ON COLUMN ®istry..events.change_id IS 'Change ID.'; +COMMENT ON COLUMN ®istry..events.change IS 'Change name.'; +COMMENT ON COLUMN ®istry..events.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry..events.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry..events.requires IS 'List of the names of required changes.'; +COMMENT ON COLUMN ®istry..events.conflicts IS 'List of the names of conflicting changes.'; +COMMENT ON COLUMN ®istry..events.tags IS 'Tags associated with the change.'; +COMMENT ON COLUMN ®istry..events.committed_at IS 'Date the event was committed.'; +COMMENT ON COLUMN ®istry..events.committer_name IS 'Name of the user who committed the event.'; +COMMENT ON COLUMN ®istry..events.committer_email IS 'Email address of the user who committed the event.'; +COMMENT ON COLUMN ®istry..events.planned_at IS 'Date the event was added to the plan.'; +COMMENT ON COLUMN ®istry..events.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry..events.planner_email IS 'Email address of the user who plan planned the change.'; + +COMMIT; diff --git a/t/lib/upgradable_registries/firebird.sql b/t/lib/upgradable_registries/firebird.sql new file mode 100644 index 00000000..c848d698 --- /dev/null +++ b/t/lib/upgradable_registries/firebird.sql @@ -0,0 +1,327 @@ +/* + * Sqitch database deployment metadata v1.0.; + */ + +/* + * Required PAGE SIZE = 16384 to avoid error: "key size exceeds + * implementation restriction for index..." + */ + +-- Table: releases + +CREATE TABLE releases ( + version FLOAT NOT NULL PRIMARY KEY, + installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + installer_name VARCHAR(255) NOT NULL, + installer_email VARCHAR(255) NOT NULL +); + +UPDATE RDB$RELATIONS SET + RDB$DESCRIPTION = 'Sqitch registry releases.' + WHERE RDB$RELATION_NAME = 'RELEASES'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Version of the Sqitch registry.' + WHERE RDB$RELATION_NAME = 'RELEASES' AND RDB$FIELD_NAME = 'VERSION'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Date the registry release was installed.' + WHERE RDB$RELATION_NAME = 'VERSIONS' AND RDB$FIELD_NAME = 'INSTALLED_AT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of the user who installed the registry release.' + WHERE RDB$RELATION_NAME = 'VERSIONS' AND RDB$FIELD_NAME = 'INSTALLER_NAME'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Email address of the user who installed the registry release.' + WHERE RDB$RELATION_NAME = 'VERSIONS' AND RDB$FIELD_NAME = 'INSTALLER_EMAIL'; + +-- Table: projects + +CREATE TABLE projects ( + project VARCHAR(255) NOT NULL PRIMARY KEY, + uri VARCHAR(255) UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + creator_name VARCHAR(255) NOT NULL, + creator_email VARCHAR(255) NOT NULL +); + +-- Description (comments) + +UPDATE RDB$RELATIONS SET + RDB$DESCRIPTION = 'Sqitch projects deployed to this database.' + WHERE RDB$RELATION_NAME = 'PROJECTS'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Unique Name of a project.' + WHERE RDB$RELATION_NAME = 'PROJECTS' AND RDB$FIELD_NAME = 'PROJECT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Optional project URI.' + WHERE RDB$RELATION_NAME = 'PROJECTS' AND RDB$FIELD_NAME = 'URI'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Date the project was added to the database.' + WHERE RDB$RELATION_NAME = 'PROJECTS' AND RDB$FIELD_NAME = 'CREATED_AT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of the user who added the project.' + WHERE RDB$RELATION_NAME = 'PROJECTS' AND RDB$FIELD_NAME = 'CREATOR_NAME'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Email address of the user who added the project.' + WHERE RDB$RELATION_NAME = 'PROJECTS' AND RDB$FIELD_NAME = 'CREATOR_EMAIL'; + +-- Table: changes + +CREATE TABLE changes ( + change_id VARCHAR(40) NOT NULL PRIMARY KEY, + change VARCHAR(255) NOT NULL, + project VARCHAR(255) NOT NULL REFERENCES projects(project) + ON UPDATE CASCADE, + note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + committer_name VARCHAR(255) NOT NULL, + committer_email VARCHAR(255) NOT NULL, + planned_at TIMESTAMP NOT NULL, + planner_name VARCHAR(255) NOT NULL, + planner_email VARCHAR(255) NOT NULL +); + +-- Description (comments) + +UPDATE RDB$RELATIONS SET + RDB$DESCRIPTION = 'Tracks the changes currently deployed to the database.' + WHERE RDB$RELATION_NAME = 'CHANGES'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Change primary key.' + WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'CHANGE_ID'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of a deployed change.' + WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'CHANGE'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of the Sqitch project to which the change belongs.' + WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'PROJECT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Description of the change.' + WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'NOTE'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Date the change was deployed.' + WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'COMMITTED_AT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of the user who deployed the change.' + WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'COMMITTER_NAME'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Email address of the user who deployed the change.' + WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'COMMITTER_EMAIL'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Date the change was added to the plan.' + WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'PLANNED_AT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of the user who planed the change.' + WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'PLANNER_NAME'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Email address of the user who planned the change.' + WHERE RDB$RELATION_NAME = 'CHANGES' AND RDB$FIELD_NAME = 'PLANNER_EMAIL'; + +-- Table: tags + +CREATE TABLE tags ( + tag_id CHAR(40) NOT NULL PRIMARY KEY, + tag VARCHAR(250) NOT NULL, + project VARCHAR(255) NOT NULL REFERENCES projects(project) + ON UPDATE CASCADE, + change_id CHAR(40) NOT NULL REFERENCES changes(change_id) + ON UPDATE CASCADE, + note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + committer_name VARCHAR(512) NOT NULL, + committer_email VARCHAR(512) NOT NULL, + planned_at TIMESTAMP NOT NULL, + planner_name VARCHAR(512) NOT NULL, + planner_email VARCHAR(512) NOT NULL, + UNIQUE(project, tag) +); + +-- Description (comments) + +UPDATE RDB$RELATIONS SET + RDB$DESCRIPTION = 'Tracks the tags currently applied to the database.' + WHERE RDB$RELATION_NAME = 'TAGS'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Tag primary key.' + WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'TAG_ID'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Project-unique tag name.' + WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'TAG'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of the Sqitch project to which the tag belongs.' + WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'PROJECT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'ID of last change deployed before the tag was applied.' + WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'CHANGE_ID'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Description of the tag.' + WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'NOTE'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Date the tag was applied to the database.' + WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'COMMITTED_AT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of the user who applied the tag.' + WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'COMMITTER_NAME'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Email address of the user who applied the tag.' + WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'COMMITTER_EMAIL'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Date the tag was added to the plan.' + WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'PLANNED_AT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of the user who planed the tag.' + WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'PLANNER_NAME'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Email address of the user who planned the tag.' + WHERE RDB$RELATION_NAME = 'TAGS' AND RDB$FIELD_NAME = 'PLANNER_EMAIL'; + +-- Table: dependencies + +CREATE TABLE dependencies ( + change_id CHAR(40) NOT NULL REFERENCES changes(change_id) + ON UPDATE CASCADE ON DELETE CASCADE, + type VARCHAR(8) NOT NULL, + dependency VARCHAR(512) NOT NULL, + dependency_id CHAR(40) REFERENCES changes(change_id) + ON UPDATE CASCADE CHECK ( + (type = 'require' AND dependency_id IS NOT NULL) + OR (type = 'conflict' AND dependency_id IS NULL) + ), + PRIMARY KEY (change_id, dependency) +); + +-- Description (comments) + +UPDATE RDB$RELATIONS SET + RDB$DESCRIPTION = 'Tracks the currently satisfied dependencies.' + WHERE RDB$RELATION_NAME = 'DEPENDENCIES'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'ID of the depending change.' + WHERE RDB$RELATION_NAME = 'DEPENDENCIES' AND RDB$FIELD_NAME = 'CHANGE_ID'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Type of dependency.' + WHERE RDB$RELATION_NAME = 'DEPENDENCIES' AND RDB$FIELD_NAME = 'TYPE'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Dependency name.' + WHERE RDB$RELATION_NAME = 'DEPENDENCIES' AND RDB$FIELD_NAME = 'DEPENDENCY'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Change ID the dependency resolves to.' + WHERE RDB$RELATION_NAME = 'DEPENDENCIES' AND RDB$FIELD_NAME = 'DEPENDENCY_ID'; + +-- Table: events + +CREATE TABLE events ( + event VARCHAR(6) NOT NULL + CHECK (event IN ('deploy', 'revert', 'fail')), + change_id CHAR(40) NOT NULL, + change VARCHAR(512) NOT NULL, + project VARCHAR(255) NOT NULL REFERENCES projects(project) + ON UPDATE CASCADE, + note BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + requires BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + conflicts BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + tags BLOB SUB_TYPE TEXT DEFAULT '' NOT NULL, + committed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + committer_name VARCHAR(512) NOT NULL, + committer_email VARCHAR(512) NOT NULL, + planned_at TIMESTAMP NOT NULL, + planner_name VARCHAR(512) NOT NULL, + planner_email VARCHAR(512) NOT NULL, + PRIMARY KEY (change_id, committed_at) +); + +-- Description (comments) + +UPDATE RDB$RELATIONS SET + RDB$DESCRIPTION = 'Contains full history of all deployment events.' + WHERE RDB$RELATION_NAME = 'EVENTS'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Type of event.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'EVENT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Change ID.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'CHANGE_ID'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Change name.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'CHANGE'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of the Sqitch project to which the change belongs.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'PROJECT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Description of the change.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'NOTE'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Array of the names of required changes.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'REQUIRES'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Array of the names of conflicting changes.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'CONFLICTS'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Tags associated with the change.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'TAGS'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Date the event was committed.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'COMMITTED_AT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of the user who committed the event.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'COMMITTER_NAME'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Email address of the user who committed the event.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'COMMITTER_EMAIL'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Date the event was added to the plan.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'PLANNED_AT'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Name of the user who planed the change.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'PLANNER_NAME'; + +UPDATE RDB$RELATION_FIELDS + SET RDB$DESCRIPTION = 'Email address of the user who plan planned the change.' + WHERE RDB$RELATION_NAME = 'EVENTS' AND RDB$FIELD_NAME = 'PLANNER_EMAIL'; + +COMMIT; diff --git a/t/lib/upgradable_registries/mysql.sql b/t/lib/upgradable_registries/mysql.sql new file mode 100644 index 00000000..4c1182ab --- /dev/null +++ b/t/lib/upgradable_registries/mysql.sql @@ -0,0 +1,189 @@ +BEGIN; + +SET SESSION sql_mode = ansi; + +CREATE TABLE releases ( + version FLOAT PRIMARY KEY + COMMENT 'Version of the Sqitch registry.', + installed_at TIMESTAMP NOT NULL + COMMENT 'Date the registry release was installed.', + installer_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who installed the registry release.', + installer_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who installed the registry release.' +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Sqitch registry releases.' +; + +CREATE TABLE projects ( + project VARCHAR(255) PRIMARY KEY + COMMENT 'Unique Name of a project.', + uri VARCHAR(255) NULL UNIQUE + COMMENT 'Optional project URI', + created_at DATETIME(6) NOT NULL + COMMENT 'Date the project was added to the database.', + creator_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who added the project.', + creator_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who added the project.' +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Sqitch projects deployed to this database.' +; + +CREATE TABLE changes ( + change_id VARCHAR(40) PRIMARY KEY + COMMENT 'Change primary key.', + "change" VARCHAR(255) NOT NULL + COMMENT 'Name of a deployed change.', + project VARCHAR(255) NOT NULL + COMMENT 'Name of the Sqitch project to which the change belongs.' + REFERENCES projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL + COMMENT 'Description of the change.', + committed_at DATETIME(6) NOT NULL + COMMENT 'Date the change was deployed.', + committer_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who deployed the change.', + committer_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who deployed the change.', + planned_at DATETIME(6) NOT NULL + COMMENT 'Date the change was added to the plan.', + planner_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who planed the change.', + planner_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who planned the change.' +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Tracks the changes currently deployed to the database.' +; + +CREATE TABLE tags ( + tag_id VARCHAR(40) PRIMARY KEY + COMMENT 'Tag primary key.', + tag VARCHAR(255) NOT NULL + COMMENT 'Project-unique tag name.', + project VARCHAR(255) NOT NULL + COMMENT 'Name of the Sqitch project to which the tag belongs.' + REFERENCES projects(project) ON UPDATE CASCADE, + change_id VARCHAR(40) NOT NULL + COMMENT 'ID of last change deployed before the tag was applied.' + REFERENCES changes(change_id) ON UPDATE CASCADE, + note VARCHAR(255) NOT NULL + COMMENT 'Description of the tag.', + committed_at DATETIME(6) NOT NULL + COMMENT 'Date the tag was applied to the database.', + committer_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who applied the tag.', + committer_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who applied the tag.', + planned_at DATETIME(6) NOT NULL + COMMENT 'Date the tag was added to the plan.', + planner_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who planed the tag.', + planner_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who planned the tag.', + UNIQUE(project, tag) +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Tracks the tags currently applied to the database.' +; + +CREATE TABLE dependencies ( + change_id VARCHAR(40) NOT NULL + COMMENT 'ID of the depending change.' + REFERENCES changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE, + type VARCHAR(8) NOT NULL + COMMENT 'Type of dependency.', + dependency VARCHAR(255) NOT NULL + COMMENT 'Dependency name.', + dependency_id VARCHAR(40) NULL + COMMENT 'Change ID the dependency resolves to.' + REFERENCES changes(change_id) ON UPDATE CASCADE, + PRIMARY KEY (change_id, dependency) +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Tracks the currently satisfied dependencies.' +; + +CREATE TABLE events ( + event ENUM ('deploy', 'fail', 'revert') NOT NULL + COMMENT 'Type of event.', + change_id VARCHAR(40) NOT NULL + COMMENT 'Change ID.', + "change" VARCHAR(255) NOT NULL + COMMENT 'Change name.', + project VARCHAR(255) NOT NULL + COMMENT 'Name of the Sqitch project to which the change belongs.' + REFERENCES projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL + COMMENT 'Description of the change.', + requires TEXT NOT NULL + COMMENT 'List of the names of required changes.', + conflicts TEXT NOT NULL + COMMENT 'List of the names of conflicting changes.', + tags TEXT NOT NULL + COMMENT 'List of tags associated with the change.', + committed_at DATETIME(6) NOT NULL + COMMENT 'Date the event was committed.', + committer_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who committed the event.', + committer_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who committed the event.', + planned_at DATETIME(6) NOT NULL + COMMENT 'Date the event was added to the plan.', + planner_name VARCHAR(255) NOT NULL + COMMENT 'Name of the user who planed the change.', + planner_email VARCHAR(255) NOT NULL + COMMENT 'Email address of the user who plan planned the change.', + PRIMARY KEY (change_id, committed_at) +) ENGINE InnoDB, + CHARACTER SET 'utf8', + COMMENT 'Contains full history of all deployment events.' +; + +-- ## BEGIN 5.5 +-- MySQL does not support checks, so we kind of create our own. The checkit() +-- function works sort of like a CHECK: if the first argument is 0 or NULL, it +-- throws the second argument as an exception. Conveniently, verify scripts +-- can also use it to ensure an error is thrown when a change cannot be +-- verified. Requires MySQL 5.5.0. + +DELIMITER | + +CREATE FUNCTION checkit(doit INTEGER, message VARCHAR(256)) RETURNS INTEGER DETERMINISTIC +BEGIN + IF doit IS NULL OR doit = 0 THEN + SIGNAL SQLSTATE 'ERR0R' SET MESSAGE_TEXT = message; + END IF; + RETURN doit; +END; +| + +CREATE TRIGGER ck_insert_dependency BEFORE INSERT ON dependencies +FOR EACH ROW BEGIN + -- DO does not work. https://bugs.mysql.com/bug.php?id=69647 + SET @dummy := checkit( + (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL) + OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL), + 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set' + ); +END; +| + +CREATE TRIGGER ck_update_dependency BEFORE UPDATE ON dependencies +FOR EACH ROW BEGIN + -- DO does not work. https://bugs.mysql.com/bug.php?id=69647 + SET @dummy := checkit( + (NEW.type = 'require' AND NEW.dependency_id IS NOT NULL) + OR (NEW.type = 'conflict' AND NEW.dependency_id IS NULL), + 'Type must be "require" with dependency_id set or "conflict" with dependency_id not set' + ); +END; +| + +DELIMITER ; +-- ## END 5.5 + +COMMIT; diff --git a/t/lib/upgradable_registries/oracle.sql b/t/lib/upgradable_registries/oracle.sql new file mode 100644 index 00000000..ed6f949a --- /dev/null +++ b/t/lib/upgradable_registries/oracle.sql @@ -0,0 +1,136 @@ +CREATE TABLE ®istry..releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL, + installer_name VARCHAR2(512 CHAR) NOT NULL, + installer_email VARCHAR2(512 CHAR) NOT NULL +); + +COMMENT ON TABLE ®istry..releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN ®istry..releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN ®istry..releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN ®istry..releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN ®istry..releases.installer_email IS 'Email address of the user who installed the registry release.'; + +CREATE TABLE ®istry..projects ( + project VARCHAR2(512 CHAR) PRIMARY KEY, + uri VARCHAR2(512 CHAR) NULL UNIQUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL, + creator_name VARCHAR2(512 CHAR) NOT NULL, + creator_email VARCHAR2(512 CHAR) NOT NULL +); + +COMMENT ON TABLE ®istry..projects IS 'Sqitch projects deployed to this database.'; +COMMENT ON COLUMN ®istry..projects.project IS 'Unique Name of a project.'; +COMMENT ON COLUMN ®istry..projects.uri IS 'Optional project URI'; +COMMENT ON COLUMN ®istry..projects.created_at IS 'Date the project was added to the database.'; +COMMENT ON COLUMN ®istry..projects.creator_name IS 'Name of the user who added the project.'; +COMMENT ON COLUMN ®istry..projects.creator_email IS 'Email address of the user who added the project.'; + +CREATE TABLE ®istry..changes ( + change_id CHAR(40) PRIMARY KEY, + change VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + note VARCHAR2(4000 CHAR) DEFAULT '', + committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL +); + +COMMENT ON TABLE ®istry..changes IS 'Tracks the changes currently deployed to the database.'; +COMMENT ON COLUMN ®istry..changes.change_id IS 'Change primary key.'; +COMMENT ON COLUMN ®istry..changes.change IS 'Name of a deployed change.'; +COMMENT ON COLUMN ®istry..changes.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry..changes.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry..changes.committed_at IS 'Date the change was deployed.'; +COMMENT ON COLUMN ®istry..changes.committer_name IS 'Name of the user who deployed the change.'; +COMMENT ON COLUMN ®istry..changes.committer_email IS 'Email address of the user who deployed the change.'; +COMMENT ON COLUMN ®istry..changes.planned_at IS 'Date the change was added to the plan.'; +COMMENT ON COLUMN ®istry..changes.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry..changes.planner_email IS 'Email address of the user who planned the change.'; + +CREATE TABLE ®istry..tags ( + tag_id CHAR(40) PRIMARY KEY, + tag VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + change_id CHAR(40) NOT NULL REFERENCES ®istry..changes(change_id), + note VARCHAR2(4000 CHAR) DEFAULT '', + committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL, + UNIQUE(project, tag) +); + +COMMENT ON TABLE ®istry..tags IS 'Tracks the tags currently applied to the database.'; +COMMENT ON COLUMN ®istry..tags.tag_id IS 'Tag primary key.'; +COMMENT ON COLUMN ®istry..tags.tag IS 'Project-unique tag name.'; +COMMENT ON COLUMN ®istry..tags.project IS 'Name of the Sqitch project to which the tag belongs.'; +COMMENT ON COLUMN ®istry..tags.change_id IS 'ID of last change deployed before the tag was applied.'; +COMMENT ON COLUMN ®istry..tags.note IS 'Description of the tag.'; +COMMENT ON COLUMN ®istry..tags.committed_at IS 'Date the tag was applied to the database.'; +COMMENT ON COLUMN ®istry..tags.committer_name IS 'Name of the user who applied the tag.'; +COMMENT ON COLUMN ®istry..tags.committer_email IS 'Email address of the user who applied the tag.'; +COMMENT ON COLUMN ®istry..tags.planned_at IS 'Date the tag was added to the plan.'; +COMMENT ON COLUMN ®istry..tags.planner_name IS 'Name of the user who planed the tag.'; +COMMENT ON COLUMN ®istry..tags.planner_email IS 'Email address of the user who planned the tag.'; + +CREATE TABLE ®istry..dependencies ( + change_id CHAR(40) NOT NULL REFERENCES ®istry..changes(change_id) ON DELETE CASCADE, + type VARCHAR2(8) NOT NULL, + dependency VARCHAR2(1024 CHAR) NOT NULL, + dependency_id CHAR(40) NULL REFERENCES ®istry..changes(change_id), + CHECK ( + (type = 'require' AND dependency_id IS NOT NULL) + OR (type = 'conflict' AND dependency_id IS NULL) + ), + PRIMARY KEY (change_id, dependency) +); + +COMMENT ON TABLE ®istry..dependencies IS 'Tracks the currently satisfied dependencies.'; +COMMENT ON COLUMN ®istry..dependencies.change_id IS 'ID of the depending change.'; +COMMENT ON COLUMN ®istry..dependencies.type IS 'Type of dependency.'; +COMMENT ON COLUMN ®istry..dependencies.dependency IS 'Dependency name.'; +COMMENT ON COLUMN ®istry..dependencies.dependency_id IS 'Change ID the dependency resolves to.'; + +CREATE TYPE ®istry..sqitch_array AS varray(1024) OF VARCHAR2(512); +/ + +CREATE TABLE ®istry..events ( + event VARCHAR2(6) NOT NULL CHECK (event IN ('deploy', 'revert', 'fail')), + change_id CHAR(40) NOT NULL, + change VARCHAR2(512 CHAR) NOT NULL, + project VARCHAR2(512 CHAR) NOT NULL REFERENCES ®istry..projects(project), + note VARCHAR2(4000 CHAR) DEFAULT '', + requires ®istry..SQITCH_ARRAY DEFAULT ®istry..SQITCH_ARRAY() NOT NULL, + conflicts ®istry..SQITCH_ARRAY DEFAULT ®istry..SQITCH_ARRAY() NOT NULL, + tags ®istry..SQITCH_ARRAY DEFAULT ®istry..SQITCH_ARRAY() NOT NULL, + committed_at TIMESTAMP WITH TIME ZONE DEFAULT current_timestamp NOT NULL, + committer_name VARCHAR2(512 CHAR) NOT NULL, + committer_email VARCHAR2(512 CHAR) NOT NULL, + planned_at TIMESTAMP WITH TIME ZONE NOT NULL, + planner_name VARCHAR2(512 CHAR) NOT NULL, + planner_email VARCHAR2(512 CHAR) NOT NULL +); + +CREATE UNIQUE INDEX ®istry..events_pkey ON ®istry..events(change_id, committed_at); + +COMMENT ON TABLE ®istry..events IS 'Contains full history of all deployment events.'; +COMMENT ON COLUMN ®istry..events.event IS 'Type of event.'; +COMMENT ON COLUMN ®istry..events.change_id IS 'Change ID.'; +COMMENT ON COLUMN ®istry..events.change IS 'Change name.'; +COMMENT ON COLUMN ®istry..events.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry..events.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry..events.requires IS 'Array of the names of required changes.'; +COMMENT ON COLUMN ®istry..events.conflicts IS 'Array of the names of conflicting changes.'; +COMMENT ON COLUMN ®istry..events.tags IS 'Tags associated with the change.'; +COMMENT ON COLUMN ®istry..events.committed_at IS 'Date the event was committed.'; +COMMENT ON COLUMN ®istry..events.committer_name IS 'Name of the user who committed the event.'; +COMMENT ON COLUMN ®istry..events.committer_email IS 'Email address of the user who committed the event.'; +COMMENT ON COLUMN ®istry..events.planned_at IS 'Date the event was added to the plan.'; +COMMENT ON COLUMN ®istry..events.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry..events.planner_email IS 'Email address of the user who plan planned the change.'; diff --git a/t/lib/upgradable_registries/pg.sql b/t/lib/upgradable_registries/pg.sql new file mode 100644 index 00000000..59f1d397 --- /dev/null +++ b/t/lib/upgradable_registries/pg.sql @@ -0,0 +1,140 @@ +BEGIN; + +SET client_min_messages = warning; +CREATE SCHEMA IF NOT EXISTS :"registry"; + +COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.0.'; + +CREATE TABLE :"registry".releases ( + version REAL PRIMARY KEY, + installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + installer_name TEXT NOT NULL, + installer_email TEXT NOT NULL +); + +COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN :"registry".releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN :"registry".releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN :"registry".releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN :"registry".releases.installer_email IS 'Email address of the user who installed the registry release.'; + +CREATE TABLE :"registry".projects ( + project TEXT PRIMARY KEY, + uri TEXT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + creator_name TEXT NOT NULL, + creator_email TEXT NOT NULL +):tableopts; + +COMMENT ON TABLE :"registry".projects IS 'Sqitch projects deployed to this database.'; +COMMENT ON COLUMN :"registry".projects.project IS 'Unique Name of a project.'; +COMMENT ON COLUMN :"registry".projects.uri IS 'Optional project URI'; +COMMENT ON COLUMN :"registry".projects.created_at IS 'Date the project was added to the database.'; +COMMENT ON COLUMN :"registry".projects.creator_name IS 'Name of the user who added the project.'; +COMMENT ON COLUMN :"registry".projects.creator_email IS 'Email address of the user who added the project.'; + +CREATE TABLE :"registry".changes ( + change_id TEXT PRIMARY KEY, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL +):tableopts; + +COMMENT ON TABLE :"registry".changes IS 'Tracks the changes currently deployed to the database.'; +COMMENT ON COLUMN :"registry".changes.change_id IS 'Change primary key.'; +COMMENT ON COLUMN :"registry".changes.change IS 'Name of a deployed change.'; +COMMENT ON COLUMN :"registry".changes.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN :"registry".changes.note IS 'Description of the change.'; +COMMENT ON COLUMN :"registry".changes.committed_at IS 'Date the change was deployed.'; +COMMENT ON COLUMN :"registry".changes.committer_name IS 'Name of the user who deployed the change.'; +COMMENT ON COLUMN :"registry".changes.committer_email IS 'Email address of the user who deployed the change.'; +COMMENT ON COLUMN :"registry".changes.planned_at IS 'Date the change was added to the plan.'; +COMMENT ON COLUMN :"registry".changes.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN :"registry".changes.planner_email IS 'Email address of the user who planned the change.'; + +CREATE TABLE :"registry".tags ( + tag_id TEXT PRIMARY KEY, + tag TEXT NOT NULL, + project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE, + change_id TEXT NOT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + UNIQUE(project, tag) +):tableopts; + +COMMENT ON TABLE :"registry".tags IS 'Tracks the tags currently applied to the database.'; +COMMENT ON COLUMN :"registry".tags.tag_id IS 'Tag primary key.'; +COMMENT ON COLUMN :"registry".tags.tag IS 'Project-unique tag name.'; +COMMENT ON COLUMN :"registry".tags.project IS 'Name of the Sqitch project to which the tag belongs.'; +COMMENT ON COLUMN :"registry".tags.change_id IS 'ID of last change deployed before the tag was applied.'; +COMMENT ON COLUMN :"registry".tags.note IS 'Description of the tag.'; +COMMENT ON COLUMN :"registry".tags.committed_at IS 'Date the tag was applied to the database.'; +COMMENT ON COLUMN :"registry".tags.committer_name IS 'Name of the user who applied the tag.'; +COMMENT ON COLUMN :"registry".tags.committer_email IS 'Email address of the user who applied the tag.'; +COMMENT ON COLUMN :"registry".tags.planned_at IS 'Date the tag was added to the plan.'; +COMMENT ON COLUMN :"registry".tags.planner_name IS 'Name of the user who planed the tag.'; +COMMENT ON COLUMN :"registry".tags.planner_email IS 'Email address of the user who planned the tag.'; + +CREATE TABLE :"registry".dependencies ( + change_id TEXT NOT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE, + type TEXT NOT NULL, + dependency TEXT NOT NULL, + dependency_id TEXT NULL REFERENCES :"registry".changes(change_id) ON UPDATE CASCADE CHECK ( + (type = 'require' AND dependency_id IS NOT NULL) + OR (type = 'conflict' AND dependency_id IS NULL) + ), + PRIMARY KEY (change_id, dependency) +):tableopts; + +COMMENT ON TABLE :"registry".dependencies IS 'Tracks the currently satisfied dependencies.'; +COMMENT ON COLUMN :"registry".dependencies.change_id IS 'ID of the depending change.'; +COMMENT ON COLUMN :"registry".dependencies.type IS 'Type of dependency.'; +COMMENT ON COLUMN :"registry".dependencies.dependency IS 'Dependency name.'; +COMMENT ON COLUMN :"registry".dependencies.dependency_id IS 'Change ID the dependency resolves to.'; + +CREATE TABLE :"registry".events ( + event TEXT NOT NULL CHECK (event IN ('deploy', 'revert', 'fail')), + change_id TEXT NOT NULL, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES :"registry".projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + requires TEXT[] NOT NULL DEFAULT '{}', + conflicts TEXT[] NOT NULL DEFAULT '{}', + tags TEXT[] NOT NULL DEFAULT '{}', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + PRIMARY KEY (change_id, committed_at) +):tableopts; + +COMMENT ON TABLE :"registry".events IS 'Contains full history of all deployment events.'; +COMMENT ON COLUMN :"registry".events.event IS 'Type of event.'; +COMMENT ON COLUMN :"registry".events.change_id IS 'Change ID.'; +COMMENT ON COLUMN :"registry".events.change IS 'Change name.'; +COMMENT ON COLUMN :"registry".events.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN :"registry".events.note IS 'Description of the change.'; +COMMENT ON COLUMN :"registry".events.requires IS 'Array of the names of required changes.'; +COMMENT ON COLUMN :"registry".events.conflicts IS 'Array of the names of conflicting changes.'; +COMMENT ON COLUMN :"registry".events.tags IS 'Tags associated with the change.'; +COMMENT ON COLUMN :"registry".events.committed_at IS 'Date the event was committed.'; +COMMENT ON COLUMN :"registry".events.committer_name IS 'Name of the user who committed the event.'; +COMMENT ON COLUMN :"registry".events.committer_email IS 'Email address of the user who committed the event.'; +COMMENT ON COLUMN :"registry".events.planned_at IS 'Date the event was added to the plan.'; +COMMENT ON COLUMN :"registry".events.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN :"registry".events.planner_email IS 'Email address of the user who plan planned the change.'; + +COMMIT; diff --git a/t/lib/upgradable_registries/snowflake.sql b/t/lib/upgradable_registries/snowflake.sql new file mode 100644 index 00000000..7ea7a25e --- /dev/null +++ b/t/lib/upgradable_registries/snowflake.sql @@ -0,0 +1,139 @@ +CREATE SCHEMA IF NOT EXISTS ®istry; + +COMMENT ON SCHEMA ®istry IS 'Sqitch database deployment metadata v1.0.'; + +CREATE TABLE ®istry.releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp, + installer_name TEXT NOT NULL, + installer_email TEXT NOT NULL +); + +COMMENT ON TABLE ®istry.releases IS 'Sqitch registry releases.'; +COMMENT ON COLUMN ®istry.releases.version IS 'Version of the Sqitch registry.'; +COMMENT ON COLUMN ®istry.releases.installed_at IS 'Date the registry release was installed.'; +COMMENT ON COLUMN ®istry.releases.installer_name IS 'Name of the user who installed the registry release.'; +COMMENT ON COLUMN ®istry.releases.installer_email IS 'Email address of the user who installed the registry release.'; + +CREATE TABLE ®istry.projects ( + project TEXT PRIMARY KEY, + uri TEXT NULL UNIQUE, + created_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp, + creator_name TEXT NOT NULL, + creator_email TEXT NOT NULL +); + +COMMENT ON TABLE ®istry.projects IS 'Sqitch projects deployed to this database.'; +COMMENT ON COLUMN ®istry.projects.project IS 'Unique Name of a project.'; +COMMENT ON COLUMN ®istry.projects.uri IS 'Optional project URI'; +COMMENT ON COLUMN ®istry.projects.created_at IS 'Date the project was added to the database.'; +COMMENT ON COLUMN ®istry.projects.creator_name IS 'Name of the user who added the project.'; +COMMENT ON COLUMN ®istry.projects.creator_email IS 'Email address of the user who added the project.'; + +CREATE TABLE ®istry.changes ( + change_id TEXT PRIMARY KEY, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES ®istry.projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at TIMESTAMP_TZ NOT NULL DEFAULT current_timestamp, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMP_TZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL +); + +COMMENT ON TABLE ®istry.changes IS 'Tracks the changes currently deployed to the database.'; +COMMENT ON COLUMN ®istry.changes.change_id IS 'Change primary key.'; +COMMENT ON COLUMN ®istry.changes.change IS 'Name of a deployed change.'; +COMMENT ON COLUMN ®istry.changes.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry.changes.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry.changes.committed_at IS 'Date the change was deployed.'; +COMMENT ON COLUMN ®istry.changes.committer_name IS 'Name of the user who deployed the change.'; +COMMENT ON COLUMN ®istry.changes.committer_email IS 'Email address of the user who deployed the change.'; +COMMENT ON COLUMN ®istry.changes.planned_at IS 'Date the change was added to the plan.'; +COMMENT ON COLUMN ®istry.changes.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry.changes.planner_email IS 'Email address of the user who planned the change.'; + +CREATE TABLE ®istry.tags ( + tag_id TEXT PRIMARY KEY, + tag TEXT NOT NULL, + project TEXT NOT NULL REFERENCES ®istry.projects(project) ON UPDATE CASCADE, + change_id TEXT NOT NULL REFERENCES ®istry.changes(change_id) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + UNIQUE(project, tag) +); + +COMMENT ON TABLE ®istry.tags IS 'Tracks the tags currently applied to the database.'; +COMMENT ON COLUMN ®istry.tags.tag_id IS 'Tag primary key.'; +COMMENT ON COLUMN ®istry.tags.tag IS 'Project-unique tag name.'; +COMMENT ON COLUMN ®istry.tags.project IS 'Name of the Sqitch project to which the tag belongs.'; +COMMENT ON COLUMN ®istry.tags.change_id IS 'ID of last change deployed before the tag was applied.'; +COMMENT ON COLUMN ®istry.tags.note IS 'Description of the tag.'; +COMMENT ON COLUMN ®istry.tags.committed_at IS 'Date the tag was applied to the database.'; +COMMENT ON COLUMN ®istry.tags.committer_name IS 'Name of the user who applied the tag.'; +COMMENT ON COLUMN ®istry.tags.committer_email IS 'Email address of the user who applied the tag.'; +COMMENT ON COLUMN ®istry.tags.planned_at IS 'Date the tag was added to the plan.'; +COMMENT ON COLUMN ®istry.tags.planner_name IS 'Name of the user who planed the tag.'; +COMMENT ON COLUMN ®istry.tags.planner_email IS 'Email address of the user who planned the tag.'; + +CREATE TABLE ®istry.dependencies ( + change_id TEXT NOT NULL REFERENCES ®istry.changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE, + type TEXT NOT NULL, + dependency TEXT NOT NULL, + dependency_id TEXT NULL REFERENCES ®istry.changes(change_id) ON UPDATE CASCADE, + -- CONSTRAINT dependencies_check CHECK ( + -- (type = 'require' AND dependency_id IS NOT NULL) + -- OR (type = 'conflict' AND dependency_id IS NULL) + -- ), + PRIMARY KEY (change_id, dependency) +); + +COMMENT ON TABLE ®istry.dependencies IS 'Tracks the currently satisfied dependencies.'; +COMMENT ON COLUMN ®istry.dependencies.change_id IS 'ID of the depending change.'; +COMMENT ON COLUMN ®istry.dependencies.type IS 'Type of dependency.'; +COMMENT ON COLUMN ®istry.dependencies.dependency IS 'Dependency name.'; +COMMENT ON COLUMN ®istry.dependencies.dependency_id IS 'Change ID the dependency resolves to.'; + +CREATE TABLE ®istry.events ( + event TEXT NOT NULL, + -- CONSTRAINT events_event_check CHECK ( + -- event IN ('deploy', 'revert', 'fail', 'merge') + -- ), + change_id TEXT NOT NULL, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES ®istry.projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + requires TEXT NOT NULL DEFAULT '', + conflicts TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + PRIMARY KEY (change_id, committed_at) +); + +COMMENT ON TABLE ®istry.events IS 'Contains full history of all deployment events.'; +COMMENT ON COLUMN ®istry.events.event IS 'Type of event.'; +COMMENT ON COLUMN ®istry.events.change_id IS 'Change ID.'; +COMMENT ON COLUMN ®istry.events.change IS 'Change name.'; +COMMENT ON COLUMN ®istry.events.project IS 'Name of the Sqitch project to which the change belongs.'; +COMMENT ON COLUMN ®istry.events.note IS 'Description of the change.'; +COMMENT ON COLUMN ®istry.events.requires IS 'Array of the names of required changes.'; +COMMENT ON COLUMN ®istry.events.conflicts IS 'Array of the names of conflicting changes.'; +COMMENT ON COLUMN ®istry.events.tags IS 'Tags associated with the change.'; +COMMENT ON COLUMN ®istry.events.committed_at IS 'Date the event was committed.'; +COMMENT ON COLUMN ®istry.events.committer_name IS 'Name of the user who committed the event.'; +COMMENT ON COLUMN ®istry.events.committer_email IS 'Email address of the user who committed the event.'; +COMMENT ON COLUMN ®istry.events.planned_at IS 'Date the event was added to the plan.'; +COMMENT ON COLUMN ®istry.events.planner_name IS 'Name of the user who planed the change.'; +COMMENT ON COLUMN ®istry.events.planner_email IS 'Email address of the user who plan planned the change.'; diff --git a/t/lib/upgradable_registries/sqlite.sql b/t/lib/upgradable_registries/sqlite.sql new file mode 100644 index 00000000..9aa00ec7 --- /dev/null +++ b/t/lib/upgradable_registries/sqlite.sql @@ -0,0 +1,75 @@ +BEGIN; + +CREATE TABLE releases ( + version FLOAT PRIMARY KEY, + installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + installer_name TEXT NOT NULL, + installer_email TEXT NOT NULL +); + +CREATE TABLE projects ( + project TEXT PRIMARY KEY, + uri TEXT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + creator_name TEXT NOT NULL, + creator_email TEXT NOT NULL +); + +CREATE TABLE changes ( + change_id TEXT PRIMARY KEY, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at DATETIME NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL +); + +CREATE TABLE tags ( + tag_id TEXT PRIMARY KEY, + tag TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE, + change_id TEXT NOT NULL REFERENCES changes(change_id) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at DATETIME NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + UNIQUE(project, tag) +); + +CREATE TABLE dependencies ( + change_id TEXT NOT NULL REFERENCES changes(change_id) ON UPDATE CASCADE ON DELETE CASCADE, + type TEXT NOT NULL, + dependency TEXT NOT NULL, + dependency_id TEXT NULL REFERENCES changes(change_id) ON UPDATE CASCADE CHECK ( + (type = 'require' AND dependency_id IS NOT NULL) + OR (type = 'conflict' AND dependency_id IS NULL) + ), + PRIMARY KEY (change_id, dependency) +); + +CREATE TABLE events ( + event TEXT NOT NULL CHECK (event IN ('deploy', 'revert', 'fail')), + change_id TEXT NOT NULL, + change TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(project) ON UPDATE CASCADE, + note TEXT NOT NULL DEFAULT '', + requires TEXT NOT NULL DEFAULT '', + conflicts TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '', + committed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + committer_name TEXT NOT NULL, + committer_email TEXT NOT NULL, + planned_at DATETIME NOT NULL, + planner_name TEXT NOT NULL, + planner_email TEXT NOT NULL, + PRIMARY KEY (change_id, committed_at) +); + +COMMIT; diff --git a/t/lib/upgradable_registries/vertica.sql b/t/lib/upgradable_registries/vertica.sql new file mode 100644 index 00000000..dc7b8754 --- /dev/null +++ b/t/lib/upgradable_registries/vertica.sql @@ -0,0 +1,84 @@ +CREATE SCHEMA :"registry"; + +COMMENT ON SCHEMA :"registry" IS 'Sqitch database deployment metadata v1.0.'; + +CREATE TABLE :"registry".releases ( + version FLOAT PRIMARY KEY, + installed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + installer_name VARCHAR(1024) NOT NULL, + installer_email VARCHAR(1024) NOT NULL +); + +COMMENT ON TABLE :"registry".releases IS 'Sqitch registry releases.'; + +CREATE TABLE :"registry".projects ( + project VARCHAR(1024) PRIMARY KEY ENCODING AUTO, + uri VARCHAR(1024) NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + creator_name VARCHAR(1024) NOT NULL, + creator_email VARCHAR(1024) NOT NULL +); + +COMMENT ON TABLE :"registry".projects IS 'Sqitch projects deployed to this database.'; + +CREATE TABLE :"registry".changes ( + change_id CHAR(40) PRIMARY KEY ENCODING AUTO, + change VARCHAR(1024) NOT NULL, + project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project), + note VARCHAR(65000) NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name VARCHAR(1024) NOT NULL, + committer_email VARCHAR(1024) NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name VARCHAR(1024) NOT NULL, + planner_email VARCHAR(1024) NOT NULL +); + +COMMENT ON TABLE :"registry".changes IS 'Tracks the changes currently deployed to the database.'; + +CREATE TABLE :"registry".tags ( + tag_id CHAR(40) PRIMARY KEY ENCODING AUTO, + tag VARCHAR(1024) NOT NULL, + project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project), + change_id CHAR(40) NOT NULL REFERENCES :"registry".changes(change_id), + note VARCHAR(65000) NOT NULL DEFAULT '', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name VARCHAR(1024) NOT NULL, + committer_email VARCHAR(1024) NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name VARCHAR(1024) NOT NULL, + planner_email VARCHAR(1024) NOT NULL, + UNIQUE(project, tag) +); + +COMMENT ON TABLE :"registry".tags IS 'Tracks the tags currently applied to the database.'; + +CREATE TABLE :"registry".dependencies ( + change_id CHAR(40) NOT NULL REFERENCES :"registry".changes(change_id), + type VARCHAR(8) NOT NULL ENCODING AUTO, + dependency VARCHAR(2048) NOT NULL, + dependency_id CHAR(40) NULL REFERENCES :"registry".changes(change_id), + PRIMARY KEY (change_id, dependency) +); + +COMMENT ON TABLE :"registry".dependencies IS 'Tracks the currently satisfied dependencies.'; + +CREATE TABLE :"registry".events ( + event VARCHAR(6) NOT NULL ENCODING AUTO, + change_id CHAR(40) NOT NULL, + change VARCHAR(1024) NOT NULL, + project VARCHAR(1024) NOT NULL REFERENCES :"registry".projects(project), + note VARCHAR(65000) NOT NULL DEFAULT '', + requires LONG VARCHAR NOT NULL DEFAULT '{}', + conflicts LONG VARCHAR NOT NULL DEFAULT '{}', + tags LONG VARCHAR NOT NULL DEFAULT '{}', + committed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + committer_name VARCHAR(1024) NOT NULL, + committer_email VARCHAR(1024) NOT NULL, + planned_at TIMESTAMPTZ NOT NULL, + planner_name VARCHAR(1024) NOT NULL, + planner_email VARCHAR(1024) NOT NULL, + PRIMARY KEY (change_id, committed_at) +); + +COMMENT ON TABLE :"registry".events IS 'Contains full history of all deployment events.'; diff --git a/t/linelist.t b/t/linelist.t new file mode 100644 index 00000000..bd3c96a4 --- /dev/null +++ b/t/linelist.t @@ -0,0 +1,81 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More tests => 28; +#use Test::More 'no_plan'; +use Test::NoWarnings; +use Test::Exception; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use lib 't/lib'; +use TestConfig; + +BEGIN { require_ok 'App::Sqitch::Plan::LineList' or die } + +my $sqitch = App::Sqitch->new(config => TestConfig->new('core.engine' => 'sqlite')); +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target); + +my $foo = App::Sqitch::Plan::Change->new(plan => $plan, name => 'foo'); +my $bar = App::Sqitch::Plan::Change->new(plan => $plan, name => 'bar'); +my $baz = App::Sqitch::Plan::Change->new(plan => $plan, name => 'baz'); +my $yo1 = App::Sqitch::Plan::Change->new(plan => $plan, name => 'yo'); +my $yo2 = App::Sqitch::Plan::Change->new(plan => $plan, name => 'yo'); + +my $blank = App::Sqitch::Plan::Blank->new(plan => $plan); +my $alpha = App::Sqitch::Plan::Tag->new( + plan => $plan, + change => $yo1, + name => 'alpha', +); + +my $lines = App::Sqitch::Plan::LineList->new( + $foo, + $bar, + $yo1, + $alpha, + $blank, + $baz, + $yo2, +); + +is $lines->count, 7, 'Count should be six'; +is_deeply [$lines->items], [$foo, $bar, $yo1, $alpha, $blank, $baz, $yo2], + 'Lines should be in order'; +is $lines->item_at(0), $foo, 'Should have foo at 0'; +is $lines->item_at(1), $bar, 'Should have bar at 1'; +is $lines->item_at(2), $yo1, 'Should have yo1 at 2'; +is $lines->item_at(3), $alpha, 'Should have @alpha at 3'; +is $lines->item_at(4), $blank, 'Should have blank at 4'; +is $lines->item_at(5), $baz, 'Should have baz at 5'; +is $lines->item_at(6), $yo2, 'Should have yo2 at 6'; + +is $lines->index_of('non'), undef, 'Should not find "non"'; +is $lines->index_of($foo), 0, 'Should find foo at 0'; +is $lines->index_of($bar), 1, 'Should find bar at 1'; +is $lines->index_of($yo1), 2, 'Should find yo1 at 2'; +is $lines->index_of($alpha), 3, 'Should find @alpha at 3'; +is $lines->index_of($blank), 4, 'Should find blank at 4'; +is $lines->index_of($baz), 5, 'Should find baz at 5'; +is $lines->index_of($yo2), 6, 'Should find yo2 at 6'; + +my $hi = App::Sqitch::Plan::Change->new(plan => $plan, name => 'hi'); +ok $lines->append($hi), 'Append hi'; +is $lines->count, 8, 'Count should now be eight'; +is_deeply [$lines->items], [$foo, $bar, $yo1, $alpha, $blank, $baz, $yo2, $hi], + 'Lines should be in order with $hi at the end'; + +# Try inserting. +my $oy = App::Sqitch::Plan::Change->new(plan => $plan, name => 'oy'); +ok $lines->insert_at($oy, 3), 'Insert a change at index 3'; +is $lines->count, 9, 'Count should now be nine'; +is_deeply [$lines->items], [$foo, $bar, $yo1, $oy, $alpha, $blank, $baz, $yo2, $hi], + 'Lines should be in order with $oy at index 3'; +is $lines->index_of($oy), 3, 'Should find oy at 3'; +is $lines->index_of($alpha), 4, 'Should find @alpha at 4'; +is $lines->index_of($hi), 8, 'Should find hi at 8'; + diff --git a/t/local.conf b/t/local.conf new file mode 100644 index 00000000..3588c218 --- /dev/null +++ b/t/local.conf @@ -0,0 +1,15 @@ +[core] + engine = pg + +[engine "pg"] + target = mydb + +[engine "sqlite"] + target = devdb + +[target "devdb"] + uri = db:sqlite: + +[target "mydb"] + uri = db:pg:mydb + plan_file = t/plans/dependencies.plan diff --git a/t/log.t b/t/log.t new file mode 100644 index 00000000..568b9d71 --- /dev/null +++ b/t/log.t @@ -0,0 +1,754 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 253; +#use Test::More 'no_plan'; +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use Test::NoWarnings; +use Test::Exception; +use Test::Warn; +use Test::MockModule; +use Path::Class; +use Term::ANSIColor qw(color); +use Encode; +use lib 't/lib'; +use MockOutput; +use TestConfig; +use LC; + +my $CLASS = 'App::Sqitch::Command::log'; +require_ok $CLASS; + +my $plan_file = Path::Class::File->new('t/sql/sqitch.plan')->stringify; +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => Path::Class::Dir->new('test-log')->stringify, + 'core.plan_file' => $plan_file, +); +ok my $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object'; +isa_ok my $log = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'log', + config => $config, +}), $CLASS, 'log command'; + +can_ok $log, qw( + target + change_pattern + project_pattern + committer_pattern + max_count + skip + reverse + format + options + execute + configure + headers + does +); + +ok $CLASS->does("App::Sqitch::Role::ConnectingCommand"), + "$CLASS does ConnectingCommand"; + +is_deeply [$CLASS->options], [qw( + event=s@ + target|t=s + change-pattern|change=s + project-pattern|project=s + committer-pattern|committer=s + format|f=s + date-format|date=s + max-count|n=i + skip=i + reverse! + color=s + no-color + abbrev=i + oneline + headers! + registry=s + client|db-client=s + db-name|d=s + db-user|db-username|u=s + db-host|h=s + db-port|p=i +)], 'Options should be correct'; + +############################################################################## +# Test database. +is $log->target, undef, 'Default target should be undef'; +isa_ok $log = $CLASS->new( + sqitch => $sqitch, + target => 'foo', +), $CLASS, 'new status with target'; +is $log->target, 'foo', 'Should have target "foo"'; + +############################################################################## +# Test configure(). +my $configured = $CLASS->configure($config, {}); +isa_ok delete $configured->{formatter}, 'App::Sqitch::ItemFormatter', 'Formatter'; +is_deeply $configured, {_params => []}, + 'Should get empty hash for no config or options'; + +# Test date_format validation. +$config->update('log.date_format' => 'nonesuch'); +throws_ok { $CLASS->configure($config, {}), {} } 'App::Sqitch::X', + 'Should get error for invalid date format in config'; +is $@->ident, 'datetime', + 'Invalid date format error ident should be "datetime"'; +is $@->message, __x( + 'Unknown date format "{format}"', + format => 'nonesuch', +), 'Invalid date format error message should be correct'; + +throws_ok { $CLASS->configure($config, { date_format => 'non'}), {} } + 'App::Sqitch::X', + 'Should get error for invalid date format in optsions'; +is $@->ident, 'datetime', + 'Invalid date format error ident should be "log"'; +is $@->message, __x( + 'Unknown date format "{format}"', + format => 'non', +), 'Invalid date format error message should be correct'; + +# Test format validation. +$config = TestConfig->new('log.format' => 'nonesuch'); +throws_ok { $CLASS->configure($config, {}), {} } 'App::Sqitch::X', + 'Should get error for invalid format in config'; +is $@->ident, 'log', + 'Invalid format error ident should be "log"'; +is $@->message, __x( + 'Unknown log format "{format}"', + format => 'nonesuch', +), 'Invalid format error message should be correct'; + +throws_ok { $CLASS->configure($config, { format => 'non'}), {} } + 'App::Sqitch::X', + 'Should get error for invalid format in optsions'; +is $@->ident, 'log', + 'Invalid format error ident should be "log"'; +is $@->message, __x( + 'Unknown log format "{format}"', + format => 'non', +), 'Invalid format error message should be correct'; + +# Test color configuration. +$config = TestConfig->new; +$configured = $CLASS->configure( $config, { no_color => 1 } ); +is $configured->{formatter}->color, 'never', + 'Configuration should respect --no-color, setting "never"'; + +# Test oneline configuration. +$configured = $CLASS->configure( $config, { oneline => 1 }); +is $configured->{format}, '%{:event}C%h %l%{reset}C %o:%n %s', + '--oneline should set format'; +is $configured->{formatter}{abbrev}, 6, '--oneline should set abbrev to 6'; + +$configured = $CLASS->configure( $config, { oneline => 1, format => 'format:foo', abbrev => 5 }); +is $configured->{format}, 'foo', '--oneline should not override --format'; +is $configured->{formatter}{abbrev}, 5, '--oneline should not overrride --abbrev'; + +$config->update('log.color' => 'auto'); +$configured = $CLASS->configure( $config, { no_color => 1 } ); + +is $configured->{formatter}->color, 'never', + 'Configuration should respect --no-color even when configure is set'; + +NEVER: { + my $configured = $CLASS->configure( $config, { color => 'never' } ); + is $configured->{formatter}->color, 'never', + 'Configuration should respect color option'; + + # Try it with config. + $config->update('log.color' => 'never'); + $configured = $CLASS->configure( $config, {} ); + is $configured->{formatter}->color, 'never', + 'Configuration should respect color config'; +} + +ALWAYS: { + my $configured = $CLASS->configure( $config, { color => 'always' } ); + is_deeply $configured->{formatter}->color, 'always', + 'Configuration should respect color option'; + + # Try it with config. + $config->update('log.color' => 'always'); + $configured = $CLASS->configure( $config, {} ); + is_deeply $configured->{formatter}->color, 'always', + 'Configuration should respect color config'; +} + +AUTO: { + for my $enabled (0, 1) { + $config->update('log.color' => 'always'); + my $configured = $CLASS->configure( $config, { color => 'auto' } ); + is_deeply $configured->{formatter}->color, 'auto', + 'Configuration should respect color option'; + + # Try it with config. + $config->update('log.color' => 'auto'); + $configured = $CLASS->configure( $config, {} ); + is_deeply $configured->{formatter}->color, 'auto', + 'Configuration should respect color config'; + } +} + +############################################################################### +# Test named formats. +my $cdt = App::Sqitch::DateTime->now; +my $pdt = $cdt->clone->subtract(days => 1); +my $event = { + event => 'deploy', + project => 'logit', + change_id => '000011112222333444', + change => 'lolz', + tags => [ '@beta', '@gamma' ], + committer_name => 'larry', + committer_email => 'larry@example.com', + committed_at => $cdt, + planner_name => 'damian', + planner_email => 'damian@example.com', + planned_at => $pdt, + note => "For the LOLZ.\n\nYou know, funny stuff and cute kittens, right?", + requires => [qw(foo bar)], + conflicts => [] +}; + +my $ciso = $cdt->as_string( format => 'iso' ); +my $craw = $cdt->as_string( format => 'raw' ); +my $piso = $pdt->as_string( format => 'iso' ); +my $praw = $pdt->as_string( format => 'raw' ); +for my $spec ( + [ raw => "deploy 000011112222333444 (\@beta, \@gamma)\n" + . "name lolz\n" + . "project logit\n" + . "requires foo, bar\n" + . "planner damian <damian\@example.com>\n" + . "planned $praw\n" + . "committer larry <larry\@example.com>\n" + . "committed $craw\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ full => __('Deploy') . " 000011112222333444 (\@beta, \@gamma)\n" + . __('Name: ') . " lolz\n" + . __('Project: ') . " logit\n" + . __('Requires: ') . " foo, bar\n" + . __('Planner: ') . " damian <damian\@example.com>\n" + . __('Planned: ') . " __PDATE__\n" + . __('Committer:') . " larry <larry\@example.com>\n" + . __('Committed:') . " __CDATE__\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ long => __('Deploy') . " 000011112222333444 (\@beta, \@gamma)\n" + . __('Name: ') . " lolz\n" + . __('Project: ') . " logit\n" + . __('Planner: ') . " damian <damian\@example.com>\n" + . __('Committer:') . " larry <larry\@example.com>\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ medium => __('Deploy') . " 000011112222333444\n" + . __('Name: ') . " lolz\n" + . __('Committer:') . " larry <larry\@example.com>\n" + . __('Date: ') . " __CDATE__\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ short => __('Deploy') . " 000011112222333444\n" + . __('Name: ') . " lolz\n" + . __('Committer:') . " larry <larry\@example.com>\n\n" + . " For the LOLZ.\n", + ], + [ oneline => '000011112222333444 ' . __('deploy') . ' logit:lolz For the LOLZ.' ], +) { + local $ENV{ANSI_COLORS_DISABLED} = 1; + my $configured = $CLASS->configure( $config, { format => $spec->[0] } ); + my $format = $configured->{format}; + ok my $log = $CLASS->new( sqitch => $sqitch, %{ $configured } ), + qq{Instantiate with format "$spec->[0]"}; + (my $exp = $spec->[1]) =~ s/__CDATE__/$ciso/; + $exp =~ s/__PDATE__/$piso/; + is $log->formatter->format( $log->format, $event ), $exp, + qq{Format "$spec->[0]" should output correctly}; + + if ($spec->[1] =~ /__CDATE__/) { + # Test different date formats. + for my $date_format (qw(rfc long medium)) { + ok my $log = $CLASS->new( + sqitch => $sqitch, + format => $format, + formatter => App::Sqitch::ItemFormatter->new(date_format => $date_format), + ), qq{Instantiate with format "$spec->[0]" and date format "$date_format"}; + my $date = $cdt->as_string( format => $date_format ); + (my $exp = $spec->[1]) =~ s/__CDATE__/$date/; + $date = $pdt->as_string( format => $date_format ); + $exp =~ s/__PDATE__/$date/; + is $log->formatter->format( $log->format, $event ), $exp, + qq{Format "$spec->[0]" and date format "$date_format" should output correctly}; + } + } + + if ($spec->[1] =~ s/\s+[(]?[@]beta,\s+[@]gamma[)]?//) { + # Test without tags. + local $event->{tags} = []; + (my $exp = $spec->[1]) =~ s/__CDATE__/$ciso/; + $exp =~ s/__PDATE__/$piso/; + is $log->formatter->format( $log->format, $event ), $exp, + qq{Format "$spec->[0]" should output correctly without tags}; + } +} + +############################################################################### +# Test all formatting characters. +my $local_cdt = $cdt->clone; +$local_cdt->set_time_zone('local'); +$local_cdt->set_locale($LC::TIME); +my $local_pdt = $pdt->clone; +$local_pdt->set_time_zone('local'); +$local_pdt->set_locale($LC::TIME); + +my $formatter = $log->formatter; +for my $spec ( + ['%e', { event => 'deploy' }, 'deploy' ], + ['%e', { event => 'revert' }, 'revert' ], + ['%e', { event => 'fail' }, 'fail' ], + + ['%L', { event => 'deploy' }, __ 'Deploy' ], + ['%L', { event => 'revert' }, __ 'Revert' ], + ['%L', { event => 'fail' }, __ 'Fail' ], + + ['%l', { event => 'deploy' }, __ 'deploy' ], + ['%l', { event => 'revert' }, __ 'revert' ], + ['%l', { event => 'fail' }, __ 'fail' ], + + ['%{event}_', {}, __ 'Event: ' ], + ['%{change}_', {}, __ 'Change: ' ], + ['%{committer}_', {}, __ 'Committer:' ], + ['%{planner}_', {}, __ 'Planner: ' ], + ['%{by}_', {}, __ 'By: ' ], + ['%{date}_', {}, __ 'Date: ' ], + ['%{committed}_', {}, __ 'Committed:' ], + ['%{planned}_', {}, __ 'Planned: ' ], + ['%{name}_', {}, __ 'Name: ' ], + ['%{email}_', {}, __ 'Email: ' ], + ['%{requires}_', {}, __ 'Requires: ' ], + ['%{conflicts}_', {}, __ 'Conflicts:' ], + + ['%H', { change_id => '123456789' }, '123456789' ], + ['%h', { change_id => '123456789' }, '123456789' ], + ['%{5}h', { change_id => '123456789' }, '12345' ], + ['%{7}h', { change_id => '123456789' }, '1234567' ], + + ['%n', { change => 'foo' }, 'foo'], + ['%n', { change => 'bar' }, 'bar'], + ['%o', { project => 'foo' }, 'foo'], + ['%o', { project => 'bar' }, 'bar'], + + ['%c', { committer_name => 'larry', committer_email => 'larry@example.com' }, 'larry <larry@example.com>'], + ['%{n}c', { committer_name => 'damian' }, 'damian'], + ['%{name}c', { committer_name => 'chip' }, 'chip'], + ['%{e}c', { committer_email => 'larry@example.com' }, 'larry@example.com'], + ['%{email}c', { committer_email => 'damian@example.com' }, 'damian@example.com'], + + ['%{date}c', { committed_at => $cdt }, $cdt->as_string( format => 'iso' ) ], + ['%{date:rfc}c', { committed_at => $cdt }, $cdt->as_string( format => 'rfc' ) ], + ['%{d:long}c', { committed_at => $cdt }, $cdt->as_string( format => 'long' ) ], + ["%{d:cldr:HH'h' mm'm'}c", { committed_at => $cdt }, $local_cdt->format_cldr( q{HH'h' mm'm'} ) ], + ["%{d:strftime:%a at %H:%M:%S}c", { committed_at => $cdt }, $local_cdt->strftime('%a at %H:%M:%S') ], + + ['%p', { planner_name => 'larry', planner_email => 'larry@example.com' }, 'larry <larry@example.com>'], + ['%{n}p', { planner_name => 'damian' }, 'damian'], + ['%{name}p', { planner_name => 'chip' }, 'chip'], + ['%{e}p', { planner_email => 'larry@example.com' }, 'larry@example.com'], + ['%{email}p', { planner_email => 'damian@example.com' }, 'damian@example.com'], + + ['%{date}p', { planned_at => $pdt }, $pdt->as_string( format => 'iso' ) ], + ['%{date:rfc}p', { planned_at => $pdt }, $pdt->as_string( format => 'rfc' ) ], + ['%{d:long}p', { planned_at => $pdt }, $pdt->as_string( format => 'long' ) ], + ["%{d:cldr:HH'h' mm'm'}p", { planned_at => $pdt }, $local_pdt->format_cldr( q{HH'h' mm'm'} ) ], + ["%{d:strftime:%a at %H:%M:%S}p", { planned_at => $pdt }, $local_pdt->strftime('%a at %H:%M:%S') ], + + ['%t', { tags => [] }, '' ], + ['%t', { tags => ['@foo'] }, ' @foo' ], + ['%t', { tags => ['@foo', '@bar'] }, ' @foo, @bar' ], + ['%{|}t', { tags => [] }, '' ], + ['%{|}t', { tags => ['@foo'] }, ' @foo' ], + ['%{|}t', { tags => ['@foo', '@bar'] }, ' @foo|@bar' ], + + ['%T', { tags => [] }, '' ], + ['%T', { tags => ['@foo'] }, ' (@foo)' ], + ['%T', { tags => ['@foo', '@bar'] }, ' (@foo, @bar)' ], + ['%{|}T', { tags => [] }, '' ], + ['%{|}T', { tags => ['@foo'] }, ' (@foo)' ], + ['%{|}T', { tags => ['@foo', '@bar'] }, ' (@foo|@bar)' ], + + ['%r', { requires => [] }, '' ], + ['%r', { requires => ['foo'] }, ' foo' ], + ['%r', { requires => ['foo', 'bar'] }, ' foo, bar' ], + ['%{|}r', { requires => [] }, '' ], + ['%{|}r', { requires => ['foo'] }, ' foo' ], + ['%{|}r', { requires => ['foo', 'bar'] }, ' foo|bar' ], + + ['%R', { requires => [] }, '' ], + ['%R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ], + ['%R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo, bar\n" ], + ['%{|}R', { requires => [] }, '' ], + ['%{|}R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ], + ['%{|}R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo|bar\n" ], + + ['%x', { conflicts => [] }, '' ], + ['%x', { conflicts => ['foo'] }, ' foo' ], + ['%x', { conflicts => ['foo', 'bax'] }, ' foo, bax' ], + ['%{|}x', { conflicts => [] }, '' ], + ['%{|}x', { conflicts => ['foo'] }, ' foo' ], + ['%{|}x', { conflicts => ['foo', 'bax'] }, ' foo|bax' ], + + ['%X', { conflicts => [] }, '' ], + ['%X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ], + ['%X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo, bar\n" ], + ['%{|}X', { conflicts => [] }, '' ], + ['%{|}X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ], + ['%{|}X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo|bar\n" ], + + ['%{yellow}C', {}, '' ], + ['%{:event}C', { event => 'deploy' }, '' ], + ['%v', {}, "\n" ], + ['%%', {}, '%' ], + + ['%s', { note => 'hi there' }, 'hi there' ], + ['%s', { note => "hi there\nyo" }, 'hi there' ], + ['%s', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, 'subject line' ], + ['%{ }s', { note => 'hi there' }, ' hi there' ], + ['%{xx}s', { note => 'hi there' }, 'xxhi there' ], + + ['%b', { note => 'hi there' }, '' ], + ['%b', { note => "hi there\nyo" }, 'yo' ], + ['%b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "first graph\n\nsecond graph\n\n" ], + ['%{ }b', { note => 'hi there' }, '' ], + ['%{xxx }b', { note => "hi there\nyo" }, "xxx yo" ], + ['%{x}b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xfirst graph\nx\nxsecond graph\nx\n" ], + ['%{ }b', { note => "hi there\r\nyo" }, " yo" ], + + ['%B', { note => 'hi there' }, 'hi there' ], + ['%B', { note => "hi there\nyo" }, "hi there\nyo" ], + ['%B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "subject line\n\nfirst graph\n\nsecond graph\n\n" ], + ['%{ }B', { note => 'hi there' }, ' hi there' ], + ['%{xxx }B', { note => "hi there\nyo" }, "xxx hi there\nxxx yo" ], + ['%{x}B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xsubject line\nx\nxfirst graph\nx\nxsecond graph\nx\n" ], + ['%{ }B', { note => "hi there\r\nyo" }, " hi there\r\n yo" ], + + ['%{change}a', $event, "change $event->{change}\n" ], + ['%{change_id}a', $event, "change_id $event->{change_id}\n" ], + ['%{event}a', $event, "event $event->{event}\n" ], + ['%{tags}a', $event, 'tags ' . join(', ', @{ $event->{tags} }) . "\n" ], + ['%{requires}a', $event, 'requires ' . join(', ', @{ $event->{requires} }) . "\n" ], + ['%{conflicts}a', $event, '' ], + ['%{committer_name}a', $event, "committer_name $event->{committer_name}\n" ], + ['%{committed_at}a', $event, "committed_at $craw\n" ], +) { + local $ENV{ANSI_COLORS_DISABLED} = 1; + (my $desc = encode_utf8 $spec->[2]) =~ s/\n/[newline]/g; + is $formatter->format( $spec->[0], $spec->[1] ), $spec->[2], + qq{Format "$spec->[0]" should output "$desc"}; +} + +throws_ok { $formatter->format( '%_', {} ) } 'App::Sqitch::X', + 'Should get exception for format "%_"'; +is $@->ident, 'format', '%_ error ident should be "format"'; +is $@->message, __ 'No label passed to the _ format', + '%_ error message should be correct'; +throws_ok { $formatter->format( '%{foo}_', {} ) } 'App::Sqitch::X', + 'Should get exception for unknown label in format "%_"'; +is $@->ident, 'format', 'Invalid %_ label error ident should be "format"'; +is $@->message, __x( + 'Unknown label "{label}" passed to the _ format', + label => 'foo' +), 'Invalid %_ label error message should be correct'; + +ok $log = $CLASS->new( + sqitch => $sqitch, + formatter => App::Sqitch::ItemFormatter->new(abbrev => 4) +), 'Instantiate with abbrev => 4'; +is $log->formatter->format( '%h', { change_id => '123456789' } ), + '1234', '%h should respect abbrev'; +is $log->formatter->format( '%H', { change_id => '123456789' } ), + '123456789', '%H should not respect abbrev'; + +ok $log = $CLASS->new( + sqitch => $sqitch, + formatter => App::Sqitch::ItemFormatter->new(date_format => 'rfc') +), 'Instantiate with date_format => "rfc"'; +is $log->formatter->format( '%{date}c', { committed_at => $cdt } ), + $cdt->as_string( format => 'rfc' ), + '%{date}c should respect the date_format attribute'; +is $log->formatter->format( '%{d:iso}c', { committed_at => $cdt } ), + $cdt->as_string( format => 'iso' ), + '%{iso}c should override the date_format attribute'; + +throws_ok { $formatter->format( '%{foo}a', {}) } 'App::Sqitch::X', + 'Should get exception for unknown attribute passed to %a'; +is $@->ident, 'format', '%a error ident should be "format"'; +is $@->message, __x( + '{attr} is not a valid change attribute', attr => 'foo' +), '%a error message should be correct'; + + +delete $ENV{ANSI_COLORS_DISABLED}; +for my $color (qw(yellow red blue cyan magenta)) { + is $formatter->format( "%{$color}C", {} ), color($color), + qq{Format "%{$color}C" should output } + . color($color) . $color . color('reset'); +} + +for my $spec ( + [ ':event', { event => 'deploy' }, 'green', 'deploy' ], + [ ':event', { event => 'revert' }, 'blue', 'revert' ], + [ ':event', { event => 'fail' }, 'red', 'fail' ], +) { + is $formatter->format( "%{$spec->[0]}C", $spec->[1] ), color($spec->[2]), + qq{Format "%{$spec->[0]}C" on "$spec->[3]" should output } + . color($spec->[2]) . $spec->[2] . color('reset'); +} + +# Make sure other colors work. +my $yellow = color('yellow') . '%s' . color('reset'); +my $green = color('green') . '%s' . color('reset'); +$event->{conflicts} = [qw(dr_evil)]; +for my $spec ( + [ full => sprintf($green, __ ('Deploy') . ' 000011112222333444') + . " (\@beta, \@gamma)\n" + . __ ('Name: ') . " lolz\n" + . __ ('Project: ') . " logit\n" + . __ ('Requires: ') . " foo, bar\n" + . __ ('Conflicts:') . " dr_evil\n" + . __ ('Planner: ') . " damian <damian\@example.com>\n" + . __ ('Planned: ') . " __PDATE__\n" + . __ ('Committer:') . " larry <larry\@example.com>\n" + . __ ('Committed:') . " __CDATE__\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ long => sprintf($green, __ ('Deploy') . ' 000011112222333444') + . " (\@beta, \@gamma)\n" + . __ ('Name: ') . " lolz\n" + . __ ('Project: ') . " logit\n" + . __ ('Planner: ') . " damian <damian\@example.com>\n" + . __ ('Committer:') . " larry <larry\@example.com>\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ medium => sprintf($green, __ ('Deploy') . ' 000011112222333444') . "\n" + . __ ('Name: ') . " lolz\n" + . __ ('Committer:') . " larry <larry\@example.com>\n" + . __ ('Date: ') . " __CDATE__\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ short => sprintf($green, __ ('Deploy') . ' 000011112222333444') . "\n" + . __ ('Name: ') . " lolz\n" + . __ ('Committer:') . " larry <larry\@example.com>\n\n" + . " For the LOLZ.\n", + ], + [ oneline => sprintf "$green %s %s", '000011112222333444' . ' ' + . __('deploy'), 'logit:lolz', 'For the LOLZ.', + ], +) { + my $format = $CLASS->configure( $config, { format => $spec->[0] } )->{format}; + ok my $log = $CLASS->new( sqitch => $sqitch, format => $format ), + qq{Instantiate with format "$spec->[0]" again}; + (my $exp = $spec->[1]) =~ s/__CDATE__/$ciso/; + $exp =~ s/__PDATE__/$piso/; + is $log->formatter->format( $log->format, $event ), $exp, + qq{Format "$spec->[0]" should output correctly with color}; +} + +throws_ok { $formatter->format( '%{BLUELOLZ}C', {} ) } 'App::Sqitch::X', + 'Should get an error for an invalid color'; +is $@->ident, 'format', 'Invalid color error ident should be "format"'; +is $@->message, __x( + '{color} is not a valid ANSI color', color => 'BLUELOLZ' +), 'Invalid color error message should be correct'; + +############################################################################## +# Test execute(). +my $emock = Test::MockModule->new('App::Sqitch::Engine::sqlite'); +$emock->mock(destination => 'flipr'); + +my $mock_target = Test::MockModule->new('App::Sqitch::Target'); +my ($target_name_arg, $orig_meth); +$target_name_arg = '_blah'; +$mock_target->mock(new => sub { + my $self = shift; + my %p = @_; + $target_name_arg = $p{name}; + $self->$orig_meth(@_); +}); +$orig_meth = $mock_target->original('new'); + +# First test for uninitialized DB. +my $init = 0; +$emock->mock(initialized => sub { $init }); +throws_ok { $log->execute } 'App::Sqitch::X', + 'Should get exception for unititialied db'; +is $@->ident, 'log', 'Uninit db error ident should be "log"'; +is $@->exitval, 1, 'Uninit db exit val should be 1'; +is $@->message, __x( + 'Database {db} has not been initialized for Sqitch', + db => 'db:sqlite:', +), 'Uninit db error message should be correct'; +is $target_name_arg, undef, 'Should have passed undef to Target'; + +# Next, test for no events. +$init = 1; +$target_name_arg = '_blah'; +my @events; +my $iter = sub { shift @events }; +my $search_args; +$emock->mock(search_events => sub { + shift; + $search_args = [@_]; + return $iter; +}); +$log = $CLASS->new(sqitch => $sqitch); +throws_ok { $log->execute } 'App::Sqitch::X', + 'Should get error for empty event table'; +is $@->ident, 'log', 'no events error ident should be "log"'; +is $@->exitval, 1, 'no events exit val should be 1'; +is $@->message, __x( + 'No events logged for {db}', + db => 'flipr', +), 'no events error message should be correct'; +is_deeply $search_args, [limit => 1], + 'Search should have been limited to one row'; +is $target_name_arg, undef, 'Should have passed undef to Target again'; + +# Okay, let's add some events. +push @events => {}, $event; +$target_name_arg = '_blah'; +$log = $CLASS->new(sqitch => $sqitch); +ok $log->execute, 'Execute log'; +is $target_name_arg, undef, 'Should have passed undef to Target once more'; +is_deeply $search_args, [ + event => undef, + change => undef, + project => undef, + committer => undef, + limit => undef, + offset => undef, + direction => 'DESC' +], 'The proper args should have been passed to search_events'; + +is_deeply +MockOutput->get_page, [ + [__x 'On database {db}', db => 'flipr'], + [ $log->formatter->format( $log->format, $event ) ], +], 'The change should have been paged'; + +# Make sure a passed target is processed. +push @events => {}, $event; +$target_name_arg = '_blah'; +ok $log->execute('db:sqlite:whatever.db'), 'Execute with target arg'; +is $target_name_arg, 'db:sqlite:whatever.db', + 'Target name should have been passed to Target'; +is_deeply $search_args, [ + event => undef, + change => undef, + project => undef, + committer => undef, + limit => undef, + offset => undef, + direction => 'DESC' +], 'The proper args should have been passed to search_events'; + +is_deeply +MockOutput->get_page, [ + [__x 'On database {db}', db => 'flipr'], + [ $log->formatter->format( $log->format, $event ) ], +], 'The change should have been paged'; + +# Make sure we can pass a plan file. +push @events => {}, $event; +$target_name_arg = '_blah'; +ok $log->execute($plan_file), 'Execute with plan file arg'; +is $target_name_arg, 'db:sqlite:', + 'Default engine target should have been passed to Target'; +is_deeply $search_args, [ + event => undef, + change => undef, + project => undef, + committer => undef, + limit => undef, + offset => undef, + direction => 'DESC' +], 'The proper args should have been passed to search_events'; + +is_deeply +MockOutput->get_page, [ + [__x 'On database {db}', db => 'flipr'], + [ $log->formatter->format( $log->format, $event ) ], +], 'The change should have been paged'; + +# Set attributes and add more events. +my $event2 = { + event => 'revert', + change_id => '84584584359345', + change => 'barf', + tags => [], + committer_name => 'theory', + committer_email => 'theory@example.com', + committed_at => $cdt, + note => 'Oh man this was a bad idea', +}; +push @events => {}, $event, $event2; +isa_ok $log = $CLASS->new( + sqitch => $sqitch, + target => 'db:sqlite:foo.db', + event => [qw(revert fail)], + change_pattern => '.+', + project_pattern => '.+', + committer_pattern => '.+', + max_count => 10, + skip => 5, + reverse => 1, + headers => 0, +), $CLASS, 'log with attributes'; + +$target_name_arg = '_blah'; +ok $log->execute, 'Execute log with attributes'; +is $target_name_arg, $log->target, 'Should have passed target name to Target'; +is_deeply $search_args, [ + event => [qw(revert fail)], + change => '.+', + project => '.+', + committer => '.+', + limit => 10, + offset => 5, + direction => 'ASC' +], 'All params should have been passed to search_events'; + +is_deeply +MockOutput->get_page, [ + [ $log->formatter->format( $log->format, $event ) ], + [ $log->formatter->format( $log->format, $event2 ) ], +], 'Both changes should have been paged with no headers'; + +# Make sure we get a warning when both the option and the arg are specified. +push @events => {}, $event; +ok $log->execute('pg'), 'Execute log with attributes'; +is $target_name_arg, 'db:pg:', 'Should have passed enginetarget to Target'; +is_deeply +MockOutput->get_warn, [[__x( + 'Too many targets specified; connecting to {target}', + target => $log->target, +)]], 'Should have got warning for two targets'; + +# Make sure we catch bad format codes. +isa_ok $log = $CLASS->new( + sqitch => $sqitch, + format => '%Z', +), $CLASS, 'log with bad format'; + +push @events, {}, $event; +$target_name_arg = '_blah'; +throws_ok { $log->execute } 'App::Sqitch::X', + 'Should get an exception for a bad format code'; +is $@->ident, 'format', + 'bad format code format error ident should be "format"'; +is $@->message, __x( + 'Unknown format code "{code}"', code => 'Z', +), 'bad format code format error message should be correct'; +is $target_name_arg, $log->target, 'Should have passed target name to Target'; diff --git a/t/mooseless.t b/t/mooseless.t new file mode 100644 index 00000000..d09fd431 --- /dev/null +++ b/t/mooseless.t @@ -0,0 +1,31 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; + +use Test::More; +use File::Find qw(find); +use Module::Runtime qw(use_module); + +my $test = sub { + return unless $_ =~ /\.pm$/; + + my $module = $File::Find::name; + $module =~ s!^(blib[/\\])?lib[/\\]!!; + $module =~ s![/\\]!::!g; + $module =~ s/\.pm$//; + + eval { use_module $module; }; + if ($@) { + diag "Couldn't load $module: $@"; + undef $@; + return; + } + + ok ! $INC{'Moose.pm'}, "No moose in $module"; +}; + +find($test, 'lib'); + +done_testing(); diff --git a/t/multiplan.conf b/t/multiplan.conf new file mode 100644 index 00000000..ae8261d6 --- /dev/null +++ b/t/multiplan.conf @@ -0,0 +1,13 @@ +[core] + engine = pg + +[engine "pg"] + top_dir = engine + reworked_dir = engine/reworked + +[engine "sqlite"] + top_dir = engine + reworked_dir = engine/reworked + +[engine "mysql"] + top_dir = sql diff --git a/t/mysql.t b/t/mysql.t new file mode 100644 index 00000000..b5e974f8 --- /dev/null +++ b/t/mysql.t @@ -0,0 +1,521 @@ +#!/usr/bin/perl -w + +# To test against a live MySQL database, you must set the MYSQL_URI environment variable. +# this is a stanard URI::db URI, and should look something like this: +# +# export MYSQL_URI=db:mysql://root:password@localhost:3306/information_schema +# + +use strict; +use warnings; +use 5.010; +use Test::More; +use App::Sqitch; +use App::Sqitch::Target; +use Test::MockModule; +use Path::Class; +use Try::Tiny; +use Test::Exception; +use Locale::TextDomain qw(App-Sqitch); +use File::Temp 'tempdir'; +use lib 't/lib'; +use DBIEngineTest; +use TestConfig; + +my $CLASS; + +my $mm = eval { Test::MockModule->new('MySQL::Config') }; +$mm->mock(parse_defaults => {}) if $mm; + +BEGIN { + $CLASS = 'App::Sqitch::Engine::mysql'; + require_ok $CLASS or die; + delete $ENV{$_} for qw(MYSQL_PWD MYSQL_HOST MYSQL_TCP_PORT); +} + +is_deeply [$CLASS->config_vars], [ + target => 'any', + registry => 'any', + client => 'any', +], 'config_vars should return three vars'; + +my $config = TestConfig->new('core.engine' => 'mysql'); +my $sqitch = App::Sqitch->new(config => $config); +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +isa_ok my $mysql = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS; + +is $mysql->key, 'mysql', 'Key should be "mysql"'; +is $mysql->name, 'MySQL', 'Name should be "MySQL"'; + +my $client = 'mysql' . (App::Sqitch::ISWIN ? '.exe' : ''); +my $uri = URI::db->new('db:mysql:'); +is $mysql->client, $client, 'client should default to mysql'; +is $mysql->registry, 'sqitch', 'registry default should be "sqitch"'; +my $sqitch_uri = $uri->clone; +$sqitch_uri->dbname('sqitch'); +is $mysql->registry_uri, $sqitch_uri, 'registry_uri should be correct'; +is $mysql->uri, $uri, qq{uri should be "$uri"}; +is $mysql->registry_destination, 'db:mysql:sqitch', + 'registry_destination should be the same as registry_uri'; + +my @std_opts = ( + (App::Sqitch::ISWIN ? () : '--skip-pager' ), + '--silent', + '--skip-column-names', + '--skip-line-numbers', +); +my $vinfo = try { $sqitch->probe($mysql->client, '--version') } || ''; +if ($vinfo =~ /mariadb/i) { + my ($version) = $vinfo =~ /Ver\s(\S+)/; + my ($maj, undef, $pat) = split /[.]/ => $version; + push @std_opts => '--abort-source-on-error' + if $maj > 5 || ($maj == 5 && $pat >= 66); +} + +my $mock_sqitch = Test::MockModule->new('App::Sqitch'); +my $warning; +$mock_sqitch->mock(warn => sub { shift; $warning = [@_] }); +is_deeply [$mysql->mysql], [$client, '--user', $sqitch->sysuser, @std_opts], + 'mysql command should be user and std opts-only'; +is_deeply $warning, [__x + 'Database name missing in URI "{uri}"', + uri => $mysql->uri +], 'Should have emitted a warning for no database name'; +$mock_sqitch->unmock_all; + +$target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI::db->new('db:mysql:foo'), +); +isa_ok $mysql = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS; + +############################################################################## +# Make sure environment variables are read. +ENV: { + local $ENV{MYSQL_PWD} = '__KAMALA'; + local $ENV{MYSQL_HOST} = 'sqitch.sql'; + local $ENV{MYSQL_TCP_PORT} = 11238; + ok my $mysql = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create engine with MYSQL_PWD set'; + is $mysql->password, $ENV{MYSQL_PWD}, + 'Password should be set from environment'; + is $mysql->uri->host, $ENV{MYSQL_HOST}, 'URI should reflect MYSQL_HOST'; + is $mysql->uri->port, $ENV{MYSQL_TCP_PORT}, 'URI should reflect MYSQL_TCP_PORT'; +} + +############################################################################## +# Make sure config settings override defaults. +$config->update( + 'engine.mysql.client' => '/path/to/mysql', + 'engine.mysql.target' => 'db:mysql://foo.com/widgets', + 'engine.mysql.registry' => 'meta', +); +my $mysql_version = 'mysql Ver 15.1 Distrib 10.0.15-MariaDB'; +$mock_sqitch->mock(probe => sub { $mysql_version }); +push @std_opts => '--abort-source-on-error' + unless $std_opts[-1] eq '--abort-source-on-error'; + +$target = App::Sqitch::Target->new(sqitch => $sqitch); +ok $mysql = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create another mysql'; +is $mysql->client, '/path/to/mysql', 'client should be as configured'; +is $mysql->uri->as_string, 'db:mysql://foo.com/widgets', + 'URI should be as configured'; +is $mysql->target->name, $mysql->uri->as_string, 'target name should be the URI'; +is $mysql->destination, $mysql->uri->as_string, 'destination should be the URI'; +is $mysql->registry, 'meta', 'registry should be as configured'; +is $mysql->registry_uri->as_string, 'db:mysql://foo.com/meta', + 'Sqitch DB URI should be the same as uri but with DB name "meta"'; +is $mysql->registry_destination, $mysql->registry_uri->as_string, + 'registry_destination should be the sqitch DB URL'; +is_deeply [$mysql->mysql], [ + '/path/to/mysql', + '--user', $sqitch->sysuser, + '--database', 'widgets', + '--host', 'foo.com', + @std_opts +], 'mysql command should be configured'; + +############################################################################## +# Make sure URI params get passed through to the client. +$target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI->new('db:mysql://foo.com/widgets?' . join( + '&', + 'mysql_compression=1', + 'mysql_ssl=1', + 'mysql_connect_timeout=20', + 'mysql_init_command=BEGIN', + 'mysql_socket=/dev/null', + 'mysql_ssl_client_key=/foo/key', + 'mysql_ssl_client_cert=/foo/cert', + 'mysql_ssl_ca_file=/foo/cafile', + 'mysql_ssl_ca_path=/foo/capath', + 'mysql_ssl_cipher=blowfeld', + 'mysql_client_found_rows=20', + 'mysql_foo=bar', + ), +)); +ok $mysql = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create a mysql with query params'; +is_deeply [$mysql->mysql], [qw( + /path/to/mysql +), '--user', $sqitch->sysuser, qw( + --database widgets + --host foo.com +), @std_opts, qw( + --compress + --ssl + --connect_timeout 20 + --init-command BEGIN + --socket /dev/null + --ssl-key /foo/key + --ssl-cert /foo/cert + --ssl-ca /foo/cafile + --ssl-capath /foo/capath + --ssl-cipher blowfeld +)], 'mysql command should be configured with query vals'; + +$target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI->new('db:mysql://foo.com/widgets?' . join( + '&', + 'mysql_compression=0', + 'mysql_ssl=0', + 'mysql_connect_timeout=20', + 'mysql_client_found_rows=20', + 'mysql_foo=bar', + ), +)); +ok $mysql = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create a mysql with disabled query params'; +is_deeply [$mysql->mysql], [qw( + /path/to/mysql +), '--user', $sqitch->sysuser, qw( + --database widgets + --host foo.com +), @std_opts, qw( + --connect_timeout 20 +)], 'mysql command should not have disabled param options'; + +############################################################################## +# Test _run(), _capture(), and _spool(). +can_ok $mysql, qw(_run _capture _spool); +my (@run, $exp_pass); +$mock_sqitch->mock(run => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @run = @_; + if (defined $exp_pass) { + is $ENV{MYSQL_PWD}, $exp_pass, qq{MYSQL_PWD should be "$exp_pass"}; + } else { + ok !exists $ENV{MYSQL_PWD}, 'MYSQL_PWD should not exist'; + } +}); + +my @capture; +$mock_sqitch->mock(capture => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @capture = @_; + if (defined $exp_pass) { + is $ENV{MYSQL_PWD}, $exp_pass, qq{MYSQL_PWD should be "$exp_pass"}; + } else { + ok !exists $ENV{MYSQL_PWD}, 'MYSQL_PWD should not exist'; + } +}); + +my @spool; +$mock_sqitch->mock(spool => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @spool = @_; + if (defined $exp_pass) { + is $ENV{MYSQL_PWD}, $exp_pass, qq{MYSQL_PWD should be "$exp_pass"}; + } else { + ok !exists $ENV{MYSQL_PWD}, 'MYSQL_PWD should not exist'; + } +}); + +$target = App::Sqitch::Target->new(sqitch => $sqitch); +ok $mysql = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create a mysql with sqitch with options'; +$exp_pass = 's3cr3t'; +$target->uri->password($exp_pass); +ok $mysql->_run(qw(foo bar baz)), 'Call _run'; +is_deeply \@run, [$mysql->mysql, qw(foo bar baz)], + 'Command should be passed to run()'; + +ok $mysql->_spool('FH'), 'Call _spool'; +is_deeply \@spool, [['FH'], $mysql->mysql], + 'Command should be passed to spool()'; +$mysql->set_variables(foo => 'bar', '"that"' => "'this'"); +ok $mysql->_spool('FH'), 'Call _spool with variables'; +ok my $fh = shift @{ $spool[0] }, 'Get variables file handle'; +is_deeply \@spool, [['FH'], $mysql->mysql], + 'Command should be passed to spool() after variables handle'; +is join("\n", <$fh>), qq{SET \@"""that""" = '''this''', \@"foo" = 'bar';\n}, + 'Variables should have been escaped and set'; +$mysql->clear_variables; + +ok $mysql->_capture(qw(foo bar baz)), 'Call _capture'; +is_deeply \@capture, [$mysql->mysql, qw(foo bar baz)], + 'Command should be passed to capture()'; + +# Without password. +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +ok $mysql = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create a mysql with sqitch with no pw'; +$exp_pass = undef; +$target->uri->password($exp_pass); +ok $mysql->_run(qw(foo bar baz)), 'Call _run again'; +is_deeply \@run, [$mysql->mysql, qw(foo bar baz)], + 'Command should be passed to run() again'; + +ok $mysql->_spool('FH'), 'Call _spool again'; +is_deeply \@spool, [['FH'], $mysql->mysql], + 'Command should be passed to spool() again'; + +ok $mysql->_capture(qw(foo bar baz)), 'Call _capture again'; +is_deeply \@capture, [$mysql->mysql, qw(foo bar baz)], + 'Command should be passed to capture() again'; + +############################################################################## +# Test file and handle running. +ok $mysql->run_file('foo/bar.sql'), 'Run foo/bar.sql'; +is_deeply \@run, [$mysql->mysql, '--execute', 'source foo/bar.sql'], + 'File should be passed to run()'; +@run = (); + +ok $mysql->run_handle('FH'), 'Spool a "file handle"'; +is_deeply \@spool, [['FH'], $mysql->mysql], + 'Handle should be passed to spool()'; +@spool = (); + +# Verify should go to capture unless verosity is > 1. +ok $mysql->run_verify('foo/bar.sql'), 'Verify foo/bar.sql'; +is_deeply \@capture, [$mysql->mysql, '--execute', 'source foo/bar.sql'], + 'Verify file should be passed to capture()'; +@capture = (); + +$mock_sqitch->mock(verbosity => 2); +ok $mysql->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again'; +is_deeply \@run, [$mysql->mysql, '--execute', 'source foo/bar.sql'], + 'Verifile file should be passed to run() for high verbosity'; +@run = (); + +# Try with variables. +$mysql->set_variables(foo => 'bar', '"that"' => "'this'"); +my $set = qq{SET \@"""that""" = '''this''', \@"foo" = 'bar';\n}; + +ok $mysql->run_file('foo/bar.sql'), 'Run foo/bar.sql with vars'; +is_deeply \@run, [$mysql->mysql, '--execute', "${set}source foo/bar.sql"], + 'Variabls and file should be passed to run()'; +@run = (); + +ok $mysql->run_handle('FH'), 'Spool a "file handle"'; +ok $fh = shift @{ $spool[0] }, 'Get variables file handle'; +is_deeply \@spool, [['FH'], $mysql->mysql], + 'File handle should be passed to spool() after variables handle'; +is join("\n", <$fh>), $set, 'Variables should have been escaped and set'; +@spool = (); + +ok $mysql->run_verify('foo/bar.sql'), 'Verbosely verify foo/bar.sql with vars'; +is_deeply \@run, [$mysql->mysql, '--execute', "${set}source foo/bar.sql"], + 'Variables and verify file should be passed to run()'; +@run = (); + +# Reset verbosity to send verify to spool. +$mock_sqitch->unmock('verbosity'); +ok $mysql->run_verify('foo/bar.sql'), 'Verify foo/bar.sql with vars'; +is_deeply \@capture, [$mysql->mysql, '--execute', "${set}source foo/bar.sql"], + 'Verify file should be passed to capture()'; +@capture = (); + +$mysql->clear_variables; +$mock_sqitch->unmock_all; + +############################################################################## +# Test DateTime formatting stuff. +can_ok $CLASS, '_ts2char_format'; +is sprintf($CLASS->_ts2char_format, 'foo'), + q{date_format(foo, 'year:%Y:month:%m:day:%d:hour:%H:minute:%i:second:%S:time_zone:UTC')}, + '_ts2char_format should work'; + +ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')"; +isa_ok my $dt = $dtfunc->( + 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC' +), 'App::Sqitch::DateTime', 'Return value of _dt()'; +is $dt->year, 2012, 'DateTime year should be set'; +is $dt->month, 7, 'DateTime month should be set'; +is $dt->day, 5, 'DateTime day should be set'; +is $dt->hour, 15, 'DateTime hour should be set'; +is $dt->minute, 7, 'DateTime minute should be set'; +is $dt->second, 1, 'DateTime second should be set'; +is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set'; + +############################################################################## +# Test SQL helpers. +is $mysql->_listagg_format, q{GROUP_CONCAT(%s SEPARATOR ' ')}, 'Should have _listagg_format'; +is $mysql->_regex_op, 'REGEXP', 'Should have _regex_op'; +is $mysql->_simple_from, '', 'Should have _simple_from'; +is $mysql->_limit_default, '18446744073709551615', 'Should have _limit_default'; + +SECS: { + my $mock = Test::MockModule->new($CLASS); + my $dbh = {mysql_serverinfo => 'foo', mysql_serverversion => 50604}; + $mock->mock(dbh => $dbh); + is $mysql->_ts_default, 'utc_timestamp(6)', + 'Should have _ts_default with fractional seconds'; + + $dbh->{mysql_serverversion} = 50101; + my $my51 = $CLASS->new(sqitch => $sqitch, target => $target); + is $my51->_ts_default, 'utc_timestamp', + 'Should have _ts_default without fractional seconds on 5.1'; + + $dbh->{mysql_serverversion} = 50604; + $dbh->{mysql_serverinfo} = 'Something about MariaDB man'; + my $maria = $CLASS->new(sqitch => $sqitch, target => $target); + is $maria->_ts_default, 'utc_timestamp', + 'Should have _ts_default without fractional seconds on mariadb'; +} + +DBI: { + local *DBI::state; + local *DBI::err; + ok !$mysql->_no_table_error, 'Should have no table error'; + ok !$mysql->_no_column_error, 'Should have no column error'; + + $DBI::state = '42S02'; + ok $mysql->_no_table_error, 'Should now have table error'; + ok !$mysql->_no_column_error, 'Still should have no column error'; + + $DBI::state = '42000'; + $DBI::err = '1049'; + ok $mysql->_no_table_error, 'Should again have table error'; + ok !$mysql->_no_column_error, 'Still should have no column error'; + + $DBI::state = '42S22'; + $DBI::err = '1054'; + ok !$mysql->_no_table_error, 'Should again have no table error'; + ok $mysql->_no_column_error, 'Should now have no column error'; +} + +is_deeply [$mysql->_limit_offset(8, 4)], + [['LIMIT ?', 'OFFSET ?'], [8, 4]], + 'Should get limit and offset'; +is_deeply [$mysql->_limit_offset(0, 2)], + [['LIMIT ?', 'OFFSET ?'], ['18446744073709551615', 2]], + 'Should get limit and offset when offset only'; +is_deeply [$mysql->_limit_offset(12, 0)], [['LIMIT ?'], [12]], + 'Should get only limit with 0 offset'; +is_deeply [$mysql->_limit_offset(12)], [['LIMIT ?'], [12]], + 'Should get only limit with noa offset'; +is_deeply [$mysql->_limit_offset(0, 0)], [[], []], + 'Should get no limit or offset for 0s'; +is_deeply [$mysql->_limit_offset()], [[], []], + 'Should get no limit or offset for no args'; + +is_deeply [$mysql->_regex_expr('corn', 'Obama$')], + ['corn REGEXP ?', 'Obama$'], + 'Should use REGEXP for regex expr'; + +############################################################################## +# Can we do live tests? +my $dbh; + +my $db = '__sqitchtest__' . $$; +my $reg1 = '__metasqitch' . $$; +my $reg2 = '__sqitchtest' . $$; + +END { + return unless $dbh; + $dbh->{Driver}->visit_child_handles(sub { + my $h = shift; + $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh; + }); + + return unless $dbh->{Active}; + $dbh->do("DROP DATABASE IF EXISTS $_") for ($db, $reg1, $reg2); +} + + +$uri = URI->new($ENV{MYSQL_URI} || 'db:mysql://root@/information_schema'); +$uri->dbname('information_schema') unless $uri->dbname; +my $err = try { + $mysql->use_driver; + $dbh = DBI->connect($uri->dbi_dsn, $uri->user, $uri->password, { + PrintError => 0, + RaiseError => 1, + AutoCommit => 1, + }); + + # Make sure we have a version we can use. + if ($dbh->{mysql_serverinfo} =~ /mariadb/i) { + die "MariaDB >= 50300 required; this is $dbh->{mysql_serverversion}\n" + unless $dbh->{mysql_serverversion} >= 50300; + } + else { + die "MySQL >= 50000 required; this is $dbh->{mysql_serverversion}\n" + unless $dbh->{mysql_serverversion} >= 50000; + } + + $dbh->do("CREATE DATABASE $db"); + $uri->dbname($db); + undef; +} catch { + eval { $_->message } || $_; +}; + +DBIEngineTest->run( + class => $CLASS, + target_params => [ registry => $reg1, uri => $uri ], + alt_target_params => [ registry => $reg2, uri => $uri ], + skip_unless => sub { + my $self = shift; + die $err if $err; + # Make sure we have mysql and can connect to the database. + $self->sqitch->probe( $self->client, '--version' ); + say '# Connected to MySQL ' . $self->_capture('--execute' => 'SELECT version()'); + 1; + }, + engine_err_regex => qr/^You have an error /, + init_error => __x( + 'Sqitch database {database} already initialized', + database => $reg2, + ), + add_second_format => q{date_add(%s, interval 1 second)}, + test_dbh => sub { + my $dbh = shift; + # Check the session configuration. + for my $spec ( + [character_set_client => 'utf8'], + [character_set_server => 'utf8'], + ($dbh->{mysql_serverversion} < 50500 ? () : ([default_storage_engine => 'InnoDB'])), + [time_zone => '+00:00'], + [group_concat_max_len => 32768], + ) { + is $dbh->selectcol_arrayref('SELECT @@SESSION.' . $spec->[0])->[0], + $spec->[1], "Setting $spec->[0] should be set to $spec->[1]"; + } + + # Special-case sql_mode. + my $sql_mode = $dbh->selectcol_arrayref('SELECT @@SESSION.sql_mode')->[0]; + for my $mode (qw( + ansi + strict_trans_tables + no_auto_value_on_zero + no_zero_date + no_zero_in_date + only_full_group_by + error_for_division_by_zero + )) { + like $sql_mode, qr/\b\Q$mode\E\b/i, "sql_mode should include $mode"; + } + }, +); + +done_testing; diff --git a/t/odbc/odbcinst.ini b/t/odbc/odbcinst.ini new file mode 100644 index 00000000..2fffd23e --- /dev/null +++ b/t/odbc/odbcinst.ini @@ -0,0 +1,11 @@ +[Exasol] +Description = ODBC for Exasol +Driver = /opt/EXASOL_ODBC-6.0.4/lib/linux/x86_64/libexaodbc-uo2214lv1.so + +[Vertica] +Description = ODBC for Vertica +Driver = /opt/vertica/lib64/libverticaodbc.so + +[Snowflake] +Description = ODBC for Snowflake +Driver = /usr/lib64/snowflake/odbc/lib/libSnowflake.so diff --git a/t/odbc/vertica.ini b/t/odbc/vertica.ini new file mode 100644 index 00000000..c2520cfb --- /dev/null +++ b/t/odbc/vertica.ini @@ -0,0 +1,4 @@ +[Driver] +DriverManagerEncoding=UTF-16 +ODBCInstLib=/usr/lib64/libodbcinst.so +ErrorMessagesPath=/opt/vertica/lib64 diff --git a/t/options.t b/t/options.t new file mode 100644 index 00000000..62b55be5 --- /dev/null +++ b/t/options.t @@ -0,0 +1,210 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More; +use Test::MockModule; +use Test::Exception; +use Capture::Tiny 0.12 ':all'; +use Locale::TextDomain qw(App-Sqitch); +use lib 't/lib'; +use TestConfig; + +my ($catch_chdir, $chdir_to, $chdir_fail); +BEGIN { + $catch_chdir = 0; + # Stub out chdir. + *CORE::GLOBAL::chdir = sub { + return CORE::chdir(@_) unless $catch_chdir; + $chdir_to = shift; + return !$chdir_fail; + }; +} + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch'; + use_ok $CLASS or die; +} + +is_deeply [$CLASS->_core_opts], [qw( + chdir|cd|C=s + etc-path + no-pager + quiet + verbose|V|v+ + help + man + version +)], 'Options should be correct'; + +############################################################################## +# Test _find_cmd. +can_ok $CLASS, '_find_cmd'; + +CMD: { + # Mock output methods. + my $mocker = Test::MockModule->new($CLASS); + my $pod; + $mocker->mock(_pod2usage => sub { $pod = $_[1]; undef }); + my @vent; + $mocker->mock(vent => sub { shift; push @vent => \@_ }); + + # Try no args. + my @args = (); + is $CLASS->_find_cmd(\@args), undef, 'Should find no command for no args'; + is $pod, 'sqitchcommands', 'Should have passed "sqitchcommands" to _pod2usage'; + is_deeply \@vent, [], 'Should have vented nothing'; + ($pod, @vent) = (); + + # Try an invalid command. + @args = qw(barf); + is $CLASS->_find_cmd(\@args), undef, 'Should find no command for invalid command'; + is $pod, 'sqitchcommands', 'Should have passed "sqitchcommands" to _pod2usage'; + is_deeply \@vent, [ + [__x '"{command}" is not a valid command', command => 'barf'], + ], 'Should have vented an invalid command message'; + ($pod, @vent) = (); + + # Obvious options should be ignored. + for my $opt (qw( + --foo + --client=psql + -R + -X=yup + )) { + @args = ($opt, 'crack'); + is $CLASS->_find_cmd(\@args), undef, + "Should find no command with option $opt"; + is $pod, 'sqitchcommands', 'Should have passed "sqitchcommands" to _pod2usage'; + is_deeply \@vent, [ + [__x '"{command}" is not a valid command', command => 'crack'], + ], qq{Should not have reported $opt as invalid command}; + ($pod, @vent) = (); + } + + # Lone -- should cancel processing. + @args = ('--', 'tag'); + is $CLASS->_find_cmd(\@args), undef, 'Should find no command after --'; + is $pod, 'sqitchcommands', 'Should have passed "sqitchcommands" to _pod2usage'; + is_deeply \@vent, [], 'Should have vented nothing'; + ($pod, @vent) = (); + + # Valid command should be removed from args. + for my $cmd (qw(bundle config help plan show tag)) { + @args = (qw(--foo=bar -xy), $cmd, qw(--quack back -x y -z)); + my $class = "App::Sqitch::Command::$cmd"; + + is $CLASS->_find_cmd(\@args), $class, qq{Should find class for "$cmd"}; + is $pod, undef, 'Should not have called _pod2usage'; + is_deeply \@vent, [], 'Should have vented nothing'; + is_deeply \@args, [qw(--foo=bar -xy --quack back -x y -z)], + qq{Should have removed "$cmd" from args}; + ($pod, @vent) = (); + + @args = (qw(--foo=bar), $cmd, qw(verify -x)); + is $CLASS->_find_cmd(\@args), $class, qq{Should find class for "$cmd" again}; + is $pod, undef, 'Should not have called _pod2usage'; + is_deeply \@vent, [], 'Should have vented nothing'; + is_deeply \@args, [qw(--foo=bar verify -x)], + qq{Should have left subsequent valid command after "$cmd" in args}; + ($pod, @vent) = (); + } +} + +############################################################################## +# Test _parse_core_opts +can_ok $CLASS, '_parse_core_opts'; + +is_deeply $CLASS->_parse_core_opts([]), {}, + 'Should have default config for no options'; + +# Make sure we can get help. +HELP: { + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(_pod2usage => sub { @args = @_} ); + ok $CLASS->_parse_core_opts(['--help']), 'Ask for help'; + is_deeply \@args, [ $CLASS, 'sqitchcommands', '-exitval', 0, '-verbose', 2 ], + 'Should have been helped'; + ok $CLASS->_parse_core_opts(['--man']), 'Ask for man'; + is_deeply \@args, [ $CLASS, 'sqitch', '-exitval', 0, '-verbose', 2 ], + 'Should have been manned'; +} + +# Silence warnings. +my $mock = Test::MockModule->new($CLASS); +$mock->mock(warn => undef); + +############################################################################## +# Try lots of options. +my $opts = $CLASS->_parse_core_opts([ + '--verbose', '--verbose', + '--no-pager', +]); + +is_deeply $opts, { + verbosity => 2, + no_pager => 1, +}, 'Should parse lots of options'; + +# Make sure --quiet trumps --verbose. +is_deeply $CLASS->_parse_core_opts([ + '--verbose', '--verbose', '--quiet' +]), { verbosity => 0 }, '--quiet should trump verbosity.'; + +############################################################################## +# Try short options. +is_deeply $CLASS->_parse_core_opts([ + '-VVV', +]), { + verbosity => 3, +}, 'Short options should work'; + +USAGE: { + my $mock = Test::MockModule->new('Pod::Usage'); + my %args; + $mock->mock(pod2usage => sub { %args = @_} ); + ok $CLASS->_pod2usage('sqitch-add', foo => 'bar'), 'Run _pod2usage'; + is_deeply \%args, { + '-sections' => '(?i:(Usage|Synopsis|Options))', + '-verbose' => 2, + '-input' => Pod::Find::pod_where({'-inc' => 1 }, 'sqitch-add'), + '-exitval' => 2, + 'foo' => 'bar', + }, 'Proper args should have been passed to Pod::Usage'; +} + +# Test --chdir. +$catch_chdir = 1; +ok $opts = $CLASS->_parse_core_opts(['--chdir', 'foo/bar']), + 'Parse --chdir'; +is $chdir_to, 'foo/bar', 'Should have changed to foo/bar'; +is_deeply $opts, {}, 'Should have preserved no opts'; + +ok $opts = $CLASS->_parse_core_opts(['--cd', 'go/dir']), 'Parse --cd'; +is $chdir_to, 'go/dir', 'Should have changed to go/dir'; +is_deeply $opts, {}, 'Should have preserved no opts'; + +ok $opts = $CLASS->_parse_core_opts(['-C', 'hi crampus']), 'Parse -C'; +is $chdir_to, 'hi crampus', 'Should have changed to hi cramus'; +is_deeply $opts, {}, 'Should have preserved no opts'; + +# Make sure it fails properly. +CHDIE: { + local $! = 9; + $chdir_fail = 1; + my $exp_err = do { chdir 'nonesuch'; $! }; + throws_ok { $CLASS->_parse_core_opts(['-C', 'nonesuch']) } + 'App::Sqitch::X', 'Should get error when chdir fails'; + is $@->ident, 'fs', 'Error ident should be "fs"'; + is $@->message, __x( + 'Cannot change to directory {directory}: {error}', + directory => 'nonesuch', + error => $exp_err, + ), 'Error message should be correct'; +} + +done_testing; diff --git a/t/oracle.t b/t/oracle.t new file mode 100644 index 00000000..658d7266 --- /dev/null +++ b/t/oracle.t @@ -0,0 +1,571 @@ +#!/usr/bin/perl -w + +# Environment variables required to test: +# +# * ORAUSER +# * ORAPASS +# * TWO_TASK +# +# Tests can be run against the Developer Days VM with a bit of configuration. +# Download the VM from: +# +# https://www.oracle.com/technetwork/database/enterprise-edition/databaseappdev-vm-161299.html +# +# Once the VM is imported into VirtualBox and started, login with the username +# "oracle" and the password "oracle". Then, in VirtualBox, go to Settings -> +# Network, select the NAT adapter, and add two port forwarding rules +# (https://barrymcgillin.blogspot.com/2011/12/using-oracle-developer-days-virtualbox.html): +# +# Host Port | Guest Port +# -----------+------------ +# 1521 | 1521 +# 2222 | 22 +# +# Then restart the VM. You should then be able to connect from your host with: +# +# sqlplus sys/oracle@localhost/ORCL as sysdba +# +# If this fails with either of these errors: +# +# ORA-01017: invalid username/password; logon denied +# ORA-21561: OID generation failed +# +# Make sure that your computer's hostname is on the localhost line of +# /etc/hosts (https://sourceforge.net/p/tora/discussion/52737/thread/f68b89ad/): +# +# > hostname +# stickywicket +# > grep 127 /etc/hosts +# 127.0.0.1 localhost stickywicket +# +# Once connected, execute this SQL to create the user and give it access: +# +# CREATE USER sqitchtest IDENTIFIED BY oracle; +# GRANT ALL PRIVILEGES TO sqitchtest; +# +# Now the tests can be run with: +# +# ORAUSER=sqitchtest ORAPASS=oracle TWO_TASK=localhost/ORCL prove -lv t/oracle.t + +use strict; +use warnings; +use 5.010; +use Test::More 0.94; +use Test::MockModule; +use Test::Exception; +use Locale::TextDomain qw(App-Sqitch); +use Capture::Tiny 0.12 qw(:all); +use Try::Tiny; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use lib 't/lib'; +use DBIEngineTest; +use TestConfig; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Engine::oracle'; + require_ok $CLASS or die; + delete $ENV{ORACLE_HOME}; +} + +is_deeply [$CLASS->config_vars], [ + target => 'any', + registry => 'any', + client => 'any', +], 'config_vars should return three vars'; + +my $config = TestConfig->new('core.engine' => 'oracle'); +my $sqitch = App::Sqitch->new(config => $config); +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +isa_ok my $ora = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS; + +is $ora->key, 'oracle', 'Key should be "oracle"'; +is $ora->name, 'Oracle', 'Name should be "Oracle"'; + +my $client = 'sqlplus' . (App::Sqitch::ISWIN ? '.exe' : ''); +is $ora->client, $client, 'client should default to sqlplus'; +ORACLE_HOME: { + local $ENV{ORACLE_HOME} = '/foo/bar'; + my $target = App::Sqitch::Target->new(sqitch => $sqitch); + isa_ok my $ora = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS; + is $ora->client, Path::Class::file('/foo/bar', $client)->stringify, + 'client should use $ORACLE_HOME'; +} + +is $ora->registry, '', 'registry default should be empty'; +is $ora->uri, 'db:oracle:', 'Default URI should be "db:oracle"'; + +my $dest_uri = $ora->uri->clone; +$dest_uri->dbname( + $ENV{TWO_TASK} + || (App::Sqitch::ISWIN ? $ENV{LOCAL} : undef) + || $ENV{ORACLE_SID} +); +is $ora->target->name, $ora->uri, 'Target name should be the uri stringified'; +is $ora->destination, $dest_uri->as_string, + 'Destination should fall back on environment variables'; +is $ora->registry_destination, $ora->destination, + 'Registry target should be the same as target'; + +my @std_opts = qw(-S -L /nolog); +is_deeply [$ora->sqlplus], [$client, @std_opts], + 'sqlplus command should connect to /nolog'; + +is $ora->_script, join( "\n" => ( + 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF', + 'WHENEVER OSERROR EXIT 9;', + 'WHENEVER SQLERROR EXIT SQL.SQLCODE;', + 'connect ', + $ora->_registry_variable, +) ), '_script should work'; + +# Set up a target URI. +$target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI::db->new('db:oracle://fred:derf@/blah') +); +isa_ok $ora = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS; + +is $ora->_script, join( "\n" => ( + 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF', + 'WHENEVER OSERROR EXIT 9;', + 'WHENEVER SQLERROR EXIT SQL.SQLCODE;', + 'connect fred/"derf"@"blah"', + $ora->_registry_variable, +) ), '_script should assemble connection string'; + +# Add a host name. +$target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI::db->new('db:oracle://fred:derf@there/blah') +); +isa_ok $ora = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS; + +is $ora->_script('@foo'), join( "\n" => ( + 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF', + 'WHENEVER OSERROR EXIT 9;', + 'WHENEVER SQLERROR EXIT SQL.SQLCODE;', + 'connect fred/"derf"@//there/"blah"', + $ora->_registry_variable, + '@foo', +) ), '_script should assemble connection string with host'; + +# Add a port and varibles. +$target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI::db->new( + 'db:oracle://fred:derf%20%22derf%22@there:1345/blah%20%22blah%22' + ), +); +isa_ok $ora = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS; +ok $ora->set_variables(foo => 'baz', whu => 'hi there', yo => q{"stellar"}), + 'Set some variables'; + +is $ora->_script, join( "\n" => ( + 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF', + 'WHENEVER OSERROR EXIT 9;', + 'WHENEVER SQLERROR EXIT SQL.SQLCODE;', + 'DEFINE foo="baz"', + 'DEFINE whu="hi there"', + 'DEFINE yo="""stellar"""', + 'connect fred/"derf ""derf"""@//there:1345/"blah ""blah"""', + $ora->_registry_variable, +) ), '_script should assemble connection string with host, port, and vars'; + +# Try a URI with nothing but the database name. +$target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI::db->new('db:oracle:secure_user_tns.tpg'), +); +is $target->uri->dbi_dsn, 'dbi:Oracle:secure_user_tns.tpg', + 'Database-only URI should produce proper DSN'; +isa_ok $ora = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS; +is $ora->_script('@foo'), join( "\n" => ( + 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF', + 'WHENEVER OSERROR EXIT 9;', + 'WHENEVER SQLERROR EXIT SQL.SQLCODE;', + 'connect /@"secure_user_tns.tpg"', + $ora->_registry_variable, + '@foo', +) ), '_script should assemble connection string with just dbname'; + +# Try a URI with double slash, but otherwise just the db name. +$target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI::db->new('db:oracle://:@/wallet_tns_name'), +); +is $target->uri->dbi_dsn, 'dbi:Oracle:wallet_tns_name', + 'Database and double-slash URI should produce proper DSN'; +isa_ok $ora = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS; +is $ora->_script('@foo'), join( "\n" => ( + 'SET ECHO OFF NEWP 0 SPA 0 PAGES 0 FEED OFF HEAD OFF TRIMS ON TAB OFF', + 'WHENEVER OSERROR EXIT 9;', + 'WHENEVER SQLERROR EXIT SQL.SQLCODE;', + 'connect /@"wallet_tns_name"', + $ora->_registry_variable, + '@foo', +) ), '_script should assemble connection string with double-slash and dbname'; + + +############################################################################## +# Test other configs for the destination. +$target = App::Sqitch::Target->new(sqitch => $sqitch); +ENV: { + # Make sure we override system-set vars. + local $ENV{TWO_TASK}; + local $ENV{ORACLE_SID}; + for my $env (qw(TWO_TASK ORACLE_SID)) { + my $ora = $CLASS->new(sqitch => $sqitch, target => $target); + local $ENV{$env} = '$ENV=whatever'; + is $ora->target->name, "db:oracle:", "Target name should not read \$$env"; + is $ora->destination, "db:oracle:\$ENV=whatever", "Destination should read \$$env"; + is $ora->registry_destination, $ora->destination, + 'Registry destination should be the same as destination'; + } + + $ENV{TWO_TASK} = 'mydb'; + $ora = $CLASS->new(sqitch => $sqitch, username => 'hi', target => $target); + is $ora->target->name, 'db:oracle:', 'Target should be the default'; + is $ora->destination, 'db:oracle:mydb', + 'Destination should prefer $TWO_TASK to username'; + is $ora->registry_destination, $ora->destination, + 'Registry destination should be the same as destination'; +} + +############################################################################## +# Make sure config settings override defaults. +$config->update( + 'engine.oracle.client' => '/path/to/sqlplus', + 'engine.oracle.target' => 'db:oracle://bob:hi@db.net:12/howdy', + 'engine.oracle.registry' => 'meta', +); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +ok $ora = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create another ora'; + +is $ora->client, '/path/to/sqlplus', 'client should be as configured'; +is $ora->uri->as_string, 'db:oracle://bob:hi@db.net:12/howdy', + 'DB URI should be as configured'; +like $ora->target->name, qr{^db:oracle://bob:?\@db\.net:12/howdy$}, + 'Target name should be the passwordless URI stringified'; +like $ora->destination, qr{^db:oracle://bob:?\@db\.net:12/howdy$}, + 'Destination should be the URI without the password'; +is $ora->registry_destination, $ora->destination, + 'registry_destination should replace be the same URI'; +is $ora->registry, 'meta', 'registry should be as configured'; +is_deeply [$ora->sqlplus], ['/path/to/sqlplus', @std_opts], + 'sqlplus command should be configured'; + +$config->update( + 'engine.oracle.client' => '/path/to/sqlplus', + 'engine.oracle.registry' => 'meta', +); + +$target = App::Sqitch::Target->new(sqitch => $sqitch); +ok $ora = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create yet another ora'; +is $ora->client, '/path/to/sqlplus', 'client should be as configured'; +is $ora->registry, 'meta', 'registry should be as configured'; +is_deeply [$ora->sqlplus], ['/path/to/sqlplus', @std_opts], + 'sqlplus command should be configured'; + +############################################################################## +# Test _run() and _capture(). +can_ok $ora, qw(_run _capture); +my $mock_sqitch = Test::MockModule->new('App::Sqitch'); +my (@capture, @spool); +$mock_sqitch->mock(spool => sub { shift; @spool = @_ }); +my $mock_run3 = Test::MockModule->new('IPC::Run3'); +$mock_run3->mock(run3 => sub { @capture = @_ }); + +ok $ora->_run(qw(foo bar baz)), 'Call _run'; +my $fh = shift @spool; +is_deeply \@spool, [$ora->sqlplus], + 'SQLPlus command should be passed to spool()'; + +is join('', <$fh> ), $ora->_script(qw(foo bar baz)), + 'The script should be spooled'; + +ok $ora->_capture(qw(foo bar baz)), 'Call _capture'; +is_deeply \@capture, [ + [$ora->sqlplus], \$ora->_script(qw(foo bar baz)), [], [], + { return_if_system_error => 1 }, +], 'Command and script should be passed to run3()'; + +# Let's make sure that IPC::Run3 actually works as expected. +$mock_run3->unmock_all; +my $echo = Path::Class::file(qw(t echo.pl)); +my $mock_ora = Test::MockModule->new($CLASS); +$mock_ora->mock(sqlplus => sub { $^X, $echo, qw(hi there) }); + +is join (', ' => $ora->_capture(qw(foo bar baz))), "hi there\n", + '_capture should actually capture'; + +# Make it die. +my $die = Path::Class::file(qw(t die.pl)); +$mock_ora->mock(sqlplus => sub { $^X, $die, qw(hi there) }); +like capture_stderr { + throws_ok { + $ora->_capture('whatever'), + } 'App::Sqitch::X', '_capture should die when sqlplus dies'; +}, qr/^OMGWTF/, 'STDERR should be emitted by _capture'; + +############################################################################## +# Test _file_for_script(). +can_ok $ora, '_file_for_script'; +is $ora->_file_for_script(Path::Class::file 'foo'), 'foo', + 'File without special characters should be used directly'; +is $ora->_file_for_script(Path::Class::file '"foo"'), '""foo""', + 'Double quotes should be SQL-escaped'; + +# Get the temp dir used by the engine. +ok my $tmpdir = $ora->tmpdir, 'Get temp dir'; +isa_ok $tmpdir, 'Path::Class::Dir', 'Temp dir'; + +# Make sure a file with @ is aliased. +my $file = $tmpdir->file('foo@bar.sql'); +$file->touch; # File must exist, because on Windows it gets copied. +is $ora->_file_for_script($file), $tmpdir->file('foo_bar.sql'), + 'File with special char should be aliased'; + +# Make sure double-quotes are escaped. +WIN32: { + $file = $tmpdir->file('"foo$bar".sql'); + my $mock_file = Test::MockModule->new(ref $file); + # Windows doesn't like the quotation marks, so prevent it from writing. + $mock_file->mock(copy_to => 1) if App::Sqitch::ISWIN; + is $ora->_file_for_script($file), $tmpdir->file('""foo_bar"".sql'), + 'File with special char and quotes should be aliased'; +} + +############################################################################## +# Test file and handle running. +my @run; +$mock_ora->mock(_run => sub {shift; @run = @_ }); +ok $ora->run_file('foo/bar.sql'), 'Run foo/bar.sql'; +is_deeply \@run, ['@"foo/bar.sql"'], + 'File should be passed to run()'; + +ok $ora->run_file('foo/"bar".sql'), 'Run foo/"bar".sql'; +is_deeply \@run, ['@"foo/""bar"".sql"'], + 'Double quotes in file passed to run() should be escaped'; + +ok $ora->run_handle('FH'), 'Spool a "file handle"'; +my $handles = shift @spool; +is_deeply \@spool, [$ora->sqlplus], + 'sqlplus command should be passed to spool()'; +isa_ok $handles, 'ARRAY', 'Array ove handles should be passed to spool'; +$fh = $handles->[0]; +is join('', <$fh>), $ora->_script, 'First file handle should be script'; +is $handles->[1], 'FH', 'Second should be the passed handle'; + +# Verify should go to capture unless verosity is > 1. +$mock_ora->mock(_capture => sub {shift; @capture = @_ }); +ok $ora->run_verify('foo/bar.sql'), 'Verify foo/bar.sql'; +is_deeply \@capture, ['@"foo/bar.sql"'], + 'Verify file should be passed to capture()'; + +$mock_sqitch->mock(verbosity => 2); +ok $ora->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again'; +is_deeply \@run, ['@"foo/bar.sql"'], + 'Verifile file should be passed to run() for high verbosity'; + +$mock_sqitch->unmock_all; +$mock_ora->unmock_all; + +############################################################################## +# Test DateTime formatting stuff. +ok my $ts2char = $CLASS->can('_ts2char_format'), "$CLASS->can('_ts2char_format')"; +is sprintf($ts2char->(), 'foo'), join( ' || ', + q{to_char(foo AT TIME ZONE 'UTC', '"year":YYYY')}, + q{to_char(foo AT TIME ZONE 'UTC', ':"month":MM')}, + q{to_char(foo AT TIME ZONE 'UTC', ':"day":DD')}, + q{to_char(foo AT TIME ZONE 'UTC', ':"hour":HH24')}, + q{to_char(foo AT TIME ZONE 'UTC', ':"minute":MI')}, + q{to_char(foo AT TIME ZONE 'UTC', ':"second":SS')}, + q{':time_zone:UTC'}, +), '_ts2char_format should work'; + +ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')"; +isa_ok my $dt = $dtfunc->( + 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC' +), 'App::Sqitch::DateTime', 'Return value of _dt()'; +is $dt->year, 2012, 'DateTime year should be set'; +is $dt->month, 7, 'DateTime month should be set'; +is $dt->day, 5, 'DateTime day should be set'; +is $dt->hour, 15, 'DateTime hour should be set'; +is $dt->minute, 7, 'DateTime minute should be set'; +is $dt->second, 1, 'DateTime second should be set'; +is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set'; +is $CLASS->_char2ts($dt), + join(' ', $dt->ymd('-'), $dt->hms(':'), $dt->time_zone->name), + 'Should have _char2ts'; + +############################################################################## +# Test SQL helpers. +is $ora->_listagg_format, q{CAST(COLLECT(CAST(%s AS VARCHAR2(512))) AS sqitch_array)}, + 'Should have _listagg_format'; +is $ora->_regex_op, 'REGEXP_LIKE(%s, ?)', 'Should have _regex_op'; +is $ora->_simple_from, ' FROM dual', 'Should have _simple_from'; +is $ora->_limit_default, undef, 'Should have _limit_default'; +is $ora->_ts_default, 'current_timestamp', 'Should have _ts_default'; +is $ora->_can_limit, 0, 'Should have _can_limit false'; + +is $ora->_multi_values(1, 'FOO'), 'SELECT FOO FROM dual', + 'Should get single expression from _multi_values'; +is $ora->_multi_values(2, 'LOWER(?)'), + "SELECT LOWER(?) FROM dual\nUNION ALL SELECT LOWER(?) FROM dual", + 'Should get double expression from _multi_values'; +is $ora->_multi_values(4, 'X'), + "SELECT X FROM dual\nUNION ALL SELECT X FROM dual\nUNION ALL SELECT X FROM dual\nUNION ALL SELECT X FROM dual", + 'Should get quadrupal expression from _multi_values'; + +DBI: { + local *DBI::err; + ok !$ora->_no_table_error, 'Should have no table error'; + ok !$ora->_no_column_error, 'Should have no column error'; + + $DBI::err = 942; + ok $ora->_no_table_error, 'Should now have table error'; + ok !$ora->_no_column_error, 'Still should have no column error'; + + $DBI::err = 904; + ok !$ora->_no_table_error, 'Should again have no table error'; + ok $ora->_no_column_error, 'Should now have no column error'; +} + +# Test _log_tags_param. +my $plan = App::Sqitch::Plan->new( + sqitch => $sqitch, + target => $target, + 'project' => 'oracle', +); +my $change = App::Sqitch::Plan::Change->new( + name => 'oracle_test', + plan => $plan, +); +my @tags = map { + App::Sqitch::Plan::Tag->new( + plan => $plan, + name => $_, + change => $change, + ) +} qw(xxx yyy zzz); +$change->add_tag($_) for @tags; +is_deeply $ora->_log_tags_param($change), [qw(@xxx @yyy @zzz)], + '_log_tags_param should format tags'; + +# Test _log_requires_param. +my @req = map { + App::Sqitch::Plan::Depend->new( + %{ App::Sqitch::Plan::Depend->parse($_) }, + plan => $plan, + ) +} qw(aaa bbb ccc); + +my $mock_change = Test::MockModule->new(ref $change); +$mock_change->mock(requires => sub { @req }); +is_deeply $ora->_log_requires_param($change), [qw(aaa bbb ccc)], + '_log_requires_param should format prereqs'; + +# Test _log_conflicts_param. +$mock_change->mock(conflicts => sub { @req }); +is_deeply $ora->_log_conflicts_param($change), [qw(aaa bbb ccc)], + '_log_conflicts_param should format prereqs'; + +$mock_change->unmock_all; + +############################################################################## +# Can we do live tests? +if (App::Sqitch::ISWIN && eval { require Win32::API}) { + # Call kernel32.SetErrorMode(SEM_FAILCRITICALERRORS): + # "The system does not display the critical-error-handler message box. + # Instead, the system sends the error to the calling process." and + # "A child process inherits the error mode of its parent process." + my $SetErrorMode = Win32::API->new('kernel32', 'SetErrorMode', 'I', 'I'); + my $SEM_FAILCRITICALERRORS = 0x0001; + $SetErrorMode->Call($SEM_FAILCRITICALERRORS); +} +my $dbh; +END { + return unless $dbh; + $dbh->{Driver}->visit_child_handles(sub { + my $h = shift; + $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh; + }); + + $dbh->{RaiseError} = 0; + $dbh->{PrintError} = 1; + $dbh->do($_) for ( + 'DROP TABLE events', + 'DROP TABLE dependencies', + 'DROP TABLE tags', + 'DROP TABLE changes', + 'DROP TABLE projects', + 'DROP TABLE releases', + 'DROP TYPE sqitch_array', + 'DROP TABLE oe.events', + 'DROP TABLE oe.dependencies', + 'DROP TABLE oe.tags', + 'DROP TABLE oe.changes', + 'DROP TABLE oe.projects', + 'DROP TABLE oe.releases', + 'DROP TYPE oe.sqitch_array', + ); +} + +my $user = $ENV{ORAUSER} || 'scott'; +my $pass = $ENV{ORAPASS} || 'tiger'; +my $err = try { + $ora->use_driver; + my $dsn = 'dbi:Oracle:'; + $dbh = DBI->connect($dsn, $user, $pass, { + PrintError => 0, + RaiseError => 1, + AutoCommit => 1, + }); + undef; +} catch { + eval { $_->message } || $_; +}; + +my $uri = URI->new('db:oracle:'); +$uri->user($user); +$uri->password($pass); +# $uri->dbname( $ENV{TWO_TASK} || $ENV{LOCAL} || $ENV{ORACLE_SID} ); +DBIEngineTest->run( + class => $CLASS, + version_query => q{SELECT * FROM v$version WHERE banner LIKE 'Oracle%'}, + target_params => [ uri => $uri ], + alt_target_params => [ uri => $uri, registry => 'oe' ], + skip_unless => sub { + my $self = shift; + die $err if $err; + # Make sure we have sqlplus and can connect to the database. + $self->sqitch->probe( $self->client, '-v' ); + $self->_capture('SELECT 1 FROM dual;'); + }, + engine_err_regex => qr/^ORA-00925: /, + init_error => __ 'Sqitch already initialized', + add_second_format => q{%s + interval '1' second}, +); + +done_testing; diff --git a/t/pg.t b/t/pg.t new file mode 100644 index 00000000..e2dc8a92 --- /dev/null +++ b/t/pg.t @@ -0,0 +1,322 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use Test::More 0.94; +use Test::MockModule; +use Test::Exception; +use Locale::TextDomain qw(App-Sqitch); +use Capture::Tiny 0.12 qw(:all); +use Try::Tiny; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use lib 't/lib'; +use DBIEngineTest; +use TestConfig; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Engine::pg'; + require_ok $CLASS or die; + delete $ENV{PGPASSWORD}; +} + +is_deeply [$CLASS->config_vars], [ + target => 'any', + registry => 'any', + client => 'any', +], 'config_vars should return three vars'; + +my $uri = URI::db->new('db:pg:'); +my $config = TestConfig->new('core.engine' => 'pg'); +my $sqitch = App::Sqitch->new(config => $config); +my $target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => $uri, +); +isa_ok my $pg = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS; + +is $pg->key, 'pg', 'Key should be "pg"'; +is $pg->name, 'PostgreSQL', 'Name should be "PostgreSQL"'; + +my $client = 'psql' . (App::Sqitch::ISWIN ? '.exe' : ''); +is $pg->client, $client, 'client should default to psqle'; +is $pg->registry, 'sqitch', 'registry default should be "sqitch"'; +is $pg->uri, $uri, 'DB URI should be "db:pg:"'; +my $dest_uri = $uri->clone; +$dest_uri->dbname($ENV{PGDATABASE} || $ENV{PGUSER} || $sqitch->sysuser); +is $pg->destination, $dest_uri->as_string, + 'Destination should fall back on environment variables'; +is $pg->registry_destination, $pg->destination, + 'Registry destination should be the same as destination'; + +my @std_opts = ( + '--quiet', + '--no-psqlrc', + '--no-align', + '--tuples-only', + '--set' => 'ON_ERROR_STOP=1', + '--set' => 'registry=sqitch', +); +my $sysuser = $sqitch->sysuser; +is_deeply [$pg->psql], [$client, @std_opts], + 'psql command should be conninfo, and std opts-only'; + +isa_ok $pg = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS; +ok $pg->set_variables(foo => 'baz', whu => 'hi there', yo => 'stellar'), + 'Set some variables'; +is_deeply [$pg->psql], [ + $client, + '--set' => 'foo=baz', + '--set' => 'whu=hi there', + '--set' => 'yo=stellar', + @std_opts, +], 'Variables should be passed to psql via --set'; + +############################################################################## +# Test other configs for the target. +ENV: { + # Make sure we override system-set vars. + local $ENV{PGDATABASE}; + for my $env (qw(PGDATABASE PGUSER PGPASSWORD)) { + my $pg = $CLASS->new(sqitch => $sqitch, target => $target); + local $ENV{$env} = "\$ENV=whatever"; + is $pg->target->uri, "db:pg:", "Target should not read \$$env"; + is $pg->registry_destination, $pg->destination, + 'Registry target should be the same as destination'; + } + + my $mocker = Test::MockModule->new('App::Sqitch'); + $mocker->mock(sysuser => 'sysuser=whatever'); + my $pg = $CLASS->new(sqitch => $sqitch, target => $target); + is $pg->target->uri, 'db:pg:', 'Target should not fall back on sysuser'; + is $pg->registry_destination, $pg->destination, + 'Registry target should be the same as destination'; + + $ENV{PGDATABASE} = 'mydb'; + $pg = $CLASS->new(sqitch => $sqitch, username => 'hi', target => $target); + is $pg->target->uri, 'db:pg:', 'Target should be the default'; + is $pg->registry_destination, $pg->destination, + 'Registry target should be the same as destination'; +} + +############################################################################## +# Make sure config settings override defaults. +$config->update( + 'engine.pg.client' => '/path/to/psql', + 'engine.pg.target' => 'db:pg://localhost/try?sslmode=disable&connect_timeout=5', + 'engine.pg.registry' => 'meta', +); +$std_opts[-1] = 'registry=meta'; + +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +ok $pg = $CLASS->new(sqitch => $sqitch, target => $target), 'Create another pg'; +is $pg->client, '/path/to/psql', 'client should be as configured'; +is $pg->uri->as_string, 'db:pg://localhost/try?sslmode=disable&connect_timeout=5', + 'uri should be as configured'; +is $pg->registry, 'meta', 'registry should be as configured'; +is_deeply [$pg->psql], [ + '/path/to/psql', + '--dbname', + "dbname=try host=localhost connect_timeout=5 sslmode=disable", +@std_opts], 'psql command should be configured from URI config'; + +############################################################################## +# Test _run(), _capture(), and _spool(). +can_ok $pg, qw(_run _capture _spool); +my $mock_sqitch = Test::MockModule->new('App::Sqitch'); +my (@run, $exp_pass); +$mock_sqitch->mock(run => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @run = @_; + if (defined $exp_pass) { + is $ENV{PGPASSWORD}, $exp_pass, qq{PGPASSWORD should be "$exp_pass"}; + } else { + ok !exists $ENV{PGPASSWORD}, 'PGPASSWORD should not exist'; + } +}); + +my @capture; +$mock_sqitch->mock(capture => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @capture = @_; + if (defined $exp_pass) { + is $ENV{PGPASSWORD}, $exp_pass, qq{PGPASSWORD should be "$exp_pass"}; + } else { + ok !exists $ENV{PGPASSWORD}, 'PGPASSWORD should not exist'; + } +}); + +my @spool; +$mock_sqitch->mock(spool => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @spool = @_; + if (defined $exp_pass) { + is $ENV{PGPASSWORD}, $exp_pass, qq{PGPASSWORD should be "$exp_pass"}; + } else { + ok !exists $ENV{PGPASSWORD}, 'PGPASSWORD should not exist'; + } +}); + +$target->uri->password('s3cr3t'); +$exp_pass = 's3cr3t'; +ok $pg->_run(qw(foo bar baz)), 'Call _run'; +is_deeply \@run, [$pg->psql, qw(foo bar baz)], + 'Command should be passed to run()'; + +ok $pg->_spool('FH'), 'Call _spool'; +is_deeply \@spool, ['FH', $pg->psql], + 'Command should be passed to spool()'; + +ok $pg->_capture(qw(foo bar baz)), 'Call _capture'; +is_deeply \@capture, [$pg->psql, qw(foo bar baz)], + 'Command should be passed to capture()'; + +# Without password. +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +ok $pg = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create a pg with sqitch with no pw'; +$exp_pass = undef; +ok $pg->_run(qw(foo bar baz)), 'Call _run again'; +is_deeply \@run, [$pg->psql, qw(foo bar baz)], + 'Command should be passed to run() again'; + +ok $pg->_spool('FH'), 'Call _spool again'; +is_deeply \@spool, ['FH', $pg->psql], + 'Command should be passed to spool() again'; + +ok $pg->_capture(qw(foo bar baz)), 'Call _capture again'; +is_deeply \@capture, [$pg->psql, qw(foo bar baz)], + 'Command should be passed to capture() again'; + +############################################################################## +# Test file and handle running. +ok $pg->run_file('foo/bar.sql'), 'Run foo/bar.sql'; +is_deeply \@run, [$pg->psql, '--file', 'foo/bar.sql'], + 'File should be passed to run()'; + +ok $pg->run_handle('FH'), 'Spool a "file handle"'; +is_deeply \@spool, ['FH', $pg->psql], + 'Handle should be passed to spool()'; + +# Verify should go to capture unless verosity is > 1. +ok $pg->run_verify('foo/bar.sql'), 'Verify foo/bar.sql'; +is_deeply \@capture, [$pg->psql, '--file', 'foo/bar.sql'], + 'Verify file should be passed to capture()'; + +$mock_sqitch->mock(verbosity => 2); +ok $pg->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again'; +is_deeply \@run, [$pg->psql, '--file', 'foo/bar.sql'], + 'Verifile file should be passed to run() for high verbosity'; + +$mock_sqitch->unmock_all; + +############################################################################## +# Test DateTime formatting stuff. +ok my $ts2char = $CLASS->can('_ts2char_format'), "$CLASS->can('_ts2char_format')"; +is sprintf($ts2char->(), 'foo'), + q{to_char(foo AT TIME ZONE 'UTC', '"year":YYYY:"month":MM:"day":DD:"hour":HH24:"minute":MI:"second":SS:"time_zone":"UTC"')}, + '_ts2char_format should work'; + +ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')"; +isa_ok my $dt = $dtfunc->( + 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC' +), 'App::Sqitch::DateTime', 'Return value of _dt()'; +is $dt->year, 2012, 'DateTime year should be set'; +is $dt->month, 7, 'DateTime month should be set'; +is $dt->day, 5, 'DateTime day should be set'; +is $dt->hour, 15, 'DateTime hour should be set'; +is $dt->minute, 7, 'DateTime minute should be set'; +is $dt->second, 1, 'DateTime second should be set'; +is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set'; + +############################################################################## +# Test _psql_major_version. +for my $spec ( + ['11beta3', 11], + ['11.3', 11], + ['10', 10], + ['9.6.3', 9], + ['8.4.2', 8], + ['9.0.19', 9], +) { + $mock_sqitch->mock(probe => "psql (PostgreSQL) $spec->[0]"); + is $pg->_psql_major_version, $spec->[1], + "Should find major version $spec->[1] in $spec->[0]"; +} +$mock_sqitch->unmock('probe'); + +############################################################################## +# Can we do live tests? +$config->replace('core.engine' => 'pg'); +$sqitch = App::Sqitch->new(config => $config); +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +$pg = $CLASS->new(sqitch => $sqitch, target => $target); +my $dbh; +my $db = '__sqitchtest__' . $$; +END { + return unless $dbh; + $dbh->{Driver}->visit_child_handles(sub { + my $h = shift; + $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh; + }); + + $dbh->do("DROP DATABASE $db") if $dbh->{Active}; +} + +my $pguser = $ENV{PGUSER} || 'postgres'; + +my $err = try { + $pg->_capture('--version'); + $pg->use_driver; + $dbh = DBI->connect('dbi:Pg:dbname=template1', $pguser, '', { + PrintError => 0, + RaiseError => 1, + AutoCommit => 1, + }); + $dbh->do($_) for ( + "CREATE DATABASE $db", + "ALTER DATABASE $db SET lc_messages = 'C'", + ); + undef; +} catch { + eval { $_->message } || $_; +}; + +DBIEngineTest->run( + class => $CLASS, + version_query => 'SELECT version()', + target_params => [ + uri => URI::db->new("db:pg://$pguser\@/$db"), + ], + alt_target_params => [ + registry => '__sqitchtest', + uri => URI::db->new("db:pg://$pguser\@/$db"), + ], + skip_unless => sub { + my $self = shift; + die $err if $err; + # Make sure we have psql and can connect to the database. + $self->sqitch->probe( $self->client, '--version' ); + $self->_capture('--command' => 'SELECT version()'); + }, + engine_err_regex => qr/^ERROR: /, + init_error => __x( + 'Sqitch schema "{schema}" already exists', + schema => '__sqitchtest', + ), + test_dbh => sub { + my $dbh = shift; + # Make sure the sqitch schema is the first in the search path. + is $dbh->selectcol_arrayref('SELECT current_schema')->[0], + '__sqitchtest', 'The Sqitch schema should be the current schema'; + }, +); + +done_testing; diff --git a/t/plan.t b/t/plan.t new file mode 100644 index 00000000..ed998392 --- /dev/null +++ b/t/plan.t @@ -0,0 +1,2034 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More; +use App::Sqitch; +use App::Sqitch::Target; +use Locale::TextDomain qw(App-Sqitch); +use Path::Class; +use Test::Exception; +use Test::File; +use Test::Deep; +use Test::File::Contents; +use Encode; +#use Test::NoWarnings; +use File::Path qw(make_path remove_tree); +use App::Sqitch::DateTime; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Plan'; + use_ok $CLASS or die; +} + +can_ok $CLASS, qw( + sqitch + target + file + changes + position + load + syntax_version + project + uri + _parse + check_changes + open_script +); + +my $config = TestConfig->new('core.engine' => 'sqlite'); +my $sqitch = App::Sqitch->new( config => $config ); +my $target = App::Sqitch::Target->new( sqitch => $sqitch ); +isa_ok my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target), + $CLASS; +is $plan->file, $target->plan_file, 'File should be coopied from Target'; + +# Set up some some utility functions for creating changes. +sub blank { + App::Sqitch::Plan::Blank->new( + plan => $plan, + lspace => $_[0] // '', + note => $_[1] // '', + ); +} + +my $prev_tag; +my $prev_change; +my %seen; + +sub clear { + undef $prev_tag; + undef $prev_change; + %seen = (); + return (); +} + +my $ts = App::Sqitch::DateTime->new( + year => 2012, + month => 7, + day => 16, + hour => 17, + minute => 25, + second => 7, + time_zone => 'UTC', +); + +sub ts($) { + my $str = shift || return $ts; + my @parts = split /[-:T]/ => $str; + return App::Sqitch::DateTime->new( + year => $parts[0], + month => $parts[1], + day => $parts[2], + hour => $parts[3], + minute => $parts[4], + second => $parts[5], + time_zone => 'UTC', + ); +} + +my $vivify = 0; +my $project; + +sub dep($) { + App::Sqitch::Plan::Depend->new( + plan => $plan, + (defined $project ? (project => $project) : ()), + %{ App::Sqitch::Plan::Depend->parse(shift) }, + ) +} + +sub change($) { + my $p = shift; + if ( my $op = delete $p->{op} ) { + @{ $p }{ qw(lopspace operator ropspace) } = split /([+-])/, $op; + $p->{$_} //= '' for qw(lopspace ropspace); + } + + $p->{requires} = [ map { dep $_ } @{ $p->{requires} } ] + if $p->{requires}; + $p->{conflicts} = [ map { dep "!$_" } @{ $p->{conflicts} }] + if $p->{conflicts}; + + $prev_change = App::Sqitch::Plan::Change->new( + plan => $plan, + timestamp => ts delete $p->{ts}, + planner_name => 'Barack Obama', + planner_email => 'potus@whitehouse.gov', + ( $prev_tag ? ( since_tag => $prev_tag ) : () ), + ( $prev_change ? ( parent => $prev_change ) : () ), + %{ $p }, + ); + if (my $duped = $seen{ $p->{name} }) { + $duped->add_rework_tags(map { $seen{$_}-> tags } @{ $p->{rtag} }); + } + $seen{ $p->{name} } = $prev_change; + if ($vivify) { + $prev_change->id; + $prev_change->tags; + } + return $prev_change; +} + +sub tag($) { + my $p = shift; + my $ret = delete $p->{ret}; + $prev_tag = App::Sqitch::Plan::Tag->new( + plan => $plan, + change => $prev_change, + timestamp => ts delete $p->{ts}, + planner_name => 'Barack Obama', + planner_email => 'potus@whitehouse.gov', + %{ $p }, + ); + $prev_change->add_tag($prev_tag); + $prev_tag->id, if $vivify; + return $ret ? $prev_tag : (); +} + +sub prag { + App::Sqitch::Plan::Pragma->new( + plan => $plan, + lspace => $_[0] // '', + hspace => $_[1] // '', + name => $_[2], + (defined $_[3] ? (lopspace => $_[3]) : ()), + (defined $_[4] ? (operator => $_[4]) : ()), + (defined $_[5] ? (ropspace => $_[5]) : ()), + (defined $_[6] ? (value => $_[6]) : ()), + rspace => $_[7] // '', + note => $_[8] // '', + ); +} + +my $mocker = Test::MockModule->new($CLASS); +# Do no sorting for now. +my $sorted = 0; +sub sorted () { + my $ret = $sorted; + $sorted = 0; + return $ret; +} +$mocker->mock(check_changes => sub { $sorted++; shift, shift, shift; @_ }); + +sub version () { + prag( + '', '', 'syntax-version', '', '=', '', App::Sqitch::Plan::SYNTAX_VERSION + ); +} + +############################################################################## +# Test parsing. +my $file = file qw(t plans widgets.plan); +my $fh = $file->open('<:utf8_strict'); +ok my $parsed = $plan->_parse($file, $fh), + 'Should parse simple "widgets.plan"'; +is sorted, 1, 'Should have sorted changes'; +isa_ok $parsed->{changes}, 'ARRAY', 'changes'; +isa_ok $parsed->{lines}, 'ARRAY', 'lines'; + +cmp_deeply $parsed->{changes}, [ + clear, + change { name => 'hey', ts => '2012-07-16T14:01:20' }, + change { name => 'you', ts => '2012-07-16T14:01:35' }, + tag { + name => 'foo', + note => 'look, a tag!', + ts => '2012-07-16T14:02:05', + rspace => ' ' + }, +, +], 'All "widgets.plan" changes should be parsed'; + +cmp_deeply $parsed->{lines}, [ + clear, + version, + prag( '', '', 'project', '', '=', '', 'widgets'), + blank('', 'This is a note'), + blank(), + blank(' ', 'And there was a blank line.'), + blank(), + change { name => 'hey', ts => '2012-07-16T14:01:20' }, + change { name => 'you', ts => '2012-07-16T14:01:35' }, + + tag { + ret => 1, + name => 'foo', + note => 'look, a tag!', + ts => '2012-07-16T14:02:05', + rspace => ' ' + }, +], 'All "widgets.plan" lines should be parsed'; + +# Plan with multiple tags. +$file = file qw(t plans multi.plan); +$fh = $file->open('<:utf8_strict'); +ok $parsed = $plan->_parse($file, $fh), + 'Should parse multi-tagged "multi.plan"'; +is sorted, 2, 'Should have sorted changes twice'; +cmp_deeply delete $parsed->{pragmas}, { + syntax_version => App::Sqitch::Plan::SYNTAX_VERSION, + project => 'multi', +}, 'Should have captured the multi pragmas'; +cmp_deeply $parsed, { + changes => [ + clear, + change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' }, + change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' }, + tag { + name => 'foo', + note => 'look, a tag!', + ts => '2012-07-16T17:24:07', + rspace => ' ', + planner_name => 'julie', + planner_email => 'j@ul.ie', + }, + change { name => 'this/rocks', pspace => ' ' }, + change { name => 'hey-there', note => 'trailing note!', rspace => ' ' }, + tag { name =>, 'bar' }, + tag { name => 'baz' }, + ], + lines => [ + clear, + version, + prag( '', '', 'project', '', '=', '', 'multi'), + blank('', 'This is a note'), + blank(), + blank('', 'And there was a blank line.'), + blank(), + change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' }, + change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' }, + tag { + ret => 1, + name => 'foo', + note => 'look, a tag!', + ts => '2012-07-16T17:24:07', + rspace => ' ', + planner_name => 'julie', + planner_email => 'j@ul.ie', + }, + blank(' '), + change { name => 'this/rocks', pspace => ' ' }, + change { name => 'hey-there', note => 'trailing note!', rspace => ' ' }, + tag { name =>, 'bar', ret => 1 }, + tag { name => 'baz', ret => 1 }, + ], +}, 'Should have "multi.plan" lines and changes'; + +# Try a plan with changes appearing without a tag. +$file = file qw(t plans changes-only.plan); +$fh = $file->open('<:utf8_strict'); +ok $parsed = $plan->_parse($file, $fh), 'Should read plan with no tags'; +is sorted, 1, 'Should have sorted changes'; +cmp_deeply delete $parsed->{pragmas}, { + syntax_version => App::Sqitch::Plan::SYNTAX_VERSION, + project => 'changes_only', +}, 'Should have captured the changes-only pragmas'; +cmp_deeply $parsed, { + lines => [ + clear, + version, + prag( '', '', 'project', '', '=', '', 'changes_only'), + blank('', 'This is a note'), + blank(), + blank('', 'And there was a blank line.'), + blank(), + change { name => 'hey' }, + change { name => 'you' }, + change { name => 'whatwhatwhat' }, + ], + changes => [ + clear, + change { name => 'hey' }, + change { name => 'you' }, + change { name => 'whatwhatwhat' }, + ], +}, 'Should have lines and changes for tagless plan'; + +# Try plans with DOS line endings. +$file = file qw(t plans dos.plan); +$fh = $file->open('<:utf8_strict'); +ok $parsed = $plan->_parse($file, $fh), 'Should read plan with DOS line endings'; +is sorted, 1, 'Should have sorted changes'; +cmp_deeply delete $parsed->{pragmas}, { + syntax_version => App::Sqitch::Plan::SYNTAX_VERSION, + project => 'dos', +}, 'Should have captured the dos pragmas'; + +# Try a plan with a bad change name. +$file = file qw(t plans bad-change.plan); +$fh = $file->open('<:utf8_strict'); +throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X', + 'Should die on plan with bad change name'; +is $@->ident, 'parse', 'Bad change name error ident should be "parse"'; +is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => $file, + lineno => 5, + error => __( + qq{Invalid name; names must not begin with punctuation, } + . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation', + ), +), 'And the bad change name error message should be correct'; + +is sorted, 0, 'Should not have sorted changes'; + +my @bad_names = ( + '^foo', # No leading punctuation + 'foo^', # No trailing punctuation + 'foo^6', # No trailing punctuation+digit + 'foo^666', # No trailing punctuation+digits + '%hi', # No leading punctuation + 'hi!', # No trailing punctuation + 'foo@bar', # No @ allowed at all + 'foo:bar', # No : allowed at all + '+foo', # No leading + + '-foo', # No leading - + '@foo', # No leading @ +); + +# Try other invalid change and tag name issues. +my $prags = '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION + . "\n%project=test\n\n"; +for my $name (@bad_names) { + for my $line ("+$name", "\@$name") { + next if $line eq '%hi'; # This would be a pragma. + my $buf = $prags . $line; + my $what = $line =~ /^[@]/ ? 'tag' : 'change'; + my $fh = IO::File->new(\$buf, '<:utf8_strict'); + throws_ok { $plan->_parse('baditem', $fh) } 'App::Sqitch::X', + qq{Should die on plan with bad name "$line"}; + is $@->ident, 'parse', 'Exception ident should be "parse"'; + is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => 'baditem', + lineno => 4, + error => __( + qq{Invalid name; names must not begin with punctuation, } + . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation', + ) + ), qq{And "$line" should trigger the appropriate message}; + is sorted, 0, 'Should not have sorted changes'; + } +} + +# Try some valid change and tag names. +my $tsnp = '2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov>'; +my $foo_proj = App::Sqitch::Plan::Pragma->new( + plan => $plan, + name => 'project', + value => 'foo', + operator => '=', +); +for my $name ( + 'foo', # alpha + '12', # digits + 't', # char + '6', # digit + '阱阪阬', # multibyte + 'foo/bar', # middle punct + 'beta1', # ending digit + 'foo_', # ending underscore + '_foo', # leading underscore + 'v1.0-1b', # punctuation followed by digit in middle + 'v1.2-1', # version number with dash + 'v1.2+1', # version number with plus + 'v1.2_1', # version number with underscore +) { + # Test a change name. + my $lines = encode_utf8 "\%project=foo\n\n$name $tsnp"; + my $fh = IO::File->new(\$lines, '<:utf8_strict'); + ok my $parsed = $plan->_parse('ooditem', $fh), + encode_utf8(qq{Should parse "$name"}); + cmp_deeply delete $parsed->{pragmas}, { + syntax_version => App::Sqitch::Plan::SYNTAX_VERSION, + project => 'foo', + }, encode_utf8("Should have captured the $name pragmas"); + cmp_deeply $parsed, { + changes => [ clear, change { name => $name } ], + lines => [ clear, version, $foo_proj, blank, change { name => $name } ], + }, encode_utf8(qq{Should have pragmas in plan with change "$name"}); + + # Test a tag name. + my $tag = '@' . $name; + $lines = encode_utf8 "\%project=foo\n\nfoo $tsnp\n$tag $tsnp"; + $fh = IO::File->new(\$lines, '<:utf8_strict'); + ok $parsed = $plan->_parse('gooditem', $fh), + encode_utf8(qq{Should parse "$tag"}); + cmp_deeply delete $parsed->{pragmas}, { + syntax_version => App::Sqitch::Plan::SYNTAX_VERSION, + project => 'foo', + }, encode_utf8(qq{Should have pragmas in plan with tag "$name"}); + cmp_deeply $parsed, { + changes => [ clear, change { name => 'foo' }, tag { name => $name } ], + lines => [ + clear, + version, + $foo_proj, + blank, + change { name => 'foo' }, + tag { name => $name, ret => 1 } + ], + }, encode_utf8(qq{Should have line and change for "$tag"}); +} +is sorted, 26, 'Should have sorted changes 18 times'; + +# Try planning with other reserved names. +for my $reserved (qw(HEAD ROOT)) { + my $root = $prags . '@' . $reserved . " $tsnp"; + $file = file qw(t plans), "$reserved.plan"; + $fh = IO::File->new(\$root, '<:utf8_strict'); + throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X', + qq{Should die on plan with reserved tag "\@$reserved"}; + is $@->ident, 'parse', qq{\@$reserved exception should have ident "plan"}; + is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => $file, + lineno => 4, + error => __x( + '"{name}" is a reserved name', + name => '@' . $reserved, + ), + ), qq{And the \@$reserved error message should be correct}; + is sorted, 0, "Should have sorted \@$reserved changes nonce"; +} + +# Try a plan with a change name that looks like a sha1 hash. +my $sha1 = '6c2f28d125aff1deea615f8de774599acf39a7a1'; +$file = file qw(t plans sha1.plan); +$fh = IO::File->new(\"$prags$sha1 $tsnp", '<:utf8_strict'); +throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X', + 'Should die on plan with SHA1 change name'; +is $@->ident, 'parse', 'The SHA1 error ident should be "parse"'; +is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => $file, + lineno => 4, + error => __x( + '"{name}" is invalid because it could be confused with a SHA1 ID', + name => $sha1, + ), +), 'And the SHA1 error message should be correct'; +is sorted, 0, 'Should have sorted changes nonce'; + +# Try a plan with a tag but no change. +$file = file qw(t plans tag-no-change.plan); +$fh = IO::File->new(\"$prags\@foo $tsnp\nbar $tsnp", '<:utf8_strict'); +throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X', + 'Should die on plan with tag but no preceding change'; +is $@->ident, 'parse', 'The missing change error ident should be "parse"'; +is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => $file, + lineno => 4, + error => __x( + 'Tag "{tag}" declared without a preceding change', + tag => 'foo', + ), +), 'And the missing change error message should be correct'; +is sorted, 0, 'Should have sorted changes nonce'; + +# Try a plan with a duplicate tag name. +$file = file qw(t plans dupe-tag.plan); +$fh = $file->open('<:utf8_strict'); +throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X', + 'Should die on plan with dupe tag'; +is $@->ident, 'parse', 'The dupe tag error ident should be "parse"'; +is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => $file, + lineno => 12, + error => __x( + 'Tag "{tag}" duplicates earlier declaration on line {line}', + tag => 'bar', + line => 7, + ), +), 'And the missing change error message should be correct'; +is sorted, 2, 'Should have sorted changes twice'; + +# Try a plan with a duplicate change within a tag section. +$file = file qw(t plans dupe-change.plan); +$fh = $file->open('<:utf8_strict'); +throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X', + 'Should die on plan with dupe change'; +is $@->ident, 'parse', 'The dupe change error ident should be "parse"'; +is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => $file, + lineno => 9, + error => __x( + 'Change "{change}" duplicates earlier declaration on line {line}', + change => 'greets', + line => 7, + ), +), 'And the dupe change error message should be correct'; +is sorted, 1, 'Should have sorted changes once'; + +# Try a plan with an invalid requirement. +$fh = IO::File->new(\"\%project=foo\n\nfoo [^bar] $tsnp", '<:utf8_strict'); +throws_ok { $plan->_parse('badreq', $fh ) } 'App::Sqitch::X', + 'Should die on invalid dependency'; +is $@->ident, 'parse', 'The invalid dependency error ident should be "parse"'; +is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => 'badreq', + lineno => 3, + error => __x( + '"{dep}" is not a valid dependency specification', + dep => '^bar', + ), +), 'And the invalid dependency error message should be correct'; +is sorted, 0, 'Should have sorted changes nonce'; + +# Try a plan with duplicate requirements. +$fh = IO::File->new(\"\%project=foo\n\nfoo [bar baz bar] $tsnp", '<:utf8_strict'); +throws_ok { $plan->_parse('dupedep', $fh ) } 'App::Sqitch::X', + 'Should die on dupe dependency'; +is $@->ident, 'parse', 'The dupe dependency error ident should be "parse"'; +is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => 'dupedep', + lineno => 3, + error => __x( + 'Duplicate dependency "{dep}"', + dep => 'bar', + ), +), 'And the dupe dependency error message should be correct'; +is sorted, 0, 'Should have sorted changes nonce'; + +# Try a plan without a timestamp. +$file = file qw(t plans no-timestamp.plan); +$fh = IO::File->new(\"${prags}foo hi <t\@heo.ry>", '<:utf8_strict'); +throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X', + 'Should die on change with no timestamp'; +is $@->ident, 'parse', 'The missing timestamp error ident should be "parse"'; +is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => $file, + lineno => 4, + error => __ 'Missing timestamp', +), 'And the missing timestamp error message should be correct'; +is sorted, 0, 'Should have sorted changes nonce'; + +# Try a plan without a planner. +$file = file qw(t plans no-planner.plan); +$fh = IO::File->new(\"${prags}foo 2012-07-16T23:12:34Z", '<:utf8_strict'); +throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X', + 'Should die on change with no planner'; +is $@->ident, 'parse', 'The missing parsener error ident should be "parse"'; +is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => $file, + lineno => 4, + error => __ 'Missing planner name and email', +), 'And the missing planner error message should be correct'; +is sorted, 0, 'Should have sorted changes nonce'; + +# Try a plan with neither timestamp nor planner. +$file = file qw(t plans no-timestamp-or-planner.plan); +$fh = IO::File->new(\"%project=foo\n\nfoo", '<:utf8_strict'); +throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X', + 'Should die on change with no timestamp or planner'; +is $@->ident, 'parse', 'The missing timestamp or parsener error ident should be "parse"'; +is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => $file, + lineno => 3, + error => __ 'Missing timestamp and planner name and email', +), 'And the missing timestamp or planner error message should be correct'; +is sorted, 0, 'Should have sorted changes nonce'; + +# Try a plan with pragmas. +$file = file qw(t plans pragmas.plan); +$fh = $file->open('<:utf8_strict'); +ok $parsed = $plan->_parse($file, $fh), + 'Should parse plan with pragmas"'; +is sorted, 1, 'Should have sorted changes once'; +cmp_deeply delete $parsed->{pragmas}, { + syntax_version => App::Sqitch::Plan::SYNTAX_VERSION, + foo => 'bar', + project => 'pragmata', + uri => 'https://github.com/sqitchers/sqitch/', + strict => 1, +}, 'Should have captured all of the pragmas'; +cmp_deeply $parsed, { + changes => [ + clear, + change { name => 'hey' }, + change { name => 'you' }, + ], + lines => [ + clear, + prag( '', ' ', 'syntax-version', '', '=', '', App::Sqitch::Plan::SYNTAX_VERSION), + prag( ' ', '', 'foo', ' ', '=', ' ', 'bar', ' ', 'lolz'), + prag( '', ' ', 'project', '', '=', '', 'pragmata'), + prag( '', ' ', 'uri', '', '=', '', 'https://github.com/sqitchers/sqitch/'), + prag( '', ' ', 'strict'), + blank(), + change { name => 'hey' }, + change { name => 'you' }, + blank(), + ], +}, 'Should have "multi.plan" lines and changes'; + +# Try a plan with deploy/revert operators. +$file = file qw(t plans deploy-and-revert.plan); +$fh = $file->open('<:utf8_strict'); +ok $parsed = $plan->_parse($file, $fh), + 'Should parse plan with deploy and revert operators'; +is sorted, 2, 'Should have sorted changes twice'; +cmp_deeply delete $parsed->{pragmas}, { + syntax_version => App::Sqitch::Plan::SYNTAX_VERSION, + project => 'deploy_and_revert', +}, 'Should have captured the deploy-and-revert pragmas'; + +cmp_deeply $parsed, { + changes => [ + clear, + change { name => 'hey', op => '+' }, + change { name => 'you', op => '+' }, + change { name => 'dr_evil', op => '+ ', lspace => ' ' }, + tag { name => 'foo' }, + change { name => 'this/rocks', op => '+', pspace => ' ' }, + change { name => 'hey-there', lspace => ' ' }, + change { + name => 'dr_evil', + note => 'revert!', + op => '-', + rspace => ' ', + pspace => ' ', + rtag => [qw(dr_evil)], + }, + tag { name => 'bar', lspace => ' ' }, + ], + lines => [ + clear, + version, + prag( '', '', 'project', '', '=', '', 'deploy_and_revert'), + blank, + change { name => 'hey', op => '+' }, + change { name => 'you', op => '+' }, + change { name => 'dr_evil', op => '+ ', lspace => ' ' }, + tag { name => 'foo', ret => 1 }, + blank( ' '), + change { name => 'this/rocks', op => '+', pspace => ' ' }, + change { name => 'hey-there', lspace => ' ' }, + change { + name => 'dr_evil', + note => 'revert!', + op => '-', + rspace => ' ', + pspace => ' ', + rtag => [qw(dr_evil)], + }, + tag { name => 'bar', lspace => ' ', ret => 1 }, + ], +}, 'Should have "deploy-and-revert.plan" lines and changes'; + +# Try a non-existent plan file with load(). +$file = file qw(t hi nonexistent.plan); +$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file); +throws_ok { App::Sqitch::Plan->new(sqitch => $sqitch, target => $target)->load } 'App::Sqitch::X', + 'Should get exception for nonexistent plan file'; +is $@->ident, 'plan', 'Nonexistent plan file ident should be "plan"'; +is $@->message, __x( + 'Plan file {file} does not exist', + file => $file, +), 'Nonexistent plan file message should be correct'; + +# Try a plan with dependencies. +$file = file qw(t plans dependencies.plan); +$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file); +isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target), $CLASS, + 'Plan with sqitch with plan file with dependencies'; +is $plan->file, $target->plan_file, 'File should be coopied from Sqitch'; +ok $parsed = $plan->load, 'Load plan with dependencies file'; +is_deeply $parsed->{changes}, [ + clear, + change { name => 'roles', op => '+' }, + change { name => 'users', op => '+', pspace => ' ', requires => ['roles'] }, + change { name => 'add_user', op => '+', pspace => ' ', requires => [qw(users roles)] }, + change { name => 'dr_evil', op => '+' }, + tag { name => 'alpha' }, + change { + name => 'users', + op => '+', + pspace => ' ', + requires => ['users@alpha'], + rtag => [qw(dr_evil add_user users)], + }, + change { name => 'dr_evil', op => '-', rtag => [qw(dr_evil)] }, + change { + name => 'del_user', + op => '+', + pspace => ' ', + requires => ['users'], + conflicts => ['dr_evil'] + }, +], 'The changes should include the dependencies'; +is sorted, 2, 'Should have sorted changes twice'; + +# Try a plan with cross-project dependencies. +$file = file qw(t plans project_deps.plan); +$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file); +isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target), $CLASS, + 'Plan with sqitch with plan file with project deps'; +is $plan->file, $target->plan_file, 'File should be coopied from Sqitch'; +ok $parsed = $plan->load, 'Load plan with project deps file'; +is_deeply $parsed->{changes}, [ + clear, + change { name => 'roles', op => '+' }, + change { name => 'users', op => '+', pspace => ' ', requires => ['roles'] }, + change { name => 'add_user', op => '+', pspace => ' ', requires => [qw(users roles log:logger)] }, + change { name => 'dr_evil', op => '+' }, + tag { name => 'alpha' }, + change { + name => 'users', + op => '+', + pspace => ' ', + requires => ['users@alpha'], + rtag => [qw(dr_evil add_user users)], + }, + change { name => 'dr_evil', op => '-', rtag => [qw(dr_evil)] }, + + change { + name => 'del_user', + op => '+', + pspace => ' ', + requires => ['users', 'log:logger@beta1'], + conflicts => ['dr_evil'] + }, +], 'The changes should include the cross-project deps'; +is sorted, 2, 'Should have sorted changes twice'; + +# Should fail with dependencies on tags. +$file = file qw(t plans tag_dependencies.plan); +$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file); +$fh = IO::File->new(\"%project=tagdep\n\nfoo $tsnp\n\@bar [:foo] $tsnp", '<:utf8_strict'); +isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target), + $CLASS, 'Plan with sqitch with plan with tag dependencies'; +is $plan->file, $target->plan_file, 'File should be coopied from Sqitch'; +throws_ok { $plan->_parse($file, $fh) } 'App::Sqitch::X', + 'Should get an exception for tag with dependencies'; +is $@->ident, 'parse', 'The tag dependencies error ident should be "plan"'; +is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => $file, + lineno => 4, + error => __ 'Tags may not specify dependencies', +), 'And the tag dependencies error message should be correct'; + +# Make sure that lines() loads the plan. +$file = file qw(t plans multi.plan); +$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file); +isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target), $CLASS, + 'Plan with sqitch with plan file'; +is $plan->file, $target->plan_file, 'File should be coopied from Sqitch'; +cmp_deeply [$plan->lines], [ + clear, + version, + prag( '', '', 'project', '', '=', '', 'multi'), + blank('', 'This is a note'), + blank(), + blank('', 'And there was a blank line.'), + blank(), + change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' }, + change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' }, + tag { + ret => 1, + name => 'foo', + note => 'look, a tag!', + ts => '2012-07-16T17:24:07', + rspace => ' ', + planner_name => 'julie', + planner_email => 'j@ul.ie', + }, + blank(' '), + change { name => 'this/rocks', pspace => ' ' }, + change { name => 'hey-there', note => 'trailing note!', rspace => ' ' }, + tag { name =>, 'bar', ret => 1 }, + tag { name => 'baz', ret => 1 }, +], 'Lines should be parsed from file'; + +$vivify = 1; +cmp_deeply [$plan->changes], [ + clear, + change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' }, + change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' }, + tag { + name => 'foo', + note => 'look, a tag!', + ts => '2012-07-16T17:24:07', + rspace => ' ', + planner_name => 'julie', + planner_email => 'j@ul.ie', + }, + change { name => 'this/rocks', pspace => ' ' }, + change { name => 'hey-there', note => 'trailing note!', rspace => ' ' }, + tag { name =>, 'bar' }, + tag { name => 'baz' }, +], 'Changes should be parsed from file'; + +clear; +change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' }; +change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' }; + +my $foo_tag = tag { + ret => 1, + name => 'foo', + note => 'look, a tag!', + ts => '2012-07-16T17:24:07', + rspace => ' ', + planner_name => 'julie', + planner_email => 'j@ul.ie', +}; + +change { name => 'this/rocks', pspace => ' ' }; +change { name => 'hey-there', rspace => ' ', note => 'trailing note!' }; +cmp_deeply [$plan->tags], [ + $foo_tag, + tag { name =>, 'bar', ret => 1 }, + tag { name => 'baz', ret => 1 }, +], 'Should get all tags from tags()'; +is sorted, 2, 'Should have sorted changes twice'; + +ok $parsed = $plan->load, 'Load should parse plan from file'; +cmp_deeply delete $parsed->{pragmas}, { + syntax_version => App::Sqitch::Plan::SYNTAX_VERSION, + project => 'multi', +}, 'Should have captured the multi pragmas'; +$vivify = 0; +cmp_deeply $parsed, { + lines => [ + clear, + version, + prag( '', '', 'project', '', '=', '', 'multi'), + blank('', 'This is a note'), + blank(), + blank('', 'And there was a blank line.'), + blank(), + change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' }, + change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' }, + tag { + ret => 1, + name => 'foo', + note => 'look, a tag!', + ts => '2012-07-16T17:24:07', + rspace => ' ', + planner_name => 'julie', + planner_email => 'j@ul.ie', + }, + blank(' '), + change { name => 'this/rocks', pspace => ' ' }, + change { name => 'hey-there', note => 'trailing note!', rspace => ' ' }, + tag { name =>, 'bar', ret => 1 }, + tag { name => 'baz', ret => 1 }, + ], + changes => [ + clear, + change { name => 'hey', planner_name => 'theory', planner_email => 't@heo.ry' }, + change { name => 'you', planner_name => 'anna', planner_email => 'a@n.na' }, + tag { + name => 'foo', + note => 'look, a tag!', + ts => '2012-07-16T17:24:07', + rspace => ' ', + planner_name => 'julie', + planner_email => 'j@ul.ie', + }, + change { name => 'this/rocks', pspace => ' ' }, + change { name => 'hey-there', note => 'trailing note!', rspace => ' ' }, + tag { name =>, 'bar' }, + tag { name => 'baz' }, + ], +}, 'And the parsed file should have lines and changes'; +is sorted, 2, 'Should have sorted changes twice'; + +############################################################################## +# Test the interator interface. +can_ok $plan, qw( + index_of + contains + get + seek + reset + next + current + peek + do +); + +is $plan->position, -1, 'Position should start at -1'; +is $plan->current, undef, 'Current should be undef'; +ok my $change = $plan->next, 'Get next change'; +isa_ok $change, 'App::Sqitch::Plan::Change', 'First change'; +is $change->name, 'hey', 'It should be the first change'; +is $plan->position, 0, 'Position should be at 0'; +is $plan->count, 4, 'Count should be 4'; +is $plan->current, $change, 'Current should be current'; +is $plan->change_at(0), $change, 'Should get first change from change_at(0)'; + +ok my $next = $plan->peek, 'Peek to next change'; +isa_ok $next, 'App::Sqitch::Plan::Change', 'Peeked change'; +is $next->name, 'you', 'Peeked change should be second change'; +is $plan->last->format_name, 'hey-there', 'last() should return last change'; +is $plan->current, $change, 'Current should still be current'; +is $plan->peek, $next, 'Peek should still be next'; +is $plan->next, $next, 'Next should be the second change'; +is $plan->position, 1, 'Position should be at 1'; +is $plan->change_at(1), $next, 'Should get second change from change_at(1)'; + +ok my $third = $plan->peek, 'Peek should return an object'; +isa_ok $third, 'App::Sqitch::Plan::Change', 'Third change'; +is $third->name, 'this/rocks', 'It should be the foo tag'; +is $plan->current, $next, 'Current should be the second change'; +is $plan->next, $third, 'Should get third change next'; +is $plan->position, 2, 'Position should be at 2'; +is $plan->current, $third, 'Current should be third change'; +is $plan->change_at(2), $third, 'Should get third change from change_at(1)'; + +ok my $fourth = $plan->next, 'Get fourth change'; +isa_ok $fourth, 'App::Sqitch::Plan::Change', 'Fourth change'; +is $fourth->name, 'hey-there', 'Fourth change should be "hey-there"'; +is $plan->position, 3, 'Position should be at 3'; + +is $plan->peek, undef, 'Peek should return undef'; +is $plan->next, undef, 'Next should return undef'; +is $plan->position, 4, 'Position should be at 7'; + +is $plan->next, undef, 'Next should still return undef'; +is $plan->position, 4, 'Position should still be at 7'; +ok $plan->reset, 'Reset the plan'; + +is $plan->position, -1, 'Position should be back at -1'; +is $plan->current, undef, 'Current should still be undef'; +is $plan->next, $change, 'Next should return the first change again'; +is $plan->position, 0, 'Position should be at 0 again'; +is $plan->current, $change, 'Current should be first change'; +is $plan->index_of($change->name), 0, "Index of change should be 0"; +ok $plan->contains($change->name), 'Plan should contain change'; +is $plan->get($change->name), $change, 'Should be able to get change 0 by name'; +is $plan->find($change->name), $change, 'Should be able to find change 0 by name'; +is $plan->get($change->id), $change, 'Should be able to get change 0 by ID'; +is $plan->find($change->id), $change, 'Should be able to find change 0 by ID'; +is $plan->index_of('@bar'), 3, 'Index of @bar should be 3'; +ok $plan->contains('@bar'), 'Plan should contain @bar'; +is $plan->get('@bar'), $fourth, 'Should be able to get hey-there via @bar'; +is $plan->get($fourth->id), $fourth, 'Should be able to get hey-there via @bar ID'; +is $plan->find('@bar'), $fourth, 'Should be able to find hey-there via @bar'; +is $plan->find($fourth->id), $fourth, 'Should be able to find hey-there via @bar ID'; +ok $plan->seek('@bar'), 'Seek to the "@bar" change'; +is $plan->position, 3, 'Position should be at 3 again'; +is $plan->current, $fourth, 'Current should be fourth again'; +is $plan->index_of('you'), 1, 'Index of you should be 1'; +ok $plan->contains('you'), 'Plan should contain "you"'; +is $plan->get('you'), $next, 'Should be able to get change 1 by name'; +is $plan->find('you'), $next, 'Should be able to find change 1 by name'; +ok $plan->seek('you'), 'Seek to the "you" change'; +is $plan->position, 1, 'Position should be at 1 again'; +is $plan->current, $next, 'Current should be second again'; +is $plan->index_of('baz'), undef, 'Index of baz should be undef'; +ok !$plan->contains('baz'), 'Plan should not contain "baz"'; +is $plan->index_of('@baz'), 3, 'Index of @baz should be 3'; +ok $plan->contains('@baz'), 'Plan should contain @baz'; +ok $plan->seek('@baz'), 'Seek to the "baz" change'; +is $plan->position, 3, 'Position should be at 3 again'; +is $plan->current, $fourth, 'Current should be fourth again'; + +is $plan->change_at(0), $change, 'Should still get first change from change_at(0)'; +is $plan->change_at(1), $next, 'Should still get second change from change_at(1)'; +is $plan->change_at(2), $third, 'Should still get third change from change_at(1)'; + +# Make sure seek() chokes on a bad change name. +throws_ok { $plan->seek('nonesuch') } 'App::Sqitch::X', + 'Should die seeking invalid change'; +is $@->ident, 'plan', 'Invalid seek change error ident should be "plan"'; +is $@->message, __x( + 'Cannot find change "{change}" in plan', + change => 'nonesuch', +), 'And the failure message should be correct'; + +# Get all! +my @changes = ($change, $next, $third, $fourth); +cmp_deeply [$plan->changes], \@changes, 'All should return all changes'; +ok $plan->reset, 'Reset the plan again'; +$plan->do(sub { + is shift, $changes[0], 'Change ' . $changes[0]->name . ' should be passed to do sub'; + is $_, $changes[0], 'Change ' . $changes[0]->name . ' should be the topic in do sub'; + shift @changes; +}); + +# There should be no more to iterate over. +$plan->do(sub { fail 'Should not get anything passed to do()' }); + +############################################################################## +# Let's try searching changes. +isa_ok my $iter = $plan->search_changes, 'CODE', + 'search_changes() should return a code ref'; + +my $get_all_names = sub { + my $iter = shift; + my @res; + while (my $change = $iter->()) { + push @res => $change->name; + } + return \@res; +}; + +is_deeply $get_all_names->($iter), [qw(hey you this/rocks hey-there)], + 'All the changes should be returned in the proper order'; + +# Try reverse order. +is_deeply $get_all_names->( $plan->search_changes( direction => 'DESC' ) ), + [qw(hey-there this/rocks you hey)], 'Direction "DESC" should work'; + +# Try invalid directions. +throws_ok { $plan->search_changes( direction => 'foo' ) } 'App::Sqitch::X', + 'Should get error for invalid direction'; +is $@->ident, 'DEV', 'Invalid direction error ident should be "DEV"'; +is $@->message, 'Search direction must be either "ASC" or "DESC"', + 'Invalid direction error message should be correct'; + +# Try ascending lowercased. +is_deeply $get_all_names->( $plan->search_changes( direction => 'asc' ) ), + [qw(hey you this/rocks hey-there)], 'Direction "asc" should work'; + +# Try change name. +is_deeply $get_all_names->( $plan->search_changes( name => 'you')), + [qw(you)], 'Search by change name should work'; + +is_deeply $get_all_names->( $plan->search_changes( name => 'hey')), + [qw(hey hey-there)], 'Search by change name should work as a regex'; + +is_deeply $get_all_names->( $plan->search_changes( name => '[-/]')), + [qw(this/rocks hey-there)], + 'Search by change name should with a character class'; + +# Try planner name. +is_deeply $get_all_names->( $plan->search_changes( planner => 'Barack' ) ), + [qw(this/rocks hey-there)], 'Search by planner should work'; + +is_deeply $get_all_names->( $plan->search_changes( planner => 'a..a' ) ), + [qw(you)], 'Search by planner should work as a regex'; + +# Search by operation. +is_deeply $get_all_names->( $plan->search_changes( operation => 'deploy' ) ), + [qw(hey you this/rocks hey-there)], 'Search by operation "deploy" should work'; + +is_deeply $get_all_names->( $plan->search_changes( operation => 'revert' ) ), + [], 'Search by operation "rever" should return nothing'; + +# Fake out an operation. +my $mock_change = Test::MockModule->new('App::Sqitch::Plan::Change'); +$mock_change->mock( operator => sub { return shift->name =~ /hey/ ? '-' : '+' }); + +is_deeply $get_all_names->( $plan->search_changes( operation => 'DEPLOY' ) ), + [qw(you this/rocks)], 'Search by operation "DEPLOY" should now return two changes'; + +is_deeply $get_all_names->( $plan->search_changes( operation => 'REVERT' ) ), + [qw(hey hey-there)], 'Search by operation "REVERT" should return the other two'; + +$mock_change->unmock_all; + +# Make sure we test only for legal operations. +throws_ok { $plan->search_changes( operation => 'foo' ) } 'App::Sqitch::X', + 'Should get an error for unknown operation'; +is $@->ident, 'DEV', 'Unknown operation error ident should be "DEV"'; +is $@->message, 'Unknown change operation "foo"', + 'Unknown operation error message should be correct'; + +# Test offset and limit. +is_deeply $get_all_names->( $plan->search_changes( offset => 2 ) ), + [qw(this/rocks hey-there)], 'Search with offset 2 should work'; + +is_deeply $get_all_names->( $plan->search_changes( offset => 2, limit => 1 ) ), + [qw(this/rocks)], 'Search with offset 2, limit 1 should work'; + +is_deeply $get_all_names->( $plan->search_changes( offset => 3, direction => 'desc' ) ), + [qw(hey)], 'Search with offset 3 and dierction "desc" should work'; + +is_deeply $get_all_names->( $plan->search_changes( offset => 2, limit => 1, direction => 'desc' ) ), + [qw(you)], 'Search with offset 2, limit 1, dierction "desc" should work'; + +############################################################################## +# Test writing the plan. +can_ok $plan, 'write_to'; +my $to = file 'plan.out'; +END { unlink $to } +file_not_exists_ok $to; +ok $plan->write_to($to), 'Write out the file'; +file_exists_ok $to; +my $v = App::Sqitch->VERSION; +file_contents_is $to, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n" + . $file->slurp(iomode => '<:utf8_strict'), + 'The contents should look right'; + +# Make sure it will start from a certain point. +ok $plan->write_to($to, 'this/rocks'), 'Write out the file from "this/rocks"'; +file_contents_is $to, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n" + . '%project=multi' . "\n" + . '# This is a note' . "\n" + . "\n" + . $plan->find('this/rocks')->as_string . "\n" + . $plan->find('hey-there')->as_string . "\n" + . join( "\n", map { $_->as_string } $plan->find('hey-there')->tags ) . "\n", + 'Plan should have been written from "this/rocks" through tags at end'; + +# Make sure it ends at a certain point. +ok $plan->write_to($to, undef, 'you'), 'Write the file up to "you"'; +file_contents_is $to, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n" + . '%project=multi' . "\n" + . '# This is a note' . "\n" + . "\n" + . '# And there was a blank line.' . "\n" + . "\n" + . $plan->find('hey')->as_string . "\n" + . $plan->find('you')->as_string . "\n" + . join( "\n", map { $_->as_string } $plan->find('you')->tags ) . "\n", + 'Plan should have been written through "you" and its tags'; + +# Try both. +ok $plan->write_to($to, '@foo', 'this/rocks'), + 'Write from "@foo" to "this/rocks"'; +file_contents_is $to, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n" + . '%project=multi' . "\n" + . '# This is a note' . "\n" + . "\n" + . $plan->find('you')->as_string . "\n" + . join( "\n", map { $_->as_string } $plan->find('you')->tags ) . "\n" + . ' ' . "\n" + . $plan->find('this/rocks')->as_string . "\n", + 'Plan should have been written from "@foo" to "this/rocks"'; + +# End with a tag. +ok $plan->write_to($to, 'hey', '@foo'), 'Write from "hey" to "@foo"'; +file_contents_is $to, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n" + . '%project=multi' . "\n" + . '# This is a note' . "\n" + . "\n" + . $plan->find('hey')->as_string . "\n" + . $plan->find('you')->as_string . "\n" + . join( "\n", map { $_->as_string } $plan->find('you')->tags ) . "\n", + 'Plan should have been written from "hey" through "@foo"'; + +############################################################################## +# Test _is_valid. +can_ok $plan, '_is_valid'; + +for my $name (@bad_names) { + throws_ok { $plan->_is_valid( tag => $name) } 'App::Sqitch::X', + qq{Should find "$name" invalid}; + is $@->ident, 'plan', qq{Invalid name "$name" error ident should be "plan"}; + is $@->message, __x( + qq{"{name}" is invalid: tags must not begin with punctuation, } + . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation', + name => $name, + ), qq{And the "$name" error message should be correct}; +} + +# Try some valid names. +for my $name ( + 'foo', # alpha + '12', # digits + 't', # char + '6', # digit + '阱阪阬', # multibyte + 'foo/bar', # middle punct + 'beta1', # ending digit + 'v1.2-1', # version number with dash + 'v1.2+1', # version number with plus + 'v1.2_1', # version number with underscore +) { + local $ENV{FOO} = 1; + my $disp = Encode::encode_utf8($name); + ok $plan->_is_valid(change => $name), qq{Name "$disp" should be valid}; +} + +############################################################################## +# Try adding a tag. +ok my $tag = $plan->tag( name => 'w00t' ), 'Add tag "w00t"'; +is $plan->count, 4, 'Should have 4 changes'; +ok $plan->contains('@w00t'), 'Should find "@w00t" in plan'; +is $plan->index_of('@w00t'), 3, 'Should find "@w00t" at index 3'; +is $plan->last->name, 'hey-there', 'Last change should be "hey-there"'; +is_deeply [map { $_->name } $plan->last->tags], [qw(bar baz w00t)], + 'The w00t tag should be on the last change'; +isa_ok $tag, 'App::Sqitch::Plan::Tag'; +is $tag->name, 'w00t', 'The returned tag should be @w00t'; +is $tag->change, $plan->last, 'The @w00t change should be the last change'; + +ok $plan->write_to($to), 'Write out the file again'; +file_contents_is $to, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n" + . $file->slurp(iomode => '<:utf8_strict') + . $tag->as_string . "\n", + { encoding => 'UTF-8' }, + 'The contents should include the "w00t" tag'; +# Try passing the tag name with a leading @. +ok my $tag2 = $plan->tag( name => '@alpha' ), 'Add tag "@alpha"'; +ok $plan->contains('@alpha'), 'Should find "@alpha" in plan'; +is $plan->index_of('@alpha'), 3, 'Should find "@alpha" at index 3'; +is $tag2->name, 'alpha', 'The returned tag should be @alpha'; +is $tag2->change, $plan->last, 'The @alpha change should be the last change'; + +# Try specifying the change to tag. +ok my $tag3 = $plan->tag(name => 'blarney', change => 'you'), + 'Tag change "you"'; +is $plan->count, 4, 'Should still have 4 changes'; +ok $plan->contains('@blarney'), 'Should find "@blarney" in plan'; +is $plan->index_of('@blarney'), 1, 'Should find "@blarney" at index 1'; +is_deeply [map { $_->name } $plan->change_at(1)->tags], [qw(foo blarney)], + 'The blarney tag should be on the second change'; +isa_ok $tag3, 'App::Sqitch::Plan::Tag'; +is $tag3->name, 'blarney', 'The returned tag should be @blarney'; +is $tag3->change, $plan->change_at(1), 'The @blarney change should be the second change'; + +# Should choke on a duplicate tag. +throws_ok { $plan->tag( name => 'w00t' ) } 'App::Sqitch::X', + 'Should get error trying to add duplicate tag'; +is $@->ident, 'plan', 'Duplicate tag error ident should be "plan"'; +is $@->message, __x( + 'Tag "{tag}" already exists', + tag => '@w00t', +), 'And the error message should report it as a dupe'; + +# Should choke on an invalid tag names. +for my $name (@bad_names, 'foo#bar') { + next if $name =~ /^@/; + throws_ok { $plan->tag( name => $name ) } 'App::Sqitch::X', + qq{Should get error for invalid tag "$name"}; + is $@->ident, 'plan', qq{Invalid name "$name" error ident should be "plan"}; + is $@->message, __x( + qq{"{name}" is invalid: tags must not begin with punctuation, } + . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation', + name => $name, + ), qq{And the "$name" error message should be correct}; +} + +# Validate reserved names. +for my $reserved (qw(HEAD ROOT)) { + throws_ok { $plan->tag( name => $reserved ) } 'App::Sqitch::X', + qq{Should get error for reserved tag "$reserved"}; + is $@->ident, 'plan', qq{Reserved tag "$reserved" error ident should be "plan"}; + is $@->message, __x( + '"{name}" is a reserved name', + name => $reserved, + ), qq{And the reserved tag "$reserved" message should be correct}; +} + +throws_ok { $plan->tag( name => $sha1 ) } 'App::Sqitch::X', + 'Should get error for a SHA1 tag'; +is $@->ident, 'plan', 'SHA1 tag error ident should be "plan"'; +is $@->message, __x( + '"{name}" is invalid because it could be confused with a SHA1 ID', + name => $sha1,, +), 'And the reserved name error should be output'; + +############################################################################## +# Try adding a change. +ok my $new_change = $plan->add(name => 'booyah', note => 'Hi there'), + 'Add change "booyah"'; +is $plan->count, 5, 'Should have 5 changes'; +ok $plan->contains('booyah'), 'Should find "booyah" in plan'; +is $plan->index_of('booyah'), 4, 'Should find "booyah" at index 4'; +is $plan->last->name, 'booyah', 'Last change should be "booyah"'; +isa_ok $new_change, 'App::Sqitch::Plan::Change'; +is $new_change->as_string, join (' ', + 'booyah', + $new_change->timestamp->as_string, + $new_change->format_planner, + $new_change->format_note, +), 'Should have plain stringification of "booya"'; + +my $contents = $file->slurp(iomode => '<:utf8_strict'); +$contents =~ s{(\s+this/rocks)}{"\n" . $tag3->as_string . $1}ems; +ok $plan->write_to($to), 'Write out the file again'; +file_contents_is $to, + '%syntax-version=' . App::Sqitch::Plan::SYNTAX_VERSION . "\n" + . $contents + . $tag->as_string . "\n" + . $tag2->as_string . "\n\n" + . $new_change->as_string . "\n", + { encoding => 'UTF-8' }, + 'The contents should include the "booyah" change'; + +# Make sure dependencies are verified. +ok $new_change = $plan->add(name => 'blow', requires => ['booyah']), + 'Add change "blow"'; +is $plan->count, 6, 'Should have 6 changes'; +ok $plan->contains('blow'), 'Should find "blow" in plan'; +is $plan->index_of('blow'), 5, 'Should find "blow" at index 5'; +is $plan->last->name, 'blow', 'Last change should be "blow"'; +is $new_change->as_string, + 'blow [booyah] ' . $new_change->timestamp->as_string . ' ' + . $new_change->format_planner, + 'Should have nice stringification of "blow [booyah]"'; +is [$plan->lines]->[-1], $new_change, + 'The new change should have been appended to the lines, too'; + +# Make sure dependencies are unique. +ok $new_change = $plan->add(name => 'jive', requires => [qw(blow blow)]), + 'Add change "jive" with dupe dependency'; +is $plan->count, 7, 'Should have 7 changes'; +ok $plan->contains('jive'), 'Should find "jive" in plan'; +is $plan->index_of('jive'), 6, 'Should find "jive" at index 6'; +is $plan->last->name, 'jive', 'jive change should be "jive"'; +is_deeply [ map { $_->change } $new_change->requires ], ['blow'], + 'Should have dependency "blow"'; +is $new_change->as_string, + 'jive [blow] ' . $new_change->timestamp->as_string . ' ' + . $new_change->format_planner, + 'Should have nice stringification of "jive [blow]"'; +is [$plan->lines]->[-1], $new_change, + 'The new change should have been appended to the lines, too'; + +# Make sure externals and conflicts are unique. +ok $new_change = $plan->add( + name => 'moo', + requires => [qw(ext:foo ext:foo)], + conflicts => [qw(blow blow ext:whu ext:whu)], +), 'Add change "moo" with dupe dependencies'; + +is $plan->count, 8, 'Should have 8 changes'; +ok $plan->contains('moo'), 'Should find "moo" in plan'; +is $plan->index_of('moo'), 7, 'Should find "moo" at index 7'; +is $plan->last->name, 'moo', 'moo change should be "moo"'; +is_deeply [ map { $_->as_string } $new_change->requires ], ['ext:foo'], + 'Should require "ext:whu"'; +is_deeply [ map { $_->as_string } $new_change->conflicts ], [qw(blow ext:whu)], + 'Should conflict with "blow" and "ext:whu"'; +is $new_change->as_string, + 'moo [ext:foo !blow !ext:whu] ' . $new_change->timestamp->as_string . ' ' + . $new_change->format_planner, + 'Should have nice stringification of "moo [ext:foo !blow !ext:whu]"'; +is [$plan->lines]->[-1], $new_change, + 'The new change should have been appended to the lines, too'; + +# Should choke on a duplicate change. +throws_ok { $plan->add(name => 'blow') } 'App::Sqitch::X', + 'Should get error trying to add duplicate change'; +is $@->ident, 'plan', 'Duplicate change error ident should be "plan"'; +is $@->message, __x( + qq{Change "{change}" already exists in plan {file}.\nUse "sqitch rework" to copy and rework it}, + change => 'blow', + file => $plan->file, +), 'And the error message should suggest "rework"'; + +# Should choke on an invalid change names. +for my $name (@bad_names) { + throws_ok { $plan->add( name => $name ) } 'App::Sqitch::X', + qq{Should get error for invalid change "$name"}; + is $@->ident, 'plan', qq{Invalid name "$name" error ident should be "plan"}; + is $@->message, __x( + qq{"{name}" is invalid: changes must not begin with punctuation, } + . 'contain "@", ":", "#", or blanks, or end in punctuation or digits following punctuation', + name => $name, + ), qq{And the "$name" error message should be correct}; +} + +# Try a reserved name. +for my $reserved (qw(HEAD ROOT)) { + throws_ok { $plan->add( name => $reserved ) } 'App::Sqitch::X', + qq{Should get error for reserved name "$reserved"}; + is $@->ident, 'plan', qq{Reserved name "$reserved" error ident should be "plan"}; + is $@->message, __x( + '"{name}" is a reserved name', + name => $reserved, + ), qq{And the reserved name "$reserved" message should be correct}; +} + +# Try an unknown dependency. +throws_ok { $plan->add( name => 'whu', requires => ['nonesuch' ] ) } 'App::Sqitch::X', + 'Should get failure for failed dependency'; +is $@->ident, 'plan', 'Dependency error ident should be "plan"'; +is $@->message, __x( + 'Cannot add change "{change}": requires unknown change "{req}"', + change => 'whu', + req => 'nonesuch', +), 'The dependency error should be correct'; + +# Try invalid dependencies. +throws_ok { $plan->add( name => 'whu', requires => ['^bogus' ] ) } 'App::Sqitch::X', + 'Should get failure for invalid dependency'; +is $@->ident, 'plan', 'Invalid dependency error ident should be "plan"'; +is $@->message, __x( + '"{dep}" is not a valid dependency specification', + dep => '^bogus', +), 'The invalid dependency error should be correct'; + +throws_ok { $plan->add( name => 'whu', conflicts => ['^bogus' ] ) } 'App::Sqitch::X', + 'Should get failure for invalid conflict'; +is $@->ident, 'plan', 'Invalid conflict error ident should be "plan"'; +is $@->message, __x( + '"{dep}" is not a valid dependency specification', + dep => '^bogus', +), 'The invalid conflict error should be correct'; + +# Should choke on an unknown tag, too. +throws_ok { $plan->add(name => 'whu', requires => ['@nonesuch' ] ) } 'App::Sqitch::X', + 'Should get failure for failed tag dependency'; +is $@->ident, 'plan', 'Tag dependency error ident should be "plan"'; +is $@->message, __x( + 'Cannot add change "{change}": requires unknown change "{req}"', + change => 'whu', + req => '@nonesuch', +), 'The tag dependency error should be correct'; + +# Should choke on a change that looks like a SHA1. +throws_ok { $plan->add(name => $sha1) } 'App::Sqitch::X', + 'Should get error for a SHA1 change'; +is $@->ident, 'plan', 'SHA1 tag error ident should be "plan"'; +is $@->message, __x( + '"{name}" is invalid because it could be confused with a SHA1 ID', + name => $sha1,, +), 'And the reserved name error should be output'; + +############################################################################## +# Try reworking a change. +can_ok $plan, 'rework'; +ok my $rev_change = $plan->rework( name => 'you' ), 'Rework change "you"'; +isa_ok $rev_change, 'App::Sqitch::Plan::Change'; +is $rev_change->name, 'you', 'Reworked change should be "you"'; +ok my $orig = $plan->change_at($plan->first_index_of('you')), + 'Get original "you" change'; +is $orig->name, 'you', 'It should also be named "you"'; +is_deeply [ map { $_->format_name } $orig->rework_tags ], + [qw(@bar)], 'And it should have the one rework tag'; +is $orig->deploy_file, $target->deploy_dir->file('you@bar.sql'), + 'The original file should now be named you@bar.sql'; +is $rev_change->as_string, + 'you [you@bar] ' . $rev_change->timestamp->as_string . ' ' + . $rev_change->format_planner, + 'It should require the previous "you" change'; +is [$plan->lines]->[-1], $rev_change, + 'The new "you" should have been appended to the lines, too'; + +# Make sure it was appended to the plan. +ok $plan->contains('you@HEAD'), 'Should find "you@HEAD" in plan'; +is $plan->index_of('you@HEAD'), 8, 'It should be at position 8'; +is $plan->count, 9, 'The plan count should be 9'; + +# Tag and add again, to be sure we can do it multiple times. +ok $plan->tag( name => '@beta1' ), 'Tag @beta1'; +ok my $rev_change2 = $plan->rework( name => 'you' ), + 'Rework change "you" again'; +isa_ok $rev_change2, 'App::Sqitch::Plan::Change'; +is $rev_change2->name, 'you', 'New reworked change should be "you"'; +ok $orig = $plan->change_at($plan->first_index_of('you')), + 'Get original "you" change again'; +is $orig->name, 'you', 'It should still be named "you"'; +is_deeply [ map { $_->format_name } $orig->rework_tags ], + [qw(@bar)], 'And it should have the one rework tag'; +ok $rev_change = $plan->get('you@beta1'), 'Get you@beta1'; +is $rev_change->name, 'you', 'The second "you" should be named that'; +is_deeply [ map { $_->format_name } $rev_change->rework_tags ], + [qw(@beta1)], 'And the second change should have the rework_tag "@beta1"'; +is_deeply [ $rev_change2->rework_tags ], + [], 'But the new reworked change should have no rework tags'; +is $rev_change2->as_string, + 'you [you@beta1] ' . $rev_change2->timestamp->as_string . ' ' + . $rev_change2->format_planner, + 'It should require the previous "you" change'; +is [$plan->lines]->[-1], $rev_change2, + 'The new reworking should have been appended to the lines'; + +# Make sure it was appended to the plan. +ok $plan->contains('you@HEAD'), 'Should find "you@HEAD" in plan'; +is $plan->index_of('you@HEAD'), 9, 'It should be at position 9'; +is $plan->count, 10, 'The plan count should be 10'; + +# Try a nonexistent change name. +throws_ok { $plan->rework( name => 'nonexistent' ) } 'App::Sqitch::X', + 'rework should die on nonexistent change'; +is $@->ident, 'plan', 'Nonexistent change error ident should be "plan"'; +is $@->message, __x( + qq{Change "{change}" does not exist in {file}.\nUse "sqitch add {change}" to add it to the plan}, + change => 'nonexistent', + file => $plan->file, +), 'And the error should suggest "sqitch add"'; + +# Try reworking without an intervening tag. +throws_ok { $plan->rework( name => 'you' ) } 'App::Sqitch::X', + 'rework_stpe should die on lack of intervening tag'; +is $@->ident, 'plan', 'Missing tag error ident should be "plan"'; +is $@->message, __x( + qq{Cannot rework "{change}" without an intervening tag.\nUse "sqitch tag" to create a tag and try again}, + change => 'you', +), 'And the error should suggest "sqitch tag"'; + +# Make sure it checks dependencies. +throws_ok { $plan->rework( name => 'booyah', requires => ['nonesuch' ] ) } + 'App::Sqitch::X', + 'rework should die on failed dependency'; +is $@->ident, 'plan', 'Rework dependency error ident should be "plan"'; +is $@->message, __x( + 'Cannot rework change "{change}": requires unknown change "{req}"', + change => 'booyah', + req => 'nonesuch', +), 'The rework dependency error should be correct'; + +# Try invalid dependencies. +throws_ok { $plan->rework( name => 'booyah', requires => ['^bogus' ] ) } 'App::Sqitch::X', + 'Should get failure for invalid dependency'; +is $@->ident, 'plan', 'Invalid dependency error ident should be "plan"'; +is $@->message, __x( + '"{dep}" is not a valid dependency specification', + dep => '^bogus', +), 'The invalid dependency error should be correct'; + +throws_ok { $plan->rework( name => 'booyah', conflicts => ['^bogus' ] ) } 'App::Sqitch::X', + 'Should get failure for invalid conflict'; +is $@->ident, 'plan', 'Invalid conflict error ident should be "plan"'; +is $@->message, __x( + '"{dep}" is not a valid dependency specification', + dep => '^bogus', +), 'The invalid conflict error should be correct'; + +############################################################################## +# Try a plan with a duplicate change in different tag sections. +$file = file qw(t plans dupe-change-diff-tag.plan); +$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file); +isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target), + $CLASS, 'Plan shoud work plan with dupe change across tags'; +is $plan->file, $target->plan_file, 'File should be coopied from Sqitch'; +is $plan->project, 'dupe_change_diff_tag', 'Project name should be set'; +cmp_deeply [ $plan->lines ], [ + clear, + version, + prag( '', '', 'project', '', '=', '', 'dupe_change_diff_tag'), + blank, + change { name => 'whatever' }, + tag { name => 'foo', ret => 1 }, + blank(), + change { name => 'hi' }, + tag { name => 'bar', ret => 1 }, + blank(), + change { name => 'greets' }, + change { name => 'whatever', rtag => [qw(hi whatever)] }, +], 'Lines with dupe change should be read from file'; + +$vivify = 1; +cmp_deeply [ $plan->changes ], [ + clear, + change { name => 'whatever' }, + tag { name => 'foo' }, + change { name => 'hi' }, + tag { name => 'bar' }, + change { name => 'greets' }, + change { name => 'whatever', rtag => [qw(hi whatever)] }, +], 'Noes with dupe change should be read from file'; +is sorted, 3, 'Should have sorted changes three times'; + +# Try to find whatever. +ok $plan->contains('whatever'), 'Should find "whatever" in plan'; +throws_ok { $plan->index_of('whatever') } 'App::Sqitch::X', + 'Should get an error trying to find dupe key.'; +is $@->ident, 'plan', 'Dupe key error ident should be "plan"'; +is $@->message, __ 'Change lookup failed', + 'Dupe key error message should be correct'; +is_deeply +MockOutput->get_vent, [ + [__x( + 'Change "{change}" is ambiguous. Please specify a tag-qualified change:', + change => 'whatever', + )], + [ ' * ', 'whatever@HEAD' ], + [ ' * ', 'whatever@foo' ], +], 'Should have output listing tag-qualified changes'; + +is $plan->index_of('whatever@HEAD'), 3, 'Should get 3 for whatever@HEAD'; +is $plan->index_of('whatever@bar'), 0, 'Should get 0 for whatever@bar'; + +# Make sure seek works, too. +throws_ok { $plan->seek('whatever') } 'App::Sqitch::X', + 'Should get an error seeking dupe key.'; +is $@->ident, 'plan', 'Dupe key error ident should be "plan"'; +is $@->message, __ 'Change lookup failed', + 'Dupe key error message should be correct'; +is_deeply +MockOutput->get_vent, [ + [__x( + 'Change "{change}" is ambiguous. Please specify a tag-qualified change:', + change => 'whatever', + )], + [ ' * ', 'whatever@HEAD' ], + [ ' * ', 'whatever@foo' ], +], 'Should have output listing tag-qualified changes'; + +is $plan->index_of('whatever@HEAD'), 3, 'Should find whatever@HEAD at index 3'; +is $plan->index_of('whatever@bar'), 0, 'Should find whatever@HEAD at index 0'; +is $plan->first_index_of('whatever'), 0, + 'Should find first instance of whatever at index 0'; +is $plan->first_index_of('whatever', '@bar'), 3, + 'Should find first instance of whatever after @bar at index 5'; +ok $plan->seek('whatever@HEAD'), 'Seek whatever@HEAD'; +is $plan->position, 3, 'Position should be 3'; +ok $plan->seek('whatever@bar'), 'Seek whatever@bar'; +is $plan->position, 0, 'Position should be 0'; +is $plan->last_tagged_change->name, 'hi', 'Last tagged change should be "hi"'; + +############################################################################## +# Test open_script. +make_path dir(qw(sql deploy stuff))->stringify; +END { remove_tree 'sql' }; + +can_ok $CLASS, 'open_script'; +my $change_file = file qw(sql deploy bar.sql); +$fh = $change_file->open('>') or die "Cannot open $change_file: $!\n"; +$fh->say('-- This is a comment'); +$fh->close; +ok $fh = $plan->open_script($change_file), 'Open bar.sql'; +is $fh->getline, "-- This is a comment\n", 'It should be the right file'; +$fh->close; + +file(qw(sql deploy baz.sql))->touch; +ok $fh = $plan->open_script(file qw(sql deploy baz.sql)), 'Open baz.sql'; +is $fh->getline, undef, 'It should be empty'; + +# Make sure it dies on an invalid file. +throws_ok { $plan->open_script(file 'nonexistent' ) } 'App::Sqitch::X', + 'open_script() should die on nonexistent file'; +is $@->ident, 'io', 'Nonexistent file error ident should be "io"'; +is $@->message, __x( + 'Cannot open {file}: {error}', + file => 'nonexistent', + error => $! || 'No such file or directory', +), 'Nonexistent file error message should be correct'; + +############################################################################## +# Test check_changes() +$mocker->unmock('check_changes'); +can_ok $CLASS, 'check_changes'; +my @deps; +my $i = 0; +my $j = 0; +$mock_change->mock(requires => sub { + my $reqs = caller eq 'App::Sqitch::Plan' ? $deps[$i++] : $deps[$j++]; + @{ $reqs->{requires} }; +}); + +sub changes { + clear; + $i = $j = 0; + map { + change { name => $_ }; + } @_; +} + +# Start with no dependencies. +$project = 'foo'; +my %ddep = ( requires => [], conflicts => [] ); +@deps = ({%ddep}, {%ddep}, {%ddep}); +cmp_deeply [map { $_->name } $plan->check_changes({}, changes qw(this that other))], + [qw(this that other)], 'Should get original order when no dependencies'; + +@deps = ({%ddep}, {%ddep}, {%ddep}); +cmp_deeply [map { $_->name } $plan->check_changes('foo', changes qw(this that other))], + [qw(this that other)], 'Should get original order when no prepreqs'; + +# Have that require this. +@deps = ({%ddep}, {%ddep, requires => [dep 'this']}, {%ddep}); +cmp_deeply [map { $_->name }$plan->check_changes('foo', changes qw(this that other))], + [qw(this that other)], 'Should get original order when that requires this'; + +# Have other require that. +@deps = ({%ddep}, {%ddep, requires => [dep 'this']}, {%ddep, requires => [dep 'that']}); +cmp_deeply [map { $_->name } $plan->check_changes('foo', changes qw(this that other))], + [qw(this that other)], 'Should get original order when other requires that'; + +my $deperr = sub { + join "\n ", __n( + 'Dependency error detected:', + 'Dependency errors detected:', + @_ + ), @_ +}; + +# Have this require other. +@deps = ({%ddep, requires => [dep 'other']}, {%ddep}, {%ddep}); +throws_ok { + $plan->check_changes('foo', changes qw(this that other)) +} 'App::Sqitch::X', 'Should get error for out-of-order dependency'; +is $@->ident, 'parse', 'Unordered dependency error ident should be "parse"'; +is $@->message, $deperr->(__nx( + 'Change "{change}" planned {num} change before required change "{required}"', + 'Change "{change}" planned {num} changes before required change "{required}"', + 2, + change => 'this', + required => 'other', + num => 2, +) . "\n " . __xn( + 'HINT: move "{change}" down {num} line in {plan}', + 'HINT: move "{change}" down {num} lines in {plan}', + 2, + change => 'this', + num => 2, + plan => $plan->file, +)), 'And the unordered dependency error message should be correct'; + +# Have this require other and that. +@deps = ({%ddep, requires => [dep 'other', dep 'that']}, {%ddep}, {%ddep}); +throws_ok { + $plan->check_changes('foo', changes qw(this that other)); +} 'App::Sqitch::X', 'Should get error for multiple dependency errors'; +is $@->ident, 'parse', 'Multiple dependency error ident should be "parse"'; +is $@->message, $deperr->( + __nx( + 'Change "{change}" planned {num} change before required change "{required}"', + 'Change "{change}" planned {num} changes before required change "{required}"', + 2, + change => 'this', + required => 'other', + num => 2, + ), __nx( + 'Change "{change}" planned {num} change before required change "{required}"', + 'Change "{change}" planned {num} changes before required change "{required}"', + 1, + change => 'this', + required => 'that', + num => 1, + ) . "\n " . __xn( + 'HINT: move "{change}" down {num} line in {plan}', + 'HINT: move "{change}" down {num} lines in {plan}', + 2, + change => 'this', + num => 2, + plan => $plan->file, + ), +), 'And the multiple dependency error message should be correct'; + +# Have that require a tag. +@deps = ({%ddep}, {%ddep, requires => [dep '@howdy']}, {%ddep}); +cmp_deeply [$plan->check_changes('foo', {'@howdy' => 2 }, changes qw(this that other))], + [changes qw(this that other)], 'Should get original order when requiring a tag'; + +# Requires a step as of a tag. +@deps = ({%ddep}, {%ddep, requires => [dep 'foo@howdy']}, {%ddep}); +cmp_deeply [$plan->check_changes('foo', {'foo' => 1, '@howdy' => 2 }, changes qw(this that other))], + [changes qw(this that other)], + 'Should get original order when requiring a step as-of a tag'; + +# Should die if the step comes *after* the specified tag. +@deps = ({%ddep}, {%ddep, requires => [dep 'foo@howdy']}, {%ddep}); +throws_ok { $plan->check_changes('foo', {'foo' => 3, '@howdy' => 2 }, changes qw(this that other)) } + 'App::Sqitch::X', 'Should get failure for a step after a tag'; +is $@->ident, 'parse', 'Step after tag error ident should be "parse"'; +is $@->message, $deperr->(__x( + 'Unknown change "{required}" required by change "{change}"', + required => 'foo@howdy', + change => 'that', +)), 'And we the unknown change as-of a tag message should be correct'; + +# Add a cycle. +@deps = ({%ddep, requires => [dep 'that']}, {%ddep, requires => [dep 'this']}, {%ddep}); +throws_ok { $plan->check_changes('foo', changes qw(this that other)) } 'App::Sqitch::X', + 'Should get failure for a cycle'; +is $@->ident, 'parse', 'Cycle error ident should be "parse"'; +is $@->message, $deperr->( + __nx( + 'Change "{change}" planned {num} change before required change "{required}"', + 'Change "{change}" planned {num} changes before required change "{required}"', + 1, + change => 'this', + required => 'that', + num => 1, + ) . "\n " . __xn( + 'HINT: move "{change}" down {num} line in {plan}', + 'HINT: move "{change}" down {num} lines in {plan}', + 1, + change => 'this', + num => 1, + plan => $plan->file, + ), +), 'The cycle error message should be correct'; + +# Add an extended cycle. +@deps = ( + {%ddep, requires => [dep 'that']}, + {%ddep, requires => [dep 'other']}, + {%ddep, requires => [dep 'this']} +); +throws_ok { $plan->check_changes('foo', changes qw(this that other)) } 'App::Sqitch::X', + 'Should get failure for a two-hop cycle'; +is $@->ident, 'parse', 'Two-hope cycle error ident should be "parse"'; +is $@->message, $deperr->( + __nx( + 'Change "{change}" planned {num} change before required change "{required}"', + 'Change "{change}" planned {num} changes before required change "{required}"', + 1, + change => 'this', + required => 'that', + num => 1, + ) . "\n " . __xn( + 'HINT: move "{change}" down {num} line in {plan}', + 'HINT: move "{change}" down {num} lines in {plan}', + 1, + change => 'this', + num => 1, + plan => $plan->file, + ), __nx( + 'Change "{change}" planned {num} change before required change "{required}"', + 'Change "{change}" planned {num} changes before required change "{required}"', + 1, + change => 'that', + required => 'other', + num => 1, + ) . "\n " . __xn( + 'HINT: move "{change}" down {num} line in {plan}', + 'HINT: move "{change}" down {num} lines in {plan}', + 1, + change => 'that', + num => 1, + plan => $plan->file, + ), +), 'The two-hop cycle error message should be correct'; + +# Okay, now deal with depedencies from earlier change sections. +@deps = ({%ddep, requires => [dep 'foo']}, {%ddep}, {%ddep}); +cmp_deeply [$plan->check_changes('foo', { foo => 1}, changes qw(this that other))], + [changes qw(this that other)], 'Should get original order with earlier dependency'; + +# Mix it up. +@deps = ({%ddep, requires => [dep 'other', dep 'that']}, {%ddep, requires => [dep 'sqitch']}, {%ddep}); +throws_ok { + $plan->check_changes('foo', {sqitch => 1 }, changes qw(this that other)) +} 'App::Sqitch::X', 'Should get error with misordered and seen dependencies'; +is $@->ident, 'parse', 'Misorderd and seen error ident should be "parse"'; +is $@->message, $deperr->( + __nx( + 'Change "{change}" planned {num} change before required change "{required}"', + 'Change "{change}" planned {num} changes before required change "{required}"', + 2, + change => 'this', + required => 'other', + num => 2, + ), __nx( + 'Change "{change}" planned {num} change before required change "{required}"', + 'Change "{change}" planned {num} changes before required change "{required}"', + 1, + change => 'this', + required => 'that', + num => 1, + ) . "\n " . __xn( + 'HINT: move "{change}" down {num} line in {plan}', + 'HINT: move "{change}" down {num} lines in {plan}', + 2, + change => 'this', + num => 2, + plan => $plan->file, + ), +), 'And the misordered and seen error message should be correct'; + +# Make sure it fails on unknown previous dependencies. +@deps = ({%ddep, requires => [dep 'foo']}, {%ddep}, {%ddep}); +throws_ok { $plan->check_changes('foo', changes qw(this that other)) } 'App::Sqitch::X', + 'Should die on unknown dependency'; +is $@->ident, 'parse', 'Unknown dependency error ident should be "parse"'; +is $@->message, $deperr->(__x( + 'Unknown change "{required}" required by change "{change}"', + required => 'foo', + change => 'this', +)), 'And the error should point to the offending change'; + +# Okay, now deal with depedencies from earlier change sections. +@deps = ({%ddep, requires => [dep '@foo']}, {%ddep}, {%ddep}); +throws_ok { $plan->check_changes('foo', changes qw(this that other)) } 'App::Sqitch::X', + 'Should die on unknown tag dependency'; +is $@->ident, 'parse', 'Unknown tag dependency error ident should be "parse"'; +is $@->message, $deperr->(__x( + 'Unknown change "{required}" required by change "{change}"', + required => '@foo', + change => 'this', +)), 'And the error should point to the offending change'; + +# Allow dependencies from different projects. +@deps = ({%ddep}, {%ddep, requires => [dep 'bar:bob']}, {%ddep}); +cmp_deeply [$plan->check_changes('foo', changes qw(this that other))], + [changes qw(this that other)], 'Should get original order with external dependency'; +$project = undef; + +# Make sure that a change does not require itself +@deps = ({%ddep, requires => [dep 'this']}, {%ddep}, {%ddep}); +throws_ok { $plan->check_changes('foo', changes qw(this that other)) } 'App::Sqitch::X', + 'Should die on self dependency'; +is $@->ident, 'parse', 'Self dependency error ident should be "parse"'; +is $@->message, $deperr->(__x( + 'Change "{change}" cannot require itself', + change => 'this', +)), 'And the self dependency error should be correct'; + +# Make sure sort ordering respects the original ordering. +@deps = ( + {%ddep}, + {%ddep}, + {%ddep, requires => [dep 'that']}, + {%ddep, requires => [dep 'that', dep 'this']}, +); +cmp_deeply [$plan->check_changes('foo', changes qw(this that other thing))], + [changes qw(this that other thing)], + 'Should get original order with cascading dependencies'; +$project = undef; + +@deps = ( + {%ddep}, + {%ddep}, + {%ddep, requires => [dep 'that']}, + {%ddep, requires => [dep 'that', dep 'this', dep 'other']}, + {%ddep, requires => [dep 'that', dep 'this']}, +); +cmp_deeply [$plan->check_changes('foo', changes qw(this that other thing yowza))], + [changes qw(this that other thing yowza)], + 'Should get original order with multiple cascading dependencies'; +$project = undef; + +############################################################################## +# Test dependency testing. +can_ok $plan, '_check_dependencies'; +$mock_change->unmock('requires'); + +for my $req (qw(hi greets whatever @foo whatever@foo ext:larry ext:greets)) { + $change = App::Sqitch::Plan::Change->new( + plan => $plan, + name => 'lazy', + requires => [dep $req], + ); + my $req_proj = $req =~ /:/ ? do { + (my $p = $req) =~ s/:.+//; + $p; + } : $plan->project; + my ($dep) = $change->requires; + is $dep->project, $req_proj, + qq{Depend "$req" should be in project "$req_proj"}; + ok $plan->_check_dependencies($change, 'add'), + qq{Dependency on "$req" should succeed}; +} + +for my $req (qw(wanker @blah greets@foo)) { + $change = App::Sqitch::Plan::Change->new( + plan => $plan, + name => 'lazy', + requires => [dep $req], + ); + throws_ok { $plan->_check_dependencies($change, 'bark') } 'App::Sqitch::X', + qq{Should get error trying to depend on "$req"}; + is $@->ident, 'plan', qq{Dependency "req" error ident should be "plan"}; + is $@->message, __x( + 'Cannot rework change "{change}": requires unknown change "{req}"', + change => 'lazy', + req => $req, + ), qq{And should get unknown dependency message for "$req"}; +} + +############################################################################## +# Test pragma accessors. +is $plan->uri, undef, 'Should have undef URI when no pragma'; +$file = file qw(t plans pragmas.plan); +$target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file); +isa_ok $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target), + $CLASS, 'Plan with sqitch with plan file with dependencies'; +is $plan->file, $target->plan_file, 'File should be coopied from Sqitch'; +is $plan->syntax_version, App::Sqitch::Plan::SYNTAX_VERSION, + 'syntax_version should be set'; +is $plan->project, 'pragmata', 'Project should be set'; +is $plan->uri, URI->new('https://github.com/sqitchers/sqitch/'), + 'Should have URI from pragma'; +isa_ok $plan->uri, 'URI', 'It'; + +# Make sure we get an error if there is no project pragma. +$fh = IO::File->new(\"%strict\n\nfoo $tsnp", '<:utf8_strict'); +throws_ok { $plan->_parse('noproject', $fh) } 'App::Sqitch::X', + 'Should die on plan with no project pragma'; +is $@->ident, 'parse', 'Missing prorject error ident should be "parse"'; +is $@->message, __x('Missing %project pragma in {file}', file => 'noproject'), + 'The missing project error message should be correct'; + +# Make sure we get an error for an invalid project name. +for my $bad (@bad_names) { + my $fh = IO::File->new(\"%project=$bad\n\nfoo $tsnp", '<:utf8_strict'); + throws_ok { $plan->_parse(badproj => $fh) } 'App::Sqitch::X', + qq{Should die on invalid project name "$bad"}; + is $@->ident, 'parse', qq{Ident for bad proj "$bad" should be "parse"}; + my $error = __x( + 'invalid project name "{project}": project names must not ' + . 'begin with punctuation, contain "@", ":", "#", or blanks, or end in ' + . 'punctuation or digits following punctuation', + project => $bad); + is $@->message, __x( + 'Syntax error in {file} at line {lineno}: {error}', + file => 'badproj', + lineno => 1, + error => $error + ), qq{Error message for bad project "$bad" should be correct}; +} + +done_testing; diff --git a/t/plan_cmd.t b/t/plan_cmd.t new file mode 100644 index 00000000..bf33393b --- /dev/null +++ b/t/plan_cmd.t @@ -0,0 +1,675 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 229; +#use Test::More 'no_plan'; +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use Test::NoWarnings; +use Test::Exception; +use Test::Warn; +use Test::MockModule; +use Path::Class; +use Term::ANSIColor qw(color); +use Encode; +use lib 't/lib'; +use MockOutput; +use TestConfig; +use LC; + +my $CLASS = 'App::Sqitch::Command::plan'; +require_ok $CLASS; + +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir('test-plan_cmd')->stringify, + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, +); +ok my $sqitch = App::Sqitch->new(config => $config), + 'Load a sqitch sqitch object'; +isa_ok my $cmd = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'plan', + config => $config, +}), $CLASS, 'plan command'; + +can_ok $cmd, qw( + target + change_pattern + planner_pattern + max_count + skip + reverse + format + options + execute + configure + headers +); + +is_deeply [$CLASS->options], [qw( + event=s + target|t=s + change-pattern|change=s + planner-pattern|planner=s + format|f=s + date-format|date=s + max-count|n=i + skip=i + reverse! + color=s + no-color + abbrev=i + oneline + headers! +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +############################################################################## +# Test configure(). +my $configured = $CLASS->configure($config, {}); +isa_ok delete $configured->{formatter}, 'App::Sqitch::ItemFormatter', 'Formatter'; +is_deeply $configured, {}, + 'Should get empty hash for no config or options'; + +# Test date_format validation. +$config->update('plan.date_format' => 'nonesuch'); +throws_ok { $CLASS->configure($config, {}), {} } 'App::Sqitch::X', + 'Should get error for invalid date format in config'; +is $@->ident, 'datetime', + 'Invalid date format error ident should be "datetime"'; +is $@->message, __x( + 'Unknown date format "{format}"', + format => 'nonesuch', +), 'Invalid date format error message should be correct'; + +throws_ok { $CLASS->configure($config, { date_format => 'non'}), {} } + 'App::Sqitch::X', + 'Should get error for invalid date format in optsions'; +is $@->ident, 'datetime', + 'Invalid date format error ident should be "plan"'; +is $@->message, __x( + 'Unknown date format "{format}"', + format => 'non', +), 'Invalid date format error message should be correct'; + +# Test format validation. +$config = TestConfig->new('plan.format' => 'nonesuch'); +throws_ok { $CLASS->configure($config, {}), {} } 'App::Sqitch::X', + 'Should get error for invalid format in config'; +is $@->ident, 'plan', + 'Invalid format error ident should be "plan"'; +is $@->message, __x( + 'Unknown plan format "{format}"', + format => 'nonesuch', +), 'Invalid format error message should be correct'; + +throws_ok { $CLASS->configure($config, { format => 'non'}), {} } + 'App::Sqitch::X', + 'Should get error for invalid format in optsions'; +is $@->ident, 'plan', + 'Invalid format error ident should be "plan"'; +is $@->message, __x( + 'Unknown plan format "{format}"', + format => 'non', +), 'Invalid format error message should be correct'; + +# Test color configuration. +$config = TestConfig->new; +$configured = $CLASS->configure( $config, { no_color => 1 } ); +is $configured->{formatter}->color, 'never', + 'Configuration should respect --no-color, setting "never"'; + +# Test oneline configuration. +$configured = $CLASS->configure( $config, { oneline => 1 }); +is $configured->{format}, '%{:event}C%h %l%{reset}C %n%{cyan}C%t%{reset}C', + '--oneline should set format'; +is $configured->{formatter}{abbrev}, 6, '--oneline should set abbrev to 6'; + +$configured = $CLASS->configure( $config, { oneline => 1, format => 'format:foo', abbrev => 5 }); +is $configured->{format}, 'foo', '--oneline should not override --format'; +is $configured->{formatter}{abbrev}, 5, '--oneline should not overrride --abbrev'; + +$config->update('plan.color' => 'auto'); +$configured = $CLASS->configure( $config, { no_color => 1 } ); +is $configured->{formatter}->color, 'never', + 'Configuration should respect --no-color even when configure is set'; + +NEVER: { + my $configured = $CLASS->configure( $config, { color => 'never' } ); + is $configured->{formatter}->color, 'never', + 'Configuration should respect color option'; + + # Try it with config. + $config->update('plan.color' => 'never'); + $configured = $CLASS->configure( $config, {} ); + is $configured->{formatter}->color, 'never', + 'Configuration should respect color config'; +} + +ALWAYS: { + my $configured = $CLASS->configure( $config, { color => 'always' } ); + is_deeply $configured->{formatter}->color, 'always', + 'Configuration should respect color option'; + + # Try it with config. + $config->update('plan.color' => 'always'); + $configured = $CLASS->configure( $config, {} ); + is_deeply $configured->{formatter}->color, 'always', + 'Configuration should respect color config'; +} + +AUTO: { + for my $enabled (0, 1) { + $config->update('plan.color' => 'always'); + my $configured = $CLASS->configure( $config, { color => 'auto' } ); + is_deeply $configured->{formatter}->color, 'auto', + 'Configuration should respect color option'; + + # Try it with config. + $config->update('plan.color' => 'auto'); + $configured = $CLASS->configure( $config, {} ); + is_deeply $configured->{formatter}->color, 'auto', + 'Configuration should respect color config'; + } +} + +############################################################################### +# Test named formats. +my $cdt = App::Sqitch::DateTime->now; +my $pdt = $cdt->clone->subtract(days => 1); +my $change = { + event => 'deploy', + project => 'planit', + change_id => '000011112222333444', + change => 'lolz', + tags => [ '@beta', '@gamma' ], + planner_name => 'damian', + planner_email => 'damian@example.com', + planned_at => $pdt, + note => "For the LOLZ.\n\nYou know, funny stuff and cute kittens, right?", + requires => [qw(foo bar)], + conflicts => [] +}; + +my $piso = $pdt->as_string( format => 'iso' ); +my $praw = $pdt->as_string( format => 'raw' ); +for my $spec ( + [ raw => "deploy 000011112222333444 (\@beta, \@gamma)\n" + . "name lolz\n" + . "project planit\n" + . "requires foo, bar\n" + . "planner damian <damian\@example.com>\n" + . "planned $praw\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ full => __('Deploy') . " 000011112222333444 (\@beta, \@gamma)\n" + . __('Name: ') . " lolz\n" + . __('Project: ') . " planit\n" + . __('Requires: ') . " foo, bar\n" + . __('Planner: ') . " damian <damian\@example.com>\n" + . __('Planned: ') . " __PDATE__\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ long => __('Deploy') . " 000011112222333444 (\@beta, \@gamma)\n" + . __('Name: ') . " lolz\n" + . __('Project: ') . " planit\n" + . __('Planner: ') . " damian <damian\@example.com>\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ medium => __('Deploy') . " 000011112222333444\n" + . __('Name: ') . " lolz\n" + . __('Planner: ') . " damian <damian\@example.com>\n" + . __('Date: ') . " __PDATE__\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ short => __('Deploy') . " 000011112222333444\n" + . __('Name: ') . " lolz\n" + . __('Planner: ') . " damian <damian\@example.com>\n\n" + . " For the LOLZ.\n", + ], + [ oneline => '000011112222333444 ' . __('deploy') . ' lolz @beta, @gamma' ], +) { + local $ENV{ANSI_COLORS_DISABLED} = 1; + my $configured = $CLASS->configure( $config, { format => $spec->[0] } ); + my $format = $configured->{format}; + ok my $cmd = $CLASS->new( sqitch => $sqitch, %{ $configured } ), + qq{Instantiate with format "$spec->[0]"}; + (my $exp = $spec->[1]) =~ s/__PDATE__/$piso/; + is $cmd->formatter->format( $cmd->format, $change ), $exp, + qq{Format "$spec->[0]" should output correctly}; + + if ($spec->[1] =~ /__PDATE__/) { + # Test different date formats. + for my $date_format (qw(rfc long medium)) { + ok my $cmd = $CLASS->new( + sqitch => $sqitch, + format => $format, + formatter => App::Sqitch::ItemFormatter->new(date_format => $date_format), + ), qq{Instantiate with format "$spec->[0]" and date format "$date_format"}; + my $date = $pdt->as_string( format => $date_format ); + (my $exp = $spec->[1]) =~ s/__PDATE__/$date/; + is $cmd->formatter->format( $cmd->format, $change ), $exp, + qq{Format "$spec->[0]" and date format "$date_format" should output correctly}; + } + } + + if ($spec->[1] =~ s/\s+[(]?[@]beta,\s+[@]gamma[)]?//) { + # Test without tags. + local $change->{tags} = []; + (my $exp = $spec->[1]) =~ s/__PDATE__/$piso/; + is $cmd->formatter->format( $cmd->format, $change ), $exp, + qq{Format "$spec->[0]" should output correctly without tags}; + } +} + +############################################################################### +# Test all formatting characters. +my $local_pdt = $pdt->clone; +$local_pdt->set_time_zone('local'); +$local_pdt->set_locale($LC::TIME); + +my $formatter = $cmd->formatter; +for my $spec ( + ['%e', { event => 'deploy' }, 'deploy' ], + ['%e', { event => 'revert' }, 'revert' ], + ['%e', { event => 'fail' }, 'fail' ], + + ['%L', { event => 'deploy' }, __ 'Deploy' ], + ['%L', { event => 'revert' }, __ 'Revert' ], + ['%L', { event => 'fail' }, __ 'Fail' ], + + ['%l', { event => 'deploy' }, __ 'deploy' ], + ['%l', { event => 'revert' }, __ 'revert' ], + ['%l', { event => 'fail' }, __ 'fail' ], + + ['%{event}_', {}, __ 'Event: ' ], + ['%{change}_', {}, __ 'Change: ' ], + ['%{planner}_', {}, __ 'Planner: ' ], + ['%{by}_', {}, __ 'By: ' ], + ['%{date}_', {}, __ 'Date: ' ], + ['%{planned}_', {}, __ 'Planned: ' ], + ['%{name}_', {}, __ 'Name: ' ], + ['%{email}_', {}, __ 'Email: ' ], + ['%{requires}_', {}, __ 'Requires: ' ], + ['%{conflicts}_', {}, __ 'Conflicts:' ], + + ['%H', { change_id => '123456789' }, '123456789' ], + ['%h', { change_id => '123456789' }, '123456789' ], + ['%{5}h', { change_id => '123456789' }, '12345' ], + ['%{7}h', { change_id => '123456789' }, '1234567' ], + + ['%n', { change => 'foo' }, 'foo'], + ['%n', { change => 'bar' }, 'bar'], + ['%o', { project => 'foo' }, 'foo'], + ['%o', { project => 'bar' }, 'bar'], + + ['%p', { planner_name => 'larry', planner_email => 'larry@example.com' }, 'larry <larry@example.com>'], + ['%{n}p', { planner_name => 'damian' }, 'damian'], + ['%{name}p', { planner_name => 'chip' }, 'chip'], + ['%{e}p', { planner_email => 'larry@example.com' }, 'larry@example.com'], + ['%{email}p', { planner_email => 'damian@example.com' }, 'damian@example.com'], + + ['%{date}p', { planned_at => $pdt }, $pdt->as_string( format => 'iso' ) ], + ['%{date:rfc}p', { planned_at => $pdt }, $pdt->as_string( format => 'rfc' ) ], + ['%{d:long}p', { planned_at => $pdt }, $pdt->as_string( format => 'long' ) ], + ["%{d:cldr:HH'h' mm'm'}p", { planned_at => $pdt }, $local_pdt->format_cldr( q{HH'h' mm'm'} ) ], + ["%{d:strftime:%a at %H:%M:%S}p", { planned_at => $pdt }, $local_pdt->strftime('%a at %H:%M:%S') ], + + ['%t', { tags => [] }, '' ], + ['%t', { tags => ['@foo'] }, ' @foo' ], + ['%t', { tags => ['@foo', '@bar'] }, ' @foo, @bar' ], + ['%{|}t', { tags => [] }, '' ], + ['%{|}t', { tags => ['@foo'] }, ' @foo' ], + ['%{|}t', { tags => ['@foo', '@bar'] }, ' @foo|@bar' ], + + ['%T', { tags => [] }, '' ], + ['%T', { tags => ['@foo'] }, ' (@foo)' ], + ['%T', { tags => ['@foo', '@bar'] }, ' (@foo, @bar)' ], + ['%{|}T', { tags => [] }, '' ], + ['%{|}T', { tags => ['@foo'] }, ' (@foo)' ], + ['%{|}T', { tags => ['@foo', '@bar'] }, ' (@foo|@bar)' ], + + ['%r', { requires => [] }, '' ], + ['%r', { requires => ['foo'] }, ' foo' ], + ['%r', { requires => ['foo', 'bar'] }, ' foo, bar' ], + ['%{|}r', { requires => [] }, '' ], + ['%{|}r', { requires => ['foo'] }, ' foo' ], + ['%{|}r', { requires => ['foo', 'bar'] }, ' foo|bar' ], + + ['%R', { requires => [] }, '' ], + ['%R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ], + ['%R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo, bar\n" ], + ['%{|}R', { requires => [] }, '' ], + ['%{|}R', { requires => ['foo'] }, __('Requires: ') . " foo\n" ], + ['%{|}R', { requires => ['foo', 'bar'] }, __('Requires: ') . " foo|bar\n" ], + + ['%x', { conflicts => [] }, '' ], + ['%x', { conflicts => ['foo'] }, ' foo' ], + ['%x', { conflicts => ['foo', 'bax'] }, ' foo, bax' ], + ['%{|}x', { conflicts => [] }, '' ], + ['%{|}x', { conflicts => ['foo'] }, ' foo' ], + ['%{|}x', { conflicts => ['foo', 'bax'] }, ' foo|bax' ], + + ['%X', { conflicts => [] }, '' ], + ['%X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ], + ['%X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo, bar\n" ], + ['%{|}X', { conflicts => [] }, '' ], + ['%{|}X', { conflicts => ['foo'] }, __('Conflicts:') . " foo\n" ], + ['%{|}X', { conflicts => ['foo', 'bar'] }, __('Conflicts:') . " foo|bar\n" ], + + ['%{yellow}C', {}, '' ], + ['%{:event}C', { event => 'deploy' }, '' ], + ['%v', {}, "\n" ], + ['%%', {}, '%' ], + + ['%s', { note => 'hi there' }, 'hi there' ], + ['%s', { note => "hi there\nyo" }, 'hi there' ], + ['%s', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, 'subject line' ], + ['%{ }s', { note => 'hi there' }, ' hi there' ], + ['%{xx}s', { note => 'hi there' }, 'xxhi there' ], + + ['%b', { note => 'hi there' }, '' ], + ['%b', { note => "hi there\nyo" }, 'yo' ], + ['%b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "first graph\n\nsecond graph\n\n" ], + ['%{ }b', { note => 'hi there' }, '' ], + ['%{xxx }b', { note => "hi there\nyo" }, "xxx yo" ], + ['%{x}b', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xfirst graph\nx\nxsecond graph\nx\n" ], + ['%{ }b', { note => "hi there\r\nyo" }, " yo" ], + + ['%B', { note => 'hi there' }, 'hi there' ], + ['%B', { note => "hi there\nyo" }, "hi there\nyo" ], + ['%B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "subject line\n\nfirst graph\n\nsecond graph\n\n" ], + ['%{ }B', { note => 'hi there' }, ' hi there' ], + ['%{xxx }B', { note => "hi there\nyo" }, "xxx hi there\nxxx yo" ], + ['%{x}B', { note => "subject line\n\nfirst graph\n\nsecond graph\n\n" }, "xsubject line\nx\nxfirst graph\nx\nxsecond graph\nx\n" ], + ['%{ }B', { note => "hi there\r\nyo" }, " hi there\r\n yo" ], + + ['%{change}a', $change, "change $change->{change}\n" ], + ['%{change_id}a', $change, "change_id $change->{change_id}\n" ], + ['%{event}a', $change, "event $change->{event}\n" ], + ['%{tags}a', $change, 'tags ' . join(', ', @{ $change->{tags} }) . "\n" ], + ['%{requires}a', $change, 'requires ' . join(', ', @{ $change->{requires} }) . "\n" ], + ['%{conflicts}a', $change, '' ], +) { + local $ENV{ANSI_COLORS_DISABLED} = 1; + (my $desc = encode_utf8 $spec->[2]) =~ s/\n/[newline]/g; + is $formatter->format( $spec->[0], $spec->[1] ), $spec->[2], + qq{Format "$spec->[0]" should output "$desc"}; +} + +throws_ok { $formatter->format( '%_', {} ) } 'App::Sqitch::X', + 'Should get exception for format "%_"'; +is $@->ident, 'format', '%_ error ident should be "format"'; +is $@->message, __ 'No label passed to the _ format', + '%_ error message should be correct'; +throws_ok { $formatter->format( '%{foo}_', {} ) } 'App::Sqitch::X', + 'Should get exception for unknown label in format "%_"'; +is $@->ident, 'format', 'Invalid %_ label error ident should be "format"'; +is $@->message, __x( + 'Unknown label "{label}" passed to the _ format', + label => 'foo' +), 'Invalid %_ label error message should be correct'; + +ok $cmd = $CLASS->new( + sqitch => $sqitch, + formatter => App::Sqitch::ItemFormatter->new(abbrev => 4) +), 'Instantiate with abbrev => 4'; +is $cmd->formatter->format( '%h', { change_id => '123456789' } ), + '1234', '%h should respect abbrev'; +is $cmd->formatter->format( '%H', { change_id => '123456789' } ), + '123456789', '%H should not respect abbrev'; + +ok $cmd = $CLASS->new( + sqitch => $sqitch, + formatter => App::Sqitch::ItemFormatter->new(date_format => 'rfc') +), 'Instantiate with date_format => "rfc"'; +is $cmd->formatter->format( '%{date}p', { planned_at => $cdt } ), + $cdt->as_string( format => 'rfc' ), + '%{date}p should respect the date_format attribute'; +is $cmd->formatter->format( '%{d:iso}p', { planned_at => $cdt } ), + $cdt->as_string( format => 'iso' ), + '%{iso}p should override the date_format attribute'; + +throws_ok { $formatter->format( '%{foo}a', {}) } 'App::Sqitch::X', + 'Should get exception for unknown attribute passed to %a'; +is $@->ident, 'format', '%a error ident should be "format"'; +is $@->message, __x( + '{attr} is not a valid change attribute', attr => 'foo' +), '%a error message should be correct'; + +delete $ENV{ANSI_COLORS_DISABLED}; +for my $color (qw(yellow red blue cyan magenta)) { + is $formatter->format( "%{$color}C", {} ), color($color), + qq{Format "%{$color}C" should output } + . color($color) . $color . color('reset'); +} + +for my $spec ( + [ ':event', { event => 'deploy' }, 'green', 'deploy' ], + [ ':event', { event => 'revert' }, 'blue', 'revert' ], + [ ':event', { event => 'fail' }, 'red', 'fail' ], +) { + is $formatter->format( "%{$spec->[0]}C", $spec->[1] ), color($spec->[2]), + qq{Format "%{$spec->[0]}C" on "$spec->[3]" should output } + . color($spec->[2]) . $spec->[2] . color('reset'); +} + +# Make sure other colors work. +my $yellow = color('yellow') . '%s' . color('reset'); +my $green = color('green') . '%s' . color('reset'); +my $cyan = color('cyan') . ' %s' . color('reset'); +$change->{conflicts} = [qw(dr_evil)]; +for my $spec ( + [ full => sprintf($green, __ ('Deploy') . ' 000011112222333444') + . " (\@beta, \@gamma)\n" + . __ ('Name: ') . " lolz\n" + . __ ('Project: ') . " planit\n" + . __ ('Requires: ') . " foo, bar\n" + . __ ('Conflicts:') . " dr_evil\n" + . __ ('Planner: ') . " damian <damian\@example.com>\n" + . __ ('Planned: ') . " __PDATE__\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ long => sprintf($green, __ ('Deploy') . ' 000011112222333444') + . " (\@beta, \@gamma)\n" + . __ ('Name: ') . " lolz\n" + . __ ('Project: ') . " planit\n" + . __ ('Planner: ') . " damian <damian\@example.com>\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ medium => sprintf($green, __ ('Deploy') . ' 000011112222333444') . "\n" + . __ ('Name: ') . " lolz\n" + . __ ('Planner: ') . " damian <damian\@example.com>\n" + . __ ('Date: ') . " __PDATE__\n\n" + . " For the LOLZ.\n \n You know, funny stuff and cute kittens, right?\n" + ], + [ short => sprintf($green, __ ('Deploy') . ' 000011112222333444') . "\n" + . __ ('Name: ') . " lolz\n" + . __ ('Planner: ') . " damian <damian\@example.com>\n\n" + . " For the LOLZ.\n", + ], + [ oneline => sprintf "$green %s$cyan", '000011112222333444' . ' ' + . __('deploy'), 'lolz', '@beta, @gamma', + ], +) { + my $format = $CLASS->configure( $config, { format => $spec->[0] } )->{format}; + ok my $cmd = $CLASS->new( sqitch => $sqitch, format => $format ), + qq{Instantiate with format "$spec->[0]" again}; + (my $exp = $spec->[1]) =~ s/__PDATE__/$piso/; + is $cmd->formatter->format( $cmd->format, $change ), $exp, + qq{Format "$spec->[0]" should output correctly with color}; +} + +throws_ok { $formatter->format( '%{BLUELOLZ}C', {} ) } 'App::Sqitch::X', + 'Should get an error for an invalid color'; +is $@->ident, 'format', 'Invalid color error ident should be "format"'; +is $@->message, __x( + '{color} is not a valid ANSI color', color => 'BLUELOLZ' +), 'Invalid color error message should be correct'; + + +############################################################################## +# Test execute(). +my $pmock = Test::MockModule->new('App::Sqitch::Plan'); + +# First, test for no changes. +$pmock->mock(count => 0); + +my $plan = $cmd->default_target->plan; +throws_ok { $cmd->execute } 'App::Sqitch::X', + 'Should get error for no changes'; +is $@->ident, 'plan', 'no changes error ident should be "plan"'; +is $@->exitval, 1, 'no changes exit val should be 1'; +is $@->message, __x( + 'No changes in {file}', + file => $plan->file, +), 'no changes error message should be correct'; +$pmock->unmock('count'); + +# Okay, let's see some changes. +my @changes; +my $iter = sub { shift @changes }; +my $search_args; +$pmock->mock(search_changes => sub { + shift; + $search_args = [@_]; + return $iter; +}); + +$change = $plan->change_at(0); +push @changes => $change; +ok $cmd->execute, 'Execute plan'; +is_deeply $search_args, [ + operation => undef, + name => undef, + planner => undef, + limit => undef, + offset => undef, + direction => 'ASC' +], 'The proper args should have been passed to search_events'; + +my $fmt_params = { + event => $change->is_deploy ? 'deploy' : 'revert', + project => $change->project, + change_id => $change->id, + change => $change->name, + note => $change->note, + tags => [ map { $_->format_name } $change->tags ], + requires => [ map { $_->as_string } $change->requires ], + conflicts => [ map { $_->as_string } $change->conflicts ], + planned_at => $change->timestamp, + planner_name => $change->planner_name, + planner_email => $change->planner_email, +}; +is_deeply +MockOutput->get_page, [ + ['# ', __x 'Project: {project}', project => $plan->project ], + ['# ', __x 'File: {file}', file => $plan->file ], + [''], + [ $cmd->formatter->format( $cmd->format, $fmt_params ) ], +], 'The event should have been paged'; + +# Set attributes and add more events. +my $change2 = $plan->change_at(1); +push @changes => $change, $change2; +isa_ok $cmd = $CLASS->new( + sqitch => $sqitch, + event => 'deploy', + change_pattern => '.+', + project_pattern => '.+', + planner_pattern => '.+', + max_count => 10, + skip => 5, + reverse => 1, + headers => 0, +), $CLASS, 'plan with attributes'; + +ok $cmd->execute, 'Execute plan with attributes'; +is_deeply $search_args, [ + operation => 'deploy', + name => '.+', + planner => '.+', + limit => 10, + offset => 5, + direction => 'DESC' +], 'All params should have been passed to search_events'; + +my $fmt_params2 = { + event => $change2->is_deploy ? 'deploy' : 'revert', + project => $change2->project, + change_id => $change2->id, + change => $change2->name, + note => $change2->note, + tags => [ map { $_->format_name } $change2->tags ], + requires => [ map { $_->as_string } $change2->requires ], + conflicts => [ map { $_->as_string } $change2->conflicts ], + planned_at => $change2->timestamp, + planner_name => $change2->planner_name, + planner_email => $change2->planner_email, +}; + +is_deeply +MockOutput->get_page, [ + [ $cmd->formatter->format( $cmd->format, $fmt_params ) ], + [ $cmd->formatter->format( $cmd->format, $fmt_params2 ) ], +], 'Both events should have been paged without headers'; + +# Make sure we catch bad format codes. +isa_ok $cmd = $CLASS->new( + sqitch => $sqitch, + format => '%Z', +), $CLASS, 'plan with bad format'; + +push @changes, $change; +throws_ok { $cmd->execute } 'App::Sqitch::X', + 'Should get an exception for a bad format code'; +is $@->ident, 'format', + 'bad format code format error ident should be "format"'; +is $@->message, __x( + 'Unknown format code "{code}"', code => 'Z', +), 'bad format code format error message should be correct'; + +# Gotta make sure params are parsed. +my $mock_cmd = Test::MockModule->new($CLASS); +my (@params, $orig_parse); +$mock_cmd->mock(parse_args => sub { + my $self = shift; + @params = @_; + $self->$orig_parse(@_); +}); +$orig_parse = $mock_cmd->original('parse_args'); + +# Try specifying an unkonwn target. +ok $cmd = $CLASS->new( sqitch => $sqitch, target => 'foo'), + 'Create plan command with unknown target option'; +throws_ok { $cmd->execute } 'App::Sqitch::X', + 'Should get error for unknown target'; +is $@->ident, 'target', 'Unknown target error ident should be "plan"'; +is $@->exitval, 2, 'Unknown target changes exit val should be 2'; +is $@->message, __x('Cannot find target "{target}"', target => 'foo'), + 'Unknown target error message should be correct'; +is_deeply \@params, [ target => 'foo', args => [] ], + 'Should have passed target for parsing'; + +# Try passing an engine target. +ok $cmd = $CLASS->new( sqitch => $sqitch), + 'Create plan command with target option'; +ok $cmd->execute('sqlite'), 'Execute with engine arg'; +is_deeply \@params, [ target => undef, args => [qw(sqlite)] ], + 'Should have passed engine for parsing'; + +# Try both --target and arg.. +ok $cmd = $CLASS->new( sqitch => $sqitch, target => 'db:pg:'), + 'Create plan command with target option'; +ok $cmd->execute('sqlite'), 'Execute with multiple targets'; +is_deeply +MockOutput->get_warn, [[__x( + 'Too many targets specified; using {target}', + target => 'db:pg:', +)]], 'Should have got warning for two targets'; diff --git a/t/plans/bad-change.plan b/t/plans/bad-change.plan new file mode 100644 index 00000000..6dcb5a86 --- /dev/null +++ b/t/plans/bad-change.plan @@ -0,0 +1,8 @@ +%project=bad_change +# This is a note + +# And there was a blank line. +what what what 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # OHNOEZ, No white space allowed! + + + diff --git a/t/plans/changes-only.plan b/t/plans/changes-only.plan new file mode 100644 index 00000000..54d7c4f9 --- /dev/null +++ b/t/plans/changes-only.plan @@ -0,0 +1,8 @@ +%project=changes_only +# This is a note + +# And there was a blank line. + +hey 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +you 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +whatwhatwhat 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> diff --git a/t/plans/dependencies.plan b/t/plans/dependencies.plan new file mode 100644 index 00000000..3929dc51 --- /dev/null +++ b/t/plans/dependencies.plan @@ -0,0 +1,12 @@ +%project=dependencies + ++roles 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> ++users [roles] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> ++add_user [users roles] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> ++dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@alpha 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + ++users [users@alpha] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +-dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> ++del_user [!dr_evil users] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + diff --git a/t/plans/deploy-and-revert.plan b/t/plans/deploy-and-revert.plan new file mode 100644 index 00000000..e9c78392 --- /dev/null +++ b/t/plans/deploy-and-revert.plan @@ -0,0 +1,11 @@ +%project=deploy_and_revert + ++hey 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> ++you 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + + dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + ++this/rocks 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + hey-there 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +-dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # revert! + @bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> diff --git a/t/plans/dos.plan b/t/plans/dos.plan new file mode 100644 index 00000000..8f6c4d16 --- /dev/null +++ b/t/plans/dos.plan @@ -0,0 +1,8 @@ +%project=dos +# This is a note + +# And there was a blank line. + +hey 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +you 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +whatwhatwhat 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> diff --git a/t/plans/dupe-change-diff-tag.plan b/t/plans/dupe-change-diff-tag.plan new file mode 100644 index 00000000..cda297c0 --- /dev/null +++ b/t/plans/dupe-change-diff-tag.plan @@ -0,0 +1,10 @@ +%project=dupe_change_diff_tag + +whatever 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + +hi 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + +greets 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +whatever 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> diff --git a/t/plans/dupe-change.plan b/t/plans/dupe-change.plan new file mode 100644 index 00000000..afc676a2 --- /dev/null +++ b/t/plans/dupe-change.plan @@ -0,0 +1,10 @@ +%project=dupe_change + +whatever 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + +hi 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +greets 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +tallyho 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +greets 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> diff --git a/t/plans/dupe-tag.plan b/t/plans/dupe-tag.plan new file mode 100644 index 00000000..32d63c85 --- /dev/null +++ b/t/plans/dupe-tag.plan @@ -0,0 +1,14 @@ +%project=dupe_tag + +whatever 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + +hi 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + +@stink 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + +@blah 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@w00t 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +OHNOEZ 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> diff --git a/t/plans/multi.plan b/t/plans/multi.plan new file mode 100644 index 00000000..953d4081 --- /dev/null +++ b/t/plans/multi.plan @@ -0,0 +1,13 @@ +%project=multi +# This is a note + +# And there was a blank line. + +hey 2012-07-16T17:25:07Z theory <t@heo.ry> +you 2012-07-16T17:25:07Z anna <a@n.na> +@foo 2012-07-16T17:24:07Z julie <j@ul.ie> # look, a tag! + +this/rocks 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +hey-there 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> # trailing note! +@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@baz 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> diff --git a/t/plans/pragmas.plan b/t/plans/pragmas.plan new file mode 100644 index 00000000..91358e2a --- /dev/null +++ b/t/plans/pragmas.plan @@ -0,0 +1,9 @@ +% syntax-version=1.0.0 + %foo = bar # lolz +% project=pragmata +% uri=https://github.com/sqitchers/sqitch/ +% strict + +hey 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +you 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + diff --git a/t/plans/project_deps.plan b/t/plans/project_deps.plan new file mode 100644 index 00000000..6cbbb9ec --- /dev/null +++ b/t/plans/project_deps.plan @@ -0,0 +1,12 @@ +%project=dependencies + ++roles 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> ++users [roles] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> ++add_user [users roles log:logger] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> ++dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@alpha 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + ++users [users@alpha] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +-dr_evil 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> ++del_user [!dr_evil users log:logger@beta1] 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + diff --git a/t/plans/reserved-tag.plan b/t/plans/reserved-tag.plan new file mode 100644 index 00000000..28f1cc6d --- /dev/null +++ b/t/plans/reserved-tag.plan @@ -0,0 +1,10 @@ +%project=reserved_tag + +hey 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@foo 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + +@bar 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@HEAD 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@whatever 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +ruh-roh 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + diff --git a/t/plans/widgets.plan b/t/plans/widgets.plan new file mode 100644 index 00000000..f5ad82f8 --- /dev/null +++ b/t/plans/widgets.plan @@ -0,0 +1,8 @@ +%project=widgets +# This is a note + + # And there was a blank line. + +hey 2012-07-16T14:01:20Z Barack Obama <potus@whitehouse.gov> +you 2012-07-16T14:01:35Z Barack Obama <potus@whitehouse.gov> +@foo 2012-07-16T14:02:05Z Barack Obama <potus@whitehouse.gov> # look, a tag! diff --git a/t/pragma.t b/t/pragma.t new file mode 100644 index 00000000..92ce6a8d --- /dev/null +++ b/t/pragma.t @@ -0,0 +1,63 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More tests => 10; +#use Test::More 'no_plan'; +use Test::NoWarnings; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use lib 't/lib'; +use TestConfig; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Plan::Pragma'; + require_ok $CLASS or die; +} + +can_ok $CLASS, qw( + name + lspace + rspace + hspace + ropspace + lopspace + note + plan + value +); + +my $config = TestConfig->new('core.engine' => 'sqlite'); +my $sqitch = App::Sqitch->new(config => $config); +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target); +isa_ok my $dir = $CLASS->new( + name => 'foo', + plan => $plan, +), $CLASS; +isa_ok $dir, 'App::Sqitch::Plan::Line'; + +is $dir->format_name, '%foo', 'Name should format as "%foo"'; +is $dir->format_value, '', 'Value should format as ""'; +is $dir->as_string, '%foo', 'should stringify to "%foo"'; + +ok $dir = $CLASS->new( + name => 'howdy', + value => 'woody', + plan => $plan, + lspace => ' ', + hspace => ' ', + rspace => "\t", + lopspace => ' ', + operator => '=', + ropspace => ' ', + note => 'blah blah blah', +), 'Create pragma with more stuff'; + +is $dir->as_string, " % howdy = woody\t# blah blah blah", + 'It should stringify correctly'; diff --git a/t/read.pl b/t/read.pl new file mode 100644 index 00000000..20881c29 --- /dev/null +++ b/t/read.pl @@ -0,0 +1,3 @@ +use 5.010; + +print while <STDIN>; diff --git a/t/rebase.t b/t/rebase.t new file mode 100644 index 00000000..de45063d --- /dev/null +++ b/t/rebase.t @@ -0,0 +1,674 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use Test::More; +use App::Sqitch; +use App::Sqitch::Target; +use Path::Class qw(dir file); +use App::Sqitch::X qw(hurl); +use Locale::TextDomain qw(App-Sqitch); +use Test::MockModule; +use Test::Exception; +use Test::Warn; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::rebase'; +require_ok $CLASS or die; + +isa_ok $CLASS, 'App::Sqitch::Command'; +can_ok $CLASS, qw( + target + options + configure + new + onto_change + upto_change + log_only + execute + deploy_variables + revert_variables + does + _collect_deploy_vars + _collect_revert_vars +); + +ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_" + for qw(RevertDeployCommand ConnectingCommand ContextCommand); + +is_deeply [$CLASS->options], [qw( + onto-change|onto=s + upto-change|upto=s + plan-file|f=s + top-dir=s + registry=s + client|db-client=s + db-name|d=s + db-user|db-username|u=s + db-host|h=s + db-port|p=i + target|t=s + mode=s + verify! + set|s=s% + set-deploy|e=s% + set-revert|r=s% + log-only + y +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir(qw(t sql))->stringify, + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, +); +ok my $sqitch = App::Sqitch->new(config => $config), + 'Load a sqitch sqitch object'; + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure($config, {}), { + no_prompt => 0, + verify => 0, + mode => 'all', + prompt_accept => 1, + _params => [], + _cx => [], +}, 'Should have empty default configuration with no config or opts'; + +is_deeply $CLASS->configure($config, { + set => { foo => 'bar' }, +}), { + no_prompt => 0, + prompt_accept => 1, + verify => 0, + mode => 'all', + deploy_variables => { foo => 'bar' }, + revert_variables => { foo => 'bar' }, + _params => [], + _cx => [], +}, 'Should have set option'; + +is_deeply $CLASS->configure($config, { + y => 1, + set_deploy => { foo => 'bar' }, + log_only => 1, + verify => 1, + mode => 'tag', +}), { + mode => 'tag', + no_prompt => 1, + prompt_accept => 1, + deploy_variables => { foo => 'bar' }, + verify => 1, + log_only => 1, + _params => [], + _cx => [], +}, 'Should have mode, deploy_variables, verify, no_prompt, and log_only'; + +is_deeply $CLASS->configure($config, { + y => 0, + set_revert => { foo => 'bar' }, +}), { + mode => 'all', + no_prompt => 0, + prompt_accept => 1, + verify => 0, + revert_variables => { foo => 'bar' }, + _params => [], + _cx => [], +}, 'Should have set_revert option and no_prompt false'; + +is_deeply $CLASS->configure($config, { + set => { foo => 'bar' }, + set_deploy => { foo => 'dep', hi => 'you' }, + set_revert => { foo => 'rev', hi => 'me' }, +}), { + mode => 'all', + no_prompt => 0, + prompt_accept => 1, + verify => 0, + deploy_variables => { foo => 'dep', hi => 'you' }, + revert_variables => { foo => 'rev', hi => 'me' }, + _params => [], + _cx => [], +}, 'set_deploy and set_revert should overrid set'; + +is_deeply $CLASS->configure($config, { + set => { foo => 'bar' }, + set_deploy => { hi => 'you' }, + set_revert => { hi => 'me' }, +}), { + mode => 'all', + no_prompt => 0, + prompt_accept => 1, + verify => 0, + deploy_variables => { foo => 'bar', hi => 'you' }, + revert_variables => { foo => 'bar', hi => 'me' }, + _params => [], + _cx => [], +}, 'set_deploy and set_revert should merge with set'; + +is_deeply $CLASS->configure($config, { + set => { foo => 'bar' }, + set_deploy => { hi => 'you' }, + set_revert => { my => 'yo' }, +}), { + mode => 'all', + no_prompt => 0, + prompt_accept => 1, + verify => 0, + deploy_variables => { foo => 'bar', hi => 'you' }, + revert_variables => { foo => 'bar', my => 'yo' }, + _params => [], + _cx => [], +}, 'set_revert should merge with set_deploy'; + +CONFIG: { + my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'deploy.variables' => { foo => 'bar', hi => 21 }, + ); + + is_deeply $CLASS->configure($config, {}), { + no_prompt => 0, + verify => 0, + mode => 'all', + prompt_accept => 1, + _params => [], + _cx => [], + }, 'Should have deploy configuration'; + + # Try setting variables. + is_deeply $CLASS->configure($config, { + onto_change => 'whu', + set => { foo => 'yo', yo => 'stellar' }, + }), { + mode => 'all', + no_prompt => 0, + prompt_accept => 1, + verify => 0, + deploy_variables => { foo => 'yo', yo => 'stellar' }, + revert_variables => { foo => 'yo', yo => 'stellar' }, + onto_change => 'whu', + _params => [], + _cx => [], + }, 'Should have merged variables'; + is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + + # Make sure we can override mode, prompting, and verify. + $config->replace( + 'core.engine' => 'sqlite', + 'revert.no_prompt' => 1, + 'revert.prompt_accept' => 0, + 'deploy.verify' => 1, + 'deploy.mode' => 'tag', + ); + is_deeply $CLASS->configure($config, {}), { + no_prompt => 1, + prompt_accept => 0, + verify => 1, + mode => 'tag', + _params => [], + _cx => [], + }, 'Should have no_prompt true'; + + # Rebase option takes precendence + $config->update( + 'rebase.no_prompt' => 0, + 'rebase.prompt_accept' => 1, + 'rebase.verify' => 0, + 'rebase.mode' => 'change', + ); + is_deeply $CLASS->configure($config, {}), { + no_prompt => 0, + prompt_accept => 1, + verify => 0, + mode => 'change', + _params => [], + _cx => [], + }, 'Should have false no_prompt, verify, and true prompt_accept from rebase config'; + + $config->update( + 'revert.no_prompt' => undef, + 'revert.prompt_accept' => undef, + 'rebase.verify' => undef, + 'rebase.mode' => undef, + 'rebase.no_prompt' => 1, + 'rebase.prompt_accept' => 0, + ); + is_deeply $CLASS->configure($config, {}), { + no_prompt => 1, + prompt_accept => 0, + verify => 1, + mode => 'tag', + _params => [], + _cx => [], + }, 'Should have true no_prompt, verify, and false prompt_accept from rebase from deploy'; + + # But option should override. + is_deeply $CLASS->configure($config, {y => 0, verify => 0, mode => 'all'}), { + no_prompt => 0, + verify => 0, + mode => 'all', + prompt_accept => 0, + _params => [], + _cx => [], + }, 'Should have no_prompt, prompt_accept false and mode all again'; + + $config->update( + 'revert.no_prompt' => 0, + 'revert.prompt_accept' => 1, + 'rebase.no_prompt' => undef, + 'rebase.prompt_accept' => undef, + ); + is_deeply $CLASS->configure($config, {}), { + no_prompt => 0, + prompt_accept => 1, + verify => 1, + mode => 'tag', + _params => [], + _cx => [], + }, 'Should have no_prompt false and prompt_accept true for revert config'; + + is_deeply $CLASS->configure($config, {y => 1}), { + no_prompt => 1, + prompt_accept => 1, + verify => 1, + mode => 'tag', + _params => [], + _cx => [], + }, 'Should have no_prompt true with -y'; +} + +############################################################################## +# Test accessors. +isa_ok my $rebase = $CLASS->new( + sqitch => $sqitch, + target => 'foo', +), $CLASS, 'new status with target'; +is $rebase->target, 'foo', 'Should have target "foo"'; + +isa_ok $rebase = $CLASS->new(sqitch => $sqitch), $CLASS; +is $rebase->target, undef, 'Should have undef target'; +is $rebase->onto_change, undef, 'onto_change should be undef'; +is $rebase->upto_change, undef, 'upto_change should be undef'; + +# Mock the engine interface. +my $mock_engine = Test::MockModule->new('App::Sqitch::Engine::sqlite'); +my @dep_args; +$mock_engine->mock(deploy => sub { shift; @dep_args = @_ }); +my @rev_args; +$mock_engine->mock(revert => sub { shift; @rev_args = @_ }); +my @vars; +$mock_engine->mock(set_variables => sub { shift; push @vars => [@_] }); + +############################################################################## +# Test _collect_deploy_vars and _collect_revert_vars. +$config->replace( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir(qw(t sql))->stringify, + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, +); +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $rebase->_collect_deploy_vars($target) }, {}, + 'Should collect no variables for deploy'; +is_deeply { $rebase->_collect_revert_vars($target) }, {}, + 'Should collect no variables for revert'; + +# Add core variables. +$config->update('core.variables' => { prefix => 'widget', priv => 'SELECT' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $rebase->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'SELECT', +}, 'Should collect core deploy vars for deploy'; +is_deeply { $rebase->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'SELECT', +}, 'Should collect core revert vars for revert'; + +# Add deploy variables. +$config->update('deploy.variables' => { dance => 'salsa', priv => 'UPDATE' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $rebase->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'salsa', +}, 'Should override core vars with deploy vars for deploy'; + +is_deeply { $rebase->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'salsa', +}, 'Should override core vars with deploy vars for revert'; + +# Add revert variables. +$config->update('revert.variables' => { dance => 'disco', lunch => 'pizza' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $rebase->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'salsa', +}, 'Deploy vars should be unaffected by revert vars'; +is_deeply { $rebase->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'pizza', +}, 'Should override deploy vars with revert vars for revert'; + +# Add engine variables. +$config->update('engine.pg.variables' => { lunch => 'burrito', drink => 'whiskey', priv => 'UP' }); +my $uri = URI::db->new('db:pg:'); +$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => $uri); +is_deeply { $rebase->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'whiskey', +}, 'Should override deploy vars with engine vars for deploy'; +is_deeply { $rebase->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'whiskey', +}, 'Should override rebase vars with engine vars for revert'; + +# Add target variables. +$config->update('target.foo.variables' => { drink => 'scotch', status => 'winning' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $rebase->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'scotch', + status => 'winning', +}, 'Should override engine vars with deploy vars for deploy'; +is_deeply { $rebase->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'winning', +}, 'Should override engine vars with target vars for revert'; + +# Add --set variables. +my %opts = ( + set => { status => 'tired', herb => 'oregano' }, +); +$rebase = $CLASS->new( + sqitch => $sqitch, + %{ $CLASS->configure($config, { %opts }) }, +); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $rebase->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'oregano', +}, 'Should override target vars with --set vars for deploy'; +is_deeply { $rebase->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'oregano', +}, 'Should override target vars with --set variables for revert'; + +# Add --set-deploy-vars +$opts{set_deploy} = { herb => 'basil', color => 'black' }; +$rebase = $CLASS->new( + sqitch => $sqitch, + %{ $CLASS->configure($config, { %opts }) }, +); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $rebase->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'basil', + color => 'black', +}, 'Should override --set vars with --set-deploy variables for deploy'; +is_deeply { $rebase->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'oregano', +}, 'Should not override --set vars with --set-deploy variables for revert'; + +# Add --set-revert-vars +$opts{set_revert} = { herb => 'garlic', color => 'red' }; +$rebase = $CLASS->new( + sqitch => $sqitch, + %{ $CLASS->configure($config, { %opts }) }, +); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $rebase->_collect_deploy_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'salsa', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'basil', + color => 'black', +}, 'Should not override --set vars with --set-revert variables for deploy'; +is_deeply { $rebase->_collect_revert_vars($target) }, { + prefix => 'widget', + priv => 'UP', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'garlic', + color => 'red', +}, 'Should override --set vars with --set-revert variables for revert'; + +$config->replace( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir(qw(t sql))->stringify, + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, +); +$rebase = $CLASS->new( sqitch => $sqitch); + +############################################################################## +# Test execute(). +my $mock_cmd = Test::MockModule->new($CLASS); +my $orig_method; +$mock_cmd->mock(parse_args => sub { + my @ret = shift->$orig_method(@_); + $target = $ret[0][0]; + @ret; +}); +$orig_method = $mock_cmd->original('parse_args'); + +ok $rebase->execute('@alpha'), 'Execute to "@alpha"'; +is_deeply \@dep_args, [undef, 'all'], + 'undef, and "all" should be passed to the engine deploy'; +is_deeply \@vars, [[], []], + 'No vars should have been passed through to the engine'; +is_deeply \@rev_args, ['@alpha'], + '"@alpha" should be passed to the engine revert'; +ok !$target->engine->no_prompt, 'Engine should prompt'; +ok !$target->engine->log_only, 'Engine should no be log only'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Pass a target. +@vars = (); +ok $rebase->execute('db:sqlite:yow'), 'Execute with target'; +is_deeply \@dep_args, [undef, 'all'], + 'undef, and "all" should be passed to the engine deploy'; +is_deeply \@rev_args, [undef], + 'undef should be passed to the engine revert'; +is_deeply \@vars, [[], []], + 'No vars should have been passed through to the engine'; +ok !$target->engine->no_prompt, 'Engine should prompt'; +ok !$target->engine->log_only, 'Engine should no be log only'; +is $target->name, 'db:sqlite:yow', 'The target name should be as passed'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Pass both. +@vars = (); +ok $rebase->execute('db:sqlite:yow', 'widgets'), 'Execute with onto and target'; +is_deeply \@dep_args, [undef, 'all'], + 'undef, and "all" should be passed to the engine deploy'; +is_deeply \@rev_args, ['widgets'], + '"widgets" should be passed to the engine revert'; +is_deeply \@vars, [[], []], + 'No vars should have been passed through to the engine'; +ok !$target->engine->no_prompt, 'Engine should prompt'; +ok !$target->engine->log_only, 'Engine should no be log only'; +is $target->name, 'db:sqlite:yow', 'The target name should be as passed'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Pass all three! +@vars = (); +ok $rebase->execute('db:sqlite:yow', 'roles', 'widgets'), + 'Execute with three args'; +is_deeply \@dep_args, ['widgets', 'all'], + '"widgets", and "all" should be passed to the engine deploy'; +is_deeply \@rev_args, ['roles'], + '"roles" should be passed to the engine revert'; +is_deeply \@vars, [[], []], + 'No vars should have been passed through to the engine'; +ok !$target->engine->no_prompt, 'Engine should prompt'; +ok !$target->engine->log_only, 'Engine should no be log only'; +is $target->name, 'db:sqlite:yow', 'The target name should be as passed'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Pass no args. +@vars = @dep_args = @rev_args = (); +ok $rebase->execute, 'Execute'; +is_deeply \@dep_args, [undef, 'all'], + 'undef and "all" should be passed to the engine deploy'; +is_deeply \@rev_args, [undef], + 'undef and = should be passed to the engine revert'; +is_deeply \@vars, [[], []], + 'No vars should have been passed through to the engine'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Mix it up with options. +isa_ok $rebase = $CLASS->new( + target => 'db:sqlite:lolwut', + no_prompt => 1, + log_only => 1, + verify => 1, + sqitch => $sqitch, + mode => 'tag', + onto_change => 'foo', + upto_change => 'bar', + deploy_variables => { foo => 'bar', one => 1 }, + revert_variables => { hey => 'there' }, +), $CLASS, 'Object with to and variables'; + +@vars = @dep_args = @rev_args = (); +ok $rebase->execute, 'Execute again'; +is $target->name, 'db:sqlite:lolwut', 'Target name should be from option'; +ok $target->engine->no_prompt, 'Engine should be no_prompt'; +ok $target->engine->log_only, 'Engine should be log_only'; +ok $target->engine->with_verify, 'Engine should verify'; +is_deeply \@dep_args, ['bar', 'tag'], + '"bar", "tag", and 1 should be passed to the engine deploy'; +is_deeply \@rev_args, ['foo'], '"foo" and 1 should be passed to the engine revert'; +is @vars, 2, 'Variables should have been passed to the engine twice'; +is_deeply { @{ $vars[0] } }, { hey => 'there' }, + 'The revert vars should have been passed first'; +is_deeply { @{ $vars[1] } }, { foo => 'bar', one => 1 }, + 'The deploy vars should have been next'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Make sure we get warnings for too many things. +@dep_args = @rev_args, @vars = (); +ok $rebase->execute('db:sqlite:yow', 'roles', 'widgets'), + 'Execute with three args'; +is $target->name, 'db:sqlite:lolwut', 'Target name should be from option'; +ok $target->engine->no_prompt, 'Engine should be no_prompt'; +ok $target->engine->log_only, 'Engine should be log_only'; +ok $target->engine->with_verify, 'Engine should verify'; +is_deeply \@dep_args, ['bar', 'tag'], + '"bar", "tag", and 1 should be passed to the engine deploy'; +is_deeply \@rev_args, ['foo'], '"foo" and 1 should be passed to the engine revert'; +is @vars, 2, 'Variables should have been passed to the engine twice'; +is_deeply { @{ $vars[0] } }, { hey => 'there' }, + 'The revert vars should have been passed first'; +is_deeply { @{ $vars[1] } }, { foo => 'bar', one => 1 }, + 'The deploy vars should have been next'; +is_deeply +MockOutput->get_warn, [[__x( + 'Too many targets specified; connecting to {target}', + target => 'db:sqlite:lolwut', +)], [__x( + 'Too many changes specified; rebasing onto "{onto}" up to "{upto}"', + onto => 'foo', + upto => 'bar', +)]], 'Should have two warnings'; + +# Make sure we get an exception for unknown args. +throws_ok { $rebase->execute(qw(greg)) } 'App::Sqitch::X', + 'Should get an exception for unknown arg'; +is $@->ident, 'rebase', 'Unknow arg ident should be "rebase"'; +is $@->message, __x( + 'Unknown argument "{arg}"', + arg => 'greg', +), 'Should get an exeption for two unknown arg'; + +throws_ok { $rebase->execute(qw(greg jon)) } 'App::Sqitch::X', + 'Should get an exception for unknown args'; +is $@->ident, 'rebase', 'Unknow args ident should be "rebase"'; +is $@->message, __x( + 'Unknown arguments: {arg}', + arg => 'greg, jon', +), 'Should get an exeption for two unknown args'; + +# If nothing is deployed, or we are already at the revert target, the revert +# should be skipped. +@dep_args = @rev_args = @vars = (); +$mock_engine->mock(revert => sub { hurl { ident => 'revert', message => 'foo', exitval => 1 } }); +ok $rebase->execute, 'Execute once more'; +is_deeply \@dep_args, ['bar', 'tag'], + '"bar", "tag", and 1 should be passed to the engine deploy'; +is @vars, 2, 'Variables should have been passed to the engine twice'; +is_deeply { @{ $vars[0] } }, { hey => 'there' }, + 'The revert vars should have been passed first'; +is_deeply { @{ $vars[1] } }, { foo => 'bar', one => 1 }, + 'The deploy vars should have been next'; +is_deeply +MockOutput->get_info, [['foo']], + 'Should have emitted info for non-fatal revert exception'; + +# Should die for fatal, unknown, or confirmation errors. +for my $spec ( + [ confirm => App::Sqitch::X->new(ident => 'revert:confirm', message => 'foo', exitval => 1) ], + [ fatal => App::Sqitch::X->new(ident => 'revert', message => 'foo', exitval => 2) ], + [ unknown => bless { } => __PACKAGE__ ], +) { + $mock_engine->mock(revert => sub { die $spec->[1] }); + throws_ok { $rebase->execute } ref $spec->[1], + "Should rethrow $spec->[0] exception"; +} + +done_testing; diff --git a/t/revert.t b/t/revert.t new file mode 100644 index 00000000..2bd0bd0d --- /dev/null +++ b/t/revert.t @@ -0,0 +1,347 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use Test::More; +use App::Sqitch; +use App::Sqitch::Target; +use Path::Class qw(dir file); +use Test::MockModule; +use Test::Exception; +use Test::Warn; +use Locale::TextDomain qw(App-Sqitch); +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::revert'; +require_ok $CLASS or die; + +isa_ok $CLASS, 'App::Sqitch::Command'; +can_ok $CLASS, qw( + target + options + configure + new + to_change + log_only + execute + variables + does +); + +ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_" + for qw(ContextCommand ConnectingCommand); + +is_deeply [$CLASS->options], [qw( + target|t=s + to-change|to|change=s + set|s=s% + log-only + y + plan-file|f=s + top-dir=s + registry=s + client|db-client=s + db-name|d=s + db-user|db-username|u=s + db-host|h=s + db-port|p=i +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir(qw(t sql))->stringify, + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, +); +my $sqitch = App::Sqitch->new(config => $config); + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure($config, {}), { + no_prompt => 0, + prompt_accept => 1, + _params => [], + _cx => [], +}, 'Should have empty default configuration with no config or opts'; + +is_deeply $CLASS->configure($config, { + y => 1, + set => { foo => 'bar' }, +}), { + no_prompt => 1, + prompt_accept => 1, + variables => { foo => 'bar' }, + _params => [], + _cx => [], +}, 'Should have set option'; + +CONFIG: { + my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'revert.variables' => { foo => 'bar', hi => 21 }, + ); + + is_deeply $CLASS->configure($config, {}), { + no_prompt => 0, + prompt_accept => 1, + _params => [], + _cx => [], + }, 'Should have no_prompt false, prompt_accept true'; + + # Make sure we can override prompting. + $config->update( + 'revert.no_prompt' => 1, + 'revert.prompt_accept' => 0, + ); + is_deeply $CLASS->configure($config, {}), { + no_prompt => 1, + prompt_accept => 0, + _params => [], + _cx => [], + }, 'Should have no_prompt true, prompt_accept false'; + + # But option should override. + is_deeply $CLASS->configure($config, {y => 0}), { + no_prompt => 0, + prompt_accept => 0, + _params => [], + _cx => [], + }, 'Should have no_prompt false again'; + + $config->update( + 'revert.no_prompt' => 0, + 'revert.prompt_accept' => 1, + ); + is_deeply $CLASS->configure($config, {}), { + no_prompt => 0, + prompt_accept => 1, + _params => [], + _cx => [], + }, 'Should have no_prompt false for false config'; + + is_deeply $CLASS->configure($config, {y => 1}), { + no_prompt => 1, + prompt_accept => 1, + _params => [], + _cx => [], + }, 'Should have no_prompt true with -y'; +} + +############################################################################## +# Test construction. +isa_ok my $revert = $CLASS->new( + sqitch => $sqitch, + target => 'foo', + no_prompt => 1, +), $CLASS, 'new revert with target'; +is $revert->target, 'foo', 'Should have target "foo"'; +is $revert->to_change, undef, 'to_change should be undef'; +isa_ok $revert = $CLASS->new(sqitch => $sqitch, no_prompt => 1), $CLASS; +is $revert->target, undef, 'Should have undef default target'; +is $revert->to_change, undef, 'to_change should be undef'; + +############################################################################## +# Test _collect_vars. +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $revert->_collect_vars($target) }, {}, 'Should collect no variables'; + +# Add core variables. +$config->update('core.variables' => { prefix => 'widget', priv => 'SELECT' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $revert->_collect_vars($target) }, { + prefix => 'widget', + priv => 'SELECT', +}, 'Should collect core vars'; + +# Add deploy variables. +$config->update('deploy.variables' => { dance => 'salsa', priv => 'UPDATE' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $revert->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'salsa', +}, 'Should override core vars with deploy vars'; + +# Add revert variables. +$config->update('revert.variables' => { dance => 'disco', lunch => 'pizza' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $revert->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'pizza', +}, 'Should override deploy vars with revert vars'; + +# Add engine variables. +$config->update('engine.pg.variables' => { lunch => 'burrito', drink => 'whiskey' }); +my $uri = URI::db->new('db:pg:'); +$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => $uri); +is_deeply { $revert->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'burrito', + drink => 'whiskey', +}, 'Should override revert vars with engine vars'; + +# Add target variables. +$config->update('target.foo.variables' => { drink => 'scotch', status => 'winning' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $revert->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'winning', +}, 'Should override engine vars with target vars'; + +# Add --set variables. +$revert = $CLASS->new( + sqitch => $sqitch, + variables => { status => 'tired', herb => 'oregano' }, +); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $revert->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'oregano', +}, 'Should override target vars with --set variables'; + +$config->replace( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir(qw(t sql))->stringify, + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, +); +$revert = $CLASS->new( sqitch => $sqitch, no_prompt => 1); + +############################################################################## +# Test execution. +# Mock the engine interface. +my $mock_engine = Test::MockModule->new('App::Sqitch::Engine::sqlite'); +my @args; +$mock_engine->mock(revert => sub { shift; @args = @_ }); +my @vars; +$mock_engine->mock(set_variables => sub { shift; @vars = @_ }); + +my $mock_cmd = Test::MockModule->new($CLASS); +my $orig_method; +$mock_cmd->mock(parse_args => sub { + my @ret = shift->$orig_method(@_); + $target = $ret[0][0]; + @ret; +}); +$orig_method = $mock_cmd->original('parse_args'); + +# Pass the change. +ok $revert->execute('@alpha'), 'Execute to "@alpha"'; +ok $target->engine->no_prompt, 'Engine should be no_prompt'; +ok !$target->engine->log_only, 'Engine should not be log_only'; +is_deeply \@args, ['@alpha'], + '"@alpha" should be passed to the engine'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Pass nothing. +@args = (); +ok $revert->execute, 'Execute'; +is_deeply \@args, [undef], + 'undef should be passed to the engine'; +is_deeply {@vars}, { }, + 'No vars should have been passed through to the engine'; +is_deeply +MockOutput->get_warn, [], 'Should still have no warnings'; + +# Pass the target. +ok $revert->execute('db:sqlite:hi'), 'Execute to target'; +ok $target->engine->no_prompt, 'Engine should be no_prompt'; +ok !$target->engine->log_only, 'Engine should not be log_only'; +is_deeply \@args, [undef], + 'undef" should be passed to the engine'; +is $target->name, 'db:sqlite:hi', 'Target name should be as passed'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Pass them both! +ok $revert->execute('db:sqlite:lol', 'widgets'), 'Execute with change and target'; +ok $target->engine->no_prompt, 'Engine should be no_prompt'; +ok !$target->engine->log_only, 'Engine should not be log_only'; +is_deeply \@args, ['widgets'], + '"widgets" should be passed to the engine'; +is $target->name, 'db:sqlite:lol', 'Target name should be as passed'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# And reverse them. +ok $revert->execute('db:sqlite:lol', 'widgets'), 'Execute with target and change'; +ok $target->engine->no_prompt, 'Engine should be no_prompt'; +ok !$target->engine->log_only, 'Engine should not be log_only'; +is_deeply \@args, ['widgets'], + '"widgets" should be passed to the engine'; +is $target->name, 'db:sqlite:lol', 'Target name should be as passed'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Now specify options. +isa_ok $revert = $CLASS->new( + sqitch => $sqitch, + target => 'db:sqlite:welp', + to_change => 'foo', + log_only => 1, + variables => { foo => 'bar', one => 1 }, +), $CLASS, 'Object with to and variables'; + +@args = (); +ok $revert->execute, 'Execute again'; +ok !$target->engine->no_prompt, 'Engine should not be no_prompt'; +ok $target->engine->log_only, 'Engine should be log_only'; +is_deeply \@args, ['foo'], + '"foo" and 1 should be passed to the engine'; +is_deeply {@vars}, { foo => 'bar', one => 1 }, + 'Vars should have been passed through to the engine'; +is $target->name, 'db:sqlite:welp', 'Target name should be from option'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Try also passing the target and change. +ok $revert->execute('db:sqlite:lol', '@alpha'), 'Execute with options and args'; +ok !$target->engine->no_prompt, 'Engine should not be no_prompt'; +ok $target->engine->log_only, 'Engine should be log_only'; +is_deeply \@args, ['foo'], + '"foo" and 1 should be passed to the engine'; +is_deeply {@vars}, { foo => 'bar', one => 1 }, + 'Vars should have been passed through to the engine'; +is $target->name, 'db:sqlite:welp', 'Target name should be from option'; +is_deeply +MockOutput->get_warn, [[__x( + 'Too many targets specified; connecting to {target}', + target => 'db:sqlite:welp', +)], [__x( + 'Too many changes specified; reverting to "{change}"', + change => 'foo', +)]], 'Should have two warnings'; + +# Make sure we get an exception for unknown args. +throws_ok { $revert->execute(qw(greg)) } 'App::Sqitch::X', + 'Should get an exception for unknown arg'; +is $@->ident, 'revert', 'Unknow arg ident should be "revert"'; +is $@->message, __x( + 'Unknown argument "{arg}"', + arg => 'greg', +), 'Should get an exeption for two unknown arg'; + +throws_ok { $revert->execute(qw(greg jon)) } 'App::Sqitch::X', + 'Should get an exception for unknown args'; +is $@->ident, 'revert', 'Unknow args ident should be "revert"'; +is $@->message, __x( + 'Unknown arguments: {arg}', + arg => 'greg, jon', +), 'Should get an exeption for two unknown args'; + +done_testing; diff --git a/t/rework.conf b/t/rework.conf new file mode 100644 index 00000000..38f726e1 --- /dev/null +++ b/t/rework.conf @@ -0,0 +1,2 @@ +[rework] + open_editor = true diff --git a/t/rework.t b/t/rework.t new file mode 100644 index 00000000..d252f370 --- /dev/null +++ b/t/rework.t @@ -0,0 +1,978 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 234; +#use Test::More 'no_plan'; +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use Test::Exception; +use Test::Warn; +use App::Sqitch::Command::add; +use Path::Class; +use Test::File qw(file_not_exists_ok file_exists_ok); +use Test::File::Contents qw(file_contents_identical file_contents_is files_eq); +use File::Path qw(make_path remove_tree); +use Test::NoWarnings; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::rework'; +my $test_dir = dir 'test-rework'; + +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => $test_dir->stringify, +); +ok my $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object'; + +isa_ok my $rework = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'rework', + config => $config, +}), $CLASS, 'rework command'; +my $target = $rework->default_target; + +sub dep($) { + my $dep = App::Sqitch::Plan::Depend->new( + conflicts => 0, + %{ App::Sqitch::Plan::Depend->parse(shift) }, + plan => $rework->default_target->plan, + ); + $dep->project; + return $dep; +} + +can_ok $CLASS, qw( + change_name + requires + conflicts + note + execute + does +); + +ok $CLASS->does("App::Sqitch::Role::ContextCommand"), + "$CLASS does ContextCommand"; + +is_deeply [$CLASS->options], [qw( + change-name|change|c=s + requires|r=s@ + conflicts|x=s@ + all|a! + note|n|m=s@ + open-editor|edit|e! + plan-file|f=s + top-dir=s +)], 'Options should be set up'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure($config, {}), { _cx => [] }, + 'Should have default configuration with no config or opts'; + +is_deeply $CLASS->configure($config, { + requires => [qw(foo bar)], + conflicts => ['baz'], + note => [qw(hi there)], +}), { + requires => [qw(foo bar)], + conflicts => ['baz'], + note => [qw(hi there)], + _cx => [], +}, 'Should have get requires, conflicts, and note options'; + +# open_editor handling +CONFIG: { + my $config = TestConfig->from(local => File::Spec->catfile(qw(t rework.conf))); + is_deeply $CLASS->configure($config, {}), { _cx => []}, + 'Grabs nothing from config'; + + ok my $sqitch = App::Sqitch->new(config => $config), 'Load Sqitch project'; + isa_ok my $rework = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'rework', + config => $config, + }), $CLASS, 'rework command'; + ok $rework->open_editor, 'Coerces rework.open_editor from config string boolean'; +} + +############################################################################## +# Test attributes. +is_deeply $rework->requires, [], 'Requires should be an arrayref'; +is_deeply $rework->conflicts, [], 'Conflicts should be an arrayref'; +is_deeply $rework->note, [], 'Note should be an arrayref'; + +############################################################################## +# Test execute(). +make_path $test_dir->stringify; +END { remove_tree $test_dir->stringify if -e $test_dir->stringify }; +my $plan_file = $target->plan_file; +my $fh = $plan_file->open('>') or die "Cannot open $plan_file: $!"; +say $fh "%project=empty\n\n"; +$fh->close or die "Error closing $plan_file: $!"; + +my $plan = $target->plan; + +throws_ok { $rework->execute('foo') } 'App::Sqitch::X', + 'Should get an example for nonexistent change'; +is $@->ident, 'plan', 'Nonexistent change error ident should be "plan"'; +is $@->message, __x( + qq{Change "{change}" does not exist in {file}.\n} + . 'Use "sqitch add {change}" to add it to the plan', + change => 'foo', + file => $plan->file, +), 'Fail message should say the step does not exist'; + +# Use the add command to create a step. +my $deploy_file = file qw(test-rework deploy foo.sql); +my $revert_file = file qw(test-rework revert foo.sql); +my $verify_file = file qw(test-rework verify foo.sql); + +my $change_mocker = Test::MockModule->new('App::Sqitch::Plan::Change'); +my %request_params; +$change_mocker->mock(request_note => sub { + my $self = shift; + %request_params = @_; + return $self->note; +}); + +# Use the same plan. +my $mock_plan = Test::MockModule->new(ref $target); +$mock_plan->mock(plan => $plan); + +ok my $add = App::Sqitch::Command::add->new( + sqitch => $sqitch, + change_name => 'foo', + template_directory => Path::Class::dir(qw(etc templates)) +), 'Create another add with template_directory'; +file_not_exists_ok($_) for ($deploy_file, $revert_file, $verify_file); +ok $add->execute, 'Execute with the --change option'; +file_exists_ok($_) for ($deploy_file, $revert_file, $verify_file); +ok my $foo = $plan->get('foo'), 'Get the "foo" change'; + +throws_ok { $rework->execute('foo') } 'App::Sqitch::X', + 'Should get an example for duplicate change'; +is $@->ident, 'plan', 'Duplicate change error ident should be "plan"'; +is $@->message, __x( + qq{Cannot rework "{change}" without an intervening tag.\n} + . 'Use "sqitch tag" to create a tag and try again', + change => 'foo', +), 'Fail message should say a tag is needed'; + +# Tag it, and *then* it should work. +ok $plan->tag( name => '@alpha' ), 'Tag it'; + +my $deploy_file2 = file qw(test-rework deploy foo@alpha.sql); +my $revert_file2 = file qw(test-rework revert foo@alpha.sql); +my $verify_file2 = file qw(test-rework verify foo@alpha.sql); +MockOutput->get_info; + +file_not_exists_ok($_) for ($deploy_file2, $revert_file2, $verify_file2); +ok $rework->execute('foo'), 'Rework "foo"'; + +# The files should have been copied. +file_exists_ok($_) for ($deploy_file, $revert_file, $verify_file); +file_exists_ok($_) for ($deploy_file2, $revert_file2, $verify_file2); +file_contents_identical($deploy_file2, $deploy_file); +file_contents_identical($verify_file2, $verify_file); +file_contents_identical($revert_file, $deploy_file); +file_contents_is($revert_file2, <<'EOF', 'New revert should revert'); +-- Revert empty:foo from sqlite + +BEGIN; + +-- XXX Add DDLs here. + +COMMIT; +EOF + +# The note should have been required. +is_deeply \%request_params, { + for => __ 'rework', + scripts => [$deploy_file, $revert_file, $verify_file], +}, 'It should have prompted for a note'; + +# The plan file should have been updated. +ok $plan->load, 'Reload the plan file'; +ok my @steps = $plan->changes, 'Get the steps'; +is @steps, 2, 'Should have two steps'; +is $steps[0]->name, 'foo', 'First step should be "foo"'; +is $steps[1]->name, 'foo', 'Second step should also be "foo"'; +is_deeply [$steps[1]->requires], [dep 'foo@alpha'], + 'Reworked step should require the previous step'; + +is_deeply +MockOutput->get_info, [ + [__x( + 'Added "{change}" to {file}.', + change => 'foo [foo@alpha]', + file => $target->plan_file, + )], + [__n( + 'Modify this file as appropriate:', + 'Modify these files as appropriate:', + 3, + )], + [" * $deploy_file"], + [" * $revert_file"], + [" * $verify_file"], +], 'And the info message should suggest editing the old files'; +is_deeply +MockOutput->get_debug, [ + [__x( + 'Copied {src} to {dest}', + dest => $deploy_file2, + src => $deploy_file, + )], + [__x( + 'Copied {src} to {dest}', + dest => $revert_file2, + src => $revert_file, + )], + [__x( + 'Copied {src} to {dest}', + dest => $verify_file2, + src => $verify_file, + )], + [__x( + 'Copied {src} to {dest}', + dest => $revert_file, + src => $deploy_file, + )], +], 'Debug should show file copying'; + +############################################################################## +# Let's do that again. This time with more dependencies and fewer files. +$deploy_file = file qw(test-rework deploy bar.sql); +$revert_file = file qw(test-rework revert bar.sql); +$verify_file = file qw(test-rework verify bar.sql); +ok $add = App::Sqitch::Command::add->new( + sqitch => $sqitch, + template_directory => Path::Class::dir(qw(etc templates)), + with_scripts => { revert => 0, verify => 0 }, +), 'Create another add with template_directory'; +file_not_exists_ok($_) for ($deploy_file, $revert_file, $verify_file); +$add->execute('bar'); +file_exists_ok($deploy_file); +file_not_exists_ok($_) for ($revert_file, $verify_file); +ok $plan->tag( name => '@beta' ), 'Tag it with @beta'; + +my $deploy_file3 = file qw(test-rework deploy bar@beta.sql); +my $revert_file3 = file qw(test-rework revert bar@beta.sql); +my $verify_file3 = file qw(test-rework verify bar@beta.sql); +MockOutput->get_info; + +isa_ok $rework = App::Sqitch::Command::rework->new( + sqitch => $sqitch, + command => 'rework', + config => $config, + requires => ['foo'], + note => [qw(hi there)], + conflicts => ['dr_evil'], +), $CLASS, 'rework command with requirements and conflicts'; + +# Check the files. +file_not_exists_ok($_) for ($deploy_file3, $revert_file3, $verify_file3); +ok $rework->execute('bar'), 'Rework "bar"'; +file_exists_ok($deploy_file); +file_not_exists_ok($_) for ($revert_file, $verify_file); +file_exists_ok($deploy_file3); +file_not_exists_ok($_) for ($revert_file3, $verify_file3); + +# The note should have been required. +is_deeply \%request_params, { + for => __ 'rework', + scripts => [$deploy_file], +}, 'It should have prompted for a note'; + +# The plan file should have been updated. +ok $plan->load, 'Reload the plan file again'; +ok @steps = $plan->changes, 'Get the steps'; +is @steps, 4, 'Should have four steps'; +is $steps[0]->name, 'foo', 'First step should be "foo"'; +is $steps[1]->name, 'foo', 'Second step should also be "foo"'; +is $steps[2]->name, 'bar', 'First step should be "bar"'; +is $steps[3]->name, 'bar', 'Second step should also be "bar"'; +is_deeply [$steps[3]->requires], [dep 'bar@beta', dep 'foo'], + 'Requires should have been passed to reworked change'; +is_deeply [$steps[3]->conflicts], [dep '!dr_evil'], + 'Conflicts should have been passed to reworked change'; +is $steps[3]->note, "hi\n\nthere", + 'Note should have been passed as comment'; + +is_deeply +MockOutput->get_info, [ + [__x( + 'Added "{change}" to {file}.', + change => 'bar [bar@beta foo !dr_evil]', + file => $target->plan_file, + )], + [__n( + 'Modify this file as appropriate:', + 'Modify these files as appropriate:', + 1, + )], + [" * $deploy_file"], +], 'And the info message should show only the one file to modify'; + +is_deeply +MockOutput->get_debug, [ + [__x( + 'Copied {src} to {dest}', + dest => $deploy_file3, + src => $deploy_file, + )], + [__x( + 'Skipped {dest}: {src} does not exist', + dest => $revert_file3, + src => $revert_file, + )], + [__x( + 'Skipped {dest}: {src} does not exist', + dest => $verify_file3, + src => $verify_file, + )], + [__x( + 'Skipped {dest}: {src} does not exist', + dest => $revert_file, + src => $revert_file3, # No previous revert, no need for new revert. + )], +], 'Should have debug oputput for missing files'; + +# Make sure --open-editor works +MOCKSHELL: { + my $sqitch_mocker = Test::MockModule->new('App::Sqitch'); + my $shell_cmd; + $sqitch_mocker->mock(shell => sub { $shell_cmd = $_[1] }); + $sqitch_mocker->mock(quote_shell => sub { shift; join ' ' => @_ }); + + ok $rework = $CLASS->new( + sqitch => $sqitch, + template_directory => Path::Class::dir(qw(etc templates)), + note => ['Testing --open-editor'], + open_editor => 1, + ), 'Create another add with open_editor'; + + ok $plan->tag( name => '@gamma' ), 'Tag it'; + + my $rework_file = file qw(test-rework deploy bar.sql); + my $deploy_file = file qw(test-rework deploy bar@gamma.sql); + my $revert_file = file qw(test-rework revert bar@gamma.sql); + my $verify_file = file qw(test-rework verify bar@gamma.sql); + MockOutput->get_info; + + file_not_exists_ok($_) for ($deploy_file, $revert_file, $verify_file); + ok $rework->execute('bar'), 'Rework "bar"'; + + # The files should have been copied. + file_exists_ok($_) for ($rework_file, $deploy_file); + file_not_exists_ok($_) for ($revert_file, $verify_file); + + is $shell_cmd, join(' ', $sqitch->editor, $rework_file), + 'It should have prompted to edit sql files'; + + is_deeply +MockOutput->get_info, [ + [__x( + 'Added "{change}" to {file}.', + change => 'bar [bar@gamma]', + file => $target->plan_file, + )], + [__n( + 'Modify this file as appropriate:', + 'Modify these files as appropriate:', + 1, + )], + [" * $rework_file"], + ], 'And the info message should suggest editing the old files'; + MockOutput->get_debug; # empty debug. +}; + +# Make sure a configuration with multiple plans works. +$mock_plan->unmock('plan'); +MULTIPLAN: { + my $dstring = $test_dir->stringify; + remove_tree $dstring; + make_path $dstring; + END { remove_tree $dstring if -e $dstring }; + chdir $dstring; + + my $conf = file 'multirework.conf'; + $conf->spew(join "\n", + '[core]', + 'engine = pg', + '[engine "pg"]', + 'top_dir = pg', + '[engine "sqlite"]', + 'top_dir = sqlite', + '[engine "mysql"]', + 'top_dir = mysql', + ); + + # Create plan files and determine the scripts that to be created. + my %scripts = map { + my $dir = dir $_; + $dir->mkpath; + $dir->file('sqitch.plan')->spew(join "\n", + '%project=rework', '', + 'widgets 2012-07-16T17:25:07Z anna <a@n.na>', + 'gadgets 2012-07-16T18:25:07Z anna <a@n.na>', + '@foo 2012-07-16T17:24:07Z julie <j@ul.ie>', '', + ); + + # Make the script files. + my (@change, @reworked); + for my $type (qw(deploy revert verify)) { + my $subdir = $dir->subdir($type); + $subdir->mkpath; + my $script = $subdir->file('widgets.sql'); + $script->spew("-- $subdir widgets"); + push @change => $script; + push @reworked => $subdir->file('widgets@foo.sql'); + } + + # Return the scripts. + $_ => { change => \@change, reworked => \@reworked }; + } qw(pg sqlite mysql); + + # Load up the configuration for this project. + my $config = TestConfig->from(local => $conf); + my $sqitch = App::Sqitch->new(config => $config); + ok my $rework = $CLASS->new( + sqitch => $sqitch, + note => ['Testing multiple plans'], + all => 1, + template_directory => dir->parent->subdir(qw(etc templates)) + ), 'Create another rework with custom multiplan config'; + + my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch); + is @targets, 3, 'Should have three targets'; + + # Make sure the target list matches our script list order (by engine). + # pg always comes first, as primary engine, but the other two are random. + push @targets, splice @targets, 1, 1 if $targets[1]->engine_key ne 'sqlite'; + + # Let's do this thing! + ok $rework->execute('widgets'), 'Rework change "widgets" in all plans'; + for my $target(@targets) { + my $ekey = $target->engine_key; + ok my $head = $target->plan->get('widgets@HEAD'), + "Get widgets\@HEAD from the $ekey plan"; + ok my $foo = $target->plan->get('widgets@foo'), + "Get widgets\@foo from the $ekey plan"; + cmp_ok $head->id, 'ne', $foo->id, + "The two $ekey widgets should be different changes"; + } + + # All the files should exist, now. + while (my ($k, $v) = each %scripts) { + file_exists_ok $_ for map { @{ $v->{$_} } } qw(change reworked); + # Deploy and verify files should be the same. + files_eq $v->{change}[0], $v->{reworked}[0]; + files_eq $v->{change}[2], $v->{reworked}[2]; + # New revert should be the same as old deploy. + files_eq $v->{change}[1], $v->{reworked}[0]; + } + + # Make sure we see the proper output. + my $info = MockOutput->get_info; + my $note = $request_params{scripts}; + my $ekey = $targets[1]->engine_key; + if ($info->[1][0] !~ /$ekey/) { + # Got the targets in a different order. So reorder results to match. + ($info->[1], $info->[2]) = ($info->[2], $info->[1]); + push @{ $info } => splice @{ $info }, 7, 3; + push @{ $note } => splice @{ $note }, 3, 3; + } + is_deeply $note, [map { @{ $scripts{$_}{change} }} qw(pg sqlite mysql)], + 'Should have listed the files in the note prompt'; + is_deeply $info, [ + [__x( + 'Added "{change}" to {file}.', + change => 'widgets [widgets@foo]', + file => $targets[0]->plan_file, + )], + [__x( + 'Added "{change}" to {file}.', + change => 'widgets [widgets@foo]', + file => $targets[1]->plan_file, + )], + [__x( + 'Added "{change}" to {file}.', + change => 'widgets [widgets@foo]', + file => $targets[2]->plan_file, + )], + [__n( + 'Modify this file as appropriate:', + 'Modify these files as appropriate:', + 3, + )], + map { + map { [" * $_" ] } @{ $scripts{$_}{change} } + } qw(pg sqlite mysql) + ], 'And the info message should show the two files to modify'; + + my $debug = +MockOutput->get_debug; + if ($debug->[4][0] !~ /$ekey/) { + # Got the targets in a different order. So reorder results to match. + push @{ $debug } => splice @{ $debug }, 4, 4; + } + is_deeply $debug, [ + map { + my ($c, $r) = @{ $scripts{$_} }{qw(change reworked)}; + ( + map { [__x( + 'Copied {src} to {dest}', + src => $c->[$_], + dest => $r->[$_], + )] } (0..2) + ), + [__x( + 'Copied {src} to {dest}', + src => $c->[0], + dest => $c->[1], + )] + } qw(pg sqlite mysql) + ], 'Should have debug oputput for all copied files'; + + # # Make sure we get an error using --all and a target arg. + throws_ok { $rework->execute('foo', 'pg' ) } 'App::Sqitch::X', + 'Should get an error for --all and a target arg'; + is $@->ident, 'rework', 'Mixed arguments error ident should be "rework"'; + is $@->message, __( + 'Cannot specify both --all and engine, target, or plan arugments' + ), 'Mixed arguments error message should be correct'; + + # # Now try reworking a change to just one engine. Remove --all + %scripts = map { + my $dir = dir $_; + $dir->mkpath; + + # Make the script files. + my (@change, @reworked); + for my $type (qw(deploy revert verify)) { + my $subdir = $dir->subdir($type); + $subdir->mkpath; + my $script = $subdir->file('gadgets.sql'); + $script->spew("-- $subdir gadgets"); + push @change => $script; + # Only SQLite is reworked. + push @reworked => $subdir->file('gadgets@foo.sql') + if $_ eq 'sqlite'; + } + + # Return the scripts. + $_ => { change => \@change, reworked => \@reworked }; + } qw(pg sqlite mysql); + + ok $rework = $CLASS->new( + sqitch => $sqitch, + note => ['Testing multiple plans'], + template_directory => dir->parent->subdir(qw(etc templates)) + ), 'Create yet another rework with custom multiplan config'; + + ok $rework->execute('gadgets', 'sqlite'), + 'Rework change "gadgets" in the sqlite plan'; + my %targets = map { $_->engine_key => $_ } + App::Sqitch::Target->all_targets(sqitch => $sqitch); + is keys %targets, 3, 'Should still have three targets'; + my $name = 'gadgets@foo'; + for my $ekey(qw(pg mysql)) { + my $target = $targets{$ekey}; + ok my $head = $target->plan->get('gadgets@HEAD'), + "Get gadgets\@HEAD from the $ekey plan"; + ok my $foo = $target->plan->get('gadgets@foo'), + "Get gadgets\@foo from the $ekey plan"; + cmp_ok $head->id, 'eq', $foo->id, + "The two $ekey gadgets should be the same change"; + } + do { + my $ekey = 'sqlite'; + my $target = $targets{$ekey}; + ok my $head = $target->plan->get('gadgets@HEAD'), + "Get gadgets\@HEAD from the $ekey plan"; + ok my $foo = $target->plan->get('gadgets@foo'), + "Get gadgets\@foo from the $ekey plan"; + cmp_ok $head->id, 'ne', $foo->id, + "The two $ekey gadgets should be different changes"; + }; + + # All the files should exist, now. + while (my ($k, $v) = each %scripts) { + file_exists_ok $_ for map { @{ $v->{$_} } } qw(change reworked); + next if $k ne 'sqlite'; + # Deploy and verify files should be the same. + files_eq $v->{change}[0], $v->{reworked}[0]; + files_eq $v->{change}[2], $v->{reworked}[2]; + # New revert should be the same as old deploy. + files_eq $v->{change}[1], $v->{reworked}[0]; + } + + is_deeply \%request_params, { + for => __ 'rework', + scripts => $scripts{sqlite}{change}, + }, 'Should have listed SQLite scripts in the note prompt'; + + # Clear the output. + MockOutput->get_info; + MockOutput->get_debug; + chdir File::Spec->updir; +} + +# Make sure we update only one plan but write out multiple target files. +MULTITARGET: { + my $dstring = $test_dir->stringify; + remove_tree $dstring; + make_path $dstring; + END { remove_tree $dstring if -e $dstring }; + chdir $dstring; + + my $conf = file 'multiadd.conf'; + $conf->spew(join "\n", + '[core]', + 'engine = pg', + 'plan_file = sqitch.plan', + '[engine "pg"]', + 'top_dir = pg', + '[engine "sqlite"]', + 'top_dir = sqlite', + '[add]', + 'all = true', + ); + file('sqitch.plan')->spew(join "\n", + '%project=rework', '', + 'widgets 2012-07-16T17:25:07Z anna <a@n.na>', + 'gadgets 2012-07-16T18:25:07Z anna <a@n.na>', + '@foo 2012-07-16T17:24:07Z julie <j@ul.ie>', '', + ); + + # Create the scripts. + my %scripts = map { + my $dir = dir $_; + my (@change, @reworked); + for my $type (qw(deploy revert verify)) { + my $subdir = $dir->subdir($type); + $subdir->mkpath; + my $script = $subdir->file('widgets.sql'); + $script->spew("-- $subdir widgets"); + push @change => $script; + push @reworked => $subdir->file('widgets@foo.sql'); + } + + # Return the scripts. + $_ => { change => \@change, reworked => \@reworked }; + } qw(pg sqlite); + + # Load up the configuration for this project. + $config = TestConfig->from(local => $conf); + $sqitch = App::Sqitch->new(config => $config); + ok my $rework = $CLASS->new( + sqitch => $sqitch, + note => ['Testing multiple plans'], + all => 1, + template_directory => dir->parent->subdir(qw(etc templates)) + ), 'Create another rework with custom multiplan config'; + + my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch); + is @targets, 2, 'Should have two targets'; + is $targets[0]->plan_file, $targets[1]->plan_file, + 'Targets should use the same plan file'; + my $target = $targets[0]; + + # Let's do this thing! + ok $rework->execute('widgets'), 'Rework change "widgets" in all plans'; + + ok my $head = $target->plan->get('widgets@HEAD'), + "Get widgets\@HEAD from the plan"; + ok my $foo = $target->plan->get('widgets@foo'), + "Get widgets\@foo from the plan"; + cmp_ok $head->id, 'ne', $foo->id, + "The two widgets should be different changes"; + + # All the files should exist, now. + while (my ($k, $v) = each %scripts) { + file_exists_ok $_ for map { @{ $v->{$_} } } qw(change reworked); + # Deploy and verify files should be the same. + files_eq $v->{change}[0], $v->{reworked}[0]; + files_eq $v->{change}[2], $v->{reworked}[2]; + # New revert should be the same as old deploy. + files_eq $v->{change}[1], $v->{reworked}[0]; + } + + is_deeply \%request_params, { + for => __ 'rework', + scripts => [ map {@{ $scripts{$_}{change} }} qw(pg sqlite)], + }, 'Should have listed all the files to edit in the note prompt'; + + # And the output should be correct. + is_deeply +MockOutput->get_info, [ + [__x( + 'Added "{change}" to {file}.', + change => 'widgets [widgets@foo]', + file => $target->plan_file, + )], + [__n( + 'Modify this file as appropriate:', + 'Modify these files as appropriate:', + 3, + )], + map { + map { [" * $_" ] } @{ $scripts{$_}{change} } + } qw(pg sqlite) + ], 'And the info message should show the two files to modify'; + + # As should the debug output + is_deeply +MockOutput->get_debug, [ + map { + my ($c, $r) = @{ $scripts{$_} }{qw(change reworked)}; + ( + map { [__x( + 'Copied {src} to {dest}', + src => $c->[$_], + dest => $r->[$_], + )] } (0..2) + ), + [__x( + 'Copied {src} to {dest}', + src => $c->[0], + dest => $c->[1], + )] + } qw(pg sqlite) + ], 'Should have debug oputput for all copied files'; + + chdir File::Spec->updir; +} + +# Try two plans with different tags. +MULTITAG: { + my $dstring = $test_dir->stringify; + remove_tree $dstring; + make_path $dstring; + END { remove_tree $dstring if -e $dstring }; + chdir $test_dir->stringify; + + my $conf = file 'multirework.conf'; + $conf->spew(join "\n", + '[core]', + 'engine = pg', + '[engine "pg"]', + 'top_dir = pg', + '[engine "sqlite"]', + 'top_dir = sqlite', + ); + + # Create plan files and determine the scripts that to be created. + my %scripts = map { + my $dir = dir $_; + $dir->mkpath; + my $tag = $_ eq 'pg' ? 'foo' : 'bar'; + $dir->file('sqitch.plan')->spew(join "\n", + '%project=rework', '', + 'widgets 2012-07-16T17:25:07Z anna <a@n.na>', + "\@$tag 2012-07-16T17:24:07Z julie <j\@ul.ie>", '', + ); + + # Make the script files. + my (@change, @reworked); + for my $type (qw(deploy revert verify)) { + my $subdir = $dir->subdir($type); + $subdir->mkpath; + my $script = $subdir->file('widgets.sql'); + $script->spew("-- $subdir widgets"); + push @change => $script; + push @reworked => $subdir->file("widgets\@$tag.sql"); + } + + # Return the scripts. + $_ => { change => \@change, reworked => \@reworked }; + } qw(pg sqlite); + + # Load up the configuration for this project. + $config = TestConfig->from(local => $conf); + $sqitch = App::Sqitch->new(config => $config); + ok my $rework = $CLASS->new( + sqitch => $sqitch, + note => ['Testing multiple plans'], + all => 1, + template_directory => dir->parent->subdir(qw(etc templates)) + ), 'Create another rework with custom multiplan config'; + + my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch); + is @targets, 2, 'Should have two targets'; + + # Let's do this thing! + ok $rework->execute('widgets'), 'Rework change "widgets" in all plans'; + for my $target(@targets) { + my $ekey = $target->engine_key; + my $tag = $ekey eq 'pg' ? 'foo' : 'bar'; + ok my $head = $target->plan->get('widgets@HEAD'), + "Get widgets\@HEAD from the $ekey plan"; + ok my $prev = $target->plan->get("widgets\@$tag"), + "Get widgets\@$tag from the $ekey plan"; + cmp_ok $head->id, 'ne', $prev->id, + "The two $ekey widgets should be different changes"; + } + + is_deeply \%request_params, { + for => __ 'rework', + scripts => [ map {@{ $scripts{$_}{change} }} qw(pg sqlite)], + }, 'Should have listed all the files to edit in the note prompt'; + + # And the output should be correct. + is_deeply +MockOutput->get_info, [ + [__x( + 'Added "{change}" to {file}.', + change => 'widgets [widgets@foo]', + file => $targets[0]->plan_file, + )], + [__x( + 'Added "{change}" to {file}.', + change => 'widgets [widgets@bar]', + file => $targets[1]->plan_file, + )], + [__n( + 'Modify this file as appropriate:', + 'Modify these files as appropriate:', + 2, + )], + map { + map { [" * $_" ] } @{ $scripts{$_}{change} } + } qw(pg sqlite) + ], 'And the info message should show the two files to modify'; + + # As should the debug output + is_deeply +MockOutput->get_debug, [ + map { + my ($c, $r) = @{ $scripts{$_} }{qw(change reworked)}; + ( + map { [__x( + 'Copied {src} to {dest}', + src => $c->[$_], + dest => $r->[$_], + )] } (0..2) + ), + [__x( + 'Copied {src} to {dest}', + src => $c->[0], + dest => $c->[1], + )] + } qw(pg sqlite) + ], 'Should have debug oputput for all copied files'; + + chdir File::Spec->updir; +} + +# Make sure we're okay with multiple plans sharing the same top dir. +ONETOP: { + remove_tree $test_dir->stringify; + make_path $test_dir->stringify; + END { remove_tree $test_dir->stringify }; + chdir $test_dir->stringify; + my $conf = file 'multirework.conf'; + $conf->spew(join "\n", + '[core]', + 'engine = pg', + '[engine "pg"]', + 'plan_file = pg.plan', + '[engine "sqlite"]', + 'plan_file = sqlite.plan', + ); + + # Write the two plan files. + file("$_.plan")->spew(join "\n", + '%project=rework', '', + 'widgets 2012-07-16T17:25:07Z anna <a@n.na>', + '@foo 2012-07-16T17:24:07Z julie <j@ul.ie>', '', + ) for qw(pg sqlite); + + # One set of scripts for both. + my (@change, @reworked); + for my $type (qw(deploy revert verify)) { + my $dir = dir $type; + $dir->mkpath; + my $script = $dir->file('widgets.sql'); + $script->spew("-- $dir widgets"); + push @change => $script; + push @reworked => $dir->file('widgets@foo.sql'); + } + + # Load up the configuration for this project. + $config = TestConfig->from(local => $conf); + $sqitch = App::Sqitch->new(config => $config); + ok my $rework = $CLASS->new( + sqitch => $sqitch, + note => ['Testing multiple plans'], + all => 1, + template_directory => dir->parent->subdir(qw(etc templates)) + ), 'Create another rework with custom multiplan config'; + + my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch); + is @targets, 2, 'Should have two targets'; + + ok $rework->execute('widgets'), 'Rework change "widgets" in all plans'; + for my $target(@targets) { + my $ekey = $target->engine_key; + ok my $head = $target->plan->get('widgets@HEAD'), + "Get widgets\@HEAD from the $ekey plan"; + ok my $foo = $target->plan->get('widgets@foo'), + "Get widgets\@foo from the $ekey plan"; + cmp_ok $head->id, 'ne', $foo->id, + "The two $ekey widgets should be different changes"; + } + + # Make sure the files were written properly. + file_exists_ok $_ for (@change, @reworked); + # Deploy and verify files should be the same. + files_eq $change[0], $reworked[0]; + files_eq $change[2], $reworked[2]; + # New revert should be the same as old deploy. + files_eq $change[1], $reworked[0]; + + is_deeply \%request_params, { + for => __ 'rework', + scripts => \@change, + }, 'Should have listed the files to edit in the note prompt'; + + # And the output should be correct. + is_deeply +MockOutput->get_info, [ + [__x( + 'Added "{change}" to {file}.', + change => 'widgets [widgets@foo]', + file => $targets[0]->plan_file, + )], + [__x( + 'Added "{change}" to {file}.', + change => 'widgets [widgets@foo]', + file => $targets[1]->plan_file, + )], + [__n( + 'Modify this file as appropriate:', + 'Modify these files as appropriate:', + 2, + )], + map { [" * $_" ] } @change, + ], 'And the info message should show the two files to modify'; + + # As should the debug output + is_deeply +MockOutput->get_debug, [ + ( + map { [__x( + 'Copied {src} to {dest}', + src => $change[$_], + dest => $reworked[$_], + )] } (0..2) + ), + [__x( + 'Copied {src} to {dest}', + src => $change[0], + dest => $change[1], + )], + ], 'Should have debug oputput for all copied files'; + + chdir File::Spec->updir; +} diff --git a/t/show.t b/t/show.t new file mode 100644 index 00000000..93cf7b3d --- /dev/null +++ b/t/show.t @@ -0,0 +1,198 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use Test::More; +use App::Sqitch; +use Path::Class; +use Test::Exception; +use Test::Warn; +use Locale::TextDomain qw(App-Sqitch); +use Test::MockModule; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::show'; +require_ok $CLASS or die; + +isa_ok $CLASS, 'App::Sqitch::Command'; +can_ok $CLASS, qw(execute exists_only target does); + +ok $CLASS->does("App::Sqitch::Role::ContextCommand"), + "$CLASS does ContextCommand"; + +is_deeply [$CLASS->options], [qw( + target|t=s + exists|e! + plan-file|f=s + top-dir=s +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +my $config = TestConfig->new( + 'core.engine' => 'pg', + 'core.plan_file' => file(qw(t engine sqitch.plan))->stringify, + 'core.top_dir' => dir(qw(t engine))->stringify, + 'core.reworked_dir' => dir(qw(t engine reworked))->stringify, +); +my $sqitch = App::Sqitch->new(config => $config); + +isa_ok my $show = $CLASS->new(sqitch => $sqitch), $CLASS; +ok !$show->exists_only, 'exists_only should be false by default'; + +ok my $eshow = $CLASS->new(sqitch => $sqitch, exists_only => 1), + 'Construct with exists_only'; +ok $eshow->exists_only, 'exists_only should be set'; + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure($config, {}), {_cx => []}, + 'Should get empty hash for no config or options'; + +is_deeply $CLASS->configure($config, {exists => 1}), + { exists_only => 1, _cx => [] }, + 'Should get exists_only => 1 for exist in options'; + +############################################################################## +# Start with the change. +ok my $change = $show->default_target->plan->get('widgets'), 'Get a change'; + +ok $show->execute( change => $change->id ), 'Find change by id'; +is_deeply +MockOutput->get_emit, [[ $change->info ]], + 'The change info should have been emitted'; + +# Try by name. +ok $show->execute( change => $change->name ), 'Find change by name'; +is_deeply +MockOutput->get_emit, [[ $change->info ]], + 'The change info should have been emitted again'; + +# What happens for something unknown? +throws_ok { $show->execute( change => 'nonexistent' ) } 'App::Sqitch::X', + 'Should get an error for an unknown change'; +is $@->ident, 'show', 'Unknown change error ident should be "show"'; +is $@->message, __x('Unknown change "{change}"', change => 'nonexistent'), + 'Should get proper error for unknown change'; + +# What about with exists_only? +ok !$eshow->execute( change => 'nonexistent' ), + 'Should return false for uknown change and exists_only'; +is_deeply +MockOutput->get_emit, [], 'Nothing should have been emitted'; + +# Let's find a change by tag. +my $tag = ($show->default_target->plan->tags)[0]; +$change = $tag->change; +ok $show->execute( change => $tag->id ), 'Find change by tag id'; +is_deeply +MockOutput->get_emit, [[ $change->info ]], + 'The change info should have been emitted'; + +# And the tag name. +ok $show->execute( change => $tag->format_name ), 'Find change by tag'; +is_deeply +MockOutput->get_emit, [[ $change->info ]], + 'The change info should have been emitted'; + +# Make sure it works with exists_only. +ok $eshow->execute( change => $change->id ), 'Run exists with ID'; +is_deeply +MockOutput->get_emit, [], + 'There should be no output'; + +# Great, let's look a the tag itself. +ok $show->execute( tag => $tag->id ), 'Find tag by id'; +is_deeply +MockOutput->get_emit, [[ $tag->info ]], + 'The tag info should have been emitted'; + +# Should work with exists_only, too. +ok $eshow->execute( tag => $tag->id ), 'Find tag by id with exists_only'; +is_deeply +MockOutput->get_emit, [], 'Nothing should have been emitted'; + +ok $show->execute( tag => $tag->name ), 'Find tag by name'; +is_deeply +MockOutput->get_emit, [[ $tag->info ]], + 'The tag info should have been emitted'; + +ok $show->execute( tag => $tag->format_name ), 'Find tag by formatted name'; +is_deeply +MockOutput->get_emit, [[ $tag->info ]], + 'The tag info should have been emitted'; + +# Try an invalid tag. +throws_ok { $show->execute( tag => 'nope') } 'App::Sqitch::X', + 'Should get error for non-existent tag'; +is $@->ident, 'show', 'Unknown tag error ident should be "show"'; +is $@->message, __x('Unknown tag "{tag}"', tag => 'nope' ), + 'Should get proper error for unknown tag'; + +# Try invalid tag with exists_only. +ok !$eshow->execute( tag => 'nope'), + 'Should return false for non-existent tag and exists_only'; +is_deeply +MockOutput->get_emit, [], 'Nothing should have been emitted'; + +# Also an invalid sha1. +throws_ok { $show->execute( tag => '7ecba288708307ef714362c121691de02ffb364d') } + 'App::Sqitch::X', + 'Should get error for non-existent tag ID'; +is $@->ident, 'show', 'Unknown tag ID error ident should be "show"'; +is $@->message, __x('Unknown tag "{tag}"', tag => '7ecba288708307ef714362c121691de02ffb364d' ), + 'Should get proper error for unknown tag ID'; + +# Now let's look at files. +ok $show->execute(deploy => $change->id), 'Show a deploy file'; +is_deeply +MockOutput->get_emit, [[ $change->deploy_file->slurp(iomode => '<:raw') ]], + 'The deploy file should have been emitted'; + +# With exists_only. +ok $eshow->execute(deploy => $change->id), 'Show a deploy file with exists_only'; +is_deeply +MockOutput->get_emit, [], 'Nothing should have been emitted'; + +ok $show->execute(revert => $change->id), 'Show a revert file'; +is_deeply +MockOutput->get_emit, [[ $change->revert_file->slurp(iomode => '<:raw') ]], + 'The revert file should have been emitted'; + +# Nonexistent verify file. +throws_ok { $show->execute( verify => $change->id ) } 'App::Sqitch::X', + 'Should get error for nonexistent varify file'; +is $@->ident, 'show', 'Nonexistent file error ident should be "show"'; +is $@->message, __x('File "{path}" does not exist', path => $change->verify_file ), + 'Should get proper error for nonexistent file'; + +# Nonexistent with exists_only. +ok !$eshow->execute( verify => $change->id ), + 'Should return false for nonexistent file'; +is_deeply +MockOutput->get_emit, [], 'Nothing should have been emitted'; + +# Now an unknown type. +throws_ok { $show->execute(foo => 'bar') } 'App::Sqitch::X', + 'Should get error for uknown type'; +is $@->ident, 'show', 'Unknown type error ident should be "show"'; +is $@->message, __x( + 'Unknown object type "{type}', + type => 'foo', +), 'Should get proper error for unknown type'; + +# Try specifying a non-default target. +$config = TestConfig->from( local => file 't', 'local.conf'); +$sqitch = App::Sqitch->new(config => $config); +my $file = file qw(t plans dependencies.plan); +my $target = App::Sqitch::Target->new(sqitch => $sqitch, plan_file => $file); +ok $change = $target->plan->get('add_user'), 'Get a change'; + +# Set it up. +isa_ok $show = $CLASS->new(sqitch => $sqitch, target => 'mydb'), $CLASS; +is $show->target, 'mydb', 'Target should be set'; +ok $show->execute( change => $change->id ), 'Find change by id'; +is_deeply +MockOutput->get_emit, [[ $change->info ]], + 'The change info should have been emitted'; + +# Now try invalid args. +my $mock = Test::MockModule->new($CLASS); +my @usage; +$mock->mock(usage => sub { shift; @usage = @_; die 'USAGE' }); +throws_ok { $show->execute } qr/USAGE/, 'Should get usage for missing params'; +is_deeply \@usage, [], 'Nothing should have been passed to usage'; + +done_testing; diff --git a/t/snowflake.t b/t/snowflake.t new file mode 100644 index 00000000..60ce88da --- /dev/null +++ b/t/snowflake.t @@ -0,0 +1,562 @@ +#!/usr/bin/perl -w + +# To test against a live Snowflake database, you must set the SNOWSQL_URI environment variable. +# this is a stanard URI::db URI, and should look something like this: +# +# export SNOWSQL_URI=db:snowflake://username:password@accountname/dbname?Driver=Snowflake;warehouse=warehouse +# +# Note that it must include the `?Driver=$driver` bit so that DBD::ODBC loads +# the proper driver. + +use strict; +use warnings; +use 5.010; +use Test::More 0.94; +use Test::MockModule; +use Test::Exception; +use Locale::TextDomain qw(App-Sqitch); +use Capture::Tiny 0.12 qw(:all); +use File::Temp 'tempdir'; +use Path::Class; +use Try::Tiny; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use App::Sqitch::DateTime; +use lib 't/lib'; +use DBIEngineTest; +use TestConfig; + +my $CLASS; + +delete $ENV{"SNOWSQL_$_"} for qw(USER PASSWORD DATABASE HOST PORT); + +BEGIN { + $CLASS = 'App::Sqitch::Engine::snowflake'; + require_ok $CLASS or die; + $ENV{SNOWSQL_ACCOUNT} = 'nonesuch'; +} + +# Mock the home directory to prevent reading a user config file. +my $tmp_dir = dir tempdir CLEANUP => 1; +local $ENV{HOME} = $tmp_dir->stringify; + +is_deeply [$CLASS->config_vars], [ + target => 'any', + registry => 'any', + client => 'any', +], 'config_vars should return three vars'; + +my $uri = 'db:snowflake:'; +my $config = TestConfig->new('core.engine' => 'snowflake'); +my $sqitch = App::Sqitch->new(config => $config); +my $target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI::db->new($uri), +); + +# Disable config file parsing for the remainder of the tests. +my $mock_snow = Test::MockModule->new($CLASS); +$mock_snow->mock(_snowcfg => {}); + +isa_ok my $snow = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS; + +is $snow->username, $sqitch->sysuser, 'Username should be sysuser'; +is $snow->password, undef, 'Password should be undef'; +is $snow->key, 'snowflake', 'Key should be "snowflake"'; +is $snow->name, 'Snowflake', 'Name should be "Snowflake"'; +is $snow->driver, 'DBD::ODBC 1.59', 'Driver should be DBD::ODBC'; +is $snow->default_client, 'snowsql', 'Default client should be snowsql'; +my $client = 'snowsql' . (App::Sqitch::ISWIN ? '.exe' : ''); +is $snow->client, $client, 'client should default to snowsql'; + +is $snow->registry, 'sqitch', 'Registry default should be "sqitch"'; +my $exp_uri = URI->new( + sprintf 'db:snowflake://%s.snowflakecomputing.com/%s', + $ENV{SNOWSQL_ACCOUNT}, $sqitch->sysuser, +)->as_string; +is $snow->uri, $exp_uri, 'DB URI should be filled in'; +is $snow->destination, $exp_uri, 'Destination should be URI string'; +is $snow->registry_destination, $snow->destination, + 'Registry destination should be the same as destination'; + +# Test environment variables. +SNOWENV: { + local $ENV{SNOWSQL_USER} = 'kamala'; + local $ENV{SNOWSQL_PWD} = 'gimme'; + local $ENV{SNOWSQL_REGION} = 'Australia'; + local $ENV{SNOWSQL_WAREHOUSE} = 'madrigal'; + local $ENV{SNOWSQL_ACCOUNT} = 'egregious'; + local $ENV{SNOWSQL_HOST} = 'test.snowflake.com'; + local $ENV{SNOWSQL_PORT} = 4242; + local $ENV{SNOWSQL_DATABASE} = 'tryme'; + + my $target = App::Sqitch::Target->new(sqitch => $sqitch, uri => URI->new($uri)); + my $snow = $CLASS->new( sqitch => $sqitch, target => $target ); + is $snow->uri, 'db:snowflake://test.snowflake.com:4242/tryme', + 'Should build URI from environment'; + is $snow->username, 'kamala', 'Should read username from environment'; + is $snow->password, 'gimme', 'Should read password from environment'; + is $snow->account, 'test', 'Should read account from host'; + is $snow->warehouse, 'madrigal', 'Should read warehouse from environment'; + + # Delete host. + $target = App::Sqitch::Target->new(sqitch => $sqitch, uri => URI->new($uri)); + delete $ENV{SNOWSQL_HOST}; + $snow = $CLASS->new( sqitch => $sqitch, target => $target ); + is $snow->uri, 'db:snowflake://egregious.Australia.snowflakecomputing.com:4242/tryme', + 'Should build URI host from account and region environment vars'; + is $snow->account, 'egregious', 'Should read account from environment'; + + # SQITCH_PASSWORD has priority. + local $ENV{SQITCH_PASSWORD} = 'irule'; + $target = App::Sqitch::Target->new(sqitch => $sqitch, uri => URI->new($uri)); + is $target->password, 'irule', 'Target password should be from SQITCH_PASSWORD'; + $snow = $CLASS->new( sqitch => $sqitch, target => $target ); + is $snow->password, 'irule', 'Should prefer password from SQITCH_PASSWORD'; +} + +# Name the target. +my $named_target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI->new($uri), + name => 'jonsnow', +); + +isa_ok $snow = $CLASS->new( + sqitch => $sqitch, + target => $named_target, +), $CLASS; + +is $snow->destination, 'jonsnow', 'Destination should be target name'; +is $snow->registry_destination, $snow->destination, + 'Registry destination should be the same as destination'; + +############################################################################## +# Test snowsql options. +my @con_opts = ( + '--accountname' => $ENV{SNOWSQL_ACCOUNT}, + '--username' => $snow->username, + '--dbname' => $snow->uri->dbname, +); + +my @std_opts = ( + '--noup', + '--option' => 'auto_completion=false', + '--option' => 'echo=false', + '--option' => 'execution_only=false', + '--option' => 'friendly=false', + '--option' => 'header=false', + '--option' => 'exit_on_error=true', + '--option' => 'stop_on_error=true', + '--option' => 'output_format=csv', + '--option' => 'paging=false', + '--option' => 'timing=false', + '--option' => 'results=true', + '--option' => 'wrap=false', + '--option' => 'rowset_size=1000', + '--option' => 'syntax_style=default', + '--option' => 'variable_substitution=true', + '--variable' => 'registry=sqitch', + '--variable' => 'warehouse=' . $snow->warehouse, +); +is_deeply [$snow->snowsql], [$client, @con_opts, @std_opts], + 'snowsql command should be std opts-only'; + +isa_ok $snow = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS; +ok $snow->set_variables(foo => 'baz', whu => 'hi there', yo => 'stellar'), + 'Set some variables'; +is_deeply [$snow->snowsql], [ + $client, + @con_opts, + '--variable' => 'foo=baz', + '--variable' => 'whu=hi there', + '--variable' => 'yo=stellar', + @std_opts, +], 'Variables should be passed to snowsql via --set'; + +############################################################################## +# Test other configs for the target. +ENV: { + # Make sure we override system-set vars. + local $ENV{SNOWSQL_DATABASE}; + local $ENV{SNOWSQL_USER}; + for my $env (qw(SNOWSQL_DATABASE SNOWSQL_USER)) { + my $snow = $CLASS->new(sqitch => $sqitch, target => $target); + local $ENV{$env} = "\$ENV=whatever"; + is $snow->target->name, "db:snowflake:", "Target name should not read \$$env"; + is $snow->registry_destination, $snow->destination, + 'Registry target should be the same as destination'; + } + + my $mocker = Test::MockModule->new('App::Sqitch'); + $mocker->mock(sysuser => 'sysuser=whatever'); + my $snow = $CLASS->new(sqitch => $sqitch, target => $target); + is $snow->target->name, 'db:snowflake:', + 'Target name should not fall back on sysuser'; + is $snow->registry_destination, $snow->destination, + 'Registry target should be the same as destination'; + + $ENV{SNOWSQL_DATABASE} = 'mydb'; + $snow = $CLASS->new(sqitch => $sqitch, username => 'hi', target => $target); + is $snow->target->name, 'db:snowflake:', 'Target name should be the default'; + is $snow->registry_destination, $snow->destination, + 'Registry target should be the same as destination'; +} + +############################################################################## +# Make sure we read snowsql config file. +SNOWSQLCFGFILE: { + # Create the mock config directory. + my $cfgdir = $tmp_dir->subdir('.snowsql'); + $cfgdir->mkpath; + my $cfgfn = $cfgdir->file('config'); + + my $cfg = { + username => 'jonSnow', + password => 'winter is cøming', + accountname => 'golem', + region => 'Africa', + warehousename => 'LaBries', + rolename => 'ACCOUNTADMIN', + dbname => 'dolphin', + }; + + # Unset the mock. + $mock_snow->unmock('_snowcfg'); + + for my $qm (q{}, q{'}, q{"}) { + # Write out a the config file. + open my $fh, '>:utf8', $cfgfn or die "Cannot open $cfgfn: $!\n"; + print {$fh} "[connections]\n"; + while (my ($k, $v) = each %{ $cfg }) { + print {$fh} "$k = $qm$v$qm\n"; + } + + # Add a named connection, which should be ignored. + print {$fh} "[connections.winner]\nusername = ${qm}WINNING$qm\n"; + close $fh or die "Cannot close $cfgfn: $!\n"; + + # Make sure we read it in. + my $target = App::Sqitch::Target->new( + name => 'db:snowflake:', + sqitch => $sqitch, + ); + my $snow = $CLASS->new( sqitch => $sqitch, target => $target ); + is_deeply $snow->_snowcfg, $cfg, 'Should have read config from file'; + } + + # Reset default mock. + $mock_snow->mock(_snowcfg => {}); +} + +############################################################################## +# Make sure we read snowsql config connection settings. +SNOWSQLCFG: { + local $ENV{SNOWSQL_ACCOUNT}; + local $ENV{SNOWSQL_HOST}; + my $target = App::Sqitch::Target->new( + name => 'db:snowflake:', + sqitch => $sqitch, + ); + + # Read config. + $mock_snow->mock(_snowcfg => { + username => 'jon_snow', + password => 'let me in', + accountname => 'flipr', + rolename => 'SYSADMIN', + warehousename => 'Waterbed', + dbname => 'monkey', + }); + my $snow = $CLASS->new( sqitch => $sqitch, target => $target ); + is $snow->username, 'jon_snow', + 'Should read username fron snowsql config file'; + is $snow->password, 'let me in', + 'Should read password fron snowsql config file'; + is $snow->account, 'flipr', + 'Should read accountname fron snowsql config file'; + is $snow->uri->dbname, 'monkey', + 'Should read dbname from snowsql config file'; + is $snow->warehouse, 'Waterbed', + 'Should read warehousename fron snowsql config file'; + is $snow->role, 'SYSADMIN', + 'Should read rolename fron snowsql config file'; + is $snow->uri->host, 'flipr.snowflakecomputing.com', + 'Should derive host name from config file accounte name'; + + # Reset default mock. + $mock_snow->mock(_snowcfg => {}); +} + +############################################################################## +# Make sure config settings override defaults. +$config->update( + 'engine.snowflake.client' => '/path/to/snowsql', + 'engine.snowflake.target' => 'db:snowflake://fred:hi@foo/try?warehouse=foo;role=yup', + 'engine.snowflake.registry' => 'meta', +); +$std_opts[-3] = 'registry=meta'; +$std_opts[-1] = 'warehouse=foo'; + +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +ok $snow = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create another snowflake'; + +is $snow->account, 'foo', 'Should extract account from URI'; +is $snow->username, 'fred', 'Should extract username from URI'; +is $snow->password, 'hi', 'Should extract password from URI'; +is $snow->warehouse, 'foo', 'Should extract warehouse from URI'; +is $snow->role, 'yup', 'Should extract role from URI'; +is $snow->registry, 'meta', 'registry should be as configured'; +is $snow->uri->as_string, + 'db:snowflake://fred:hi@foo.snowflakecomputing.com/try?warehouse=foo;role=yup', + 'URI should be as configured with full domain name'; +is $snow->destination, + 'db:snowflake://fred:@foo.snowflakecomputing.com/try?warehouse=foo;role=yup', + 'Destination should omit password'; + +is $snow->client, '/path/to/snowsql', 'client should be as configured'; +is_deeply [$snow->snowsql], [qw( + /path/to/snowsql + --accountname foo + --username fred + --dbname try + --rolename yup +), @std_opts], 'snowsql command should be configured from URI config'; + +############################################################################## +# Test SQL helpers. +is $snow->_listagg_format, q{listagg(%s, ' ')}, 'Should have _listagg_format'; +is $snow->_ts_default, 'current_timestamp', 'Should have _ts_default'; +is $snow->_regex_op, 'REGEXP', 'Should have _regex_op'; +is $snow->_simple_from, ' FROM dual', 'Should have _simple_from'; +is $snow->_limit_default, '4611686018427387903', 'Should have _limit_default'; + +DBI: { + local *DBI::state; + ok !$snow->_no_table_error, 'Should have no table error'; + ok !$snow->_no_column_error, 'Should have no column error'; + $DBI::state = '42S02'; + ok $snow->_no_table_error, 'Should now have table error'; + ok !$snow->_no_column_error, 'Still should have no column error'; + $DBI::state = '42703'; + ok !$snow->_no_table_error, 'Should again have no table error'; + ok $snow->_no_column_error, 'Should now have no column error'; +} + +is_deeply [$snow->_limit_offset(8, 4)], + [['LIMIT 8', 'OFFSET 4'], []], + 'Should get limit and offset'; +is_deeply [$snow->_limit_offset(0, 2)], + [['LIMIT 4611686018427387903', 'OFFSET 2'], []], + 'Should get limit and offset when offset only'; +is_deeply [$snow->_limit_offset(12, 0)], [['LIMIT 12'], []], + 'Should get only limit with 0 offset'; +is_deeply [$snow->_limit_offset(12)], [['LIMIT 12'], []], + 'Should get only limit with noa offset'; +is_deeply [$snow->_limit_offset(0, 0)], [[], []], + 'Should get no limit or offset for 0s'; +is_deeply [$snow->_limit_offset()], [[], []], + 'Should get no limit or offset for no args'; + +is_deeply [$snow->_regex_expr('corn', 'Obama$')], + ["regexp_substr(corn, ?) IS NOT NULL", 'Obama$'], + 'Should use regexp_substr IS NOT NULL for regex expr'; + +############################################################################## +# Test _run(), _capture() _spool(), and _probe(). +$config->replace('core.engine' => 'snowflake'); +can_ok $snow, qw(_run _capture _spool); +my $mock_sqitch = Test::MockModule->new('App::Sqitch'); +my ($exp_pass, @capture) = ('s3cr3t'); +$mock_sqitch->mock(capture => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @capture = @_; + if (defined $exp_pass) { + is $ENV{SNOWSQL_PWD}, $exp_pass, qq{SNOWSQL_PWD should be "$exp_pass"}; + } else { + ok !exists $ENV{SNOWSQL_PWD}, 'SNOWSQL_PWD should not exist'; + } + return; +}); + +my @spool; +$mock_sqitch->mock(spool => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @spool = @_; + if (defined $exp_pass) { + is $ENV{SNOWSQL_PWD}, $exp_pass, qq{SNOWSQL_PWD should be "$exp_pass"}; + } else { + ok !exists $ENV{SNOWSQL_PWD}, 'SNOWSQL_PWD should not exist'; + } +}); + +my @probe; +$mock_sqitch->mock(probe => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @probe = @_; + if (defined $exp_pass) { + is $ENV{SNOWSQL_PWD}, $exp_pass, qq{SNOWSQL_PWD should be "$exp_pass"}; + } else { + ok !exists $ENV{SNOWSQL_PWD}, 'SNOWSQL_PWD should not exist'; + } + return; +}); + +$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => URI->new($uri)); +$target->uri->password($exp_pass); +ok $snow = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create a snowflake with sqitch with options'; + +ok $snow->_run(qw(foo bar baz)), 'Call _run'; +is_deeply \@capture, [$snow->snowsql, qw(foo bar baz)], + 'Command should be passed to capture()'; + +ok $snow->_spool('FH'), 'Call _spool'; +is_deeply \@spool, ['FH', $snow->snowsql, $snow->_verbose_opts], + 'Command should be passed to spool()'; + +lives_ok { $snow->_capture(qw(foo bar baz)) } 'Call _capture'; +is_deeply \@capture, [$snow->snowsql, $snow->_verbose_opts, qw(foo bar baz)], + 'Command should be passed to capture()'; + +lives_ok { $snow->_probe(qw(foo bar baz)) } 'Call _probe'; +is_deeply \@probe, [$snow->snowsql, $snow->_verbose_opts, qw(foo bar baz)], + 'Command should be passed to probe()'; + +# Without password. +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +ok $snow = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create a snowflake with sqitch with no pw'; +$exp_pass = undef; +ok $snow->_run(qw(foo bar baz)), 'Call _run again'; +is_deeply \@capture, [$snow->snowsql, qw(foo bar baz)], + 'Command should be passed to capture() again'; + +ok $snow->_spool('FH'), 'Call _spool again'; +is_deeply \@spool, ['FH', $snow->snowsql, $snow->_verbose_opts], + 'Command should be passed to spool() again'; + +lives_ok { $snow->_capture(qw(foo bar baz)) } 'Call _capture again'; +is_deeply \@capture, [$snow->snowsql, $snow->_verbose_opts, qw(foo bar baz)], + 'Command should be passed to capture() again'; + +lives_ok { $snow->_probe(qw(foo bar baz)) } 'Call _probe again'; +is_deeply \@probe, [$snow->snowsql, $snow->_verbose_opts, qw(foo bar baz)], + 'Command should be passed to probe() again'; + +############################################################################## +# Test file and handle running. +ok $snow->run_file('foo/bar.sql'), 'Run foo/bar.sql'; +is_deeply \@capture, [$snow->snowsql, $snow->_quiet_opts, '--filename', 'foo/bar.sql'], + 'File should be passed to capture()'; + +ok $snow->run_handle('FH'), 'Spool a "file handle"'; +is_deeply \@spool, ['FH', $snow->snowsql, $snow->_verbose_opts], + 'Handle should be passed to spool()'; + +# Verify should go to capture unless verosity is > 1. +# ok $snow->run_verify('foo/bar.sql'), 'Verify foo/bar.sql'; +# is_deeply \@capture, [$snow->snowsql, '--filename', 'foo/bar.sql'], +# 'Verify file should be passed to capture()'; + +$mock_sqitch->mock(verbosity => 2); +ok $snow->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again'; +is_deeply \@capture, [$snow->snowsql, $snow->_verbose_opts, '--filename', 'foo/bar.sql'], + 'Verifile file should be passed to run() for high verbosity'; + +$mock_sqitch->unmock_all; + +############################################################################## +# Test DateTime formatting stuff. +ok my $ts2char = $CLASS->can('_ts2char_format'), "$CLASS->can('_ts2char_format')"; +is sprintf($ts2char->(), 'foo'), + q{to_varchar(CONVERT_TIMEZONE('UTC', foo), '"year:"YYYY":month:"MM":day:"DD":hour:"HH24":minute:"MI":second:"SS":time_zone:UTC"')}, + '_ts2char_format should work'; + +ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')"; +isa_ok my $dt = $dtfunc->( + 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC' +), 'App::Sqitch::DateTime', 'Return value of _dt()'; +is $dt->year, 2012, 'DateTime year should be set'; +is $dt->month, 7, 'DateTime month should be set'; +is $dt->day, 5, 'DateTime day should be set'; +is $dt->hour, 15, 'DateTime hour should be set'; +is $dt->minute, 7, 'DateTime minute should be set'; +is $dt->second, 1, 'DateTime second should be set'; +is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set'; + +ok my $now = App::Sqitch::DateTime->now, 'Construct a datetime object'; +is $snow->_char2ts($now), $now->as_string(format => 'iso'), + 'Should get ISO output from _char2ts'; + +############################################################################## +# Can we do live tests? +my $dbh; +END { + return unless $dbh; + $dbh->{Driver}->visit_child_handles(sub { + my $h = shift; + $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh; + }); + + $dbh->{RaiseError} = 0; + $dbh->{PrintError} = 1; + $dbh->do($_) for ( + 'DROP SCHEMA IF EXISTS sqitch CASCADE', + 'DROP SCHEMA IF EXISTS __sqitchtest CASCADE', + ); +} + +$uri = URI->new($ENV{SNOWSQL_URI} || 'db:snowflake://accountname/?Driver=Snowflake'); +$uri->host($uri->host . ".snowflakecomputing.com") if $uri->host !~ /snoflakecomputing[.]com/; +my $err = try { + $snow->use_driver; + $dbh = DBI->connect($uri->dbi_dsn, $uri->user, $uri->password, { + PrintError => 0, + RaiseError => 1, + AutoCommit => 1, + }); + undef; +} catch { + eval { $_->message } || $_; +}; + +DBIEngineTest->run( + class => $CLASS, + version_query => q{SELECT 'Snowflake ' || CURRENT_VERSION()}, + target_params => [ uri => $uri ], + alt_target_params => [ uri => $uri, registry => '__sqitchtest' ], + skip_unless => sub { + my $self = shift; + die $err if $err; + # Make sure we have vsql and can connect to the database. + $self->sqitch->probe( $self->client, '--version' ); + $self->_capture('--query' => 'SELECT CURRENT_DATE FROM dual'); + }, + engine_err_regex => qr/\bSQL\s+compilation\s+error:/, + init_error => __x( + 'Sqitch schema "{schema}" already exists', + schema => '__sqitchtest', + ), + test_dbh => sub { + my $dbh = shift; + # Make sure the sqitch schema is the first in the search path. + is $dbh->selectcol_arrayref('SELECT current_schema()')->[0], + '__SQITCHTEST', 'The Sqitch schema should be the current schema'; + }, + add_second_format => 'dateadd(second, 1, %s)', + +); + +done_testing; diff --git a/t/sqitch b/t/sqitch new file mode 100755 index 00000000..350e7fc0 --- /dev/null +++ b/t/sqitch @@ -0,0 +1,16 @@ +#!/usr/bin/env perl -CAS + +use POSIX qw(setlocale); +BEGIN { + if ($^O eq 'MSWin32') { + require Win32::Locale; + setlocale POSIX::LC_ALL, Win32::Locale::get_locale(); + } else { + setlocale POSIX::LC_ALL, ''; + } +} +use FindBin; +use lib "$FindBin::Bin/../lib"; +use App::Sqitch; + +exit App::Sqitch->go; diff --git a/t/sqitch.conf b/t/sqitch.conf new file mode 100644 index 00000000..ca5c4f9a --- /dev/null +++ b/t/sqitch.conf @@ -0,0 +1,24 @@ +[core] + uri = https://github.com/sqitchers/sqitch/ + engine = pg + top_dir = migrations + extension = ddl + pager = less -r + +[engine "pg"] + client = /usr/local/pgsql/bin/psql + +[revert] + to = gamma + count = 2 + revision = 1.1 + +[bundle] + from = gamma + tags_only = true + dest_dir = _build/sql +[foo "BAR"] + baz = hello +[guess "Yes.No"] + red = true + Calico = false diff --git a/t/sql/deploy/roles.sql b/t/sql/deploy/roles.sql new file mode 100644 index 00000000..63889917 --- /dev/null +++ b/t/sql/deploy/roles.sql @@ -0,0 +1 @@ +-- Create roles. \ No newline at end of file diff --git a/t/sql/deploy/users.sql b/t/sql/deploy/users.sql new file mode 100644 index 00000000..2bd98af3 --- /dev/null +++ b/t/sql/deploy/users.sql @@ -0,0 +1,2 @@ +-- Create users. +-- requires: roles diff --git a/t/sql/deploy/widgets.sql b/t/sql/deploy/widgets.sql new file mode 100644 index 00000000..fe52fee1 --- /dev/null +++ b/t/sql/deploy/widgets.sql @@ -0,0 +1,2 @@ +-- Create widgets. +-- requires: users diff --git a/t/sql/sqitch.plan b/t/sql/sqitch.plan new file mode 100644 index 00000000..27feaec3 --- /dev/null +++ b/t/sql/sqitch.plan @@ -0,0 +1,8 @@ +%project=sql + +roles 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +users 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@alpha 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> + +widgets 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> +@beta 2012-07-16T17:25:07Z Barack Obama <potus@whitehouse.gov> diff --git a/t/sql/verify/users.sql b/t/sql/verify/users.sql new file mode 100644 index 00000000..a8ed305c --- /dev/null +++ b/t/sql/verify/users.sql @@ -0,0 +1 @@ +SELECT nick, name FROM __myapp.users WHERE FALSE; diff --git a/t/sqlite.t b/t/sqlite.t new file mode 100644 index 00000000..45a33a92 --- /dev/null +++ b/t/sqlite.t @@ -0,0 +1,384 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use Test::More; +use App::Sqitch; +use App::Sqitch::Target; +use Test::MockModule; +use Path::Class; +use Try::Tiny; +use Test::Exception; +use Locale::TextDomain qw(App-Sqitch); +use File::Temp 'tempdir'; +use lib 't/lib'; +use DBIEngineTest; +use TestConfig; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Engine::sqlite'; + require_ok $CLASS or die; +} + +is_deeply [$CLASS->config_vars], [ + target => 'any', + registry => 'any', + client => 'any', +], 'config_vars should return three vars'; + +my $config = TestConfig->new('core.engine' => 'sqlite'); +my $sqitch = App::Sqitch->new(config => $config); +my $target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI->new('db:sqlite:foo.db'), +); +isa_ok my $sqlite = $CLASS->new(sqitch => $sqitch, target => $target), $CLASS; + +is $sqlite->key, 'sqlite', 'Key should be "sqlite"'; +is $sqlite->name, 'SQLite', 'Name should be "SQLite"'; + +is $sqlite->client, 'sqlite3' . (App::Sqitch::ISWIN ? '.exe' : ''), + 'client should default to sqlite3'; +is $sqlite->uri->dbname, file('foo.db'), 'dbname should be filled in'; +is $sqlite->target, $target, 'Target attribute should be specified target'; +is $sqlite->destination, $sqlite->uri->as_string, + 'Destination should be uri stringified'; +is $sqlite->registry_destination, $sqlite->registry_uri->as_string, + 'Registry target should be registry_uri stringified'; + +# Pretend for now that we always have a valid SQLite. +my $mock_sqitch = Test::MockModule->new(ref $sqitch); +my $sqlite_version = '3.7.12 2012-04-03 19:43:07 86b8481be7e76cccc92d14ce762d21bfb69504af'; +$mock_sqitch->mock(capture => sub { return $sqlite_version }); + +my @std_opts = ( + '-noheader', + '-bail', + '-batch', + '-csv', +); + +is_deeply [$sqlite->sqlite3], [$sqlite->client, @std_opts, $sqlite->uri->dbname], + 'sqlite3 command should have the proper opts'; + +############################################################################## +# Make sure we get an error for no database name. +my $tmp_dir = Path::Class::dir( tempdir CLEANUP => 1 ); +my $have_sqlite = try { $sqlite->use_driver }; +if ($have_sqlite) { + # We have DBD::SQLite. + # Find out if it's built with SQLite >= 3.7.11. + my $dbh = DBI->connect('DBI:SQLite:'); + my @v = split /[.]/ => $dbh->{sqlite_version}; + $have_sqlite = $v[0] > 3 || ($v[0] == 3 && ($v[1] > 7 || ($v[1] == 7 && $v[2] >= 11))); + unless ($have_sqlite) { + # We have DBD::SQLite, but it is too old. Make sure we complain about that. + isa_ok $sqlite = $CLASS->new( + sqitch => $sqitch, + target => $target, + ), $CLASS; + throws_ok { $sqlite->dbh } 'App::Sqitch::X', 'Should get an error for old SQLite'; + is $@->ident, 'sqlite', 'Unsupported SQLite error ident should be "sqlite"'; + is $@->message, __x( + 'Sqitch requires SQLite 3.7.11 or later; DBD::SQLite was built with {version}', + version => $dbh->{sqlite_version} + ), 'Unsupported SQLite error message should be correct'; + } +} else { + # No DBD::SQLite at all. + throws_ok { $sqlite->dbh } 'App::Sqitch::X', + 'Should get an error without DBD::SQLite'; + is $@->ident, 'sqlite', 'No DBD::SQLite error ident should be "sqlite"'; + is $@->message, __x( + '{driver} required to manage {engine}', + driver => $sqlite->driver, + engine => $sqlite->name, + ), 'No DBD::SQLite error message should be correct'; +} + +############################################################################## +# Make sure config settings override defaults. +$config->update( + 'engine.sqlite.client' => '/path/to/sqlite3', + 'engine.sqlite.target' => 'test', + 'engine.sqlite.registry' => 'meta', + 'target.test.uri' => 'db:sqlite:/path/to/sqlite.db', +); +$target = ref($target)->new( sqitch => $sqitch ); +ok $sqlite = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create another sqlite'; +is $sqlite->client, '/path/to/sqlite3', + 'client should fall back on config'; +is $sqlite->uri->as_string, 'db:sqlite:/path/to/sqlite.db', + 'dbname should fall back on config'; +is $sqlite->target, $target, 'Target should be as specified'; +is $sqlite->destination, 'test', + 'Destination should be configured target name'; +is $sqlite->registry_uri->as_string, 'db:sqlite:/path/to/meta.db', + 'registry_uri should fall back on config'; +is $sqlite->registry_destination, $sqlite->registry_uri->as_string, + 'Registry target should be configured registry_uri stringified'; + +# Try a registry with an extension and a dbname without. +$config->update( + 'engine.sqlite.registry' => 'meta.db', + 'engine.sqlite.target' => 'test', + 'target.test.uri' => 'db:sqlite:/path/to/sqitch', +); +$target = ref($target)->new( sqitch => $sqitch ); +ok $sqlite = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create another sqlite'; +is $sqlite->uri->as_string, 'db:sqlite:/path/to/sqitch', + 'dbname should fall back on config with no extension'; +is $sqlite->target, $target, 'Target should be as specified'; +is $sqlite->destination, 'test', + 'Destination should be configured target name'; +is $sqlite->registry_uri->as_string, 'db:sqlite:/path/to/meta.db', + 'registry_uri should fall back on config wth extension'; +is $sqlite->registry_destination, $sqlite->registry_uri->as_string, + 'Registry target should be configured registry_uri stringified'; + +# Also try a registry with no extension and a dbname with. +$config->update( + 'engine.sqlite.registry' => 'registry', + 'engine.sqlite.target' => 'noext', + 'target.noext.uri' => 'db:sqlite:/path/to/sqitch.db', +); +$target = ref($target)->new( sqitch => $sqitch ); +ok $sqlite = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create another sqlite'; +is $sqlite->uri->as_string, 'db:sqlite:/path/to/sqitch.db', + 'dbname should fall back on config with no extension'; +is $sqlite->target, $target, 'Target should be as specified'; +is $sqlite->destination, 'noext', + 'Destination should be configured target name'; +is $sqlite->registry_uri->as_string, 'db:sqlite:/path/to/registry.db', + 'registry_uri should fall back on config wth extension'; +is $sqlite->registry_destination, $sqlite->registry_uri->as_string, + 'Registry target should be configured registry_uri stringified'; + +# Try a registry with an absolute path. +$config->update( + 'engine.sqlite.registry' => '/some/other/path.db', + 'engine.sqlite.target' => 'abs', + 'target.abs.uri' => 'db:sqlite:/path/to/sqitch.db', +); +$target = ref($target)->new( sqitch => $sqitch ); +ok $sqlite = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create another sqlite'; +is $sqlite->uri->as_string, 'db:sqlite:/path/to/sqitch.db', + 'dbname should fall back on config with no extension'; +is $sqlite->target, $target, 'Target should be as specified'; +is $sqlite->destination, 'abs', + 'Destination should be configured target name'; +is $sqlite->registry_uri->as_string, 'db:sqlite:/some/other/path.db', + 'registry_uri should fall back on config wth extension'; +is $sqlite->registry_destination, $sqlite->registry_uri->as_string, + 'Registry target should be configured registry_uri stringified'; + +############################################################################## +# Test _read(). +$config->replace('core.engine' => 'sqlite'); +my $db_name = $tmp_dir->file('sqitch.db'); +$target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => URI->new("db:sqlite:$db_name") +); +ok $sqlite = $CLASS->new(sqitch => $sqitch, target => $target ), + 'Instantiate with a temporary database file'; +can_ok $sqlite, qw(_read); +SKIP: { + skip 'DBD::SQLite not available', 3 unless $have_sqlite; + is $sqlite->_read('foo'), q{.read 'foo'}, '_read() should work'; + is $sqlite->_read('foo bar'), q{.read 'foo bar'}, + '_read() should SQL-quote the file name'; + is $sqlite->_read('foo \'bar\''), q{.read 'foo ''bar'''}, + '_read() should SQL-quote quotes, too'; +} + +############################################################################## +# Test _run(), _capture(), and _spool(). +can_ok $sqlite, qw(_run _capture _spool); + +my (@run, @capture, @spool); +$mock_sqitch->mock(run => sub { shift; @run = @_ }); +$mock_sqitch->mock(capture => sub { shift; @capture = @_; return $sqlite_version }); +$mock_sqitch->mock(spool => sub { shift; @spool = @_ }); + +ok $sqlite->_run(qw(foo bar baz)), 'Call _run'; +is_deeply \@run, [$sqlite->sqlite3, qw(foo bar baz)], + 'Command should be passed to run()'; + +ok $sqlite->_spool('FH'), 'Call _spool'; +is_deeply \@spool, ['FH', $sqlite->sqlite3], + 'Command should be passed to spool()'; + +ok $sqlite->_capture(qw(foo bar baz)), 'Call _capture'; +is_deeply \@capture, [$sqlite->sqlite3, qw(foo bar baz)], + 'Command should be passed to capture()'; + +# Test file and handle running. +SKIP: { + skip 'DBD::SQLite not available', 2 unless $have_sqlite; + ok $sqlite->run_file('foo/bar.sql'), 'Run foo/bar.sql'; + is_deeply \@run, [$sqlite->sqlite3, ".read 'foo/bar.sql'"], + 'File should be passed to run()'; +} + +ok $sqlite->run_handle('FH'), 'Spool a "file handle"'; +is_deeply \@spool, ['FH', $sqlite->sqlite3], + 'Handle should be passed to spool()'; + +SKIP: { + skip 'DBD::SQLite not available', 2 unless $have_sqlite; + + # Verify should go to capture unless verosity is > 1. + ok $sqlite->run_verify('foo/bar.sql'), 'Verify foo/bar.sql'; + is_deeply \@capture, [$sqlite->sqlite3, ".read 'foo/bar.sql'"], + 'Verify file should be passed to capture()'; + + $mock_sqitch->mock(verbosity => 2); + ok $sqlite->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again'; + is_deeply \@run, [$sqlite->sqlite3, ".read 'foo/bar.sql'"], + 'Verifile file should be passed to run() for high verbosity'; +} + +############################################################################## +# Test DateTime formatting stuff. +can_ok $CLASS, '_ts2char_format'; +is sprintf($CLASS->_ts2char_format, 'foo'), + q{strftime('year:%Y:month:%m:day:%d:hour:%H:minute:%M:second:%S:time_zone:UTC', foo)}, + '_ts2char should work'; + +ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')"; +isa_ok my $dt = $dtfunc->( + 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC' +), 'App::Sqitch::DateTime', 'Return value of _dt()'; +is $dt->year, 2012, 'DateTime year should be set'; +is $dt->month, 7, 'DateTime month should be set'; +is $dt->day, 5, 'DateTime day should be set'; +is $dt->hour, 15, 'DateTime hour should be set'; +is $dt->minute, 7, 'DateTime minute should be set'; +is $dt->second, 1, 'DateTime second should be set'; +is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set'; + +############################################################################## +# Test checking the SQLite version. +for my $v (qw( + 3.3.9 + 3.3.10 + 3.3.200 + 3.4.0 + 3.4.8 + 3.7.11 + 3.8.12 + 3.10.0 + 4.1.30 +)) { + $sqlite_version = "$v 2012-04-03 19:43:07 86b8481be7e76cccc92d14ce762d21bfb69504af"; + ok my $sqlite = $CLASS->new( + sqitch => $sqitch, + target => $target, + ), "Create command for v$v"; + ok $sqlite->sqlite3, "Should be okay with sqlite v$v"; +} + +for my $v (qw( + 3.3.8 + 3.3.0 + 3.2.8 + 3.0.1 + 3.0.0 + 2.8.1 + 2.20.0 + 1.0.0 +)) { + $sqlite_version = "$v 2012-04-03 19:43:07 86b8481be7e76cccc92d14ce762d21bfb69504af"; + ok my $sqlite = $CLASS->new( + sqitch => $sqitch, + target => $target, + ), "Create command for v$v"; + throws_ok { $sqlite->sqlite3 } 'App::Sqitch::X', "Should not be okay with v$v"; + is $@->ident, 'sqlite', qq{Should get ident "sqlite" for v$v}; + is $@->message, __x( + 'Sqitch requires SQLite 3.3.9 or later; {client} is {version}', + client => $sqlite->client, + version => $v + ), "Should get proper error message for v$v"; +} + +$mock_sqitch->unmock_all; + +############################################################################## +# Test against extra newline in capture. +$sqlite_version = '3.7.12 2012-04-03 19:43:07 86b8481be7e76cccc92d14ce762d21bfb69504af'; +$mock_sqitch->mock(capture => sub { return ( "\n",$sqlite_version) }); +{ + ok my $sqlite = $CLASS->new( + sqitch => $sqitch, + target => $target, + ), "Create command for v3.7.12 with newline"; + ok $sqlite->sqlite3, "Should be okay with sqlite version v3.7.12 with newline"; +} + +# Un-mock for live tests below +$mock_sqitch->unmock_all; + +############################################################################## +my $alt_db = $db_name->dir->file('sqitchtest.db'); +# Can we do live tests? +END { + my %drivers = DBI->installed_drivers; + for my $driver (values %drivers) { + $driver->visit_child_handles(sub { + my $h = shift; + $h->disconnect if $h->{Type} eq 'db' && $h->{Active}; + }); + } +} + +DBIEngineTest->run( + class => $CLASS, + version_query => q{select 'SQLite ' || sqlite_version()}, + target_params => [ uri => URI->new("db:sqlite:$db_name") ], + alt_target_params => [ + registry => 'sqitchtest', + uri => URI->new("db:sqlite:$db_name"), + ], + skip_unless => sub { + my $self = shift; + + # Should have the database handle and client. + $self->dbh && $self->sqlite3; + + # Make sure we have a supported version. + my $version = $self->dbh->{sqlite_version}; + my @v = split /[.]/ => $version; + die "SQLite >= 3.7.11 required; DBD::SQLite built with $version\n" + unless $v[0] > 3 || ($v[0] == 3 && ($v[1] > 7 || ($v[1] == 7 && $v[2] >= 11))); + + $version = (split / / => scalar $self->sqitch->capture( $self->client, '-version' ))[0]; + @v = split /[.]/ => $version; + die "SQLite >= 3.3.9 required; CLI is $version\n" + unless $v[0] > 3 || ($v[0] == 3 && ($v[1] > 3 || ($v[1] == 3 && $v[2] >= 9))); + say "# Detected SQLite CLI $version"; + return 1; + }, + engine_err_regex => qr/^near "blah": syntax error/, + init_error => __x( + 'Sqitch database {database} already initialized', + database => $alt_db, + ), + test_dbh => sub { + my $dbh = shift; + # Make sure foreign key constraints are enforced. + ok $dbh->selectcol_arrayref('PRAGMA foreign_keys')->[0], + 'The foreign_keys pragma should be enabled'; + }, + add_second_format => q{strftime('%%Y-%%m-%%d %%H:%%M:%%f', strftime('%%J', %s) + (1/86400.0))}, +); + +done_testing; diff --git a/t/status.t b/t/status.t new file mode 100644 index 00000000..84d11398 --- /dev/null +++ b/t/status.t @@ -0,0 +1,616 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 124; +#use Test::More 'no_plan'; +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use Test::NoWarnings; +use Test::Exception; +use Test::Warn; +use Test::MockModule; +use Path::Class; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::status'; +require_ok $CLASS; + +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => 'test-status', +); +ok my $sqitch = App::Sqitch->new(config => $config), + 'Load a sqitch object'; +isa_ok my $status = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'status', + config => $config, +}), $CLASS, 'status command'; + +can_ok $status, qw( + project + show_changes + show_tags + date_format + options + execute + configure + emit_state + emit_changes + emit_tags + emit_status + does +); + +ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_" + for qw(ContextCommand ConnectingCommand); + +is_deeply [ $CLASS->options ], [qw( + project=s + target|t=s + show-tags + show-changes + date-format|date=s + plan-file|f=s + top-dir=s + registry=s + client|db-client=s + db-name|d=s + db-user|db-username|u=s + db-host|h=s + db-port|p=i +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +my $engine_mocker = Test::MockModule->new('App::Sqitch::Engine::sqlite'); +my @projs; +$engine_mocker->mock( registered_projects => sub { @projs }); +my $initialized; +$engine_mocker->mock( initialized => sub { + diag "Gonna return $initialized" if $ENV{RELEASE_TESTING}; + $initialized; +} ); + +my $mock_target = Test::MockModule->new('App::Sqitch::Target'); +my ($target, $orig_new); +$mock_target->mock(new => sub { $target = shift->$orig_new(@_); }); +$orig_new = $mock_target->original('new'); + +# Start with uninitialized database. +$initialized = 0; + +############################################################################## +# Test project. +$status->target($status->default_target); +throws_ok { $status->project } 'App::Sqitch::X', + 'Should have error for uninitialized database'; +is $@->ident, 'status', 'Uninitialized database error ident should be "status"'; +is $@->message, __( + 'Database not initialized for Sqitch' +), 'Uninitialized database error message should be correct'; + +# Specify a project. +isa_ok $status = $CLASS->new( + sqitch => $sqitch, + project => 'foo', +), $CLASS, 'new status command'; +is $status->project, 'foo', 'Should have project "foo"'; + +# Look up the project in the database. +ok $sqitch = App::Sqitch->new( config => $config), + 'Load a sqitch object with SQLite'; + +ok $status = $CLASS->new(sqitch => $sqitch), 'Create another status command'; +$status->target($status->default_target); +throws_ok { $status->project } 'App::Sqitch::X', + 'Should get an error for uninitialized db'; +is $@->ident, 'status', 'Uninitialized db error ident should be "status"'; +is $@->message, __ 'Database not initialized for Sqitch', + 'Uninitialized db error message should be correct'; + +# Try no registered projects. +$initialized = 1; +throws_ok { $status->project } 'App::Sqitch::X', + 'Should get an error for no registered projects'; +is $@->ident, 'status', 'No projects error ident should be "status"'; +is $@->message, __ 'No projects registered', + 'No projects error message should be correct'; + +# Try too many registered projects. +@projs = qw(foo bar); +throws_ok { $status->project } 'App::Sqitch::X', + 'Should get an error for too many projects'; +is $@->ident, 'status', 'Too many projects error ident should be "status"'; +is $@->message, __x( + 'Use --project to select which project to query: {projects}', + projects => join __ ', ', @projs, +), 'Too many projects error message should be correct'; + +# Go for one project. +@projs = ('status'); +is $status->project, 'status', 'Should find single project'; +$engine_mocker->unmock_all; + +# Fall back on plan project name. + +ok $sqitch = App::Sqitch->new(config => TestConfig->new( + 'core.top_dir' => dir(qw(t sql))->stringify, +)); + +isa_ok $status = $CLASS->new( sqitch => $sqitch ), $CLASS, + 'another status command'; +$status->target($status->default_target); +is $status->project, $target->plan->project, 'Should have plan project'; + +############################################################################## +# Test database. +is $status->target_name, undef, 'Default target should be undef'; +isa_ok $status = $CLASS->new( + sqitch => $sqitch, + target_name => 'foo', +), $CLASS, 'new status with target'; +is $status->target_name, 'foo', 'Should have target "foo"'; + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure($config, {}), {_params => [], _cx => []}, + 'Should get empty hash for no config or options'; +$config->update('status.date_format' => 'nonesuch'); +throws_ok { $CLASS->configure($config, {}), {} } 'App::Sqitch::X', + 'Should get error for invalid date format in config'; +is $@->ident, 'datetime', + 'Invalid date format error ident should be "datetime"'; +is $@->message, __x( + 'Unknown date format "{format}"', + format => 'nonesuch', +), 'Invalid date format error message should be correct'; + +$config->replace( + 'status.show_changes' => 1, + 'status.show_tags' => 0, +); +is_deeply $CLASS->configure($config, {}), { + show_changes => 1, + show_tags => 0, + _params => [], + _cx => [], +}, 'Should get bool values set from config'; + +throws_ok { $CLASS->configure($config, { date_format => 'non'}), {} } + 'App::Sqitch::X', + 'Should get error for invalid date format in optsions'; +is $@->ident, 'datetime', + 'Invalid date format error ident should be "status"'; +is $@->message, __x( + 'Unknown date format "{format}"', + format => 'non', +), 'Invalid date format error message should be correct'; + +############################################################################## +# Test emit_state(). +my $dt = App::Sqitch::DateTime->new( + year => 2012, + month => 7, + day => 7, + hour => 16, + minute => 12, + second => 47, + time_zone => 'America/Denver', +); + +my $state = { + project => 'mystatus', + change_id => 'someid', + change => 'widgets_table', + committer_name => 'fred', + committer_email => 'fred@example.com', + committed_at => $dt->clone, + tags => [], + planner_name => 'barney', + planner_email => 'barney@example.com', + planned_at => $dt->clone->subtract(days => 2), +}; +$dt->set_time_zone('local'); +my $ts = $dt->as_string( format => $status->date_format ); + +ok $status->emit_state($state), 'Emit the state'; +is_deeply +MockOutput->get_comment, [ + [__x 'Project: {project}', project => 'mystatus'], + [__x 'Change: {change_id}', change_id => 'someid'], + [__x 'Name: {change}', change => 'widgets_table'], + [__x 'Deployed: {date}', date => $ts], + [__x 'By: {name} <{email}>', name => 'fred', email => 'fred@example.com' ], +], 'The state should have been emitted'; + +# Try with a tag. +$state-> {tags} = ['@alpha']; +ok $status->emit_state($state), 'Emit the state with a tag'; +is_deeply +MockOutput->get_comment, [ + [__x 'Project: {project}', project => 'mystatus'], + [__x 'Change: {change_id}', change_id => 'someid'], + [__x 'Name: {change}', change => 'widgets_table'], + [__nx 'Tag: {tags}', 'Tags: {tags}', 1, tags => '@alpha'], + [__x 'Deployed: {date}', date => $ts], + [__x 'By: {name} <{email}>', name => 'fred', email => 'fred@example.com' ], +], 'The state should have been emitted with a tag'; + +# Try with mulitple tags. +$state-> {tags} = ['@alpha', '@beta', '@gamma']; +ok $status->emit_state($state), 'Emit the state with multiple tags'; +is_deeply +MockOutput->get_comment, [ + [__x 'Project: {project}', project => 'mystatus'], + [__x 'Change: {change_id}', change_id => 'someid'], + [__x 'Name: {change}', change => 'widgets_table'], + [__nx 'Tag: {tags}', 'Tags: {tags}', 3, + tags => join(__ ', ', qw(@alpha @beta @gamma))], + [__x 'Deployed: {date}', date => $ts], + [__x 'By: {name} <{email}>', name => 'fred', email => 'fred@example.com' ], +], 'The state should have been emitted with multiple tags'; + +############################################################################## +# Test emit_changes(). +my @current_changes; +my $project; +$engine_mocker->mock(current_changes => sub { + $project = $_[1]; + sub { shift @current_changes }; +}); +@current_changes = ({ + change_id => 'someid', + change => 'foo', + committer_name => 'anna', + committer_email => 'anna@example.com', + committed_at => $dt, + planner_name => 'anna', + planner_email => 'anna@example.com', + planned_at => $dt->clone->subtract( hours => 4 ), +}); +$config->replace('core.engine' => 'sqlite'); +$sqitch = App::Sqitch->new(config => $config); +ok $status = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'status', + config => $config, +}), 'Create status command with an engine'; + +ok $status->emit_changes, 'Try to emit changes'; +is_deeply +MockOutput->get_comment, [], + 'Should have emitted no changes'; + +ok $status = App::Sqitch::Command::status->new( + sqitch => $sqitch, + show_changes => 1, + project => 'foo', +), 'Create change-showing status command'; +$status->target($status->default_target); + +ok $status->emit_changes, 'Emit changes again'; +is $project, 'foo', 'Project "foo" should have been passed to current_changes'; +is_deeply +MockOutput->get_comment, [ + [''], + [__n 'Change:', 'Changes:', 1], + [" foo - $ts - anna <anna\@example.com>"], +], 'Should have emitted one change'; + +# Add a couple more changes. +@current_changes = ( + { + change_id => 'someid', + change => 'foo', + committer_name => 'anna', + committer_email => 'anna@example.com', + committed_at => $dt, + planner_name => 'anna', + planner_email => 'anna@example.com', + planned_at => $dt->clone->subtract( hours => 4 ), + }, + { + change_id => 'anid', + change => 'blech', + committer_name => 'david', + committer_email => 'david@example.com', + committed_at => $dt, + planner_name => 'david', + planner_email => 'david@example.com', + planned_at => $dt->clone->subtract( hours => 4 ), + }, + { + change_id => 'anotherid', + change => 'long_name', + committer_name => 'julie', + committer_email => 'julie@example.com', + committed_at => $dt, + planner_name => 'julie', + planner_email => 'julie@example.com', + planned_at => $dt->clone->subtract( hours => 4 ), + }, +); + +ok $status->emit_changes, 'Emit changes thrice'; +is $project, 'foo', + 'Project "foo" again should have been passed to current_changes'; +is_deeply +MockOutput->get_comment, [ + [''], + [__n 'Change:', 'Changes:', 3], + [" foo - $ts - anna <anna\@example.com>"], + [" blech - $ts - david <david\@example.com>"], + [" long_name - $ts - julie <julie\@example.com>"], +], 'Should have emitted three changes'; + +############################################################################## +# Test emit_tags(). +my @current_tags; +$engine_mocker->mock(current_tags => sub { + $project = $_[1]; + sub { shift @current_tags }; +}); + +ok $status->emit_tags, 'Try to emit tags'; +is_deeply +MockOutput->get_comment, [], 'No tags should have been emitted'; + +ok $status = App::Sqitch::Command::status->new( + sqitch => $sqitch, + show_tags => 1, + project => 'bar', +), 'Create tag-showing status command'; +$status->target($status->default_target); + +# Try with no tags. +ok $status->emit_tags, 'Try to emit tags again'; +is $project, 'bar', 'Project "bar" should be passed to current_tags()'; +is_deeply +MockOutput->get_comment, [ + [''], + [__ 'Tags: None.'], +], 'Should have emitted a header for no tags'; + +@current_tags = ({ + tag_id => 'tagid', + tag => '@alpha', + committer_name => 'duncan', + committer_email => 'duncan@example.com', + committed_at => $dt, + planner_name => 'duncan', + planner_email => 'duncan@example.com', + planned_at => $dt->clone->subtract( hours => 4 ), +}); + +ok $status->emit_tags, 'Emit tags'; +is $project, 'bar', 'Project "bar" should again be passed to current_tags()'; +is_deeply +MockOutput->get_comment, [ + [''], + [__n 'Tag:', 'Tags:', 1], + [" \@alpha - $ts - duncan <duncan\@example.com>"], +], 'Should have emitted one tag'; + +# Add a couple more tags. +@current_tags = ( + { + tag_id => 'tagid', + tag => '@alpha', + committer_name => 'duncan', + committer_email => 'duncan@example.com', + committed_at => $dt, + planner_name => 'duncan', + planner_email => 'duncan@example.com', + planned_at => $dt->clone->subtract( hours => 4 ), + }, + { + tag_id => 'myid', + tag => '@beta', + committer_name => 'nick', + committer_email => 'nick@example.com', + committed_at => $dt, + planner_name => 'nick', + planner_email => 'nick@example.com', + planned_at => $dt->clone->subtract( hours => 4 ), + }, + { + tag_id => 'yourid', + tag => '@gamma', + committer_name => 'jacqueline', + committer_email => 'jacqueline@example.com', + committed_at => $dt, + planner_name => 'jacqueline', + planner_email => 'jacqueline@example.com', + planned_at => $dt->clone->subtract( hours => 4 ), + }, +); + +ok $status->emit_tags, 'Emit tags again'; +is $project, 'bar', 'Project "bar" should once more be passed to current_tags()'; +is_deeply +MockOutput->get_comment, [ + [''], + [__n 'Tag:', 'Tags:', 3], + [" \@alpha - $ts - duncan <duncan\@example.com>"], + [" \@beta - $ts - nick <nick\@example.com>"], + [" \@gamma - $ts - jacqueline <jacqueline\@example.com>"], +], 'Should have emitted all three tags'; + +############################################################################## +# Test emit_status(). +my $file = file qw(t plans multi.plan); +$config->update('core.plan_file' => $file->stringify); +$sqitch = App::Sqitch->new(config => $config); +ok $status = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'status', + config => $config, +}), 'Create status command with actual plan command'; +$status->target($target = $status->default_target); +my @changes = $target->plan->changes; + +# Start with an up-to-date state. +$state->{change_id} = $changes[-1]->id; +ok $status->emit_status($state), 'Emit status'; +is_deeply +MockOutput->get_comment, [['']], 'Should have a blank comment line'; +is_deeply +MockOutput->get_emit, [ + [__ 'Nothing to deploy (up-to-date)'], +], 'Should emit up-to-date output'; + +# Start with second-to-last change. +$state->{change_id} = $changes[2]->id; +ok $status->emit_status($state), 'Emit status again'; +is_deeply +MockOutput->get_comment, [['']], 'Should have a blank comment line'; +is_deeply +MockOutput->get_emit, [ + [__n 'Undeployed change:', 'Undeployed changes:', 1], + [' * ', $changes[3]->format_name_with_tags], +], 'Should emit list of undeployed changes'; + +# Start with second step. +$state->{change_id} = $changes[1]->id; +ok $status->emit_status($state), 'Emit status thrice'; +is_deeply +MockOutput->get_comment, [['']], 'Should have a blank comment line'; +is_deeply +MockOutput->get_emit, [ + [__n 'Undeployed change:', 'Undeployed changes:', 2], + map { [' * ', $_->format_name_with_tags] } @changes[2..$#changes], +], 'Should emit list of undeployed changes'; + +# Now go for an ID that cannot be found. +$state->{change_id} = 'nonesuchid'; +throws_ok { $status->emit_status($state) } 'App::Sqitch::X', 'Die on invalid ID'; +is $@->ident, 'status', 'Invalid ID error ident should be "status"'; +is $@->message, __ 'Make sure you are connected to the proper database for this project.', + 'The invalid ID error message should be correct'; +is_deeply +MockOutput->get_comment, [['']], 'Should have a blank comment line'; +is_deeply +MockOutput->get_vent, [ + [__x 'Cannot find this change in {file}', file => $file], +], 'Should have a message about inability to find the change'; + +############################################################################## +# Test execute(). +my ($target_name_arg, $orig_meth); +$target_name_arg = '_blah'; +$mock_target->mock(new => sub { + my $self = shift; + my %p = @_; + $target_name_arg = $p{name}; + $self->$orig_meth(@_); +}); +$orig_meth = $mock_target->original('new'); + +ok $status = App::Sqitch::Command::status->new( + sqitch => $sqitch, + config => $config, +), 'Recreate status command'; + +my $check_output = sub { + local $Test::Builder::Level = $Test::Builder::Level + 1; + is_deeply +MockOutput->get_comment, [ + [__x 'On database {db}', db => $target->engine->destination ], + [__x 'Project: {project}', project => 'mystatus'], + [__x 'Change: {change_id}', change_id => $state->{change_id}], + [__x 'Name: {change}', change => 'widgets_table'], + [__nx 'Tag: {tags}', 'Tags: {tags}', 3, + tags => join(__ ', ', qw(@alpha @beta @gamma))], + [__x 'Deployed: {date}', date => $ts], + [__x 'By: {name} <{email}>', name => 'fred', email => 'fred@example.com'], + [''], + ], 'The state should have been emitted'; + is_deeply +MockOutput->get_emit, [ + [__n 'Undeployed change:', 'Undeployed changes:', 2], + map { [' * ', $_->format_name_with_tags] } @changes[2..$#changes], + ], 'Should emit list of undeployed changes'; +}; + + +$state->{change_id} = $changes[1]->id; +$engine_mocker->mock( current_state => $state ); +ok $status->execute, 'Execute'; +$check_output->(); +is $target_name_arg, undef, 'No target name should have been passed to Target'; + +# Test with a database argument. +ok $status->execute('db:sqlite:'), 'Execute with target arg'; +$check_output->(); +is $target_name_arg, 'db:sqlite:', 'Name "db:sqlite:" should have been passed to Target'; + +# Pass the target in an option. +ok $status = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'status', + config => $config, + args => ['--target', 'db:sqlite:'], +}), 'Create status command with a target option'; +ok $status->execute, 'Execute with target attribute'; +$check_output->(); +is $target_name_arg, 'db:sqlite:', 'Name "db:sqlite:" should have been passed to Target'; + +# Test with two targets. +ok $status->execute('db:pg:'), 'Execute with target attribute and arg'; +$check_output->(); +is $target_name_arg, 'db:pg:', 'Name "db:sqlite:" should have been passed to Target'; +is_deeply +MockOutput->get_warn, [[__x( + 'Too many targets specified; connecting to {option}', + option => $status->target_name, +)]], 'Should have got warning for two targets'; + +# Test with a plan file param and no option. +ok $status = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'status', + config => $config, +}), 'Create status command with no target option'; +ok $status->execute($file), 'Execute with plan file'; +$check_output->(); +is $target_name_arg, 'db:sqlite:', 'Name "db:sqlite:" should have been passed to Target'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +# Test with unknown plan. +for my $spec ( + [ 'specified', App::Sqitch->new(config => $config) ], + [ 'external', $sqitch ], +) { + my ( $desc, $sqitch ) = @{ $spec }; + ok $status = $CLASS->new( + sqitch => $sqitch, + project => 'foo', + ), "Create status command with $desc project"; + + ok $status->execute, "Execute for $desc project"; + is_deeply +MockOutput->get_comment, [ + [__x 'On database {db}', db => $target->engine->destination ], + [__x 'Project: {project}', project => 'mystatus'], + [__x 'Change: {change_id}', change_id => $state->{change_id}], + [__x 'Name: {change}', change => 'widgets_table'], + [__nx 'Tag: {tags}', 'Tags: {tags}', 3, + tags => join(__ ', ', qw(@alpha @beta @gamma))], + [__x 'Deployed: {date}', date => $ts], + [__x 'By: {name} <{email}>', name => 'fred', email => 'fred@example.com'], + [''], + ], "The $desc project state should have been emitted"; + is_deeply +MockOutput->get_emit, [ + [__x 'Status unknown. Use --plan-file to assess "{project}" status', project => 'foo'], + ], "Should emit unknown status message for $desc project"; +} + +# Test with no changes. +$engine_mocker->mock( current_state => undef ); +throws_ok { $status->execute } 'App::Sqitch::X', 'Die on no state'; +is $@->ident, 'status', 'No state error ident should be "status"'; +is $@->message, __ 'No changes deployed', + 'No state error message should be correct'; +is_deeply +MockOutput->get_comment, [ + [__x 'On database {db}', db => $target->engine->destination ], +], 'The "On database" comment should have been emitted'; + +# Test with no initilization. +$initialized = 0; +$engine_mocker->mock( initialized => sub { $initialized } ); +$engine_mocker->mock( current_state => sub { die 'No Sqitch tables' } ); +throws_ok { $status->execute } 'App::Sqitch::X', + 'Should get an error for uninitialized db'; +is $@->ident, 'status', 'Uninitialized db error ident should be "status"'; +is $@->message, __x( + 'Database {db} has not been initialized for Sqitch', + db => $status->engine->destination, +), 'Uninitialized db error message should be correct'; diff --git a/t/tag.t b/t/tag.t new file mode 100644 index 00000000..fcfe4348 --- /dev/null +++ b/t/tag.t @@ -0,0 +1,167 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use utf8; +use Test::More tests => 22; +#use Test::More 'no_plan'; +use Test::NoWarnings; +use Path::Class; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use Test::MockModule; +use Digest::SHA; +use URI; +use lib 't/lib'; +use TestConfig; + +my $CLASS; + +BEGIN { + $CLASS = 'App::Sqitch::Plan::Tag'; + require_ok $CLASS or die; + delete $ENV{PGDATABASE}; + delete $ENV{PGUSER}; + delete $ENV{USER}; +} + +can_ok $CLASS, qw( + name + info + id + lspace + rspace + note + plan + timestamp + planner_name + planner_email + format_planner +); + +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir(qw(t sql))->stringify, +); +my $sqitch = App::Sqitch->new(config => $config); +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +my $plan = App::Sqitch::Plan->new(sqitch => $sqitch, target => $target); +my $change = App::Sqitch::Plan::Change->new( plan => $plan, name => 'roles' ); + +isa_ok my $tag = $CLASS->new( + name => 'foo', + plan => $plan, + change => $change, +), $CLASS; +isa_ok $tag, 'App::Sqitch::Plan::Line'; +my $mock_plan = Test::MockModule->new('App::Sqitch::Plan'); +$mock_plan->mock(index_of => 0); # no other changes + +is $tag->format_name, '@foo', 'Name should format as "@foo"'; +isa_ok $tag->timestamp, 'App::Sqitch::DateTime', 'Timestamp'; + +is $tag->planner_name, $sqitch->user_name, + 'Planner name shoudld default to user name'; +is $tag->planner_email, $sqitch->user_email, + 'Planner email shoudld default to user email'; +is $tag->format_planner, join( + ' ', + $sqitch->user_name, + '<' . $sqitch->user_email . '>' +), 'Planner name and email should format properly'; + +my $ts = $tag->timestamp->as_string; +is $tag->as_string, "\@foo $ts ". $tag->format_planner, + 'Should as_string to "@foo" + timstamp + planner'; +my $uri = URI->new('https://github.com/sqitchers/sqitch/'); +$mock_plan->mock( uri => $uri ); +is $tag->info, join("\n", + 'project sql', + 'uri https://github.com/sqitchers/sqitch/', + 'tag @foo', + 'change ' . $change->id, + 'planner ' . $tag->format_planner, + 'date ' . $ts, +), 'Tag info should incldue the URI'; + +my $date = App::Sqitch::DateTime->new( + year => 2012, + month => 7, + day => 16, + hour => 17, + minute => 25, + second => 7, + time_zone => 'UTC', +); + +ok $tag = $CLASS->new( + name => 'howdy', + plan => $plan, + change => $change, + lspace => ' ', + rspace => "\t", + note => 'blah blah blah', + timestamp => $date, + planner_name => 'Barack Obama', + planner_email => 'potus@whitehouse.gov', +), 'Create tag with more stuff'; + +my $ts2 = '2012-07-16T17:25:07Z'; +is $tag->as_string, + " \@howdy $ts2 Barack Obama <potus\@whitehouse.gov>\t# blah blah blah", + 'It should as_string correctly'; + +$mock_plan->mock(index_of => 1); +$mock_plan->mock(change_at => $change); +is $tag->change, $change, 'Change should be correct'; +is $tag->format_planner, 'Barack Obama <potus@whitehouse.gov>', + 'Planner name and email should format properly'; + +# Make sure it gets the change even if there is a tag in between. +my @prevs = ($tag, $change); +$mock_plan->mock(index_of => 8); +$mock_plan->mock(change_at => sub { shift @prevs }); +is $tag->change, $change, 'Change should be for previous change'; + +is $tag->info, join("\n", + 'project sql', + 'uri https://github.com/sqitchers/sqitch/', + 'tag @howdy', + 'change ' . $change->id, + 'planner Barack Obama <potus@whitehouse.gov>', + 'date 2012-07-16T17:25:07Z', + '', 'blah blah blah', +), 'Tag info should include the change'; + +is $tag->id, do { + my $content = $tag->info; + Digest::SHA->new(1)->add( + 'tag ' . length($content) . "\0" . $content + )->hexdigest; +},'Tag ID should be correct'; + +############################################################################## +# Test ID for a tag with a UTF-8 name. +ok $tag = $CLASS->new( + name => '阱阪阬', + plan => $plan, + change => $change, +), 'Create tag with UTF-8 name'; + +is $tag->info, join("\n", + 'project sql', + 'uri https://github.com/sqitchers/sqitch/', + 'tag ' . '@阱阪阬', + 'change ' . $change->id, + 'planner ' . $tag->format_planner, + 'date ' . $tag->timestamp->as_string, +), 'The name should be decoded text in info'; + +is $tag->id, do { + my $content = Encode::encode_utf8 $tag->info; + Digest::SHA->new(1)->add( + 'tag ' . length($content) . "\0" . $content + )->hexdigest; +},'Tag ID should be hahsed from encoded UTF-8'; diff --git a/t/tag_cmd.t b/t/tag_cmd.t new file mode 100644 index 00000000..ba645ee4 --- /dev/null +++ b/t/tag_cmd.t @@ -0,0 +1,370 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 86; +#use Test::More 'no_plan'; +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use Test::Exception; +use Test::Warn; +use Test::NoWarnings; +use Path::Class qw(file dir); +use File::Path qw(make_path remove_tree); +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::tag'; + +my $dir = dir 'test-tag_cmd'; +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => $dir->stringify, +); +ok my $sqitch = App::Sqitch->new(config => $config), + 'Load a sqitch sqitch object'; +isa_ok my $tag = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'tag', + config => $config, +}), $CLASS, 'tag command'; +ok !$tag->all, 'The all attribute should be false by default'; + +can_ok $CLASS, qw( + options + configure + note + execute + does +); + +ok $CLASS->does("App::Sqitch::Role::ContextCommand"), + "$CLASS does ContextCommand"; + +is_deeply [$CLASS->options], [qw( + tag-name|tag|t=s + change-name|change|c=s + all|a! + note|n|m=s@ + plan-file|f=s + top-dir=s +)], 'Should have note option'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +############################################################################## +# Test configure(). +my (@params, $orig_get); +my $cmock = TestConfig->mock( + get => sub { my $c = shift; push @params, \@_; $orig_get->($c, @_) }, +); +$orig_get = $cmock->original('get'); + +is_deeply $CLASS->configure($config, {}), { _cx => [] }, + 'Should get empty hash for no config or options'; +is_deeply \@params, [], 'Should not have fetched boolean tag.all config'; +@params = (); +is_deeply $CLASS->configure( + $config, + { tag_name => 'foo', change_name => 'bar', all => 1 } +), + { tag_name => 'foo', change_name => 'bar', all => 1, _cx => [] }, + 'Should get populated hash for no all options'; + +is_deeply \@params, [], 'Should not have fetched boolean tag.all config'; +@params = (); +$cmock->unmock_all; + +############################################################################## +# Test tagging a single plan. +make_path $dir->stringify; +END { remove_tree $dir->stringify }; +my $plan_file = $tag->default_target->plan_file; +$plan_file->spew("%project=empty\n\n"); + +# Override request_note(). +my $tag_mocker = Test::MockModule->new('App::Sqitch::Plan::Tag'); +my %request_params; +$tag_mocker->mock(request_note => sub { + my $self = shift; + %request_params = @_; + $self->note; +}); + +my $reload = sub { + my $plan = shift; + $plan->_plan( $plan->load); + delete $plan->{$_} for qw(_changes _lines project uri); + 1; +}; + +my $plan = $tag->default_target->plan; +ok $plan->add( name => 'foo' ), 'Add change "foo"'; +$plan->write_to( $plan->file ); + +# Tag it. +isa_ok $tag = App::Sqitch::Command::tag->new({ sqitch => $sqitch }), + $CLASS, 'new tag command'; +ok $tag->execute('alpha'), 'Tag @alpha'; +ok $reload->($plan), 'Reload plan'; +is $plan->get('@alpha')->name, 'foo', 'Should have tagged "foo"'; +is $plan->get('@alpha')->name, 'foo', 'New tag should have been written'; +is [$plan->tags]->[-1]->note, '', 'New tag should have empty note'; +is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note'; + +is_deeply +MockOutput->get_info, [ + [__x + 'Tagged "{change}" with {tag} in {file}', + change => 'foo', + tag => '@alpha', + file => $plan->file, + ] +], 'The info message should be correct'; + +# With no arg, should get a list of tags. +ok $tag->execute, 'Execute with no arg'; +is_deeply +MockOutput->get_info, [ + ['@alpha'], +], 'The one tag should have been listed'; +is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note'; + +# Add a tag. +ok $plan->tag( name => '@beta' ), 'Add tag @beta'; +$plan->write_to( $plan->file ); +ok $tag->execute, 'Execute with no arg again'; +is_deeply +MockOutput->get_info, [ + ['@alpha'], + ['@beta'], +], 'Both tags should have been listed'; +is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note'; + +# Set a note and a name. +isa_ok $tag = App::Sqitch::Command::tag->new({ + sqitch => $sqitch, + note => [qw(hello there)], + tag_name => 'gamma', +}), $CLASS, 'tag command with note'; +$plan = $tag->default_target->plan; + +ok $tag->execute, 'Tag @gamma'; +is $plan->get('@gamma')->name, 'foo', 'Gamma tag should be on change "foo"'; +is [$plan->tags]->[-1]->note, "hello\n\nthere", 'Gamma tag should have note'; +ok $reload->($plan), 'Reload plan'; +is $plan->get('@gamma')->name, 'foo', 'Gamma tag should have been written'; +is [$plan->tags]->[-1]->note, "hello\n\nthere", 'Written tag should have note'; +is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note'; + +is_deeply +MockOutput->get_info, [ + [__x + 'Tagged "{change}" with {tag} in {file}', + change => 'foo', + tag => '@gamma', + file => $plan->file, + ] +], 'The gamma note should be correct'; + +# Tag a specific change. +isa_ok $tag = App::Sqitch::Command::tag->new({ + sqitch => $sqitch, + note => ['here we go'], +}), $CLASS, 'tag command with note'; +$plan = $tag->default_target->plan; + +ok $plan->add( name => 'bar' ), 'Add change "bar"'; +ok $plan->add( name => 'baz' ), 'Add change "baz"'; +$plan->write_to( $plan->file ); +ok $tag->execute('delta', 'bar'), 'Tag change "bar" with @delta'; +ok $reload->($plan), 'Reload plan'; +is $plan->get('@delta')->name, 'bar', 'Should have tagged "bar"'; +ok $reload->($plan), 'Reload plan'; +is $plan->get('@delta')->name, 'bar', 'New tag should have been written'; +is [$plan->tags]->[-1]->note, 'here we go', 'New tag should have the proper note'; +is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note'; + +is_deeply +MockOutput->get_info, [ + [__x + 'Tagged "{change}" with {tag} in {file}', + change => 'bar', + tag => '@delta', + file => $plan->file, + ] +], 'The info message should be correct'; + +# Use --change to tage a specific change. +isa_ok $tag = App::Sqitch::Command::tag->new({ + sqitch => $sqitch, + change_name => 'bar', + note => ['here we go'], +}), $CLASS, 'tag command with change name'; +$plan = $tag->default_target->plan; + +ok $tag->execute('zeta'), 'Tag change "bar" with @zeta'; +is $plan->get('@zeta')->name, 'bar', 'Should have tagged "bar" with @zeta'; +ok $reload->($plan), 'Reload plan'; +is $plan->get('@zeta')->name, 'bar', 'Tag @zeta should have been written'; +is [$plan->tags]->[-1]->note, 'here we go', 'Tag @zeta should have the proper note'; +is_deeply \%request_params, { for => __ 'tag' }, 'Should have requested a note'; + +is_deeply +MockOutput->get_info, [ + [__x + 'Tagged "{change}" with {tag} in {file}', + change => 'bar', + tag => '@zeta', + file => $plan->file, + ] +], 'The zeta info message should be correct'; + +############################################################################## +# Let's deal with multiple engines. +$config->replace( + 'core.engine' => 'sqlite', + 'engine.pg.plan_file' => $plan->file->stringify, + 'engine.sqlite.plan_file' => $plan->file->stringify, + 'engine.mysql.plan_file' => $plan->file->stringify, +); + +ok $sqitch = App::Sqitch->new(config => $config), + 'Load another sqitch sqitch object'; + +isa_ok $tag = App::Sqitch::Command::tag->new({ + sqitch => $sqitch, + all => 1, + note => ['here we go again'], +}), $CLASS, 'another tag command'; +$plan = $tag->default_target->plan; +ok $tag->execute('whacko'), 'Tag with @whacko'; +is $plan->get('@whacko')->name, 'baz', 'Should have tagged "baz" with @whacko'; + +is_deeply +MockOutput->get_info, [ + [__x + 'Tagged "{change}" with {tag} in {file}', + change => 'baz', + tag => '@whacko', + file => $plan->file, + ] +], 'The whacko info message should be correct'; + +# With --all and args, should get an error. +throws_ok { $tag->execute('fred', 'pg') } 'App::Sqitch::X', + 'Should get an error for --all and a target arg'; +is $@->ident, 'tag', 'Mixed arguments error ident should be "tag"'; +is $@->message, __( + 'Cannot specify both --all and engine, target, or plan arugments' +), 'Mixed arguments error message should be correct'; + +# Great. Now try two plans! +(my $pg = $dir->file('pg.plan')->stringify) =~ s{\\}{\\\\}g; +(my $sqlite = $dir->file('sqlite.plan')->stringify) =~ s{\\}{\\\\}g; +$dir->file("$_.plan")->spew( + "%project=tag\n\n${_}_change 2012-07-16T17:25:07Z Hi <hi\@foo.com>\n" +) for qw(pg sqlite); + +$config->replace( + 'core.engine' => 'pg', + 'core.top_dir' => $dir->stringify, + 'engine.pg.plan_file' => $pg, + 'engine.sqlite.plan_file' => $sqlite, + 'tag.all' => 1, +); +ok $sqitch = App::Sqitch->new(config => $config), + 'Load another sqitch sqitch object'; +isa_ok $tag = App::Sqitch::Command::tag->new({ + sqitch => $sqitch, + note => ['here we go again'], +}), $CLASS, 'yet another tag command'; + +ok $tag->execute('dubdub'), 'Tag with @dubdub'; +my @targets = App::Sqitch::Target->all_targets(sqitch => $sqitch); +is @targets, 2, 'Should have two targets'; +is $targets[0]->plan->get('@dubdub')->name, 'pg_change', + 'Should have tagged pg plan change "pg_change" with @dubdub'; +is $targets[1]->plan->get('@dubdub')->name, 'sqlite_change', + 'Should have tagged sqlite plan change "sqlite_change" with @dubdub'; + +is_deeply +MockOutput->get_info, [ + [__x + 'Tagged "{change}" with {tag} in {file}', + change => 'pg_change', + tag => '@dubdub', + file => $targets[0]->plan_file, + ], + [__x + 'Tagged "{change}" with {tag} in {file}', + change => 'sqlite_change', + tag => '@dubdub', + file => $targets[1]->plan_file, + ], +], 'The dubdub info message should show both plans tagged'; + +# With tag.all and an argument, we should just get the argument. +ok $tag->execute('shoot', 'sqlite'), 'Tag sqlite plan with @shoot'; +@targets = App::Sqitch::Target->all_targets(sqitch => $sqitch); +is @targets, 2, 'Should still have two targets'; +ok !$targets[0]->plan->get('@shoot'), + 'Should not have tagged pg plan change "sqlite_change" with @shoot'; +is $targets[1]->plan->get('@shoot')->name, 'sqlite_change', + 'Should have tagged sqlite plan change "sqlite_change" with @shoot'; + +is_deeply +MockOutput->get_info, [ + [__x + 'Tagged "{change}" with {tag} in {file}', + change => 'sqlite_change', + tag => '@shoot', + file => $targets[1]->plan_file, + ], +], 'The shoot info message should the sqlite plan getting tagged'; + +# Without --all or tag.all, we should just get the default target. +$config->replace( + 'core.engine' => 'pg', + 'core.to_dir' => $dir->stringify, + 'engine.pg.plan_file' => $pg, + 'engine.sqlite.plan_file' => $sqlite, +); +$sqitch = App::Sqitch->new(config => $config); +isa_ok $tag = App::Sqitch::Command::tag->new({ + sqitch => $sqitch, + note => ['here we go again'], +}), $CLASS, 'yet another tag command'; +ok $tag->execute('huwah'), 'Tag with @huwah'; +@targets = App::Sqitch::Target->all_targets(sqitch => $sqitch); +is @targets, 2, 'Should still have two targets'; +is $targets[0]->plan->get('@huwah')->name, 'pg_change', + 'Should have tagged pg plan change "pg_change" with @huwah'; +ok !$targets[1]->plan->get('@huwah'), + 'Should not have tagged sqlite plan change "sqlite_change" with @huwah'; + +is_deeply +MockOutput->get_info, [ + [__x + 'Tagged "{change}" with {tag} in {file}', + change => 'pg_change', + tag => '@huwah', + file => $targets[0]->plan_file, + ], +], 'The huwah info message should the pg plan getting tagged'; + +# Make sure we die if the passed name conflicts with a target. +TARGET: { + my $mock_add = Test::MockModule->new($CLASS); + $mock_add->mock(parse_args => sub { + return undef, undef, [$tag->default_target]; + }); + $mock_add->mock(name => 'blog'); + my $mock_target = Test::MockModule->new('App::Sqitch::Target'); + $mock_target->mock(name => 'blog'); + + throws_ok { $tag->execute('blog') } 'App::Sqitch::X', + 'Should get an error for conflict with target name'; + is $@->ident, 'tag', 'Conflicting target error ident should be "tag"'; + is $@->message, __x( + 'Name "{name}" identifies a target; use "--tag {name}" to use it for the tag name', + name => 'blog', + ), 'Conflicting target error message should be correct'; +} diff --git a/t/target.conf b/t/target.conf new file mode 100644 index 00000000..3b743724 --- /dev/null +++ b/t/target.conf @@ -0,0 +1,13 @@ +[core] + engine = pg + +[target "dev"] + uri = db:pg:widgets + +[target "qa"] + uri = db:pg://qa.example.com/qa_widgets + registry = meta + client = /usr/sbin/psql + +[target "prod"] + uri = db:pg://prod.example.us/pr_widgets diff --git a/t/target.t b/t/target.t new file mode 100644 index 00000000..075a4658 --- /dev/null +++ b/t/target.t @@ -0,0 +1,669 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More; +use App::Sqitch; +use Path::Class qw(dir file); +use Test::Exception; +use Locale::TextDomain qw(App-Sqitch); +use List::Util qw(first); +use lib 't/lib'; +use TestConfig; + +my $CLASS; +BEGIN { + $CLASS = 'App::Sqitch::Target'; + use_ok $CLASS or die; +} + +############################################################################## +# Load a target and test the basics. +my $config = TestConfig->new('core.engine' => 'sqlite'); +ok my $sqitch = App::Sqitch->new(config => $config), + 'Load a sqitch sqitch object'; +isa_ok my $target = $CLASS->new(sqitch => $sqitch), $CLASS; +can_ok $target, qw( + new + name + target + uri + sqitch + engine + registry + client + plan_file + plan + top_dir + deploy_dir + revert_dir + verify_dir + reworked_dir + reworked_deploy_dir + reworked_revert_dir + reworked_verify_dir + extension + variables +); + +# Look at default values. +is $target->name, 'db:sqlite:', 'Name should be "db:sqlite:"'; +is $target->target, $target->name, 'Target should be alias for name'; +is $target->uri, URI::db->new('db:sqlite:'), 'URI should be "db:sqlite:"'; +is $target->sqitch, $sqitch, 'Sqitch should be as passed'; +is $target->engine_key, 'sqlite', 'Engine key should be "sqlite"'; +isa_ok $target->engine, 'App::Sqitch::Engine::sqlite', 'Engine'; +is $target->registry, $target->engine->default_registry, + 'Should have default registry'; +my $client = $target->engine->default_client; +$client .= '.exe' if App::Sqitch::ISWIN && $client !~ /[.](?:exe|bat)$/; +is $target->client, $client, 'Should have default client'; +is $target->top_dir, dir, 'Should have default top_dir'; +is $target->deploy_dir, $target->top_dir->subdir('deploy'), + 'Should have default deploy_dir'; +is $target->revert_dir, $target->top_dir->subdir('revert'), + 'Should have default revert_dir'; +is $target->verify_dir, $target->top_dir->subdir('verify'), + 'Should have default verify_dir'; +is $target->reworked_dir, $target->top_dir, 'Should have default reworked_dir'; +is $target->reworked_deploy_dir, $target->reworked_dir->subdir('deploy'), + 'Should have default reworked_deploy_dir'; +is $target->reworked_revert_dir, $target->reworked_dir->subdir('revert'), + 'Should have default reworked_revert_dir'; +is $target->reworked_verify_dir, $target->reworked_dir->subdir('verify'), + 'Should have default reworked_verify_dir'; +is $target->extension, 'sql', 'Should have default extension'; +is $target->plan_file, $target->top_dir->file('sqitch.plan')->cleanup, + 'Should have default plan file'; +isa_ok $target->plan, 'App::Sqitch::Plan', 'Should get plan'; +is $target->plan->file, $target->plan_file, + 'Plan file should be copied from Target'; +my $uri = $target->uri; +is $target->dsn, $uri->dbi_dsn, 'DSN should be from URI'; +is $target->username, $uri->user, 'Username should be from URI'; +is $target->password, $uri->password, 'Password should be from URI'; +is_deeply $target->variables, {}, 'Variables should be empty'; + +do { + isa_ok my $target = $CLASS->new(sqitch => $sqitch), $CLASS; + local $ENV{SQITCH_USERNAME} = 'kamala'; + local $ENV{SQITCH_PASSWORD} = 'S3cre7s'; + is $target->username, $ENV{SQITCH_USERNAME}, + 'Username should be from environment variable'; + is $target->password, $ENV{SQITCH_PASSWORD}, + 'Password should be from environment variable'; +}; + +############################################################################## +# Let's look at how the object is created based on the params to new(). +# First try no params. +throws_ok { $CLASS->new } qr/^Missing required arguments:/, + 'Should get error for missing params'; + +# Pass both name and URI. +$uri = URI::db->new('db:pg://hi:there@localhost/blah'), +isa_ok $target = $CLASS->new( + sqitch => $sqitch, + name => 'foo', + uri => $uri, + variables => {a => 1}, +), $CLASS, 'Target with name and URI'; + +is $target->name, 'foo', 'Name should be "foo"'; +is $target->target, $target->name, 'Target should be alias for name'; +is $target->uri, $uri, 'URI should be set as passed'; +is $target->sqitch, $sqitch, 'Sqitch should be as passed'; +is $target->engine_key, 'pg', 'Engine key should be "pg"'; +isa_ok $target->engine, 'App::Sqitch::Engine::pg', 'Engine'; +is $target->dsn, $uri->dbi_dsn, 'DSN should be from URI'; +is $target->username, 'hi', 'Username should be from URI'; +do { + local $ENV{SQITCH_PASSWORD} = 'lolz'; + is $target->password, 'lolz', 'Password should be from environment'; +}; +is_deeply $target->variables, {a => 1}, 'Variables should be set'; + +# Pass a URI but no name. +isa_ok $target = $CLASS->new( + sqitch => $sqitch, + uri => $uri, +), $CLASS, 'Target with URI'; +like $target->name, qr{db:pg://hi:?\@localhost/blah}, + 'Name should be URI without password'; +is $target->target, $target->name, 'Target should be alias for name'; +is $target->engine_key, 'pg', 'Engine key should be "pg"'; +isa_ok $target->engine, 'App::Sqitch::Engine::pg', 'Engine'; +is $target->dsn, $uri->dbi_dsn, 'DSN should be from URI'; +is $target->username, $uri->user, 'Username should be from URI'; +is $target->password, $uri->password, 'Password should be from URI'; + +# Set the URI via SQITCH_TARGET. +ENV: { + local $ENV{SQITCH_TARGET} = 'db:pg:'; + isa_ok my $target = $CLASS->new(sqitch => $sqitch), $CLASS, + 'Target from environment'; + is $target->name, 'db:pg:', 'Name should be set'; + is $target->uri, 'db:pg:', 'URI should be set'; + is $target->engine_key, 'pg', 'Engine key should be "pg"'; + isa_ok $target->engine, 'App::Sqitch::Engine::pg', 'Engine'; +} + +# Set up a config. +CONSTRUCTOR: { + my (@get_params, $orig_get); + my $mock = TestConfig->mock( + get => sub { my $c = shift; push @get_params => \@_; $orig_get->($c, @_); } + ); + $orig_get = $mock->original('get'); + $config->replace('core.engine' => 'sqlite'); + + # Pass neither, but rely on the engine in the Sqitch object. + my $sqitch = App::Sqitch->new(config => $config); + isa_ok my $target = $CLASS->new(sqitch => $sqitch), $CLASS, 'Default target'; + is $target->name, 'db:sqlite:', 'Name should be "db:sqlite:"'; + is $target->uri, URI::db->new('db:sqlite:'), 'URI should be "db:sqlite:"'; + is_deeply \@get_params, [ + [key => 'core.target'], + [key => 'core.engine'], + [key => 'engine.sqlite.target'], + ], 'Should have tried to get engine target'; + + # Try with just core.engine. + delete $sqitch->options->{engine}; + $config->update('core.engine' => 'mysql'); + @get_params = (); + isa_ok $target = $CLASS->new(sqitch => $sqitch), $CLASS, 'Default target'; + is $target->name, 'db:mysql:', 'Name should be "db:mysql:"'; + is $target->uri, URI::db->new('db:mysql:'), 'URI should be "db:mysql"'; + is_deeply \@get_params, [ + [key => 'core.target'], + [key => 'core.engine'], + [key => 'engine.mysql.target'], + ], 'Should have tried to get core.target, core.engine and then the target'; + + # Try with no engine option but a name that looks like a URI. + @get_params = (); + delete $sqitch->options->{engine}; + isa_ok $target = $CLASS->new( + sqitch => $sqitch, + name => 'db:pg:', + ), $CLASS, 'Target with URI in name'; + is $target->name, 'db:pg:', 'Name should be "db:pg:"'; + is $target->uri, URI::db->new('db:pg:'), 'URI should be "db:pg"'; + is_deeply \@get_params, [], 'Should have fetched no config'; + + # Try it with a name with no engine. + throws_ok { $CLASS->new(sqitch => $sqitch, name => 'db:') } 'App::Sqitch::X', + 'Should have error for no engine in URI'; + is $@->ident, 'target', 'Should have target ident'; + is $@->message, __x( + 'No engine specified by URI {uri}; URI must start with "db:$engine:"', + uri => 'db:', + ), 'Should have message about no engine-less URI'; + + # Try it with no configured core engine or target. + $config->replace; + throws_ok { $CLASS->new(sqitch => $sqitch) } 'App::Sqitch::X', + 'Should have error for no engine or target'; + is $@->ident, 'target', 'Should have target ident'; + is $@->message, __( + 'No project configuration found. Run the "init" command to initialize a project', + ), 'Should have message about no configuration'; + + # Try it with a config file but no engine config. + MOCK: { + my $mock_init = TestConfig->mock(initialized => 1); + throws_ok { $CLASS->new(sqitch => $sqitch) } 'App::Sqitch::X', + 'Should again have error for no engine or target'; + is $@->ident, 'target', 'Should have target ident again'; + is $@->message, __( + 'No engine specified; specify via target or core.engine', + ), 'Should have message about no specified engine'; + } + + # Try with engine-less URI. + @get_params = (); + isa_ok $target = $CLASS->new( + sqitch => $sqitch, + uri => URI::db->new('db:'), + ), $CLASS, 'Engineless target'; + is $target->name, 'db:', 'Name should be "db:"'; + is $target->uri, URI::db->new('db:'), 'URI should be "db:"'; + is_deeply \@get_params, [], 'Should not have tried to get engine target'; + + is $target->sqitch, $sqitch, 'Sqitch should be as passed'; + is $target->engine_key, undef, 'Engine key should be undef'; + throws_ok { $target->engine } 'App::Sqitch::X', + 'Should get exception for no engine'; + is $@->ident, 'engine', 'Should have engine ident'; + is $@->message, __( + 'No engine specified; specify via target or core.engine', + ), 'Should have message about no engine'; + + is $target->top_dir, dir, 'Should have default top_dir'; + is $target->deploy_dir, $target->top_dir->subdir('deploy'), + 'Should have default deploy_dir'; + is $target->revert_dir, $target->top_dir->subdir('revert'), + 'Should have default revert_dir'; + is $target->verify_dir, $target->top_dir->subdir('verify'), + 'Should have default verify_dir'; + is $target->reworked_dir, $target->top_dir, 'Should have default reworked_dir'; + is $target->reworked_deploy_dir, $target->reworked_dir->subdir('deploy'), + 'Should have default reworked_deploy_dir'; + is $target->reworked_revert_dir, $target->reworked_dir->subdir('revert'), + 'Should have default reworked_revert_dir'; + is $target->reworked_verify_dir, $target->reworked_dir->subdir('verify'), + 'Should have default reworked_verify_dir'; + is $target->extension, 'sql', 'Should have default extension'; + is $target->plan_file, $target->top_dir->file('sqitch.plan')->cleanup, + 'Should have default plan file'; + isa_ok $target->plan, 'App::Sqitch::Plan', 'Should get plan'; + is $target->plan->file, $target->plan_file, + 'Plan file should be copied from Target'; + is $target->dsn, '', 'DSN should be empty'; + is $target->username, undef, 'Username should be undef'; + is $target->password, undef, 'Password should be undef'; + + # Try passing a proper URI via the name. + @get_params = (); + isa_ok $target = $CLASS->new(sqitch => $sqitch, name => 'db:pg://a:b@foo/scat'), $CLASS, + 'Engine URI target'; + like $target->name, qr{db:pg://a:?\@foo/scat}, 'Name should be "db:pg://a@foo/scat"'; + is $target->uri, URI::db->new('db:pg://a:b@foo/scat'), + 'URI should be "db:pg://a:b@foo/scat"'; + is_deeply \@get_params, [], 'Nothing should have been fetched from config'; + + # Pass nothing, but let a URI be in core.target. + @get_params = (); + $config->update('core.target' => 'db:pg://s:b@ack/shi'); + isa_ok $target = $CLASS->new(sqitch => $sqitch), $CLASS, + 'Engine URI core.target'; + like $target->name, qr{db:pg://s:?\@ack/shi}, 'Name should be "db:pg://s@ack/shi"'; + is $target->uri, URI::db->new('db:pg://s:b@ack/shi'), + 'URI should be "db:pg://s:b@ack/shi"'; + is_deeply \@get_params, [[key => 'core.target']], + 'Should have fetched core.target from config'; + + # Pass nothing, but let a target name be in core.target. + @get_params = (); + $config->update( + 'core.target' => 'shout', + 'target.shout.uri' => 'db:pg:w:e@we/bar', + ); + isa_ok $target = $CLASS->new(sqitch => $sqitch), $CLASS, + 'Engine name core.target'; + is $target->name, 'shout', 'Name should be "shout"'; + is $target->uri, URI::db->new('db:pg:w:e@we/bar'), + 'URI should be "db:pg:w:e@we/bar"'; + is_deeply \@get_params, [ + [key => 'core.target'], + [key => 'target.shout.uri'] + ], 'Should have fetched target.shout.uri from config'; + + # Mock get_section. + my (@sect_params, $orig_sect); + $mock->mock(get_section => sub { + my $c = shift; push @sect_params => \@_; $orig_sect->($c, @_); + }); + $orig_sect = $mock->original('get_section'); + + # Try it with a name. + $sqitch->options->{engine} = 'sqlite'; + @get_params = (); + throws_ok { $CLASS->new(sqitch => $sqitch, name => 'foo') } 'App::Sqitch::X', + 'Should have exception for unknown named target'; + is $@->ident, 'target', 'Unknown target error ident should be "target"'; + is $@->message, __x( + 'Cannot find target "{target}"', + target => 'foo', + ), 'Unknown target error message should be correct'; + is_deeply \@get_params, [[key => 'target.foo.uri']], + 'Should have requested target URI from config'; + is_deeply \@sect_params, [[section => 'target.foo']], + 'Should have requested target.foo section'; + + # Let the name section exist, but without a URI. + @get_params = @sect_params = (); + $config->replace('target.foo.bar' => 1); + throws_ok { $CLASS->new(sqitch => $sqitch, name => 'foo') } 'App::Sqitch::X', + 'Should have exception for URL-less named target'; + is $@->ident, 'target', 'URL-less target error ident should be "target"'; + is $@->message, __x( + 'No URI associated with target "{target}"', + target => 'foo', + ), 'URL-less target error message should be correct'; + is_deeply \@get_params, [[key => 'target.foo.uri']], + 'Should have requested target URI from config'; + is_deeply \@sect_params, [[section => 'target.foo']], + 'Should have requested target.foo section'; + + # Now give it a URI. + @get_params = @sect_params = (); + $config->replace( 'target.foo.uri' => 'db:pg:foo'); + $sqitch = App::Sqitch->new(config => $config); + isa_ok $target = $CLASS->new(sqitch => $sqitch, name => 'foo'), $CLASS, + 'Named target'; + is $target->name, 'foo', 'Name should be "foo"'; + is $target->uri, URI::db->new('db:pg:foo'), 'URI should be "db:pg:foo"'; + is_deeply \@get_params, [[key => 'target.foo.uri']], + 'Should have requested target URI from config'; + is_deeply \@sect_params, [], + 'Should not have requested deprecated pg section'; + + # Let the name be looked up by the engine. + @get_params = @sect_params = (); + $config->update( + 'core.target' => 'foo', + 'target.foo.uri' => 'db:sqlite:foo', + ); + isa_ok $target = $CLASS->new(sqitch => $sqitch), $CLASS, 'Engine named target'; + is $target->name, 'foo', 'Name should be "foo"'; + is $target->uri, URI::db->new('db:sqlite:foo'), 'URI should be "db:sqlite:foo"'; + is_deeply \@get_params, [ + [key => 'core.target'], + [key => 'target.foo.uri'] + ], 'Should have requested engine target and target URI from config'; + is_deeply \@sect_params, [], 'Should have requested no section'; + + # Let the name come from the environment. + ENV: { + @get_params = @sect_params = (); + $config->replace('target.bar.uri' => 'db:sqlite:bar'); + local $ENV{SQITCH_TARGET} = 'bar'; + isa_ok $target = $CLASS->new(sqitch => $sqitch), $CLASS, 'Environment-named target'; + is $target->name, 'bar', 'Name should be "bar"'; + is $target->uri, URI::db->new('db:sqlite:bar'), 'URI should be "db:sqlite:bar"'; + is_deeply \@get_params, [[key => 'target.bar.uri']], + 'Should have requested target URI from config'; + is_deeply \@sect_params, [], 'Should have requested no sections'; + } + + # Make sure uri params work. + @get_params = @sect_params = (); + $config->replace('core.engine' => 'pg'); + $uri = URI::db->new('db:pg://fred@foo.com:12245/widget'); + isa_ok $target = $CLASS->new( + sqitch => $sqitch, + host => 'foo.com', + port => 12245, + user => 'fred', + dbname => 'widget', + ), $CLASS, 'URI-munged target'; + is_deeply \@sect_params, [], 'Should have requested no section'; + like $target->name, qr{db:pg://fred:?\@foo.com:12245/widget}, + 'Name should be passwordless stringified URI'; + is $target->uri, $uri, 'URI should be tweaked by URI params'; + + # URI params should work when URI read from target config. + $uri = URI::db->new('db:pg://foo.com/widget'); + @get_params = @sect_params = (); + $sqitch->options->{db_host} = 'foo.com'; + $sqitch->options->{db_name} = 'widget'; + $config->update('target.foo.uri' => 'db:pg:'); + isa_ok $target = $CLASS->new( + sqitch => $sqitch, + name => 'foo', + host => 'foo.com', + dbname => 'widget', + ), $CLASS, 'Foo target'; + is_deeply \@get_params, [ [key => 'target.foo.uri' ]], + 'Should have requested target URI'; + is_deeply \@sect_params, [], 'Should have fetched no section'; + is $target->name, 'foo', 'Name should be as passed'; + is $target->uri, $uri, 'URI should be tweaked by URI params'; + + # URI params should work when URI passsed. + $uri = URI::db->new('db:pg://:1919/'); + @get_params = @sect_params = (); + $sqitch->options->{db_host} = 'foo.com'; + $sqitch->options->{db_name} = 'widget'; + isa_ok $target = $CLASS->new( + sqitch => $sqitch, + name => 'db:pg:widget', + host => '', + dbname => '', + port => 1919, + ), $CLASS, 'URI target'; + is_deeply \@get_params, [], 'Should have requested no config'; + is_deeply \@sect_params, [], 'Should have fetched no section'; + is $target->name, $uri, 'Name should tweaked by URI params'; + is $target->uri, $uri, 'URI should be tweaked by URI params'; +} + +CONFIG: { + # Look at how attributes are populated from options, config. + my $opts = {}; + $config->replace( + 'core.engine' => 'pg', + 'core.registry' => 'myreg', + 'core.client' => 'pgsql', + 'core.plan_file' => 'my.plan', + 'core.top_dir' => 'top', + 'core.deploy_dir' => 'dep', + 'core.revert_dir' => 'rev', + 'core.verify_dir' => 'ver', + 'core.reworked_dir' => 'wrk', + 'core.reworked_deploy_dir' => 'rdep', + 'core.reworked_revert_dir' => 'rrev', + 'core.reworked_verify_dir' => 'rver', + 'core.extension' => 'ddl', + ); + my $sqitch = App::Sqitch->new(options => $opts, config => $config); + my $target = $CLASS->new( + sqitch => $sqitch, + name => 'foo', + uri => URI::db->new('db:pg:foo'), + ); + + is $target->registry, 'myreg', 'Registry should be "myreg"'; + is $target->client, 'pgsql', 'Client should be "pgsql"'; + is $target->plan_file, 'my.plan', 'Plan file should be "my.plan"'; + isa_ok $target->plan_file, 'Path::Class::File', 'Plan file'; + isa_ok my $plan = $target->plan, 'App::Sqitch::Plan', 'Plan'; + is $plan->file, $target->plan_file, 'Plan should use target plan file'; + is $target->top_dir, 'top', 'Top dir should be "top"'; + isa_ok $target->top_dir, 'Path::Class::Dir', 'Top dir'; + is $target->deploy_dir, 'dep', 'Deploy dir should be "dep"'; + isa_ok $target->deploy_dir, 'Path::Class::Dir', 'Deploy dir'; + is $target->revert_dir, 'rev', 'Revert dir should be "rev"'; + isa_ok $target->revert_dir, 'Path::Class::Dir', 'Revert dir'; + is $target->verify_dir, 'ver', 'Verify dir should be "ver"'; + isa_ok $target->verify_dir, 'Path::Class::Dir', 'Verify dir'; + is $target->reworked_dir, 'wrk', 'Reworked dir should be "wrk"'; + isa_ok $target->reworked_dir, 'Path::Class::Dir', 'Reworked dir'; + is $target->reworked_deploy_dir, 'rdep', 'Reworked deploy dir should be "rdep"'; + isa_ok $target->reworked_deploy_dir, 'Path::Class::Dir', 'Reworked deploy dir'; + is $target->reworked_revert_dir, 'rrev', 'Reworked revert dir should be "rrev"'; + isa_ok $target->reworked_revert_dir, 'Path::Class::Dir', 'Reworked revert dir'; + is $target->reworked_verify_dir, 'rver', 'Reworked verify dir should be "rver"'; + isa_ok $target->reworked_verify_dir, 'Path::Class::Dir', 'Reworked verify dir'; + is $target->extension, 'ddl', 'Extension should be "ddl"'; + is_deeply $target->variables, {}, 'Should have no variables'; + + # Add engine config. + $config->update( + 'engine.pg.registry' => 'yoreg', + 'engine.pg.client' => 'mycli', + 'engine.pg.plan_file' => 'pg.plan', + 'engine.pg.top_dir' => 'pg', + 'engine.pg.deploy_dir' => 'pgdep', + 'engine.pg.revert_dir' => 'pgrev', + 'engine.pg.verify_dir' => 'pgver', + 'engine.pg.reworked_dir' => 'pg/r', + 'engine.pg.reworked_deploy_dir' => 'pgrdep', + 'engine.pg.reworked_revert_dir' => 'pgrrev', + 'engine.pg.reworked_verify_dir' => 'pgrver', + 'engine.pg.extension' => 'pgddl', + 'engine.pg.variables' => { x => 'ex', y => 'why', z => 'zee' }, + ); + $target = $CLASS->new( + sqitch => $sqitch, + name => 'foo', + uri => URI::db->new('db:pg:foo'), + ); + + is $target->registry, 'yoreg', 'Registry should be "yoreg"'; + is $target->client, 'mycli', 'Client should be "mycli"'; + is $target->plan_file, 'pg.plan', 'Plan file should be "pg.plan"'; + isa_ok $target->plan_file, 'Path::Class::File', 'Plan file'; + isa_ok $plan = $target->plan, 'App::Sqitch::Plan', 'Plan'; + is $plan->file, $target->plan_file, 'Plan should use target plan file'; + is $target->top_dir, 'pg', 'Top dir should be "pg"'; + isa_ok $target->top_dir, 'Path::Class::Dir', 'Top dir'; + is $target->deploy_dir, 'pgdep', 'Deploy dir should be "pgdep"'; + isa_ok $target->deploy_dir, 'Path::Class::Dir', 'Deploy dir'; + is $target->revert_dir, 'pgrev', 'Revert dir should be "pgrev"'; + isa_ok $target->revert_dir, 'Path::Class::Dir', 'Revert dir'; + is $target->verify_dir, 'pgver', 'Verify dir should be "pgver"'; + isa_ok $target->verify_dir, 'Path::Class::Dir', 'Verify dir'; + is $target->reworked_dir, dir('pg/r'), 'Reworked dir should be "pg/r"'; + isa_ok $target->reworked_dir, 'Path::Class::Dir', 'Reworked dir'; + is $target->reworked_deploy_dir, 'pgrdep', 'Reworked deploy dir should be "pgrdep"'; + isa_ok $target->reworked_deploy_dir, 'Path::Class::Dir', 'Reworked deploy dir'; + is $target->reworked_revert_dir, 'pgrrev', 'Reworked revert dir should be "pgrrev"'; + isa_ok $target->reworked_revert_dir, 'Path::Class::Dir', 'Reworked revert dir'; + is $target->reworked_verify_dir, 'pgrver', 'Reworked verify dir should be "pgrver"'; + isa_ok $target->reworked_verify_dir, 'Path::Class::Dir', 'Reworked verify dir'; + is $target->extension, 'pgddl', 'Extension should be "pgddl"'; + is_deeply $target->variables, {x => 'ex', y => 'why', z => 'zee'}, + 'Variables should be read from engine.variables'; + + # Add target config. + $config->update( + 'target.foo.registry' => 'fooreg', + 'target.foo.client' => 'foocli', + 'target.foo.plan_file' => 'foo.plan', + 'target.foo.top_dir' => 'foo', + 'target.foo.deploy_dir' => 'foodep', + 'target.foo.revert_dir' => 'foorev', + 'target.foo.verify_dir' => 'foover', + 'target.foo.reworked_dir' => 'foo/r', + 'target.foo.reworked_deploy_dir' => 'foodepr', + 'target.foo.reworked_revert_dir' => 'foorevr', + 'target.foo.reworked_verify_dir' => 'fooverr', + 'target.foo.extension' => 'fooddl', + 'engine.pg.variables' => { z => 'zie', a => 'ay' }, + ); + $target = $CLASS->new( + sqitch => $sqitch, + name => 'foo', + uri => URI::db->new('db:pg:foo'), + ); + + is $target->registry, 'fooreg', 'Registry should be "fooreg"'; + is $target->client, 'foocli', 'Client should be "foocli"'; + is $target->plan_file, 'foo.plan', 'Plan file should be "foo.plan"'; + isa_ok $target->plan_file, 'Path::Class::File', 'Plan file'; + isa_ok $plan = $target->plan, 'App::Sqitch::Plan', 'Plan'; + is $plan->file, $target->plan_file, 'Plan should use target plan file'; + is $target->top_dir, 'foo', 'Top dir should be "foo"'; + isa_ok $target->top_dir, 'Path::Class::Dir', 'Top dir'; + is $target->deploy_dir, 'foodep', 'Deploy dir should be "foodep"'; + isa_ok $target->deploy_dir, 'Path::Class::Dir', 'Deploy dir'; + is $target->revert_dir, 'foorev', 'Revert dir should be "foorev"'; + isa_ok $target->revert_dir, 'Path::Class::Dir', 'Revert dir'; + is $target->verify_dir, 'foover', 'Verify dir should be "foover"'; + isa_ok $target->verify_dir, 'Path::Class::Dir', 'Verify dir'; + is $target->reworked_dir, dir('foo/r'), 'Reworked dir should be "foo/r"'; + isa_ok $target->reworked_dir, 'Path::Class::Dir', 'Reworked dir'; + is $target->reworked_deploy_dir, 'foodepr', 'Reworked deploy dir should be "foodepr"'; + isa_ok $target->reworked_deploy_dir, 'Path::Class::Dir', 'Reworked deploy dir'; + is $target->reworked_revert_dir, 'foorevr', 'Reworked revert dir should be "foorevr"'; + isa_ok $target->reworked_revert_dir, 'Path::Class::Dir', 'Reworked revert dir'; + is $target->reworked_verify_dir, 'fooverr', 'Reworked verify dir should be "fooverr"'; + isa_ok $target->reworked_verify_dir, 'Path::Class::Dir', 'Reworked verify dir'; + is $target->extension, 'fooddl', 'Extension should be "fooddl"'; + is_deeply $target->variables, {x => 'ex', y => 'why', z => 'zie', a => 'ay'}, + 'Variables should be read from engine., and target.variables'; +} + +sub _load($) { + my $config = App::Sqitch::Config->new; + $config->load_file(file 't', "$_[0].conf"); + return $config; +} + +ALL: { + # Let's test loading all targets. Start with only core. + my $config = TestConfig->from(local => file qw(t core.conf) ); + my $sqitch = App::Sqitch->new(config => $config); + ok my @targets = $CLASS->all_targets(sqitch => $sqitch), 'Load all targets'; + is @targets, 1, 'Should have one target'; + is $targets[0]->name, 'db:pg:', + 'It should be the generic core engine target'; + + # Now load one with a core target defined. + $config = TestConfig->from(local => file qw(t core_target.conf) ); + $sqitch = App::Sqitch->new(config => $config); + ok @targets = $CLASS->all_targets(sqitch => $sqitch), + 'Load all targets with core target config'; + is @targets, 1, 'Should again have one target'; + is $targets[0]->name, 'db:pg:whatever', 'It should be the named target'; + is_deeply $targets[0]->variables, {}, 'It should have no variables'; + + # Try it with both engine and target defined. + $sqitch->config->load_file(file 't', 'core.conf'); + ok @targets = $CLASS->all_targets(sqitch => $sqitch), + 'Load all targets with core engine and target config'; + is @targets, 1, 'Should still have one target'; + is $targets[0]->name, 'db:pg:whatever', 'It should again be the named target'; + is_deeply $targets[0]->variables, {}, 'It should have no variables'; + + # Great, now let's load one with some engines in it. + $config = TestConfig->from(local => file qw(t user.conf) ); + $sqitch = App::Sqitch->new(config => $config); + ok @targets = $CLASS->all_targets(sqitch => $sqitch), 'Load all user conf targets'; + is @targets, 4, 'Should have four user targets'; + is_deeply [ sort map { $_->name } @targets ], [ + 'db:firebird:', + 'db:mysql:', + 'db:pg://postgres@localhost/thingies', + 'db:sqlite:my.db', + ], 'Should have all the engine targets'; + my $mysql = first { $_->name eq 'db:mysql:' } @targets; + is_deeply $mysql->variables, {prefix => 'foo_'}, + 'MySQL target should have engine variables'; + + # Load one with targets. + $config = TestConfig->from(local => file qw(t target.conf) ); + $sqitch = App::Sqitch->new(config => $config); + ok @targets = $CLASS->all_targets(sqitch => $sqitch), 'Load all target conf targets'; + is @targets, 4, 'Should have three targets'; + is $targets[0]->name, 'db:pg:', 'Core engine should be default target'; + is_deeply [ sort map { $_->name } @targets ], [qw(db:pg: dev prod qa)], + 'Should have the core target plus the named targets'; + + # Load one with engines and targets. + $config = TestConfig->from(local => file qw(t local.conf) ); + $sqitch = App::Sqitch->new(config => $config); + ok @targets = $CLASS->all_targets(sqitch => $sqitch), 'Load all local conf targets'; + is @targets, 2, 'Should have two local targets'; + is $targets[0]->name, 'mydb', 'Core engine should be lead to default target'; + is_deeply [ sort map { $_->name } @targets ], [qw(devdb mydb)], + 'Should have the core target plus the named targets'; + + # Mix up a core engine, engines, and targets. + $config = TestConfig->from(local => file qw(t engine.conf) ); + $sqitch = App::Sqitch->new(config => $config); + ok @targets = $CLASS->all_targets(sqitch => $sqitch), 'Load all engine conf targets'; + is @targets, 3, 'Should have three engine conf targets'; + is_deeply [ sort map { $_->name } @targets ], + [qw(db:mysql://root@/foo db:pg:try widgets)], + 'Should have the engine and target targets'; + + # Make sure parameters are set on all targets. + ok @targets = $CLASS->all_targets( + sqitch => $sqitch, + registry => 'quack', + dbname => 'w00t', + ), 'Overload all engine conf targets'; + is @targets, 3, 'Should again have three engine conf targets'; + is_deeply [ sort map { $_->uri->as_string } @targets ], + [qw(db:mysql://root@/w00t db:pg:w00t db:sqlite:w00t)], + 'Should have set dbname on all target URIs'; + is_deeply [ map { $_->registry } @targets ], [('quack') x 3], + 'Should have set the registry on all targets.'; +} + +done_testing; diff --git a/t/target_cmd.t b/t/target_cmd.t new file mode 100644 index 00000000..2770673f --- /dev/null +++ b/t/target_cmd.t @@ -0,0 +1,766 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 243; +#use Test::More 'no_plan'; +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use Test::Exception; +use Test::Warn; +use Test::Dir; +use Test::File qw(file_not_exists_ok file_exists_ok); +use Test::NoWarnings; +use File::Copy; +use Path::Class; +use File::Temp 'tempdir'; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::target'; + +############################################################################## +# Set up a test directory and config file. +my $tmp_dir = tempdir CLEANUP => 1; + +File::Copy::copy file(qw(t target.conf))->stringify, "$tmp_dir" + or die "Cannot copy t/target.conf to $tmp_dir: $!\n"; +File::Copy::copy file(qw(t engine sqitch.plan))->stringify, "$tmp_dir" + or die "Cannot copy t/engine/sqitch.plan to $tmp_dir: $!\n"; +chdir $tmp_dir; +my $psql = 'psql' . (App::Sqitch::ISWIN ? '.exe' : ''); + +############################################################################## +# Load a target command and test the basics. +my $config = TestConfig->from(local => 'target.conf'); +ok my $sqitch = App::Sqitch->new(config => $config), + 'Load a sqitch sqitch object'; +isa_ok my $cmd = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'target', + config => $config, +}), $CLASS, 'Target command'; +isa_ok $cmd, 'App::Sqitch::Command', 'Target command'; + +can_ok $cmd, qw( + options + configure + execute + list + add + remove + rename + rm + show + does +); + +ok $CLASS->does("App::Sqitch::Role::TargetConfigCommand"), + "$CLASS does TargetConfigCommand"; + +is_deeply [$CLASS->options], [qw( + uri=s + plan-file|f=s + registry=s + client=s + extension=s + top-dir=s + dir|d=s% + set|s=s% +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +############################################################################## +# Test configure(). +is_deeply $cmd->properties, {}, 'Default properties should be empty'; + +# Make sure configure ignores config file. +is_deeply $CLASS->configure({ foo => 'bar'}, {}), { properties => {} }, + 'configure() should ignore config file'; + +# Check default property values. +ok my $conf = $CLASS->configure($config, { + top_dir => 'top', + plan_file => 'my.plan', + registry => 'bats', + client => 'cli', + extension => 'ddl', + uri => 'db:pg:foo', + dir => { + deploy => 'dep', + revert => 'rev', + verify => 'ver', + reworked => 'wrk', + reworked_deploy => 'rdep', + reworked_revert => 'rrev', + reworked_verify => 'rver', + }, + set => { + foo => 'bar', + prefix => 'x_', + }, +}), 'Get full config'; + +is_deeply $conf->{properties}, { + top_dir => 'top', + plan_file => 'my.plan', + registry => 'bats', + client => 'cli', + extension => 'ddl', + uri => URI->new('db:pg:foo'), + deploy_dir => 'dep', + revert_dir => 'rev', + verify_dir => 'ver', + reworked_dir => 'wrk', + reworked_deploy_dir => 'rdep', + reworked_revert_dir => 'rrev', + reworked_verify_dir => 'rver', + variables => { + foo => 'bar', + prefix => 'x_', + } +}, 'Should have properties'; +isa_ok $conf->{properties}{$_}, 'Path::Class::File', "$_ file attribute" for qw( + plan_file +); +isa_ok $conf->{properties}{$_}, 'Path::Class::Dir', "$_ directory attribute" for ( + 'top_dir', + 'reworked_dir', + map { ($_, "reworked_$_") } qw(deploy_dir revert_dir verify_dir) +); + +# Make sure invalid directories are ignored. +throws_ok { $CLASS->new($CLASS->configure({}, { + dir => { foo => 'bar' }, +})) } 'App::Sqitch::X', 'Should fail on invalid directory name'; +is $@->ident, 'target', 'Invalid directory ident should be "target"'; +is $@->message, __x( + 'Unknown directory name: {prop}', + prop => 'foo', +), 'The invalid directory messsage should be correct'; + +throws_ok { $CLASS->new($CLASS->configure({}, { + dir => { foo => 'bar', cavort => 'ha' }, +})) } 'App::Sqitch::X', 'Should fail on invalid directory names'; +is $@->ident, 'target', 'Invalid directories ident should be "target"'; +is $@->message, __x( + 'Unknown directory names: {props}', + props => 'cavort, foo', +), 'The invalid properties messsage should be correct'; + +############################################################################## +# Test list(). +ok $cmd->list, 'Run list()'; +is_deeply +MockOutput->get_emit, [['dev'], ['prod'], ['qa']], + 'The list of targets should have been output'; + +# Make it verbose. +isa_ok $cmd = $CLASS->new({ + sqitch => App::Sqitch->new( config => $config, options => { verbosity => 1 }) +}), $CLASS, 'Verbose engine'; +ok $cmd->list, 'Run verbose list()'; +is_deeply +MockOutput->get_emit, [ + ["dev\tdb:pg:widgets"], + ["prod\tdb:pg://prod.example.us/pr_widgets"], + ["qa\tdb:pg://qa.example.com/qa_widgets"] +], 'The list of targets and their URIs should have been output'; + +############################################################################## +# Test add(). +MISSINGARGS: { + # Test handling of no name. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; die 'USAGE' }); + throws_ok { $cmd->add } qr/USAGE/, + 'No name arg to add() should yield usage'; + is_deeply \@args, [$cmd], 'No args should be passed to usage'; + + @args = (); + throws_ok { $cmd->add('foo') } qr/USAGE/, + 'No URI arg to add() should yield usage'; + is_deeply \@args, [$cmd], 'No args should be passed to usage'; +} + +# Should die on existing key. +throws_ok { $cmd->add('dev', 'db:pg:') } 'App::Sqitch::X', + 'Should get error for existing target'; +is $@->ident, 'target', 'Existing target error ident should be "target"'; +is $@->message, __x( + 'Target "{target}" already exists', + target => 'dev' +), 'Existing target error message should be correct'; + +# Now add a new target. +dir_not_exists_ok $_ for qw(deploy revert verify); +ok $cmd->add('test', 'db:pg:test'), 'Add target "test"'; +dir_exists_ok $_ for qw(deploy revert verify); +$config->load; +is $config->get(key => 'target.test.uri'), 'db:pg:test', + 'Target "test" URI should have been set'; +for my $key (qw( + client + registry + top_dir + plan_file + deploy_dir + revert_dir + verify_dir + extension +)) { + is $config->get(key => "target.test.$key"), undef, + qq{Target "test" should have no $key set}; +} +is_deeply $config->get_section(section => 'target.test.variables'), {}, + qq{Target "test" should have no variables set} +; +# Try adding a target with a registry. +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { registry => 'meta' }, +}), $CLASS, 'Target with registry'; +ok $cmd->add('withreg', 'db:pg:withreg'), 'Add target "withreg"'; +$config->load; +is $config->get(key => 'target.withreg.uri'), 'db:pg:withreg', + 'Target "withreg" URI should have been set'; +is $config->get(key => 'target.withreg.registry'), 'meta', + 'Target "withreg" registry should have been set'; +for my $key (qw( + client + top_dir + plan_file + deploy_dir + revert_dir + verify_dir + extension +)) { + is $config->get(key => "target.withreg.$key"), undef, + qq{Target "test" should have no $key set}; + +} +is_deeply $config->get_section(section => 'target.withreg.variables'), {}, + qq{Target "withreg" should have no variables set}; + +# Try a client. +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { client => 'hi.exe' }, +}), $CLASS, 'Target with client'; +ok $cmd->add('withcli', 'db:pg:withcli'), 'Add target "withcli"'; +$config->load; +is $config->get(key => 'target.withcli.uri'), 'db:pg:withcli', + 'Target "withcli" URI should have been set'; +is $config->get(key => 'target.withcli.client'), 'hi.exe', + 'Target "withcli" should have client set'; +for my $key (qw( + registry + top_dir + plan_file + deploy_dir + revert_dir + verify_dir + extension +)) { + is $config->get(key => "target.withcli.$key"), undef, + qq{Target "withcli" should have no $key set}; +} +is_deeply $config->get_section(section => 'target.withcli.variables'), {}, + qq{Target "withcli" should have no variables set}; + +# Try both. +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { client => 'ack', registry => 'foo', variables => { a => 'y' } }, +}), $CLASS, 'Target with client and registry'; +ok $cmd->add('withboth', 'db:pg:withboth'), 'Add target "withboth"'; +$config->load; +is $config->get(key => 'target.withboth.uri'), 'db:pg:withboth', + 'Target "withboth" URI should have been set'; +is $config->get(key => 'target.withboth.registry'), 'foo', + 'Target "withboth" registry should have been set'; +is $config->get(key => 'target.withboth.client'), 'ack', + 'Target "withboth" should have client set'; +for my $key (qw( + top_dir + plan_file + deploy_dir + revert_dir + verify_dir + extension +)) { + is $config->get(key => "target.withboth.$key"), undef, + qq{Target "withboth" should have no $key set}; +} +is_deeply $config->get_section(section => 'target.withboth.variables'), {a => 'y'}, + qq{Target "withboth" should have variables set}; + +# Try all the properties. +my %props = ( + client => 'poo', + registry => 'reg', + top_dir => dir('top'), + plan_file => file('my.plan'), + deploy_dir => dir('dep'), + revert_dir => dir('rev'), + verify_dir => dir('ver'), + reworked_dir => dir('r'), + reworked_deploy_dir => dir('r/d'), + extension => 'ddl', + variables => { ay => 'first', Bee => 'second' }, +); +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { %props }, +}), $CLASS, 'Target with all properties'; +file_not_exists_ok 'my.plan'; +dir_not_exists_ok dir $_ for qw(top/deploy top/revert top/verify r/d r/revert r/verify); +ok $cmd->add('withall', 'db:pg:withall'), 'Add target "withall"'; +dir_exists_ok dir $_ for qw(top/deploy top/revert top/verify r/d r/revert r/verify); +file_exists_ok 'my.plan'; +$config->load; +is $config->get(key => "target.withall.uri"), 'db:pg:withall', + qq{Target "withall" should have uri set}; +while (my ($k, $v) = each %props) { + if ($k ne 'variables') { + is $config->get(key => "target.withall.$k"), $v, + qq{Target "withall" should have $k set}; + } else { + is_deeply $config->get_section(section => "target.withall.$k"), $v, + qq{Target "withall" should have $k set}; + } +} + +############################################################################## +# Test alter(). +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, +}), $CLASS, 'Target with no properties'; + +MISSINGARGS: { + # Test handling of no name. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; die 'USAGE' }); + throws_ok { $cmd->alter } qr/USAGE/, + 'No name arg to alter() should yield usage'; + is_deeply \@args, [$cmd], 'No args should be passed to usage'; +} + +# Should die on missing key. +throws_ok { $cmd->alter('nonesuch') } 'App::Sqitch::X', + 'Should get error for missing target'; +is $@->ident, 'target', 'Missing target error ident should be "target"'; +is $@->message, __x( + 'Missing Target "{target}"; use "{command}" to add it', + target => 'nonesuch', + command => 'add nonesuch $uri', +), 'Missing target error message should be correct'; + +# Should include the URI, if present, in the error message. +$cmd->properties->{uri} = URI::db->new('db:pg:'); +throws_ok { $cmd->alter('nonesuch') } 'App::Sqitch::X', + 'Should get error for missing target with URI'; +is $@->ident, 'target', 'Missing target with URI error ident should be "target"'; +is $@->message, __x( + 'Missing Target "{target}"; use "{command}" to add it', + target => 'nonesuch', + command => 'add nonesuch db:pg:', +), 'Missing target error message should include URI'; + + +# Try all the properties. +%props = ( + uri => URI->new('db:firebird:bar'), + client => 'argh', + registry => 'migrations', + top_dir => dir('fb'), + plan_file => file('fb.plan'), + deploy_dir => dir('fb/dep'), + revert_dir => dir('fb/rev'), + verify_dir => dir('fb/ver'), + reworked_dir => dir('fb/r'), + reworked_deploy_dir => dir('fb/r/d'), + extension => 'fbsql', + variables => { ay => 'x', ceee => 'third' }, +); +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { %props }, +}), $CLASS, 'Target with more properties'; +ok $cmd->alter('withall'), 'Alter target "withall"'; +$config->load; +while (my ($k, $v) = each %props) { + if ($k ne 'variables') { + is $config->get(key => "target.withall.$k"), $v, + qq{Target "withall" should have $k set}; + } else { + $v->{Bee} = 'second'; + is_deeply $config->get_section(section => "target.withall.$k"), $v, + qq{Target "withall" should have merged $k set}; + } +} + +# Try changing the top directory. +isa_ok $cmd = $CLASS->new({ + sqitch => $sqitch, + properties => { top_dir => dir 'big' }, +}), $CLASS, 'Target with new top_dir property'; +dir_not_exists_ok dir $_ for qw(big big/deploy big/revert big/verify); +ok $cmd->alter('withall'), 'Alter target "withall"'; +dir_exists_ok dir $_ for qw(big big/deploy big/revert big/verify); +$config->load; +is $config->get(key => 'target.withall.top_dir'), 'big', + 'The withall top_dir should have been set'; + +############################################################################## +# Test rename. +MISSINGARGS: { + # Test handling of no names. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; die 'USAGE' }); + throws_ok { $cmd->rename } qr/USAGE/, + 'No name args to rename() should yield usage'; + is_deeply \@args, [$cmd], 'No args should be passed to usage'; + + @args = (); + throws_ok { $cmd->rename('foo') } qr/USAGE/, + 'No second arg to rename() should yield usage'; + is_deeply \@args, [$cmd], 'No args should be passed to usage'; +} + +# Should get an error if the target does not exist. +throws_ok { $cmd->rename('nonexistent', 'existant' ) } 'App::Sqitch::X', + 'Should get error for nonexistent target'; +is $@->ident, 'target', 'Nonexistent target error ident should be "target"'; +is $@->message, __x( + 'Unknown target "{target}"', + target => 'nonexistent' +), 'Nonexistent target error message should be correct'; + +# Rename one that exists. +ok $cmd->rename('withboth', 'àlafois'), 'Rename'; +$config->load; +ok $config->get(key => "target.àlafois.uri"), + qq{Target "àlafois" should now be present}; +is $config->get(key => "target.àlafois.variables.a"), 'y', + qq{Target "àlafois" variables should now be present}; +is $config->get(key => "target.withboth.uri"), undef, + qq{Target "withboth" should no longer be present}; +is $config->get(key => "target.withboth.variables.a"), undef, + qq{Target "withboth" variables should be gone}; +is_deeply $config->get_section(section => "target.àlafois.variables"), { a => 'y' }, + qq{Target "àlafois" should have variables}; + +# Make sure we die on dependencies. +$config->group_set( $config->local_file, [ + {key => 'core.target', value => 'prod'}, + {key => 'engine.firebird.target', value => 'prod'}, +]); +$cmd->sqitch->config->load; +# Should get an error for a target with dependencies. +throws_ok { $cmd->rename('prod', 'fodder' ) } 'App::Sqitch::X', + 'Should get error renaming a target with dependencies'; +is $@->ident, 'target', 'Dependency target error ident should be "target"'; +is $@->message, __x( + q{Cannot rename target "{target}" because it's referenced by: {engines}}, + target => 'prod', + engines => 'core.target, engine.firebird.target', +), 'Dependency target error message should be correct'; + +# Should get no error removing a target with no variables. +ok $cmd->rename('test', 'funky'), 'Rename "test"'; +$config->load; +ok $config->get(key => "target.funky.uri"), + qq{Target "funky" should now be present}; +is $config->get(key => "target.test.uri"), undef, + qq{Target "test" should no longer be present}; +is_deeply $config->get_section(section => "target.funky.variables"), {}, + qq{Target "funcky" should have no variables}; + +############################################################################## +# Test remove. +MISSINGARGS: { + # Test handling of no names. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; die 'USAGE' }); + throws_ok { $cmd->remove } qr/USAGE/, + 'No name args to remove() should yield usage'; + is_deeply \@args, [$cmd], 'No args should be passed to usage'; +} + +# Should get an error if the target does not exist. +throws_ok { $cmd->remove('nonexistent', 'existant' ) } 'App::Sqitch::X', + 'Should get error for nonexistent target'; +is $@->ident, 'target', 'Nonexistent target error ident should be "target"'; +is $@->message, __x( + 'Unknown target "{target}"', + target => 'nonexistent' +), 'Nonexistent target error message should be correct'; + +# Remove one that exists. +ok $cmd->remove('àlafois'), 'Remove'; +$config->load; +is $config->get(key => "target.àlafois.uri"), undef, + qq{Target "àlafois" should now be gone}; +is_deeply $config->get_section(section => "target.àlafois.variables"), {}, + qq{Target "àlafois" variables should be gone, too}; + +throws_ok { $cmd->remove('prod' ) } 'App::Sqitch::X', + 'Should get error removing a target with dependencies'; +is $@->ident, 'target', 'Dependency target error ident should be "target"'; +is $@->message, __x( + q{Cannot rename target "{target}" because it's referenced by: {engines}}, + target => 'prod', + engines => 'core.target, engine.firebird.target', +), 'Dependency target error message should be correct'; + +# Remove one without variables, too. +ok $cmd->remove('funky'), 'Remove "funky"'; +$config->load; +is $config->get(key => "target.funky.uri"), undef, + qq{Target "funky" should now be gone}; + +############################################################################## +# Test show. +ok $cmd->show, 'Run show()'; +is_deeply +MockOutput->get_emit, [ + ['dev'], ['prod'], ['qa'], ['withall'], ['withcli'], ['withreg'] +], 'Show with no names should emit the list of targets'; + +# Try one target. +ok $cmd->show('dev'), 'Show dev'; +is_deeply +MockOutput->get_emit, [ + ['* dev'], + [' ', 'URI: ', 'db:pg:widgets'], + [' ', 'Registry: ', 'sqitch'], + [' ', 'Client: ', $psql], + [' ', 'Top Directory: ', '.'], + [' ', 'Plan File: ', 'sqitch.plan'], + [' ', 'Extension: ', 'sql'], + [' ', 'Script Directories:'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'Reworked Script Directories:'], + [' ', ' Reworked: ', '.'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'No Variables'], +], 'The "dev" target should have been shown'; + +# Try a target with a non-default client. +ok $cmd->show('withcli'), 'Show withcli'; +is_deeply +MockOutput->get_emit, [ + ['* withcli'], + [' ', 'URI: ', 'db:pg:withcli'], + [' ', 'Registry: ', 'sqitch'], + [' ', 'Client: ', 'hi.exe'], + [' ', 'Top Directory: ', '.'], + [' ', 'Plan File: ', 'sqitch.plan'], + [' ', 'Extension: ', 'sql'], + [' ', 'Script Directories:'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'Reworked Script Directories:'], + [' ', ' Reworked: ', '.'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'No Variables'], +], 'The "with_cli" target should have been shown'; + +# Try a target with a non-default registry. +ok $cmd->show('withreg'), 'Show withreg'; +is_deeply +MockOutput->get_emit, [ + ['* withreg'], + [' ', 'URI: ', 'db:pg:withreg'], + [' ', 'Registry: ', 'meta'], + [' ', 'Client: ', $psql], + [' ', 'Top Directory: ', '.'], + [' ', 'Plan File: ', 'sqitch.plan'], + [' ', 'Extension: ', 'sql'], + [' ', 'Script Directories:'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'Reworked Script Directories:'], + [' ', ' Reworked: ', '.'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'No Variables'], +], 'The "withreg" target should have been shown'; + +# Try a target with variables. +ok $cmd->show('withall'), 'Show withall'; +#use Data::Dump; ddx +MockOutput->get_emit; +is_deeply +MockOutput->get_emit, [ + ['* withall'], + [' ', 'URI: ', 'db:firebird:bar'], + [' ', 'Registry: ', 'migrations'], + [' ', 'Client: ', 'argh'], + [' ', 'Top Directory: ', 'big'], + [' ', 'Plan File: ', 'fb.plan'], + [' ', 'Extension: ', 'fbsql'], + [' ', 'Script Directories:'], + [' ', ' Deploy: ', dir qw(fb dep)], + [' ', ' Revert: ', dir qw(fb rev)], + [' ', ' Verify: ', dir qw(fb ver)], + [' ', 'Reworked Script Directories:'], + [' ', ' Reworked: ', dir qw(fb r)], + [' ', ' Deploy: ', dir qw(fb r d)], + [' ', ' Revert: ', dir qw(fb r revert)], + [' ', ' Verify: ', dir qw(fb r verify)], + [' ', 'Variables:'], + [' ay: x'], + [' Bee: second'], + [' ceee: third'], +], 'The "withall" target should have been shown with variables'; + +# Try multiples. +ok $cmd->show(qw(dev qa withreg)), 'Show three targets'; +is_deeply +MockOutput->get_emit, [ + ['* dev'], + [' ', 'URI: ', 'db:pg:widgets'], + [' ', 'Registry: ', 'sqitch'], + [' ', 'Client: ', $psql], + [' ', 'Top Directory: ', '.'], + [' ', 'Plan File: ', 'sqitch.plan'], + [' ', 'Extension: ', 'sql'], + [' ', 'Script Directories:'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'Reworked Script Directories:'], + [' ', ' Reworked: ', '.'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'No Variables'], + ['* qa'], + [' ', 'URI: ', 'db:pg://qa.example.com/qa_widgets'], + [' ', 'Registry: ', 'meta'], + [' ', 'Client: ', '/usr/sbin/psql'], + [' ', 'Top Directory: ', '.'], + [' ', 'Plan File: ', 'sqitch.plan'], + [' ', 'Extension: ', 'sql'], + [' ', 'Script Directories:'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'Reworked Script Directories:'], + [' ', ' Reworked: ', '.'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'No Variables'], + ['* withreg'], + [' ', 'URI: ', 'db:pg:withreg'], + [' ', 'Registry: ', 'meta'], + [' ', 'Client: ', $psql], + [' ', 'Top Directory: ', '.'], + [' ', 'Plan File: ', 'sqitch.plan'], + [' ', 'Extension: ', 'sql'], + [' ', 'Script Directories:'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'Reworked Script Directories:'], + [' ', ' Reworked: ', '.'], + [' ', ' Deploy: ', 'deploy'], + [' ', ' Revert: ', 'revert'], + [' ', ' Verify: ', 'verify'], + [' ', 'No Variables'], +], 'All three targets should have been shown'; + +############################################################################## +# Test execute(). +isa_ok $cmd = $CLASS->new({ sqitch => $sqitch }), $CLASS, 'Simple target'; +for my $spec ( + [ undef, 'list' ], + [ 'list' ], + [ 'add' ], + [ 'remove' ], + [ 'rm', 'remove' ], + [ 'rename' ], + [ 'show' ], +) { + my ($arg, $meth) = @{ $spec }; + $meth //= $arg; + $meth =~ s/-/_/g; + my $mocker = Test::MockModule->new($CLASS); + my @args; + $mocker->mock($meth => sub { @args = @_ }); + ok $cmd->execute($spec->[0]), "Execute " . ($spec->[0] // 'undef'); + is_deeply \@args, [$cmd], "$meth() should have been called"; + + # Make sure args are passed. + ok $cmd->execute($spec->[0], qw(foo bar)), + "Execute " . ($spec->[0] // 'undef') . ' with args'; + is_deeply \@args, [$cmd, qw(foo bar)], + "$meth() should have been passed args"; +} + +# Make sure an invalid action dies with a usage statement. +MISSINGARGS: { + # Test handling of no names. + my $mock = Test::MockModule->new($CLASS); + my @args; + $mock->mock(usage => sub { @args = @_; die 'USAGE' }); + throws_ok { $cmd->execute('nonexistent') } qr/USAGE/, + 'Should get an exception for a nonexistent action'; + is_deeply \@args, [$cmd, __x( + 'Unknown action "{action}"', + action => 'nonexistent', + )], 'Nonexistent action message should be passed to usage'; +} + +############################################################################## +# Test URI validation. +for my $val ( + 'rock', + 'https://www.google.com/', +) { + my $uri = URI->new($val); + throws_ok { + $CLASS->new({ sqitch => $sqitch, properties => { uri => $uri } }) + } 'App::Sqitch::X', "Invalid URI $val should throw an error"; + is $@->ident, 'target', qq{Invalid URI $val error ident should be "target"}; + is $@->message, __x( + 'URI "{uri}" is not a database URI', + uri => $uri, + ), qq{Invalid URI $val error message should be correct}; +} + +my $uri = URI->new('db:'); +throws_ok { + $CLASS->new({ sqitch => $sqitch, properties => { uri => $uri } }) +} 'App::Sqitch::X', 'Engineless URI should throw an error'; +is $@->ident, 'target', 'Engineless URI error ident should be "target"'; +is $@->message, __x( + 'No database engine in URI "{uri}"', + uri => $uri, +), 'Engineless URI error message should be correct'; + +$uri = URI->new('db:nonesuch:foo'); +throws_ok { + $CLASS->new({ sqitch => $sqitch, properties => { uri => $uri } }) +} 'App::Sqitch::X', 'Unknown engine URI should throw an error'; +is $@->ident, 'target', 'Unknown engine URI error ident should be "target"'; +is $@->message, __x( + 'Unknown engine "{engine}" in URI "{uri}"', + uri => $uri, + engine => 'nonesuch', +), 'Unknown engine URI error message should be correct'; diff --git a/t/templates.conf b/t/templates.conf new file mode 100644 index 00000000..d5555fee --- /dev/null +++ b/t/templates.conf @@ -0,0 +1,9 @@ +[core] + engine = pg + +[add "templates"] + deploy = etc/templates/deploy/pg.tmpl + revert = etc/templates/revert/pg.tmpl + test = etc/templates/verify/pg.tmpl + verify = etc/templates/verify/pg.tmpl + diff --git a/t/upgrade.t b/t/upgrade.t new file mode 100644 index 00000000..e641a4df --- /dev/null +++ b/t/upgrade.t @@ -0,0 +1,118 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use utf8; +use Test::More tests => 25; +#use Test::More 'no_plan'; +use App::Sqitch; +use Locale::TextDomain qw(App-Sqitch); +use Test::NoWarnings; +use Test::Exception; +use Test::Warn; +use Test::MockModule; +use Path::Class; +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::upgrade'; +require_ok $CLASS; + +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.top_dir' => dir->new('test-upgrade')->stringify, +); +ok my $sqitch = App::Sqitch->new(config => $config), 'Load a sqitch object'; +isa_ok my $upgrade = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'upgrade', + config => $config, +}), $CLASS, 'upgrade command'; + +can_ok $upgrade, qw( + target + options + execute + configure + does +); + +ok $CLASS->does("App::Sqitch::Role::ConnectingCommand"), + "$CLASS does ConnectingCommand"; + +is_deeply [ $CLASS->options ], [qw( + target|t=s + registry=s + client|db-client=s + db-name|d=s + db-user|db-username|u=s + db-host|h=s + db-port|p=i +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +# Start with the engine up-to-date. +my $engine_mocker = Test::MockModule->new('App::Sqitch::Engine::sqlite'); +my $registry_version = App::Sqitch::Engine->registry_release; +my $upgrade_called = 0; +$engine_mocker->mock(registry_version => sub { $registry_version }); +$engine_mocker->mock(upgrade_registry => sub { $upgrade_called = 1 }); + +ok $upgrade->execute, 'Execute upgrade'; +ok !$upgrade_called, 'Upgrade should not have been called'; +is_deeply +MockOutput->get_info, [[__x( + 'Registry {registry} is up-to-date at version {version}', + registry => 'db:sqlite:', + version => App::Sqitch::Engine->registry_release, +)]], 'Should get output for up-to-date registry'; + +# Pass in a different target. +ok $upgrade->execute('db:sqlite:foo.db'), 'Execute upgrade with target'; +ok !$upgrade_called, 'Upgrade should again not have been called'; +is_deeply +MockOutput->get_info, [[__x( + 'Registry {registry} is up-to-date at version {version}', + registry => 'db:sqlite:sqitch.db', + version => App::Sqitch::Engine->registry_release, +)]], 'Should get output for up-to-date registry with target'; + +# Pass in an engine. +ok $upgrade->execute('sqlite'), 'Execute upgrade with engine'; +ok !$upgrade_called, 'Upgrade should again not have been called'; +is_deeply +MockOutput->get_info, [[__x( + 'Registry {registry} is up-to-date at version {version}', + registry => 'db:sqlite:', + version => App::Sqitch::Engine->registry_release, +)]], 'Should get output for up-to-date registry with target'; + +# Specify a target as an option. +isa_ok $upgrade = App::Sqitch::Command->load({ + sqitch => $sqitch, + command => 'upgrade', + config => $config, + args => [qw(--target db:sqlite:my.sqlite)], +}), $CLASS, 'upgrade command with target'; + +ok $upgrade->execute, 'Execute upgrade with target option'; +ok !$upgrade_called, 'Upgrade should still not have been called'; +is_deeply +MockOutput->get_info, [[__x( + 'Registry {registry} is up-to-date at version {version}', + registry => 'db:sqlite:sqitch.sqlite', + version => App::Sqitch::Engine->registry_release, +)]], 'Should get output for up-to-date registry with target option'; + +# Now make it upgrade. +$registry_version = 0.1; +ok $upgrade->execute, 'Execute upgrade with out-of-date registry'; +ok $upgrade_called, 'Upgrade should now have been called'; +is_deeply +MockOutput->get_info, [[__x( + 'Upgrading registry {registry} to version {version}', + registry => 'db:sqlite:sqitch.sqlite', + version => App::Sqitch::Engine->registry_release, +)]], 'Should get output for the upgrade'; diff --git a/t/user.conf b/t/user.conf new file mode 100644 index 00000000..e62f34b2 --- /dev/null +++ b/t/user.conf @@ -0,0 +1,24 @@ +[user] + name = Michael Stonebraker + email = michael@example.com + +[engine "pg"] + client = /opt/local/pgsql/bin/psql + target = db:pg://postgres@localhost/thingies + registry = meta + +[engine "mysql"] + client = /opt/local/mysql/bin/mysql + registry = meta + +[engine "mysql.variables"] + prefix = foo_ + +[engine "sqlite"] + client = /opt/local/bin/sqlite3 + registry = meta + target = db:sqlite:my.db + +[engine "firebird"] + client = /opt/firebird/bin/isql + registry = meta diff --git a/t/verify.t b/t/verify.t new file mode 100644 index 00000000..184e94a6 --- /dev/null +++ b/t/verify.t @@ -0,0 +1,298 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; +use 5.010; +use Test::More; +use App::Sqitch; +use App::Sqitch::Target; +use Path::Class qw(dir file); +use Test::MockModule; +use Test::Exception; +use Test::Warn; +use Locale::TextDomain qw(App-Sqitch); +use lib 't/lib'; +use MockOutput; +use TestConfig; + +my $CLASS = 'App::Sqitch::Command::verify'; +require_ok $CLASS or die; + +isa_ok $CLASS, 'App::Sqitch::Command'; +can_ok $CLASS, qw( + target + options + configure + new + from_change + to_change + variables + does +); + +ok $CLASS->does("App::Sqitch::Role::$_"), "$CLASS does $_" + for qw(ContextCommand ConnectingCommand); + +is_deeply [$CLASS->options], [qw( + target|t=s + from-change|from=s + to-change|to=s + set|s=s% + plan-file|f=s + top-dir=s + registry=s + client|db-client=s + db-name|d=s + db-user|db-username|u=s + db-host|h=s + db-port|p=i +)], 'Options should be correct'; + +warning_is { + Getopt::Long::Configure(qw(bundling pass_through)); + ok Getopt::Long::GetOptionsFromArray( + [], {}, App::Sqitch->_core_opts, $CLASS->options, + ), 'Should parse options'; +} undef, 'Options should not conflict with core options'; + +my $config = TestConfig->new( + 'core.engine' => 'sqlite', + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, + 'core.top_dir' => dir(qw(t sql))->stringify, +); +my $sqitch = App::Sqitch->new(config => $config); + +############################################################################## +# Test configure(). +is_deeply $CLASS->configure($config, {}), { + _params => [], + _cx => [], +}, 'Should have default configuration with no config or opts'; + +is_deeply $CLASS->configure($config, { + from_change => 'foo', + to_change => 'bar', + set => { foo => 'bar' }, +}), { + from_change => 'foo', + to_change => 'bar', + variables => { foo => 'bar' }, + _params => [], + _cx => [], +}, 'Should have changes and variables from options'; + +CONFIG: { + my $config = TestConfig->new( + 'verify.variables' => { foo => 'bar', hi => 21 }, + ); + is_deeply $CLASS->configure($config, {}), { _params => [], _cx => [] }, + 'Should have no config if no options'; +} + +############################################################################## +# Test construction. +isa_ok my $verify = $CLASS->new( + sqitch => $sqitch, + target => 'foo', +), $CLASS, 'new status with target'; +is $verify->target, 'foo', 'Should have target "foo"'; + +isa_ok $verify = $CLASS->new(sqitch => $sqitch), $CLASS; +is $verify->target, undef, 'Default target should be undef'; +is $verify->from_change, undef, 'from_change should be undef'; +is $verify->to_change, undef, 'to_change should be undef'; + +############################################################################## +# Test _collect_vars. +my $target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $verify->_collect_vars($target) }, {}, 'Should collect no variables'; + +# Add core variables. +$config->update('core.variables' => { prefix => 'widget', priv => 'SELECT' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $verify->_collect_vars($target) }, { + prefix => 'widget', + priv => 'SELECT', +}, 'Should collect core vars'; + +# Add deploy variables. +$config->update('deploy.variables' => { dance => 'salsa', priv => 'UPDATE' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $verify->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'salsa', +}, 'Should override core vars with deploy vars'; + +# Add verify variables. +$config->update('verify.variables' => { dance => 'disco', lunch => 'pizza' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch); +is_deeply { $verify->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'pizza', +}, 'Should override deploy vars with verify vars'; + +# Add engine variables. +$config->update('engine.pg.variables' => { lunch => 'burrito', drink => 'whiskey' }); +my $uri = URI::db->new('db:pg:'); +$target = App::Sqitch::Target->new(sqitch => $sqitch, uri => $uri); +is_deeply { $verify->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'burrito', + drink => 'whiskey', +}, 'Should override verify vars with engine vars'; + +# Add target variables. +$config->update('target.foo.variables' => { drink => 'scotch', status => 'winning' }); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $verify->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'winning', +}, 'Should override engine vars with target vars'; + +# Add --set variables. +$verify = $CLASS->new( + sqitch => $sqitch, + variables => { status => 'tired', herb => 'oregano' }, +); +$target = App::Sqitch::Target->new(sqitch => $sqitch, name => 'foo', uri => $uri); +is_deeply { $verify->_collect_vars($target) }, { + prefix => 'widget', + priv => 'UPDATE', + dance => 'disco', + lunch => 'burrito', + drink => 'scotch', + status => 'tired', + herb => 'oregano', +}, 'Should override target vars with --set variables'; + +$config->replace( + 'core.engine' => 'sqlite', + 'core.plan_file' => file(qw(t sql sqitch.plan))->stringify, + 'core.top_dir' => dir(qw(t sql))->stringify, +); +$verify = $CLASS->new( sqitch => $sqitch, no_prompt => 1); + +############################################################################## +# Test execution. +# Mock the engine interface. +my $mock_engine = Test::MockModule->new('App::Sqitch::Engine::sqlite'); +my @args; +$mock_engine->mock(verify => sub { shift; @args = @_ }); +my @vars; +$mock_engine->mock(set_variables => sub { shift; @vars = @_ }); + +ok $verify->execute, 'Execute with nothing.'; +is_deeply \@args, [undef, undef], + 'Two undefs should be passed to the engine'; +is_deeply +MockOutput->get_warn, [], 'Should have no warnings'; + +ok $verify->execute('@alpha'), 'Execute from "@alpha"'; +is_deeply \@args, ['@alpha', undef], + '"@alpha" and undef should be passed to the engine'; +is_deeply +MockOutput->get_warn, [], 'Should again have no warnings'; + +ok $verify->execute('@alpha', '@beta'), 'Execute from "@alpha" to "@beta"'; +is_deeply \@args, ['@alpha', '@beta'], + '"@alpha" and "@beat" should be passed to the engine'; +is_deeply +MockOutput->get_warn, [], 'Should still have no warnings'; + +isa_ok $verify = $CLASS->new( + sqitch => $sqitch, + from_change => 'foo', + to_change => 'bar', + variables => { foo => 'bar', one => 1 }, +), $CLASS, 'Object with from, to, and variables'; + +ok $verify->execute, 'Execute again'; +is_deeply \@args, ['foo', 'bar'], + '"foo" and "bar" should be passed to the engine'; +is_deeply {@vars}, { foo => 'bar', one => 1 }, + 'Vars should have been passed through to the engine'; +is_deeply +MockOutput->get_warn, [], 'Still should have no warnings'; + +# Pass and specify changes. +ok $verify->execute('roles', 'widgets'), 'Execute with command-line args'; +is_deeply \@args, ['foo', 'bar'], + '"foo" and "bar" should be passed to the engine'; +is_deeply {@vars}, { foo => 'bar', one => 1 }, + 'Vars should have been passed through to the engine'; +is_deeply +MockOutput->get_warn, [[__x( + 'Too many changes specified; verifying from "{from}" to "{to}"', + from => 'foo', + to => 'bar', +)]], 'Should have warning about which roles are used'; + +# Pass a target. +$target = 'db:pg:'; +my $mock_cmd = Test::MockModule->new(ref $verify); +my ($target_name_arg, $orig_meth); +$mock_cmd->mock(parse_args => sub { + my $self = shift; + my %p = @_; + my @ret = $self->$orig_meth(@_); + $target_name_arg = $ret[0][0]->name; + $ret[0][0] = $self->default_target; + return @ret; +}); +$orig_meth = $mock_cmd->original('parse_args'); + +ok $verify->execute($target), 'Execute with target arg'; +is $target_name_arg, $target, 'The target should have been passed to the engine'; +is_deeply \@args, ['foo', 'bar'], + '"foo" and "bar" should be passed to the engine'; +is_deeply {@vars}, { foo => 'bar', one => 1 }, + 'Vars should have been passed through to the engine'; +is_deeply +MockOutput->get_warn, [], 'Should once again have no warnings'; + +# Pass a --target option. +isa_ok $verify = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS, 'Object with target'; +$target_name_arg = undef; +@vars = (); +ok $verify->execute, 'Execute with no args'; +is $target_name_arg, $target, 'The target option should have been passed to the engine'; +is_deeply \@args, [undef, undef], 'Undefs should be passed to the engine'; +is_deeply {@vars}, {}, 'No vars should have been passed through to the engine'; +is_deeply +MockOutput->get_warn, [], 'Should once again have no warnings'; + +# Pass a target, get a warning. +ok $verify->execute('db:sqlite:', 'roles', 'widgets'), + 'Execute with two targegs and two changes'; +is $target_name_arg, $target, 'The target option should have been passed to the engine'; +is_deeply \@args, ['roles', 'widgets'], + 'The two changes should be passed to the engine'; +is_deeply {@vars}, {}, 'No vars should have been passed through to the engine'; +is_deeply +MockOutput->get_warn, [[__x( + 'Too many targets specified; connecting to {target}', + target => $verify->default_target->name, +)]], 'Should have warning about too many targets'; + +# Make sure we get an exception for unknown args. +throws_ok { $verify->execute(qw(greg)) } 'App::Sqitch::X', + 'Should get an exception for unknown arg'; +is $@->ident, 'verify', 'Unknow arg ident should be "verify"'; +is $@->message, __x( + 'Unknown argument "{arg}"', + arg => 'greg', +), 'Should get an exeption for two unknown arg'; + +throws_ok { $verify->execute(qw(greg jon)) } 'App::Sqitch::X', + 'Should get an exception for unknown args'; +is $@->ident, 'verify', 'Unknow args ident should be "verify"'; +is $@->message, __x( + 'Unknown arguments: {arg}', + arg => 'greg, jon', +), 'Should get an exeption for two unknown args'; + +done_testing; diff --git a/t/vertica.t b/t/vertica.t new file mode 100644 index 00000000..3b3e4c4d --- /dev/null +++ b/t/vertica.t @@ -0,0 +1,320 @@ +#!/usr/bin/perl -w + +# To test against a live Vertica database, you must set the VSQL_URI environment variable. +# this is a stanard URI::db URI, and should look something like this: +# +# export VSQL_URI=db:vertica://dbadmin:password@localhost:5433/dbadmin?Driver=Vertica +# +# Note that it must include the `?Driver=$driver` bit so that DBD::ODBC loads +# the proper driver. + +use strict; +use warnings; +use 5.010; +use Test::More 0.94; +use Test::MockModule; +use Test::Exception; +use Locale::TextDomain qw(App-Sqitch); +use Capture::Tiny 0.12 qw(:all); +use Try::Tiny; +use App::Sqitch; +use App::Sqitch::Target; +use App::Sqitch::Plan; +use lib 't/lib'; +use DBIEngineTest; +use TestConfig; + +my $CLASS; + +delete $ENV{"VSQL_$_"} for qw(USER PASSWORD DATABASE HOST PORT); + +BEGIN { + $CLASS = 'App::Sqitch::Engine::vertica'; + require_ok $CLASS or die; +} + +is_deeply [$CLASS->config_vars], [ + target => 'any', + registry => 'any', + client => 'any', +], 'config_vars should return three vars'; + +my $uri = URI::db->new('db:vertica:'); +my $config = TestConfig->new('core.engine' => 'vertica'); +my $sqitch = App::Sqitch->new(config => $config); +my $target = App::Sqitch::Target->new( + sqitch => $sqitch, + uri => $uri, +); +isa_ok my $vta = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS; + +is $vta->key, 'vertica', 'Key should be "vertica"'; +is $vta->name, 'Vertica', 'Name should be "Vertica"'; + +my $client = 'vsql' . (App::Sqitch::ISWIN ? '.exe' : ''); +is $vta->client, $client, 'client should default to vsql'; +is $vta->registry, 'sqitch', 'registry default should be "sqitch"'; +is $vta->uri, $uri, 'DB URI should be "db:vertica:"'; +my $dest_uri = $uri->clone; +$dest_uri->dbname($ENV{VSQL_DATABASE} || $ENV{VSQL_USER} || $sqitch->sysuser); +is $vta->destination, $dest_uri->as_string, + 'Destination should fall back on environment variables'; +is $vta->registry_destination, $vta->destination, + 'Registry destination should be the same as destination'; + +my @std_opts = ( + '--quiet', + '--no-vsqlrc', + '--no-align', + '--tuples-only', + '--set' => 'ON_ERROR_STOP=1', + '--set' => 'registry=sqitch', +); +is_deeply [$vta->vsql], [$client, '--username', $sqitch->sysuser, @std_opts], + 'vsql command should be username and std opts-only'; + +isa_ok $vta = $CLASS->new( + sqitch => $sqitch, + target => $target, +), $CLASS; +ok $vta->set_variables(foo => 'baz', whu => 'hi there', yo => 'stellar'), + 'Set some variables'; +is_deeply [$vta->vsql], [ + $client, + '--username', $sqitch->sysuser, + '--set' => 'foo=baz', + '--set' => 'whu=hi there', + '--set' => 'yo=stellar', + @std_opts, +], 'Variables should be passed to vsql via --set'; + +############################################################################## +# Test other configs for the target. +ENV: { + # Make sure we override system-set vars. + local $ENV{VSQL_DATABASE}; + local $ENV{VSQL_USER}; + local $ENV{VSQL_PASSWORD}; + for my $env (qw(VSQL_DATABASE VSQL_USER VSQL_PASSWORD)) { + my $vta = $CLASS->new(sqitch => $sqitch, target => $target); + local $ENV{$env} = "\$ENV=whatever"; + is $vta->target->name, "db:vertica:", "Target name should not read \$$env"; + is $vta->registry_destination, $vta->destination, + 'Registry target should be the same as destination'; + is $vta->username, $ENV{VSQL_USER} || $sqitch->sysuser, + "Should have username when $env set"; + is $vta->password, $ENV{VSQL_PASSWORD}, + "Should have password when $env set"; + } + + my $mocker = Test::MockModule->new('App::Sqitch'); + $mocker->mock(sysuser => 'sysuser=whatever'); + my $vta = $CLASS->new(sqitch => $sqitch, target => $target); + is $vta->target->name, 'db:vertica:', + 'Target name should not fall back on sysuser'; + is $vta->registry_destination, $vta->destination, + 'Registry target should be the same as destination'; + + $ENV{VSQL_DATABASE} = 'mydb'; + $vta = $CLASS->new(sqitch => $sqitch, username => 'hi', target => $target); + is $vta->target->name, 'db:vertica:', 'Target name should be the default'; + is $vta->registry_destination, $vta->destination, + 'Registry target should be the same as destination'; +} + +############################################################################## +# Make sure config settings override defaults. +$config->update( + 'engine.vertica.client' => '/path/to/vsql', + 'engine.vertica.target' => 'db:vertica://localhost/try', + 'engine.vertica.registry' => 'meta', +); +$std_opts[-1] = 'registry=meta'; + +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +ok $vta = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create another vertica'; +is $vta->client, '/path/to/vsql', 'client should be as configured'; +is $vta->uri->as_string, 'db:vertica://localhost/try', + 'uri should be as configured'; +is $vta->registry, 'meta', 'registry should be as configured'; +is_deeply [$vta->vsql], [ + '/path/to/vsql', + '--username', $sqitch->sysuser, + '--dbname', 'try', + '--host', 'localhost', + @std_opts +], 'vsql command should be configured from URI config'; + +############################################################################## +# Test _run(), _capture(), and _spool(). +can_ok $vta, qw(_run _capture _spool); +my $mock_sqitch = Test::MockModule->new('App::Sqitch'); +my (@run, $exp_pass); +$mock_sqitch->mock(run => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @run = @_; + if (defined $exp_pass) { + is $ENV{VSQL_PASSWORD}, $exp_pass, qq{VSQL_PASSWORD should be "$exp_pass"}; + } else { + ok !exists $ENV{VSQL_PASSWORD}, 'VSQL_PASSWORD should not exist'; + } +}); + +my @capture; +$mock_sqitch->mock(capture => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @capture = @_; + if (defined $exp_pass) { + is $ENV{VSQL_PASSWORD}, $exp_pass, qq{VSQL_PASSWORD should be "$exp_pass"}; + } else { + ok !exists $ENV{VSQL_PASSWORD}, 'VSQL_PASSWORD should not exist'; + } +}); + +my @spool; +$mock_sqitch->mock(spool => sub { + local $Test::Builder::Level = $Test::Builder::Level + 2; + shift; + @spool = @_; + if (defined $exp_pass) { + is $ENV{VSQL_PASSWORD}, $exp_pass, qq{VSQL_PASSWORD should be "$exp_pass"}; + } else { + ok !exists $ENV{VSQL_PASSWORD}, 'VSQL_PASSWORD should not exist'; + } +}); + +$exp_pass = 's3cr3t'; +$target->uri->password($exp_pass); +ok $vta->_run(qw(foo bar baz)), 'Call _run'; +is_deeply \@run, [$vta->vsql, qw(foo bar baz)], + 'Command should be passed to run()'; + +ok $vta->_spool('FH'), 'Call _spool'; +is_deeply \@spool, ['FH', $vta->vsql], + 'Command should be passed to spool()'; + +ok $vta->_capture(qw(foo bar baz)), 'Call _capture'; +is_deeply \@capture, [$vta->vsql, qw(foo bar baz)], + 'Command should be passed to capture()'; + +# Without password. +$target = App::Sqitch::Target->new( sqitch => $sqitch ); +ok $vta = $CLASS->new(sqitch => $sqitch, target => $target), + 'Create a vertica with sqitch with no pw'; +$exp_pass = undef; +ok $vta->_run(qw(foo bar baz)), 'Call _run again'; +is_deeply \@run, [$vta->vsql, qw(foo bar baz)], + 'Command should be passed to run() again'; + +ok $vta->_spool('FH'), 'Call _spool again'; +is_deeply \@spool, ['FH', $vta->vsql], + 'Command should be passed to spool() again'; + +ok $vta->_capture(qw(foo bar baz)), 'Call _capture again'; +is_deeply \@capture, [$vta->vsql, qw(foo bar baz)], + 'Command should be passed to capture() again'; + +############################################################################## +# Test file and handle running. +ok $vta->run_file('foo/bar.sql'), 'Run foo/bar.sql'; +is_deeply \@run, [$vta->vsql, '--file', 'foo/bar.sql'], + 'File should be passed to run()'; + +ok $vta->run_handle('FH'), 'Spool a "file handle"'; +is_deeply \@spool, ['FH', $vta->vsql], + 'Handle should be passed to spool()'; + +# Verify should go to capture unless verosity is > 1. +ok $vta->run_verify('foo/bar.sql'), 'Verify foo/bar.sql'; +is_deeply \@capture, [$vta->vsql, '--file', 'foo/bar.sql'], + 'Verify file should be passed to capture()'; + +$mock_sqitch->mock(verbosity => 2); +ok $vta->run_verify('foo/bar.sql'), 'Verify foo/bar.sql again'; +is_deeply \@run, [$vta->vsql, '--file', 'foo/bar.sql'], + 'Verifile file should be passed to run() for high verbosity'; + +$mock_sqitch->unmock_all; + +############################################################################## +# Test DateTime formatting stuff. +ok my $ts2char = $CLASS->can('_ts2char_format'), "$CLASS->can('_ts2char_format')"; +is sprintf($ts2char->(), 'foo'), + q{to_char(foo AT TIME ZONE 'UTC', '"year":YYYY:"month":MM:"day":DD:"hour":HH24:"minute":MI:"second":SS:"time_zone":"UTC"')}, + '_ts2char_format should work'; + +ok my $dtfunc = $CLASS->can('_dt'), "$CLASS->can('_dt')"; +isa_ok my $dt = $dtfunc->( + 'year:2012:month:07:day:05:hour:15:minute:07:second:01:time_zone:UTC' +), 'App::Sqitch::DateTime', 'Return value of _dt()'; +is $dt->year, 2012, 'DateTime year should be set'; +is $dt->month, 7, 'DateTime month should be set'; +is $dt->day, 5, 'DateTime day should be set'; +is $dt->hour, 15, 'DateTime hour should be set'; +is $dt->minute, 7, 'DateTime minute should be set'; +is $dt->second, 1, 'DateTime second should be set'; +is $dt->time_zone->name, 'UTC', 'DateTime TZ should be set'; + +############################################################################## +# Can we do live tests? +my $dbh; +END { + return unless $dbh; + $dbh->{Driver}->visit_child_handles(sub { + my $h = shift; + $h->disconnect if $h->{Type} eq 'db' && $h->{Active} && $h ne $dbh; + }); + + $dbh->{RaiseError} = 0; + $dbh->{PrintError} = 1; + $dbh->do($_) for ( + 'DROP SCHEMA sqitch CASCADE', + 'DROP SCHEMA __sqitchtest CASCADE', + ); +} + +$uri = URI->new($ENV{VSQL_URI} || 'db:vertica://dbadmin:password@localhost/dbadmin'); +my $err = try { + $vta->use_driver; + $dbh = DBI->connect($uri->dbi_dsn, $uri->user, $uri->password, { + PrintError => 0, + RaiseError => 1, + AutoCommit => 1, + }); + undef; +} catch { + eval { $_->message } || $_; +}; + +DBIEngineTest->run( + class => $CLASS, + version_query => 'SELECT version()', + target_params => [ uri => $uri ], + alt_target_params => [ uri => $uri, registry => '__sqitchtest' ], + skip_unless => sub { + my $self = shift; + die $err if $err; + # Make sure we have vsql and can connect to the database. + $self->sqitch->probe( $self->client, '--version' ); + $self->_capture('--command' => 'SELECT version()'); + }, + engine_err_regex => qr/\bERROR \d+:/, + init_error => __x( + 'Sqitch schema "{schema}" already exists', + schema => '__sqitchtest', + ), + test_dbh => sub { + my $dbh = shift; + # Make sure the sqitch schema is the first in the search path. + is $dbh->selectcol_arrayref('SELECT current_schema')->[0], + '__sqitchtest', 'The Sqitch schema should be the current schema'; + }, +); + +done_testing; diff --git a/t/x.t b/t/x.t new file mode 100644 index 00000000..dbd5778e --- /dev/null +++ b/t/x.t @@ -0,0 +1,78 @@ +#!/usr/bin/perl -w + +use strict; +use Test::More; +use Test::Exception; +use Try::Tiny; +use Path::Class; +use lib 't/lib'; +use TestConfig; + +my $CLASS; +BEGIN { + $CLASS = 'App::Sqitch::X'; + require_ok $CLASS or die; + $CLASS->import(':all'); +} + +isa_ok my $x = $CLASS->new(ident => 'test', message => 'Die'), $CLASS, 'X object'; + +for my $role(qw( + Throwable + StackTrace::Auto +)) { + ok $x->does($role), "X object does $role"; +} + +# Make sure default ident works. +ok $x = $CLASS->new(message => 'whatever'), 'Create X without ident'; +is $x->ident, 'DEV', 'Default ident should be "DEV"'; + +throws_ok { hurl basic => 'OMFG!' } $CLASS; +isa_ok $x = $@, $CLASS, 'Thrown object'; +is $x->ident, 'basic', 'Ident should be "basic"'; +is $x->message, 'OMFG!', 'The message should have been passed'; +ok $x->stack_trace->frames, 'It should have a stack trace'; +is $x->exitval, 2, 'Exit val should be 2'; +is +($x->stack_trace->frames)[0]->filename, file(qw(t x.t)), + 'The trace should start in this file'; + +# NB: Don't use `local $@`, as it does not work on Perls < 5.14. +throws_ok { $@ = 'Yo dawg'; hurl 'OMFG!' } $CLASS; +isa_ok $x = $@, $CLASS, 'Thrown object'; +is $x->ident, 'DEV', 'Ident should be "DEV"'; +is $x->message, 'OMFG!', 'The message should have been passed'; +is $x->exitval, 2, 'Exit val should again be 2'; +is $x->previous_exception, 'Yo dawg', + 'Previous exception should have been passed'; + +throws_ok { hurl {ident => 'blah', message => 'OMFG!', exitval => 1} } $CLASS; +isa_ok $x = $@, $CLASS, 'Thrown object'; +is $x->message, 'OMFG!', 'The params should have been passed'; +is $x->exitval, 1, 'Exit val should be 1'; +is $x->as_string, join("\n", grep { defined } + $x->message, + $x->previous_exception, + $x->stack_trace +), 'Stringification should work'; + +is $x->as_string, "$x", 'Stringification should work'; + +# Do some actual exception handling. +try { + hurl io => 'Cannot open file'; +} catch { + return fail "Not a Sqitch::X: $_" unless eval { $_->isa('App::Sqitch::X') }; + is $_->ident, 'io', 'Should be an "io" exception'; +}; + +# Make sure we can goto hurl. +try { + @_ = (io => 'Cannot open file'); + goto &hurl; +} catch { + return fail "Not a Sqitch::X: $_" unless eval { $_->isa('App::Sqitch::X') }; + is $_->ident, 'io', 'Should catch error called via &goto'; +}; + +done_testing; -- cgit v1.2.3 From 99ea1e5bf9e295ae908314f4428862d1f10166d3 Mon Sep 17 00:00:00 2001 From: Christian Hofstaedtler <zeha@debian.org> Date: Mon, 4 Jul 2016 13:49:40 +0200 Subject: Fix bad whatis indexing of man pages By removing the private markers, the generated man pages get their correct format, so whatis indexing works. Forwarded: not-needed Gbp-Pq: Name fix-bad-whatis-man.patch --- lib/sqitchcommands.pod | 7 ------- lib/sqitchguides.pod | 7 ------- lib/sqitchusage.pod | 7 ------- 3 files changed, 21 deletions(-) diff --git a/lib/sqitchcommands.pod b/lib/sqitchcommands.pod index 9fc1bb48..4b19d8b1 100644 --- a/lib/sqitchcommands.pod +++ b/lib/sqitchcommands.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchcommands - List of common sqitch commands -=end private - =head1 Usage sqitch [--etc-path | --help | --man | --version] diff --git a/lib/sqitchguides.pod b/lib/sqitchguides.pod index 8b195388..1d9b87a4 100644 --- a/lib/sqitchguides.pod +++ b/lib/sqitchguides.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchguides - List of common Sqitch guides -=end private - The common Sqitch guides are: changes Specifying changes for Sqitch diff --git a/lib/sqitchusage.pod b/lib/sqitchusage.pod index 4955f113..4655904c 100644 --- a/lib/sqitchusage.pod +++ b/lib/sqitchusage.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchusage - Sqitch usage statement -=end private - =head1 Usage sqitch <command> [options] [command-options] [args] -- cgit v1.2.3 From f4d3fb9df897d5f1706b114cd7070fb684d3ff6f Mon Sep 17 00:00:00 2001 From: Niko Tyni <ntyni@debian.org> Date: Mon, 18 May 2020 20:58:26 +0200 Subject: Import sqitch_1.0.0000-3.debian.tar.xz [dgit import tarball sqitch 1.0.0000-3 sqitch_1.0.0000-3.debian.tar.xz] --- NEWS | 7 ++ changelog | 212 +++++++++++++++++++++++++++++++++++++++ control | 95 ++++++++++++++++++ copyright | 59 +++++++++++ patches/fix-bad-whatis-man.patch | 65 ++++++++++++ patches/series | 1 + rules | 19 ++++ source/format | 1 + tests/pkg-perl/SKIP | 2 + tests/pkg-perl/smoke-env | 3 + tests/pkg-perl/smoke-files | 3 + tests/pkg-perl/smoke-skip | 7 ++ upstream/metadata | 5 + watch | 3 + 14 files changed, 482 insertions(+) create mode 100644 NEWS create mode 100644 changelog create mode 100644 control create mode 100644 copyright create mode 100644 patches/fix-bad-whatis-man.patch create mode 100644 patches/series create mode 100755 rules create mode 100644 source/format create mode 100644 tests/pkg-perl/SKIP create mode 100644 tests/pkg-perl/smoke-env create mode 100644 tests/pkg-perl/smoke-files create mode 100644 tests/pkg-perl/smoke-skip create mode 100644 upstream/metadata create mode 100644 watch diff --git a/NEWS b/NEWS new file mode 100644 index 00000000..7d8b8203 --- /dev/null +++ b/NEWS @@ -0,0 +1,7 @@ +sqitch (0.9999-1) unstable; urgency=medium + + The upstream Changes file, installed as /usr/share/doc/sqitch/changelog.gz, + contains the sections 'Deprecations & Removals' and 'API Changes' which + might be relevant for upgrades. + + -- gregor herrmann <gregoa@debian.org> Tue, 05 Feb 2019 18:45:33 +0100 diff --git a/changelog b/changelog new file mode 100644 index 00000000..b469e61c --- /dev/null +++ b/changelog @@ -0,0 +1,212 @@ +sqitch (1.0.0000-3) unstable; urgency=medium + + * Team upload. + * Add build and runtime dependencies on libpod-parser-perl. + + -- Niko Tyni <ntyni@debian.org> Mon, 18 May 2020 21:58:26 +0300 + +sqitch (1.0.0000-2) unstable; urgency=medium + + * Team upload. + * Set SQITCH_ORIG_FULLNAME in debian/tests/pkg-perl/smoke-env. + Apparently the user which runs the autopkgtests on ci.debian.net has + no name set, so the test if the name is set from the system fails. + + -- gregor herrmann <gregoa@debian.org> Fri, 28 Feb 2020 14:27:31 +0100 + +sqitch (1.0.0000-1) unstable; urgency=medium + + * Team upload. + + [ gregor herrmann ] + * debian/*: replace ADTTMP with AUTOPKGTEST_TMP. + + [ Debian Janitor ] + * Update standards version, no changes needed. + * Bump debhelper from old 11 to 12. + * Remove obsolete fields Name, Contact from debian/upstream/metadata. + + [ gregor herrmann ] + * debian/watch: use uscan version 4. + * Import upstream version 1.0.0000 + * Update (build) dependencies. + * Update short description. + * Update years of packaging copyright. + * Declare compliance with Debian Policy 4.5.0. + * Set Rules-Requires-Root: no. + * Drop unneeded version constraints from (build) dependencies. + * Annotate test-only build dependencies with <!nocheck>. + * Set upstream metadata fields: Bug-Submit, Repository, Repository- + Browse. + + -- gregor herrmann <gregoa@debian.org> Sun, 23 Feb 2020 11:56:51 +0100 + +sqitch (0.9999-2) unstable; urgency=medium + + * Team upload. + * Add back libio-pager-perl to Depends. + Thanks to Tommi Vainikainen for the bug report. (Closes: #922436) + + -- gregor herrmann <gregoa@debian.org> Fri, 15 Feb 2019 22:57:27 +0100 + +sqitch (0.9999-1) unstable; urgency=medium + + * Team upload. + * Import upstream version 0.9999. + * Refresh fix-bad-whatis-man.patch (fuzz). + * Update years of upstream and packaging copyright. + * Update (build) dependencies. + * Declare compliance with Debian Policy 4.3.0. + * Bump debhelper compatibility level to 11. + * Add debian/NEWS with a warning about deprecations, removals, and + API changes. + + -- gregor herrmann <gregoa@debian.org> Tue, 05 Feb 2019 18:48:17 +0100 + +sqitch (0.9998-2) unstable; urgency=medium + + * Team upload. + * debian/control: add version constraint to liburi-db-perl (build) + dependency. + Thanks to Tommi Vainikainen for the bug report. (Closes: #911576) + + -- gregor herrmann <gregoa@debian.org> Mon, 22 Oct 2018 16:33:36 +0200 + +sqitch (0.9998-1) unstable; urgency=medium + + * Team upload. + * Import upstream version 0.9998. + * Update debian/upstream/metadata. + * Update (build) dependencies. + * Declare compliance with Debian Policy 4.2.1. + + -- gregor herrmann <gregoa@debian.org> Mon, 08 Oct 2018 21:50:09 +0200 + +sqitch (0.9997-1) unstable; urgency=medium + + * Team upload. + + [ Salvatore Bonaccorso ] + * Update Vcs-* headers for switch to salsa.debian.org + + [ gregor herrmann ] + * Import upstream version 0.9997. + * Update years of upstream copyright and add additional copyright + holders. + * Declare compliance with Debian Policy 4.1.5. + * Use HTTPS for Homepage field in debian/control. + Thanks to duck. + + -- gregor herrmann <gregoa@debian.org> Fri, 27 Jul 2018 20:31:30 +0200 + +sqitch (0.9996-1) unstable; urgency=medium + + * Team upload. + * Import upstream version 0.9996. + * Update years of upstream and packaging copyright. + * New build dependency: libmodule-runtime-perl. + * Declare compliance with Debian Policy 4.1.3. + * Bump debhelper compatibility level to 10. + + -- gregor herrmann <gregoa@debian.org> Sat, 13 Jan 2018 21:25:08 +0100 + +sqitch (0.9995-2) unstable; urgency=medium + + * Team upload. + * Update MariaDB/MySQL alternative dependencies. (Closes: #848463) + * Update years of packaging copyright. + * Update versioned (build) dependencies. + Drop unnecessary version constraints. + + -- gregor herrmann <gregoa@debian.org> Sat, 17 Dec 2016 18:01:55 +0100 + +sqitch (0.9995-1) unstable; urgency=medium + + * New upstream version, drop patch for DateTime.pm. + + -- Christian Hofstaedtler <zeha@debian.org> Sat, 30 Jul 2016 21:18:25 +0000 + +sqitch (0.9994-1) unstable; urgency=medium + + [ gregor herrmann ] + * Rename autopkgtest configuration file(s) as per new pkg-perl- + autopkgtest schema. + + [ Salvatore Bonaccorso ] + * debian/control: Use HTTPS transport protocol for Vcs-Git URI + + [ gregor herrmann ] + * debian/copyright: change Copyright-Format 1.0 URL to HTTPS. + * debian/upstream/metadata: change GitHub/CPAN URL(s) to HTTPS. + + [ Christian Hofstaedtler ] + * Update Standards-Version to 3.9.8 + * New upstream version + * Import patch from upstream replacing DateTime::set(locale=>) + with DateTime::set_locale() to fix warnings and subsequent + failure in testsuite. + + -- Christian Hofstaedtler <zeha@debian.org> Mon, 04 Jul 2016 14:12:17 +0200 + +sqitch (0.9993-2) unstable; urgency=medium + + * Team upload. + * Set LC_ALL=C for the test suite, it breaks in other locales. + (Closes: #800070) + * Make the package autopkgtestable. + + -- Niko Tyni <ntyni@debian.org> Sat, 26 Sep 2015 20:59:45 +0300 + +sqitch (0.9993-1) unstable; urgency=medium + + * Import upstream version 0.9993 + + -- Christian Hofstaedtler <zeha@debian.org> Fri, 21 Aug 2015 21:15:22 +0000 + +sqitch (0.9992-1) unstable; urgency=medium + + * New upstream version. + * Update copyright notice for Debian packaging. + + -- Christian Hofstaedtler <zeha@debian.org> Sun, 14 Jun 2015 12:59:52 +0200 + +sqitch (0.9991-1) unstable; urgency=medium + + * Team upload. + * Add debian/upstream/metadata + * Import upstream version 0.9991 + Fixes "FTBFS: new warnings" (Closes: #785229) + * debian/watch: add uversionmangle in case upstream goes back from 4 + minor digits to less. + * Update fix-bad-whatis-man.patch. + * Move libmodule-build-perl to Build-Depends (needed during clean). + * Make (build) dependency on libpath-class-perl versioned. + * Drop a couple of version constraint from (build) dependencies. + They are all satisfied in (old)oldstable. + * Update years of upstream copyright. + * Declare compliance with Debian Policy 3.9.6. + + -- gregor herrmann <gregoa@debian.org> Thu, 14 May 2015 22:28:40 +0200 + +sqitch (0.996-1) unstable; urgency=medium + + [ Salvatore Bonaccorso ] + * Update Vcs-Browser URL to cgit web frontend + + [ gregor herrmann ] + * debian/control: update Module::Build dependency. + + [ Christian Hofstaedtler ] + * New upstream version. + * Remove upstream-supplied and -applied Digest::SHA patch. + + -- Christian Hofstaedtler <zeha@debian.org> Sun, 28 Sep 2014 16:59:27 +0200 + +sqitch (0.995-1) unstable; urgency=low + + * Initial Release. (Closes: #751740) + * Add patch from upstream to use Digest::SHA instead of Digest::SHA1. + * Many thanks to gregor herrmann <gregoa@debian.org> for review and + packaging suggestions. + + -- Christian Hofstaedtler <zeha@debian.org> Thu, 24 Jul 2014 23:59:59 +0200 diff --git a/control b/control new file mode 100644 index 00000000..640c2ae8 --- /dev/null +++ b/control @@ -0,0 +1,95 @@ +Source: sqitch +Maintainer: Debian Perl Group <pkg-perl-maintainers@lists.alioth.debian.org> +Uploaders: Chris Hofstaedtler <zeha@debian.org> +Section: database +Testsuite: autopkgtest-pkg-perl +Priority: optional +Build-Depends: debhelper-compat (= 12), + libmodule-build-perl, + perl +Build-Depends-Indep: libcapture-tiny-perl <!nocheck>, + libclass-xsaccessor-perl <!nocheck>, + libclone-perl <!nocheck>, + libconfig-gitlike-perl <!nocheck>, + libdatetime-perl <!nocheck>, + libdatetime-timezone-perl <!nocheck>, + libdbi-perl <!nocheck>, + libdevel-stacktrace-perl <!nocheck>, + libencode-locale-perl <!nocheck>, + libhash-merge-perl (>= 0.299) <!nocheck>, + libintl-perl <!nocheck>, + libio-pager-perl (>= 0.34) <!nocheck>, + libipc-run3-perl <!nocheck>, + libipc-system-simple-perl <!nocheck>, + liblist-moreutils-perl <!nocheck>, + libmodule-runtime-perl <!nocheck>, + libmoo-perl <!nocheck>, + libnamespace-autoclean-perl <!nocheck>, + libpath-class-perl <!nocheck>, + libperlio-utf8-strict-perl <!nocheck>, + libpod-parser-perl <!nocheck>, + libstring-formatter-perl <!nocheck>, + libstring-shellquote-perl <!nocheck>, + libsub-exporter-perl <!nocheck>, + libtemplate-perl <!nocheck>, + libtemplate-tiny-perl <!nocheck>, + libtest-deep-perl <!nocheck>, + libtest-dir-perl <!nocheck>, + libtest-exception-perl <!nocheck>, + libtest-file-contents-perl <!nocheck>, + libtest-file-perl <!nocheck>, + libtest-mockmodule-perl (>= 0.170) <!nocheck>, + libtest-nowarnings-perl <!nocheck>, + libtest-warn-perl <!nocheck>, + libthrowable-perl <!nocheck>, + libtry-tiny-perl <!nocheck>, + libtype-tiny-perl <!nocheck>, + libtype-tiny-xs-perl <!nocheck>, + liburi-db-perl (>= 0.19) <!nocheck>, + liburi-perl <!nocheck> +Standards-Version: 4.5.0 +Vcs-Browser: https://salsa.debian.org/perl-team/modules/packages/sqitch +Vcs-Git: https://salsa.debian.org/perl-team/modules/packages/sqitch.git +Homepage: https://sqitch.org/ +Rules-Requires-Root: no + +Package: sqitch +Architecture: all +Depends: ${misc:Depends}, + ${perl:Depends}, + libclone-perl, + libconfig-gitlike-perl, + libdatetime-perl, + libdatetime-timezone-perl, + libdbd-pg-perl | libdbd-sqlite3-perl | libdbd-mysql-perl | libdbd-firebird-perl, + libdbi-perl, + libdevel-stacktrace-perl, + libencode-locale-perl, + libhash-merge-perl (>= 0.299), + libintl-perl, + libio-pager-perl (>= 0.34), + libipc-run3-perl, + libipc-system-simple-perl, + liblist-moreutils-perl, + libmoo-perl, + libnamespace-autoclean-perl, + libpath-class-perl, + libperlio-utf8-strict-perl, + libpod-parser-perl, + libstring-formatter-perl, + libstring-shellquote-perl, + libsub-exporter-perl, + libtemplate-tiny-perl, + libthrowable-perl, + libtry-tiny-perl, + libtype-tiny-perl, + liburi-db-perl (>= 0.19), + liburi-perl, + postgresql-client | sqlite3 | default-mysql-client | virtual-mysql-client | firebird2.5-classic | firebird2.5-super +Recommends: libclass-xsaccessor-perl, + libtemplate-perl, + libtype-tiny-xs-perl +Description: sensible database change management + Sqitch provides a simple yet robust interface for database change + management. The philosophy and functionality is inspired by + Git. diff --git a/copyright b/copyright new file mode 100644 index 00000000..63935428 --- /dev/null +++ b/copyright @@ -0,0 +1,59 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Source: https://metacpan.org/release/App-Sqitch +Upstream-Contact: David E. Wheeler <david@justatheory.com> +Upstream-Name: App-Sqitch + +Files: * +Copyright: 2012-2019, iovation Inc. +License: Expat + +Files: lib/App/Sqitch/Command/checkout.pm +Copyright: 2012-2013, Ronan Dunklau + 2012-2018, iovation Inc. +License: Expat + +Files: lib/App/Sqitch/Engine/firebird.pm +Copyright: 2013, Ștefan Suciu + 2012-2018, iovation Inc. +License: Expat + +Files: debian/* +Copyright: 2014, 2015, Christian Hofstaedtler <zeha@debian.org> + 2014-2020, gregor herrmann <gregoa@debian.org> +License: Artistic or GPL-1+ + +License: Artistic + This program is free software; you can redistribute it and/or modify + it under the terms of the Artistic License, which comes with Perl. + . + On Debian systems, the complete text of the Artistic License can be + found in `/usr/share/common-licenses/Artistic'. + +License: GPL-1+ + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 1, or (at your option) + any later version. + . + On Debian systems, the complete text of version 1 of the GNU General + Public License can be found in `/usr/share/common-licenses/GPL-1'. + +License: Expat + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + . + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/patches/fix-bad-whatis-man.patch b/patches/fix-bad-whatis-man.patch new file mode 100644 index 00000000..2b4be1c3 --- /dev/null +++ b/patches/fix-bad-whatis-man.patch @@ -0,0 +1,65 @@ +From: Christian Hofstaedtler <zeha@debian.org> +Date: Mon, 4 Jul 2016 13:49:40 +0200 +Subject: Fix bad whatis indexing of man pages + +By removing the private markers, the generated man pages get their +correct format, so whatis indexing works. + +Forwarded: not-needed +--- + lib/sqitchcommands.pod | 7 ------- + lib/sqitchguides.pod | 7 ------- + lib/sqitchusage.pod | 7 ------- + 3 files changed, 21 deletions(-) + +--- a/lib/sqitchcommands.pod ++++ b/lib/sqitchcommands.pod +@@ -1,14 +1,7 @@ +-=begin private +- +-Keep private so it's not displayed, but will still be indexed by the CPAN +-toolchain. +- + =head1 Name + + sqitchcommands - List of common sqitch commands + +-=end private +- + =head1 Usage + + sqitch [--etc-path | --help | --man | --version] +--- a/lib/sqitchguides.pod ++++ b/lib/sqitchguides.pod +@@ -1,14 +1,7 @@ +-=begin private +- +-Keep private so it's not displayed, but will still be indexed by the CPAN +-toolchain. +- + =head1 Name + + sqitchguides - List of common Sqitch guides + +-=end private +- + The common Sqitch guides are: + + changes Specifying changes for Sqitch +--- a/lib/sqitchusage.pod ++++ b/lib/sqitchusage.pod +@@ -1,14 +1,7 @@ +-=begin private +- +-Keep private so it's not displayed, but will still be indexed by the CPAN +-toolchain. +- + =head1 Name + + sqitchusage - Sqitch usage statement + +-=end private +- + =head1 Usage + + sqitch <command> [options] [command-options] [args] diff --git a/patches/series b/patches/series new file mode 100644 index 00000000..eae37e5e --- /dev/null +++ b/patches/series @@ -0,0 +1 @@ +fix-bad-whatis-man.patch diff --git a/rules b/rules new file mode 100755 index 00000000..7893313f --- /dev/null +++ b/rules @@ -0,0 +1,19 @@ +#!/usr/bin/make -f + +# The tests require a home directory (mostly for the .conf file), so +# provide one. +BUILDHOME = $(CURDIR)/debian/build + +%: + dh $@ + +override_dh_auto_configure: + dh_auto_configure -- --etcdir=/etc/sqitch + +override_dh_clean: + dh_clean + rm -rf $(BUILDHOME) + +override_dh_auto_test: + mkdir -p $(BUILDHOME) + LC_ALL=C HOME=$(BUILDHOME) dh_auto_test diff --git a/source/format b/source/format new file mode 100644 index 00000000..163aaf8d --- /dev/null +++ b/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/tests/pkg-perl/SKIP b/tests/pkg-perl/SKIP new file mode 100644 index 00000000..8141f35a --- /dev/null +++ b/tests/pkg-perl/SKIP @@ -0,0 +1,2 @@ +# Locale-TextDomain makes 'perl -c' fail +syntax.t diff --git a/tests/pkg-perl/smoke-env b/tests/pkg-perl/smoke-env new file mode 100644 index 00000000..a7f9d4a1 --- /dev/null +++ b/tests/pkg-perl/smoke-env @@ -0,0 +1,3 @@ +LC_ALL=C +HOME=${AUTOPKGTEST_TMP} +SQITCH_ORIG_FULLNAME="A Debian CI system" diff --git a/tests/pkg-perl/smoke-files b/tests/pkg-perl/smoke-files new file mode 100644 index 00000000..ad45b820 --- /dev/null +++ b/tests/pkg-perl/smoke-files @@ -0,0 +1,3 @@ +README.md +t +etc diff --git a/tests/pkg-perl/smoke-skip b/tests/pkg-perl/smoke-skip new file mode 100644 index 00000000..b49363da --- /dev/null +++ b/tests/pkg-perl/smoke-skip @@ -0,0 +1,7 @@ +# Failed test 'Default system directory should be correct' +# at t/configuration.t line 27. +# got: '/etc/sqitch' +# expected: '/usr/etc/sqitch' +# +# this test is skipped during build so we probably don't care +t/configuration.t diff --git a/upstream/metadata b/upstream/metadata new file mode 100644 index 00000000..3c4915da --- /dev/null +++ b/upstream/metadata @@ -0,0 +1,5 @@ +Archive: CPAN +Bug-Database: https://github.com/sqitchers/sqitch/issues/ +Bug-Submit: https://github.com/sqitchers/sqitch/issues//new +Repository: https://github.com/sqitchers/sqitch.git +Repository-Browse: https://github.com/sqitchers/sqitch diff --git a/watch b/watch new file mode 100644 index 00000000..790116a7 --- /dev/null +++ b/watch @@ -0,0 +1,3 @@ +version=4 +opts=uversionmangle=s/\.\d$/$&000/;s/\.\d\d$/$&00/;s/\.\d\d\d$/$&0/; \ + https://metacpan.org/release/App-Sqitch .*/App-Sqitch-v?@ANY_VERSION@@ARCHIVE_EXT@$ -- cgit v1.2.3 From c0e20649e14d4d3abea93aff081db949930dc347 Mon Sep 17 00:00:00 2001 From: Christian Hofstaedtler <zeha@debian.org> Date: Mon, 4 Jul 2016 13:49:40 +0200 Subject: Fix bad whatis indexing of man pages By removing the private markers, the generated man pages get their correct format, so whatis indexing works. Forwarded: not-needed Gbp-Pq: Name fix-bad-whatis-man.patch --- lib/sqitchcommands.pod | 7 ------- lib/sqitchguides.pod | 7 ------- lib/sqitchusage.pod | 7 ------- 3 files changed, 21 deletions(-) diff --git a/lib/sqitchcommands.pod b/lib/sqitchcommands.pod index 9fc1bb48..4b19d8b1 100644 --- a/lib/sqitchcommands.pod +++ b/lib/sqitchcommands.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchcommands - List of common sqitch commands -=end private - =head1 Usage sqitch [--etc-path | --help | --man | --version] diff --git a/lib/sqitchguides.pod b/lib/sqitchguides.pod index 8b195388..1d9b87a4 100644 --- a/lib/sqitchguides.pod +++ b/lib/sqitchguides.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchguides - List of common Sqitch guides -=end private - The common Sqitch guides are: changes Specifying changes for Sqitch diff --git a/lib/sqitchusage.pod b/lib/sqitchusage.pod index 4955f113..4655904c 100644 --- a/lib/sqitchusage.pod +++ b/lib/sqitchusage.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchusage - Sqitch usage statement -=end private - =head1 Usage sqitch <command> [options] [command-options] [args] -- cgit v1.2.3 From 08308436f3a9afb76eb4adb265e87f88dfed66ba Mon Sep 17 00:00:00 2001 From: Christian Hofstaedtler <zeha@debian.org> Date: Mon, 4 Jul 2016 13:49:40 +0200 Subject: Fix bad whatis indexing of man pages By removing the private markers, the generated man pages get their correct format, so whatis indexing works. Forwarded: not-needed Gbp-Pq: Name fix-bad-whatis-man.patch --- lib/sqitchcommands.pod | 7 ------- lib/sqitchguides.pod | 7 ------- lib/sqitchusage.pod | 7 ------- 3 files changed, 21 deletions(-) diff --git a/lib/sqitchcommands.pod b/lib/sqitchcommands.pod index 9fc1bb48..4b19d8b1 100644 --- a/lib/sqitchcommands.pod +++ b/lib/sqitchcommands.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchcommands - List of common sqitch commands -=end private - =head1 Usage sqitch [--etc-path | --help | --man | --version] diff --git a/lib/sqitchguides.pod b/lib/sqitchguides.pod index 8b195388..1d9b87a4 100644 --- a/lib/sqitchguides.pod +++ b/lib/sqitchguides.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchguides - List of common Sqitch guides -=end private - The common Sqitch guides are: changes Specifying changes for Sqitch diff --git a/lib/sqitchusage.pod b/lib/sqitchusage.pod index 4955f113..4655904c 100644 --- a/lib/sqitchusage.pod +++ b/lib/sqitchusage.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchusage - Sqitch usage statement -=end private - =head1 Usage sqitch <command> [options] [command-options] [args] -- cgit v1.2.3 From a0ca8aead38e34546ee89a6c360b675b2f4b0815 Mon Sep 17 00:00:00 2001 From: Christian Hofstaedtler <zeha@debian.org> Date: Mon, 4 Jul 2016 13:49:40 +0200 Subject: Fix bad whatis indexing of man pages By removing the private markers, the generated man pages get their correct format, so whatis indexing works. Forwarded: not-needed Gbp-Pq: Name fix-bad-whatis-man.patch --- lib/sqitchcommands.pod | 7 ------- lib/sqitchguides.pod | 7 ------- lib/sqitchusage.pod | 7 ------- 3 files changed, 21 deletions(-) diff --git a/lib/sqitchcommands.pod b/lib/sqitchcommands.pod index 9fc1bb48..4b19d8b1 100644 --- a/lib/sqitchcommands.pod +++ b/lib/sqitchcommands.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchcommands - List of common sqitch commands -=end private - =head1 Usage sqitch [--etc-path | --help | --man | --version] diff --git a/lib/sqitchguides.pod b/lib/sqitchguides.pod index 8b195388..1d9b87a4 100644 --- a/lib/sqitchguides.pod +++ b/lib/sqitchguides.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchguides - List of common Sqitch guides -=end private - The common Sqitch guides are: changes Specifying changes for Sqitch diff --git a/lib/sqitchusage.pod b/lib/sqitchusage.pod index 4955f113..4655904c 100644 --- a/lib/sqitchusage.pod +++ b/lib/sqitchusage.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchusage - Sqitch usage statement -=end private - =head1 Usage sqitch <command> [options] [command-options] [args] -- cgit v1.2.3 From 9ea29b2df29f0a0401bc0d6fbcf065df18e38892 Mon Sep 17 00:00:00 2001 From: Christian Hofstaedtler <zeha@debian.org> Date: Mon, 4 Jul 2016 13:49:40 +0200 Subject: Fix bad whatis indexing of man pages By removing the private markers, the generated man pages get their correct format, so whatis indexing works. Forwarded: not-needed Gbp-Pq: Name fix-bad-whatis-man.patch --- lib/sqitchcommands.pod | 7 ------- lib/sqitchguides.pod | 7 ------- lib/sqitchusage.pod | 7 ------- 3 files changed, 21 deletions(-) diff --git a/lib/sqitchcommands.pod b/lib/sqitchcommands.pod index 9fc1bb48..4b19d8b1 100644 --- a/lib/sqitchcommands.pod +++ b/lib/sqitchcommands.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchcommands - List of common sqitch commands -=end private - =head1 Usage sqitch [--etc-path | --help | --man | --version] diff --git a/lib/sqitchguides.pod b/lib/sqitchguides.pod index 8b195388..1d9b87a4 100644 --- a/lib/sqitchguides.pod +++ b/lib/sqitchguides.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchguides - List of common Sqitch guides -=end private - The common Sqitch guides are: changes Specifying changes for Sqitch diff --git a/lib/sqitchusage.pod b/lib/sqitchusage.pod index 4955f113..4655904c 100644 --- a/lib/sqitchusage.pod +++ b/lib/sqitchusage.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchusage - Sqitch usage statement -=end private - =head1 Usage sqitch <command> [options] [command-options] [args] -- cgit v1.2.3 From 407e838060c70dde3b07c1589571a6f0ecf527f9 Mon Sep 17 00:00:00 2001 From: Christian Hofstaedtler <zeha@debian.org> Date: Mon, 4 Jul 2016 13:49:40 +0200 Subject: Fix bad whatis indexing of man pages By removing the private markers, the generated man pages get their correct format, so whatis indexing works. Forwarded: not-needed Gbp-Pq: Name fix-bad-whatis-man.patch --- lib/sqitchcommands.pod | 7 ------- lib/sqitchguides.pod | 7 ------- lib/sqitchusage.pod | 7 ------- 3 files changed, 21 deletions(-) diff --git a/lib/sqitchcommands.pod b/lib/sqitchcommands.pod index 9fc1bb48..4b19d8b1 100644 --- a/lib/sqitchcommands.pod +++ b/lib/sqitchcommands.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchcommands - List of common sqitch commands -=end private - =head1 Usage sqitch [--etc-path | --help | --man | --version] diff --git a/lib/sqitchguides.pod b/lib/sqitchguides.pod index 8b195388..1d9b87a4 100644 --- a/lib/sqitchguides.pod +++ b/lib/sqitchguides.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchguides - List of common Sqitch guides -=end private - The common Sqitch guides are: changes Specifying changes for Sqitch diff --git a/lib/sqitchusage.pod b/lib/sqitchusage.pod index 4955f113..4655904c 100644 --- a/lib/sqitchusage.pod +++ b/lib/sqitchusage.pod @@ -1,14 +1,7 @@ -=begin private - -Keep private so it's not displayed, but will still be indexed by the CPAN -toolchain. - =head1 Name sqitchusage - Sqitch usage statement -=end private - =head1 Usage sqitch <command> [options] [command-options] [args] -- cgit v1.2.3