├── .gitignore ├── box.json ├── .dev ├── docker │ ├── bin │ │ ├── box_build.sh │ │ ├── composer_install.sh │ │ ├── composer_update.sh │ │ ├── composer_show_outdated.sh │ │ ├── composer_download.sh │ │ ├── code_fix.sh │ │ ├── run_tests.sh │ │ └── refresh.sh │ └── images │ │ ├── php │ │ └── Dockerfile │ │ ├── release │ │ └── Dockerfile │ │ └── postgres │ │ ├── docker-entrypoint-initdb.d │ │ └── structure.sql │ │ └── config │ │ ├── pg_hba.conf │ │ └── postgresql.conf └── keeper-config.php ├── tests ├── bootstrap.php ├── phpunit.xml ├── SchemaKeeper │ ├── Core │ │ ├── SchemaFilterTest.php │ │ ├── SectionComparatorTest.php │ │ ├── DumpComparatorTest.php │ │ ├── ArrayConverterTest.php │ │ └── DumperTest.php │ ├── KeeperTest.php │ ├── CLI │ │ ├── ShellEntryPointTest.php │ │ ├── ParserTest.php │ │ ├── RunnerTest.php │ │ └── EntryPointTest.php │ ├── Provider │ │ └── PostgreSQL │ │ │ ├── PSQLClientTest.php │ │ │ └── SavepointHelperTest.php │ ├── Filesystem │ │ ├── SectionReaderTest.php │ │ ├── SectionWriterTest.php │ │ ├── DumpReaderTest.php │ │ └── DumpWriterTest.php │ └── Worker │ │ ├── VerifierTest.php │ │ ├── DeployerTest.php │ │ └── SaverTest.php └── helpers │ └── SchemaTestCase.php ├── .travis.yml ├── src └── SchemaKeeper │ ├── Exception │ ├── KeeperException.php │ └── NotEquals.php │ ├── CLI │ ├── Version.php │ ├── Result.php │ ├── Parsed.php │ ├── Parser.php │ ├── Runner.php │ └── EntryPoint.php │ ├── Core │ ├── SchemaFilter.php │ ├── Dump.php │ ├── DumpComparator.php │ ├── ArrayConverter.php │ ├── Dumper.php │ ├── SectionComparator.php │ └── SchemaStructure.php │ ├── Provider │ ├── PostgreSQL │ │ ├── PSQLChecker.php │ │ ├── SavepointHelper.php │ │ ├── PSQLParameters.php │ │ ├── PSQLClient.php │ │ └── PSQLProvider.php │ ├── IProvider.php │ └── ProviderFactory.php │ ├── Worker │ ├── Saver.php │ ├── Verifier.php │ └── Deployer.php │ ├── Filesystem │ ├── SectionReader.php │ ├── SectionWriter.php │ ├── FilesystemHelper.php │ ├── DumpWriter.php │ └── DumpReader.php │ ├── Outside │ └── DeployedFunctions.php │ └── Keeper.php ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md ├── docker-compose.yml ├── LICENSE ├── composer.json ├── bin └── schemakeeper ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.phar 2 | /box.phar 3 | /vendor 4 | /composer.lock 5 | /coverage.xml -------------------------------------------------------------------------------- /box.json: -------------------------------------------------------------------------------- 1 | { 2 | "directories": ["src"], 3 | "files": [ 4 | "LICENSE" 5 | ], 6 | "banner": false 7 | } -------------------------------------------------------------------------------- /.dev/docker/bin/box_build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | docker-compose run --rm php /data/box.phar compile -c /data/box.json -------------------------------------------------------------------------------- /.dev/docker/bin/composer_install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | docker-compose run --rm php php /data/composer.phar --working-dir=/data install -------------------------------------------------------------------------------- /.dev/docker/bin/composer_update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | docker-compose run --rm php php /data/composer.phar --working-dir=/data update -------------------------------------------------------------------------------- /.dev/docker/bin/composer_show_outdated.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | docker-compose run --rm php php /data/composer.phar --working-dir=/data outdated -D -------------------------------------------------------------------------------- /.dev/docker/bin/composer_download.sh: -------------------------------------------------------------------------------- 1 | docker-compose run --rm php php -r "copy(\"https://getcomposer.org/composer.phar\", \"/data/composer.phar\");" 2 | -------------------------------------------------------------------------------- /.dev/docker/bin/code_fix.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker-compose run --rm php php /data/vendor/bin/phpcbf \ 4 | -p \ 5 | --standard=PSR2 \ 6 | /data/src \ 7 | /data/tests -------------------------------------------------------------------------------- /.dev/docker/bin/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | docker-compose run --rm php /data/vendor/bin/phpunit -c /data/tests/phpunit.xml --coverage-clover=/data/coverage.xml /data/tests/SchemaKeeper -------------------------------------------------------------------------------- /.dev/keeper-config.php: -------------------------------------------------------------------------------- 1 | setSkippedSchemas([ 8 | 'information_schema', 9 | 'pg_%' 10 | ]); 11 | 12 | return $params; -------------------------------------------------------------------------------- /.dev/docker/images/php/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.1-cli-stretch 2 | 3 | RUN mkdir /usr/share/man/man1 /usr/share/man/man7 4 | 5 | RUN apt-get update \ 6 | && apt-get install -y unzip libpq-dev libz-dev postgresql-client \ 7 | && docker-php-ext-install pdo_pgsql zip \ 8 | && pecl install xdebug-2.7.2 \ 9 | && docker-php-ext-enable xdebug -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | require_once dirname(__DIR__).'/vendor/autoload.php'; 9 | 10 | error_reporting(E_ALL); 11 | -------------------------------------------------------------------------------- /.dev/docker/bin/refresh.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | docker-compose exec -T postgres bash <<'EOF' 3 | export PGPASSWORD=postgres \ 4 | && echo "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = 'schema_keeper' AND pid <> pg_backend_pid();" | psql -hpostgres -Upostgres \ 5 | && for f in /docker-entrypoint-initdb.d/*; do psql -hpostgres -Upostgres < "$f"; done 6 | EOF -------------------------------------------------------------------------------- /.dev/docker/images/release/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:5.6.40-cli-alpine 2 | 3 | RUN apk add postgresql-dev postgresql-client wget \ 4 | && docker-php-ext-install pdo_pgsql 5 | 6 | RUN mkdir /data \ 7 | && cd /data \ 8 | && wget https://github.com/dmytro-demchyna/schema-keeper/releases/latest/download/schemakeeper.phar \ 9 | && chmod +x /data/schemakeeper.phar 10 | 11 | ENTRYPOINT ["/data/schemakeeper.phar"] -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: minimal 2 | 3 | services: 4 | - docker 5 | 6 | script: 7 | - docker-compose up -d --build 8 | - ./.dev/docker/bin/composer_download.sh 9 | - ./.dev/docker/bin/composer_update.sh 10 | - ./.dev/docker/bin/code_fix.sh 11 | - ./.dev/docker/bin/run_tests.sh 12 | 13 | after_success: 14 | - bash <(curl -s https://codecov.io/bash) -f coverage.xml 15 | 16 | notifications: 17 | email: false -------------------------------------------------------------------------------- /src/SchemaKeeper/Exception/KeeperException.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Exception; 9 | 10 | /** 11 | * @api 12 | */ 13 | class KeeperException extends \RuntimeException 14 | { 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Additional context** 21 | Add any other context about the problem here. 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | php: 5 | build: ./.dev/docker/images/php 6 | volumes: 7 | - ./:/data 8 | depends_on: 9 | - postgres 10 | 11 | postgres: 12 | image: postgres:10.7 13 | ports: 14 | - "54322:5432" 15 | volumes: 16 | - ./.dev/docker/images/postgres/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d 17 | - ./.dev/docker/images/postgres/config:/etc/postgresql 18 | environment: 19 | POSTGRES_PASSWORD: postgres -------------------------------------------------------------------------------- /src/SchemaKeeper/CLI/Version.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\CLI; 9 | 10 | /** 11 | * @internal 12 | */ 13 | class Version 14 | { 15 | public const VERSION = 'v3.0-dev'; 16 | 17 | public static function getVersionText(): string 18 | { 19 | return 'SchemaKeeper ' . self::VERSION . ' by Dmytro Demchyna and contributors.' . PHP_EOL. PHP_EOL; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /tests/phpunit.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | ignore 11 | 12 | 13 | 14 | 15 | ../src 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/SchemaKeeper/CLI/Result.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\CLI; 9 | 10 | /** 11 | * @internal 12 | */ 13 | class Result 14 | { 15 | /** 16 | * @var string 17 | */ 18 | private $message; 19 | 20 | /** 21 | * @var int 22 | */ 23 | private $status; 24 | 25 | public function __construct(string $message, int $status) 26 | { 27 | $this->message = $message; 28 | $this->status = $status; 29 | } 30 | 31 | public function getMessage(): string 32 | { 33 | return $this->message; 34 | } 35 | 36 | public function getStatus(): int 37 | { 38 | return $this->status; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.dev/docker/images/postgres/docker-entrypoint-initdb.d/structure.sql: -------------------------------------------------------------------------------- 1 | DROP DATABASE IF EXISTS schema_keeper; 2 | CREATE DATABASE schema_keeper; 3 | 4 | \connect schema_keeper 5 | 6 | CREATE TABLE public.test_table ( 7 | id bigserial primary key , 8 | values text 9 | ); 10 | 11 | CREATE view public.test_view AS SELECT * FROM public.test_table; 12 | 13 | CREATE materialized view public.test_mat_view AS SELECT * FROM public.test_table; 14 | 15 | CREATE OR REPLACE FUNCTION public.trig_test() 16 | RETURNS trigger 17 | LANGUAGE plpgsql 18 | AS $function$ 19 | DECLARE 20 | BEGIN 21 | RETURN NEW; 22 | END; 23 | $function$; 24 | 25 | CREATE TRIGGER test_trigger BEFORE UPDATE ON public.test_table FOR EACH ROW EXECUTE PROCEDURE public.trig_test(); 26 | 27 | CREATE TYPE public.test_type AS ( 28 | id bigint, 29 | values character varying 30 | ); 31 | 32 | CREATE TYPE public.test_enum_type AS ENUM ( 33 | 'enum1', 34 | 'enum2' 35 | ); 36 | 37 | CREATE SCHEMA test_schema; -------------------------------------------------------------------------------- /src/SchemaKeeper/Core/SchemaFilter.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Core; 9 | 10 | /** 11 | * @internal 12 | */ 13 | class SchemaFilter 14 | { 15 | /** 16 | * @param string $schemaName 17 | * @param array $items 18 | * @return array 19 | */ 20 | public function filter(string $schemaName, array $items): array 21 | { 22 | $filteredItems = []; 23 | foreach ($items as $itemName => $itemContent) { 24 | $schemaNameLength = strlen($schemaName); 25 | if (substr($itemName, 0, $schemaNameLength + 1) == $schemaName.'.') { 26 | $newName = substr($itemName, $schemaNameLength + 1); 27 | $filteredItems[$newName] = $itemContent; 28 | } 29 | } 30 | 31 | return $filteredItems; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Dmytro Demchyna 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /tests/SchemaKeeper/Core/SchemaFilterTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Core; 9 | 10 | use SchemaKeeper\Core\SchemaFilter; 11 | use SchemaKeeper\Tests\SchemaTestCase; 12 | 13 | class SchemaFilterTest extends SchemaTestCase 14 | { 15 | /** 16 | * @var SchemaFilter 17 | */ 18 | private $target; 19 | 20 | public function setUp() 21 | { 22 | parent::setUp(); 23 | 24 | $this->target = new SchemaFilter(); 25 | } 26 | 27 | public function testOk() 28 | { 29 | $actual = $this->target->filter('schema1', [ 30 | 'schema1.table_name1' => 'table_content1', 31 | 'schema2.table_name2' => 'table_content2', 32 | ]); 33 | 34 | $expected = [ 35 | 'table_name1' => 'table_content1' 36 | ]; 37 | 38 | self::assertEquals($expected, $actual); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Core/Dump.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Core; 9 | 10 | /** 11 | * @internal 12 | */ 13 | class Dump 14 | { 15 | /** 16 | * @var SchemaStructure[] 17 | */ 18 | private $schemas; 19 | 20 | /** 21 | * @var string[] 22 | */ 23 | private $extensions; 24 | 25 | /** 26 | * @param SchemaStructure[] $schemas 27 | * @param string[] $extensions 28 | */ 29 | public function __construct(array $schemas, array $extensions) 30 | { 31 | $this->schemas = $schemas; 32 | $this->extensions = $extensions; 33 | } 34 | 35 | /** 36 | * @return SchemaStructure[] 37 | */ 38 | public function getSchemas(): array 39 | { 40 | return $this->schemas; 41 | } 42 | 43 | /** 44 | * @return string[] 45 | */ 46 | public function getExtensions(): array 47 | { 48 | return $this->extensions; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/SchemaKeeper/CLI/Parsed.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\CLI; 9 | 10 | use SchemaKeeper\Provider\PostgreSQL\PSQLParameters; 11 | 12 | /** 13 | * @internal 14 | */ 15 | class Parsed 16 | { 17 | /** 18 | * @var PSQLParameters 19 | */ 20 | private $params; 21 | 22 | /** 23 | * @var string 24 | */ 25 | private $command; 26 | 27 | /** 28 | * @var string 29 | */ 30 | private $path; 31 | 32 | public function __construct(PSQLParameters $params, string $command, string $path) 33 | { 34 | $this->params = $params; 35 | $this->command = $command; 36 | $this->path = $path; 37 | } 38 | 39 | public function getParams(): PSQLParameters 40 | { 41 | return $this->params; 42 | } 43 | 44 | public function getCommand(): string 45 | { 46 | return $this->command; 47 | } 48 | 49 | public function getPath(): string 50 | { 51 | return $this->path; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Provider/PostgreSQL/PSQLChecker.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Provider\PostgreSQL; 9 | 10 | use SchemaKeeper\Exception\KeeperException; 11 | 12 | /** 13 | * @internal 14 | */ 15 | class PSQLChecker 16 | { 17 | /** 18 | * @var PSQLParameters 19 | */ 20 | private $parameters; 21 | 22 | 23 | public function __construct(PSQLParameters $parameters) 24 | { 25 | $this->parameters = $parameters; 26 | } 27 | 28 | public function check(): void 29 | { 30 | if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { 31 | throw new KeeperException('OS Windows is currently not supported'); 32 | } 33 | 34 | $executable = $this->parameters->getExecutable(); 35 | 36 | exec('command -v ' . $executable . ' >/dev/null 2>&1 || exit 1', $output, $retVal); 37 | 38 | if ($retVal !== 0) { 39 | throw new KeeperException($executable . ' not installed. Please, install "postgresql-client" package'); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Contributor Code of Conduct 4 | 5 | Please note that this project is released with a [Contributor Code of Conduct](https://github.com/dmytro-demchyna/schema-keeper/blob/master/.github/CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. 6 | 7 | ## Environment 8 | 9 | Development environment are fully virtualized, so it requires installed [Docker Compose](https://docs.docker.com/compose/). 10 | 11 | Please, use steps below to setting the project on your machine: 12 | 13 | 1. Clone project via `git clone` 14 | 1. Open project directory in terminal 15 | 1. Execute `docker-compose up -d` 16 | 1. Execute `./.dev/docker/bin/composer_download.sh` 17 | 1. Execute `./.dev/docker/bin/composer_install.sh` 18 | 1. Execute `./.dev/docker/bin/run_tests.sh` to ensure that project works as expected 19 | 20 | > Directory `.dev/docker/images/postgres/docker-entrypoint-initdb.d/` contains scripts that [container](https://hub.docker.com/_/postgres) will automatically run on startup. 21 | 22 | ## Workflow 23 | 24 | 1. Fork the project 25 | 1. Make your changes 26 | 1. Add tests for it 27 | 1. Send a pull request 28 | 29 | ## Coding guidelines 30 | 31 | This project comes with an executable `./docker/bin/code_fix.sh` that you can use to format source code for compliance with this project's coding guidelines. -------------------------------------------------------------------------------- /src/SchemaKeeper/Worker/Saver.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Worker; 9 | 10 | use Exception; 11 | use SchemaKeeper\Core\Dumper; 12 | use SchemaKeeper\Core\SchemaFilter; 13 | use SchemaKeeper\Filesystem\DumpWriter; 14 | use SchemaKeeper\Filesystem\FilesystemHelper; 15 | use SchemaKeeper\Filesystem\SectionWriter; 16 | use SchemaKeeper\Provider\IProvider; 17 | 18 | /** 19 | * @internal 20 | */ 21 | class Saver 22 | { 23 | /** 24 | * @var Dumper 25 | */ 26 | private $dumper; 27 | 28 | /** 29 | * @var DumpWriter 30 | */ 31 | private $writer; 32 | 33 | public function __construct(IProvider $provider) 34 | { 35 | $schemaFilter = new SchemaFilter(); 36 | $this->dumper = new Dumper($provider, $schemaFilter); 37 | $helper = new FilesystemHelper(); 38 | $sectionWriter = new SectionWriter($helper); 39 | $this->writer = new DumpWriter($sectionWriter, $helper); 40 | } 41 | 42 | public function save(string $destinationPath): void 43 | { 44 | $dump = $this->dumper->dump(); 45 | $this->writer->write($destinationPath, $dump); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/SchemaKeeper/CLI/Parser.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\CLI; 9 | 10 | use SchemaKeeper\Exception\KeeperException; 11 | use SchemaKeeper\Provider\PostgreSQL\PSQLParameters; 12 | 13 | /** 14 | * @internal 15 | */ 16 | class Parser 17 | { 18 | public function parse(array $options, array $argv): Parsed 19 | { 20 | $configPath = isset($options['c']) ? $options['c'] : null; 21 | 22 | if (!$configPath || !is_readable($configPath)) { 23 | throw new KeeperException("Config file not found or not readable ".$configPath); 24 | } 25 | 26 | $params = require $configPath; 27 | 28 | if (!($params instanceof PSQLParameters)) { 29 | throw new KeeperException("Config file must return instance of ".PSQLParameters::class); 30 | } 31 | 32 | $path = isset($options['d']) ? $options['d'] : null; 33 | 34 | if (!$path) { 35 | throw new KeeperException("Destination path not specified"); 36 | } 37 | 38 | $count = count($argv); 39 | $command = isset($argv[$count - 1]) ? $argv[$count - 1] : null; 40 | 41 | return new Parsed($params, $command, $path); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Filesystem/SectionReader.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Filesystem; 9 | 10 | /** 11 | * @internal 12 | */ 13 | class SectionReader 14 | { 15 | /** 16 | * @var FilesystemHelper 17 | */ 18 | private $helper; 19 | 20 | 21 | public function __construct(FilesystemHelper $helper) 22 | { 23 | $this->helper = $helper; 24 | } 25 | 26 | /** 27 | * @param string $sectionPath 28 | * @return array 29 | * @throws \Exception 30 | */ 31 | public function readSection(string $sectionPath): array 32 | { 33 | $list = []; 34 | 35 | if (!$this->helper->isDir($sectionPath)) { 36 | return []; 37 | } 38 | 39 | foreach ($this->helper->glob($sectionPath . '/*') as $itemPath) { 40 | $parts = pathinfo($itemPath); 41 | 42 | $parts['extension'] = $parts['extension'] ?? ''; 43 | 44 | if (!in_array($parts['extension'], ['txt', 'sql'])) { 45 | continue; 46 | } 47 | 48 | $content = $this->helper->fileGetContents($itemPath); 49 | $list[$parts['filename']] = $content; 50 | } 51 | 52 | return $list; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "schema-keeper/schema-keeper", 3 | "description": "Database development kit for PostgreSQL", 4 | "minimum-stability": "stable", 5 | "type": "library", 6 | "license": "MIT", 7 | "keywords": [ 8 | "database", 9 | "db", 10 | "postgresql", 11 | "postgres", 12 | "plpgsql", 13 | "pgsql", 14 | "stored procedures", 15 | "schema", 16 | "dump", 17 | "deploy", 18 | "sync" 19 | ], 20 | "authors": [ 21 | { 22 | "name": "Dmytro Demchyna", 23 | "email": "dmitry.demchina@gmail.com", 24 | "role": "Developer", 25 | "homepage": "https://github.com/dmytro-demchyna" 26 | } 27 | ], 28 | "autoload": { 29 | "psr-4": { 30 | "SchemaKeeper\\": "src/SchemaKeeper" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "SchemaKeeper\\Tests\\": "tests/SchemaKeeper" 36 | }, 37 | "classmap": ["tests/helpers"] 38 | }, 39 | "require": { 40 | "php": ">=7.1", 41 | "ext-pdo": "*", 42 | "ext-pdo_pgsql": "*", 43 | "ext-json": "*" 44 | }, 45 | "require-dev": { 46 | "phpunit/phpunit": "^5.0||^6.0||^7.0||^8.0", 47 | "mockery/mockery": "^1.0", 48 | "squizlabs/php_codesniffer": "^3.0", 49 | "phpstan/phpstan": "*" 50 | }, 51 | "suggest": { 52 | "doctrine/migrations": "" 53 | }, 54 | "bin": [ 55 | "bin/schemakeeper" 56 | ], 57 | "extra": { 58 | "branch-alias": { 59 | "dev-master": "v3.0-dev" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Filesystem/SectionWriter.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Filesystem; 9 | 10 | /** 11 | * @internal 12 | */ 13 | class SectionWriter 14 | { 15 | /** 16 | * @var FilesystemHelper 17 | */ 18 | private $helper; 19 | 20 | public function __construct(FilesystemHelper $helper) 21 | { 22 | $this->helper = $helper; 23 | } 24 | 25 | /** 26 | * @param string $sectionPath 27 | * @param array $sectionContent 28 | * @throws \Exception 29 | */ 30 | public function writeSection(string $sectionPath, array $sectionContent): void 31 | { 32 | if (!$sectionContent) { 33 | return; 34 | } 35 | 36 | $this->helper->mkdir($sectionPath, 0775, true); 37 | 38 | $parts = pathinfo($sectionPath); 39 | $sectionName = $parts['filename']; 40 | 41 | foreach ($sectionContent as $name => $content) { 42 | if (in_array($sectionName, ['functions', 'triggers'])) { 43 | $name = $name . '.sql'; 44 | } else { 45 | $name = $name . '.txt'; 46 | } 47 | 48 | $this->helper->filePutContents($sectionPath . '/' . $name, $content); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Provider/PostgreSQL/SavepointHelper.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Provider\PostgreSQL; 9 | 10 | use PDO; 11 | 12 | /** 13 | * @internal 14 | */ 15 | class SavepointHelper 16 | { 17 | /** 18 | * @var PDO 19 | */ 20 | private $conn; 21 | 22 | 23 | public function __construct(PDO $conn) 24 | { 25 | $this->conn = $conn; 26 | } 27 | 28 | public function beginTransaction(string $possibleSavePointName, bool $isTransaction): bool 29 | { 30 | if ($isTransaction) { 31 | return (bool) $this->conn->exec('SAVEPOINT '.$possibleSavePointName); 32 | } 33 | 34 | return $this->conn->beginTransaction(); 35 | } 36 | 37 | public function commit(string $possibleSavePointName, bool $isTransaction): bool 38 | { 39 | if ($isTransaction) { 40 | return (bool) $this->conn->exec('RELEASE SAVEPOINT '.$possibleSavePointName); 41 | } 42 | 43 | return $this->conn->commit(); 44 | } 45 | 46 | public function rollback(string $possibleSavePointName, bool $isTransaction): bool 47 | { 48 | if ($isTransaction) { 49 | return (bool) $this->conn->exec('ROLLBACK TO SAVEPOINT '.$possibleSavePointName); 50 | } 51 | 52 | return $this->conn->rollBack(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Exception/NotEquals.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Exception; 9 | 10 | /** 11 | * @api 12 | */ 13 | class NotEquals extends KeeperException 14 | { 15 | /** 16 | * @var array 17 | */ 18 | private $expected = []; 19 | 20 | /** 21 | * @var array 22 | */ 23 | private $actual = []; 24 | 25 | /** 26 | * @param string $message 27 | * @param array $expected 28 | * @param array $actual 29 | */ 30 | public function __construct($message, array $expected, array $actual) 31 | { 32 | $message .= PHP_EOL . json_encode([ 33 | 'expected' => $expected, 34 | 'actual' => $actual, 35 | ], JSON_PRETTY_PRINT); 36 | 37 | if (json_last_error() !== JSON_ERROR_NONE) { 38 | throw new \RuntimeException('Json error: ' . json_last_error_msg()); 39 | } 40 | 41 | parent::__construct($message); 42 | 43 | $this->expected = $expected; 44 | $this->actual = $actual; 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | public function getExpected() 51 | { 52 | return $this->expected; 53 | } 54 | 55 | /** 56 | * @return array 57 | */ 58 | public function getActual() 59 | { 60 | return $this->actual; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/KeeperTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests; 9 | 10 | use SchemaKeeper\Keeper; 11 | 12 | class KeeperTest extends SchemaTestCase 13 | { 14 | /** 15 | * @var Keeper 16 | */ 17 | private $target; 18 | 19 | public function setUp() 20 | { 21 | parent::setUp(); 22 | 23 | $conn = $this->getConn(); 24 | $params = $this->getDbParams(); 25 | $this->target = new Keeper($conn, $params); 26 | 27 | exec('rm -rf /tmp/schema_keeper'); 28 | } 29 | 30 | public function testOk() 31 | { 32 | $this->target->saveDump('/tmp/schema_keeper'); 33 | 34 | $conn = $this->getConn(); 35 | $conn->beginTransaction(); 36 | $this->target->deployDump('/tmp/schema_keeper'); 37 | $conn->rollBack(); 38 | 39 | $this->target->verifyDump('/tmp/schema_keeper'); 40 | 41 | self::assertTrue(true); 42 | } 43 | 44 | /** 45 | * @expectedException \SchemaKeeper\Exception\KeeperException 46 | * @expectedExceptionMessage blabla not installed. Please, install "postgresql-client" package 47 | */ 48 | function testRequirementsCheck() 49 | { 50 | $conn = $this->getConn(); 51 | $params = $this->getDbParams(); 52 | $params->setExecutable('blabla'); 53 | 54 | $this->target = new Keeper($conn, $params); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /bin/schemakeeper: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 6 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 7 | */ 8 | 9 | if (version_compare(PHP_VERSION, '5.6.0', '<')) { 10 | fwrite(STDERR, 'SchemaKeeper requires PHP >= 5.6' . PHP_EOL); 11 | exit(1); 12 | } 13 | 14 | foreach (['pdo', 'pdo_pgsql', 'json'] as $extension) { 15 | if (!extension_loaded($extension)) { 16 | fwrite(STDERR, 'SchemaKeeper requires "' . $extension . '" extension' . PHP_EOL); 17 | exit(1); 18 | } 19 | } 20 | 21 | $autoloadVariants = [ 22 | __DIR__ . '/../../../autoload.php', 23 | __DIR__ . '/../vendor/autoload.php', 24 | ]; 25 | 26 | $autoloadPath = null; 27 | 28 | foreach ($autoloadVariants as $variantPath) { 29 | if (file_exists($variantPath)) { 30 | $autoloadPath = $variantPath; 31 | 32 | break; 33 | } 34 | } 35 | 36 | if (!$autoloadPath) { 37 | fwrite(STDERR, 38 | 'You must set up the project dependencies:' . PHP_EOL . 39 | 'curl -s http://getcomposer.org/installer | php' . PHP_EOL . 40 | 'php composer.phar install' . PHP_EOL); 41 | 42 | exit(1); 43 | } 44 | 45 | require_once $autoloadPath; 46 | 47 | echo \SchemaKeeper\CLI\Version::getVersionText(); 48 | 49 | $options = getopt('c:d:', ['help', 'version']); 50 | 51 | $entryPoint = new \SchemaKeeper\CLI\EntryPoint(); 52 | $result = $entryPoint->run($options, $argv); 53 | 54 | echo $result->getMessage() . PHP_EOL; 55 | exit($result->getStatus()); -------------------------------------------------------------------------------- /src/SchemaKeeper/Core/DumpComparator.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Core; 9 | 10 | /** 11 | * @internal 12 | */ 13 | class DumpComparator 14 | { 15 | /** 16 | * @var ArrayConverter 17 | */ 18 | private $converter; 19 | 20 | /** 21 | * @var SectionComparator 22 | */ 23 | private $sectionComparator; 24 | 25 | public function __construct(ArrayConverter $converter, SectionComparator $sectionComparator) 26 | { 27 | $this->converter = $converter; 28 | $this->sectionComparator = $sectionComparator; 29 | } 30 | 31 | /** 32 | * @param Dump $expectedDump 33 | * @param Dump $actualDump 34 | * @return array{expected:array,actual:array} 35 | */ 36 | public function compare(Dump $expectedDump, Dump $actualDump): array 37 | { 38 | $diff = [ 39 | 'expected' => [], 40 | 'actual' => [], 41 | ]; 42 | 43 | $expectedArray = $this->converter->dump2Array($expectedDump); 44 | $actualArray = $this->converter->dump2Array($actualDump); 45 | 46 | $keys = array_keys($expectedArray); 47 | 48 | foreach ($keys as $key) { 49 | $diff = array_merge_recursive( 50 | $diff, 51 | $this->sectionComparator->compareSection($key, $expectedArray[$key], $actualArray[$key]) 52 | ); 53 | } 54 | 55 | return $diff; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Core/SectionComparatorTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Core; 9 | 10 | use SchemaKeeper\Core\SectionComparator; 11 | use SchemaKeeper\Tests\SchemaTestCase; 12 | 13 | class SectionComparatorTest extends SchemaTestCase 14 | { 15 | /** 16 | * @var SectionComparator 17 | */ 18 | private $target; 19 | 20 | public function setUp() 21 | { 22 | parent::setUp(); 23 | 24 | $this->target = new SectionComparator(); 25 | } 26 | 27 | public function testOk() 28 | { 29 | $sectionName = 'tables'; 30 | 31 | $leftContent = [ 32 | 'test1' => 'test_content1', 33 | 'test2' => 'test_content2', 34 | 'test3' => 'test_content3', 35 | ]; 36 | 37 | $rightContent = [ 38 | 'test1' => 'test_content1', 39 | 'test2' => 'test_content', 40 | 'test3' => 'test_content3', 41 | ]; 42 | 43 | $expected = [ 44 | 'expected' => [ 45 | 'tables' => [ 46 | 'test2' => "test_content2", 47 | ], 48 | ], 49 | 'actual' => [ 50 | 'tables' => [ 51 | 'test2' => "test_content", 52 | ], 53 | ], 54 | ]; 55 | 56 | $actual = $this->target->compareSection($sectionName, $leftContent, $rightContent); 57 | 58 | self::assertEquals($expected, $actual); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/helpers/SchemaTestCase.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests; 9 | 10 | use PDO; 11 | use PHPUnit\Framework\TestCase; 12 | use SchemaKeeper\Provider\PostgreSQL\PSQLParameters; 13 | 14 | abstract class SchemaTestCase extends TestCase 15 | { 16 | /** 17 | * @var \PDO 18 | */ 19 | private static $conn; 20 | 21 | protected function tearDown() 22 | { 23 | parent::tearDown(); 24 | 25 | $this->addToAssertionCount( 26 | \Mockery::getContainer()->mockery_getExpectationCount() 27 | ); 28 | 29 | \Mockery::close(); 30 | } 31 | 32 | /** 33 | * @return PDO 34 | */ 35 | protected static function getConn() 36 | { 37 | if (!self::$conn) { 38 | self::$conn = self::createConn(); 39 | } 40 | 41 | return self::$conn; 42 | } 43 | 44 | 45 | /** 46 | * @return PSQLParameters 47 | */ 48 | protected static function getDbParams() 49 | { 50 | $dbParams = include '/data/.dev/keeper-config.php'; 51 | 52 | return $dbParams; 53 | } 54 | 55 | /** 56 | * @return PDO 57 | */ 58 | private static function createConn() 59 | { 60 | $dbParams = self::getDbParams(); 61 | 62 | $dsn = 'pgsql:dbname=' . $dbParams->getDbName() . ';host=' . $dbParams->getHost(); 63 | 64 | $conn = new \PDO($dsn, $dbParams->getUser(), $dbParams->getPassword(), [ 65 | \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION 66 | ]); 67 | 68 | return $conn; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [2.2.0] - 2019-06-05 8 | 9 | ### Changed 10 | - Pretty print instead of minified print for a json output 11 | 12 | ## [2.1.1] - 2019-05-27 13 | 14 | ### Added 15 | - PHAR builder 16 | 17 | ## [2.1.0] - 2019-05-14 18 | 19 | ### Added 20 | - More accurate CLI output 21 | - CLI parameter --version 22 | - Failure on unrecognized CLI parameter 23 | - OS checking 24 | 25 | ### Changed 26 | - Output with exit-code 1 to STDOUT instead of STDERR for SchemaKeeper's native exceptions 27 | 28 | ## [2.0.1] - 2019-05-10 29 | 30 | ### Changed 31 | - `schemakeeper deploy` runs in transaction by default 32 | 33 | ## [2.0.0] - 2019-05-10 34 | 35 | ### Added 36 | - bin/schemakeeper 37 | 38 | ### Changed 39 | - `verifyDump` returns void 40 | - `deployDump` returns object instead of array 41 | - `verifyDump` and `deployDump` throws exception on diff 42 | 43 | ## [1.0.5] - 2019-05-08 44 | 45 | ### Added 46 | - Protection from removing all functions using deployDump 47 | 48 | ### Changed 49 | - Fix bug that provoke error in case running deployDump without transaction 50 | 51 | ## [1.0.4] - 2019-05-02 52 | 53 | ### Added 54 | - Throwing exception if `psql` not installed 55 | 56 | ## [1.0.3] - 2019-04-17 57 | 58 | ### Changed 59 | - Exception message in some cases 60 | 61 | ## [1.0.2] - 2019-04-11 62 | 63 | ### Changed 64 | - README.md 65 | - "suggest" block in composer.json 66 | 67 | ## [1.0.1] - 2019-03-22 68 | ### Added 69 | - CHANGELOG.md 70 | 71 | ### Changed 72 | - Keywords and description in composer.json 73 | 74 | ## [1.0.0] - 2019-03-22 -------------------------------------------------------------------------------- /src/SchemaKeeper/Outside/DeployedFunctions.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Outside; 9 | 10 | /** 11 | * @api 12 | */ 13 | class DeployedFunctions 14 | { 15 | /** 16 | * @var string[] 17 | */ 18 | private $changed = []; 19 | 20 | /** 21 | * @var string[] 22 | */ 23 | private $created = []; 24 | 25 | /** 26 | * @var string[] 27 | */ 28 | private $deleted = []; 29 | 30 | /** 31 | * @param string[] $changed 32 | * @param string[] $created 33 | * @param string[] $deleted 34 | */ 35 | public function __construct(array $changed, array $created, array $deleted) 36 | { 37 | $this->changed = $changed; 38 | $this->created = $created; 39 | $this->deleted = $deleted; 40 | } 41 | 42 | /** 43 | * List of functions that were changed in the current database, as their source code is different between saved dump and current database 44 | * @return string[] 45 | */ 46 | public function getChanged(): array 47 | { 48 | return $this->changed; 49 | } 50 | 51 | /** 52 | * List of functions that were created in the current database, as they do not exist in the saved dump 53 | * @return string[] 54 | */ 55 | public function getCreated(): array 56 | { 57 | return $this->created; 58 | } 59 | 60 | /** 61 | * List of functions that were deleted from the current database, as they do not exist in the saved dump 62 | * @return string[] 63 | */ 64 | public function getDeleted(): array 65 | { 66 | return $this->deleted; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Provider/IProvider.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Provider; 9 | 10 | /** 11 | * @internal 12 | */ 13 | interface IProvider 14 | { 15 | /** 16 | * @return array 17 | */ 18 | public function getTables(): array; 19 | 20 | /** 21 | * @return array 22 | */ 23 | public function getViews(): array; 24 | 25 | /** 26 | * @return array 27 | */ 28 | public function getMaterializedViews(): array; 29 | 30 | /** 31 | * @return array 32 | */ 33 | public function getTriggers(): array; 34 | 35 | /** 36 | * @return array 37 | */ 38 | public function getFunctions(): array; 39 | 40 | /** 41 | * @return array 42 | */ 43 | public function getTypes(): array; 44 | 45 | /** 46 | * @return array 47 | */ 48 | public function getSchemas(): array; 49 | 50 | /** 51 | * @return array 52 | */ 53 | public function getExtensions(): array; 54 | 55 | /** 56 | * @return array 57 | */ 58 | public function getSequences(): array; 59 | 60 | /** 61 | * @param string $definition 62 | */ 63 | public function createFunction(string $definition): void; 64 | 65 | /** 66 | * @param string $name 67 | */ 68 | public function deleteFunction(string $name): void; 69 | 70 | /** 71 | * @param string $name 72 | * @param string $definition 73 | */ 74 | public function changeFunction(string $name, string $definition): void; 75 | } 76 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/CLI/ShellEntryPointTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\CLI; 9 | 10 | use SchemaKeeper\CLI\Version; 11 | use SchemaKeeper\Tests\SchemaTestCase; 12 | 13 | class ShellEntryPointTest extends SchemaTestCase 14 | { 15 | function setUp() 16 | { 17 | parent::setUp(); 18 | 19 | exec('rm -rf /tmp/dump'); 20 | } 21 | 22 | function testOk() 23 | { 24 | exec('/data/bin/schemakeeper -c /data/.dev/keeper-config.php -d /tmp/dump save', $output, $status); 25 | $output = implode(PHP_EOL, $output); 26 | self::assertEquals(Version::getVersionText() . 'Success: Dump saved /tmp/dump', $output); 27 | self::assertSame(0, $status); 28 | } 29 | 30 | function testHelp() 31 | { 32 | exec('/data/bin/schemakeeper --help', $output, $status); 33 | $output = implode(PHP_EOL, $output); 34 | self::assertContains('Usage: schemakeeper [options] ', $output); 35 | self::assertSame(0, $status); 36 | } 37 | 38 | function testVersion() 39 | { 40 | exec('/data/bin/schemakeeper --version', $output, $status); 41 | $output = implode(PHP_EOL, $output); 42 | 43 | self::assertEquals(Version::getVersionText(), $output); 44 | self::assertSame(0, $status); 45 | } 46 | 47 | function testError() 48 | { 49 | exec('/data/bin/schemakeeper -c /data/.dev/keeper-config.php -d /tmp/dump verify', $output, $status); 50 | $output = implode(PHP_EOL, $output); 51 | self::assertEquals(Version::getVersionText() . 'Failure: Dump is empty /tmp/dump', $output); 52 | self::assertSame(1, $status); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Provider/ProviderFactory.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Provider; 9 | 10 | use PDO; 11 | use SchemaKeeper\Exception\KeeperException; 12 | use SchemaKeeper\Provider\PostgreSQL\PSQLChecker; 13 | use SchemaKeeper\Provider\PostgreSQL\PSQLClient; 14 | use SchemaKeeper\Provider\PostgreSQL\PSQLParameters; 15 | use SchemaKeeper\Provider\PostgreSQL\PSQLProvider; 16 | 17 | /** 18 | * @internal 19 | */ 20 | class ProviderFactory 21 | { 22 | /** 23 | * @param PDO $conn 24 | * @param object $parameters 25 | * @return IProvider 26 | * @throws KeeperException 27 | */ 28 | public function createProvider(PDO $conn, $parameters = null): IProvider 29 | { 30 | if ($conn->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'pgsql') { 31 | throw new KeeperException('Only pgsql driver is supported'); 32 | } 33 | 34 | if (!($parameters instanceof PSQLParameters)) { 35 | throw new KeeperException('$parameters must be instance of '.PSQLParameters::class); 36 | } 37 | 38 | $checker = new PSQLChecker($parameters); 39 | $checker->check(); 40 | 41 | $client = new PSQLClient( 42 | $parameters->getExecutable(), 43 | $parameters->getDbName(), 44 | $parameters->getHost(), 45 | $parameters->getPort(), 46 | $parameters->getUser(), 47 | $parameters->getPassword() 48 | ); 49 | 50 | $provider = new PSQLProvider( 51 | $conn, 52 | $client, 53 | $parameters->getSkippedSchemas(), 54 | $parameters->getSkippedExtensions() 55 | ); 56 | 57 | return $provider; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Core/ArrayConverter.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Core; 9 | 10 | use SchemaKeeper\Exception\KeeperException; 11 | 12 | /** 13 | * @internal 14 | */ 15 | class ArrayConverter 16 | { 17 | /** 18 | * @param Dump $dump 19 | * @return array 20 | */ 21 | public function dump2Array(Dump $dump): array 22 | { 23 | $keysMapping = [ 24 | 'tables' => 'getTables', 25 | 'views' => 'getViews', 26 | 'materialized_views' => 'getMaterializedViews', 27 | 'types' => 'getTypes', 28 | 'functions' => 'getFunctions', 29 | 'triggers' => 'getTriggers', 30 | 'sequences' => 'getSequences', 31 | ]; 32 | 33 | $result = array_combine(array_keys($keysMapping), array_fill(0, count($keysMapping), [])); 34 | 35 | if ($result === false) { 36 | throw new KeeperException('array_combine() problem'); 37 | } 38 | 39 | $result['schemas'] = []; 40 | $result['extensions'] = $dump->getExtensions(); 41 | 42 | foreach ($dump->getSchemas() as $schemaDump) { 43 | $result['schemas'][] = $schemaDump->getSchemaName(); 44 | 45 | foreach ($keysMapping as $key => $methodName) { 46 | $newItems = []; 47 | 48 | foreach ($schemaDump->$methodName() as $itemName => $itemContent) { 49 | $newItems[$schemaDump->getSchemaName().'.'.$itemName] = $itemContent; 50 | } 51 | 52 | $result[$key] = isset($result[$key]) ? $result[$key] : []; 53 | $result[$key] = array_merge($result[$key], $newItems); 54 | } 55 | } 56 | 57 | return $result; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Filesystem/FilesystemHelper.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Filesystem; 9 | 10 | use Exception; 11 | use SchemaKeeper\Exception\KeeperException; 12 | 13 | /** 14 | * @internal 15 | */ 16 | class FilesystemHelper 17 | { 18 | public function isDir(string $path): bool 19 | { 20 | return is_dir($path); 21 | } 22 | 23 | public function fileGetContents(string $filename): string 24 | { 25 | $content = file_get_contents($filename); 26 | 27 | if ($content === false) { 28 | throw new KeeperException('file_get_contents error on: '.$filename); 29 | } 30 | 31 | return $content; 32 | } 33 | 34 | public function filePutContents(string $filename, string $data): void 35 | { 36 | $result = file_put_contents($filename, $data); 37 | 38 | if ($result === false) { 39 | throw new KeeperException('file_put_contents error on: '.$filename); 40 | } 41 | } 42 | 43 | public function glob(string $pattern): array 44 | { 45 | $result = glob($pattern); 46 | 47 | if ($result === false) { 48 | throw new KeeperException('glob() error'); 49 | } 50 | 51 | return $result; 52 | } 53 | 54 | public function mkdir(string $pathname, int $mode = 0775, bool $recursive = false): void 55 | { 56 | $result = mkdir($pathname, $mode, $recursive); 57 | 58 | if ($result === false) { 59 | throw new KeeperException('mkdir error on: '.$pathname); 60 | } 61 | } 62 | 63 | public function rmDirIfExisted(string $path): void 64 | { 65 | if ($this->isDir($path)) { 66 | shell_exec("rm -rf ".escapeshellarg($path)); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/CLI/ParserTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\CLI; 9 | 10 | use SchemaKeeper\CLI\Parser; 11 | use SchemaKeeper\Provider\PostgreSQL\PSQLParameters; 12 | use SchemaKeeper\Tests\SchemaTestCase; 13 | 14 | class ParserTest extends SchemaTestCase 15 | { 16 | /** 17 | * @var Parser 18 | */ 19 | private $target; 20 | 21 | function setUp() 22 | { 23 | parent::setUp(); 24 | 25 | $this->target = new Parser(); 26 | } 27 | 28 | function testOk() 29 | { 30 | $parsed = $this->target->parse(['c' => '/data/.dev/keeper-config.php', 'd' => '/tmp/dump'], ['save']); 31 | 32 | self::assertInstanceOf(PSQLParameters::class, $parsed->getParams()); 33 | self::assertEquals('/tmp/dump', $parsed->getPath()); 34 | self::assertEquals('save', $parsed->getCommand()); 35 | } 36 | 37 | /** 38 | * @expectedException \SchemaKeeper\Exception\KeeperException 39 | * @expectedExceptionMessage Config file not found or not readable /data/dummyconfig 40 | */ 41 | function testConfigNotExisted() 42 | { 43 | $this->target->parse(['c' => '/data/dummyconfig'], []); 44 | } 45 | 46 | /** 47 | * @expectedException \SchemaKeeper\Exception\KeeperException 48 | * @expectedExceptionMessage Config file must return instance of SchemaKeeper\Provider\PostgreSQL\PSQLParameters 49 | */ 50 | function testConfigBadInstance() 51 | { 52 | file_put_contents('/tmp/dummyconfig', ''); 53 | 54 | $this->target->parse(['c' => '/tmp/dummyconfig'], []); 55 | } 56 | 57 | /** 58 | * @expectedException \SchemaKeeper\Exception\KeeperException 59 | * @expectedExceptionMessage Destination path not specified 60 | */ 61 | function testWithoutDestinationPath() 62 | { 63 | $this->target->parse(['c' => '/data/.dev/keeper-config.php'], []); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Worker/Verifier.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Worker; 9 | 10 | use Exception; 11 | use SchemaKeeper\Core\ArrayConverter; 12 | use SchemaKeeper\Core\DumpComparator; 13 | use SchemaKeeper\Core\Dumper; 14 | use SchemaKeeper\Core\SchemaFilter; 15 | use SchemaKeeper\Core\SectionComparator; 16 | use SchemaKeeper\Exception\NotEquals; 17 | use SchemaKeeper\Filesystem\DumpReader; 18 | use SchemaKeeper\Filesystem\FilesystemHelper; 19 | use SchemaKeeper\Filesystem\SectionReader; 20 | use SchemaKeeper\Provider\IProvider; 21 | 22 | /** 23 | * @internal 24 | */ 25 | class Verifier 26 | { 27 | /** 28 | * @var Dumper 29 | */ 30 | private $dumper; 31 | 32 | /** 33 | * @var DumpReader 34 | */ 35 | private $dumpReader; 36 | 37 | /** 38 | * @var DumpComparator 39 | */ 40 | private $comparator; 41 | 42 | 43 | public function __construct(IProvider $provider) 44 | { 45 | $schemaFilter = new SchemaFilter(); 46 | $this->dumper = new Dumper($provider, $schemaFilter); 47 | 48 | $helper = new FilesystemHelper(); 49 | $sectionReader = new SectionReader($helper); 50 | $this->dumpReader = new DumpReader($sectionReader, $helper); 51 | $converter = new ArrayConverter(); 52 | $sectionComparator = new SectionComparator(); 53 | $this->comparator = new DumpComparator($converter, $sectionComparator); 54 | } 55 | 56 | public function verify(string $sourcePath): void 57 | { 58 | $actual = $this->dumper->dump(); 59 | $expected = $this->dumpReader->read($sourcePath); 60 | 61 | $comparisonResult = $this->comparator->compare($expected, $actual); 62 | 63 | if ($comparisonResult['expected'] !== $comparisonResult['actual']) { 64 | throw new NotEquals('Dump and current database not equals:', $comparisonResult['expected'], $comparisonResult['actual']); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Provider/PostgreSQL/PSQLClientTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Provider\PostgreSQL; 9 | 10 | use SchemaKeeper\Provider\PostgreSQL\PSQLClient; 11 | use SchemaKeeper\Tests\SchemaTestCase; 12 | 13 | class PSQLClientTest extends SchemaTestCase 14 | { 15 | /** 16 | * @var PSQLClient 17 | */ 18 | private $target; 19 | 20 | public function setUp() 21 | { 22 | parent::setUp(); 23 | 24 | $dbParams = $this->getDbParams(); 25 | 26 | $this->target = new PSQLClient( 27 | $dbParams->getExecutable(), 28 | $dbParams->getDbName(), 29 | $dbParams->getHost(), 30 | $dbParams->getPort(), 31 | $dbParams->getUser(), 32 | $dbParams->getPassword() 33 | ); 34 | } 35 | 36 | public function testOneCommandOk() 37 | { 38 | $expected = "12345"; 39 | 40 | $actual = $this->target->run("\qecho -n 12345"); 41 | 42 | self::assertEquals($expected, $actual); 43 | } 44 | 45 | public function testMultipleCommands() 46 | { 47 | $expected = [ 48 | "12345", 49 | "54321" 50 | ]; 51 | 52 | $actual = $this->target->runMultiple(["\qecho -n 12345", "\qecho -n 54321"]); 53 | 54 | self::assertEquals($expected, $actual); 55 | } 56 | 57 | public function testBatchCommands() 58 | { 59 | $commands = []; 60 | 61 | for ($i = 0; $i < 502; $i++) { 62 | $commands[] = '\qecho -n num'.$i; 63 | } 64 | 65 | $actual = $this->target->runMultiple($commands); 66 | 67 | self::assertCount(502, $actual); 68 | self::assertEquals('num501', $actual[501]); 69 | } 70 | 71 | public function testEmptyCommands() 72 | { 73 | $expected = []; 74 | 75 | $actual = $this->target->runMultiple([]); 76 | 77 | self::assertEquals($expected, $actual); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Core/Dumper.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Core; 9 | 10 | use SchemaKeeper\Provider\IProvider; 11 | 12 | /** 13 | * @internal 14 | */ 15 | class Dumper 16 | { 17 | /** 18 | * @var IProvider 19 | */ 20 | private $provider; 21 | 22 | /** 23 | * @var SchemaFilter 24 | */ 25 | private $filter; 26 | 27 | 28 | public function __construct(IProvider $provider, SchemaFilter $filter) 29 | { 30 | $this->provider = $provider; 31 | $this->filter = $filter; 32 | } 33 | 34 | public function dump(): Dump 35 | { 36 | $schemas = []; 37 | 38 | $tables = $this->provider->getTables(); 39 | $views = $this->provider->getViews(); 40 | $materializedViews = $this->provider->getMaterializedViews(); 41 | $types = $this->provider->getTypes(); 42 | $functions = $this->provider->getFunctions(); 43 | $triggers = $this->provider->getTriggers(); 44 | $sequences = $this->provider->getSequences(); 45 | 46 | foreach ($this->provider->getSchemas() as $schemaName) { 47 | $structure = new SchemaStructure($schemaName); 48 | 49 | $structure->setTables($this->filter->filter($schemaName, $tables)); 50 | $structure->setViews($this->filter->filter($schemaName, $views)); 51 | $structure->setMaterializedViews($this->filter->filter($schemaName, $materializedViews)); 52 | $structure->setTypes($this->filter->filter($schemaName, $types)); 53 | $structure->setFunctions($this->filter->filter($schemaName, $functions)); 54 | $structure->setTriggers($this->filter->filter($schemaName, $triggers)); 55 | $structure->setSequences($this->filter->filter($schemaName, $sequences)); 56 | 57 | $schemas[] = $structure; 58 | } 59 | 60 | $dump = new Dump($schemas, $this->provider->getExtensions()); 61 | 62 | return $dump; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Filesystem/SectionReaderTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Filesystem; 9 | 10 | use Mockery\MockInterface; 11 | use SchemaKeeper\Filesystem\FilesystemHelper; 12 | use SchemaKeeper\Filesystem\SectionReader; 13 | use SchemaKeeper\Tests\SchemaTestCase; 14 | 15 | class SectionReaderTest extends SchemaTestCase 16 | { 17 | /** 18 | * @var SectionReader 19 | */ 20 | private $target; 21 | 22 | /** 23 | * @var FilesystemHelper|MockInterface 24 | */ 25 | private $helper; 26 | 27 | public function setUp() 28 | { 29 | parent::setUp(); 30 | 31 | $this->helper = \Mockery::mock(FilesystemHelper::class); 32 | $this->helper->shouldReceive('isDir')->andReturnTrue()->byDefault(); 33 | 34 | $this->target = new SectionReader($this->helper); 35 | } 36 | 37 | public function testOk() 38 | { 39 | $files = [ 40 | '/etc/file1.sql', 41 | '/etc/file2.sh', 42 | '/etc/file3.txt', 43 | ]; 44 | 45 | $this->helper->shouldReceive('glob')->with('/etc/*')->andReturn($files)->once(); 46 | 47 | $this->helper->shouldReceive('fileGetContents')->with('/etc/file1.sql')->andReturn('file_content1')->ordered(); 48 | $this->helper->shouldReceive('fileGetContents')->with('/etc/file3.txt')->andReturn('file_content2')->ordered(); 49 | 50 | $actual = $this->target->readSection('/etc'); 51 | 52 | $expected = [ 53 | 'file1' => 'file_content1', 54 | 'file3' => 'file_content2' 55 | ]; 56 | 57 | self::assertEquals($expected, $actual); 58 | } 59 | 60 | public function testNotExistedDirectory() 61 | { 62 | $this->helper->shouldReceive('isDir')->andReturnFalse()->once(); 63 | $this->helper->shouldNotReceive('glob'); 64 | 65 | $actual = $this->target->readSection('/etc'); 66 | 67 | self::assertEquals([], $actual); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/CLI/RunnerTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\CLI; 9 | 10 | use Mockery\MockInterface; 11 | use SchemaKeeper\CLI\Runner; 12 | use SchemaKeeper\Keeper; 13 | use SchemaKeeper\Outside\DeployedFunctions; 14 | use SchemaKeeper\Tests\SchemaTestCase; 15 | 16 | class RunnerTest extends SchemaTestCase 17 | { 18 | /** 19 | * @var Runner 20 | */ 21 | private $target; 22 | 23 | /** 24 | * @var Keeper|MockInterface 25 | */ 26 | private $keeper; 27 | 28 | function setUp() 29 | { 30 | parent::setUp(); 31 | 32 | $this->keeper = \Mockery::mock(Keeper::class); 33 | $this->target = new Runner($this->keeper); 34 | } 35 | 36 | function testSave() 37 | { 38 | $this->keeper->shouldReceive('saveDump')->with('/tmp/dump')->once(); 39 | 40 | $message = $this->target->run('save', '/tmp/dump'); 41 | 42 | self::assertEquals('Dump saved /tmp/dump', $message); 43 | } 44 | 45 | function testVerify() 46 | { 47 | $this->keeper->shouldReceive('verifyDump')->with('/tmp/dump')->once(); 48 | 49 | $message = $this->target->run('verify', '/tmp/dump'); 50 | 51 | self::assertEquals('Dump verified /tmp/dump', $message); 52 | } 53 | 54 | function testDeploy() 55 | { 56 | $this->keeper->shouldReceive('deployDump')->with('/tmp/dump')->andReturn(new DeployedFunctions(['2'], ['1', '11'], ['3'])) 57 | ->once(); 58 | 59 | $message = $this->target->run('deploy', '/tmp/dump'); 60 | 61 | self::assertEquals("Dump deployed /tmp/dump\n Deleted 3\n Created 1\n Created 11\n Changed 2", $message); 62 | } 63 | 64 | /** 65 | * @expectedException \SchemaKeeper\Exception\KeeperException 66 | * @expectedExceptionMessage Unrecognized command blabla. Available commands: save, verify, deploy 67 | */ 68 | function testUndefinedFunction() 69 | { 70 | $this->target->run('blabla', '/tmp/dump'); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/SchemaKeeper/CLI/Runner.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\CLI; 9 | 10 | use SchemaKeeper\Exception\KeeperException; 11 | use SchemaKeeper\Keeper; 12 | 13 | /** 14 | * @internal 15 | */ 16 | class Runner 17 | { 18 | /** 19 | * @var Keeper 20 | */ 21 | private $keeper; 22 | 23 | public function __construct(Keeper $keeper) 24 | { 25 | $this->keeper = $keeper; 26 | } 27 | 28 | public function run(string $command, string $path): string 29 | { 30 | switch ($command) { 31 | case 'save': 32 | $this->keeper->saveDump($path); 33 | $message = 'Dump saved ' . $path; 34 | 35 | break; 36 | case 'verify': 37 | $this->keeper->verifyDump($path); 38 | 39 | $message = 'Dump verified ' . $path; 40 | 41 | break; 42 | case 'deploy': 43 | $result = $this->keeper->deployDump($path); 44 | 45 | $message = ''; 46 | 47 | foreach ($result->getDeleted() as $nameDeleted) { 48 | $message .= PHP_EOL . " Deleted $nameDeleted"; 49 | } 50 | 51 | foreach ($result->getCreated() as $nameCreated) { 52 | $message .= PHP_EOL . " Created $nameCreated"; 53 | } 54 | 55 | foreach ($result->getChanged() as $nameChanged) { 56 | $message .= PHP_EOL . " Changed $nameChanged"; 57 | } 58 | 59 | if ($message) { 60 | $message = 'Dump deployed ' . $path . $message; 61 | } else { 62 | $message = 'Nothing to deploy ' . $path; 63 | } 64 | 65 | break; 66 | default: 67 | throw new KeeperException('Unrecognized command ' . $command . '. Available commands: save, verify, deploy'); 68 | 69 | break; 70 | } 71 | 72 | return $message; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Filesystem/DumpWriter.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Filesystem; 9 | 10 | use Exception; 11 | use SchemaKeeper\Core\Dump; 12 | 13 | /** 14 | * @internal 15 | */ 16 | class DumpWriter 17 | { 18 | /** 19 | * @var SectionWriter 20 | */ 21 | private $sectionWriter; 22 | 23 | /** 24 | * @var FilesystemHelper 25 | */ 26 | private $helper; 27 | 28 | 29 | public function __construct(SectionWriter $sectionWriter, FilesystemHelper $helper) 30 | { 31 | $this->sectionWriter = $sectionWriter; 32 | $this->helper = $helper; 33 | } 34 | 35 | public function write(string $path, Dump $dump): void 36 | { 37 | $structurePath = $path.'/structure'; 38 | $extensionsPath = $path.'/extensions'; 39 | 40 | $this->helper->rmDirIfExisted($extensionsPath); 41 | $this->sectionWriter->writeSection($extensionsPath, $dump->getExtensions()); 42 | 43 | foreach ($dump->getSchemas() as $schemaDump) { 44 | $schemaPath = $structurePath.'/'.$schemaDump->getSchemaName(); 45 | 46 | $this->helper->rmDirIfExisted($schemaPath); 47 | $this->helper->mkdir($schemaPath, 0775, true); 48 | 49 | $this->helper->filePutContents($schemaPath.'/.gitkeep', ''); 50 | 51 | $this->sectionWriter->writeSection($schemaPath . '/tables', $schemaDump->getTables()); 52 | $this->sectionWriter->writeSection($schemaPath . '/views', $schemaDump->getViews()); 53 | $this->sectionWriter->writeSection($schemaPath . '/materialized_views', $schemaDump->getMaterializedViews()); 54 | $this->sectionWriter->writeSection($schemaPath . '/types', $schemaDump->getTypes()); 55 | $this->sectionWriter->writeSection($schemaPath . '/functions', $schemaDump->getFunctions()); 56 | $this->sectionWriter->writeSection($schemaPath . '/triggers', $schemaDump->getTriggers()); 57 | $this->sectionWriter->writeSection($schemaPath . '/sequences', $schemaDump->getSequences()); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Filesystem/SectionWriterTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Filesystem; 9 | 10 | use Mockery\MockInterface; 11 | use SchemaKeeper\Filesystem\FilesystemHelper; 12 | use SchemaKeeper\Filesystem\SectionWriter; 13 | use SchemaKeeper\Tests\SchemaTestCase; 14 | 15 | class SectionWriterTest extends SchemaTestCase 16 | { 17 | /** 18 | * @var SectionWriter 19 | */ 20 | private $target; 21 | 22 | /** 23 | * @var FilesystemHelper|MockInterface 24 | */ 25 | private $helper; 26 | 27 | public function setUp() 28 | { 29 | parent::setUp(); 30 | 31 | $this->helper = \Mockery::mock(FilesystemHelper::class); 32 | $this->helper->shouldReceive('mkdir')->andReturnTrue()->byDefault(); 33 | 34 | $this->target = new SectionWriter($this->helper); 35 | } 36 | 37 | public function testTablesOk() 38 | { 39 | $this->helper->shouldReceive('filePutContents')->with('/etc/tables/test1.txt', 'content1')->ordered(); 40 | $this->helper->shouldReceive('filePutContents')->with('/etc/tables/test2.txt', 'content2')->ordered(); 41 | 42 | $this->target->writeSection('/etc/tables', [ 43 | 'test1' => 'content1', 44 | 'test2' => 'content2', 45 | ]); 46 | } 47 | 48 | public function testFunctionsOk() 49 | { 50 | $this->helper->shouldReceive('filePutContents')->with('/etc/functions/test1.sql', 'content1')->ordered(); 51 | 52 | $this->target->writeSection('/etc/functions', [ 53 | 'test1' => 'content1', 54 | ]); 55 | } 56 | 57 | public function testTriggersOk() 58 | { 59 | $this->helper->shouldReceive('filePutContents')->with('/etc/triggers/test1.sql', 'content1')->ordered(); 60 | 61 | $this->target->writeSection('/etc/triggers', [ 62 | 'test1' => 'content1', 63 | ]); 64 | } 65 | 66 | public function testEmptyContent() 67 | { 68 | $this->helper->shouldNotReceive('mkdir'); 69 | $this->target->writeSection('/etc/tables', []); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Core/SectionComparator.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Core; 9 | 10 | /** 11 | * @internal 12 | */ 13 | class SectionComparator 14 | { 15 | /** 16 | * @param string $sectionName 17 | * @param array $expectedSection 18 | * @param array $actualSection 19 | * @return array{expected:array,actual:array} 20 | */ 21 | public function compareSection(string $sectionName, array $expectedSection, array $actualSection): array 22 | { 23 | if ($expectedSection === $actualSection) { 24 | return ['expected' => [], 'actual' => []]; 25 | } 26 | 27 | $compared = $this->doCompare($sectionName, $expectedSection, $actualSection); 28 | $comparedInverted = $this->doCompare($sectionName, $actualSection, $expectedSection); 29 | 30 | $result = [ 31 | 'expected' => array_merge($compared['expected'], $comparedInverted['actual']), 32 | 'actual' => array_merge($compared['actual'], $comparedInverted['expected']), 33 | ]; 34 | 35 | return $result; 36 | } 37 | 38 | /** 39 | * @param string $sectionName 40 | * @param array $expectedSection 41 | * @param array $actualSection 42 | * @return array 43 | */ 44 | private function doCompare(string $sectionName, array $expectedSection, array $actualSection): array 45 | { 46 | $result = [ 47 | 'expected' => [], 48 | 'actual' => [], 49 | ]; 50 | 51 | foreach ($expectedSection as $expectedItemName => $expectedItemContent) { 52 | $actualItemContent = isset($actualSection[$expectedItemName]) ? $actualSection[$expectedItemName] : ''; 53 | if ($expectedItemContent === $actualItemContent) { 54 | continue; 55 | } 56 | 57 | $result['expected'][$sectionName][$expectedItemName] = $expectedSection[$expectedItemName]; 58 | if ($actualItemContent) { 59 | $result['actual'][$sectionName][$expectedItemName] = $actualItemContent; 60 | } 61 | } 62 | 63 | return $result; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Filesystem/DumpReader.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Filesystem; 9 | 10 | use SchemaKeeper\Core\Dump; 11 | use SchemaKeeper\Core\SchemaStructure; 12 | use SchemaKeeper\Exception\KeeperException; 13 | 14 | /** 15 | * @internal 16 | */ 17 | class DumpReader 18 | { 19 | /** 20 | * @var SectionReader 21 | */ 22 | private $sectionReader; 23 | 24 | /** 25 | * @var FilesystemHelper 26 | */ 27 | private $helper; 28 | 29 | 30 | public function __construct(SectionReader $sectionReader, FilesystemHelper $helper) 31 | { 32 | $this->sectionReader = $sectionReader; 33 | $this->helper = $helper; 34 | } 35 | 36 | public function read(string $path): Dump 37 | { 38 | $structurePath = $path.'/structure'; 39 | $extensionsPath = $path.'/extensions'; 40 | 41 | $extensions = $this->sectionReader->readSection($extensionsPath); 42 | 43 | $schemas = []; 44 | 45 | foreach ($this->helper->glob($structurePath . '/*') as $schemaPath) { 46 | $parts = pathinfo($schemaPath); 47 | $schemaName = $parts['filename']; 48 | 49 | $structure = new SchemaStructure($schemaName); 50 | 51 | $structure->setTables($this->sectionReader->readSection($schemaPath.'/tables')); 52 | $structure->setViews($this->sectionReader->readSection($schemaPath.'/views')); 53 | $structure->setMaterializedViews($this->sectionReader->readSection($schemaPath.'/materialized_views')); 54 | $structure->setTypes($this->sectionReader->readSection($schemaPath.'/types')); 55 | $structure->setFunctions($this->sectionReader->readSection($schemaPath.'/functions')); 56 | $structure->setTriggers($this->sectionReader->readSection($schemaPath.'/triggers')); 57 | $structure->setSequences($this->sectionReader->readSection($schemaPath.'/sequences')); 58 | 59 | $schemas[] = $structure; 60 | } 61 | 62 | if (!$schemas) { 63 | throw new KeeperException('Dump is empty '.$path); 64 | } 65 | 66 | $dump = new Dump($schemas, $extensions); 67 | 68 | return $dump; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Keeper.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper; 9 | 10 | use Exception; 11 | use PDO; 12 | use SchemaKeeper\Outside\DeployedFunctions; 13 | use SchemaKeeper\Provider\ProviderFactory; 14 | use SchemaKeeper\Worker\Deployer; 15 | use SchemaKeeper\Worker\Saver; 16 | use SchemaKeeper\Worker\Verifier; 17 | 18 | /** 19 | * @api 20 | */ 21 | class Keeper 22 | { 23 | /** 24 | * @var Saver 25 | */ 26 | private $saver; 27 | 28 | /** 29 | * @var Deployer 30 | */ 31 | private $deployer; 32 | 33 | /** 34 | * @var Verifier 35 | */ 36 | private $verifier; 37 | 38 | /** 39 | * @param PDO $conn 40 | * @param object $parameters Depends on DBMS. Only PSQLParameters supported now (PostgreSQL) 41 | * @throws Exception 42 | * @see \SchemaKeeper\Provider\PostgreSQL\PSQLParameters 43 | */ 44 | public function __construct(PDO $conn, $parameters = null) 45 | { 46 | $factory = new ProviderFactory(); 47 | $provider = $factory->createProvider($conn, $parameters); 48 | 49 | $this->saver = new Saver($provider); 50 | $this->deployer = new Deployer($provider); 51 | $this->verifier = new Verifier($provider); 52 | } 53 | 54 | /** 55 | * Make structure dump from current database and save it in filesystem 56 | * @param string $destinationPath Dump will be saved in this folder 57 | * @throws Exception 58 | */ 59 | public function saveDump(string $destinationPath): void 60 | { 61 | $this->saver->save($destinationPath); 62 | } 63 | 64 | /** 65 | * Compare current dump with dump previously saved in filesystem. 66 | * @param string $dumpPath Path to previously saved dump 67 | * @throws Exception 68 | */ 69 | public function verifyDump(string $dumpPath): void 70 | { 71 | $this->verifier->verify($dumpPath); 72 | } 73 | 74 | /** 75 | * Deploy functions from dump previously saved in filesystem. 76 | * @param string $dumpPath Path to previously saved dump 77 | * @return DeployedFunctions 78 | * @throws Exception 79 | */ 80 | public function deployDump(string $dumpPath): DeployedFunctions 81 | { 82 | return $this->deployer->deploy($dumpPath); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Provider/PostgreSQL/SavepointHelperTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Provider\PostgreSQL; 9 | 10 | use Mockery\MockInterface; 11 | use PDO; 12 | use SchemaKeeper\Provider\PostgreSQL\SavepointHelper; 13 | use SchemaKeeper\Tests\SchemaTestCase; 14 | 15 | class SavepointHelperTest extends SchemaTestCase 16 | { 17 | /** 18 | * @var SavepointHelper 19 | */ 20 | private $target; 21 | 22 | /** 23 | * @var PDO|MockInterface 24 | */ 25 | private $conn; 26 | 27 | public function setUp() 28 | { 29 | parent::setUp(); 30 | 31 | $this->conn = \Mockery::mock(PDO::class); 32 | 33 | $this->target = new SavepointHelper($this->conn); 34 | } 35 | 36 | public function testBeginTransaction() 37 | { 38 | $this->conn->shouldNotReceive('exec'); 39 | $this->conn->shouldReceive('beginTransaction')->andReturnFalse()->once(); 40 | 41 | $this->target->beginTransaction('test', false); 42 | } 43 | 44 | public function testBeginTransactionUsingSavepoint() 45 | { 46 | $this->conn->shouldReceive('exec')->with('SAVEPOINT test')->once(); 47 | $this->conn->shouldNotReceive('beginTransaction'); 48 | 49 | $this->target->beginTransaction('test', true); 50 | } 51 | 52 | public function testCommit() 53 | { 54 | $this->conn->shouldNotReceive('exec'); 55 | $this->conn->shouldReceive('commit')->andReturnFalse()->once(); 56 | 57 | $this->target->commit('test', false); 58 | } 59 | 60 | public function testCommitUsingSavepoint() 61 | { 62 | $this->conn->shouldReceive('exec')->with('RELEASE SAVEPOINT test')->once(); 63 | $this->conn->shouldNotReceive('commit'); 64 | 65 | $this->target->commit('test', true); 66 | } 67 | 68 | public function testRollback() 69 | { 70 | $this->conn->shouldNotReceive('exec'); 71 | $this->conn->shouldReceive('rollback')->andReturnFalse()->once(); 72 | 73 | $this->target->rollback('test', false); 74 | } 75 | 76 | public function testRollbackUsingSavepoint() 77 | { 78 | $this->conn->shouldReceive('exec')->with('ROLLBACK TO SAVEPOINT test')->once(); 79 | $this->conn->shouldNotReceive('rollback'); 80 | 81 | $this->target->rollback('test', true); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Core/DumpComparatorTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Core; 9 | 10 | use Mockery\MockInterface; 11 | use SchemaKeeper\Core\ArrayConverter; 12 | use SchemaKeeper\Core\Dump; 13 | use SchemaKeeper\Core\DumpComparator; 14 | use SchemaKeeper\Core\SectionComparator; 15 | use SchemaKeeper\Tests\SchemaTestCase; 16 | 17 | class DumpComparatorTest extends SchemaTestCase 18 | { 19 | /** 20 | * @var DumpComparator 21 | */ 22 | private $target; 23 | 24 | /** 25 | * @var ArrayConverter|MockInterface 26 | */ 27 | private $converter; 28 | 29 | /** 30 | * @var SectionComparator|MockInterface 31 | */ 32 | private $sectionComparator; 33 | 34 | public function setUp() 35 | { 36 | parent::setUp(); 37 | 38 | $this->converter = \Mockery::mock(ArrayConverter::class); 39 | $this->sectionComparator = \Mockery::mock(SectionComparator::class); 40 | $this->target = new DumpComparator($this->converter, $this->sectionComparator); 41 | } 42 | 43 | public function testOk() 44 | { 45 | $dump1 = new Dump([], ['ext1']); 46 | $dump2 = new Dump([], ['ext2']); 47 | 48 | $this->converter->shouldReceive('dump2Array')->with($dump1)->andReturn([ 49 | 'tables' => ['table1'], 50 | 'functions' => ['function1'], 51 | ])->once(); 52 | 53 | $this->converter->shouldReceive('dump2Array')->with($dump2)->andReturn([ 54 | 'tables' => ['table2'], 55 | 'functions' => ['function2'], 56 | ])->once(); 57 | 58 | $this->sectionComparator->shouldReceive('compareSection')->with('tables', ['table1'], ['table2'])->andReturn([ 59 | 'expected' => 'left1', 60 | 'actual' => 'right1', 61 | ])->once(); 62 | 63 | $this->sectionComparator->shouldReceive('compareSection')->with('functions', ['function1'], ['function2'])->andReturn([ 64 | 'expected' => 'left2', 65 | 'actual' => 'right2', 66 | ])->once(); 67 | 68 | $expected = [ 69 | 'expected' => [ 70 | 'left1', 71 | 'left2', 72 | ], 73 | 'actual' => [ 74 | 'right1', 75 | 'right2', 76 | ], 77 | ]; 78 | 79 | $actual = $this->target->compare($dump1, $dump2); 80 | 81 | self::assertEquals($expected, $actual); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Provider/PostgreSQL/PSQLParameters.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Provider\PostgreSQL; 9 | 10 | /** 11 | * @api 12 | */ 13 | class PSQLParameters 14 | { 15 | /** 16 | * @var string 17 | */ 18 | private $host; 19 | 20 | /** 21 | * @var int 22 | */ 23 | private $port; 24 | 25 | /** 26 | * @var string 27 | */ 28 | private $dbName; 29 | 30 | /** 31 | * @var string 32 | */ 33 | private $user; 34 | 35 | /** 36 | * @var string 37 | */ 38 | private $password; 39 | 40 | /** 41 | * @var string 42 | */ 43 | private $executable = 'psql'; 44 | 45 | /** 46 | * @var string[] 47 | */ 48 | private $skippedSchemaNames = []; 49 | 50 | /** 51 | * @var string[] 52 | */ 53 | private $skippedExtensionNames = []; 54 | 55 | 56 | public function __construct(string $host, int $port, string $dbName, string $user, string $password) 57 | { 58 | $this->host = $host; 59 | $this->port = $port; 60 | $this->dbName = $dbName; 61 | $this->user = $user; 62 | $this->password = $password; 63 | } 64 | 65 | public function getDbName(): string 66 | { 67 | return $this->dbName; 68 | } 69 | 70 | public function getUser(): string 71 | { 72 | return $this->user; 73 | } 74 | 75 | public function getPassword(): string 76 | { 77 | return $this->password; 78 | } 79 | 80 | public function getHost(): string 81 | { 82 | return $this->host; 83 | } 84 | 85 | public function getPort(): int 86 | { 87 | return $this->port; 88 | } 89 | 90 | public function getExecutable(): string 91 | { 92 | return $this->executable; 93 | } 94 | 95 | public function setExecutable(string $executable): void 96 | { 97 | $this->executable = $executable; 98 | } 99 | 100 | public function getSkippedSchemas(): array 101 | { 102 | return $this->skippedSchemaNames; 103 | } 104 | 105 | public function setSkippedSchemas(array $skippedSchemaNames): void 106 | { 107 | $this->skippedSchemaNames = $skippedSchemaNames; 108 | } 109 | 110 | public function getSkippedExtensions(): array 111 | { 112 | return $this->skippedExtensionNames; 113 | } 114 | 115 | public function setSkippedExtensions(array $skippedExtensionNames): void 116 | { 117 | $this->skippedExtensionNames = $skippedExtensionNames; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Worker/VerifierTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Worker; 9 | 10 | use SchemaKeeper\Exception\NotEquals; 11 | use SchemaKeeper\Provider\ProviderFactory; 12 | use SchemaKeeper\Tests\SchemaTestCase; 13 | use SchemaKeeper\Worker\Saver; 14 | use SchemaKeeper\Worker\Verifier; 15 | 16 | class VerifierTest extends SchemaTestCase 17 | { 18 | /** 19 | * @var Verifier 20 | */ 21 | private $target; 22 | 23 | /** 24 | * @var Saver 25 | */ 26 | private $saver; 27 | 28 | public function setUp() 29 | { 30 | parent::setUp(); 31 | 32 | $conn = $this->getConn(); 33 | $params = $this->getDbParams(); 34 | $providerFactory = new ProviderFactory(); 35 | $provider = $providerFactory->createProvider($conn, $params); 36 | 37 | $this->target = new Verifier($provider); 38 | $this->saver = new Saver($provider); 39 | 40 | exec('rm -rf /tmp/schema_keeper'); 41 | } 42 | 43 | public function testOk() 44 | { 45 | $this->saver->save('/tmp/schema_keeper'); 46 | $this->target->verify('/tmp/schema_keeper'); 47 | 48 | self::assertTrue(true); 49 | } 50 | 51 | public function testDiff() 52 | { 53 | $this->saver->save('/tmp/schema_keeper'); 54 | exec('rm -r /tmp/schema_keeper/structure/public/triggers'); 55 | 56 | $catch = false; 57 | 58 | try { 59 | $this->target->verify('/tmp/schema_keeper'); 60 | } catch (NotEquals $e) { 61 | $catch = true; 62 | $expectedMessage = 'Dump and current database not equals: 63 | { 64 | "expected": [], 65 | "actual": { 66 | "triggers": { 67 | "public.test_table.test_trigger": "CREATE TRIGGER test_trigger BEFORE UPDATE ON test_table FOR EACH ROW EXECUTE PROCEDURE trig_test()" 68 | } 69 | } 70 | }'; 71 | $expectedTriggers = [ 72 | 'triggers' => [ 73 | 'public.test_table.test_trigger' => 'CREATE TRIGGER test_trigger BEFORE UPDATE ON test_table FOR EACH ROW EXECUTE PROCEDURE trig_test()', 74 | ], 75 | ]; 76 | 77 | self::assertEquals($expectedMessage, $e->getMessage()); 78 | self::assertEquals([], $e->getExpected()); 79 | self::assertEquals($expectedTriggers, $e->getActual()); 80 | } 81 | 82 | self::assertTrue($catch); 83 | } 84 | 85 | /** 86 | * @expectedException \SchemaKeeper\Exception\KeeperException 87 | * @expectedExceptionMessage Dump is empty /tmp/schema_keeper 88 | */ 89 | function testEmptyDump() 90 | { 91 | $this->target->verify('/tmp/schema_keeper'); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/CLI/EntryPointTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\CLI; 9 | 10 | use SchemaKeeper\CLI\EntryPoint; 11 | use SchemaKeeper\Tests\SchemaTestCase; 12 | 13 | class EntryPointTest extends SchemaTestCase 14 | { 15 | /** 16 | * @var EntryPoint 17 | */ 18 | private $target; 19 | 20 | function setUp() 21 | { 22 | parent::setUp(); 23 | 24 | $this->target = new EntryPoint(); 25 | 26 | exec('rm -rf /tmp/dump'); 27 | } 28 | 29 | function testOk() 30 | { 31 | $result = $this->target->run(['c' => '/data/.dev/keeper-config.php', 'd' => '/tmp/dump'], ['save']); 32 | self::assertEquals('Success: Dump saved /tmp/dump', $result->getMessage()); 33 | self::assertSame(0, $result->getStatus()); 34 | 35 | $result = $this->target->run(['c' => '/data/.dev/keeper-config.php', 'd' => '/tmp/dump'], ['verify']); 36 | self::assertEquals('Success: Dump verified /tmp/dump', $result->getMessage()); 37 | self::assertSame(0, $result->getStatus()); 38 | 39 | $result = $this->target->run(['c' => '/data/.dev/keeper-config.php', 'd' => '/tmp/dump'], ['deploy']); 40 | self::assertEquals('Success: Nothing to deploy /tmp/dump', $result->getMessage()); 41 | self::assertSame(0, $result->getStatus()); 42 | } 43 | 44 | function testHelp() 45 | { 46 | $result = $this->target->run(['help' => 0], []); 47 | self::assertContains('Usage: schemakeeper [options] ', $result->getMessage()); 48 | self::assertSame(0, $result->getStatus()); 49 | } 50 | 51 | function testVersion() 52 | { 53 | $result = $this->target->run(['version' => 0], []); 54 | self::assertEquals('', $result->getMessage()); 55 | self::assertSame(0, $result->getStatus()); 56 | } 57 | 58 | function testConfigError() 59 | { 60 | $result = $this->target->run([], []); 61 | self::assertEquals('Failure: Config file not found or not readable ', $result->getMessage()); 62 | 63 | self::assertSame(1, $result->getStatus()); 64 | } 65 | 66 | function testUnrecognizedCommand() 67 | { 68 | $result = $this->target->run(['c' => '/data/.dev/keeper-config.php', 'd' => '/tmp/dump'], ['blabla']); 69 | self::assertEquals( 70 | 'Failure: Unrecognized command blabla. Available commands: save, verify, deploy', 71 | $result->getMessage() 72 | ); 73 | self::assertSame(1, $result->getStatus()); 74 | } 75 | 76 | function testUnrecognizedOption() 77 | { 78 | $result = $this->target->run(['blabla' => 0], []); 79 | self::assertEquals('Unrecognized option: blabla', $result->getMessage()); 80 | self::assertSame(1, $result->getStatus()); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/SchemaKeeper/CLI/EntryPoint.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\CLI; 9 | 10 | use PDO; 11 | use SchemaKeeper\Exception\KeeperException; 12 | use SchemaKeeper\Keeper; 13 | 14 | /** 15 | * @internal 16 | */ 17 | class EntryPoint 18 | { 19 | public function run(array $options, array $argv): Result 20 | { 21 | $message = ''; 22 | 23 | foreach ($options as $optionName => $optionValue) { 24 | if (!in_array($optionName, ['c', 'd', 'help', 'version'])) { 25 | $message .= 'Unrecognized option: ' . $optionName; 26 | 27 | return new Result($message, 1); 28 | } 29 | } 30 | 31 | if (isset($options['help'])) { 32 | $message .= "Usage: schemakeeper [options] " . PHP_EOL . PHP_EOL . 33 | 'Example: schemakeeper -c /path_to_config.php -d /path_to_dump save' . PHP_EOL . PHP_EOL 34 | . 'Options:' . PHP_EOL 35 | . ' -c The path to a config file' . PHP_EOL 36 | . ' -d The destination path to a dump directory' . PHP_EOL . PHP_EOL 37 | . ' --help Print this help message' . PHP_EOL 38 | . ' --version Print version information' . PHP_EOL . PHP_EOL 39 | . 'Available commands:' . PHP_EOL 40 | . ' save' . PHP_EOL 41 | . ' verify' . PHP_EOL 42 | . ' deploy'; 43 | 44 | return new Result($message, 0); 45 | } 46 | 47 | if (isset($options['version'])) { 48 | return new Result($message, 0); 49 | } 50 | 51 | try { 52 | $parser = new Parser(); 53 | $parsed = $parser->parse($options, $argv); 54 | 55 | $params = $parsed->getParams(); 56 | $path = $parsed->getPath(); 57 | $command = $parsed->getCommand(); 58 | 59 | $dsn = 'pgsql:dbname=' . $params->getDbName() . ';host=' . $params->getHost() . ';port=' . $params->getPort(); 60 | $conn = new PDO( 61 | $dsn, 62 | $params->getUser(), 63 | $params->getPassword(), 64 | [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] 65 | ); 66 | 67 | $keeper = new Keeper($conn, $params); 68 | $runner = new Runner($keeper); 69 | 70 | $conn->beginTransaction(); 71 | 72 | try { 73 | $result = $runner->run($command, $path); 74 | $message .= 'Success: ' . $result; 75 | $conn->commit(); 76 | } catch (\Exception $exception) { 77 | $conn->rollBack(); 78 | throw $exception; 79 | } 80 | 81 | return new Result($message, 0); 82 | } catch (KeeperException $e) { 83 | $message .= 'Failure: ' . $e->getMessage(); 84 | return new Result($message, 1); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Core/SchemaStructure.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Core; 9 | 10 | /** 11 | * @internal 12 | */ 13 | class SchemaStructure 14 | { 15 | /** 16 | * @var string 17 | */ 18 | private $schemaName; 19 | 20 | /** 21 | * @var array 22 | */ 23 | private $tables = []; 24 | 25 | /** 26 | * @var array 27 | */ 28 | private $views = []; 29 | 30 | /** 31 | * @var array 32 | */ 33 | private $materializedViews = []; 34 | 35 | /** 36 | * @var array 37 | */ 38 | private $types = []; 39 | 40 | /** 41 | * @var array 42 | */ 43 | private $functions = []; 44 | 45 | /** 46 | * @var array 47 | */ 48 | private $triggers = []; 49 | 50 | /** 51 | * @var array 52 | */ 53 | private $sequences = []; 54 | 55 | 56 | public function __construct(string $schemaName) 57 | { 58 | $this->schemaName = $schemaName; 59 | } 60 | 61 | public function getSchemaName(): string 62 | { 63 | return $this->schemaName; 64 | } 65 | 66 | public function getTables(): array 67 | { 68 | return $this->tables; 69 | } 70 | 71 | public function setTables(array $tables) 72 | { 73 | $this->tables = $tables; 74 | } 75 | 76 | public function getViews(): array 77 | { 78 | return $this->views; 79 | } 80 | 81 | public function setViews(array $views) 82 | { 83 | $this->views = $views; 84 | } 85 | 86 | public function getTypes(): array 87 | { 88 | return $this->types; 89 | } 90 | 91 | public function getMaterializedViews(): array 92 | { 93 | return $this->materializedViews; 94 | } 95 | 96 | public function setMaterializedViews(array $materializedViews) 97 | { 98 | $this->materializedViews = $materializedViews; 99 | } 100 | 101 | public function setTypes(array $types) 102 | { 103 | $this->types = $types; 104 | } 105 | 106 | public function getFunctions(): array 107 | { 108 | return $this->functions; 109 | } 110 | 111 | public function setFunctions(array $functions) 112 | { 113 | $this->functions = $functions; 114 | } 115 | 116 | public function getTriggers(): array 117 | { 118 | return $this->triggers; 119 | } 120 | 121 | public function setTriggers(array $triggers) 122 | { 123 | $this->triggers = $triggers; 124 | } 125 | 126 | public function getSequences(): array 127 | { 128 | return $this->sequences; 129 | } 130 | 131 | public function setSequences(array $sequences) 132 | { 133 | $this->sequences = $sequences; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Provider/PostgreSQL/PSQLClient.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Provider\PostgreSQL; 9 | 10 | use SchemaKeeper\Exception\KeeperException; 11 | 12 | /** 13 | * @internal 14 | */ 15 | class PSQLClient 16 | { 17 | /** 18 | * @var string 19 | */ 20 | private $executable; 21 | 22 | /** 23 | * @var string 24 | */ 25 | protected $dbName; 26 | 27 | /** 28 | * @var string 29 | */ 30 | protected $host; 31 | 32 | /** 33 | * @var int 34 | */ 35 | protected $port; 36 | 37 | /** 38 | * @var string 39 | */ 40 | protected $user; 41 | 42 | /** 43 | * @var string 44 | */ 45 | protected $password; 46 | 47 | public function __construct(string $executable, string $dbName, string $host, int $port, string $user, string $password) 48 | { 49 | $this->executable = $executable; 50 | $this->dbName = $dbName; 51 | $this->host = $host; 52 | $this->port = $port; 53 | $this->user = $user; 54 | $this->password = $password; 55 | } 56 | 57 | public function run(string $command): ?string 58 | { 59 | $this->putPassword(); 60 | 61 | $req = "echo " . escapeshellarg($command) . " | ".$this->generateScript(); 62 | 63 | return shell_exec($req); 64 | } 65 | 66 | /** 67 | * @param array $commands 68 | * @return array 69 | * @throws KeeperException 70 | */ 71 | public function runMultiple(array $commands): array 72 | { 73 | $this->putPassword(); 74 | 75 | if (!$commands) { 76 | return []; 77 | } 78 | 79 | $results = []; 80 | 81 | if (count($commands) > 500) { 82 | $parts = array_chunk($commands, 500, true); 83 | 84 | foreach ($parts as $part) { 85 | $results = array_merge($results, $this->runMultiple($part)); 86 | } 87 | 88 | return $results; 89 | } 90 | 91 | $commandsString = ''; 92 | $separator = '##|$$1$$$$#$$1$$$|##'; 93 | 94 | 95 | foreach ($commands as $cmd) { 96 | $commandsString .= ' -c ' . escapeshellarg($cmd) . ' -c ' . escapeshellarg("\qecho -n '" . $separator . "'"); 97 | } 98 | 99 | $req = $this->generateScript() . $commandsString; 100 | 101 | $rawOutput = (string) shell_exec($req); 102 | 103 | $outputs = explode($separator, $rawOutput); 104 | 105 | $i = 0; 106 | foreach ($commands as $table => $cmd) { 107 | $results[$table] = $outputs[$i]; 108 | $i++; 109 | } 110 | 111 | return $results; 112 | } 113 | 114 | private function generateScript(): string 115 | { 116 | return $this->executable . ' -U' . $this->user . ' -h' . $this->host . ' -p' . $this->port . ' -d' . $this->dbName; 117 | } 118 | 119 | private function putPassword(): void 120 | { 121 | putenv("PGPASSWORD=" . $this->password); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Core/ArrayConverterTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Core; 9 | 10 | use SchemaKeeper\Core\ArrayConverter; 11 | use SchemaKeeper\Core\Dump; 12 | use SchemaKeeper\Core\SchemaStructure; 13 | use SchemaKeeper\Tests\SchemaTestCase; 14 | 15 | class ArrayConverterTest extends SchemaTestCase 16 | { 17 | /** 18 | * @var ArrayConverter 19 | */ 20 | private $target; 21 | 22 | public function setUp() 23 | { 24 | parent::setUp(); 25 | 26 | $this->target = new ArrayConverter(); 27 | } 28 | 29 | public function testOk() 30 | { 31 | $extensions = ['ext1', 'ext2']; 32 | $tables = ['table' => 'table_content']; 33 | $views = ['view' => 'view_content']; 34 | $materializedViews = ['m_view' => 'm_view_content']; 35 | $types = ['type' => 'type_content']; 36 | $functions = ['function' => 'function_content']; 37 | $triggers = ['trigger' => 'trigger_content']; 38 | $sequences = ['sequence' => 'sequence_content']; 39 | 40 | $structure = new SchemaStructure('schema'); 41 | $dump = new Dump([$structure], $extensions); 42 | $structure->setTables($tables); 43 | $structure->setViews($views); 44 | $structure->setMaterializedViews($materializedViews); 45 | $structure->setTypes($types); 46 | $structure->setFunctions($functions); 47 | $structure->setTriggers($triggers); 48 | $structure->setSequences($sequences); 49 | 50 | $expected = [ 51 | 'schemas' => [ 52 | 'schema', 53 | ], 54 | 'extensions' => [ 55 | 'ext1', 56 | 'ext2', 57 | ], 58 | 'tables' => [ 59 | 'schema.table' => 'table_content', 60 | ], 61 | 'views' => [ 62 | 'schema.view' => 'view_content', 63 | ], 64 | 'materialized_views' => [ 65 | 'schema.m_view' => 'm_view_content', 66 | ], 67 | 'types' => [ 68 | 'schema.type' => 'type_content', 69 | ], 70 | 'functions' => [ 71 | 'schema.function' => 'function_content', 72 | ], 73 | 'triggers' => [ 74 | 'schema.trigger' => 'trigger_content', 75 | ], 76 | 'sequences' => [ 77 | 'schema.sequence' => 'sequence_content', 78 | ], 79 | ]; 80 | 81 | $actual = $this->target->dump2Array($dump); 82 | 83 | self::assertEquals($expected, $actual); 84 | } 85 | 86 | function testEmptyDump() 87 | { 88 | $dump = new Dump([], []); 89 | 90 | $actual = $this->target->dump2Array($dump); 91 | 92 | $expected = [ 93 | 'tables' => [], 94 | 'views' => [], 95 | 'materialized_views' => [], 96 | 'types' => [], 97 | 'functions' => [], 98 | 'triggers' => [], 99 | 'sequences' => [], 100 | 'schemas' => [], 101 | 'extensions' => [], 102 | ]; 103 | 104 | self::assertSame($expected, $actual); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Filesystem/DumpReaderTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Filesystem; 9 | 10 | use Mockery\MockInterface; 11 | use SchemaKeeper\Filesystem\DumpReader; 12 | use SchemaKeeper\Filesystem\FilesystemHelper; 13 | use SchemaKeeper\Filesystem\SectionReader; 14 | use SchemaKeeper\Tests\SchemaTestCase; 15 | 16 | class DumpReaderTest extends SchemaTestCase 17 | { 18 | /** 19 | * @var DumpReader 20 | */ 21 | private $target; 22 | 23 | /** 24 | * @var SectionReader|MockInterface 25 | */ 26 | private $sectionReader; 27 | 28 | /** 29 | * @var FilesystemHelper|MockInterface 30 | */ 31 | private $helper; 32 | 33 | public function setUp() 34 | { 35 | parent::setUp(); 36 | 37 | $this->helper = \Mockery::mock(FilesystemHelper::class); 38 | $this->sectionReader = \Mockery::mock(SectionReader::class); 39 | 40 | $this->target = new DumpReader($this->sectionReader, $this->helper); 41 | } 42 | 43 | public function testOk() 44 | { 45 | $this->helper->shouldReceive('glob')->with('/etc/structure/*')->andReturn(['/etc/schema'])->once(); 46 | 47 | $extensions = ['ext1', 'ext2']; 48 | $tables = ['table' => 'table_content']; 49 | $views = ['view' => 'view_content']; 50 | $materializedViews = ['m_view' => 'm_view_content']; 51 | $types = ['type' => 'type_content']; 52 | $functions = ['function' => 'function_content']; 53 | $triggers = ['trigger' => 'trigger_content']; 54 | $sequences = ['sequence' => 'sequence_content']; 55 | 56 | $this->sectionReader->shouldReceive('readSection')->with('/etc/extensions')->andReturn($extensions)->once(); 57 | $this->sectionReader->shouldReceive('readSection')->with('/etc/schema/tables')->andReturn($tables)->once(); 58 | $this->sectionReader->shouldReceive('readSection')->with('/etc/schema/views')->andReturn($views)->once(); 59 | $this->sectionReader->shouldReceive('readSection')->with('/etc/schema/materialized_views')->andReturn($materializedViews)->once(); 60 | $this->sectionReader->shouldReceive('readSection')->with('/etc/schema/types')->andReturn($types)->once(); 61 | $this->sectionReader->shouldReceive('readSection')->with('/etc/schema/functions')->andReturn($functions)->once(); 62 | $this->sectionReader->shouldReceive('readSection')->with('/etc/schema/triggers')->andReturn($triggers)->once(); 63 | $this->sectionReader->shouldReceive('readSection')->with('/etc/schema/sequences')->andReturn($sequences)->once(); 64 | 65 | $dump = $this->target->read('/etc'); 66 | 67 | self::assertEquals($extensions, $dump->getExtensions()); 68 | self::assertCount(1, $dump->getSchemas()); 69 | 70 | $structure = $dump->getSchemas()[0]; 71 | self::assertEquals($tables, $structure->getTables()); 72 | self::assertEquals($views, $structure->getViews()); 73 | self::assertEquals($materializedViews, $structure->getMaterializedViews()); 74 | self::assertEquals($types, $structure->getTypes()); 75 | self::assertEquals($functions, $structure->getFunctions()); 76 | self::assertEquals($triggers, $structure->getTriggers()); 77 | self::assertEquals($sequences, $structure->getSequences()); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Filesystem/DumpWriterTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Filesystem; 9 | 10 | use Mockery\MockInterface; 11 | use SchemaKeeper\Core\Dump; 12 | use SchemaKeeper\Core\SchemaStructure; 13 | use SchemaKeeper\Filesystem\DumpWriter; 14 | use SchemaKeeper\Filesystem\FilesystemHelper; 15 | use SchemaKeeper\Filesystem\SectionWriter; 16 | use SchemaKeeper\Tests\SchemaTestCase; 17 | 18 | class DumpWriterTest extends SchemaTestCase 19 | { 20 | /** 21 | * @var DumpWriter 22 | */ 23 | private $target; 24 | 25 | /** 26 | * @var SectionWriter|MockInterface 27 | */ 28 | private $sectionWriter; 29 | 30 | /** 31 | * @var FilesystemHelper|MockInterface 32 | */ 33 | private $helper; 34 | 35 | public function setUp() 36 | { 37 | parent::setUp(); 38 | 39 | $this->helper = \Mockery::mock(FilesystemHelper::class); 40 | $this->sectionWriter = \Mockery::mock(SectionWriter::class); 41 | 42 | $this->target = new DumpWriter($this->sectionWriter, $this->helper); 43 | } 44 | 45 | public function testOk() 46 | { 47 | $extensions = ['ext1', 'ext2']; 48 | $tables = ['table' => 'table_content']; 49 | $views = ['view' => 'view_content']; 50 | $materializedViews = ['m_view' => 'm_view_content']; 51 | $types = ['type' => 'type_content']; 52 | $functions = ['function' => 'function_content']; 53 | $triggers = ['trigger' => 'trigger_content']; 54 | $sequences = ['sequence' => 'sequence_content']; 55 | 56 | $this->helper->shouldReceive('rmDirIfExisted')->with('/etc/extensions')->once(); 57 | $this->sectionWriter->shouldReceive('writeSection')->with('/etc/extensions', $extensions)->once(); 58 | $this->helper->shouldReceive('rmDirIfExisted')->with('/etc/structure/schema')->once(); 59 | $this->helper->shouldReceive('mkdir')->with('/etc/structure/schema', 0775, true)->once(); 60 | $this->helper->shouldReceive('filePutContents')->with('/etc/structure/schema/.gitkeep', '')->once(); 61 | $this->sectionWriter->shouldReceive('writeSection')->with('/etc/structure/schema/tables', $tables)->once(); 62 | $this->sectionWriter->shouldReceive('writeSection')->with('/etc/structure/schema/views', $views)->once(); 63 | $this->sectionWriter->shouldReceive('writeSection')->with('/etc/structure/schema/materialized_views', $materializedViews)->once(); 64 | $this->sectionWriter->shouldReceive('writeSection')->with('/etc/structure/schema/types', $types)->once(); 65 | $this->sectionWriter->shouldReceive('writeSection')->with('/etc/structure/schema/functions', $functions)->once(); 66 | $this->sectionWriter->shouldReceive('writeSection')->with('/etc/structure/schema/triggers', $triggers)->once(); 67 | $this->sectionWriter->shouldReceive('writeSection')->with('/etc/structure/schema/sequences', $sequences)->once(); 68 | 69 | $structure = new SchemaStructure('schema'); 70 | $structure->setTables($tables); 71 | $structure->setViews($views); 72 | $structure->setMaterializedViews($materializedViews); 73 | $structure->setTypes($types); 74 | $structure->setFunctions($functions); 75 | $structure->setTriggers($triggers); 76 | $structure->setSequences($sequences); 77 | 78 | $dump = new Dump([$structure], $extensions); 79 | $this->target->write('/etc', $dump); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Core/DumperTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Core; 9 | 10 | use Mockery\MockInterface; 11 | use SchemaKeeper\Core\Dump; 12 | use SchemaKeeper\Core\Dumper; 13 | use SchemaKeeper\Core\SchemaFilter; 14 | use SchemaKeeper\Provider\IProvider; 15 | use SchemaKeeper\Tests\SchemaTestCase; 16 | 17 | class DumperTest extends SchemaTestCase 18 | { 19 | /** 20 | * @var Dumper 21 | */ 22 | private $target; 23 | 24 | /** 25 | * @var IProvider|MockInterface 26 | */ 27 | private $provider; 28 | 29 | /** 30 | * @var SchemaFilter|MockInterface 31 | */ 32 | private $filter; 33 | 34 | public function setUp() 35 | { 36 | parent::setUp(); 37 | 38 | $this->provider = \Mockery::mock(IProvider::class); 39 | $this->filter = \Mockery::mock(SchemaFilter::class); 40 | 41 | $this->target = new Dumper($this->provider, $this->filter); 42 | } 43 | 44 | public function testOk() 45 | { 46 | $schemas = ['schema1', 'schema1']; 47 | $extensions = ['ext1', 'ext2']; 48 | $tables = ['schema1.table1' => 'table_content1']; 49 | $views = ['schema1.view1' => 'view_content1']; 50 | $matViews = ['schema1.mat_view1' => 'mat_view_content1']; 51 | $types = ['schema1.type1' => 'type_content1']; 52 | $functions = ['schema1.function1' => 'function_content1']; 53 | $triggers = ['schema1.trigger1' => 'trigger_content1']; 54 | $sequences = ['schema1.sequence1' => 'sequence_content1']; 55 | 56 | $this->provider->shouldReceive('getSchemas')->andReturn($schemas)->once(); 57 | $this->provider->shouldReceive('getExtensions')->andReturn($extensions)->once(); 58 | $this->provider->shouldReceive('getTables')->andReturn($tables)->once(); 59 | $this->provider->shouldReceive('getViews')->andReturn($views)->once(); 60 | $this->provider->shouldReceive('getMaterializedViews')->andReturn($matViews)->once(); 61 | $this->provider->shouldReceive('getTypes')->andReturn($types)->once(); 62 | $this->provider->shouldReceive('getFunctions')->andReturn($functions)->once(); 63 | $this->provider->shouldReceive('getTriggers')->andReturn($triggers)->once(); 64 | $this->provider->shouldReceive('getSequences')->andReturn($sequences)->once(); 65 | 66 | $this->filter->shouldReceive('filter')->andReturnUsing(function ($schemaName, $items) { 67 | return $items; 68 | }); 69 | 70 | $actualDump = $this->target->dump(); 71 | 72 | self::assertInstanceOf(Dump::class, $actualDump); 73 | self::assertCount(2, $actualDump->getSchemas()); 74 | self::assertEquals($extensions, $actualDump->getExtensions()); 75 | 76 | $schemaStructure1 = $actualDump->getSchemas()[0]; 77 | $schemaStructure2 = $actualDump->getSchemas()[1]; 78 | self::assertEquals($schemaStructure1, $schemaStructure2); 79 | 80 | self::assertEquals($tables, $schemaStructure1->getTables()); 81 | self::assertEquals($views, $schemaStructure1->getViews()); 82 | self::assertEquals($matViews, $schemaStructure1->getMaterializedViews()); 83 | self::assertEquals($types, $schemaStructure1->getTypes()); 84 | self::assertEquals($functions, $schemaStructure1->getFunctions()); 85 | self::assertEquals($triggers, $schemaStructure1->getTriggers()); 86 | self::assertEquals($sequences, $schemaStructure1->getSequences()); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Worker/Deployer.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Worker; 9 | 10 | use SchemaKeeper\Core\ArrayConverter; 11 | use SchemaKeeper\Core\SectionComparator; 12 | use SchemaKeeper\Exception\KeeperException; 13 | use SchemaKeeper\Exception\NotEquals; 14 | use SchemaKeeper\Filesystem\DumpReader; 15 | use SchemaKeeper\Filesystem\FilesystemHelper; 16 | use SchemaKeeper\Filesystem\SectionReader; 17 | use SchemaKeeper\Outside\DeployedFunctions; 18 | use SchemaKeeper\Provider\IProvider; 19 | 20 | /** 21 | * @internal 22 | */ 23 | class Deployer 24 | { 25 | /** 26 | * @var DumpReader 27 | */ 28 | private $reader; 29 | 30 | /** 31 | * @var IProvider 32 | */ 33 | private $provider; 34 | 35 | /** 36 | * @var SectionComparator 37 | */ 38 | private $comparator; 39 | 40 | /** 41 | * @var ArrayConverter 42 | */ 43 | private $converter; 44 | 45 | 46 | public function __construct(IProvider $provider) 47 | { 48 | $helper = new FilesystemHelper(); 49 | $sectionReader = new SectionReader($helper); 50 | $this->reader = new DumpReader($sectionReader, $helper); 51 | $this->converter = new ArrayConverter(); 52 | $this->comparator = new SectionComparator(); 53 | $this->provider = $provider; 54 | } 55 | 56 | public function deploy(string $sourcePath): DeployedFunctions 57 | { 58 | $functions = $this->provider->getFunctions(); 59 | $actualFunctionNames = array_keys($functions); 60 | 61 | $structurePath = $sourcePath; 62 | $expectedDump = $this->reader->read($structurePath); 63 | $expectedFunctions = $this->converter->dump2Array($expectedDump)['functions']; 64 | 65 | $expectedFunctionNames = array_keys($expectedFunctions); 66 | 67 | $functionNamesToCreate = array_diff($expectedFunctionNames, $actualFunctionNames); 68 | $functionNamesToDelete = array_diff($actualFunctionNames, $expectedFunctionNames); 69 | $functionsToChange = array_diff_assoc($expectedFunctions, $functions); 70 | 71 | if (count($functionNamesToDelete) == count($functions) 72 | && count($functionNamesToDelete) > 0 73 | && count($functionsToChange) == 0 74 | && count($functionNamesToCreate) == 0 75 | ) { 76 | throw new KeeperException('Forbidden to remove all functions using SchemaKeeper'); 77 | } 78 | 79 | $lastExecutedName = null; 80 | 81 | try { 82 | foreach ($functionNamesToDelete as $nameToDelete) { 83 | $lastExecutedName = $nameToDelete; 84 | $this->provider->deleteFunction($nameToDelete); 85 | 86 | unset($functionsToChange[$nameToDelete]); 87 | } 88 | 89 | foreach ($functionNamesToCreate as $nameToCreate) { 90 | $lastExecutedName = $nameToCreate; 91 | $functionContent = $expectedFunctions[$nameToCreate]; 92 | $this->provider->createFunction($functionContent); 93 | 94 | unset($functionsToChange[$nameToCreate]); 95 | } 96 | 97 | foreach ($functionsToChange as $nameToChange => $contentToChange) { 98 | $lastExecutedName = $nameToChange; 99 | $this->provider->changeFunction($nameToChange, $contentToChange); 100 | } 101 | } catch (\PDOException $e) { 102 | $keeperException = new KeeperException("TARGET: $lastExecutedName; " . $e->getMessage(), 0, $e); 103 | 104 | throw $keeperException; 105 | } 106 | 107 | $actualFunctions = $this->provider->getFunctions(); 108 | $comparisonResult = $this->comparator->compareSection('functions', $expectedFunctions, $actualFunctions); 109 | 110 | if ($comparisonResult['actual'] !== $comparisonResult['expected']) { 111 | $message = 'Some functions have diff between their definitions before deploy and their definitions after deploy:'; 112 | 113 | throw new NotEquals($message, $comparisonResult['expected'], $comparisonResult['actual']); 114 | } 115 | 116 | return new DeployedFunctions(array_keys($functionsToChange), $functionNamesToCreate, $functionNamesToDelete); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /.dev/docker/images/postgres/config/pg_hba.conf: -------------------------------------------------------------------------------- 1 | # PostgreSQL Client Authentication Configuration File 2 | # =================================================== 3 | # 4 | # Refer to the "Client Authentication" section in the PostgreSQL 5 | # documentation for a complete description of this file. A short 6 | # synopsis follows. 7 | # 8 | # This file controls: which hosts are allowed to connect, how clients 9 | # are authenticated, which PostgreSQL user names they can use, which 10 | # databases they can access. Records take one of these forms: 11 | # 12 | # local DATABASE USER METHOD [OPTIONS] 13 | # host DATABASE USER ADDRESS METHOD [OPTIONS] 14 | # hostssl DATABASE USER ADDRESS METHOD [OPTIONS] 15 | # hostnossl DATABASE USER ADDRESS METHOD [OPTIONS] 16 | # 17 | # (The uppercase items must be replaced by actual values.) 18 | # 19 | # The first field is the connection type: "local" is a Unix-domain 20 | # socket, "host" is either a plain or SSL-encrypted TCP/IP socket, 21 | # "hostssl" is an SSL-encrypted TCP/IP socket, and "hostnossl" is a 22 | # plain TCP/IP socket. 23 | # 24 | # DATABASE can be "all", "sameuser", "samerole", "replication", a 25 | # database name, or a comma-separated list thereof. The "all" 26 | # keyword does not match "replication". Access to replication 27 | # must be enabled in a separate record (see example below). 28 | # 29 | # USER can be "all", a user name, a group name prefixed with "+", or a 30 | # comma-separated list thereof. In both the DATABASE and USER fields 31 | # you can also write a file name prefixed with "@" to include names 32 | # from a separate file. 33 | # 34 | # ADDRESS specifies the set of hosts the record matches. It can be a 35 | # host name, or it is made up of an IP address and a CIDR mask that is 36 | # an integer (between 0 and 32 (IPv4) or 128 (IPv6) inclusive) that 37 | # specifies the number of significant bits in the mask. A host name 38 | # that starts with a dot (.) matches a suffix of the actual host name. 39 | # Alternatively, you can write an IP address and netmask in separate 40 | # columns to specify the set of hosts. Instead of a CIDR-address, you 41 | # can write "samehost" to match any of the server's own IP addresses, 42 | # or "samenet" to match any address in any subnet that the server is 43 | # directly connected to. 44 | # 45 | # METHOD can be "trust", "reject", "md5", "password", "gss", "sspi", 46 | # "ident", "peer", "pam", "ldap", "radius" or "cert". Note that 47 | # "password" sends passwords in clear text; "md5" is preferred since 48 | # it sends encrypted passwords. 49 | # 50 | # OPTIONS are a set of options for the authentication in the format 51 | # NAME=VALUE. The available options depend on the different 52 | # authentication methods -- refer to the "Client Authentication" 53 | # section in the documentation for a list of which options are 54 | # available for which authentication methods. 55 | # 56 | # Database and user names containing spaces, commas, quotes and other 57 | # special characters must be quoted. Quoting one of the keywords 58 | # "all", "sameuser", "samerole" or "replication" makes the name lose 59 | # its special character, and just match a database or username with 60 | # that name. 61 | # 62 | # This file is read on server startup and when the postmaster receives 63 | # a SIGHUP signal. If you edit the file on a running system, you have 64 | # to SIGHUP the postmaster for the changes to take effect. You can 65 | # use "pg_ctl reload" to do that. 66 | 67 | # Put your actual configuration here 68 | # ---------------------------------- 69 | # 70 | # If you want to allow non-local connections, you need to add more 71 | # "host" records. In that case you will also need to make PostgreSQL 72 | # listen on a non-local interface via the listen_addresses 73 | # configuration parameter, or via the -i or -h command line switches. 74 | 75 | # CAUTION: Configuring the system for local "trust" authentication 76 | # allows any local user to connect as any PostgreSQL user, including 77 | # the database superuser. If you do not trust all your local users, 78 | # use another authentication method. 79 | 80 | 81 | # TYPE DATABASE USER ADDRESS METHOD 82 | 83 | # "local" is for Unix domain socket connections only 84 | local all all trust 85 | # IPv4 local connections: 86 | host all all 127.0.0.1/32 trust 87 | # IPv6 local connections: 88 | host all all ::1/128 trust 89 | # Allow replication connections from localhost, by a user with the 90 | # replication privilege. 91 | #local replication postgres trust 92 | #host replication postgres 127.0.0.1/32 trust 93 | #host replication postgres ::1/128 trust 94 | 95 | host all all all md5 96 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Worker/DeployerTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Worker; 9 | 10 | use PDO; 11 | use SchemaKeeper\Provider\ProviderFactory; 12 | use SchemaKeeper\Tests\SchemaTestCase; 13 | use SchemaKeeper\Worker\Deployer; 14 | use SchemaKeeper\Worker\Saver; 15 | 16 | class DeployerTest extends SchemaTestCase 17 | { 18 | /** 19 | * @var Deployer 20 | */ 21 | private $target; 22 | 23 | /** 24 | * @var Saver 25 | */ 26 | private $saver; 27 | 28 | /** 29 | * @var PDO 30 | */ 31 | private $conn; 32 | 33 | public function setUp() 34 | { 35 | parent::setUp(); 36 | 37 | $this->conn = $this->getConn(); 38 | $params = $this->getDbParams(); 39 | $providerFactory = new ProviderFactory(); 40 | $provider = $providerFactory->createProvider($this->conn, $params); 41 | 42 | $this->target = new Deployer($provider); 43 | $this->saver = new Saver($provider); 44 | 45 | exec('rm -rf /tmp/schema_keeper'); 46 | $this->conn->beginTransaction(); 47 | } 48 | 49 | public function tearDown() 50 | { 51 | parent::tearDown(); 52 | 53 | if ($this->conn->inTransaction()) { 54 | $this->conn->rollBack(); 55 | } 56 | } 57 | 58 | public function testOk() 59 | { 60 | $this->saver->save('/tmp/schema_keeper'); 61 | $result = $this->target->deploy('/tmp/schema_keeper'); 62 | 63 | self::assertEquals([], $result->getChanged()); 64 | self::assertEquals([], $result->getCreated()); 65 | self::assertEquals([], $result->getDeleted()); 66 | } 67 | 68 | public function testCreateFunction() 69 | { 70 | $this->saver->save('/tmp/schema_keeper'); 71 | 72 | $function = 'CREATE OR REPLACE FUNCTION public.func_test() 73 | RETURNS void 74 | LANGUAGE plpgsql 75 | AS $function$ 76 | DECLARE 77 | BEGIN 78 | RAISE NOTICE \'test\'; 79 | END; 80 | $function$ 81 | '; 82 | 83 | file_put_contents('/tmp/schema_keeper/structure/public/functions/func_test().sql', $function); 84 | 85 | $result = $this->target->deploy('/tmp/schema_keeper'); 86 | 87 | $created = [ 88 | 'public.func_test()' 89 | ]; 90 | 91 | self::assertEquals([], $result->getChanged()); 92 | self::assertEquals($created, $result->getCreated()); 93 | self::assertEquals([], $result->getDeleted()); 94 | } 95 | 96 | public function testChangeFunction() 97 | { 98 | $this->saver->save('/tmp/schema_keeper'); 99 | 100 | $function = 'CREATE OR REPLACE FUNCTION public.trig_test() 101 | RETURNS trigger 102 | LANGUAGE plpgsql 103 | AS $function$ 104 | DECLARE 105 | BEGIN 106 | RAISE NOTICE \'test\'; 107 | RETURN NEW; 108 | END; 109 | $function$ 110 | '; 111 | 112 | file_put_contents('/tmp/schema_keeper/structure/public/functions/trig_test().sql', $function); 113 | 114 | $result = $this->target->deploy('/tmp/schema_keeper'); 115 | 116 | $changed = [ 117 | 'public.trig_test()' 118 | ]; 119 | 120 | self::assertEquals($changed, $result->getChanged()); 121 | self::assertEquals([], $result->getCreated()); 122 | self::assertEquals([], $result->getDeleted()); 123 | } 124 | 125 | /** 126 | * @expectedException \SchemaKeeper\Exception\KeeperException 127 | * @expectedExceptionMessage Some functions have diff between their definitions before deploy and their definitions after deploy: 128 | */ 129 | public function testChangeFunctionWithDiff() 130 | { 131 | $this->saver->save('/tmp/schema_keeper'); 132 | 133 | $function = 'cREATE OR REPLACE FUNCTION public.trig_test() 134 | RETURNS trigger 135 | LANGUAGE plpgsql 136 | AS $function$ 137 | DECLARE 138 | BEGIN 139 | RETURN NEW; 140 | END; 141 | $function$ 142 | '; 143 | 144 | file_put_contents('/tmp/schema_keeper/structure/public/functions/trig_test().sql', $function); 145 | 146 | $this->target->deploy('/tmp/schema_keeper'); 147 | } 148 | 149 | public function testDeleteFunction() 150 | { 151 | $this->saver->save('/tmp/schema_keeper'); 152 | 153 | $function = 'CREATE OR REPLACE FUNCTION public.func_test() 154 | RETURNS void 155 | LANGUAGE plpgsql 156 | AS $function$ 157 | DECLARE 158 | BEGIN 159 | RAISE NOTICE \'test\'; 160 | END; 161 | $function$ 162 | '; 163 | 164 | $this->conn->exec($function); 165 | 166 | $result = $this->target->deploy('/tmp/schema_keeper'); 167 | 168 | $deleted = [ 169 | 'public.func_test()' 170 | ]; 171 | 172 | self::assertEquals([], $result->getChanged()); 173 | self::assertEquals([], $result->getCreated()); 174 | self::assertEquals($deleted, $result->getDeleted()); 175 | } 176 | 177 | public function testChangeFunctionReturnType() 178 | { 179 | $function = 'CREATE OR REPLACE FUNCTION public.func_test() 180 | RETURNS void 181 | LANGUAGE plpgsql 182 | AS $function$ 183 | DECLARE 184 | BEGIN 185 | RAISE NOTICE \'test\'; 186 | END; 187 | $function$ 188 | '; 189 | 190 | $this->conn->exec($function); 191 | 192 | $this->saver->save('/tmp/schema_keeper'); 193 | 194 | $changedFunction = 'CREATE OR REPLACE FUNCTION public.func_test() 195 | RETURNS boolean 196 | LANGUAGE plpgsql 197 | AS $function$ 198 | DECLARE 199 | BEGIN 200 | RETURN TRUE; 201 | END; 202 | $function$ 203 | '; 204 | 205 | file_put_contents('/tmp/schema_keeper/structure/public/functions/func_test().sql', $changedFunction); 206 | 207 | $result = $this->target->deploy('/tmp/schema_keeper'); 208 | 209 | $changed = 210 | [ 211 | 'public.func_test()' 212 | ]; 213 | 214 | self::assertEquals($changed, $result->getChanged()); 215 | self::assertEquals([], $result->getCreated()); 216 | self::assertEquals([], $result->getDeleted()); 217 | } 218 | 219 | /** 220 | * @expectedException \SchemaKeeper\Exception\KeeperException 221 | * @expectedExceptionMessage TARGET: public.trig_test() 222 | */ 223 | public function testError() 224 | { 225 | $this->saver->save('/tmp/schema_keeper'); 226 | 227 | $function = 'fd'; 228 | 229 | file_put_contents('/tmp/schema_keeper/structure/public/functions/trig_test().sql', $function); 230 | 231 | $this->target->deploy('/tmp/schema_keeper'); 232 | } 233 | 234 | /** 235 | * @expectedException \SchemaKeeper\Exception\KeeperException 236 | * @expectedExceptionMessage Forbidden to remove all functions using SchemaKeeper 237 | */ 238 | public function testEmptyDump() 239 | { 240 | $this->saver->save('/tmp/schema_keeper'); 241 | 242 | unlink('/tmp/schema_keeper/structure/public/functions/trig_test().sql'); 243 | 244 | $this->target->deploy('/tmp/schema_keeper'); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /tests/SchemaKeeper/Worker/SaverTest.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Tests\Worker; 9 | 10 | use SchemaKeeper\Provider\ProviderFactory; 11 | use SchemaKeeper\Tests\SchemaTestCase; 12 | use SchemaKeeper\Worker\Saver; 13 | 14 | class SaverTest extends SchemaTestCase 15 | { 16 | /** 17 | * @var Saver 18 | */ 19 | private $target; 20 | 21 | public function setUp() 22 | { 23 | parent::setUp(); 24 | 25 | $conn = $this->getConn(); 26 | $params = $this->getDbParams(); 27 | $providerFactory = new ProviderFactory(); 28 | $provider = $providerFactory->createProvider($conn, $params); 29 | 30 | $this->target = new Saver($provider); 31 | 32 | exec('rm -rf /tmp/schema_keeper'); 33 | } 34 | 35 | public function testOk() 36 | { 37 | $this->target->save('/tmp/schema_keeper'); 38 | 39 | self::assertEquals([ 40 | '/tmp/schema_keeper/extensions', 41 | '/tmp/schema_keeper/structure', 42 | ], glob('/tmp/schema_keeper/*')); 43 | 44 | self::assertEquals([ 45 | '/tmp/schema_keeper/extensions/plpgsql.txt', 46 | ], glob('/tmp/schema_keeper/extensions/*')); 47 | 48 | self::assertEquals('pg_catalog', file_get_contents('/tmp/schema_keeper/extensions/plpgsql.txt')); 49 | 50 | self::assertEquals([ 51 | '/tmp/schema_keeper/structure/public', 52 | '/tmp/schema_keeper/structure/test_schema', 53 | ], glob('/tmp/schema_keeper/structure/*')); 54 | 55 | self::assertEquals([ 56 | '/tmp/schema_keeper/structure/public/functions', 57 | '/tmp/schema_keeper/structure/public/materialized_views', 58 | '/tmp/schema_keeper/structure/public/sequences', 59 | '/tmp/schema_keeper/structure/public/tables', 60 | '/tmp/schema_keeper/structure/public/triggers', 61 | '/tmp/schema_keeper/structure/public/types', 62 | '/tmp/schema_keeper/structure/public/views', 63 | ], glob('/tmp/schema_keeper/structure/public/*')); 64 | 65 | self::assertEquals([ 66 | '/tmp/schema_keeper/structure/public/tables/test_table.txt', 67 | ], glob('/tmp/schema_keeper/structure/public/tables/*')); 68 | 69 | $expectedTable = ' Table "public.test_table" 70 | Column | Type | Modifiers 71 | --------+--------+--------------------------------------------------------- 72 | id | bigint | not null default nextval(\'test_table_id_seq\'::regclass) 73 | values | text | 74 | Indexes: 75 | "test_table_pkey" PRIMARY KEY, btree (id) 76 | Triggers: 77 | test_trigger BEFORE UPDATE ON test_table FOR EACH ROW EXECUTE PROCEDURE trig_test() 78 | 79 | '; 80 | 81 | $actualTable = file_get_contents('/tmp/schema_keeper/structure/public/tables/test_table.txt'); 82 | self::assertEquals($expectedTable, $actualTable); 83 | 84 | self::assertEquals([ 85 | '/tmp/schema_keeper/structure/public/sequences/test_table_id_seq.txt', 86 | ], glob('/tmp/schema_keeper/structure/public/sequences/*')); 87 | 88 | $expectedSequence = '{ 89 | "seq_path": "public.test_table_id_seq", 90 | "data_type": "bigint", 91 | "start_value": "1", 92 | "minimum_value": "1", 93 | "maximum_value": "9223372036854775807", 94 | "increment": "1", 95 | "cycle_option": "NO" 96 | }'; 97 | 98 | $actualSequence = file_get_contents('/tmp/schema_keeper/structure/public/sequences/test_table_id_seq.txt'); 99 | self::assertEquals($expectedSequence, $actualSequence); 100 | 101 | self::assertEquals([ 102 | '/tmp/schema_keeper/structure/public/views/test_view.txt', 103 | ], glob('/tmp/schema_keeper/structure/public/views/*')); 104 | 105 | $expectedView = ' View "public.test_view" 106 | Column | Type | Modifiers | Storage | Description 107 | --------+--------+-----------+----------+------------- 108 | id | bigint | | plain | 109 | values | text | | extended | 110 | View definition: 111 | SELECT test_table.id, 112 | test_table."values" 113 | FROM test_table; 114 | 115 | '; 116 | $actualView = file_get_contents('/tmp/schema_keeper/structure/public/views/test_view.txt'); 117 | 118 | self::assertEquals($expectedView, $actualView); 119 | 120 | self::assertEquals([ 121 | '/tmp/schema_keeper/structure/public/materialized_views/test_mat_view.txt', 122 | ], glob('/tmp/schema_keeper/structure/public/materialized_views/*')); 123 | 124 | $actualMaterializedView = file_get_contents('/tmp/schema_keeper/structure/public/materialized_views/test_mat_view.txt'); 125 | $expectedMaterializedView = ' Materialized view "public.test_mat_view" 126 | Column | Type | Modifiers | Storage | Stats target | Description 127 | --------+--------+-----------+----------+--------------+------------- 128 | id | bigint | | plain | | 129 | values | text | | extended | | 130 | View definition: 131 | SELECT test_table.id, 132 | test_table."values" 133 | FROM test_table; 134 | 135 | '; 136 | self::assertEquals($expectedMaterializedView, $actualMaterializedView); 137 | 138 | self::assertEquals([ 139 | '/tmp/schema_keeper/structure/public/functions/trig_test().sql', 140 | ], glob('/tmp/schema_keeper/structure/public/functions/*')); 141 | 142 | $actualFunction = file_get_contents('/tmp/schema_keeper/structure/public/functions/trig_test().sql'); 143 | $expectedFunction = 'CREATE OR REPLACE FUNCTION public.trig_test() 144 | RETURNS trigger 145 | LANGUAGE plpgsql 146 | AS $function$ 147 | DECLARE 148 | BEGIN 149 | RETURN NEW; 150 | END; 151 | $function$ 152 | '; 153 | 154 | self::assertEquals($expectedFunction, $actualFunction); 155 | 156 | self::assertEquals([ 157 | '/tmp/schema_keeper/structure/public/triggers/test_table.test_trigger.sql', 158 | ], glob('/tmp/schema_keeper/structure/public/triggers/*')); 159 | 160 | $actualTrigger = file_get_contents('/tmp/schema_keeper/structure/public/triggers/test_table.test_trigger.sql'); 161 | $expectedTrigger = 'CREATE TRIGGER test_trigger BEFORE UPDATE ON test_table FOR EACH ROW EXECUTE PROCEDURE trig_test()'; 162 | 163 | self::assertEquals($expectedTrigger, $actualTrigger); 164 | 165 | self::assertEquals([ 166 | '/tmp/schema_keeper/structure/public/types/test_enum_type.txt', 167 | '/tmp/schema_keeper/structure/public/types/test_type.txt', 168 | ], glob('/tmp/schema_keeper/structure/public/types/*')); 169 | 170 | $actualType = file_get_contents('/tmp/schema_keeper/structure/public/types/test_type.txt'); 171 | $expectedType = ' Composite type "public.test_type" 172 | Column | Type | Modifiers 173 | --------+-------------------+----------- 174 | id | bigint | 175 | values | character varying | 176 | 177 | '; 178 | 179 | self::assertEquals($expectedType, $actualType); 180 | 181 | $actualEnumType = file_get_contents('/tmp/schema_keeper/structure/public/types/test_enum_type.txt'); 182 | $expectedEnumType = '{enum1,enum2}'; 183 | 184 | self::assertEquals($expectedEnumType, $actualEnumType); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/SchemaKeeper/Provider/PostgreSQL/PSQLProvider.php: -------------------------------------------------------------------------------- 1 | 5 | * For the full copyright and license information, please view the LICENSE file that was distributed with this source code. 6 | */ 7 | 8 | namespace SchemaKeeper\Provider\PostgreSQL; 9 | 10 | use Exception; 11 | use PDO; 12 | use SchemaKeeper\Exception\KeeperException; 13 | use SchemaKeeper\Provider\IProvider; 14 | 15 | /** 16 | * @internal 17 | */ 18 | class PSQLProvider implements IProvider 19 | { 20 | /** 21 | * @var PDO 22 | */ 23 | protected $conn; 24 | 25 | /** 26 | * @var PSQLClient 27 | */ 28 | protected $psqlClient; 29 | 30 | /** 31 | * @var string[] 32 | */ 33 | protected $skippedSchemaNames; 34 | 35 | /** 36 | * @var string[] 37 | */ 38 | protected $skippedExtensionNames; 39 | 40 | /** 41 | * @var SavepointHelper 42 | */ 43 | protected $savePointHelper; 44 | 45 | 46 | public function __construct( 47 | PDO $conn, 48 | PSQLClient $psqlClient, 49 | array $skippedSchemaNames, 50 | array $skippedExtensionNames 51 | ) { 52 | $this->conn = $conn; 53 | $this->psqlClient = $psqlClient; 54 | $this->skippedSchemaNames = $skippedSchemaNames; 55 | $this->skippedExtensionNames = $skippedExtensionNames; 56 | 57 | $this->savePointHelper = new SavepointHelper($conn); 58 | } 59 | 60 | public function getTables(): array 61 | { 62 | $sql = sprintf(" 63 | SELECT concat_ws('.', schemaname, tablename) AS table_path 64 | FROM pg_catalog.pg_tables 65 | WHERE %s 66 | ORDER BY table_path 67 | ", $this->expandLike('schemaname', $this->skippedSchemaNames)); 68 | 69 | $stmt = $this->query($sql); 70 | 71 | $commands = []; 72 | while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 73 | $table = $this->prepareName($row['table_path']); 74 | $cmd = '\d ' . $table; 75 | $commands[$table] = $cmd; 76 | } 77 | 78 | $actualTables = $this->psqlClient->runMultiple($commands); 79 | 80 | return $actualTables; 81 | } 82 | 83 | public function getViews(): array 84 | { 85 | $sql = sprintf(" 86 | SELECT (schemaname || '.' || viewname) AS view_path 87 | FROM pg_catalog.pg_views 88 | WHERE %s 89 | ORDER BY view_path 90 | ", $this->expandLike('schemaname', $this->skippedSchemaNames)); 91 | 92 | $stmt = $this->query($sql); 93 | 94 | $commands = []; 95 | while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 96 | $view = $this->prepareName($row['view_path']); 97 | $commands[$view] = '\d+ ' . $view; 98 | } 99 | 100 | $actualViews = $this->psqlClient->runMultiple($commands); 101 | 102 | return $actualViews; 103 | } 104 | 105 | public function getMaterializedViews(): array 106 | { 107 | $sql = sprintf(" 108 | SELECT (schemaname || '.' || matviewname) AS view_path 109 | FROM pg_catalog.pg_matviews 110 | WHERE %s 111 | ORDER BY view_path 112 | ", $this->expandLike('schemaname', $this->skippedSchemaNames)); 113 | 114 | $stmt = $this->query($sql); 115 | 116 | $commands = []; 117 | while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 118 | $view = $this->prepareName($row['view_path']); 119 | $commands[$view] = '\d+ ' . $view; 120 | } 121 | 122 | $actualViews = $this->psqlClient->runMultiple($commands); 123 | 124 | return $actualViews; 125 | } 126 | 127 | public function getTriggers(): array 128 | { 129 | $actualTriggers = []; 130 | 131 | $sql = " 132 | SELECT 133 | concat_ws('.', n.nspname, c.relname, t.tgname) as tg_path, 134 | pg_catalog.pg_get_triggerdef(t.OID, true) AS tg_def 135 | FROM pg_catalog.pg_trigger t 136 | INNER JOIN pg_catalog.pg_class c 137 | ON c.OID = t.tgrelid 138 | INNER JOIN pg_catalog.pg_namespace n 139 | ON n.OID = c.relnamespace 140 | WHERE t.tgisinternal = FALSE 141 | ORDER BY tg_path 142 | "; 143 | 144 | $stmt = $this->query($sql); 145 | 146 | while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 147 | $trigger = $this->prepareName($row['tg_path']); 148 | $definition = $row['tg_def']; 149 | 150 | $actualTriggers[$trigger] = $definition; 151 | } 152 | 153 | return $actualTriggers; 154 | } 155 | 156 | public function getFunctions(): array 157 | { 158 | $actualFunctions = []; 159 | 160 | $sql = sprintf(" 161 | SELECT 162 | concat_ws('.', n.nspname, p.proname) AS pro_path, 163 | ARRAY( 164 | SELECT concat_ws('.', n1.nspname, pgt.typname) as typname 165 | FROM (SELECT 166 | u AS type_oid, 167 | row_number() 168 | OVER () AS row_number 169 | FROM unnest(p.proargtypes) u) types 170 | LEFT JOIN pg_catalog.pg_type pgt ON 171 | pgt.OID = types.type_oid 172 | LEFT JOIN pg_catalog.pg_namespace n1 173 | ON n1.OID = pgt.typnamespace 174 | AND n1.nspname NOT IN ('pg_catalog') 175 | ORDER BY types.row_number 176 | ) AS arg_types, 177 | pg_catalog.pg_get_functiondef(p.oid) AS pro_def 178 | FROM pg_catalog.pg_namespace n 179 | JOIN pg_catalog.pg_proc p 180 | ON p.pronamespace = n.oid 181 | WHERE %s 182 | ORDER BY pro_path 183 | ", $this->expandLike('n.nspname', $this->skippedSchemaNames)); 184 | 185 | $stmt = $this->query($sql); 186 | 187 | while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 188 | $argTypes = $row['arg_types']; 189 | $function = $this->prepareName($row['pro_path'] . $argTypes); 190 | $definition = $row['pro_def']; 191 | $actualFunctions[$function] = $definition; 192 | } 193 | 194 | return $actualFunctions; 195 | } 196 | 197 | public function getTypes(): array 198 | { 199 | $actualTypes = []; 200 | 201 | $sql = sprintf(" 202 | SELECT 203 | concat_ws('.', n.nspname, t.typname) AS type_path, 204 | t.typbyval 205 | FROM pg_catalog.pg_type t 206 | LEFT JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace 207 | WHERE (t.typrelid = 0 OR (SELECT c.relkind = 'c' 208 | FROM pg_catalog.pg_class c 209 | WHERE c.oid = t.typrelid)) 210 | AND NOT EXISTS(SELECT 1 211 | FROM pg_catalog.pg_type el 212 | WHERE el.oid = t.typelem AND el.typarray = t.oid) 213 | AND %s 214 | ORDER BY type_path 215 | ", $this->expandLike('n.nspname', $this->skippedSchemaNames)); 216 | 217 | $stmt = $this->query($sql); 218 | 219 | while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 220 | $type = $row['type_path']; 221 | if ($row['typbyval']) { 222 | $stmtEnum = $this->conn->query('select enum_range(null::' . $type . ')'); 223 | $definition = $stmtEnum ? $stmtEnum->fetchColumn() : ''; 224 | } else { 225 | $definition = $this->psqlClient->run('\d ' . $type); 226 | } 227 | 228 | $type = $this->prepareName($type); 229 | $actualTypes[$type] = (string) $definition; 230 | } 231 | 232 | return $actualTypes; 233 | } 234 | 235 | public function getSchemas(): array 236 | { 237 | $actualSchemas = []; 238 | 239 | $sql = sprintf(" 240 | SELECT schema_name 241 | FROM information_schema.schemata 242 | WHERE %s 243 | ORDER BY schema_name 244 | ", $this->expandLike('schema_name', $this->skippedSchemaNames)); 245 | 246 | $stmt = $this->query($sql); 247 | 248 | while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 249 | $schema = $this->prepareName($row['schema_name']); 250 | $actualSchemas[$schema] = $schema; 251 | } 252 | 253 | return $actualSchemas; 254 | } 255 | 256 | public function getExtensions(): array 257 | { 258 | $actualExtensions = []; 259 | 260 | $sql = sprintf(" 261 | SELECT 262 | ext.extname, 263 | nsp.nspname 264 | FROM pg_catalog.pg_extension ext 265 | LEFT JOIN pg_catalog.pg_namespace nsp 266 | ON nsp.OID = ext.extnamespace 267 | WHERE %s 268 | ORDER BY extname; 269 | ", $this->expandLike('extname', $this->skippedExtensionNames)); 270 | 271 | $stmt = $this->query($sql); 272 | 273 | while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 274 | $extension = $this->prepareName($row['extname']); 275 | $schema = $row['nspname']; 276 | $actualExtensions[$extension] = $schema; 277 | } 278 | 279 | return $actualExtensions; 280 | } 281 | 282 | public function getSequences(): array 283 | { 284 | $sql = " 285 | SELECT 286 | concat(s.sequence_schema, '.', s.sequence_name) as seq_path, 287 | data_type, 288 | start_value, 289 | minimum_value, 290 | maximum_value, 291 | increment, 292 | cycle_option 293 | FROM information_schema.sequences s 294 | ORDER BY seq_path 295 | "; 296 | 297 | $stmt = $this->query($sql); 298 | 299 | $actualSequences = []; 300 | while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 301 | $sequence = $this->prepareName($row['seq_path']); 302 | $actualSequences[$sequence] = (string) json_encode($row, JSON_PRETTY_PRINT); 303 | 304 | if (json_last_error() !== JSON_ERROR_NONE) { 305 | throw new \RuntimeException('Json error: ' . json_last_error_msg()); 306 | } 307 | } 308 | 309 | return $actualSequences; 310 | } 311 | 312 | public function createFunction(string $definition): void 313 | { 314 | $this->conn->exec($definition); 315 | } 316 | 317 | public function deleteFunction(string $name): void 318 | { 319 | $sqlDelete = 'DROP FUNCTION ' . $name; 320 | $this->conn->exec($sqlDelete); 321 | } 322 | 323 | public function changeFunction(string $name, string $definition): void 324 | { 325 | $isTransaction = $this->conn->inTransaction(); 326 | 327 | try { 328 | $this->savePointHelper->beginTransaction('before_change', $isTransaction); 329 | $this->conn->exec($definition); 330 | $this->savePointHelper->commit('before_change', $isTransaction); 331 | } catch (Exception $e) { 332 | $this->savePointHelper->rollback('before_change', $isTransaction); 333 | $this->conn->exec('DROP FUNCTION ' . $name); 334 | $this->conn->exec($definition); 335 | } 336 | } 337 | 338 | private function prepareName(string $name): string 339 | { 340 | return str_replace(['{', '}'], ['(', ')'], $name); 341 | } 342 | 343 | private function expandLike(string $columnName, array $patterns): string 344 | { 345 | $sql = ''; 346 | 347 | foreach ($patterns as $pattern) { 348 | $sql .= " AND $columnName NOT LIKE '$pattern'"; 349 | } 350 | 351 | if (!$sql) { 352 | $sql = ' AND TRUE'; 353 | } 354 | 355 | $sql = trim($sql, " AND"); 356 | 357 | return $sql; 358 | } 359 | 360 | private function query(string $sql): \PDOStatement 361 | { 362 | $stmt = $this->conn->query($sql); 363 | 364 | if ($stmt === false) { 365 | throw new KeeperException(print_r($this->conn->errorInfo(), true)); 366 | } 367 | 368 | return $stmt; 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SchemaKeeper 2 | 3 | [![Latest Stable Version](https://img.shields.io/packagist/v/schema-keeper/schema-keeper.svg?color=blue)](https://packagist.org/packages/schema-keeper/schema-keeper) 4 | [![Minimum PHP Version](https://img.shields.io/packagist/php-v/schema-keeper/schema-keeper.svg?color=blue)](https://php.net/) 5 | [![Minimum PostgreSQL Version](https://img.shields.io/badge/postgreSQL-%3E%3D9.4-blue.svg)](https://www.postgresql.org/) 6 | [![Build Status](https://img.shields.io/travis/com/dmytro-demchyna/schema-keeper/master.svg)](https://travis-ci.com/dmytro-demchyna/schema-keeper) 7 | [![Coverage](https://img.shields.io/codecov/c/github/dmytro-demchyna/schema-keeper/master.svg)](https://codecov.io/gh/dmytro-demchyna/schema-keeper) 8 | [![License](https://img.shields.io/github/license/dmytro-demchyna/schema-keeper.svg)](https://github.com/dmytro-demchyna/schema-keeper/blob/master/LICENSE) 9 | 10 | Track a structure of your PostgreSQL database in a VCS using SchemaKeeper. 11 | 12 | SchemaKeeper provides 3 functions: 13 | 1. `save` — saves a database structure as separate text files to a specified directory 14 | 1. `verify` — detects changes between an actual database structure and the saved one 15 | 1. `deploy` — deploys stored procedures to a database from the saved structure 16 | 17 | SchemaKeeper allows to use `gitflow` principles for a database development. Each branch contains its own database structure dump, and when branches are merged, dumps are merged too. 18 | 19 | ## Table of contents 20 | - [Installation](#installation) 21 | - [Composer](#composer) 22 | - [PHAR](#phar) 23 | - [Docker](#docker) 24 | - [Basic usage](#basic-usage) 25 | - [save](#save) 26 | - [verify](#verify) 27 | - [deploy](#deploy) 28 | - [Extended usage](#extended-usage) 29 | - [PHPUnit](#phpunit) 30 | - [Custom transaction block](#custom-transaction-block) 31 | - [Workflow recommendations](#workflow-recommendations) 32 | - [Safe deploy to a production](#safe-deploy-to-a-production) 33 | - [Conflicts resolving](#conflicts-resolving) 34 | - [Extra links](#extra-links) 35 | - [Contributing](#contributing) 36 | 37 | ## Installation 38 | 39 | > If you choose the installation via Composer or PHAR, please, install [psql](https://www.postgresql.org/docs/current/app-psql.html) app on machines where SchemaKeeper will be used. A Docker build includes pre-installed [psql](https://www.postgresql.org/docs/current/app-psql.html). 40 | 41 | ### Composer 42 | 43 | ```bash 44 | $ composer require schema-keeper/schema-keeper 45 | ``` 46 | 47 | ### PHAR 48 | 49 | ```bash 50 | $ wget https://github.com/dmytro-demchyna/schema-keeper/releases/latest/download/schemakeeper.phar 51 | ``` 52 | 53 | ### Docker 54 | 55 | ```bash 56 | $ docker pull dmytrodemchyna/schema-keeper 57 | ``` 58 | 59 | ## Basic Usage 60 | 61 | Create a `config.php` file: 62 | 63 | ```php 64 | setSkippedSchemas(['information_schema', 'pg_%']); 73 | 74 | // These extensions will be ignored 75 | $params->setSkippedExtensions(['pgtap']); 76 | 77 | // The path to psql executable 78 | $params->setExecutable('/bin/psql'); 79 | 80 | return $params; 81 | ``` 82 | 83 | Now you can use the `schemakeeper` binary. It returns exit-code `0` on success and exit-code `1` on failure. 84 | 85 | ### save 86 | 87 | ```bash 88 | $ schemakeeper -c config.php -d /project_path/db_name save 89 | ``` 90 | 91 | The command above saves a database structure to a `/project_path/db_name` directory. 92 | 93 | - /project_path/db_name: 94 | - structure: 95 | - public: 96 | - functions: 97 | - func1(int8).sql 98 | - materialized_views: 99 | - mat_view1.txt 100 | - sequences: 101 | - sequence1.txt 102 | - tables: 103 | - table1.txt 104 | - triggers: 105 | - trigger1.sql 106 | - types: 107 | - type1.txt 108 | - views: 109 | - view1.txt 110 | - schema2: 111 | - views: 112 | - view2.txt 113 | - ... 114 | - extensions: 115 | - plpgsql.txt 116 | 117 | Examples of conversion database structure to files: 118 | 119 | Object type | Schema | Name | Relative file path | File content 120 | --------------------|----------------|------------------------------------------|------------------------------------|--------------- 121 | Table | public | table1 | ./public/tables/table1.txt | A description of the table structure obtained by `\d` [meta](https://www.postgresql.org/docs/current/app-psql.html#APP-PSQL-META-COMMANDS) command 122 | Stored procedure | public | func1(param bigint) | ./public/functions/func1(int8).sql | A definition of the stored procedure, including a `CREATE OR REPLACE FUNCTION` block, obtained by [pg_get_functiondef](https://www.postgresql.org/docs/current/functions-info.html#FUNCTIONS-INFO-CATALOG-TABLE) 123 | View | schema2 | view2 | ./schema2/views/view2.txt | A description of the view structure obtained by `\d+` [meta](https://www.postgresql.org/docs/current/app-psql.html#APP-PSQL-META-COMMANDS) command 124 | ... | ... | ... | ... | ... 125 | 126 | The file path stores information about a type, a scheme and a name of a object. This approach makes an easier navigation through the database structure, as well as code review of changes in VCS. 127 | 128 | ### verify 129 | 130 | ```bash 131 | $ schemakeeper -c config.php -d /project_path/db_name verify 132 | ``` 133 | 134 | The command above compares an actual database structure with the previously saved in `/project_path/db_name` one and displays an information about changed objects. 135 | 136 | If changes exists, the `verify` will returns an exit-code `1`. 137 | 138 | An alternative way to find changes is to call the `save` again, specifying the same directory `/project_path/db_name`, and check changes in the VCS. Since objects from the database are stored in separate files, the VCS will show only changed objects. A main disadvantage of this way — a need to overwrite files. 139 | 140 | ### deploy 141 | 142 | ```bash 143 | $ schemakeeper -c config.php -d /project_path/db_name deploy 144 | ``` 145 | 146 | The command above deploys stored procedures from the `/project_path/db_name` to the actual database. 147 | 148 | You can edit a source code of stored procedures in the same way as a rest of an application source code. Modification of a stored procedure occurs by making changes to the corresponding file in the `/project_path/db_name` directory, which is automatically reflected in the VCS. 149 | 150 | For example, to create a new stored procedure in the `public` schema, just create a new file with a `.sql` extension in the `/project_path/db_name/structure/public/functions` directory, place a source code of the stored procedure into it, including a `CREATE OR REPLACE FUNCTION` block, then call the `deploy`. Similarly occur modifying or removal of stored procedures. Thus, the code simultaneously enters both the VCS and the database. 151 | 152 | The `deploy` changes parameters of a function or a return type without additional actions, while with a classical approach it would be necessary to first perform `DROP FUNCTION`, and only then `CREATE OR REPLACE FUNCTION`. 153 | 154 | Unfortunately, in some situations `deploy` is not able to automatically apply changes. For example, if you try to delete a trigger function, that is used by at least one trigger. Such situations must be solved manually using migration files. 155 | 156 | The `deploy` transfers changes only from stored procedures. To transfer other changes, please, use migration files (for example, [doctrine/migrations](https://packagist.org/packages/doctrine/migrations)). 157 | 158 | Migrations must be applied before the `deploy` to resolve possible problem situations. 159 | 160 | > The `deploy` is designed to work with stored procedures written in [PL/pgSQL](https://www.postgresql.org/docs/current/plpgsql.html). Using with other languages may be less effective or impossible. 161 | 162 | ## Extended usage 163 | 164 | You can inject SchemaKeeper to your own code. 165 | 166 | ```php 167 | PDO::ERRMODE_EXCEPTION]); 180 | 181 | $params = new PSQLParameters($host, $port, $dbName, $user, $password); 182 | $keeper = new Keeper($conn, $params); 183 | ``` 184 | 185 | ```php 186 | saveDump('path_to_dump'); 189 | $keeper->verifyDump('path_to_dump'); 190 | $keeper->deployDump('path_to_dump'); 191 | ``` 192 | 193 | ### PHPUnit 194 | 195 | You can wrap `verifyDump` into a PHPUnit test: 196 | 197 | ```php 198 | verifyDump('/path_to_dump'); 208 | } catch (\SchemaKeeper\Exception\NotEquals $e) { 209 | $expectedFormatted = print_r($e->getExpected(), true); 210 | $actualFormatted = print_r($e->getActual(), true); 211 | 212 | // assertEquals will show the detailed diff between the saved dump and actual database 213 | self::assertEquals($expectedFormatted, $actualFormatted); 214 | } 215 | } 216 | } 217 | 218 | ``` 219 | 220 | ### Custom transaction block 221 | 222 | You can wrap `deployDump` into a custom transaction block: 223 | 224 | ```php 225 | beginTransaction(); 232 | 233 | try { 234 | $result = $keeper->deployDump('/path_to_dump'); 235 | 236 | // $result->getDeleted() - these functions were deleted from the current database 237 | // $result->getCreated() - these functions were created in the current database 238 | // $result->getChanged() - these functions were changed in the current database 239 | 240 | $conn->commit(); 241 | } catch (\Exception $e) { 242 | $conn->rollBack(); 243 | } 244 | ``` 245 | 246 | ## Workflow recommendations 247 | 248 | ### Safe deploy to a production 249 | 250 | A dump of a database structure saved in a VCS allows you to check a production database for exact match to a required structure. This ensures that only intended changes were transferred to the production-DB by deploy. 251 | 252 | Since the PostgreSQL [DDL](https://www.postgresql.org/docs/current/ddl.html) is [transactional](https://wiki.postgresql.org/wiki/Transactional_DDL_in_PostgreSQL:_A_Competitive_Analysis), the following deployment order is recommended: 253 | 1. Start transaction 254 | 1. Apply all migrations in the transaction 255 | 1. In the same transaction, perform `deployDump` 256 | 1. Perform `verifyDump`. If there are no errors, execute `COMMIT`. If there are errors, execute `ROLLBACK` 257 | 258 | ### Conflicts resolving 259 | A possible conflict situation: *branch1* and *branch2* are branched from *develop*. They haven't conflict with *develop*, but have conflict with each other. A goal is to merge *branch1* and *branch2* into *develop*. 260 | 261 | First, merge *branch1* into *develop*, then merge *develop* into *branch2*, resolve conflicts in *branch2*, and then merge *branch2* into *develop*. At the stage of conflict resolution inside *branch2*, you may have to correct a migration file in *branch2* to match the final dump that contains merge results. 262 | 263 | ## Extra links 264 | 265 | If you are not satisfied with SchemaKeeper, look at the list of another tools: https://wiki.postgresql.org/wiki/Change_management_tools_and_techniques 266 | 267 | ## Contributing 268 | Any contributions are welcome. 269 | 270 | Please refer to [CONTRIBUTING.md](https://github.com/dmytro-demchyna/schema-keeper/blob/master/.github/CONTRIBUTING.md) for information on how to contribute to SchemaKeeper. 271 | -------------------------------------------------------------------------------- /.dev/docker/images/postgres/config/postgresql.conf: -------------------------------------------------------------------------------- 1 | # ----------------------------- 2 | # PostgreSQL configuration file 3 | # ----------------------------- 4 | # 5 | # This file consists of lines of the form: 6 | # 7 | # name = value 8 | # 9 | # (The "=" is optional.) Whitespace may be used. Comments are introduced with 10 | # "#" anywhere on a line. The complete list of parameter names and allowed 11 | # values can be found in the PostgreSQL documentation. 12 | # 13 | # The commented-out settings shown in this file represent the default values. 14 | # Re-commenting a setting is NOT sufficient to revert it to the default value; 15 | # you need to reload the server. 16 | # 17 | # This file is read on server startup and when the server receives a SIGHUP 18 | # signal. If you edit the file on a running system, you have to SIGHUP the 19 | # server for the changes to take effect, or use "pg_ctl reload". Some 20 | # parameters, which are marked below, require a server shutdown and restart to 21 | # take effect. 22 | # 23 | # Any parameter can also be given as a command-line option to the server, e.g., 24 | # "postgres -c log_connections=on". Some parameters can be changed at run time 25 | # with the "SET" SQL command. 26 | # 27 | # Memory units: kB = kilobytes Time units: ms = milliseconds 28 | # MB = megabytes s = seconds 29 | # GB = gigabytes min = minutes 30 | # TB = terabytes h = hours 31 | # d = days 32 | 33 | 34 | #------------------------------------------------------------------------------ 35 | # FILE LOCATIONS 36 | #------------------------------------------------------------------------------ 37 | 38 | # The default values of these variables are driven from the -D command-line 39 | # option or PGDATA environment variable, represented here as ConfigDir. 40 | 41 | #data_directory = 'ConfigDir' # use data in another directory 42 | # (change requires restart) 43 | hba_file = '/etc/postgresql/pg_hba.conf' # host-based authentication file 44 | # (change requires restart) 45 | #ident_file = 'ConfigDir/pg_ident.conf' # ident configuration file 46 | # (change requires restart) 47 | 48 | # If external_pid_file is not explicitly set, no extra PID file is written. 49 | #external_pid_file = '' # write an extra PID file 50 | # (change requires restart) 51 | 52 | 53 | #------------------------------------------------------------------------------ 54 | # CONNECTIONS AND AUTHENTICATION 55 | #------------------------------------------------------------------------------ 56 | 57 | # - Connection Settings - 58 | 59 | listen_addresses = '*' 60 | # comma-separated list of addresses; 61 | # defaults to 'localhost'; use '*' for all 62 | # (change requires restart) 63 | #port = 5432 # (change requires restart) 64 | #max_connections = 100 # (change requires restart) 65 | #superuser_reserved_connections = 3 # (change requires restart) 66 | #unix_socket_directories = '/tmp' # comma-separated list of directories 67 | # (change requires restart) 68 | #unix_socket_group = '' # (change requires restart) 69 | #unix_socket_permissions = 0777 # begin with 0 to use octal notation 70 | # (change requires restart) 71 | #bonjour = off # advertise server via Bonjour 72 | # (change requires restart) 73 | #bonjour_name = '' # defaults to the computer name 74 | # (change requires restart) 75 | 76 | # - Security and Authentication - 77 | 78 | #authentication_timeout = 1min # 1s-600s 79 | #ssl = off # (change requires restart) 80 | #ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers 81 | # (change requires restart) 82 | #ssl_prefer_server_ciphers = on # (change requires restart) 83 | #ssl_ecdh_curve = 'prime256v1' # (change requires restart) 84 | #ssl_cert_file = 'server.crt' # (change requires restart) 85 | #ssl_key_file = 'server.key' # (change requires restart) 86 | #ssl_ca_file = '' # (change requires restart) 87 | #ssl_crl_file = '' # (change requires restart) 88 | #password_encryption = on 89 | #db_user_namespace = off 90 | #row_security = on 91 | 92 | # GSSAPI using Kerberos 93 | #krb_server_keyfile = '' 94 | #krb_caseins_users = off 95 | 96 | # - TCP Keepalives - 97 | # see "man 7 tcp" for details 98 | 99 | #tcp_keepalives_idle = 0 # TCP_KEEPIDLE, in seconds; 100 | # 0 selects the system default 101 | #tcp_keepalives_interval = 0 # TCP_KEEPINTVL, in seconds; 102 | # 0 selects the system default 103 | #tcp_keepalives_count = 0 # TCP_KEEPCNT; 104 | # 0 selects the system default 105 | 106 | 107 | #------------------------------------------------------------------------------ 108 | # RESOURCE USAGE (except WAL) 109 | #------------------------------------------------------------------------------ 110 | 111 | # - Memory - 112 | 113 | #shared_buffers = 32MB # min 128kB 114 | # (change requires restart) 115 | #huge_pages = try # on, off, or try 116 | # (change requires restart) 117 | #temp_buffers = 8MB # min 800kB 118 | #max_prepared_transactions = 0 # zero disables the feature 119 | # (change requires restart) 120 | # Caution: it is not advisable to set max_prepared_transactions nonzero unless 121 | # you actively intend to use prepared transactions. 122 | #work_mem = 4MB # min 64kB 123 | #maintenance_work_mem = 64MB # min 1MB 124 | #autovacuum_work_mem = -1 # min 1MB, or -1 to use maintenance_work_mem 125 | #max_stack_depth = 2MB # min 100kB 126 | #dynamic_shared_memory_type = posix # the default is the first option 127 | # supported by the operating system: 128 | # posix 129 | # sysv 130 | # windows 131 | # mmap 132 | # use none to disable dynamic shared memory 133 | # (change requires restart) 134 | 135 | # - Disk - 136 | 137 | #temp_file_limit = -1 # limits per-session temp file space 138 | # in kB, or -1 for no limit 139 | 140 | # - Kernel Resource Usage - 141 | 142 | #max_files_per_process = 1000 # min 25 143 | # (change requires restart) 144 | #shared_preload_libraries = '' # (change requires restart) 145 | 146 | # - Cost-Based Vacuum Delay - 147 | 148 | #vacuum_cost_delay = 0 # 0-100 milliseconds 149 | #vacuum_cost_page_hit = 1 # 0-10000 credits 150 | #vacuum_cost_page_miss = 10 # 0-10000 credits 151 | #vacuum_cost_page_dirty = 20 # 0-10000 credits 152 | #vacuum_cost_limit = 200 # 1-10000 credits 153 | 154 | # - Background Writer - 155 | 156 | #bgwriter_delay = 200ms # 10-10000ms between rounds 157 | #bgwriter_lru_maxpages = 100 # 0-1000 max buffers written/round 158 | #bgwriter_lru_multiplier = 2.0 # 0-10.0 multipler on buffers scanned/round 159 | 160 | # - Asynchronous Behavior - 161 | 162 | #effective_io_concurrency = 1 # 1-1000; 0 disables prefetching 163 | #max_worker_processes = 8 164 | 165 | 166 | #------------------------------------------------------------------------------ 167 | # WRITE AHEAD LOG 168 | #------------------------------------------------------------------------------ 169 | 170 | # - Settings - 171 | 172 | #wal_level = minimal # minimal, archive, hot_standby, or logical 173 | # (change requires restart) 174 | #fsync = on # turns forced synchronization on or off 175 | #synchronous_commit = on # synchronization level; 176 | # off, local, remote_write, or on 177 | #wal_sync_method = fsync # the default is the first option 178 | # supported by the operating system: 179 | # open_datasync 180 | # fdatasync (default on Linux) 181 | # fsync 182 | # fsync_writethrough 183 | # open_sync 184 | #full_page_writes = on # recover from partial page writes 185 | #wal_compression = off # enable compression of full-page writes 186 | #wal_log_hints = off # also do full page writes of non-critical updates 187 | # (change requires restart) 188 | #wal_buffers = -1 # min 32kB, -1 sets based on shared_buffers 189 | # (change requires restart) 190 | #wal_writer_delay = 200ms # 1-10000 milliseconds 191 | 192 | #commit_delay = 0 # range 0-100000, in microseconds 193 | #commit_siblings = 5 # range 1-1000 194 | 195 | # - Checkpoints - 196 | 197 | #checkpoint_timeout = 5min # range 30s-1h 198 | #max_wal_size = 1GB 199 | #min_wal_size = 80MB 200 | #checkpoint_completion_target = 0.5 # checkpoint target duration, 0.0 - 1.0 201 | #checkpoint_warning = 30s # 0 disables 202 | 203 | # - Archiving - 204 | 205 | #archive_mode = off # enables archiving; off, on, or always 206 | # (change requires restart) 207 | #archive_command = '' # command to use to archive a logfile segment 208 | # placeholders: %p = path of file to archive 209 | # %f = file name only 210 | # e.g. 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f' 211 | #archive_timeout = 0 # force a logfile segment switch after this 212 | # number of seconds; 0 disables 213 | 214 | 215 | #------------------------------------------------------------------------------ 216 | # REPLICATION 217 | #------------------------------------------------------------------------------ 218 | 219 | # - Sending Server(s) - 220 | 221 | # Set these on the master and on any standby that will send replication data. 222 | 223 | #max_wal_senders = 0 # max number of walsender processes 224 | # (change requires restart) 225 | #wal_keep_segments = 0 # in logfile segments, 16MB each; 0 disables 226 | #wal_sender_timeout = 60s # in milliseconds; 0 disables 227 | 228 | #max_replication_slots = 0 # max number of replication slots 229 | # (change requires restart) 230 | #track_commit_timestamp = off # collect timestamp of transaction commit 231 | # (change requires restart) 232 | 233 | # - Master Server - 234 | 235 | # These settings are ignored on a standby server. 236 | 237 | #synchronous_standby_names = '' # standby servers that provide sync rep 238 | # comma-separated list of application_name 239 | # from standby(s); '*' = all 240 | #vacuum_defer_cleanup_age = 0 # number of xacts by which cleanup is delayed 241 | 242 | # - Standby Servers - 243 | 244 | # These settings are ignored on a master server. 245 | 246 | #hot_standby = off # "on" allows queries during recovery 247 | # (change requires restart) 248 | #max_standby_archive_delay = 30s # max delay before canceling queries 249 | # when reading WAL from archive; 250 | # -1 allows indefinite delay 251 | #max_standby_streaming_delay = 30s # max delay before canceling queries 252 | # when reading streaming WAL; 253 | # -1 allows indefinite delay 254 | #wal_receiver_status_interval = 10s # send replies at least this often 255 | # 0 disables 256 | #hot_standby_feedback = off # send info from standby to prevent 257 | # query conflicts 258 | #wal_receiver_timeout = 60s # time that receiver waits for 259 | # communication from master 260 | # in milliseconds; 0 disables 261 | #wal_retrieve_retry_interval = 5s # time to wait before retrying to 262 | # retrieve WAL after a failed attempt 263 | 264 | 265 | #------------------------------------------------------------------------------ 266 | # QUERY TUNING 267 | #------------------------------------------------------------------------------ 268 | 269 | # - Planner Method Configuration - 270 | 271 | #enable_bitmapscan = on 272 | #enable_hashagg = on 273 | #enable_hashjoin = on 274 | #enable_indexscan = on 275 | #enable_indexonlyscan = on 276 | #enable_material = on 277 | #enable_mergejoin = on 278 | #enable_nestloop = on 279 | #enable_seqscan = on 280 | #enable_sort = on 281 | #enable_tidscan = on 282 | 283 | # - Planner Cost Constants - 284 | 285 | #seq_page_cost = 1.0 # measured on an arbitrary scale 286 | #random_page_cost = 4.0 # same scale as above 287 | #cpu_tuple_cost = 0.01 # same scale as above 288 | #cpu_index_tuple_cost = 0.005 # same scale as above 289 | #cpu_operator_cost = 0.0025 # same scale as above 290 | #effective_cache_size = 4GB 291 | 292 | # - Genetic Query Optimizer - 293 | 294 | #geqo = on 295 | #geqo_threshold = 12 296 | #geqo_effort = 5 # range 1-10 297 | #geqo_pool_size = 0 # selects default based on effort 298 | #geqo_generations = 0 # selects default based on effort 299 | #geqo_selection_bias = 2.0 # range 1.5-2.0 300 | #geqo_seed = 0.0 # range 0.0-1.0 301 | 302 | # - Other Planner Options - 303 | 304 | #default_statistics_target = 100 # range 1-10000 305 | #constraint_exclusion = partition # on, off, or partition 306 | #cursor_tuple_fraction = 0.1 # range 0.0-1.0 307 | #from_collapse_limit = 8 308 | #join_collapse_limit = 8 # 1 disables collapsing of explicit 309 | # JOIN clauses 310 | 311 | 312 | #------------------------------------------------------------------------------ 313 | # ERROR REPORTING AND LOGGING 314 | #------------------------------------------------------------------------------ 315 | 316 | # - Where to Log - 317 | 318 | #log_destination = 'stderr' # Valid values are combinations of 319 | # stderr, csvlog, syslog, and eventlog, 320 | # depending on platform. csvlog 321 | # requires logging_collector to be on. 322 | 323 | # This is used when logging to stderr: 324 | #logging_collector = off # Enable capturing of stderr and csvlog 325 | # into log files. Required to be on for 326 | # csvlogs. 327 | # (change requires restart) 328 | 329 | # These are only used if logging_collector is on: 330 | #log_directory = 'pg_log' # directory where log files are written, 331 | # can be absolute or relative to PGDATA 332 | #log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log file name pattern, 333 | # can include strftime() escapes 334 | #log_file_mode = 0600 # creation mode for log files, 335 | # begin with 0 to use octal notation 336 | #log_truncate_on_rotation = off # If on, an existing log file with the 337 | # same name as the new log file will be 338 | # truncated rather than appended to. 339 | # But such truncation only occurs on 340 | # time-driven rotation, not on restarts 341 | # or size-driven rotation. Default is 342 | # off, meaning append to existing files 343 | # in all cases. 344 | #log_rotation_age = 1d # Automatic rotation of logfiles will 345 | # happen after that time. 0 disables. 346 | #log_rotation_size = 10MB # Automatic rotation of logfiles will 347 | # happen after that much log output. 348 | # 0 disables. 349 | 350 | # These are relevant when logging to syslog: 351 | #syslog_facility = 'LOCAL0' 352 | #syslog_ident = 'postgres' 353 | 354 | # This is only relevant when logging to eventlog (win32): 355 | # (change requires restart) 356 | #event_source = 'PostgreSQL' 357 | 358 | # - When to Log - 359 | 360 | #client_min_messages = notice # values in order of decreasing detail: 361 | # debug5 362 | # debug4 363 | # debug3 364 | # debug2 365 | # debug1 366 | # log 367 | # notice 368 | # warning 369 | # error 370 | 371 | #log_min_messages = warning # values in order of decreasing detail: 372 | # debug5 373 | # debug4 374 | # debug3 375 | # debug2 376 | # debug1 377 | # info 378 | # notice 379 | # warning 380 | # error 381 | # log 382 | # fatal 383 | # panic 384 | 385 | #log_min_error_statement = error # values in order of decreasing detail: 386 | # debug5 387 | # debug4 388 | # debug3 389 | # debug2 390 | # debug1 391 | # info 392 | # notice 393 | # warning 394 | # error 395 | # log 396 | # fatal 397 | # panic (effectively off) 398 | 399 | #log_min_duration_statement = -1 # -1 is disabled, 0 logs all statements 400 | # and their durations, > 0 logs only 401 | # statements running at least this number 402 | # of milliseconds 403 | 404 | 405 | # - What to Log - 406 | 407 | #debug_print_parse = off 408 | #debug_print_rewritten = off 409 | #debug_print_plan = off 410 | #debug_pretty_print = on 411 | #log_checkpoints = off 412 | #log_connections = off 413 | #log_disconnections = off 414 | #log_duration = off 415 | #log_error_verbosity = default # terse, default, or verbose messages 416 | #log_hostname = off 417 | #log_line_prefix = '' # special values: 418 | # %a = application name 419 | # %u = user name 420 | # %d = database name 421 | # %r = remote host and port 422 | # %h = remote host 423 | # %p = process ID 424 | # %t = timestamp without milliseconds 425 | # %m = timestamp with milliseconds 426 | # %i = command tag 427 | # %e = SQL state 428 | # %c = session ID 429 | # %l = session line number 430 | # %s = session start timestamp 431 | # %v = virtual transaction ID 432 | # %x = transaction ID (0 if none) 433 | # %q = stop here in non-session 434 | # processes 435 | # %% = '%' 436 | # e.g. '<%u%%%d> ' 437 | #log_lock_waits = off # log lock waits >= deadlock_timeout 438 | #log_statement = 'none' # none, ddl, mod, all 439 | #log_replication_commands = off 440 | #log_temp_files = -1 # log temporary files equal or larger 441 | # than the specified size in kilobytes; 442 | # -1 disables, 0 logs all temp files 443 | #log_timezone = 'GMT' 444 | 445 | 446 | # - Process Title - 447 | 448 | #cluster_name = '' # added to process titles if nonempty 449 | # (change requires restart) 450 | #update_process_title = on 451 | 452 | 453 | #------------------------------------------------------------------------------ 454 | # RUNTIME STATISTICS 455 | #------------------------------------------------------------------------------ 456 | 457 | # - Query/Index Statistics Collector - 458 | 459 | #track_activities = on 460 | #track_counts = on 461 | #track_io_timing = off 462 | #track_functions = none # none, pl, all 463 | #track_activity_query_size = 1024 # (change requires restart) 464 | #stats_temp_directory = 'pg_stat_tmp' 465 | 466 | 467 | # - Statistics Monitoring - 468 | 469 | #log_parser_stats = off 470 | #log_planner_stats = off 471 | #log_executor_stats = off 472 | #log_statement_stats = off 473 | 474 | 475 | #------------------------------------------------------------------------------ 476 | # AUTOVACUUM PARAMETERS 477 | #------------------------------------------------------------------------------ 478 | 479 | #autovacuum = on # Enable autovacuum subprocess? 'on' 480 | # requires track_counts to also be on. 481 | #log_autovacuum_min_duration = -1 # -1 disables, 0 logs all actions and 482 | # their durations, > 0 logs only 483 | # actions running at least this number 484 | # of milliseconds. 485 | #autovacuum_max_workers = 3 # max number of autovacuum subprocesses 486 | # (change requires restart) 487 | #autovacuum_naptime = 1min # time between autovacuum runs 488 | #autovacuum_vacuum_threshold = 50 # min number of row updates before 489 | # vacuum 490 | #autovacuum_analyze_threshold = 50 # min number of row updates before 491 | # analyze 492 | #autovacuum_vacuum_scale_factor = 0.2 # fraction of table size before vacuum 493 | #autovacuum_analyze_scale_factor = 0.1 # fraction of table size before analyze 494 | #autovacuum_freeze_max_age = 200000000 # maximum XID age before forced vacuum 495 | # (change requires restart) 496 | #autovacuum_multixact_freeze_max_age = 400000000 # maximum multixact age 497 | # before forced vacuum 498 | # (change requires restart) 499 | #autovacuum_vacuum_cost_delay = 20ms # default vacuum cost delay for 500 | # autovacuum, in milliseconds; 501 | # -1 means use vacuum_cost_delay 502 | #autovacuum_vacuum_cost_limit = -1 # default vacuum cost limit for 503 | # autovacuum, -1 means use 504 | # vacuum_cost_limit 505 | 506 | 507 | #------------------------------------------------------------------------------ 508 | # CLIENT CONNECTION DEFAULTS 509 | #------------------------------------------------------------------------------ 510 | 511 | # - Statement Behavior - 512 | 513 | #search_path = '"$user", public' # schema names 514 | #default_tablespace = '' # a tablespace name, '' uses the default 515 | #temp_tablespaces = '' # a list of tablespace names, '' uses 516 | # only default tablespace 517 | #check_function_bodies = on 518 | #default_transaction_isolation = 'read committed' 519 | #default_transaction_read_only = off 520 | #default_transaction_deferrable = off 521 | #session_replication_role = 'origin' 522 | #statement_timeout = 0 # in milliseconds, 0 is disabled 523 | #lock_timeout = 0 # in milliseconds, 0 is disabled 524 | #vacuum_freeze_min_age = 50000000 525 | #vacuum_freeze_table_age = 150000000 526 | #vacuum_multixact_freeze_min_age = 5000000 527 | #vacuum_multixact_freeze_table_age = 150000000 528 | #bytea_output = 'hex' # hex, escape 529 | #xmlbinary = 'base64' 530 | #xmloption = 'content' 531 | #gin_fuzzy_search_limit = 0 532 | #gin_pending_list_limit = 4MB 533 | 534 | # - Locale and Formatting - 535 | 536 | #datestyle = 'iso, mdy' 537 | #intervalstyle = 'postgres' 538 | #timezone = 'GMT' 539 | #timezone_abbreviations = 'Default' # Select the set of available time zone 540 | # abbreviations. Currently, there are 541 | # Default 542 | # Australia (historical usage) 543 | # India 544 | # You can create your own file in 545 | # share/timezonesets/. 546 | #extra_float_digits = 0 # min -15, max 3 547 | #client_encoding = sql_ascii # actually, defaults to database 548 | # encoding 549 | 550 | # These settings are initialized by initdb, but they can be changed. 551 | #lc_messages = 'C' # locale for system error message 552 | # strings 553 | #lc_monetary = 'C' # locale for monetary formatting 554 | #lc_numeric = 'C' # locale for number formatting 555 | #lc_time = 'C' # locale for time formatting 556 | 557 | # default configuration for text search 558 | #default_text_search_config = 'pg_catalog.simple' 559 | 560 | # - Other Defaults - 561 | 562 | #dynamic_library_path = '$libdir' 563 | #local_preload_libraries = '' 564 | #session_preload_libraries = '' 565 | 566 | 567 | #------------------------------------------------------------------------------ 568 | # LOCK MANAGEMENT 569 | #------------------------------------------------------------------------------ 570 | 571 | #deadlock_timeout = 1s 572 | #max_locks_per_transaction = 64 # min 10 573 | # (change requires restart) 574 | #max_pred_locks_per_transaction = 64 # min 10 575 | # (change requires restart) 576 | 577 | 578 | #------------------------------------------------------------------------------ 579 | # VERSION/PLATFORM COMPATIBILITY 580 | #------------------------------------------------------------------------------ 581 | 582 | # - Previous PostgreSQL Versions - 583 | 584 | #array_nulls = on 585 | #backslash_quote = safe_encoding # on, off, or safe_encoding 586 | #default_with_oids = off 587 | #escape_string_warning = on 588 | #lo_compat_privileges = off 589 | #operator_precedence_warning = off 590 | #quote_all_identifiers = off 591 | #sql_inheritance = on 592 | #standard_conforming_strings = on 593 | #synchronize_seqscans = on 594 | 595 | # - Other Platforms and Clients - 596 | 597 | #transform_null_equals = off 598 | 599 | 600 | #------------------------------------------------------------------------------ 601 | # ERROR HANDLING 602 | #------------------------------------------------------------------------------ 603 | 604 | #exit_on_error = off # terminate session on any error? 605 | #restart_after_crash = on # reinitialize after backend crash? 606 | 607 | 608 | #------------------------------------------------------------------------------ 609 | # CONFIG FILE INCLUDES 610 | #------------------------------------------------------------------------------ 611 | 612 | # These options allow settings to be loaded from files other than the 613 | # default postgresql.conf. 614 | 615 | #include_dir = 'conf.d' # include files ending in '.conf' from 616 | # directory 'conf.d' 617 | #include_if_exists = 'exists.conf' # include file only if it exists 618 | #include = 'special.conf' # include file 619 | 620 | 621 | #------------------------------------------------------------------------------ 622 | # CUSTOMIZED OPTIONS 623 | #------------------------------------------------------------------------------ 624 | 625 | # Add settings for extensions here 626 | 627 | --------------------------------------------------------------------------------