diff options
author | gregor herrmann <gregoa@debian.org> | 2020-02-23 11:56:51 +0100 |
---|---|---|
committer | gregor herrmann <gregoa@debian.org> | 2020-02-23 11:56:51 +0100 |
commit | 2d95027a3c426e133c6612ae1016d408483ec7ef (patch) | |
tree | 86da540d08eadb81effe53c7900723e45a39ad12 |
Import sqitch_1.0.0000.orig.tar.gz
[dgit import orig sqitch_1.0.0000.orig.tar.gz]
280 files changed, 77326 insertions, 0 deletions
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 <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 + - 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 `<database>` 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 `<database>` parameter to the `plan` command. + - Sqitch now loads targets from all config files, not just the local + file, when trying to determine if a `<database>` 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' @@ -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 <david.wheeler@iovation.com> 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 <david.wheeler@iovation.com> 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 <david.wheeler@iovation.com> 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 <david.wheeler@iovation.com> 0.9997-1 +- Upgrade to v0.9997. + +* Wed Jul 19 2017 David E. Wheeler <david.wheeler@iovation.com> 0.9996-2 +- Require File::Find and Module::Runtime at build time. +- Remove Moo::sification. + +* Mon Jul 17 2017 David E. Wheeler <david.wheeler@iovation.com> 0.9996-1 +- Upgrade to v0.9996. + +* Wed Jul 27 2016 David E. Wheeler <david.wheeler@iovation.com> 0.9995-1 +- Require DateTime v1.04. +- Upgrade to v0.9995. + +* Thu Feb 11 2016 David E. Wheeler <david.wheeler@iovation.com> 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 <david.wheeler@iovation.com> 0.9994-1 +- Reduced required MySQL version to 5.0. +- Upgrade to v0.9994. + +* Mon Aug 17 2015 David E. Wheeler <david.wheeler@iovation.com> 0.9993-1 +- Upgrade to v0.9993. + +* Wed May 20 2015 David E. Wheeler <david.wheeler@iovation.com> 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 <david.wheeler@iovation.com> 0.9991-1 +- Upgrade to v0.9991. +- Reduced required MySQL version to 5.1. + +* Thu Feb 12 2015 David E. Wheeler <david.wheeler@iovation.com> 0.999-1 +- Upgrade to v0.999. + +* Thu Jan 15 2015 David E. Wheeler <david.wheeler@iovation.com> 0.998-1 +- Upgrade to v0.998. +- Require Path::Class v0.33 when building. + +* Tue Nov 4 2014 David E. Wheeler <david.wheeler@iovation.com> 0.997-1 +- Upgrade to v0.997. + +* Fri Sep 5 2014 David E. Wheeler <david.wheeler@iovation.com> 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 <david.wheeler@iovation.com> 0.995-1 +- Upgrade to v0.995. + +* Thu Jun 19 2014 David E. Wheeler <david.wheeler@iovation.com> 0.994-1 +- Upgrade to v0.994. + +* Wed Jun 4 2014 David E. Wheeler <david.wheeler@iovation.com> 0.993-1 +- Upgrade to v0.993. + +* Tue Mar 4 2014 David E. Wheeler <david.wheeler@iovation.com> 0.992-1 +- Upgrade to v0.992. + +* Thu Jan 16 2014 David E. Wheeler <david.wheeler@iovation.com> 0.991-1 +- Upgrade to v0.991. +- Remove File::Which from sqitch-firebird. + +* Fri Jan 3 2014 David E. Wheeler <david.wheeler@iovation.com> 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 <david.wheeler@iovation.com> 0.983-1 +- Upgrade to v0.983. +- Require DBD::Pg 2.0.0 or higher. + +* Wed Sep 18 2013 David E. Wheeler <david.wheeler@iovation.com> 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 <david.wheeler@iovation.com> 0.982-1 +- Upgrade to v0.982. +- Require Clone. + +* Thu Sep 5 2013 David E. Wheeler <david.wheeler@iovation.com> 0.981-1 +- Upgrade to v0.981. + +* Wed Aug 28 2013 David E. Wheeler <david.wheeler@iovation.com> 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 <david.wheeler@iovation.com> 0.973-1 +- Upgrade to v0.973. + +* Fri May 31 2013 David E. Wheeler <david.wheeler@iovation.com> 0.972-1 +- Upgrade to v0.972. + +* Sat May 18 2013 David E. Wheeler <david.wheeler@iovation.com> 0.971-1 +- Upgrade to v0.971. + +* Wed May 8 2013 David E. Wheeler <david.wheeler@iovation.com> 0.970-1 +- Upgrade to v0.970. +- Add sqitch-oracle. + +* Tue Apr 23 2013 David E. Wheeler <david.wheeler@iovation.com> 0.965-1 +- Upgrade to v0.965. + +* Mon Apr 15 2013 David E. Wheeler <david.wheeler@iovation.com> 0.964-1 +- Upgrade to v0.964. + +* Fri Apr 12 2013 David E. Wheeler <david.wheeler@iovation.com> 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 <david.wheeler@iovation.com> 0.962-1 +- Upgrade to v0.962. + +* Tue Apr 9 2013 David E. Wheeler <david.wheeler@iovation.com> 0.961-1 +- Upgrade to v0.961. + +* Mon Apr 8 2013 David E. Wheeler <david.wheeler@iovation.com> 0.960-2 +- Add missing dependency on Git::Wrapper. + +* Fri Apr 5 2013 David E. Wheeler <david.wheeler@iovation.com> 0.960-1 +- Upgrade to v0.960. +- Add sqitch-sqlite. + +* Thu Feb 21 2013 David E. Wheeler <david.wheeler@iovation.com> 0.953-1 +- Upgrade to v0.953. + +* Fri Jan 11 2013 David E. Wheeler <david.wheeler@iovation.com> 0.952-1 +- Upgrade to v0.952. + +* Mon Jan 7 2013 David E. Wheeler <david.wheeler@iovation.com> 0.951-1 +- Upgrade to v0.951. + +* Thu Jan 3 2013 David E. Wheeler <david.wheeler@iovation.com> 0.950-1 +- Upgrade to v0.950. + +* Mon Dec 3 2012 David E. Wheeler <david.wheeler@iovation.com> 0.940-1 +- Upgrade to v0.940. + +* Fri Oct 12 2012 David E. Wheeler <david.wheeler@iovation.com> 0.938-1 +- Upgrade to v0.938. + +* Tue Oct 9 2012 David E. Wheeler <david.wheeler@iovation.com> 0.937-1 +- Upgrade to v0.937. + +* Tue Oct 9 2012 David E. Wheeler <david.wheeler@iovation.com> 0.936-1 +- Upgrade to v0.936. + +* Tue Oct 2 2012 David E. Wheeler <david.wheeler@iovation.com> 0.935-1 +- Upgrade to v0.935. + +* Fri Sep 28 2012 David E. Wheeler <david.wheeler@iovation.com> 0.934-1 +- Upgrade to v0.934. + +* Thu Sep 27 2012 David E. Wheeler <david.wheeler@iovation.com> 0.933-1 +- Upgrade to v0.933. + +* Wed Sep 26 2012 David E. Wheeler <david.wheeler@iovation.com> 0.932-1 +- Upgrade to v0.932. + +* Tue Sep 25 2012 David E. Wheeler <david.wheeler@iovation.com> 0.931-1 +- Upgrade to v0.931. + +* Fri Aug 31 2012 David E. Wheeler <david.wheeler@iovation.com> 0.930-1 +- Upgrade to v0.93. + +* Thu Aug 30 2012 David E. Wheeler <david.wheeler@iovation.com> 0.922-1 +- Upgrade to v0.922. + +* Wed Aug 29 2012 David E. Wheeler <david.wheeler@iovation.com> 0.921-1 +- Upgrade to v0.921. + +* Tue Aug 28 2012 David E. Wheeler <david.wheeler@iovation.com> 0.920-1 +- Upgrade to v0.92. + +* Tue Aug 28 2012 David E. Wheeler <david.wheeler@iovation.com> 0.913-1 +- Upgrade to v0.913. + +* Mon Aug 27 2012 David E. Wheeler <david.wheeler@iovation.com> 0.912-1 +- Upgrade to v0.912. + +* Thu Aug 23 2012 David E. Wheeler <david.wheeler@iovation.com> 0.911-1 +- Upgrade to v0.911. + +* Wed Aug 22 2012 David E. Wheeler <david.wheeler@iovation.com> 0.91-1 +- Upgrade to v0.91. + +* Mon Aug 20 2012 David E. Wheeler <david.wheeler@iovation.com> 0.902-1 +- Upgrade to v0.902. + +* Mon Aug 20 2012 David E. Wheeler <david.wheeler@iovation.com> 0.901-1 +- Upgrade to v0.901. + +* Mon Aug 13 2012 David E. Wheeler <david.wheeler@iovation.com> 0.82-2 +- Require Config::GitLike 1.09, which offers better encoding support an other + bug fixes. + +* Fri Aug 03 2012 David E. Wheeler <david.wheeler@iovation.com> 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 } <DATA> }, + ); +} + +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 = <STDIN>; + 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<sqitch>. You probably want to +read L<its documentation|sqitch>, or L<the tutorial|sqitchtutorial>. Unless +you want to hack on Sqitch itself, or provide support for a new engine or +L<command|Sqitch::App::Command>. In which case, you will find this API +documentation useful. + +=head1 Interface + +=head2 Class Methods + +=head3 C<go> + + App::Sqitch->go; + +Called from C<sqitch>, 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<new> + + my $sqitch = App::Sqitch->new(\%params); + +Constructs and returns a new Sqitch object. The supported parameters include: + +=over + +=item C<options> + +=item C<user_name> + +=item C<user_email> + +=item C<editor> + +=item C<verbosity> + +=back + +=head2 Accessors + +=head3 C<user_name> + +=head3 C<user_email> + +=head3 C<editor> + +=head3 C<options> + + my $options = $sqitch->options; + +Returns a hashref of the core command-line options. + +=head3 C<config> + + my $config = $sqitch->config; + +Returns the full configuration, combined from the project, user, and system +configuration files. + +=head3 C<verbosity> + +=head2 Instance Methods + +=head3 C<run> + + $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<shell> to run a command and its arguments as a single string. + +=over + +=item C<target> + +The name of the target, as passed. + +=item C<uri> + +A L<database URI|URI::db> object, to be used to connect to the target +database. + + +=item C<registry> + +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<uri> +value a upgraded to a L<URI> object. Otherwise returns C<undef>. + +=head3 C<shell> + + $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<quote_shell> to assemble strings +into a single shell command. Use C<run> to execute a list without a shell. + +=head3 C<quote_shell> + + my $cmd = $sqitch->quote_shell('echo', '-n', 'hello'); + +Assemble a list into a single string quoted for execution by C<shell>. Useful +for combining a specified command, such as C<editor()>, which might include +the options in the string, for example: + + $sqitch->shell( $sqitch->editor, $sqitch->quote_shell($file) ); + +=head3 C<capture> + + my @files = $sqitch->capture(qw(ls -lah)); + +Runs a system command and captures its output to C<STDOUT>. Returns the output +lines in list context and the concatenation of the lines in scalar context. +Throws an exception on error. + +=head3 C<probe> + + my $git_version = $sqitch->capture(qw(git --version)); + +Like C<capture>, but returns just the C<chomp>ed first line of output. + +=head3 C<spool> + + $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<trace> + +=head3 C<trace_literal> + + $sqitch->trace_literal('About to fuzzle the wuzzle.'); + $sqitch->trace('Done.'); + +Send trace information to C<STDOUT> if the verbosity level is 3 or higher. +Trace messages will have C<trace: > prefixed to every line. If it's lower than +3, nothing will be output. C<trace> appends a newline to the end of the +message while C<trace_literal> does not. + +=head3 C<debug> + +=head3 C<debug_literal> + + $sqitch->debug('Found snuggle in the crib.'); + $sqitch->debug_literal('ITYM "snuggie".'); + +Send debug information to C<STDOUT> if the verbosity level is 2 or higher. +Debug messages will have C<debug: > prefixed to every line. If it's lower than +2, nothing will be output. C<debug> appends a newline to the end of the +message while C<debug_literal> does not. + +=head3 C<info> + +=head3 C<info_literal> + + $sqitch->info('Nothing to deploy (up-to-date)'); + $sqitch->info_literal('Going to frobble the shiznet.'); + +Send informational message to C<STDOUT> 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<info> appends a newline to the end of the message while C<info_literal> does +not. + +=head3 C<comment> + +=head3 C<comment_literal> + + $sqitch->comment('On database flipr_test'); + $sqitch->comment_literal('Uh-oh...'); + +Send comments to C<STDOUT> 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<comment> appends a newline to the end +of the message while C<comment_literal> does not. + +=head3 C<emit> + +=head3 C<emit_literal> + + $sqitch->emit('core.editor=emacs'); + $sqitch->emit_literal('Getting ready...'); + +Send a message to C<STDOUT>, without regard to the verbosity. Should be used +only if the user explicitly asks for output, such as for C<sqitch config --get +core.editor>. C<emit> appends a newline to the end of the message while +C<emit_literal> does not. + +=head3 C<vent> + +=head3 C<vent_literal> + + $sqitch->vent('That was a misage.'); + $sqitch->vent_literal('This is going to be bad...'); + +Send a message to C<STDERR>, 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<vent> appends a newline to the end of the +message while C<vent_literal> does not. + +=head3 C<page> + +=head3 C<page_literal> + + $sqitch->page('Search results:'); + $sqitch->page("Here we go\n"); + +Like C<emit()>, but sends the output to a pager handle rather than C<STDOUT>. +Unless there is no TTY (such as when output is being piped elsewhere), in +which case it I<is> sent to C<STDOUT>. C<page> appends a newline to the end of +the message while C<page_literal> 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<warn> + +=head3 C<warn_literal> + + $sqitch->warn('Could not find nerble; using nobble instead.'); + $sqitch->warn_literal("Cannot read file: $!\n"); + +Send a warning messages to C<STDERR>. Warnings will have C<warning: > prefixed +to every line. Use if something unexpected happened but you can recover from +it. C<warn> appends a newline to the end of the message while C<warn_literal> +does not. + +=head3 C<prompt> + + 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<ask_yes_no> + + 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<prompt()>, +an exception will be thrown if Sqitch is running unattended and there is no +default. + +=head3 C<ask_y_n> + +This method has been deprecated in favor of C<ask_yes_no()> and will be +removed in a future version of Sqitch. + + +=head2 Constants + +=head3 C<ISWIN> + + my $app = 'sqitch' . ( ISWIN ? '.bat' : '' ); + +True when Sqitch is running on Windows, and false when it's not. + +=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/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<ENGINES> + +Returns the list of supported engines, currently: + +=over + +=item * C<firebird> + +=item * C<mysql> + +=item * C<oracle> + +=item * C<pg> + +=item * C<sqlite> + +=item * C<vertica> + +=item * C<exasol> + +=item * C<snowflake> + +=back + +=head2 Class Methods + +=head3 C<options> + + my @spec = App::Sqitch::Command->options; + +Returns a list of L<Getopt::Long> options specifications. When C<load> 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<configure> +along with a L<App::Sqitch::Config> object for munging into parameters to be +passed to the constructor. + +Here's an example excerpted from the C<config> 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<config-file=s>, which will be named C<config_file>. + +=head3 C<configure> + + my $params = App::Sqitch::Command->configure($config, $options); + +Takes two arguments, an L<App::Sqitch::Config> object and the hash of +command-line options as specified by C<options>. 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<class_for> + + 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<undef> and sends errors to the +C<debug> method of the <$sqitch> object if no such subclass can +be loaded. + +=head2 Constructors + +=head3 C<load> + + 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<create> to instantiate and return an +instance of the subclass. Sends error messages to the C<debug> method of the +C<sqitch> parameter and throws an exception if the subclass does not exist or +cannot be loaded. Supported parameters are: + +=over + +=item C<sqitch> + +The App::Sqitch object driving the whole thing. + +=item C<config> + +An L<App::Sqitch::Config> representing the current application configuration +state. + +=item C<command> + +The name of the command to be executed. + +=item C<args> + +An array reference of command-line arguments passed to the command. + +=back + +=head3 C<create> + + 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<args> parameter, calls C<configure> to merge +configuration with the options, and finally calls C<new> with the resulting +hash. Supported parameters are the same as for C<load> except for the +C<command> parameter, which will be ignored. + +=head3 C<new> + + 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<C<BUILDARGS>|Moo::Manual::Construction/BUILDARGS> or +L<C<BUILD>|Moo::Manual::Construction/BUILD>, instead. + +=head2 Accessors + +=head3 C<sqitch> + + my $sqitch = $cmd->sqitch; + +Returns the L<App::Sqitch> 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<execute> + + $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<command> + + 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<default_target> + + my $target = $cmd->default_target; + +This method returns the default target. It should only be used by commands +that don't use a C<parse_args()> to find and load a target. + +This method should always return a target option, never C<undef>. If the +C<core.engine> configuration option has been set, then the target will support +that engine. In the latter case, if C<engine.$engine.target> is set, that +value will be used. Otherwise, the returned target will have a URI of C<db:> +and no associated engine; the C<engine> method will throw an exception. This +behavior should be fine for commands that don't need to load the engine. + +=head3 C<parse_args> + + 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<names> 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<args> + +An array reference of the command arguments. + +=item C<target> + +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<names> + +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<all> + +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<no_changes> + +If true, the parser will not check to see if any argument corresponds to a +change. The last value returned will be C<undef> instead of the usual array +reference. Any argument that might have been recognized as a change will +instead be included in either the C<targets> 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<default_target>, this target B<must> 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<target> parameter or the default target will be searched. Such changes can +be specified in any way documented in L<sqitchchanges>. + +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<parse_args()> 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<target_params> + + my $target = App::Sqitch::Target->new( $cmd->target_params ); + +Returns a list of parameters suitable for passing to the C<new> or +C<all_targets> constructors of App::Sqitch::Target. + +=head3 C<run> + + $cmd->run('echo hello'); + +Runs a system command and waits for it to finish. Throws an exception on +error. + +=head3 C<capture> + + my @files = $cmd->capture(qw(ls -lah)); + +Runs a system command and captures its output to C<STDOUT>. Returns the output +lines in list context and the concatenation of the lines in scalar context. +Throws an exception on error. + +=head3 C<probe> + + my $git_version = $cmd->capture(qw(git --version)); + +Like C<capture>, but returns just the C<chomp>ed first line of output. + +=head3 C<verbosity> + + my $verbosity = $cmd->verbosity; + +Returns the verbosity level. + +=head3 C<trace> + +Send trace information to C<STDOUT> if the verbosity level is 3 or higher. +Trace messages will have C<trace: > prefixed to every line. If it's lower than +3, nothing will be output. + +=head3 C<debug> + + $cmd->debug('Found snuggle in the crib.'); + +Send debug information to C<STDOUT> if the verbosity level is 2 or higher. +Debug messages will have C<debug: > prefixed to every line. If it's lower than +2, nothing will be output. + +=head3 C<info> + + $cmd->info('Nothing to deploy (up-to-date)'); + +Send informational message to C<STDOUT> 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<comment> + + $cmd->comment('On database flipr_test'); + +Send comments to C<STDOUT> 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<emit> + + $cmd->emit('core.editor=emacs'); + +Send a message to C<STDOUT>, without regard to the verbosity. Should be used +only if the user explicitly asks for output, such as for +C<sqitch config --get core.editor>. + +=head3 C<vent> + + $cmd->vent('That was a misage.'); + +Send a message to C<STDERR>, 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<page> + + $sqitch->page('Search results:'); + +Like C<emit()>, but sends the output to a pager handle rather than C<STDOUT>. +Unless there is no TTY (such as when output is being piped elsewhere), in +which case it I<is> sent to C<STDOUT>. 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<warn> + + $cmd->warn('Could not find nerble; using nobble instead.'); + +Send a warning messages to C<STDERR>. Warnings will have C<warning: > prefixed +to every line. Use if something unexpected happened but you can recover from +it. + +=head3 C<usage> + + $cmd->usage('Missing "value" argument'); + +Sends the specified message to C<STDERR>, 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<sqitch-$command-usage> + +=item C<sqitch-$command> + +=item C<sqitch> + +=item C<App::Sqitch::Command::$command> + +=item C<App::Sqitch::Command> + +=back + +For an ideal usage messages, C<sqitch-$command-usage.pod> should be created by +all command subclasses. + +=head1 See Also + +=over + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<Template::Tiny> templates in F<~/.sqitch/templates/> or +C<$(prefix)/etc/sqitch/templates> (call C<sqitch --etc-path> to find out +where, exactly (e.g., C<$(sqitch --etc-path)/sqitch.conf>). + +=head1 Interface + +=head2 Class Methods + +=head3 C<options> + + my @opts = App::Sqitch::Command::add->options; + +Returns a list of L<Getopt::Long> option specifications for the command-line +options for the C<add> command. + +=head3 C<configure> + + 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<change_name> + +The name of the change to be added. + +=head3 C<note> + +Text of the change note. + +=head3 C<requires> + +List of required changes. + +=head3 C<conflicts> + +List of conflicting changes. + +=head3 C<all> + +Boolean indicating whether or not to run the command against all plans in the +project. + +=head3 C<template_name> + +The name of the templates to use when generating scripts. Defaults to the +engine for which the scripts are being generated. + +=head3 C<template_directory> + +Directory in which to find the change script templates. + +=head3 C<with_scripts> + +Hash reference indicating which scripts to create. + +=head2 Instance Methods + +=head3 C<execute> + + $add->execute($command); + +Executes the C<add> command. + +=head3 C<all_templates> + +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<sqitch-add> + +Documentation for the C<add> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<from> + +Change from which to build the bundled plan. + +=head3 C<to> + +Change up to which to build the bundled plan. + +=head3 C<all> + +Boolean indicating whether or not to run the command against all plans in the +project. + +=head2 Instance Methods + +=head3 C<execute> + + $bundle->execute($command); + +Executes the C<bundle> command. + +=head3 C<bundle_config> + + $bundle->bundle_config; + +Copies the configuration file to the bundle directory. + +=head3 C<bundle_plan> + + $bundle->bundle_plan($target); + +Copies the plan file for the specified target to the bundle directory. + +=head3 C<bundle_scripts> + + $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<dest_top_dir> + + my $top_dir = $bundle->top_dir($target); + +Returns the destination top directory for the specified target. + +=head3 C<dest_dirs_for> + + 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<deploy> + +=item C<revert> + +=item C<verfiy> + +=item C<reworked_deploy> + +=item C<reworked_revert> + +=item C<reworked_verfiy> + +=back + +=head1 See Also + +=over + +=item L<sqitch-bundle> + +Documentation for the C<bundle> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<checkout> command, you probably want to +be reading C<sqitch-checkout>. But if you really want to know how the +C<checkout> command works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C<options> + + my @opts = App::Sqitch::Command::checkout->options; + +Returns a list of L<Getopt::Long> option specifications for the command-line +options for the C<checkout> command. + +=head2 Instance Methods + +=head3 C<execute> + + $checkout->execute; + +Executes the checkout command. + +=head1 See Also + +=over + +=item L<sqitch-checkout> + +Documentation for the C<checkout> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=back + +=head1 Authors + +=over + +=item * Ronan Dunklau <ronan@dunklau.fr> + +=item * David E. Wheeler <david@justatheory.com> + +=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<options> + + my @opts = App::Sqitch::Command::config->options; + +Returns a list of L<Getopt::Long> option specifications for the command-line +options for the C<config> command. + +=head3 C<configure> + + 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<new> + + my $config = App::Sqitch::Command::config->new($params); + +Creates and returns a new C<config> command object. The supported parameters +include: + +=over + +=item C<sqitch> + +The core L<Sqitch|App::Sqitch> object. + +=item C<file> + +Configuration file to read from and write to. + +=item C<action> + +The action to be executed. May be one of: + +=over + +=item * C<get> + +=item * C<get-all> + +=item * C<get-regexp> + +=item * C<set> + +=item * C<add> + +=item * C<replace-all> + +=item * C<unset> + +=item * C<unset-all> + +=item * C<list> + +=item * C<edit> + +=item * C<rename-section> + +=item * C<remove-section> + +=back + +If not specified, the action taken by C<execute()> will depend on the number +of arguments passed to it. If only one, the action will be C<get>. If two or +more, the action will be C<set>. + +=item C<context> + +The configuration file context. Must be one of: + +=over + +=item * C<local> + +=item * C<user> + +=item * C<system> + +=back + +=item C<type> + +The type to cast a value to be set to or fetched as. May be one of: + +=over + +=item * C<bool> + +=item * C<int> + +=item * C<num> + +=item * C<bool-or-int> + +=back + +If not specified or C<undef>, no casting will be performed. + +=back + +=head2 Instance Methods + +These methods are mainly provided as utilities for the command subclasses to +use. + +=head3 C<execute> + + $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<get> + + $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<get_all> + + $config->get_all($key); + $config->get_all($key, $regex); + +Like C<get()>, 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<get_regex> + + $config->get_regex($key); + $config->get_regex($key, $regex); + +Like C<get_all()>, but the first parameter is a regular expression that will +be matched against all keys. + +=head3 C<set> + + $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<add> + + $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<replace_all> + + $config->replace_all($key, $value); + $config->replace_all($key, $value, $regex); + +Replace all matching values. + +=head3 C<unset> + + $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<unset()> will exit with an error. + +=head3 C<unset_all> + + $config->unset_all($key); + $config->unset_all($key, $regex); + +Like C<unset()>, but will not exit with an error if the key has multiple +values. + +=head3 C<rename_section> + + $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<remove_section> + + $config->remove_section($section); + +Removes a section. Exits with an error if the section does not exist. + +=head3 C<list> + + $config->list; + +Lists all of the values in the configuration. If the context is C<local>, +C<user>, or C<system>, only the settings set for that context will be emitted. +Otherwise, all settings will be listed. + +=head3 C<edit> + + $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<Sqitch/editor>. + +=head2 Instance Accessors + +=head3 C<file> + + my $file_name = $config->file; + +Returns the path to the configuration file to be acted upon. If the context is +C<system>, then the value returned is C<$($etc_prefix)/sqitch.conf>. If the +context is C<user>, then the value returned is C<~/.sqitch/sqitch.conf>. +Otherwise, the default is F<./sqitch.conf>. + +=head1 See Also + +=over + +=item L<sqitch-config> + +Help for the C<config> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<deploy> command, you probably want to be +reading C<sqitch-deploy>. But if you really want to know how the C<deploy> command +works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C<options> + + my @opts = App::Sqitch::Command::deploy->options; + +Returns a list of L<Getopt::Long> option specifications for the command-line +options for the C<deploy> command. + +=head2 Attributes + +=head3 C<log_only> + +Boolean indicating whether to log the deploy without running the scripts. + +=head3 C<mode> + +Deploy mode, one of "change", "tag", or "all". + +=head3 C<target> + +The deployment target URI. + +=head3 C<to_change> + +Change up to which to deploy. + +=head3 C<verify> + +Boolean indicating whether or not to run verify scripts after each change. + +=head2 Instance Methods + +=head3 C<execute> + + $deploy->execute; + +Executes the deploy command. + +=head1 See Also + +=over + +=item L<sqitch-deploy> + +Documentation for the C<deploy> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<extra_target_keys> + +Returns a list of additional option keys to be specified via options. + +=head2 Instance Methods + +=head2 Attributes + +=head3 C<properties> + +Hash of property values to set. + +=head3 C<execute> + + $engine->execute($command); + +Executes the C<engine> command. + +=head3 C<add> + +Implements the C<add> action. + +=head3 C<alter> + +Implements the C<alter> action. + +=head3 C<list> + +Implements the C<list> action. + +=head3 C<remove> + +=head3 C<rm> + +Implements the C<remove> action. + +=head3 C<show> + +Implements the C<show> action. + +=head3 C<update_config> + +Implements the C<update_config> action. + +=head1 See Also + +=over + +=item L<sqitch-engine> + +Documentation for the C<engine> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<help> command, you probably want to be +reading C<sqitch-help>. But if you really want to know how the C<help> command +works, read on. + +=head1 Interface + +=head2 Attributes + +=head3 C<guide> + +Boolean indicating whether to list the guides. + +=head2 Instance Methods + +=head3 C<execute> + + $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<Sqitch core documentation|sqitch> will be shown. + +=head3 C<find_and_show> + + $help->find_and_show($file, %options); + +Does the work of finding the pod file C<$file> and passing it on to +L<Pod::Usage>, along with any additional options for Pod::Usage's constructor. + +=head1 See Also + +=over + +=item L<sqitch-help> + +Documentation for the C<help> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<sqitch.conf> file and directories for deploy and revert +scripts. + +=head1 Interface + +=head2 Class Methods + +=head3 C<options> + + my @opts = App::Sqitch::Command::init->options; + +Returns a list of L<Getopt::Long> option specifications for the command-line +options for the C<config> command. + +=head3 C<extra_target_keys> + +Returns a list of additional option keys to be specified via options. + +=head2 Attributes + +=head3 C<uri> + +URI for the project. + +=head3 C<properties> + +Hash of property values to set. + +=head2 Instance Methods + +=head3 C<execute> + + $init->execute($project); + +Executes the C<init> command. + +=head3 C<write_config> + + $init->write_config; + +Writes out the configuration file. Called by C<execute()>. + +=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/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} = <<EOF; +%{:event}C%e %H%{reset}C%T +name %n +project %o +%{requires}a%{conflicts}aplanner %{name}p <%{email}p> +planned %{date:raw}p +committer %{name}c <%{email}c> +committed %{date:raw}c + +%{ }B +EOF + +$FORMATS{full} = <<EOF; +%{:event}C%L %h%{reset}C%T +%{name}_ %n +%{project}_ %o +%R%X%{planner}_ %p +%{planned}_ %{date}p +%{committer}_ %c +%{committed}_ %{date}c + +%{ }B +EOF + +$FORMATS{long} = <<EOF; +%{:event}C%L %h%{reset}C%T +%{name}_ %n +%{project}_ %o +%{planner}_ %p +%{committer}_ %c + +%{ }B +EOF + +$FORMATS{medium} = <<EOF; +%{:event}C%L %h%{reset}C +%{name}_ %n +%{committer}_ %c +%{date}_ %{date}c + +%{ }B +EOF + +$FORMATS{short} = <<EOF; +%{:event}C%L %h%{reset}C +%{name}_ %n +%{committer}_ %c + +%{ }s +EOF + +$FORMATS{oneline} = '%{:event}C%h %l%{reset}C %o:%n %s'; + +has target => ( + 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<log> command, you probably want to be +reading C<sqitch-log>. But if you really want to know how the C<log> command +works, read on. + +=head1 Interface + +=head2 Attributes + +=head3 C<change_pattern> + +Regular expression to match against change names. + +=head3 C<committer_pattern> + +Regular expression to match against committer names. + +=head3 C<project_pattern> + +Regular expression to match against project names. + +=head3 C<event> + +Event type buy which to filter entries to display. + +=head3 C<format> + +Display format template. + +=head3 C<max_count> + +Maximum number of entries to display. + +=head3 C<reverse> + +Reverse the usual order of the display of entries. + +=head3 C<headers> + +Output headers. Defaults to true. + +=head3 C<skip> + +Number of entries to skip before displaying entries. + +=head3 C<target> + +The database target from which to read the log. + +=head2 Instance Methods + +=head3 C<execute> + + $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<sqitch-log> + +Documentation for the C<log> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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} = <<EOF; +%{:event}C%e %H%{reset}C%T +name %n +project %o +%{requires}a%{conflicts}aplanner %{name}p <%{email}p> +planned %{date:raw}p + +%{ }B +EOF + +$FORMATS{full} = <<EOF; +%{:event}C%L %h%{reset}C%T +%{name}_ %n +%{project}_ %o +%R%X%{planner}_ %p +%{planned}_ %{date}p + +%{ }B +EOF + +$FORMATS{long} = <<EOF; +%{:event}C%L %h%{reset}C%T +%{name}_ %n +%{project}_ %o +%{planner}_ %p + +%{ }B +EOF + +$FORMATS{medium} = <<EOF; +%{:event}C%L %h%{reset}C +%{name}_ %n +%{planner}_ %p +%{date}_ %{date}p + +%{ }B +EOF + +$FORMATS{short} = <<EOF; +%{:event}C%L %h%{reset}C +%{name}_ %n +%{planner}_ %p + +%{ }s +EOF + +$FORMATS{oneline} = '%{:event}C%h %l%{reset}C %n%{cyan}C%t%{reset}C'; + +has target => ( + 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<plan> command, you probably want to be +reading C<sqitch-plan>. But if you really want to know how the C<plan> command +works, read on. + +=head1 Interface + +=head2 Attributes + +=head3 C<change_pattern> + +Regular expression to match against change names. + +=head3 C<planner_pattern> + +Regular expression to match against planner names. + +=head3 C<event> + +Event type buy which to filter entries to display. + +=head3 C<format> + +Display format template. + +=head3 C<max_count> + +Maximum number of entries to display. + +=head3 C<reverse> + +Reverse the usual order of the display of entries. + +=head3 C<headers> + +Output headers. Defaults to true. + +=head3 C<skip> + +Number of entries to skip before displaying entries. + +=head2 Instance Methods + +=head3 C<execute> + + $plan->execute; + +Executes the plan command. The plan will be searched and the results output. + +=head1 See Also + +=over + +=item L<sqitch-plan> + +Documentation for the C<plan> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<rebase> command, you probably want to be +reading C<sqitch-rebase>. But if you really want to know how the C<rebase> command +works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C<options> + + my @opts = App::Sqitch::Command::rebase->options; + +Returns a list of L<Getopt::Long> option specifications for the command-line +options for the C<rebase> command. + +=head2 Attributes + +=head3 C<onto_change> + +Change onto which to rebase the target. + +=head3 C<upto_change> + +Change up to which to rebase the target. + +=head2 Instance Methods + +=head3 C<execute> + + $rebase->execute; + +Executes the rebase command. + +=head1 See Also + +=over + +=item L<sqitch-rebase> + +Documentation for the C<rebase> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<revert> command, you probably want to be +reading C<sqitch-revert>. But if you really want to know how the C<revert> command +works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C<options> + + my @opts = App::Sqitch::Command::revert->options; + +Returns a list of L<Getopt::Long> option specifications for the command-line +options for the C<revert> command. + +=head2 Attributes + +=head3 C<log_only> + +Boolean indicating whether to log the deploy without running the scripts. + +=head3 C<no_prompt> + +Boolean indicating whether or not to prompt the user to really go through with +the revert. + +=head3 C<prompt_accept> + +Boolean value to indicate whether or not the default value for the prompt, +should the user hit C<return>, is to accept the prompt or deny it. + +=head3 C<target> + +The deployment target URI. + +=head3 C<to_change> + +Change to revert to. + +=head2 Instance Methods + +=head3 C<execute> + + $revert->execute; + +Executes the revert command. + +=head1 See Also + +=over + +=item L<sqitch-revert> + +Documentation for the C<revert> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<options> + + my @opts = App::Sqitch::Command::rework->options; + +Returns a list of L<Getopt::Long> option specifications for the command-line +options for the C<rework> command. + +=head3 C<configure> + + 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<change_name> + +The name of the change to be reworked. + +=head3 C<note> + +Text of the change note. + +=head3 C<requires> + +List of required changes. + +=head3 C<conflicts> + +List of conflicting changes. + +=head3 C<all> + +Boolean indicating whether or not to run the command against all plans in the +project. + +=head2 Instance Methods + +=head3 C<execute> + + $rework->execute($command); + +Executes the C<rework> command. + +=head1 See Also + +=over + +=item L<sqitch-rework> + +Documentation for the C<rework> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<show> command, you probably want to be +reading C<sqitch-show>. But if you really want to know how the C<show> command +works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C<options> + + my @opts = App::Sqitch::Command::show->options; + +Returns a list of L<Getopt::Long> option specifications for the command-line +options for the C<show> command. + +=head2 Attributes + +=head3 C<exists_only> + +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<execute> + + $show->execute; + +Executes the show command. + +=head1 See Also + +=over + +=item L<sqitch-show> + +Documentation for the C<show> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<status> command, you probably want to be +reading C<sqitch-status>. But if you really want to know how the C<status> command +works, read on. + +=head1 Interface + +=head2 Attributes + +=head3 C<target_name> + +The name or URI of the database target as specified by the C<--target> option. + +=head3 C<target> + +An L<App::Sqitch::Target> object from which to read the status. Must be +instantiated by C<execute()>. + +=head2 Instance Methods + +=head3 C<execute> + + $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<emit_changes> + + $status->emit_changes; + +Emits a list of deployed changes if C<show_changes> is true. + +=head3 C<emit_tags> + + $status->emit_tags; + +Emits a list of deployed tags if C<show_tags> is true. + +=head3 C<emit_state> + + $status->emit_state($state); + +Emits the current state of the target database. Pass in a state hash as +returned by L<App::Sqitch::Engine> C<current_state()>. + +=head3 C<emit_status> + + $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<App::Sqitch::Engine> +C<current_state()>. Throws an exception if the current state's change cannot +be found in the plan. + +=head1 See Also + +=over + +=item L<sqitch-status> + +Documentation for the C<status> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<tag_name> + +The name of the tag to add. + +=head3 C<change_name> + +The name of the change to tag. + +=head3 C<all> + +Boolean indicating whether or not to run the command against all plans in the +project. + +=head3 C<note> + +Text of the tag note. + +=head2 Instance Methods + +=head3 C<execute> + + $tag->execute($command); + +Executes the C<tag> command. + +=head1 See Also + +=over + +=item L<sqitch-tag> + +Documentation for the C<tag> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<extra_target_keys> + +Returns a list of additional option keys to be specified via options. + +=head2 Instance Methods + +=head2 Attributes + +=head3 C<properties> + +Hash of property values to set. + +=head3 C<verbose> + +Verbosity. + +=head3 C<execute> + + $target->execute($command); + +Executes the C<target> command. + +=head3 C<add> + +Implements the C<add> action. + +=head3 C<alter> + +Implements the C<alter> action. + +=head3 C<list> + +Implements the C<list> action. + +=head3 C<remove> + +=head3 C<rm> + +Implements the C<remove> action. + +=head3 C<rename> + +Implements the C<rename> action. + +=head3 C<show> + +Implements the C<show> action. + +=head1 See Also + +=over + +=item L<sqitch-target> + +Documentation for the C<target> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<upgrade> command, you probably want to be +reading C<sqitch-upgrade>. But if you really want to know how the C<upgrade> +command works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C<options> + + my @opts = App::Sqitch::Command::upgrade->options; + +Returns a list of L<Getopt::Long> option specifications for the command-line +options for the C<upgrade> command. + +=head2 Attributes + +=head3 C<target> + +The upgrade target. + +=head2 Instance Methods + +=head3 C<execute> + + $upgrade->execute; + +Executes the upgrade command. + +=head1 See Also + +=over + +=item L<sqitch-upgrade> + +Documentation for the C<upgrade> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<verify> command, you probably want to be +reading C<sqitch-verify>. But if you really want to know how the C<verify> command +works, read on. + +=head1 Interface + +=head2 Class Methods + +=head3 C<options> + + my @opts = App::Sqitch::Command::verify->options; + +Returns a list of L<Getopt::Long> option specifications for the command-line +options for the C<verify> command. + +=head2 Attributes + +=head3 C<onto_change> + +Change onto which to rebase the target. + +=head3 C<target> + +The verify target database URI. + +=head3 C<from_change> + +Change from which to verify changes. + +=head3 C<to_change> + +Change up to which to verify changes. + +=head2 Instance Methods + +=head3 C<execute> + + $verify->execute; + +Executes the verify command. + +=head1 See Also + +=over + +=item L<sqitch-verify> + +Documentation for the C<verify> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<Config::GitLike>, and therefore provides the complete interface of that +module. + +=head1 Interface + +=head2 Instance Methods + +=head3 C<confname> + +Returns the configuration file base name, which is F<sqitch.conf>. + +=head3 C<system_dir> + +Returns the path to the system configuration directory, which is +F<$(prefix)/etc/sqitch/templates>. Call C<sqitch --etc-path> to find out +where, exactly (e.g., C<$(sqitch --etc-path)/sqitch.plan>). + +=head3 C<user_dir> + +Returns the path to the user configuration directory, which is F<~/.sqitch/>. + +=head3 C<system_file> + +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<sqitch --etc-path> +to find out where, exactly (e.g., C<$(sqitch --etc-path)/sqitch.plan>). + +=head3 C<global_file> + +An alias for C<system_file()> for use by the parent class. + +=head3 C<user_file> + +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<local_file> + +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<dir_file> + +An alias for C<local_file()> for use by the parent class. + +=head3 C<initialized> + + 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<get_section> + + 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<add_comment> + +Adds a comment to the configuration file. + +=head3 C<initial_key> + + 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<original_key>, only in the case where there are +multiple keys (for multivalue keys), only the first key is returned. + +=head1 See Also + +=over + +=item * L<Config::GitLike> + +=item * L<App::Sqitch::Command::config> + +=item * L<sqitch-config> + +=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/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<DateTime> provides additional interfaces to support named +formats. These can be used for L<status|sqitch-status> or L<log|sqitch-log> +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<as_string_formats> + + my @formats = App::Sqitch::DateTime->as_string_formats; + +Returns a list of formats supported by the C<format> parameter to +C<as_string>. The list currently includes: + +=over + +=item C<iso> + +=item C<iso8601> + +ISO-8601 format. + +=item C<rfc> + +=item C<rfc2822> + +RFC-2822 format. + +=item C<full> + +=item C<long> + +=item C<medium> + +=item C<short> + +Localized format of the specified length. + +=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 + +=head3 C<validate_as_string_format> + + App::Sqitch::DateTime->validate_as_string_format($format); + +Validates that a format is supported by C<as_string>. Throws an exception if +it's not, and returns if it is. + +=head2 Instance Methods + +=head3 C<as_string> + + $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<as_string_formats> or an exception will be thrown. If +no format is passed, the string will be formatted with the C<raw> format. + +=head1 See Also + +=over + +=item L<sqitch-status> + +Documentation for the C<status> command to the Sqitch command-line client. + +=item L<sqitch-log> + +Documentation for the C<log> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<key> + + my $name = App::Sqitch::Engine->key; + +The key name of the engine. Should be the last part of the package name. + +=head3 C<name> + + my $name = App::Sqitch::Engine->name; + +The name of the engine. Returns the same value as C<key> by default, but +should probably be overridden to return a display name for the engine. + +=head3 C<default_registry> + + 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<sqitch>, but some must do more munging, such as +specifying a file name, to determine the default registry name. + +=head3 C<default_client> + + 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<driver> + + 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<use>. Used internally by C<use_driver()> +to C<use> the driver and, if it dies, to display an appropriate error message. +Must be overridden by subclasses. + +=head3 C<use_driver> + + App::Sqitch::Engine->use_driver; + +Uses the driver and version returned by C<driver>. Returns an error on failure +and returns true on success. + +=head3 C<config_vars> + + 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<engine.$engine_name> 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<any> + +=item C<int> + +=item C<num> + +=item C<bool> + +=item C<bool-or-int> + +=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<port> variable will be stored and retrieved as an +integer. The C<set> 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<registry_release> + +Returns the version of the registry understood by this release of Sqitch. The +C<needs_upgrade()> method compares this value to that returned by +C<registry_version()> to determine whether the target's registry needs +upgrading. + +=head2 Constructors + +=head3 C<load> + + 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<new>, passing the Sqitch object. Supported +parameters are: + +=over + +=item C<sqitch> + +The App::Sqitch object driving the whole thing. + +=back + +=head3 C<new> + + my $engine = App::Sqitch::Engine->new(%params); + +Instantiates and returns a App::Sqitch::Engine object. + +=head2 Instance Accessors + +=head3 C<sqitch> + +The current Sqitch object. + +=head3 C<target> + +An L<App::Sqitch::Target> object identifying the database target, usually +derived from the name of target specified on the command-line, or the default. + +=head3 C<uri> + +A L<URI::db> object representing the target database. Defaults to a URI +constructed from the L<App::Sqitch> C<db_*> attributes. + +=head3 C<destination> + +A string identifying the target database. Usually the same as the C<target>, +unless it's a URI with the password included, in which case it returns the +value of C<uri> with the password removed. + +=head3 C<registry> + +The name of the registry schema or database. + +=head3 C<start_at> + +The point in the plan from which to start deploying changes. + +=head3 C<no_prompt> + +Boolean indicating whether or not to prompt for reverts. False by default. + +=head3 C<log_only> + +Boolean indicating whether or not to log changes I<without running deploy or +revert scripts>. This is useful for an existing database schema that needs to +be converted to Sqitch. False by default. + +=head3 C<with_verify> + +Boolean indicating whether or not to run the verification script after each +deploy script. False by default. + +=head3 C<variables> + +A hash of engine client variables to be set. May be set and retrieved as a +list. + +=head2 Instance Methods + +=head3 C<username> + + 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<sqitch-authentication> for details and best practices for Sqitch engine +authentication. + +=head3 C<password> + + 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<sqitch-authentication> for details and best practices for Sqitch engine +authentication. + +=head3 C<registry_destination> + + 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<target()>, +but some engines, such as L<SQLite|App::Sqitch::Engine::sqlite>, may use a +separate database. Used internally to name the target when the registration +tables are created. + +=head3 C<variables> + +=head3 C<set_variables> + +=head3 C<clear_variables> + + 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<deploy> and C<revert>, if the +client supports variables. For example, the +L<PostgreSQL|App::Sqitch::Engine::pg> and +L<Vertica|App::Sqitch::Engine::vertica> engines pass all the variables to +their C<psql> and C<vsql> clients via the C<--set> option, while the +L<MySQL engine|App::Sqitch::Engine::mysql> engine sets them via the C<SET> +command and the L<Oracle engine|App::Sqitch::Engine::oracle> engine sets them +via the SQL*Plus C<DEFINE> command. + + +=head3 C<deploy> + + $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<index_of()> method of L<App::Sqitch::Plan>. +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<all> + +In the event of failure, revert all deployed changes, back to the point at +which deployment started. 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 during this deployment, all +changes will be reverted to the pint at which deployment began. + +=item C<change> + +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<may be left in a corrupted state>. Write your revert scripts carefully! + +=head3 C<revert> + + $engine->revert; + $engine->revert($tag); + $engine->revert($tag); + +Reverts the L<App::Sqitch::Plan::Tag> from the database, including all of its +associated changes. + +=head3 C<verify> + + $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<sqitchchanges>, 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<check_deploy_dependencies> + + $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<deploy()> +to ensure that dependencies will be satisfied before deploying any changes. + +=head3 C<check_revert_dependencies> + + $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<revert()> to ensure no dependencies will be +violated before revering any changes. + +=head3 C<deploy_change> + + $engine->deploy_change($change); + $engine->deploy_change($change); + +Used internally by C<deploy()> to deploy an individual change. + +=head3 C<revert_change> + + $engine->revert_change($change); + $engine->revert_change($change); + +Used internally by C<revert()> (and, by C<deploy()> when a deploy fails) to +revert an individual change. + +=head3 C<verify_change> + + $engine->verify_change($change); + +Used internally by C<deploy_change()> to verify a just-deployed change if +C<with_verify> is true. + +=head3 C<is_deployed> + + say "Tag deployed" if $engine->is_deployed($tag); + say "Change deployed" if $engine->is_deployed($change); + +Convenience method that dispatches to C<is_deployed_tag()> or +C<is_deployed_change()> as appropriate to its argument. + +=head3 C<earliest_change> + + my $change = $engine->earliest_change; + my $change = $engine->earliest_change($offset); + +Returns the L<App::Sqitch::Plan::Change> 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<latest_change> + + my $change = $engine->latest_change; + my $change = $engine->latest_change($offset); + +Returns the L<App::Sqitch::Plan::Change> 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<change_for_key> + + 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<sqitchchanges>. Throws an +exception if the key matches more than one changes. Returns C<undef> if it +matches no changes. + +=head3 C<change_id_for_key> + + 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<sqitchchanges>, and returns the +change's ID. Throws an exception if the key matches more than one change. +Returns C<undef> if it matches no changes. + +=head3 C<change_for_key> + + 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<sqitchchanges>. +Throws an exception if the key matches multiple changes. + +=head3 C<change_id_for_depend> + + say 'Dependency satisfied' if $engine->change_id_for_depend($depend); + +Returns the change ID for a L<dependency|App::Sqitch::Plan::Depend>, if the +dependency resolves to a change currently deployed to the database. Returns +C<undef> if the dependency resolves to no currently-deployed change. + +=head3 C<find_change> + + my $change = $engine->find_change(%params); + +Finds and returns a deployed change, or C<undef> if the change has not been +deployed. The supported parameters are: + +=over + +=item C<change_id> + +The change ID. + +=item C<change> + +A change name. + +=item C<tag> + +A tag name. + +=item C<project> + +A project name. Defaults to the current project. + +=item C<offset> + +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<find_change_id> + + my $change_id = $engine->find_change_id(%params); + +Like C<find_change()>, taking the same parameters, but returning an ID instead +of a change. + +=head3 C<run_deploy> + + $engine->run_deploy($deploy_file); + +Runs a deploy script. The implementation is just an alias for C<run_file()>; +subclasses may override as appropriate. + +=head3 C<run_revert> + + $engine->run_revert($revert_file); + +Runs a revert script. The implementation is just an alias for C<run_file()>; +subclasses may override as appropriate. + +=head3 C<run_verify> + + $engine->run_verify($verify_file); + +Runs a verify script. The implementation is just an alias for C<run_file()>; +subclasses may override as appropriate. + +=head3 C<run_upgrade> + + $engine->run_upgrade($upgrade_file); + +Runs an upgrade script. The implementation is just an alias for C<run_file()>; +subclasses may override as appropriate. + +=head3 C<needs_upgrade> + + if ($engine->needs_upgrade) { + $engine->upgrade_registry; + } + +Determines if the target's registry needs upgrading and returns true if it +does. + +=head3 C<upgrade_registry> + + $engine->upgrade_registry; + +Upgrades the target's registry, if it needs upgrading. Used by the +L<C<upgrade>|App::Sqitch::Command::upgrade> command. + +=head2 Abstract Instance Methods + +These methods must be overridden in subclasses. + +=head3 C<begin_work> + + $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<finish_work> or C<rollback_work>. + +=head3 C<finish_work> + + $engine->finish_work($change); + +This method is called after a change has been deployed or reverted. It should +unlock the lock created by C<begin_work>. + +=head3 C<rollback_work> + + $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<begin_work>. + +=head3 C<initialized> + + $engine->initialize unless $engine->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C<initialize> + + $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<register_project> + + $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<is_deployed_tag> + + say 'Tag deployed' if $engine->is_deployed_tag($tag); + +Should return true if the L<tag|App::Sqitch::Plan::Tag> has been applied to +the database, and false if it has not. + +=head3 C<is_deployed_change> + + say 'Change deployed' if $engine->is_deployed_change($change); + +Should return true if the L<change|App::Sqitch::Plan::Change> has been +deployed to the database, and false if it has not. + +=head3 C<are_deployed_changes> + + 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<deploy> to ensure that no changes already deployed are +re-deployed. + +=head3 C<change_id_for> + + 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<undef> if it matches no changes. If it matches more than one +change, it returns the earliest deployed change if the C<first> parameter is +passed; otherwise it throws an exception The parameters are as follows: + +=over + +=item C<change> + +The name of a change. Required unless C<tag> or C<change_id> is passed. + +=item C<change_id> + +The ID of a change. Required unless C<tag> or C<change> is passed. Useful +to determine whether an ID in a plan has been deployed to the database. + +=item C<tag> + +The name of a tag. Required unless C<change> is passed. + +=item C<project> + +The name of the project to search. Defaults to the current project. + +=item C<first> + +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<change> and C<tag> are passed, C<find_change_id> will search for the +last instance of the named change deployed I<before> the tag. + +=head3 C<changes_requiring_change> + + 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<change_id> + +The requiring change ID. + +=item C<change> + +The requiring change name. + +=item C<project> + +The project the requiring change is from. + +=item C<asof_tag> + +Name of the first tag to be applied after the requiring change was deployed, +if any. + +=back + +=head3 C<log_deploy_change> + + $engine->log_deploy_change($change); + +Should write the records to the registry necessary to indicate that the change +has been deployed. + +=head3 C<log_fail_change> + + $engine->log_fail_change($change); + +Should write to the database event history a record reflecting that deployment +of the change failed. + +=head3 C<log_revert_change> + + $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<log_new_tags> + + $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<earliest_change_id> + + 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<latest_change_id> + + 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<deployed_changes> + + 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<id> + +The change ID. + +=item C<name> + +The change name. + +=item C<project> + +The name of the project with which the change is associated. + +=item C<note> + +The note attached to the change. + +=item C<planner_name> + +The name of the user who planned the change. + +=item C<planner_email> + +The email address of the user who planned the change. + +=item C<timestamp> + +An L<App::Sqitch::DateTime> object representing the time the change was planned. + +=item C<tags> + +An array reference of the tag names associated with the change. + +=back + +=head3 C<deployed_changes_since> + + 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<deployed_changes()>. + +=head3 C<name_for_change_id> + + 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<app_user@beta>. Otherwise, it will include the +symbolic tag C<@HEAD>. e.g., C<widgets@HEAD>. This value should be suitable +for uniquely identifying the change, and passing to the C<get> or C<index_of> +methods of L<App::Sqitch::Plan>. + +=head3 C<registered_projects> + + my @projects = $engine->registered_projects; + +Returns a list of the names of Sqitch projects registered in the database. + +=head3 C<current_state> + + 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<undef> 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<project> + +The name of the project for which the state is reported. + +=item C<change_id> + +The current change ID. + +=item C<script_hash> + +The deploy script SHA-1 hash. + +=item C<change> + +The current change name. + +=item C<note> + +A brief description of the change. + +=item C<tags> + +An array reference of the names of associated tags. + +=item C<committed_at> + +An L<App::Sqitch::DateTime> object representing the date and time at which the +change was deployed. + +=item C<committer_name> + +Name of the user who deployed the change. + +=item C<committer_email> + +Email address of the user who deployed the change. + +=item C<planned_at> + +An L<App::Sqitch::DateTime> object representing the date and time at which the +change was added to the plan. + +=item C<planner_name> + +Name of the user who added the change to the plan. + +=item C<planner_email> + +Email address of the user who added the change to the plan. + +=back + +=head3 C<current_changes> + + 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<change_id> + +The current change ID. + +=item C<script_hash> + +The deploy script SHA-1 hash. + +=item C<change> + +The current change name. + +=item C<committed_at> + +An L<App::Sqitch::DateTime> object representing the date and time at which the +change was deployed. + +=item C<committer_name> + +Name of the user who deployed the change. + +=item C<committer_email> + +Email address of the user who deployed the change. + +=item C<planned_at> + +An L<App::Sqitch::DateTime> object representing the date and time at which the +change was added to the plan. + +=item C<planner_name> + +Name of the user who added the change to the plan. + +=item C<planner_email> + +Email address of the user who added the change to the plan. + +=back + +=head3 C<current_tags> + + 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<tag_id> + +The tag ID. + +=item C<tag> + +The name of the tag. + +=item C<committed_at> + +An L<App::Sqitch::DateTime> object representing the date and time at which the +tag was applied. + +=item C<committer_name> + +Name of the user who applied the tag. + +=item C<committer_email> + +Email address of the user who applied the tag. + +=item C<planned_at> + +An L<App::Sqitch::DateTime> object representing the date and time at which the +tag was added to the plan. + +=item C<planner_name> + +Name of the user who added the tag to the plan. + +=item C<planner_email> + +Email address of the user who added the tag to the plan. + +=back + +=head3 C<search_events> + + 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<event> + +An array of the type of event to search for. Allowed values are "deploy", +"revert", and "fail". + +=item C<project> + +Limit the events to those with project names matching the specified regular +expression. + +=item C<change> + +Limit the events to those with changes matching the specified regular +expression. + +=item C<committer> + +Limit the events to those logged for the actions of the committers with names +matching the specified regular expression. + +=item C<planner> + +Limit the events to those with changes who's planner's name matches the +specified regular expression. + +=item C<limit> + +Limit the number of events to the specified number. + +=item C<offset> + +Skip the specified number of events. + +=item C<direction> + +Return the results in the specified order, which must be a value matching +C</^(:?a|de)sc/i> for "ascending" or "descending". + +=back + +Each event is represented by a hash reference containing the following keys: + +=over + +=item C<event> + +The type of event, which is one of: + +=over + +=item C<deploy> + +=item C<revert> + +=item C<fail> + +=back + +=item C<project> + +The name of the project with which the change is associated. + +=item C<change_id> + +The change ID. + +=item C<change> + +The name of the change. + +=item C<note> + +A brief description of the change. + +=item C<tags> + +An array reference of the names of associated tags. + +=item C<requires> + +An array reference of the names of any changes required by the change. + +=item C<conflicts> + +An array reference of the names of any changes that conflict with the change. + +=item C<committed_at> + +An L<App::Sqitch::DateTime> object representing the date and time at which the +event was logged. + +=item C<committer_name> + +Name of the user who deployed the change. + +=item C<committer_email> + +Email address of the user who deployed the change. + +=item C<planned_at> + +An L<App::Sqitch::DateTime> object representing the date and time at which the +change was added to the plan. + +=item C<planner_name> + +Name of the user who added the change to the plan. + +=item C<planner_email> + +Email address of the user who added the change to the plan. + +=back + +=head3 C<run_file> + + $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<run_handle> + + $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<load_change> + + 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<deployed_changes()>. Returns C<undef> if the change +has not been deployed. + +=head3 C<change_offset_from_id> + + 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<deployed_changes()>) 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<undef>, the change +represented by C<$change_id> should be returned (just like C<load_change()>). +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<undef> 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<change_id_offset_from_id> + + my $id = $engine->change_id_offset_from_id( $change_id, $offset ); + +Like C<change_offset_from_id()> but returns the change ID rather than the +change object. + +=head3 C<registry_version> + +Should return the current version of the target's registry. + +=head1 See Also + +=over + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<initialized> + + $exasol->initialize unless $exasol->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C<initialize> + + $exasol->initialize; + +Initializes a database for Sqitch by installing the Sqitch registry schema. + +=head3 C<exaplus> + +Returns a list containing the C<exaplus> client and options to be passed to it. +Used internally when executing scripts. + +=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/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<connection_string> + +Constructs a connection string from a database URI for passing to C<isql>. + +=head3 C<isql> + +Returns a list containing the C<isql> client and options to be passed to it. +Used internally when executing scripts. + +=head1 Author + +David E. Wheeler <david@justatheory.com> + +Ștefan Suciu <stefan@s2i2.ro> + +=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<mysql> + +Returns a list containing the C<mysql> client and options to be passed to it. +Used internally when executing scripts. Query parameters in the URI that map +to C<mysql> client options will be passed to the client, as follows: + +=over + +=item * C<mysql_compression=1>: C<--compress> + +=item * C<mysql_ssl=1>: C<--ssl> + +=item * C<mysql_connect_timeout>: C<--connect_timeout> + +=item * C<mysql_init_command>: C<--init-command> + +=item * C<mysql_socket>: C<--socket> + +=item * C<mysql_ssl_client_key>: C<--ssl-key> + +=item * C<mysql_ssl_client_cert>: C<--ssl-cert> + +=item * C<mysql_ssl_ca_file>: C<--ssl-ca> + +=item * C<mysql_ssl_ca_path>: C<--ssl-capath> + +=item * C<mysql_ssl_cipher>: C<--ssl-cipher> + +=back + +=head3 C<username> + +=head3 C<password> + +Overrides the methods provided by the target so that, if the target has +no username or password, Sqitch looks them up in the +L<F</etc/my.cnf> 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 <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/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<initialized> + + $oracle->initialize unless $oracle->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C<initialize> + + $oracle->initialize; + +Initializes a database for Sqitch by installing the Sqitch registry schema. + +=head3 C<sqlplus> + +Returns a list containing the C<sqlplus> client and options to be passed to it. +Used internally when executing scripts. + +=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/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<initialized> + + $pg->initialize unless $pg->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C<initialize> + + $pg->initialize; + +Initializes a database for Sqitch by installing the Sqitch registry schema. + +=head3 C<psql> + +Returns a list containing the C<psql> client and options to be passed to it. +Used internally when executing scripts. + +=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/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) { + # <account_name>.<region_id>.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<uri> + +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<snowflakecomputing.com>, 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<connections.accountname> setting +in the +L<SnowSQL configuration file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#configuring-default-connection-settings>, +the C<$SNOWSQL_REGION> or C<connections.region> setting in the +L<SnowSQL configuration file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#configuring-default-connection-settings>, +and C<snowflakecomputing.com>. + +=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<connections.dbname> setting in the +L<SnowSQL configuration file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#configuring-default-connection-settings>. + +=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<account>, C<username> and +C<password> attributes documented below. + +=head3 C<account> + +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<connections.accountname> setting in the +L<SnowSQL configuration file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#configuring-default-connection-settings>. + +=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<connections.username> variable from the +L<SnowSQL config file|https://docs.snowflake.net/manuals/user-guide/snowsql-config.html#snowsql-config-file>. + +=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<connections.password> variable from the +L<SnowSQL config file|https://docs.snowflake.net/manuals/user-guide/snowsql-config.html#snowsql-config-file>. + +=back + +=head3 C<warehouse> + +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<warehouse> query parameter of the target URI + +=item 2 + +In the C<$SNOWSQL_WAREHOUSE> environment variable. + +=item 3 + +In the C<connections.warehousename> variable from the +L<SnowSQL config file|https://docs.snowflake.net/manuals/user-guide/snowsql-config.html#snowsql-config-file>. + +=item 4 + +If none of the above are found, it falls back on the hard-coded value +"sqitch". + +=back + +=head3 C<role> + +Returns the role to use for all connections. Sqitch looks for the role in this +order: + +=over + +=item 1 + +In the C<role> query parameter of the target URI + +=item 2 + +In the C<$SNOWSQL_ROLE> environment variable. + +=item 3 + +In the C<connections.rolename> variable from the +L<SnowSQL config file|https://docs.snowflake.net/manuals/user-guide/snowsql-config.html#snowsql-config-file>. + +=item 4 + +If none of the above are found, no role will be set. + +=back + +=head2 Instance Methods + +=head3 C<initialized> + + $snowflake->initialize unless $snowflake->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C<initialize> + + $snowflake->initialize; + +Initializes a database for Sqitch by installing the Sqitch registry schema. + +=head3 C<snowsql> + +Returns a list containing the C<snowsql> client and options to be passed to +it. Used internally when executing scripts. + +=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/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<client> + +Returns the path to the SQLite client. If C<--client> was passed to C<sqitch>, +that's what will be returned. Otherwise, it uses the C<engine.sqlite.client> +configuration value, or else defaults to C<sqlite3> (or C<sqlite3.exe> on +Windows), which should work if it's in your path. + +=head2 Instance Methods + +=head3 C<sqlite3> + +Returns a list containing the C<sqlite3> client and options to be passed to it. +Used internally when executing scripts. + +=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/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<initialized> + + $vertica->initialize unless $vertica->initialized; + +Returns true if the database has been initialized for Sqitch, and false if it +has not. + +=head3 C<initialize> + + $vertica->initialize; + +Initializes a database for Sqitch by installing the Sqitch registry schema. + +=head3 C<vsql> + +Returns a list containing the C<vsql> client and options to be passed to it. +Used internally when executing scripts. + +=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/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<C<log>|sqitch-log> uses it to format the events it finds. It uses +L<String::Formatter> 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<format()> is a format and then a hash reference of values to be used +in the format. + +=head1 Interface + +=head2 Constructor + +=head3 C<new> + + my $formatter = App::Sqitch::ItemFormatter->new(%params); + +Constructs and returns a formatter object. The supported parameters are: + +=over + +=item C<abbrev> + +Instead of showing the full 40-byte hexadecimal change ID, format as a partial +prefix the specified number of characters long. + +=item C<date_format> + +Format to use for timestamps. Defaults to C<iso>. Allowed values: + +=over + +=item C<iso> + +=item C<iso8601> + +ISO-8601 format. + +=item C<rfc> + +=item C<rfc2822> + +RFC-2822 format. + +=item C<full> + +=item C<long> + +=item C<medium> + +=item C<short> + +A format length to pass to the system locale's C<LC_TIME> category. + +=item C<raw> + +Raw format, which is strict ISO-8601 in the UTC time zone. + +=item C<strftime:$string> + +An arbitrary C<strftime> pattern. See L<DateTime/strftime Paterns> for +comprehensive documentation of supported patterns. + +=item C<cldr:$pattern> + +An arbitrary C<cldr> pattern. See L<DateTime/CLDR Paterns> for comprehensive +documentation of supported patterns. + +=back + +=item C<color> + +Controls the use of ANSI color formatting. The value may be one of: + +=over + +=item C<auto> (the default) + +=item C<always> + +=item C<never> + +=back + +=item C<formatter> + +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<format> + + $formatter->format( $format, $item ); + +Formats an item as a string and returns it. The item will be formatted using +the first argument. See L</Formats> 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<event> + +The type of event, which is one of: + +=over + +=item C<deploy> + +=item C<revert> + +=item C<fail> + +=back + +=item C<project> + +The name of the project with which the change is associated. + +=item C<change_id> + +The change ID. + +=item C<change> + +The name of the change. + +=item C<note> + +A brief description of the change. + +=item C<tags> + +An array reference of the names of associated tags. + +=item C<requires> + +An array reference of the names of any changes required by the change. + +=item C<conflicts> + +An array reference of the names of any changes that conflict with the change. + +=item C<committed_at> + +An L<App::Sqitch::DateTime> object representing the date and time at which the +event was logged. + +=item C<committer_name> + +Name of the user who deployed the change. + +=item C<committer_email> + +Email address of the user who deployed the change. + +=item C<planned_at> + +An L<App::Sqitch::DateTime> object representing the date and time at which the +change was added to the plan. + +=item C<planner_name> + +Name of the user who added the change to the plan. + +=item C<planner_email> + +Email address of the user who added the change to the plan. + +=back + +=head1 Formats + +The format argument to C<format()> specifies the item information to be +included in the resulting string. 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 + +=head1 See Also + +=over + +=item L<sqitch-log> + +Documentation for the C<log> command to the Sqitch command-line client. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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 + (?<![$punct])\b # last character isn't punctuation +}x; + +my %reserved = map { $_ => 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/ + (?<yr>[[:digit:]]{4}) # year + - # dash + (?<mo>[[:digit:]]{2}) # month + - # dash + (?<dy>[[:digit:]]{2}) # day + T # T + (?<hr>[[:digit:]]{2}) # hour + : # colon + (?<mi>[[:digit:]]{2}) # minute + : # colon + (?<sc>[[:digit:]]{2}) # second + Z # Zulu time + /x; + + my $planner_re = qr/ + (?<planner_name>[^<]+) # name + [[:blank:]]+ # blanks + <(?<planner_email>[^>]+)> # 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(?<lspace>[[:blank:]]*)(?:#[[:blank:]]*(?<note>.+)|$)/) { + 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/(?<rspace>[[:blank:]]*)(?:[#][[:blank:]]*(?<note>.*))?$//; + my %params = %+; + + $raise_syntax_error->( + __ 'Invalid pragma; a blank line must come between pragmas and changes' + ) unless $line =~ / + \A # Beginning of line + (?<lspace>[[:blank:]]*)? # Optional leading space + [%] # Required % + (?<hspace>[[:blank:]]*)? # Optional space + (?<name> # 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... + (?<lopspace>[[:blank:]]*) # Optional blanks + (?<operator>=) # Required = + (?<ropspace>[[:blank:]]*) # Optional blanks + (?<value>.+) # 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(?<lspace>[[:blank:]]*)(?:#[[:blank:]]*(?<note>.+)|$)/) { + my $line = App::Sqitch::Plan::Blank->new( plan => $self, %+ ); + push @lines => $line; + next LINE; + } + + # Grab inline note. + $line =~ s/(?<rspace>[[:blank:]]*)(?:[#][[:blank:]]*(?<note>.*))?$//; + my %params = %+; + + # Is it a tag or a change? + my $type = $line =~ /^[[:blank:]]*[@]/ ? 'tag' : 'change'; + $line =~ / + ^ # Beginning of line + (?<lspace>[[:blank:]]*)? # Optional leading space + + (?: # followed by... + [@] # @ for tag + | # ...or... + (?<lopspace>[[:blank:]]*) # Optional blanks + (?<operator>[+-]) # Required + or - + (?<ropspace>[[:blank:]]*) # Optional blanks + )? # ... optionally + + (?<name>$name_re) # followed by name + (?<pspace>[[:blank:]]+)? # blanks + + (?: # followed by... + [[](?<dependencies>[^]]+)[]] # 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<SYNTAX_VERSION> + +Returns the current version of the Sqitch plan syntax. Used for the +C<%sytax-version> pragma. + +=head2 Class Methods + +=head3 C<name_regex> + + 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<new> + + my $plan = App::Sqitch::Plan->new( sqitch => $sqitch ); + +Instantiates and returns a App::Sqitch::Plan object. Takes a single parameter: +an L<App::Sqitch> object. + +=head2 Accessors + +=head3 C<sqitch> + + my $sqitch = $plan->sqitch; + +Returns the L<App::Sqitch> object that instantiated the plan. + +=head3 C<target> + + my $target = $plan->target + +Returns the L<App::Sqitch::Target> passed to the constructor. + +=head3 C<file> + + my $file = $plan->file; + +The file name from which to read the plan. + +=head3 C<position> + +Returns the current position of the iterator. This is an integer that's used +as an index into plan. If C<next()> has not been called, or if C<reset()> has +been called, the value will be -1, meaning it is outside of the plan. When +C<next> returns C<undef>, the value will be the last index in the plan plus 1. + +=head3 C<project> + + my $project = $plan->project; + +Returns the name of the project as set via the C<%project> pragma in the plan +file. + +=head3 C<uri> + + 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<undef> will be returned. + +=head3 C<syntax_version> + + my $syntax_version = $plan->syntax_version; + +Returns the plan syntax version, which is always the latest version. + +=head2 Instance Methods + +=head3 C<index_of> + + 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<undef> 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<contains> + + say 'Yes!' if $plan->contains('6c2f28d125aff1deea615f8de774599acf39a7a1'); + +Like C<index_of()>, but never throws an exception, and returns true if the +plan contains the specified change, and false if it does not. + +=head3 C<get> + + 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<index_of()>. + +=head3 C<find> + + 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<index_of()>. Unlike C<get()>, C<find()> +will not throw an error if more than one change exists with the specified name, +but will return the first instance. + +=head3 C<first_index_of> + + 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<after> 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<foo> 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<undef> if the change does not appear in the plan, or if it does not appear +after the specified second argument change name. + +=head3 C<last_tagged_change> + + my $change = $plan->last_tagged_change; + +Returns the last tagged change object. Returns C<undef> if no changes have +been tagged. + +=head3 C<change_at> + + my $change = $plan->change_at($index); + +Returns the change at the specified index. + +=head3 C<seek> + + $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<reset> + + $plan->reset; + +Resets iteration. Same as C<< $plan->position(-1) >>, but better. + +=head3 C<next> + + while (my $change = $plan->next) { + say "Deploy ", $change->format_name; + } + +Returns the next L<change|App::Sqitch::Plan::Change> in the plan. Returns C<undef> +if there are no more changes. + +=head3 C<last> + + my $change = $plan->last; + +Returns the last change in the plan. Does not change the current position. + +=head3 C<current> + + my $change = $plan->current; + +Returns the same change as was last returned by C<next()>. Returns C<undef> if +C<next()> has not been called or if the plan has been reset. + +=head3 C<peek> + + my $change = $plan->peek; + +Returns the next change in the plan without incrementing the iterator. Returns +C<undef> if there are no more changes beyond the current change. + +=head3 C<changes> + + my @changes = $plan->changes; + +Returns all of the changes in the plan. This constitutes the entire plan. + +=head3 C<tags> + + my @tags = $plan->tags; + +Returns all of the tags in the plan. + +=head3 C<count> + + my $count = $plan->count; + +Returns the number of changes in the plan. + +=head3 C<lines> + + my @lines = $plan->lines; + +Returns all of the lines in the plan. This includes all the +L<changes|App::Sqitch::Plan::Change>, L<tags|App::Sqitch::Plan::Tag>, +L<pragmas|App::Sqitch::Plan::Pragma>, and L<blank +lines|App::Sqitch::Plan::Blank>. + +=head3 C<do> + + $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<next()> has been called prior +to the call to C<do()>, 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<search_changes> + + 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<event> + +An array of the type of event to search for. Allowed values are "deploy" and + "revert". + +=item C<name> + +Limit the results to changes with names matching the specified regular +expression. + +=item C<planner> + +Limit the changes to those added by planners matching the specified regular +expression. + +=item C<limit> + +Limit the number of changes to the specified number. + +=item C<offset> + +Skip the specified number of events. + +=item C<direction> + +Return the results in the specified order, which must be a value matching +C</^(:?a|de)sc/i> for "ascending" or "descending". + +=back + +=head3 C<write_to> + + $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<from> 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<open_script> + + 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<load> + + 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<changes()> instead. And if you want to load an +alternate plan, use C<parse()>. + +=head3 C<parse> + + $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<check_changes> + + @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<tag> + + $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<name> + +The tag name to use. Required. + +=item C<change> + +The change to be tagged, specified as a supported change specification as +described in L<sqitchchanges>. Defaults to the last change in the plan. + +=item C<note> + +A brief note about the tag. + +=item C<planner_name> + +The name of the user adding the tag to the plan. Defaults to the value of the +C<user.name> configuration variable. + +=item C<planner_email> + +The email address of the user adding the tag to the plan. Defaults to the +value of the C<user.email> configuration variable. + +=back + +=head3 C<add> + + $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<App::Sqitch::Plan::Change> constructor. Exits with a fatal +error if the change already exists, or if the any of the dependencies are +unknown. + +=head3 C<rework> + + $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<C<add>|sqitch-add> and +L<C<rework>|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<syntax-version>. + +=item * A change. + +A named change change as defined in L<sqitchchanges>. 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<add_widget@delta.sql> 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 = { <pragma> | <change-line> | <tag-line> | <note-line> | <blank-line> }* ; + + blank-line = [ <blanks> ] <eol>; + note-line = <note> ; + change-line = <name> [ "[" { <requires> | <conflicts> } "]" ] ( <eol> | <note> ) ; + tag-line = <tag> ( <eol> | <note> ) ; + pragma = "%" [ <blanks> ] <name> [ <blanks> ] = [ <blanks> ] <value> ( <eol> | <note> ) ; + + tag = "@" <name> ; + requires = <name> ; + conflicts = "!" <name> ; + name = <non-punct> [ [ ? non-blank and not "@", ":", or "#" characters ? ] <non-punct> ] ; + non-punct = ? non-punctuation, non-blank character ? ; + value = ? non-EOL or "#" characters ? + + note = [ <blanks> ] "#" [ <string> ] <EOL> ; + eol = [ <blanks> ] <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<sqitch> + +The Sqitch command-line client. + +=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/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<App::Sqitch::Plan::Line> for its interface. The only +difference is that the C<name> is always an empty string. + +=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/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<App::Sqitch::Plan::Line>, 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<App::Sqitch::Plan::Line> for the basics. + +=head2 Accessors + +=head3 C<since_tag> + +An L<App::Sqitch::Plan::Tag> object representing the last tag to appear in the +plan B<before> the change. May be C<undef>. + +=head3 C<pspace> + +Blank space separating the change name from the dependencies, timestamp, and +planner in the file. + +=head3 C<is_reworked> + +Boolean indicting whether or not the change has been reworked. + +=head3 C<info> + +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<id> + +A SHA1 hash of the data returned by C<info()>, which can be used as a +globally-unique identifier for the change. + +=head3 C<timestamp> + +Returns the an L<App::Sqitch::DateTime> object representing the time at which +the change was added to the plan. + +=head3 C<planner_name> + +Returns the name of the user who added the change to the plan. + +=head3 C<planner_email> + +Returns the email address of the user who added the change to the plan. + +=head3 C<parent> + +Parent change object. + +=head3 C<tags> + +A list of tag objects associated with the change. + +=head2 Instance Methods + +=head3 C<path_segments> + + 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<deploy_dir> + + my $file = $change->deploy_dir; + +Returns the path to the deploy directory for the change. + +=head3 C<deploy_file> + + my $file = $change->deploy_file; + +Returns the path to the deploy script file for the change. + +=head3 C<revert_dir> + + my $file = $change->revert_dir; + +Returns the path to the revert directory for the change. + +=head3 C<revert_file> + + my $file = $change->revert_file; + +Returns the path to the revert script file for the change. + +=head3 C<verify_dir> + + my $file = $change->verify_dir; + +Returns the path to the verify directory for the change. + +=head3 C<verify_file> + + my $file = $change->verify_file; + +Returns the path to the verify script file for the change. + +=head3 C<script_file> + + my $file = $sqitch->script_file($script_name); + +Returns the path to a script, for the change. + +=head3 C<script_hash> + + my $hash = $change->script_hash; + +Returns the hex digest of the SHA-1 hash for the deploy script. + +=head3 C<rework_tags> + + 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<add_tag> + + $change->add_tag($tag); + +Adds a tag object to the change. + +=head3 C<add_rework_tags> + + $change->add_rework_tags(@tags); + +Adds tags to the list of rework tags. + +=head3 C<clear_rework_tags> + + $change->clear_rework_tags(@tags); + +Clears the list of rework tags. + +=head3 C<requires> + + my @requires = $change->requires; + +Returns a list of L<App::Sqitch::Plan::Depend> objects representing changes +required by this change. + +=head3 C<requires_changes> + + my @requires_changes = $change->requires_changes; + +Returns a list of the C<App::Sqitch::Plan::Change> objects representing +changes required by this change. + +=head3 C<conflicts> + + my @conflicts = $change->conflicts; + +Returns a list of L<App::Sqitch::Plan::Depend> objects representing changes +with which this change conflicts. + +=head3 C<conflicts_changes> + + my @conflicts_changes = $change->conflicts_changes; + +Returns a list of the C<App::Sqitch::Plan::Change> objects representing +changes with which this change conflicts. + +=head3 C<dependencies> + + my @dependencies = $change->dependencies; + +Returns a list of L<App::Sqitch::Plan::Depend> objects representing all +dependencies, required and conflicting. + +=head3 C<is_deploy> + +Returns true if the change is intended to be deployed, and false if it should be +reverted. + +=head3 C<is_revert> + +Returns true if the change is intended to be reverted, and false if it should be +deployed. + +=head3 C<action> + +Returns "deploy" if the change should be deployed, or "revert" if it should be +reverted. + +=head3 C<format_tag_qualified_name> + + 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<format_name_with_tags> + + 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<format_dependencies> + + 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<format_name_with_dependencies> + + 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<format_op_name_dependencies> + + my $op_name_dependencies = $change->format_op_name_dependencies; + +Like C<format_name_with_dependencies>, but includes the operator, if present. + +=head3 C<format_planner> + + 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<deploy_handle> + + my $fh = $change->deploy_handle; + +Returns an L<IO::File> file handle, opened for reading, for the deploy script +for the change. + +=head3 C<revert_handle> + + my $fh = $change->revert_handle; + +Returns an L<IO::File> file handle, opened for reading, for the revert script +for the change. + +=head3 C<verify_handle> + + my $fh = $change->verify_handle; + +Returns an L<IO::File> file handle, opened for reading, for the verify script +for the change. + +=head3 C<note_prompt> + + my $prompt = $change->note_prompt( + for => 'rework', + scripts => [$change->deploy_file, $change->revert_file], + ); + +Overrides the implementation from C<App::Sqitch::Plan::Line> to add the +C<files> 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<App::Sqitch::Plan> + +Class representing a plan. + +=item L<App::Sqitch::Plan::Line> + +Base class from which App::Sqitch::Plan::Change inherits. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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{(?<![$punct])([~^])(?:(\1)|(\d+))?\z}{} ) { + my $offset = $3 // ($2 ? 2 : 1); + $offset *= -1 if $1 eq '^'; + return $offset; + } else { + return 0; + } +} + +sub index_of { + my ( $self, $key ) = @_; + + # Look for non-deployed symbolic references. + if ( my $offset = _offset $key ) { + my $idx = $self->_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<App::Sqitch::Plan> to manage plan changes. +It's modeled on L<Array::AsHash> and L<Hash::MultiValue>, but makes allowances +for finding changes relative to tags. + +=head1 Interface + +=head2 Constructors + +=head3 C<new> + + 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<App::Sqitch::Plan::Change> 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<count> + + my $count = $changelist->count; + +Returns the number of changes in the list. + +=head3 C<changes> + + my @changes = $changelist->changes; + +Returns all of the changes in the list. + +=head3 C<tags> + + my @tags = $changelist->tags; + +Returns all of the tags associated with changes in the list. + +=head3 C<items> + + my @changes = $changelist->items; + +An alias for C<changes>. + +=head3 C<change_at> + + my $change = $change_list->change_at(10); + +Returns the change at the specified index. + +=head3 C<index_of> + + 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<first_index_of> + + 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<after> 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<undef> if the change does not appear in the list, or if it does not appear +after the specified second argument change name. + +=head3 C<last_change> + + my $change = $changelist->last_change; + +Returns the last change to be appear in the list. Returns C<undef> if the list +contains no changes. + +=head3 C<last_tagged_change> + + my $change = $changelist->last_tagged_change; + +Returns the last tagged change in the list. Returns C<undef> if the list +contains no tagged changes. + +=head3 C<index_of_last_tagged> + + my $index = $changelist->index_of_last_tagged; + +Returns the index of the last tagged change in the list. Returns C<undef> if the +list contains no tags. + +=head3 C<get> + + 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<index_of()>. 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<contains> + + say 'Yes!' if $plan->contains('6c2f28d125aff1deea615f8de774599acf39a7a1'); + +Like C<index_of()>, but never throws an exception, and returns true if the +plan contains the specified change, and false if it does not. + +=head3 C<find> + + 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<get()>, it will not throw an error +if more than one change exists with the specified name, but will return the +first instance. + +=head3 C<append> + + $changelist->append(@changes); + +Append one or more changes to the list. Does not check for duplicates, so +use with care. + +=head3 C<index_tag> + + $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<App::Sqitch::Plan> + +The Sqitch plan. + +=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/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 + (?<conflicts>!?) # Optional negation + (?:(?<project>$name_re)[:])? # Optional project + : + (?: # Followed by... + (?<id>[0-9a-f]{40}) # SHA1 hash + | # - OR - + (?<change>$name_re) # Change name + (?:[@](?<tag>$name_re))? # Optional tag + | # - OR - + (?:[@](?<tag>$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<App::Sqitch::Plan> and +included in L<App::Sqitch::Plan::Change> objects C<conflicts> and C<requires> +attributes. + +=head1 Interface + +=head2 Constructors + +=head3 C<new> + + my $depend = App::Sqitch::Plan::Depend->new(%params); + +Instantiates and returns a App::Sqitch::Plan::Line object. Parameters: + +=over + +=item C<plan> + +The plan with which the dependency is associated. Required. + +=item C<project> + +Name of the project. Required. + +=item C<conflicts> + +Boolean to indicate whether the dependency is a conflicting dependency. + +=item C<change> + +The name of the change. + +=item C<tag> + +The name of the tag claimed as the dependency. + +=item C<id> + +The ID of a change. Mutually exclusive with C<change> and C<tag>. + +=back + +=head3 C<parse> + + 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<new()>. Returns C<undef> if +the string is not a properly-formatted dependency. + +=head2 Accessors + +=head3 C<plan> + + my $plan = $depend->plan; + +Returns the L<App::Sqitch::Plan> object with which the dependency +specification is associated. + +=head3 C<conflicts> + + 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<required> + + 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<type> + + say $depend->type; + +Returns a string indicating the type of dependency, either "require" or +"conflict". + +=head3 C<project> + + my $proj = $depend->project; + +Returns the name of the project with which the dependency is associated. + +=head3 C<got_project> + +Returns true if the C<project> parameter was passed to the constructor with a +defined value, and false if it was not passed to the constructor. + +=head3 C<change> + + my $change = $depend->change; + +Returns the name of the change, if any. If C<undef> is returned, the dependency +is a tag-only dependency. + +=head3 C<tag> + + my $tag = $depend->tag; + +Returns the name of the tag, if any. If C<undef> is returned, the dependency +is a change-only dependency. + +=head3 C<id> + +Returns the ID of the change if the dependency was specified as an ID, or if +the dependency is a local dependency. + +=head3 C<got_id> + +Returns true if the C<id> parameter was passed to the constructor with a +defined value, and false if it was not passed to the constructor. + +=head3 C<resolved_id> + +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<resolved_id> will be undef. + +=head3 C<is_external> + +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<is_internal> + +The opposite of C<is_external()>: returns true if the dependency is in the +internal (current) project, and false if not. + +=head2 Instance Methods + +=head3 C<key_name> + +Returns the key name of the dependency, with the change name and/or tag, +properly formatted for passing to the C<find()> method of +L<App::Sqitch::Plan>. If the dependency was specified as an ID, rather than a +change or tag, then the ID will be returned. + +=head3 C<as_string> + +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<as_plan_string> + + 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<as_string> unless C<conflicts> returns true, +in which case it is prepended with "!". + +=head1 See Also + +=over + +=item L<App::Sqitch::Plan> + +Class representing a plan. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<App::Sqitch::Plan> object is derived from this +class. This is actually an abstract base class. See +L<App::Sqitch::Plan::Change>, L<App::Sqitch::Plan::Tag>, and +L<App::Sqitch::Plan::Blank> for concrete subclasses. + +=head1 Interface + +=head2 Constructors + +=head3 C<new> + + my $plan = App::Sqitch::Plan::Line->new(%params); + +Instantiates and returns a App::Sqitch::Plan::Line object. Parameters: + +=over + +=item C<plan> + +The L<App::Sqitch::Plan> object with which the line is associated. + +=item C<name> + +The name of the line. Should be empty for blank lines. Tags names should +not include the leading C<@>. + +=item C<lspace> + +The white space from the beginning of the line, if any. + +=item C<lopspace> + +The white space to the left of the operator, if any. + +=item C<operator> + +An operator, if any. + +=item C<ropspace> + +The white space to the right of the operator, if any. + +=item C<rspace> + +The white space after the name until the end of the line or the start of a +note. + +=item C<note> + +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<plan> + + my $plan = $line->plan; + +Returns the plan object with which the line object is associated. + +=head3 C<name> + + my $name = $line->name; + +Returns the name of the line. Returns an empty string if there is no name. + +=head3 C<lspace> + + my $lspace = $line->lspace. + +Returns the white space from the beginning of the line, if any. + +=head3 C<rspace> + + 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<note> + + 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<format_name> + + my $formatted_name = $line->format_name; + +Returns the name of the line properly formatted for output. For +L<tags|App::Sqitch::Plan::Tag>, it's the name with a leading C<@>. For all +other lines, it is simply the name. + +=head3 C<format_operator> + + 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<as_string()>. + +=head3 C<format_content> + + 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<format_note> + + my $note = $line->format_note; + +Returns the note formatted for output. That is, with a leading C<#> and +newlines encoded. + +=head3 C<as_string> + + my $string = $line->as_string; + +Returns the full stringification of the line, suitable for output to a plan +file. + +=head3 C<request_note> + + 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<for> 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<exitval> of 1. + +=head3 C<note_prompt> + + my $prompt = $line->note_prompt( for => 'tag' ); + +Returns a localized string for use in the temporary file created by +C<request_note()>. Pass in the name of the command for which to prompt via the +C<for> parameter. + +=head1 See Also + +=over + +=item L<App::Sqitch::Plan> + +Class representing a plan. + +=item L<sqitch> + +The Sqitch command-line client. + +=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/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<App::Sqitch::Plan> to manage plan file +lines. It's modeled on L<Array::AsHash>, but is much simpler and hews closer +to the API of L<App::Sqitch::Plan::ChangeList>. + +=head1 Interface + +=head2 Constructors + +=head3 C<new> + + 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<count> + +=head3 C<items> + +=head3 C<item_at> + +=head3 C<index_of> + +=head3 C<append> + +=head3 C<insert_at> + +=head1 See Also + +=over + +=item L<App::Sqitch::Plan> + +The Sqitch plan. + +=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/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<App::Sqitch::Plan::Line> for its interface. + +=head1 Interface + +In addition to the interface inherited from L<App::Sqitch::Plan::Line>, +App::Sqitch::Plan::Line::Pragma adds a few methods of its own. + +=head2 Accessors + +=head3 C<value> + +The value of the pragma. + +=head3 C<op> + +The operator, including surrounding white space. + +=head3 C<hspace> + +The horizontal space between the pragma and its value. + +=head2 Instance Methods + +=head3 C<format_value> + +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 <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/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<App::Sqitch::Plan::Line>, it offers +interfaces fetching and formatting timestamp and planner information. + +=head1 Interface + +See L<App::Sqitch::Plan::Line> for the basics. + +=head2 Accessors + +=head3 C<change> + +Returns the L<App::Sqitch::Plan::Change> object with which the tag is +associated. + +=head3 C<timestamp> + +Returns the an L<App::Sqitch::DateTime> object representing the time at which +the tag was added to the plan. + +=head3 C<planner_name> + +Returns the name of the user who added the tag to the plan. + +=head3 C<planner_email> + +Returns the email address of the user who added the tag to the plan. + +=head3 C<info> + +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<id> + +A SHA1 hash of the data returned by C<info()>, which can be used as a +globally-unique identifier for the tag. + +=head2 Instance Methods + +=head3 C<format_planner> + + 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 <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/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<options> + + my @opts = App::Sqitch::Command::deploy->options; + +Adds database connection options. + +=head3 C<configure> + +Configures the options used for target parameters. + +=head2 Instance Methods + +=head3 C<target_params> + +Returns a list of parameters to be passed to App::Sqitch::Target's C<new> +and C<all_targets> methods. +=head1 See Also + +=over + +=item L<App::Sqitch::Command::deploy> + +The C<deploy> command deploys changes to a database. + +=item L<App::Sqitch::Command::revert> + +The C<revert> command reverts changes from a database. + +=item L<App::Sqitch::Command::log> + +The C<log> command shows the event log for a database. + +=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/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<options> + + my @opts = App::Sqitch::Command::add->options; + +Adds contextual options C<--plan-file> and C<--top-dir>. + +=head3 C<configure> + +Configures the options used for target parameters. + +=head2 Instance Methods + +=head3 C<target_params> + +Returns a list of parameters to be passed to App::Sqitch::Target's C<new> +and C<all_targets> methods. + +=head1 See Also + +=over + +=item L<App::Sqitch::Command::add> + +The C<add> command adds changes to the the plan and change scripts to the project. + +=item L<App::Sqitch::Command::deploy> + +The C<deploy> command deploys changes to a database. + +=item L<App::Sqitch::Command::bundle> + +The C<bundle> command bundles Sqitch changes for distribution. + +=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/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<earliest_change_id> + +=head3 C<latest_change_id> + +=head3 C<current_state> + +=head3 C<current_changes> + +=head3 C<current_tags> + +=head3 C<search_events> + +=head3 C<registered_projects> + +=head3 C<register_project> + +=head3 C<is_deployed_change> + +=head3 C<are_deployed_changes> + +=head3 C<log_deploy_change> + +=head3 C<log_fail_change> + +=head3 C<changes_requiring_change> + +=head3 C<name_for_change_id> + +=head3 C<log_new_tags> + +=head3 C<log_revert_change> + +=head3 C<begin_work> + +=head3 C<finish_work> + +=head3 C<rollback_work> + +=head3 C<is_deployed_tag> + +=head3 C<deployed_changes> + +=head3 C<deployed_changes_since> + +=head3 C<load_change> + +=head3 C<change_offset_from_id> + +=head3 C<change_id_offset_from_id> + +=head3 C<change_id_for> + +=head3 C<registry_version> + +=head1 See Also + +=over + +=item L<App::Sqitch::Engine::pg> + +The PostgreSQL engine. + +=item L<App::Sqitch::Engine::sqlite> + +The SQLite engine. + +=item L<App::Sqitch::Engine::oracle> + +The Oracle engine. + +=item L<App::Sqitch::Engine::mysql> + +The MySQL engine. + +=item L<App::Sqitch::Engine::vertica> + +The Vertica engine. + +=item L<App::Sqitch::Engine::exasol> + +The Exasol engine. + +=item L<App::Sqitch::Engine::snowflake> + +The Snowflake engine. + +=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/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<options> + + my @opts = App::Sqitch::Command::checkout->options; + +Adds options common to the commands that revert and deploy. + +=head3 C<configure> + +Configures the options common to commands that revert and deploy. + +=head2 Attributes + +=head3 C<log_only> + +Boolean indicating whether to log the deploy without running the scripts. + +=head3 C<no_prompt> + +Boolean indicating whether or not to prompt the user to really go through with +the revert. + +=head3 C<prompt_accept> + +Boolean value to indicate whether or not the default value for the prompt, +should the user hit C<return>, is to accept the prompt or deny it. + +=head3 C<target> + +The deployment target URI. + +=head3 C<verify> + +Boolean indicating whether or not to run verify scripts after deploying +changes. + +=head3 C<mode> + +Deploy mode, one of "change", "tag", or "all". + +=head1 See Also + +=over + +=item L<App::Sqitch::Command::rebase> + +The C<rebase> command reverts and deploys changes. + +=item L<App::Sqitch::Command::checkout> + +The C<checkout> 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 <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/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<options> + + my @opts = App::Sqitch::Command::checkout->options; + +Adds options common to the commands that manage script configuration. + +=head3 C<configure> + +Configures the options common to commands manage script configuration. + +=head2 Attributes + +=head3 C<properties> + +A hash reference of target configurations. The keys may be as follows: + +=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> + +=item C<extension> + +=back + +=head2 Instance Methods + +=head3 C<config_target> + + my $target = $cmd->config_target; + my $target = $cmd->config_target(%params); + +Constructs a target based on the contents of C<properties>. The supported +parameters are: + +=over + +=item C<name> + +A target name. + +=item C<uri> + +A target URI. + +=item C<engine> + +An engine name. + +=back + +The passed target and engine names take highest precedence, falling back on +the properties and the C<default_target>. All other properties are applied to +the target before returning it. + +=head3 C<write_plan> + + $cmd->write_plan(%params); + +Writes out the plan file. Supported parameters are: + +=over + +=item C<target> + +The target for which the plan will be written. Defaults to the target returned +by C<config_target()>. + +=item C<project> + +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<uri> + +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<directories_for> + + 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<make_directories_for> + + $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<mkdirs> + + $cmd->directories_for(@dirs); + +Creates the list of directories on the file system. Directories that already +exist are skipped. Messages are sent to C<info()> for each directory, and an +error is thrown on the first to fail. + +=head3 C<config_params> + + my @params = $cmd->config_params($key); + +Returns a list of parameters to pass to the L<App::Sqitch::Config> C<set> +method, built up from the C<properties>. + +=head1 See Also + +=over + +=item L<App::Sqitch::Command::init> + +The C<init> command initializes a Sqitch project, setting up the change script +configuration and directories. + +=item L<App::Sqitch::Command::engine> + +The C<engine> command manages engine configuration, including engine-specific +change script configuration. + +=item L<App::Sqitch::Command::target> + +The C<engine> command manages target configuration, including target-specific +change script configuration. + +=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/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<engine|App::Sqitch::Engine>, L<plan|App::Sqitch::Engine>, 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<new> + + my $target = App::Sqitch::Target->new( sqitch => $sqitch ); + +Instantiates and returns an App::Sqitch::Target object. The most important +parameters are C<sqitch>, C<name>, and C<uri>. The constructor tries really +hard to figure out the proper name and URI during construction. If the C<uri> +parameter is passed, this is straight-forward: if no C<name> is passed, +C<name> 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<core.engine> ++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<engine.$engine.target> +configuration option. If none is found, use C<db:$key:>. + +=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<target.$name.uri> configuration option. + +=back + +As a general rule, then, pass either a target name or URI string in the +C<name> 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<new()>. In addition, +C<new()> accepts a few non-attribute parameters that may be used to override +parts of the connection URI. They are: + +=over + +=item * C<user> + +=item * C<host> + +=item * C<port> + +=item * C<dbname> + +=back + +For example, if the the named target had its URI configured as +C<db:pg://fred@example.com/work>, The C<uri> 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<db:pg://bill@example.com:1212/work>. + +=head3 C<all_targets> + +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<new>; only +C<sqitch> is required. All other parameters will be set on all of the returned +targets. + +=head2 Accessors + +=head3 C<sqitch> + + my $sqitch = $target->sqitch; + +Returns the L<App::Sqitch> object that instantiated the target. + +=head3 C<name> + +=head3 C<target> + + 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<uri> + + my $uri = $target->uri; + +The L<URI::db> object encapsulating the database connection information. + +=head3 C<username> + + my $username = $target->username; + +Returns the target username, if any. The username is looked up from the URI. + +=head3 C<password> + + 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<engine> + + my $engine = $target->engine; + +A L<App::Sqitch::Engine> object to use for database interactions with the +target. + +=head3 C<registry> + + 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<target.$name.registry> + +=item * C<engine.$engine.registry> + +=item * C<core.registry> + +=item * Engine-specific default + +=back + +=head3 C<client> + + 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<target.$name.client> + +=item * C<engine.$engine.client> + +=item * C<core.client> + +=item * Engine-and-OS-specific default + +=back + +=head3 C<top_dir> + + 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<target.$name.top_dir> + +=item * C<engine.$engine.top_dir> + +=item * C<core.top_dir> + +=item * F<.> + +=back + +=head3 C<plan_file> + + 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<target.$name.plan_file> + +=item * C<engine.$engine.plan_file> + +=item * C<core.plan_file> + +=item * F<C<$top_dir>/sqitch.plan> + +=back + +=head3 C<deploy_dir> + + 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<plan_file>. The value +comes from one of these options, searched in this order: + +=over + +=item * C<--dir deploy_dir=$deploy_dir> + +=item * C<target.$name.deploy_dir> + +=item * C<engine.$engine.deploy_dir> + +=item * C<core.deploy_dir> + +=item * F<C<$top_dir/deploy>> + +=back + +=head3 C<revert_dir> + + 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<plan_file>. The value comes +from one of these options, searched in this order: + +=over + +=item * C<--dir revert_dir=$revert_dir> + +=item * C<target.$name.revert_dir> + +=item * C<engine.$engine.revert_dir> + +=item * C<core.revert_dir> + +=item * F<C<$top_dir/revert>> + +=back + +=head3 C<verify_dir> + + 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<plan_file>. The value +comes from one of these options, searched in this order: + +=over + +=item * C<--dir verify_dir=$verify_dir> + +=item * C<target.$name.verify_dir> + +=item * C<engine.$engine.verify_dir> + +=item * C<core.verify_dir> + +=item * F<C<$top_dir/verify>> + +=back + +=head3 C<reworked_dir> + + 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<target.$name.reworked_dir> + +=item * C<engine.$engine.reworked_dir> + +=item * C<core.reworked_dir> + +=item * C<$top_dir> + +=back + +=head3 C<reworked_deploy_dir> + + 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<plan_file>. The value comes from one of these options, searched in this +order: + +=over + +=item * C<--dir reworked_deploy_dir=$reworked_deploy_dir> + +=item * C<target.$name.reworked_deploy_dir> + +=item * C<engine.$engine.reworked_deploy_dir> + +=item * C<core.reworked_deploy_dir> + +=item * F<C<$reworked_dir/reworked_deploy>> + +=back + +=head3 C<reworked_revert_dir> + + 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<plan_file>. The value comes from one of these options, searched in this +order: + +=over + +=item * C<--dir reworked_revert_dir=$reworked_revert_dir> + +=item * C<target.$name.reworked_revert_dir> + +=item * C<engine.$engine.reworked_revert_dir> + +=item * C<core.reworked_revert_dir> + +=item * F<C<$reworked_dir/reworked_revert>> + +=back + +=head3 C<reworked_verify_dir> + + 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<plan_file>. The value comes from one of these options, searched in this +order: + +=over + +=item * C<--dir reworked_verify_dir=$reworked_verify_dir> + +=item * C<target.$name.reworked_verify_dir> + +=item * C<engine.$engine.reworked_verify_dir> + +=item * C<core.reworked_verify_dir> + +=item * F<C<$reworked_dir/reworked_verify>> + +=back + +=head3 C<extension> + + 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<target.$name.extension> + +=item * C<engine.$engine.extension> + +=item * C<core.extension> + +=item * C<"sql"> + +=back + +=head3 C<variables> + + 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<target.$name.variables> + +=item * C<engine.$engine.variables> + +=back + +The C<core.variables> configuration is not read, because command-specific +configurations, such as C<deploy.variables> and C<revert.variables> take +priority. The command themselves therefore pass them to the engine in the +proper priority order. + +=head3 C<engine_key> + + my $key = $target->engine_key; + +The key defining which engine to use. This value defines the class loaded by +C<engine>. Convenience method for C<< $target->uri->canonical_engine >>. + +=head3 C<dsn> + + 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<username> + + 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<password> + + 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<sqitch> + +The Sqitch command-line client. + +=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/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 /^[[]/ || /</; + 1; +}; + +subtype UserEmail, as Str, where { + hurl user => __ '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<Sqitch> + +An L<App::Sqitch> object. + +=item C<Engine> + +An L<App::Sqitch::Engine> object. + +=item C<Target> + +An L<App::Sqitch::Target> object. + +=item C<UserName> + +A Sqitch user name. + +=item C<UserEmail> + +A Sqitch user email address. + +=item C<Plan> + +A L<Sqitch::App::Plan> object. + +=item C<Change> + +A L<Sqitch::App::Plan::Change> object. + +=item C<ChangeList> + +A L<Sqitch::App::Plan::ChangeList> object. + +=item C<LineList> + +A L<Sqitch::App::Plan::LineList> object. + +=item C<Tag> + +A L<Sqitch::App::Plan::Tag> object. + +=item C<Depend> + +A L<Sqitch::App::Plan::Depend> object. + +=item C<DateTime> + +A L<Sqitch::App::DateTime> object. + +=item C<URI> + +A L<URI> object. + +=item C<URIDB> + +A L<URI::db> object. + +=item C<File> + +A C<Class::Path::File> object. + +=item C<Dir> + +A C<Class::Path::Dir> object. + +=item C<Config> + +A L<Sqitch::App::Config> object. + +=item C<DBH> + +A L<DBI> database handle. + +=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/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<hurl> + +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<ident> and C<message> +parameters, you can pass them as the only arguments to C<hurl()>: + + 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<ident>: + + hurl 'Odd number of arguments passed to burf()' if @_ % 2; + +In this case, the C<ident> will be C<DEV>, which you should not otherwise use. +Sqitch will emit a more detailed error message, including a stack trace, when +it sees C<DEV> exceptions. + +The supported parameters are: + +=over + +=item C<ident> + +A non-localized string identifying the type of exception. + +=item C<message> + +The exception message. Use L<Locale::TextDomain> to craft localized messages. + +=item C<exitval> + +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<as_string> + + 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<Try::Tiny> 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<ident> attribute to determine what category of exception it is, and +take changes as appropriate. + +=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/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo b/lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo Binary files differnew file mode 100644 index 00000000..6c977d94 --- /dev/null +++ b/lib/LocaleData/de_DE/LC_MESSAGES/App-Sqitch.mo diff --git a/lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo b/lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo Binary files differnew file mode 100644 index 00000000..c0eea0a1 --- /dev/null +++ b/lib/LocaleData/fr_FR/LC_MESSAGES/App-Sqitch.mo diff --git a/lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo b/lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo Binary files differnew file mode 100644 index 00000000..c449b8ac --- /dev/null +++ b/lib/LocaleData/it_IT/LC_MESSAGES/App-Sqitch.mo 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] <change> [<target>] + +=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 <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</Templates> +for details. + +The paths and extensions of the generated scripts depend on the configuration +of Sqitch targets, engines, and the core. See L<sqitch-configuration> for +details. + +Note that the name of the new change must adhere to the rules as defined in +L<sqitchchanges>. + +By default, the C<add> 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</Examples> 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<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> + +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<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 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<name=value>, 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<add> 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<deploy/$name.tmpl> + +=item C<revert/$name.tmpl> + +=item C<verify/$name.tmpl> + +=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<pg>, C<sqlite>, C<mysql>, +C<oracle>, C<firebird>, C<vertica>, C<exasol>, or C<snowflake>). + +=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<C<add.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 + +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<users> 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<extract> from a completely different Sqitch project named C<utilities>: + + sqitch add coffee -r users -r utilities:extract -n 'Mmmmm...coffee!' + +Add a change that uses the C<createtable> templates to generate the scripts, +as well as variables to be used in that template (See +L<https://justatheory.com/2013/09/sqitch-templating/> 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<vertica> 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<sqitch --etc-path> 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<add.template_directory> + +=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<deploy>, F<revert>, and F<verify>, 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<pg>, +when you add a new change like so: + + sqitch add schema -n 'Creates schema' + +Sqitch will use the C<pg.tmpl> files to create the following files in the +top directory configured for the project (See L<sqitch-configuration> for +details). + + deploy/schema.sql + revert/schema.sql + test/schema.sql + verify/schema.sql + +If you want to use the C<create_table> 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<create_table.tmpl> files to create the following files +in the top directory configured for the project (See L<sqitch-configuration> +for details). + + deploy/user_table.sql + revert/user_table.sql + verify/user_table.sql + +Note that the C<test> file was not created, because no +F<test/create_table.tmpl> template file exists. + +=head2 Syntax + +The syntax of Sqitch templates is the very simple language provided by +L<Template::Tiny>, 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<Template::Toolkit> +and all templates will be processed by its more comprehensive features. See +the L<complete Template Toolkit documentation|http://tt2.org/docs/manual/> for +details, especially the L<syntax docs|http://tt2.org/docs/manual/Syntax.html> + +=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<add.variables> +L<config|sqitch-config> section (see L</Configuration Variables>). Variables +specified via C<--set> will override configuration variables. + +The five core variables are: + +=over + +=item C<change> + +The name of the change being added. + +=item C<engine> + +The name of the engine for which the change was added. One of C<pg>, +C<sqlite>, C<mysql>, C<oracle>, C<firebird>, C<vertica> C<exasol>, or +C<snowflake>. + +=item C<project> + +The name of the Sqitch project to which the change was added. The project name +is set in the plan by the L<C<init> command>|sqitch-init>. + +=item C<requires> + +A list of required changes as passed via one or more instances of the +C<--requires> option. + +=item C<conflicts> + +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.all> + +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<add.template_directory> + +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<add.template_name> + +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<add.templates.deploy> + +=item C<add.templates.revert> + +=item C<add.templates.verify> + +=back + +But a custom template type can have its location specified here, as well, +such as C<add.template.unit_test>. 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<add.open_editor> + +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<sqitch> 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<PGUSER> environment variable, if set. +Otherwise, it uses the system username. + + +=item MySQL + +For MySQL, if the L<MySQL::Config> module is installed, usernames and +passwords can be specified in the +L<F</etc/my.cnf> 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<VSQL_USER> environment variable, if set. +Otherwise, it uses the system username. + +=item Firebird + +The Firebird engine uses the C<ISC_USER> environment variable, if set. + +=item Exasol + +Exasol provides no default to search for a username. + +=item Snowflake + + +The Snowflake engine uses the C<SNOWSQL_USER> environment variable, if set. +Next, it looks in the +L<F<~/.snowsql/config> file|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#snowsql-config-file> +and use the default C<connections.username> 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<authentication methods|https://www.postgresql.org/docs/current/static/auth-methods.html>, +including the passwordless L<SSL certificate|https://www.postgresql.org/docs/current/static/auth-methods.html#AUTH-CERT>, L<GSSAPI|https://www.postgresql.org/docs/current/static/auth-methods.html#GSSAPI-AUTH>, and, for local connections, +L<peer authentication|https://www.postgresql.org/docs/current/static/auth-methods.html#AUTH-PEER>. + +=item MySQL + +MySQL supports a number of +L<authentication methods|https://dev.mysql.com/doc/internals/en/authentication-method.html>, +plus L<SSL authentication|https://dev.mysql.com/doc/internals/en/ssl.html>. + +=item Oracle + +Oracle supports a number of +L<authentication methods|https://docs.oracle.com/cd/B19306_01/network.102/b14266/authmeth.htm#BABCGGEB>, +including +L<SSL authentication|https://docs.oracle.com/cd/B19306_01/network.102/b14266/authmeth.htm#i1009722>, +L<third-party authentication|https://docs.oracle.com/cd/B19306_01/network.102/b14266/authmeth.htm#i1009853>, +and, for local connections, +L<OS authentication|https://docs.oracle.com/cd/B19306_01/network.102/b14266/authmeth.htm#i1007520>. + +=item Vertica + +Vertica supports a number of +L<authentication methods|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/AdministratorsGuide/Security/ClientAuth/SupportedClientAuthenticationMethods.htm> +including the passwordless L<TLS authentication|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/AdministratorsGuide/Security/ClientAuth/ConfiguringTLSAuthentication.htm>, +L<GSS authentication|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/AdministratorsGuide/Security/ClientAuth/Kerberos/ImplementingKerberosAuthentication.htm>, +and, for local connections, +L<ident authentication|https://my.vertica.com/docs/7.1.x/HTML/index.htm#Authoring/AdministratorsGuide/Security/ClientAuth/ConfiguringIdentAuthentication.htm>. + +=item Firebird + +Firebird supports passwordless authentication only via +L<trusted authentication|https://www.firebirdsql.org/manual/qsg2-config.html> +for local connections. + +=item Exasol + +Exasol doesn't seem to support password-less authentication at this time; for +other options, see the L<documentation|https://www.exasol.com/portal/display/DOC/Database+User+Manual>. + +=item Snowflake + +Snowflake does not support password-less authentication, but does support +key-pair authentication. Follow +L<the instructions|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#using-key-pair-authentication> +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<authenticator=SNOWFLAKE_JWT> + +=item * C<uid=$username> + +=item * C<priv_key_file=path/to/privatekey.p8> + +=item * C<priv_key_file_pwd=$private_key_password> + +=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<F<.pgpass> 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<MySQL::Config> module is installed, usernames and +passwords can be specified in the +L<F</etc/my.cnf> 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<password file|https://docs.oracle.com/cd/B28359_01/server.111/b28310/dba007.htm#ADMIN10241> +created with the C<ORAPWD> utility to authenticate C<SYSDBA> and C<SYSOPER> +users, but B<Sqitch is unable to take advantage of this functionality.> Neither can +one L<embed a username and password|https://stackoverflow.com/q/7183513/79202> +into a +L<F<tnsnames.ora>|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<documentation|https://www.exasol.com/portal/display/DOC/Database+User+Manual> +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<F<~/.snowsql/config> 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<connections.accountname> + +=item C<connections.username> + +=item C<connections.password> + +=item C<connections.rolename> + +=item C<connections.region> + +=item C<connections.warehousename> + +=item C<connections.dbname> + +=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<ps>. + +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<target URIs|sqitch-target/Description>. +This is not generally recommended, since such URIs are either specified via +the command-line (and therefore visible in C<ps> and your shell history) or +stored in the L<configuration|sqitch-configuration>, 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<sqitch-targets> and C<sqitch-configuration> for details on target +configuration. + +=head1 See Also + +=over + +=item * L<sqitch-environment> + +=item * L<sqitch-configuration> + +=item * L<sqitch-target> + +=back + +=head1 Sqitch + +Part of the L<sqitch> 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] [<engine>] [<plan>] [<target>] + +=head1 Options + + --dest-dir --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 <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<bundle> 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</Examples> 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<sqitchchanges>. See L</Examples> 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<bundle>. + +=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<change specification|sqitchchanges> +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<change specification|sqitchchanges> +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<bundle.all> configuration; use +C<--no-all> to override a true C<bundle.all> 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<bundle.dest_dir> + +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<bundle>: + + sqitch bundle + +Bundle a Sqitch project with all plans and scripts into F<bundle>: + + sqitch bundle --all + +Bundle a Sqitch project into F<BUILDROOT/MyProj>: + + sqitch bundle --dest-dir BUILDROOT/MyProj + +Bundle a project including changes C<adduser> through C<@v1.0>: + + sqitch bundle --from adduser --to @v1.0 + +Bundle a the C<pg> engine plans with changes C<adduser> through C<@v1.0>, and +the C<sqlite> engine with changes from the start of the plan up to C<widgets>: + + sqitch bundle pg adduser @v1.0 sqlite @ROOT wigets + +Bundle just the files necessary to execute the plan for the C<pg> 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<sqitch> 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] [<database>] <branch> + +=head1 Options + + -t --target <target> database to which to connect + --mode <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 <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-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] [<database>] <branch> + +=head1 Description + +Checkout another branch in your project's VCS (such as +L<git|https://git-scm.org/>), while performing the necessary database changes +to update your database for the new branch. + +More specifically, the C<checkout> 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<C<sqitch revert>|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<C<sqitch deploy>|sqitch-deploy> had been called. + +If the VCS is already on the specified branch, nothing will be done. + +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<--mode> + +Specify the reversion mode to use in case of failure. Possible values are: + +=over + +=item C<all> + +In the event of failure, revert all deployed changes, back to the point at +which deployment started. 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 during this deployment, all +changes will be reverted to the point at which deployment began. + +=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. 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, 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<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 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<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 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<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 checkout --db-port 7654 + sqitch checkout -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 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<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<checkout.verify> + +=item C<deploy.verify> + +Boolean indicating whether or not to verify each change after deploying it. + +=item C<checkout.mode> + +=item C<deploy.mode> + +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<checkout.no_prompt> variable takes precedence over +C<revert.no_prompt>, 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<checkout.prompt_accept> +variable takes precedence over C<revert.prompt_accept>, and both default to +true, meaning to accept the revert. + +=back + +=head1 Sqitch + +Part of the L<sqitch> 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 <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 [<file-option>] [type] name [value [value_regex]] + sqitch config [<file-option>] [type] --add name value + sqitch config [<file-option>] [type] --replace-all name value [value_regex] + sqitch config [<file-option>] [type] --get name [value_regex] + sqitch config [<file-option>] [type] --get-all name [value_regex] + sqitch config [<file-option>] [type] --get-regexp name_regex [value_regex] + sqitch config [<file-option>] --unset name [value_regex] + sqitch config [<file-option>] --unset-all name [value_regex] + sqitch config [<file-option>] --rename-section old_name new_name + sqitch config [<file-option>] --remove-section name + sqitch config [<file-option>] -l | --list + sqitch config [<file-option>] -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<value_regex> 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<Examples>). + +The C<type> 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<file-option> 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</Files>). + +=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<value_regex>). + +=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</Files>. + +=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</Files>. + +=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<sqitch --etc-path> to find out exactly where the system configuration +file lives (e.g., C<$(sqitch --etc-path)/sqitch.conf>). + +See also L</Files>. + +=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<sqitch config> will ensure that the output is "true" or "false". + +=item C<--int> + +C<sqitch config> will ensure that the output is a simple integer. + +=item C<--num> + +C<sqitch config> will ensure that the output is a simple decimal number. + +=item C<--bool-or-int> + +C<sqitch config> 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<sqitch config> 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<SQITCH_CONFIG> + +Take the local configuration from the given file instead of F<./sqitch.conf>. + +=item C<SQITCH_USER_CONFIG> + +Take the user configuration from the given file instead of +F<~/.sqitch/sqitch.conf>. + +=item C<SQITCH_SYSTEM_CONFIG> + +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<tags_only> setting to true with + + % sqitch config bundle.tags_only true + +The hypothetical C<clack> key in the C<core.fuzzle> section might need to set +C<foo> 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<bundle.from>, do + + % sqitch config --unset bundle.from + +If you want to delete an entry for a multivalue setting (like +C<core.fuzzle.clack>), 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</ba/>: + + % 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<core.fuzzle.clack> 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<bar>, 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<name = value>. If there is +no equal sign on the line, the entire line is taken as name and the variable +is recognized as boolean C<true>. 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<sqitch config> 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<core.plan_file> + +The plan file to use. Defaults to F<$top_dir/sqitch.plan>. + +=item C<core.engine> + +The database engine to use. Supported engines include: + +=over + +=item * C<pg> - L<PostgreSQL|https://postgresql.org/> and L<Postgres-XC|https://sourceforge.net/> + +=item * C<sqlite> - L<SQLite|https://sqlite.org/> + +=item * C<oracle> - L<Oracle|https://www.oracle.com/us/products/database/> + +=item * C<mysql> - L<MySQL|https://dev.mysql.com/> and L<MariaDB|https://mariadb.com/> + +=item * C<firebird> - L<Firebird|https://www.firebirdsql.org/> + +=item * C<vertica> - L<Vertica|https://my.vertica.com/> + +=item * C<exasol> - L<Exasol|https://www.exasol.com/> + +=item * C<snowflake> - L<Snowflake|https://www.snowflake.net/> + +=back + +=item C<core.top_dir> + +Path to directory containing deploy, revert, and verify SQL scripts. It +should contain subdirectories named C<deploy>, C<revert>, and (optionally) +C<verify>. These may be overridden by C<deploy_dir>, C<revert_dir>, and +C<verify_dir>. Defaults to C<.>. + +=item C<core.deploy_dir> + +Path to a directory containing SQL deployment scripts. Overrides the value +implied by C<core.top_dir>. + +=item C<core.revert_dir> + +Path to a directory containing SQL reversion scripts. Overrides the value +implied by C<core.top_dir>. + +=item C<core.verify_dir> + +Path to a directory containing SQL verify scripts. Overrides the value implied +by C<core.top_dir>. + +=item C<core.extension> + +The file name extension on deploy, revert, and verify SQL scripts. Defaults to +C<sql>. + +=item C<core.verbosity> + +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<core.pager> + +The command to use as a pager program. This overrides the C<PAGER> +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<less> and C<more>. + +=item C<core.editor> + +The command to use as a editor program. This overrides the C<EDITOR> +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<notepad.exe> on Windows and C<vi> elsewhere. + +=back + +=head3 C<user> + +Configuration properties that identify the user. + +=over + +=item C<user.name> + +Your full name, to be recorded in changes and tags added to the plan, +and to commits to the database. + +=item C<user.email> + +Your email address, to be recorded in changes and tags added to the plan, and +to commits to the database. + +=back + +=head3 C<engine.$engine> + +Each supported engine offers a set of configuration variables, falling under +the key C<engine.$engine> where C<$engine> may be any value accepted for +C<core.engine>. + +=over + +=item C<engine.$engine.target> + +A database target, either the name of target managed by the +L<C<target>|sqitch-target> command, or a database connection URI. If it's a +target name, then the associated C<uri>, C<registry>, and C<client> 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<engine.$engine.uri> + +A database connection URI. + +=item C<engine.$engine.registry> + +The name of the Sqitch registry schema or database. Sqitch will store its own +data in this schema. + +=item C<engine.$engine.client> + +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<engine.pg.registry> + +For the PostgreSQL engine, the C<registry> value identifies the schema for +Sqitch to use for its own data. No other data should be stored there. Defaults +to C<sqitch>. + +=item C<engine.sqlite.registry> + +For the SQLite engine, if the C<registry> 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<uri>. Defaults to C<sqitch>. + +=item C<engine.mysql.registry> + +For the MySQL engine, the C<registry> 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<sqitch>. + +=item C<engine.oracle.registry> + +For Oracle, C<registry> 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<engine.firebird.registry> + +For the Firebird engine, if the C<registry> 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<uri>. Defaults to C<sqitch.$extension>, +where C<$extension> is the same as that in the C<uri>, if any. + +=item C<engine.vertica.registry> + +For the Vertica engine, the C<registry> value identifies the schema for Sqitch +to use for its own data. No other data should be stored there. Defaults to +C<sqitch>. + +=item C<engine.exasol.registry> + +For the Exasol engine, the C<registry> value identifies the schema for Sqitch +to use for its own data. No other data should be stored there. Defaults to +C<sqitch>. + +=item C<engine.snowflake.registry> + +For the Snowflake engine, the C<registry> value identifies the schema for +Sqitch to use for its own data. No other data should be stored there. Defaults +to C<sqitch>. + +=back + +=head3 C<core.vcs> + +Configuration properties for the version control system. Currently, only Git +is supported. + +=over + +=item C<core.vcs.client> + +Path to the C<VCS> command-line client. Defaults to the first instance of +F<git> found in the path. + +=back + +=head3 C<user> + +=over + +=item C<user.email> + +Your email address to be recorded in any newly planned changes. + +=item C<user.name> + +Your full name to be recorded in any newly planned changes. + +=back + +=head1 Sqitch + +Part of the L<sqitch> 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<database connection URI|https://github.com/libwww-perl/uri-db>, 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<C<init>|sqitch-init>, L<C<config>|sqitch-config>, +L<C<engine>|sqitch-engine>, and L<C<target>|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<C<init>|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<pg>. Of course, it's the I<only> 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<C<add>|sqitch-add> command to add changes, and the +L<C<deploy>|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<target> defines the default L<database URI|https://github.com/libwww-perl/uri-db> +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<registry> +determines where Sqitch will store its own metadata when managing a database; +generally the default, "sqitch", is fine. + +More interesting, perhaps, is the C<client> setting, which defaults to the +appropriate engine-specific client name appropriate for your OS. In this +example, sqitch will assume it can find F<psql> in your path. + +=head1 Global Configuration + +But sometimes that's not the case. Let's say that the C<psql> client on your +system is not in the path, but instead in F</usr/local/pgsql/bin/psql>. 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<psql> for I<all> of your +projects. Use the L<C<config>|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<C<engine>|sqitch-engine> command's C<add> action: + + sqitch engine add pg --top-dir pg + +The C<add> action adds the C<pg> engine to the configuration, setting the top +directory to our newly-created C<pg> 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<sqitch engine show> +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<show> 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<almost> 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<sqlite/sqitch.conf> 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<add> 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<pg> when adding PostgreSQL changes, or omit it, in which case Sqitch +will fall back on the default engine, defined by the C<core.engine> 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<sqlite> 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<add> 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<tag>, C<rework>, and C<bundle> commands. If +you know you always want to act on all plans, set the C<all> 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<database URI|https://github.com/libwww-perl/uri-db> +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<db.example.com>: + + 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<db:pg:> URI, rest assured that Sqitch won't try to deploy the SQLite +changes. Use a C<db:sqlite:> 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<C<status>|sqitch-status> + +=item * L<C<log>|sqitch-log> + +=item * L<C<deploy>|sqitch-deploy> + +=item * L<C<revert>|sqitch-revert> + +=item * L<C<rebase>|sqitch-rebase> + +=item * L<C<checkout>|sqitch-checkout> + +=item * L<C<verify>|sqitch-verify> + +=item * L<C<verify>|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<pg> 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<C<deploy>|sqitch-deploy> command (assuming the +C<sqitch> 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</Database Interactions>. + +=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<customers> and C<users> 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<prod-billing> target will use the F<target.plan> +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<reworked|sqitch-rework>. (You really only do that with +procedures and views, right? Because it's silly to use for C<ALTER> +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<reworked_deploy>, +C<reworked_revert>, and C<reworked_verify> 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<pg/reworked/deploy>, the reworked +revert files are in F<pg/reworked/revert>, and the reworked verify files are +in F<pg/reworked/verify>. And you're good to go! From here on in Sqitch always +knows to find the reworked scripts when doing a L<deploy|sqitch-deploy>, +L<revert|sqitch-revert>, or L<bundle|sqitch-bundle>. 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<C<init>|sqitch-init>, +L<C<engine>|sqitch-engine>, and L<C<target>|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<target> + +The target database. May be a L<database URI|https://github.com/libwww-perl/uri-db> or +a named target managed by the L<C<target>|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<engine.$engine.target> + + sqitch init $project --engine $engine --target $target + sqitch engine add $engine --target $target + sqitch engine alter $engine --target target + +=item C<core.target> + + sqitch config core.target $target + +=back + +=item C<uri> + +The L<database URI|https://github.com/libwww-perl/uri-db> 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<target.$target.uri> + + sqitch init $project --engine $engine --target $uri + sqitch target add $target --uri $uri + sqitch target alter $target --uri $uri + +=back + +=item C<client> + +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<target.$target.client> + + sqitch target add $target --client $client + sqitch target alter $target --client $client + sqitch config --user target.$target.client $client + +=item C<engine.$engine.client> + + 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<core.client> + + sqitch config core.client $client + sqitch config --user core.client $client + +=back + +=item C<registry> + +The name of the Sqitch registry schema or database. The default is C<sqitch>, +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<target.$target.registry> + + sqitch target add $target --registry $registry + sqitch target alter $target --registry $registry + +=item C<engine.$engine.registry> + + sqitch init $project --engine $engine --registry $registry + sqitch engine add $engine --registry $registry + sqitch engine alter $engine --registry $registry + +=item C<core.registry> + + sqitch config core.registry $registry + +=back + +=item C<top_dir> + +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<target.$target.top_dir> + + sqitch target add $target --top-dir $top_dir + sqitch target alter $target --top-dir $top_dir + +=item C<engine.$engine.top_dir> + + sqitch engine add $engine --top-dir $top_dir + sqitch engine alter $engine --top-dir $top_dir + +=item C<core.top_dir> + + sqitch init $project --top-dir $top_dir + sqitch config core.top_dir $top_dir + +=back + +=item C<plan_file> + +The project deployment plan file, which defaults to F<C<$top_dir/sqitch.plan>>. +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<target.$target.plan_file> + + sqitch target add $target --plan-file $plan_file + sqitch target alter $target --plan-file $plan_file + +=item C<engine.$engine.plan_file> + + sqitch engine add $engine --plan-file $plan_file + sqitch engine alter $engine --plan-file $plan_file + +=item C<core.plan_file> + + sqitch init $project --plan-file $plan_file + sqitch config core.plan_file $plan_file + +=back + +=item C<extension> + +The file name extension to append to change names for change script file +names. Defaults to C<sql>. If you need a custom extension, specify it via the +following: + +=over + +=item C<target.$target.extension> + + sqitch target add $target --extension $extension + sqitch target alter $target --extension $extension + +=item C<engine.$engine.extension> + + sqitch engine add $engine --extension $extension + sqitch engine alter $engine --extension $extension + +=item C<core.extension> + + sqitch init $project --extension $extension + sqitch config core.extension $extension + +=back + +=item C<variables> + +Database client variables. 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>. +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<target.$target.variables> + + sqitch target add $target --set $key=$val -s $key2=$val2 + sqitch target alter $target --set $key=$val -s $key2=$val2 + +=item C<engine.$engine.variables> + + 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<core.variables> + + sqitch init $project --set $key=$val -s $key2=$val2 + sqitch config core.variables.$key $val + sqitch config core.variables.$key2 $val2 + +=back + +=item C<deploy_dir> + +The directory in which project deploy scripts can be found. Defaults to +F<C<$top_dir/deploy>>. If you need a different directory, specify it via the +following: + +=over + +=item C<target.$target.deploy_dir> + + sqitch target add $target --dir deploy=$deploy_dir + sqitch target alter $target --dir deploy=$deploy_dir + +=item C<engine.$engine.deploy_dir> + + sqitch engine add $engine --dir deploy=$deploy_dir + sqitch engine alter --dir deploy=$deploy_dir + +=item C<core.deploy_dir> + + sqitch init $project --dir deploy=$deploy_dir + sqitch config core.deploy_dir $deploy_dir + +=back + +=item C<revert_dir> + +=item F<C<$top_dir/deploy>> + + +The directory in which project revert scripts can be found. Defaults to +F<C<$top_dir/revert>>. If you need a different directory, specify it via the +following: + +=over + +=item C<target.$target.revert_dir> + + sqitch target add $target --dir revert=$revert_dir + sqitch target alter $target --dir revert=$revert_dir + +=item C<engine.$engine.revert_dir> + + sqitch engine add $engine --dir revert=$revert_dir + sqitch engine alter --dir revert=$revert_dir + +=item C<core.revert_dir> + + sqitch init $project --dir revert=$revert_dir + sqitch config core.revert_dir $revert_dir + +=back + +=item C<verify_dir> + +The directory in which project verify scripts can be found. Defaults to +F<C<$top_dir/verify>>. If you need a different directory, specify it via the +following: + +=over + +=item C<target.$target.verify_dir> + + sqitch target add $target --dir verify=$verify_dir + sqitch target alter $target --dir verify=$verify_dir + +=item C<engine.$engine.verify_dir> + + sqitch engine add $engine --dir verify=$verify_dir + sqitch engine alter $engine --dir verify=$verify_dir + +=item C<core.verify_dir> + + sqitch init $project --dir verify=$verify_dir + sqitch config core.verify_dir $verify_dir + +=back + +=item C<reworked_dir> + +The directory in which subdirectories for reworked scripts can be found. +Defaults to F<C<$top_dir>>. If you need a different directory, specify it via +the following: + +=over + +=item C<target.$target.reworked_dir> + + sqitch target add $target --dir reworked=$reworked_dir + sqitch target alter $target --dir reworked=$reworked_dir + +=item C<engine.$engine.reworked_dir> + + sqitch engine add $engine --dir reworked=$reworked_dir + sqitch engine alter $engine --dir reworked=$reworked_dir + +=item C<core.reworked_dir> + + sqitch init $project --dir reworked=$reworked_dir + sqitch config core.reworked_dir $reworked_dir + +=back + +=item C<reworked_deploy_dir> + +The directory in which project deploy scripts can be found. Defaults to +F<C<reworked_dir/deploy>>. If you need a different directory, specify it via the +following: + +=over + +=item C<target.$target.reworked_deploy_dir> + + sqitch target add $target --dir deploy=$reworked_deploy_dir + sqitch target alter $target --dir deploy=$reworked_deploy_dir + +=item C<engine.$engine.reworked_deploy_dir> + + sqitch engine add $engine --dir deploy=$reworked_deploy_dir + sqitch engine alter --dir deploy=$reworked_deploy_dir + +=item C<core.reworked_deploy_dir> + + sqitch init $project --dir deploy=$reworked_deploy_dir + sqitch config core.reworked_deploy_dir $reworked_deploy_dir + +=back + +=item C<reworked_revert_dir> + +The directory in which project revert scripts can be found. Defaults to +F<C<reworked_dir/revert>>. If you need a different directory, specify it via the +following: + +=over + +=item C<target.$target.reworked_revert_dir> + + sqitch target add $target --dir revert=$reworked_revert_dir + sqitch target alter $target --dir revert=$reworked_revert_dir + +=item C<engine.$engine.reworked_revert_dir> + + sqitch engine add $engine --dir revert=$reworked_revert_dir + sqitch engine alter --dir revert=$reworked_revert_dir + +=item C<core.reworked_revert_dir> + + sqitch init $project --dir revert=$reworked_revert_dir + sqitch config core.reworked_revert_dir $reworked_revert_dir + +=back + +=item C<reworked_verify_dir> + +The directory in which project verify scripts can be found. Defaults to +F<C<reworked_dir/verify>>. If you need a different directory, specify it via the +following: + +=over + +=item C<target.$target.reworked_verify_dir> + + sqitch target add $target --dir verify=$reworked_verify_dir + sqitch target alter $target --dir verify=$reworked_verify_dir + +=item C<engine.$engine.reworked_verify_dir> + + sqitch engine add $engine --dir verify=$reworked_verify_dir + sqitch engine alter $engine --dir verify=$reworked_verify_dir + +=item C<core.reworked_verify_dir> + + 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<sqitch-init> + +=item * L<sqitch-target> + +=item * L<sqitch-engine> + +=item * L<sqitch-config> + +=back + +=head1 Sqitch + +Part of the L<sqitch> 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] [<database>] + +=head1 Options + + -t --target <target> database to which to connect + --to-change <change> deploy to change + --mode <mode> failure reversion mode + -s --set <key=value> 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> 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-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] [<database>] + sqitch deploy [options] [<database>] <change> + sqitch deploy [options] [<database>] --to-change <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<sqitch-revert>. + +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<--to-change> + +=item C<--change> + +=item C<--to> + +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 or verify failure. +Possible values are: + +=over + +=item C<all> + +In the event of failure, revert all deployed changes, back to the point at +which deployment started. 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 during this deployment, all +changes will be reverted to the point at which deployment began. + +=item C<change> + +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<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 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<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 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<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 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<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 deploy --db-port 7654 + sqitch deploy -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 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> + +Deploy mode. The supported values are the same as for the C<--mode> option. + +=item C<deploy.verify> + +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<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<deploy.variables> + +=item C<core.variables> + +=back + +=back + +=head1 Sqitch + +Part of the L<sqitch> 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 <name> [engine-options] + sqitch engine alter <name> [engine-options] + sqitch engine remove <name> + sqitch engine show <name> + sqitch engine update-config + +=head1 Options + + -v, --verbose be verbose; must be placed before an action + --target <target> database target + --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-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 <name> [engine-options] + sqitch engine alter <name> [engine-options] + sqitch engine remove <name> + sqitch engine show <name> + sqitch engine update-config + +=head1 Description + +Manage the database engines you deploy to. The list of supported engines +includes: + +=over + +=item * C<firebird> + +=item * C<mysql> + +=item * C<oracle> + +=item * C<pg> + +=item * C<sqlite> + +=item * C<vertica> + +=item * C<exasol> + +=item * C<snowflake> + +=back + +Each engine may have a number of properties: + +=over + +=item C<target> + +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<db:$engine>. See L<sqitch-target> for details on target configuration. + +=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 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<plan_file> + +The plan file to use for this engine. The default is C<$top_dir/sqitch.plan>. + +=item C<deploy_dir> + +The path to the deploy directory for the engine. 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 engine. 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 engine. 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 engine. 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 engine. 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 engine. 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 engine. 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 core configuration -- for example, +the C<core.target>, C<core.plan_file>, C<core.registry>, and C<core.client> +L<config|sqitch-config> 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<sql>. + +=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<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<--target> + + sqitch engine add pg --target db:pg:widgets + +Specifies the name or L<URI|https://github.com/libwww-perl/uri-db/> 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<sqitch>. + +=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<name=value>, 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> + +Add an engine named C<< <name> >> for the database at C<< <uri> >>. 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> + +Alter an engine 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 engine named C<< <name> >> from the configuration. The plan file +and script directories will not be affected. + +=head2 C<show> + +Gives some information about the engine C<< <name> >>, including the +associated properties. Specify multiple engine names to see information for +each. + +=head2 C<update-config> + +Update the configuration from a configuration file that predates the addition +of the C<engine> 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<sqitch> 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<SQITCH_CONFIG> + +Path to the project configuration file. Overrides the default, which is +F<./sqitch.conf>. See L<sqitch-config> for details. + +=item C<SQITCH_USER_CONFIG> + +Path to the user's configuration file. Overrides the default, which is +F<./.sqitch/sqitch.conf>. See L<sqitch-config> for details. + +=item C<SQITCH_SYSTEM_CONFIG> + +Path to the system's configuration file. Overrides the default, which is a +file named C<sqitch.conf> in the directory identified by C<sqitch --etc>. See +L<sqitch-config> for details. + +=item C<SQITCH_TARGET> + +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<SQITCH_USERNAME> + +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<sqitch-authentication> for details. + +=item C<SQITCH_PASSWORD> + +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<sqitch-authentication> for details. + +=item C<SQITCH_FULLNAME> + +Full name of the current user. Used to identify the user adding a change to a +plan file or deploying a change. Supersedes the <user.name> L<sqitch-config> +variable. + +=item C<SQITCH_EMAIL> + +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<user.email> L<sqitch-config> +variable. + +=item C<SQITCH_ORIG_SYSUSER> + +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<this Docker script|https://github.com/sqitchers/docker-sqitch/blob/master/docker-sqitch.sh>. + +=item C<SQITCH_ORIG_FULLNAME> + +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 Docker script|https://github.com/sqitchers/docker-sqitch/blob/master/docker-sqitch.sh>. +This value will be used only when neither the C<$SQITCH_FULLNAME> nor the +C<user.name> L<sqitch-config> variable is set. + +=item C<SQITCH_ORIG_EMAIL> + +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 Docker script|https://github.com/sqitchers/docker-sqitch/blob/master/docker-sqitch.sh>. +This value will be used only when neither the C<$SQITCH_EMAIL> nor the +C<user.email> L<sqitch-config> variable is set. + +=item C<SQITCH_EDITOR> + +The editor that Sqitch will launch when the user needs to edit some text (a +change note, for example). If unset, the C<core.editor> 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<notepad.exe> on Windows and C<vi> elsewhere. + +=item C<SQITCH_PAGER> + +The pager program that Sqitch will use when a command (like C<sqitch log>) +produces multi-page output. If unset, the C<core.pager> configuration +variable will be used. If this is also not set, the C<PAGER> 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<less> and +C<more>. + +=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<PostgreSQL environment variables|https://www.postgresql.org/docs/current/static/libpq-envars.html> +should be implicitly used. However, the following variables are explicitly +recognized by Sqitch: + +=over + +=item C<PGUSER> + +The username to use to connect to the server. Superseded by +C<$SQITCH_USERNAME> and the target URI username. + +=item C<PGPASSWORD> + +The password to use to connect to the server. Superseded by +C<$SQITCH_PASSWORD> and the target URI password. + +=item C<PGHOST> + +The PostgreSQL server host to connect to. Superseded by the target URI host +name. + +=item C<PGPORT> + +The PostgreSQL server port to connect to. Superseded by the target URI port. + +=item C<PGDATABASE> + +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<MySQL environment variables|https://dev.mysql.com/doc/refman/5.7/en/environment-variables.html>: + +=over + +=item C<MYSQL_PWD> + +The password to use to connect to the server. Superseded by +C<$SQITCH_PASSWORD> and the target URI password. + +=item C<MYSQL_HOST> + +The MySQL server host to connect to. Superseded by the target URI host +name. + +=item C<MYSQL_TCP_PORT> + +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<ORACLE_HOME> + +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<TNS_ADMIN> + +The directory in which the Oracle networking interface will find its configuration +files, notably F<tnsnames.ora>. Defaults to C<$ORACLE HOME/network/admin> if not +set. + +=item C<TWO_TASK> + +The name of the Oracle database to connect to. Superseded by the target URI. + +=item C<LOCAL> + +The name of the Oracle database to connect to. Windows only. Superseded by the +target URI. + +=item C<ORACLE_SID> + +The System Identifier (SID) representing the Oracle database to connect to. +Superseded by the target URI, C<TWO_TASK> and C<LOCAL> on Windows. + +=back + +In addition, the Oracle engine in Sqitch explicitly overrides the C<NLS_LANG> +and C<SQLPATH> environment variables. The former is set to +C<AMERICAN_AMERICA.AL32UTF8> 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<ISC_USER> + +The username to use to connect to Firebird. Superseded by +C<$SQITCH_USERNAME> and the target URI username. + +=item C<ISC_PASSWORD> + +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<Vertica environment variables|https://www.vertica.com/docs/8.1.x/HTML/index.htm#Authoring/ConnectingToVertica/vsql/vsqlEnvironmentVariables.htm>: + +=over + +=item C<VSQL_USER> + +The username to use to connect to the server. Superseded by +C<$SQITCH_USERNAME> and the target URI username. + +=item C<VSQL_PASSWORD> + +The password to use to connect to the server. Superseded by +C<$SQITCH_PASSWORD> and the target URI password. + +=item C<VSQL_HOST> + +The PostgreSQL server host to connect to. Superseded by the target URI host +name. + +=item C<VSQL_PORT> + +The PostgreSQL server port to connect to. Superseded by the target URI port. + +=item C<VSQL_DATABASE> + +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<SQLPATH> environment variable, to prevent EXAplus +executing SQL scripts unexpectedly. + +=head3 Snowflake + +Sqitch provides explicit support for the following +L<Snowflake environment variables|https://docs.snowflake.net/manuals/user-guide/snowsql-start.html#connection-syntax>: + +=over + +=item C<SNOWSQL_ACCOUNT> + +The name assigned to the snowflake account. Superseded by the target URI host +name. + +=item C<SNOWSQL_USER> + +The username to use to connect to the server. Superseded by +C<$SQITCH_USERNAME> and the target URI username. + +=item C<SNOWSQL_PWD> + +The password to use to connect to the server. Superseded by +C<$SQITCH_PASSWORD> and the target URI password. + +=item C<SNOWSQL_PRIVATE_KEY_PASSPHRASE> + +The passphrase for the private key file when using key pair authentication. +See L<sqitch-authentication> for details. + +=item C<SNOWSQL_ROLE> + +The role to use when connecting to the server. Superseded by the target URI +database C<role> query parameter. + +=item C<SNOWSQL_HOST> + +The PostgreSQL server host to connect to. Superseded by the target URI host +name. + +=item C<SNOWSQL_PORT> + +The PostgreSQL server port to connect to. Superseded by the target URI port. + +=item C<SNOWSQL_DATABASE> + +The name of the database to connect to. Superseded by the target URI database +name. + +=item C<SNOWSQL_REGION> + +The snowflake region. Superseded by the target URI host name. + +=item C<SNOWSQL_WAREHOUSE> + +The warehouse to use. Superseded by the target URI database C<warehouse> query +parameter. + +=back + +=head1 See Also + +=over + +=item * L<sqitch-configuration> + +=item * L<sqitch-config> + +=item * L<sqitch-authentication> + +=back + +=head1 Sqitch + +Part of the L<sqitch> 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] [<command>] [<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<COMMAND> given, the synopsis of the C<sqitch> 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<sqitch> 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 <project> + sqitch init <project> --uri <uri> + +=head1 Options + + --uri <uri> associate a URI with the project plan + --engine <engine> database engine + --top-dir <dir> path to directory with plan and scripts + -f --plan-file <file> path to deployment plan file + --target <target> database target + --registry <registry> registry schema or database + --client <path> path to engine command-line client + --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-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 <project> + sqitch init <project> --uri <uri> + +=head1 Description + +This command creates an new Sqitch project -- basically a F<sqitch.conf> file, +a F<sqitch.plan> file, and F<deploy>, F<revert>, and F<verify> 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<pg> - L<PostgreSQL|https://postgresql.org/> and L<Postgres-XC|https://sourceforge.net/> + +=item * C<sqlite> - L<SQLite|https://sqlite.org/> + +=item * C<oracle> - L<Oracle|https://www.oracle.com/us/products/database/> + +=item * C<mysql> - L<MySQL|https://dev.mysql.com/> and L<MariaDB|https://mariadb.com/> + +=item * C<firebird> - L<Firebird|https://www.firebirdsql.org/> + +=item * C<vertica> - L<Vertica|https://my.vertica.com/> + +=item * C<exasol> - L<Exasol|https://www.exasol.com/> + +=item * C<snowflake> - L<Snowflake|https://www.snowflake.net/> + +=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<sql>. + +=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<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<--target> + + sqitch init widgets --target db:pg:widgets + +Specifies the name or L<URI|https://github.com/libwww-perl/uri-db/> of the default +target database. If specified as a name, the default URI for the target will +be C<db:$engine:>. + +=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<sqitch>. + +=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<sqitch init> does is create the project plan file, +F<sqitch.conf>. The options determine what gets written to the file: + +=over + +=item C<--engine> + +Sets the C<core.engine> configuration variable. + +=item C<--top-dir> + +Sets the C<core.top_dir> configuration variable. + +=item C<--plan-file> + +=item C<-f> + +Sets the C<core.plan_file> configuration variable. + +=item C<--extension> + +Sets the C<core.extension> configuration variable. + +=item C<--dir> + +Sets the following configuration variables: + +=over + +=item * C<deploy> sets C<core.deploy_dir> + +=item * C<revert> sets C<core.revert_dir> + +=item * C<verify> sets C<core.verify_dir> + +=item * C<reworked> sets C<core.reworked_dir> + +=item * C<reworked_deplpoy> sets C<core.reworked_deploy_dir> + +=item * C<reworked_deplpoy> sets C<core.reworked_revert_dir> + +=item * C<reworked_deplpoy> sets C<core.reworked_verify_dir> + +=back + +=item C<--target> + +Sets the C<engine.$engine.target> configuration variable if C<--engine> is +also passed and, if it's a target name, C<target.$target.uri> + +=item C<--registry> + +Sets the C<engine.$engine.registry> configuration variable if C<--engine> is also +passed. + +=item C<--client> + +Sets the C<engine.$engine.client> 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<name=value>, e.g., +C<--set defuser='Homer Simpson'>. Variables are set in C<core.variables>. + +=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<sqlite>: + + 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<postgres>, script extension to C<ddl>, reworked +directory to C<reworked> 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<sqitch-configuration> + +Describes how Sqitch hierarchical engine and target configuration works. + +=item L<sqitch-engine> + +Command to manage database engine configuration. + +=item L<sqitch-target> + +Command to manage target database configuration. + +=item L<sqitch-config> + +Command to manage all Sqitch configuration. + +=back + +=head1 Sqitch + +Part of the L<sqitch> 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] [<database>] + +=head1 Options + +Search options: + + -t --target <target> database to which to connect + --event <type> type of event + --change-pattern --change <name match regex against change names + --committer-pattern --committer <name> match regex against committer names + -n --max-count <count> show only specified number of events + --skip <number> skip the specified number of events + --reverse show events in reverse order + --no-reverse don't show events in reverse order + +Formatting: + + -f --format <format> show events in the specified format + --date-format --date <format> 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> 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-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] [<database>] + +=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<log> 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<< <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<--event> + +Filter by event type. May be specified more than once. Allowed values are: + +=over + +=item * C<deploy> + +=item * C<revert> + +=item * C<fail> + +=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<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 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<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>. + +=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<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 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<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 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<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 log --db-port 7654 + sqitch log -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 Configuration Variables + +=over + +=item C<log.format> + +Output format to use. Supports the same values as C<--format>. + +=item C<log.date_format> + +Format to use for timestamps. Supports the same values as the C<--date-format> +option. + +=item C<log.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> <project name>:<change name> <title line> + +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; @@ -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; @@ -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; |