diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c623b36 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] + +end_of_line = lf +charset = utf-8 +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = false + +[*.php] + +insert_final_newline = true \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8a176c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +/tests export-ignore +/examples export-ignore +/docker export-ignore +/vendor-bin export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/captainhook.json export-ignore +/phpstan.neon export-ignore +/phpstan-baseline.neon export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml export-ignore +/psalm-baseline.xml export-ignore +/ruleset.xml export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..328f9c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/.idea +/composer.lock +/vendor +/vendor-bin/**/vendor +/vendor-bin/**/composer.lock +/.phpunit.result.cache +/.phpcs-cache +/tests/_output +/tests/_reports +/build +/tools/cache/* +!/tools/cache/.gitkeep \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..89b3c50 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Andreas Leathley +Copyright (c) 2006-2018 Doctrine Project + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bc0388 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +Squirrel Connection +=================== + +![PHPStan](https://img.shields.io/badge/style-level%20max-success.svg?style=flat-round&label=phpstan) [![Packagist Version](https://img.shields.io/packagist/v/squirrelphp/connection.svg?style=flat-round)](https://packagist.org/packages/squirrelphp/connection) [![PHP Version](https://img.shields.io/packagist/php-v/squirrelphp/connection.svg)](https://packagist.org/packages/squirrelphp/connection) [![Software License](https://img.shields.io/badge/license-MIT-success.svg?style=flat-round)](LICENSE) + +Provides a slimmed down concise interface for the low level database connection (ConnectionInterface), as a more simple replacement to Doctrine DBAL and a more streamlined/opinionated interface compared to pure PDO. It supports MySQL, MariaDB, Sqlite and PostgreSQL and is currently based on PDO, although that is considered an implementation detail. + +Much code for the exception handling was taken from Doctrine DBAL. This library is currently only internally used in [squirrelphp/queries](https://github.com/squirrelphp/queries) and should not be used on its own, as the API is not yet stable. Use [squirrelphp/queries](https://github.com/squirrelphp/queries) instead or even better, use [squirrelphp/entities](https://github.com/squirrelphp/entities) / [squirrelphp/entities-bundle](https://github.com/squirrelphp/entities-bundle) instead, as those are more high-level and stable. + +A stable version of this package will eventually come out, once the API surface has been thought about more and there is more experience using this package within the other SquirrelPHP packages. \ No newline at end of file diff --git a/bin/vendorbin b/bin/vendorbin new file mode 100755 index 0000000..a80e213 --- /dev/null +++ b/bin/vendorbin @@ -0,0 +1,48 @@ +#!/usr/bin/env php +in($projectDir . '/vendor-bin')->directories()->depth(0)->sortByName(); + +/** @var array $tools */ +$tools = []; + +foreach ($sourceFinder as $directory) { + $toolName = $directory->getFilename(); + + $options = [ + '--ansi', + ]; + + if ($composerRunType === 'update') { + $options[] = '--no-progress'; + } + + $process = new \Symfony\Component\Process\Process(['composer', $composerRunType, ...$options]); + if (isset($_SERVER['COMPOSER_CACHE_DIR'])) { + $process->setEnv(['COMPOSER_CACHE_DIR' => $_SERVER['COMPOSER_CACHE_DIR']]); + } + $process->setWorkingDirectory($projectDir . '/vendor-bin/' . $toolName); + $process->start(); + $process->wait(); + + echo 'Running composer ' . $composerRunType . ' for ' . $toolName . ' ...' . "\n"; + + $processOutput = \trim($process->getOutput()); + + if ($composerRunType === 'update') { + $processOutput = \trim($processOutput . "\n" . $process->getErrorOutput()); + } + + if (\strlen($processOutput) > 0) { + echo $processOutput . "\n"; + } +} \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..448d21e --- /dev/null +++ b/composer.json @@ -0,0 +1,72 @@ +{ + "name": "squirrelphp/connection", + "type": "library", + "description": "Slimmed down concise connection interface for database queries and transactions supporting MySQL, MariaDB, PostgreSQL and SQLite.", + "keywords": [ + "php", + "mysql", + "pgsql", + "sqlite", + "database", + "connection", + "abstraction" + ], + "homepage": "https://github.com/squirrelphp/connection", + "license": "MIT", + "authors": [ + { + "name": "Andreas Leathley", + "email": "andreas.leathley@panaxis.ch" + } + ], + "require": { + "php": ">=8.2", + "ext-pdo": "*" + }, + "require-dev": { + "captainhook/captainhook-phar": "^5.0", + "phpunit/phpunit": "^11.0", + "mockery/mockery": "^1.0", + "squirrelphp/types": "^1.0", + "symfony/finder": "^7.0", + "symfony/process": "^7.0" + }, + "suggest": { + "squirrelphp/queries": "Symfony integration of squirrelphp/queries - automatic assembling of decorated connections", + "squirrelphp/queries-bundle": "Symfony integration of squirrelphp/queries - automatic assembling of decorated connections", + "squirrelphp/entities": "Makes defining typed entities possible and easy", + "squirrelphp/entities-bundle": "Automatic integration of squirrelphp/entities in Symfony" + }, + "config": { + "sort-packages": false, + "allow-plugins": { + "captainhook/captainhook-phar": true + } + }, + "autoload": { + "psr-4": { + "Squirrel\\Connection\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Squirrel\\Connection\\Tests\\": "tests/" + } + }, + "scripts": { + "phpstan": "vendor-bin/phpstan/vendor/bin/phpstan analyse --configuration=tools/phpstan.neon", + "phpstan_full": "rm -Rf tools/cache/phpstan && vendor-bin/phpstan/vendor/bin/phpstan analyse --configuration=tools/phpstan.neon", + "phpstan_base": "vendor-bin/phpstan/vendor/bin/phpstan analyse --configuration=tools/phpstan.neon --generate-baseline=tools/phpstan-baseline.php", + "psalm": "vendor-bin/psalm/vendor/bin/psalm --config=tools/psalm.xml --show-info=false", + "psalm_full": "vendor-bin/psalm/vendor/bin/psalm --config=tools/psalm.xml --clear-cache && vendor-bin/psalm/vendor/bin/psalm --config=tools/psalm.xml --show-info=false", + "psalm_base": "vendor-bin/psalm/vendor/bin/psalm --config=tools/psalm.xml --set-baseline=tools/psalm-baseline.xml", + "phpunit": "vendor/bin/phpunit --configuration=tools/phpunit.xml.dist --cache-result-file=tools/cache/.phpunit.result.cache --colors=always --testsuite=unit", + "phpunit_clover": "vendor/bin/phpunit --configuration=tools/phpunit.xml.dist --cache-result-file=tools/cache/.phpunit.result.cache --coverage-text --coverage-clover build/logs/clover.xml", + "coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --configuration=tools/phpunit.xml.dist --coverage-html=tests/_reports", + "phpcs": "vendor-bin/phpcs/vendor/bin/phpcs --standard=tools/ruleset.xml --extensions=php --cache=tools/cache/.phpcs-cache --colors src tests", + "phpcs_diff": "vendor-bin/phpcs/vendor/bin/phpcs -s --standard=tools/ruleset.xml --extensions=php --cache=tools/cache/.phpcs-cache --colors src tests", + "phpcs_fix": "vendor-bin/phpcs/vendor/bin/phpcbf --standard=tools/ruleset.xml --extensions=php --cache=tools/cache/.phpcs-cache --colors src tests", + "binupdate": "bin/vendorbin update", + "binoutdated": "bin/vendorbin outdated" + } +} diff --git a/docker/Dockerfile/mariadb_ssl b/docker/Dockerfile/mariadb_ssl new file mode 100644 index 0000000..7b8fe36 --- /dev/null +++ b/docker/Dockerfile/mariadb_ssl @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1 +FROM mariadb:latest + +COPY docker/ssl/squirrel.crt /etc/mysql/certs/server.crt +COPY docker/ssl/squirrel.key /etc/mysql/certs/server.key +COPY docker/ssl/DadaismCA.crt /etc/mysql/certs/ca.crt + +RUN chmod 600 /etc/mysql/certs/server.key +RUN chown mysql:mysql /etc/mysql/certs/server.key \ No newline at end of file diff --git a/docker/Dockerfile/mysql_ssl b/docker/Dockerfile/mysql_ssl new file mode 100644 index 0000000..6558e25 --- /dev/null +++ b/docker/Dockerfile/mysql_ssl @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1 +FROM mysql/mysql-server:latest + +COPY docker/ssl/squirrel.crt /etc/mysql/certs/server.crt +COPY docker/ssl/squirrel.key /etc/mysql/certs/server.key +COPY docker/ssl/DadaismCA.crt /etc/mysql/certs/ca.crt + +RUN chmod 600 /etc/mysql/certs/server.key +RUN chown mysql:mysql /etc/mysql/certs/server.key \ No newline at end of file diff --git a/docker/Dockerfile/postgres_ssl b/docker/Dockerfile/postgres_ssl new file mode 100644 index 0000000..97438ec --- /dev/null +++ b/docker/Dockerfile/postgres_ssl @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1 +FROM postgres:latest + +COPY docker/ssl/squirrel.crt /var/lib/postgresql/server.crt +COPY docker/ssl/squirrel.key /var/lib/postgresql/server.key +COPY docker/ssl/DadaismCA.crt /var/lib/postgresql/ca.crt + +RUN chmod 600 /var/lib/postgresql/server.key +RUN chown postgres:postgres /var/lib/postgresql/server.key \ No newline at end of file diff --git a/docker/compose/composer.yml b/docker/compose/composer.yml new file mode 100644 index 0000000..ba420c0 --- /dev/null +++ b/docker/compose/composer.yml @@ -0,0 +1,31 @@ +services: + composer: + image: thecodingmachine/php:8.3-v4-cli + container_name: squirrel_connection_composer + working_dir: /usr/src/app + command: [ "composer", "${COMPOSER_COMMAND}", "--ansi" ] + logging: + driver: "none" + volumes: + - ./.editorconfig:/usr/src/app/.editorconfig + - ./bin:/usr/src/app/bin + - ./composer.json:/usr/src/app/composer.json + - ./composer.lock:/usr/src/app/composer.lock + - ./src:/usr/src/app/src + - ./tests:/usr/src/app/tests + - ./tools:/usr/src/app/tools + - ./vendor:/usr/src/app/vendor + - ./vendor-bin:/usr/src/app/vendor-bin + - "$HOME/.cache/composer:/tmp/composer_cache" + environment: + COMPOSER_CACHE_DIR: "/tmp/composer_cache" + COMPOSER_ROOT_VERSION: 'dev-master' + # Basic config for CLI commands + PHP_INI_ERROR_REPORTING: "E_ALL" + PHP_INI_MEMORY_LIMIT: "1g" + PHP_INI_MAX_EXECUTION_TIME: 3600 + # Enable Opcache + JIT + PHP_INI_OPCACHE__ENABLE_CLI: 1 + PHP_INI_OPCACHE__MEMORY_CONSUMPTION: 256 + PHP_INI_OPCACHE__VALIDATE_TIMESTAMPS: 0 + PHP_INI_JIT_BUFFER_SIZE: "256m" \ No newline at end of file diff --git a/docker/compose/coverage.yml b/docker/compose/coverage.yml new file mode 100644 index 0000000..8576348 --- /dev/null +++ b/docker/compose/coverage.yml @@ -0,0 +1,127 @@ +services: + coverage: + image: thecodingmachine/php:8.3-v4-cli + container_name: squirrel_connection_coverage + tty: true + working_dir: /usr/src/app + # --path-coverage could be added later, it is much too slow currently and only supported by xdebug + command: [ "vendor/bin/phpunit", "--configuration=tools/phpunit.xml.dist", "--colors=always", "--stop-on-defect", "--coverage-html", "tests/_reports"] + volumes: + - ./composer.json:/usr/src/app/composer.json + - ./composer.lock:/usr/src/app/composer.lock + - ./docker/ssl:/usr/src/app/ssl + - ./src:/usr/src/app/src + - ./tests:/usr/src/app/tests + - ./tools:/usr/src/app/tools + - ./vendor:/usr/src/app/vendor + environment: + # We currently use PCOV because it is at least 8x faster + # - 3 seconds compared to 23 seconds (or 5 minutes with path coverage enabled) + PHP_EXTENSION_XDEBUG: 1 + XDEBUG_MODE: coverage + #PHP_EXTENSION_PCOV: 1 + PHP_EXTENSION_APCU: 0 + PHP_EXTENSION_REDIS: 0 + PHP_EXTENSION_SQLITE3: 1 + PHP_EXTENSION_PDO_MYSQL: 1 + PHP_EXTENSION_PDO_PGSQL: 1 + PHP_EXTENSION_PDO_SQLITE: 1 + PHP_INI_MEMORY_LIMIT: 1g + PHP_INI_ERROR_REPORTING: E_ALL + SQUIRREL_CONNECTION_USER: 'user' + SQUIRREL_CONNECTION_PASSWORD: 'password' + SQUIRREL_CONNECTION_ROOT_PASSWORD: 'whatever' + SQUIRREL_CONNECTION_DBNAME: 'shop' + SQUIRREL_CONNECTION_HOST_MYSQL: 'squirrel_connection_mysql' + SQUIRREL_CONNECTION_HOST_MARIADB: 'squirrel_connection_mariadb' + SQUIRREL_CONNECTION_HOST_POSTGRES: 'squirrel_connection_postgres' + SQUIRREL_CONNECTION_SSL_CERT: '/usr/src/app/ssl/squirrel.crt' + SQUIRREL_CONNECTION_SSL_KEY: '/usr/src/app/ssl/squirrel.key' + SQUIRREL_CONNECTION_SSL_CA: '/usr/src/app/ssl/DadaismCA.crt' + COMPOSER_ROOT_VERSION: 'dev-master' + STARTUP_COMMAND_1: composer --no-interaction --no-progress --no-scripts --no-plugins --quiet install + STARTUP_COMMAND_2: rm -rf /usr/src/app/tests/_reports/* + depends_on: + - postgres + - mysql + - mariadb + - postgres_ssl + - mysql_ssl + - mariadb_ssl + + postgres: + image: postgres:latest + container_name: squirrel_connection_postgres + volumes: + - ./docker/sql/postgres_init.sql:/docker-entrypoint-initdb.d/postgres_init.sql + environment: + POSTGRES_DB: 'shop' + POSTGRES_USER: 'user' + POSTGRES_PASSWORD: 'password' + + mysql: + image: mysql/mysql-server:latest + container_name: squirrel_connection_mysql + environment: + MYSQL_ROOT_PASSWORD: 'whatever' + MYSQL_DATABASE: 'shop' + MYSQL_USER: 'user' + MYSQL_PASSWORD: 'password' + + mariadb: + image: mariadb:latest + container_name: squirrel_connection_mariadb + environment: + MARIADB_ROOT_PASSWORD: 'whatever' + MARIADB_DATABASE: 'shop' + MARIADB_USER: 'user' + MARIADB_PASSWORD: 'password' + + postgres_ssl: + build: + context: . + dockerfile: ./docker/Dockerfile/postgres_ssl + container_name: squirrel_connection_postgres_ssl + volumes: + - ./docker/sql/postgres_init.sql:/docker-entrypoint-initdb.d/postgres_init.sql + environment: + POSTGRES_DB: 'shop' + POSTGRES_USER: 'user' + POSTGRES_PASSWORD: 'password' + command: + - --ssl_ca_file=/var/lib/postgresql/ca.crt + - --ssl_cert_file=/var/lib/postgresql/server.crt + - --ssl_key_file=/var/lib/postgresql/server.key + - --ssl=on + + mysql_ssl: + build: + context: . + dockerfile: ./docker/Dockerfile/mysql_ssl + container_name: squirrel_connection_mysql_ssl + environment: + MYSQL_ROOT_PASSWORD: 'whatever' + MYSQL_DATABASE: 'shop' + MYSQL_USER: 'user' + MYSQL_PASSWORD: 'password' + command: + - --ssl-ca=/etc/mysql/certs/ca.crt + - --ssl-cert=/etc/mysql/certs/server.crt + - --ssl-key=/etc/mysql/certs/server.key + - --require-secure-transport=ON + + mariadb_ssl: + build: + context: . + dockerfile: ./docker/Dockerfile/mariadb_ssl + container_name: squirrel_connection_mariadb_ssl + environment: + MARIADB_ROOT_PASSWORD: 'whatever' + MARIADB_DATABASE: 'shop' + MARIADB_USER: 'user' + MARIADB_PASSWORD: 'password' + command: + - --ssl-ca=/etc/mysql/certs/ca.crt + - --ssl-cert=/etc/mysql/certs/server.crt + - --ssl-key=/etc/mysql/certs/server.key + - --require-secure-transport=ON \ No newline at end of file diff --git a/docker/compose/test.yml b/docker/compose/test.yml new file mode 100644 index 0000000..2416aba --- /dev/null +++ b/docker/compose/test.yml @@ -0,0 +1,120 @@ +services: + test: + image: thecodingmachine/php:8.3-v4-cli + container_name: squirrel_connection_test + tty: true + working_dir: /usr/src/app + command: ["vendor/bin/phpunit", "--configuration=tools/phpunit.xml.dist", "--colors=always", "--filter", "Integration"] + volumes: + - ./composer.json:/usr/src/app/composer.json + - ./composer.lock:/usr/src/app/composer.lock + - ./docker/ssl:/usr/src/app/ssl + - ./src:/usr/src/app/src + - ./tests:/usr/src/app/tests + - ./tools:/usr/src/app/tools + - ./vendor:/usr/src/app/vendor + environment: + PHP_EXTENSION_APCU: 0 + PHP_EXTENSION_REDIS: 0 + PHP_EXTENSION_SQLITE3: 1 + PHP_EXTENSION_PDO_MYSQL: 1 + PHP_EXTENSION_PDO_PGSQL: 1 + PHP_EXTENSION_PDO_SQLITE: 1 + PHP_INI_MEMORY_LIMIT: 1g + PHP_INI_ERROR_REPORTING: E_ALL + SQUIRREL_CONNECTION_USER: 'user' + SQUIRREL_CONNECTION_PASSWORD: 'password' + SQUIRREL_CONNECTION_ROOT_PASSWORD: 'whatever' + SQUIRREL_CONNECTION_DBNAME: 'shop' + SQUIRREL_CONNECTION_HOST_MYSQL: 'squirrel_connection_mysql' + SQUIRREL_CONNECTION_HOST_MARIADB: 'squirrel_connection_mariadb' + SQUIRREL_CONNECTION_HOST_POSTGRES: 'squirrel_connection_postgres' + SQUIRREL_CONNECTION_SSL_CERT: '/usr/src/app/ssl/squirrel.crt' + SQUIRREL_CONNECTION_SSL_KEY: '/usr/src/app/ssl/squirrel.key' + SQUIRREL_CONNECTION_SSL_CA: '/usr/src/app/ssl/DadaismCA.crt' + COMPOSER_ROOT_VERSION: 'dev-master' + STARTUP_COMMAND_1: composer --no-interaction --no-progress --no-scripts --no-plugins --quiet install + depends_on: + - postgres + - mysql + - mariadb + - postgres_ssl + - mysql_ssl + - mariadb_ssl + + postgres: + image: postgres:latest + container_name: squirrel_connection_postgres + volumes: + - ./docker/sql/postgres_init.sql:/docker-entrypoint-initdb.d/postgres_init.sql + environment: + POSTGRES_DB: 'shop' + POSTGRES_USER: 'user' + POSTGRES_PASSWORD: 'password' + + mysql: + image: mysql/mysql-server:latest + container_name: squirrel_connection_mysql + environment: + MYSQL_ROOT_PASSWORD: 'whatever' + MYSQL_DATABASE: 'shop' + MYSQL_USER: 'user' + MYSQL_PASSWORD: 'password' + + mariadb: + image: mariadb:latest + container_name: squirrel_connection_mariadb + environment: + MARIADB_ROOT_PASSWORD: 'whatever' + MARIADB_DATABASE: 'shop' + MARIADB_USER: 'user' + MARIADB_PASSWORD: 'password' + + postgres_ssl: + build: + context: . + dockerfile: ./docker/Dockerfile/postgres_ssl + container_name: squirrel_connection_postgres_ssl + volumes: + - ./docker/sql/postgres_init.sql:/docker-entrypoint-initdb.d/postgres_init.sql + environment: + POSTGRES_DB: 'shop' + POSTGRES_USER: 'user' + POSTGRES_PASSWORD: 'password' + command: + - --ssl_ca_file=/var/lib/postgresql/ca.crt + - --ssl_cert_file=/var/lib/postgresql/server.crt + - --ssl_key_file=/var/lib/postgresql/server.key + - --ssl=on + + mysql_ssl: + build: + context: . + dockerfile: ./docker/Dockerfile/mysql_ssl + container_name: squirrel_connection_mysql_ssl + environment: + MYSQL_ROOT_PASSWORD: 'whatever' + MYSQL_DATABASE: 'shop' + MYSQL_USER: 'user' + MYSQL_PASSWORD: 'password' + command: + - --ssl-ca=/etc/mysql/certs/ca.crt + - --ssl-cert=/etc/mysql/certs/server.crt + - --ssl-key=/etc/mysql/certs/server.key + - --require-secure-transport=ON + + mariadb_ssl: + build: + context: . + dockerfile: ./docker/Dockerfile/mariadb_ssl + container_name: squirrel_connection_mariadb_ssl + environment: + MARIADB_ROOT_PASSWORD: 'whatever' + MARIADB_DATABASE: 'shop' + MARIADB_USER: 'user' + MARIADB_PASSWORD: 'password' + command: + - --ssl-ca=/etc/mysql/certs/ca.crt + - --ssl-cert=/etc/mysql/certs/server.crt + - --ssl-key=/etc/mysql/certs/server.key + - --require-secure-transport=ON \ No newline at end of file diff --git a/docker/composer b/docker/composer new file mode 100755 index 0000000..c5689c1 --- /dev/null +++ b/docker/composer @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Get directory of this script +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +COMPOSER_COMMAND="$@" docker compose -f "$DIR/compose/composer.yml" --project-directory "$DIR/.." --project-name=squirrel_connection_composer up --abort-on-container-exit --exit-code-from=composer --no-log-prefix composer 2>&1 \ No newline at end of file diff --git a/docker/coverage b/docker/coverage new file mode 100755 index 0000000..f0559cf --- /dev/null +++ b/docker/coverage @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Get directory of this script +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +# Remove all running docker containers +docker compose -f "$DIR/compose/coverage.yml" --project-directory "$DIR/.." down --volumes --remove-orphans + +# Test SQLite and real live tests with PostgreSQL and MySQL +docker compose -f "$DIR/compose/coverage.yml" --project-directory "$DIR/.." up --build --force-recreate --renew-anon-volumes --remove-orphans --always-recreate-deps --abort-on-container-exit --exit-code-from=coverage coverage + +# Remove all running docker containers +docker compose -f "$DIR/compose/coverage.yml" --project-directory "$DIR/.." down --volumes --remove-orphans \ No newline at end of file diff --git a/docker/pull b/docker/pull new file mode 100755 index 0000000..e5d9675 --- /dev/null +++ b/docker/pull @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Get directory of this script +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +# Pull new docker images in case there were updates +docker-compose -f "$DIR/compose/coverage.yml" --project-directory "$DIR/.." pull +docker-compose -f "$DIR/compose/composer.yml" --project-directory "$DIR/.." pull +docker-compose -f "$DIR/compose/test.yml" --project-directory "$DIR/.." pull \ No newline at end of file diff --git a/docker/sql/postgres_init.sql b/docker/sql/postgres_init.sql new file mode 100644 index 0000000..8670b13 --- /dev/null +++ b/docker/sql/postgres_init.sql @@ -0,0 +1,3 @@ +/* This creates our shop schema and set it as the default for our database shop */ +CREATE SCHEMA shop; +ALTER DATABASE shop SET search_path TO shop; \ No newline at end of file diff --git a/docker/ssl/DadaismCA.crt b/docker/ssl/DadaismCA.crt new file mode 100644 index 0000000..1167682 --- /dev/null +++ b/docker/ssl/DadaismCA.crt @@ -0,0 +1,53 @@ +-----BEGIN CERTIFICATE----- +MIIJZTCCBU2gAwIBAgIJAKU+tZ/mpk9rMA0GCSqGSIb3DQEBCwUAMEkxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMRAwDgYDVQQKDAdEYWRhaXNtMRMw +EQYDVQQDDApEYWRhaXNtIENBMB4XDTE3MDIyMDE2MTMwNFoXDTQ0MDcwODE2MTMw +NFowSTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxEDAOBgNVBAoM +B0RhZGFpc20xEzARBgNVBAMMCkRhZGFpc20gQ0EwggQiMA0GCSqGSIb3DQEBAQUA +A4IEDwAwggQKAoIEAQCdqcxUlj6RY4YJaw5geWgFFrVtT4sgr9jQPgeg6dwGiFL9 +ZMkVnEhK3Ebzm3wC+e4vYDPWtmutRKL7H81PVZW6yX/++bAtsB8ZZ96lW/38hYFD +PTqnJBBDTG9GkDIHz4YBi2Bc/rdc5YpI94DNHOzSt7/eMyg5OOFeM5CGguUX1eFz +Eh9h9YQ1+1x7xP8ds2uPFW2Ek1G8pH3Sklu2iCkR6FbfaFCuWcLdtm+4Gk8H7Xs5 +w88+r28Xc+idMCXNfL1fqiW33/4pV6TTdBRVRDj6GnOLXUgQ55KzAaLMrqQTcx9Z +aIkEmOUjBhIT/BfKp8jlqPl/gLeIff0P3USsMqXGdF6srTol801B9uoJiKuZ4iI2 +sHkNA9OQHmeheWW/j6dNs4+Z1mS6Qguf1T80vjxbSzLZ3kqtSEhHHZcMEW7WuUB/ +j7inwc2OsqKyn2UrC8wKapRYCKbxN4p7tfrD6Mp6CK/XyBjAjKyDd52ZtWUA8XLA +jZv40GxCLQXgzdCYb1LfOZ1C121lZXXwE9revHAvRc/EcFGFGyc2exNJONV2mId4 +exXtqfDEvCxGk3i0DtMcN5usrMBDksY0mgo5Ssk1cJIF0wIiV1hH427UCXkrPv0O +MGQ5qkubkTCiXbzTklkTr6hkA+lP+pz9G/jTkepeMHrftVFbGrAXM+Z4LBdhZg45 +v5o4gZDe5Kw1VqjMim1D9MXqE7Nh2XkXACGEF7uD0g9Y7eY/dVYPQr8IAqhdgRZb +FQrWgxu848P3I2v0CsfXjjIWpjy3yvTS/Ur5Da4EUMSVmRtlNjjHDo96Tj8qMuXu +gPLETuVyF0voc0Htv+FBJpSNOm6W6xb/OotMUeviX0U8BCPuqXHPgL3dLaQaFVwN +61aM+4+bFElDPAZxAuJ61JAJLlrhZFTJlCO19cS/cAPG7jzyKE0Ksihx2rV1JlVV +WB5Q4iIeohqJ6UJL/riWQ1ENP4n4gLiCW9om8JIU2yN4BTosVdFiBOSA0Q9yIKm7 +DouNJ9o2ZGtEYduSahpPEFXGt+Jgt6PH4ASNuwv5m5FmSAXz753VMILNyqTXUSaF +myeVA69lOp3epXzky8AlZfrgZxJr3JZy+sEzqkjXRbqN0XvIdBuTTvH/ALqveihi +cwsXQFtKdne89yGu2a7F8iHKlHsjQ8FnBLXp915FWsxTDomdWu6vv1BiOj7la32R +O6/OKXRegxUB5fOUCHnSAuUegIPJD5Mt61u/nXGeMIlw/ryDEgZnEatkjh6C0ij5 +x3csmv2BIJMCS2t0S3ZpEJChT1KLfeZDvCyoXne38m5KuB6iIxXmepXmWZp0t9Tt +V+du1Rvo1kP0AqJwJBpFyHg7r2Wet6ymJjEPvNllAgMBAAGjUDBOMB0GA1UdDgQW +BBQ7TDIjaRRXz5cj0L7GS6TN30SORDAfBgNVHSMEGDAWgBQ7TDIjaRRXz5cj0L7G +S6TN30SORDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IEAQAaxf3HTCVG ++rdI+j+goFIzamn8whSmeCa0y2b8l/O97Wj8iBxA2iWwvAAjIttDHDEdfFTy0jGv +24wxW0Xq49WC5+aiBCxRkFUHT+8UapKIEHYWDualFj91byRUHCUsn0t3SFBnQ2+J +cqdxrndbK/xXCD5SHXgk7NH36hph6+H6P7PIy2rIz04yo6DO1QeRENa0SE+oN2NY +MhTu/tFugOtzgR2D5802Rewb3bcMuM0p3r/JgLyGmFxEd908WeXAhxhpa1x1nBmc +WHvpbWzWqVfC0E73/iAzB6ha3WdK/brjOMPz2O4hqX0mgDRfb54TPfo7PXG55wM1 +xrEKJDW6XShapQcnmCj6guOj47FmP496g8CixffRbHvZtl8CrZadKvxeojU6Dwnq +bYAO8zVlsn4hLlFXDDiSao/7vTle/VBwTnkhJ/nSgRYdNU2/2osB16zJuRove7vJ +tUygyoTrjzQ3OYkTNrkLKG3WG8Sp8Ik2dGVDrHQEYZP5xpw76Tyt8VEsB5IhWv7m +TaapTzdkrlp1QM02/ikx0S8jpP+mhXxvPJDDjXpINsV9Pm96tfm1FeiqKU1Uf0tR +aFYZU8g/CbZN0gfiDIKrwClYYEnirXbOOTa2bbz3YrWh7leSQB9l08m93960ZCRO +7ZjZjkz9Mkq4+3OiPsXh4SZZNzG1ZKQohRlmtKp3rFau1H3zoTMkYd/uC8CmTtN+ +39tz1mnAw0LM2/OIYtVoWHRY51clI03dq09uTC9xwZc7Jve8xoYs4I/ythCHqHuX +IRronZb6R3Ls3n5zqQ4w0OH4Btklk16VlE99xt3g/1iqc+DleA2yDI4WADfuDZJS +4hKo/nylqG4NVY3xQ1xNlXVFZDUfFc3H0c+7/7mU3Dmbra8K7EKOAXmiZWZdpARB +7ihJOg8hPrxA+o6wJtkPhtvybs/v9rXptMXgN1YIU4SWtlBZth2QTFjFiOpyL9LF +izKbCHaWM0d4NHXaU9W/iMLSXEMfu8UGugFEx7OkT/t22vHrrHxhSw8lEmNc+cYW +Zca3SCXd0nFhKOC/LSkQHdtE20BJ3KxWPwcQ+6Z9fMVJVDQatd/V3bUplv8R6vms +uzXrq2OeaorgjUsw38Gywfa0WwBG/wSchbqbMW1c5i+JHi76DWo3cNzAYCcW3ffj +2O7lV8rxXyDiSP9ooofOM2XU2ejdlCKVKmy+jiim0A4wHVoOGSJm5lafGpEPMSmz +Fijk+cb7roQ8yu7vgHYoQS7VRF4/ZcyttUC/0b79pTb2liTUbsl2yyE43xcV0biO +s9C2gt7mPxHfAxjR8qrV52pI7XF5fi18tX6xQ4xopxSlzucPQbZRrSPdbO/WBLuG +JoupoI/ITbbY +-----END CERTIFICATE----- diff --git a/docker/ssl/squirrel.crt b/docker/ssl/squirrel.crt new file mode 100644 index 0000000..e3a11b7 --- /dev/null +++ b/docker/ssl/squirrel.crt @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- diff --git a/docker/ssl/squirrel.key b/docker/ssl/squirrel.key new file mode 100644 index 0000000..26a4c37 --- /dev/null +++ b/docker/ssl/squirrel.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +kjxr/scbUSMv54obQR2M26yubc3ps2M= +-----END PRIVATE KEY----- diff --git a/docker/test b/docker/test new file mode 100755 index 0000000..ff745b4 --- /dev/null +++ b/docker/test @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Get directory of this script +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +# Remove all running docker containers +docker compose -f "$DIR/compose/test.yml" --project-directory "$DIR/.." down --volumes --remove-orphans + +# Test SQLite and real live tests with PostgreSQL and MySQL +docker compose -f "$DIR/compose/test.yml" --project-directory "$DIR/.." up --build --force-recreate --renew-anon-volumes --remove-orphans --always-recreate-deps --abort-on-container-exit --exit-code-from=test test + +# Remove all running docker containers +#docker compose -f "$DIR/compose/test.yml" --project-directory "$DIR/.." down --volumes --remove-orphans \ No newline at end of file diff --git a/src/Config/Mysql.php b/src/Config/Mysql.php new file mode 100644 index 0000000..a34ddbe --- /dev/null +++ b/src/Config/Mysql.php @@ -0,0 +1,20 @@ + $values */ + public function executeQuery(ConnectionQueryInterface $query, array $values = []): void; + + /** @param array $values */ + public function prepareAndExecuteQuery(string $query, array $values = []): ConnectionQueryInterface; + + /** @return array|null */ + public function fetchOne(ConnectionQueryInterface $query): ?array; + + /** @return list> */ + public function fetchAll(ConnectionQueryInterface $query): array; + + public function freeResults(ConnectionQueryInterface $query): void; + + public function rowCount(ConnectionQueryInterface $query): int; + + public function lastInsertId(): string; + + public function reconnect(): void; + + public function quoteIdentifier(string $identifier): string; +} diff --git a/src/ConnectionQueryInterface.php b/src/ConnectionQueryInterface.php new file mode 100644 index 0000000..d0bca30 --- /dev/null +++ b/src/ConnectionQueryInterface.php @@ -0,0 +1,9 @@ +query !== null) { + $message = 'An exception occurred while executing a query: ' . $driverException->getMessage(); + } else { + $message = 'An exception occurred in the driver: ' . $driverException->getMessage(); + } + + parent::__construct( + message: $message, + code: 1337, + previous: $driverException, + ); + } + + public function getQuery(): ?string + { + return $this->query; + } + + public function getSqlState(): string + { + return $this->sqlState; + } +} diff --git a/src/Exception/ForeignKeyConstraintViolationException.php b/src/Exception/ForeignKeyConstraintViolationException.php new file mode 100644 index 0000000..da1dc5e --- /dev/null +++ b/src/Exception/ForeignKeyConstraintViolationException.php @@ -0,0 +1,12 @@ +errorInfo !== null) { + [$sqlState, $code] = $exception->errorInfo; + } else { + \trigger_error('No errorInfo available for PDOException', E_USER_WARNING); + + $code = $exception->getCode(); + $sqlState = ''; + } + + return match ($code) { + 1008, + 1049 => new DatabaseDoesNotExist($exception, $sqlState, $query), + 1213 => new DeadlockException($exception, $sqlState, $query), + 1205 => new LockWaitTimeoutException($exception, $sqlState, $query), + 1050 => new TableExistsException($exception, $sqlState, $query), + 1051, + 1146 => new TableNotFoundException($exception, $sqlState, $query), + 1216, + 1217, + 1451, + 1452, + 1701 => new ForeignKeyConstraintViolationException($exception, $sqlState, $query), + 1062, + 1557, + 1569, + 1586 => new UniqueConstraintViolationException($exception, $sqlState, $query), + 1054, + 1166, + 1611 => new InvalidFieldNameException($exception, $sqlState, $query), + 1052, + 1060, + 1110 => new NonUniqueFieldNameException($exception, $sqlState, $query), + 1064, + 1149, + 1287, + 1341, + 1342, + 1343, + 1344, + 1382, + 1479, + 1541, + 1554, + 1626 => new SyntaxErrorException($exception, $sqlState, $query), + 1044, + 1045, + 1129, + 1130, + 1133 => new AuthorizationException($exception, $sqlState, $query), + 1046, + 1095, + 1142, + 1143, + 1227, + 1370, + 1429, + 2002, + 2005, + 2054, + 3159 => new ConnectionException($exception, $sqlState, $query), + 2006 => new ConnectionLost($exception, $sqlState, $query), + 1048, + 1121, + 1138, + 1171, + 1252, + 1263, + 1364, + 1566 => new NotNullConstraintViolationException($exception, $sqlState, $query), + default => new DriverException($exception, $sqlState, $query), + }; + } +} diff --git a/src/ExceptionConverter/PostgreSQL/ExceptionConverter.php b/src/ExceptionConverter/PostgreSQL/ExceptionConverter.php new file mode 100644 index 0000000..224aa03 --- /dev/null +++ b/src/ExceptionConverter/PostgreSQL/ExceptionConverter.php @@ -0,0 +1,57 @@ +errorInfo !== null) { + [$sqlState, $code] = $exception->errorInfo; + } else { + \trigger_error('No errorInfo available for PDOException', E_USER_WARNING); + $sqlState = ''; + } + + if ($sqlState === '0A000' && str_contains($exception->getMessage(), 'truncate')) { + return new ForeignKeyConstraintViolationException($exception, $sqlState, $query); + } + + return match ($sqlState) { + '40001', '40P01' => new DeadlockException($exception, $sqlState, $query), + '23502' => new NotNullConstraintViolationException($exception, $sqlState, $query), + '23503' => new ForeignKeyConstraintViolationException($exception, $sqlState, $query), + '23505' => new UniqueConstraintViolationException($exception, $sqlState, $query), + '3D000' => new DatabaseDoesNotExist($exception, $sqlState, $query), + '3F000' => new SchemaDoesNotExist($exception, $sqlState, $query), + '42601' => new SyntaxErrorException($exception, $sqlState, $query), + '42702' => new NonUniqueFieldNameException($exception, $sqlState, $query), + '42703' => new InvalidFieldNameException($exception, $sqlState, $query), + '42P01' => new TableNotFoundException($exception, $sqlState, $query), + '42P07' => new TableExistsException($exception, $sqlState, $query), + '08006' => new ConnectionException($exception, $sqlState, $query), + '55000' => new NoIdentityValue($exception, $sqlState, $query), + default => new DriverException($exception, $sqlState, $query), + }; + } +} diff --git a/src/ExceptionConverter/SQLite/ExceptionConverter.php b/src/ExceptionConverter/SQLite/ExceptionConverter.php new file mode 100644 index 0000000..926ee16 --- /dev/null +++ b/src/ExceptionConverter/SQLite/ExceptionConverter.php @@ -0,0 +1,88 @@ +errorInfo !== null) { + [$sqlState, $code] = $exception->errorInfo; + } else { + \trigger_error('No errorInfo available for PDOException', E_USER_WARNING); + $sqlState = ''; + } + + if (str_contains($exception->getMessage(), 'database is locked')) { + return new LockWaitTimeoutException($exception, $sqlState, $query); + } + + if ( + str_contains($exception->getMessage(), 'must be unique') || + str_contains($exception->getMessage(), 'is not unique') || + str_contains($exception->getMessage(), 'are not unique') || + str_contains($exception->getMessage(), 'UNIQUE constraint failed') + ) { + return new UniqueConstraintViolationException($exception, $sqlState, $query); + } + + if ( + str_contains($exception->getMessage(), 'may not be NULL') || + str_contains($exception->getMessage(), 'NOT NULL constraint failed') + ) { + return new NotNullConstraintViolationException($exception, $sqlState, $query); + } + + if (str_contains($exception->getMessage(), 'no such table:')) { + return new TableNotFoundException($exception, $sqlState, $query); + } + + if (str_contains($exception->getMessage(), 'already exists')) { + return new TableExistsException($exception, $sqlState, $query); + } + + if (str_contains($exception->getMessage(), 'has no column named')) { + return new InvalidFieldNameException($exception, $sqlState, $query); + } + + if (str_contains($exception->getMessage(), 'ambiguous column name')) { + return new NonUniqueFieldNameException($exception, $sqlState, $query); + } + + if (str_contains($exception->getMessage(), 'syntax error')) { + return new SyntaxErrorException($exception, $sqlState, $query); + } + + if (str_contains($exception->getMessage(), 'attempt to write a readonly database')) { + return new ReadOnlyException($exception, $sqlState, $query); + } + + if (str_contains($exception->getMessage(), 'unable to open database file')) { + return new ConnectionException($exception, $sqlState, $query); + } + + if (str_contains($exception->getMessage(), 'FOREIGN KEY constraint failed')) { + return new ForeignKeyConstraintViolationException($exception, $sqlState, $query); + } + + return new DriverException($exception, $sqlState, $query); + } +} diff --git a/src/LargeObject.php b/src/LargeObject.php new file mode 100644 index 0000000..858e512 --- /dev/null +++ b/src/LargeObject.php @@ -0,0 +1,38 @@ +data; + } + + /** + * @return resource + */ + public function getStream() + { + $fp = \fopen('php://temp', 'rb+'); + + // @codeCoverageIgnoreStart + if ($fp === false) { + throw new \UnexpectedValueException('fopen with php://temp was surprisingly unsuccessful'); + } + // @codeCoverageIgnoreEnd + + \fwrite($fp, $this->data); + \fseek($fp, 0); + + return $fp; + } +} diff --git a/src/PDO/ConnectionPDO.php b/src/PDO/ConnectionPDO.php new file mode 100644 index 0000000..27e56dd --- /dev/null +++ b/src/PDO/ConnectionPDO.php @@ -0,0 +1,272 @@ + */ + private readonly array $options; + private readonly string $dsn; + private readonly ExceptionConverterInterface $exceptionConverter; + + public function __construct( + private readonly Mysql|Pgsql|Sqlite $config, + ) { + $options = []; + $options[PDO::ATTR_ERRMODE] = PDO::ERRMODE_EXCEPTION; + $options[PDO::ATTR_EMULATE_PREPARES] = false; + $options[PDO::ATTR_AUTOCOMMIT] = true; + + if ($this->config instanceof Mysql) { + $options[PDO::MYSQL_ATTR_MULTI_STATEMENTS] = false; + $options[PDO::MYSQL_ATTR_FOUND_ROWS] = true; + + if ($this->config->ssl !== null) { + $options[PDO::MYSQL_ATTR_SSL_CA] = $this->config->ssl->rootCertificatePath; + $options[PDO::MYSQL_ATTR_SSL_KEY] = $this->config->ssl->privateKeyPath; + $options[PDO::MYSQL_ATTR_SSL_CERT] = $this->config->ssl->certificatePath; + $options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false; + } + } + + if ($this->config instanceof Pgsql) { + $options[PDO::PGSQL_ATTR_DISABLE_PREPARES] = false; + } + + $this->options = $options; + + $this->exceptionConverter = match ($this->config::class) { + Mysql::class => new \Squirrel\Connection\ExceptionConverter\MySQL\ExceptionConverter(), + Pgsql::class => new \Squirrel\Connection\ExceptionConverter\PostgreSQL\ExceptionConverter(), + Sqlite::class => new \Squirrel\Connection\ExceptionConverter\SQLite\ExceptionConverter(), + }; + + if ($this->config instanceof Pgsql) { + $this->dsn = 'pgsql:host=' . $this->config->host . ';port=' . $this->config->port . ( $this->config->dbname !== null ? ';dbname=' . $this->config->dbname : '' ) . ';options=\'--client_encoding=' . $this->config->charset . '\'' . ( $this->config->ssl !== null ? ';sslmode=verify-ca;sslcert=' . $this->config->ssl->certificatePath . ';sslkey=' . $this->config->ssl->privateKeyPath . ';sslrootcert=' . $this->config->ssl->rootCertificatePath : '' ); + } elseif ($this->config instanceof Sqlite) { + $this->dsn = 'sqlite:' . ( $this->config->path !== null ? $this->config->path : ':memory:' ); + } else { + $this->dsn = 'mysql:host=' . $this->config->host . ';port=' . $this->config->port . ( $this->config->dbname !== null ? ';dbname=' . $this->config->dbname : '' ) . ';charset=' . $this->config->charset; + } + + + + $this->connect(); + } + + private function connect(): void + { + try { + if ($this->config instanceof Sqlite) { + $this->pdo = new \PDO( + dsn: $this->dsn, + options: $this->options, + ); + } else { + $this->pdo = new \PDO( + dsn: $this->dsn, + username: $this->config->user, + password: $this->config->password, + options: $this->options, + ); + } + } catch (PDOException $e) { + throw $this->exceptionConverter->convert($e); + } + } + + public function beginTransaction(): void + { + try { + $this->pdo->beginTransaction(); + } catch (PDOException $e) { + throw $this->exceptionConverter->convert($e); + } + } + + public function commitTransaction(): void + { + try { + $this->pdo->commit(); + } catch (PDOException $e) { + throw $this->exceptionConverter->convert($e); + } + } + + public function rollbackTransaction(): void + { + try { + $this->pdo->rollBack(); + } catch (PDOException $e) { + throw $this->exceptionConverter->convert($e); + } + } + + public function prepareQuery(string $query): ConnectionQueryPDO + { + try { + return new ConnectionQueryPDO($this->pdo->prepare($query), $query); + } catch (PDOException $e) { + throw $this->exceptionConverter->convert($e, $query); + } + } + + public function executeQuery(ConnectionQueryInterface $query, array $values = []): void + { + $this->validateConnectionQueryType($query); + + try { + $statement = $query->getPDOStatement(); + + $paramCounter = 1; + foreach ($values as $columnValue) { + if (\is_bool($columnValue)) { + $columnValue = \intval($columnValue); + } + + $statement->bindValue( + $paramCounter++, + ($columnValue instanceof LargeObject) ? $columnValue->getStream() : $columnValue, + ($columnValue instanceof LargeObject) ? \PDO::PARAM_LOB : \PDO::PARAM_STR, + ); + } + + $statement->execute(); + } catch (PDOException $e) { + throw $this->exceptionConverter->convert($e, $query->getQuery()); + } + } + + public function prepareAndExecuteQuery(string $query, array $values = []): ConnectionQueryInterface + { + $query = $this->prepareQuery($query); + $this->executeQuery($query, $values); + + return $query; + } + + public function fetchOne(ConnectionQueryInterface $query): ?array + { + $this->validateConnectionQueryType($query); + + try { + $result = $query->getPDOStatement()->fetch(PDO::FETCH_ASSOC); + + if ($result === false) { + return null; + } + + return $this->resolveStreamsinEntry($result); + } catch (PDOException $e) { + throw $this->exceptionConverter->convert($e, $query->getQuery()); + } + } + + public function fetchAll(ConnectionQueryInterface $query): array + { + $this->validateConnectionQueryType($query); + + try { + $results = $query->getPDOStatement()->fetchAll(PDO::FETCH_ASSOC); + + foreach ($results as $key => $result) { + $results[$key] = $this->resolveStreamsinEntry($result); + } + + return $results; + } catch (PDOException $e) { + throw $this->exceptionConverter->convert($e, $query->getQuery()); + } + } + + private function resolveStreamsinEntry(array $entry): array + { + foreach ($entry as $key => $value) { + if (\is_resource($value)) { + $entry[$key] = \stream_get_contents($value); + } + } + + return $entry; + } + + public function freeResults(ConnectionQueryInterface $query): void + { + $this->validateConnectionQueryType($query); + + try { + $query->getPDOStatement()->closeCursor(); + } catch (PDOException $e) { + throw $this->exceptionConverter->convert($e, $query->getQuery()); + } + } + + public function rowCount(ConnectionQueryInterface $query): int + { + $this->validateConnectionQueryType($query); + + try { + return $query->getPDOStatement()->rowCount(); + } catch (PDOException $e) { + throw $this->exceptionConverter->convert($e, $query->getQuery()); + } + } + + public function lastInsertId(): string + { + try { + return \strval($this->pdo->lastInsertId()); + } catch (PDOException $e) { + throw $this->exceptionConverter->convert($e); + } + } + + public function reconnect(): void + { + $this->connect(); + } + + public function quoteIdentifier(string $identifier): string + { + if (\str_contains($identifier, '.')) { + return \implode('.', \array_map([$this, 'quoteSingleIdentifier'], \explode('.', $identifier))); + } + + return $this->quoteSingleIdentifier($identifier); + } + + private function quoteSingleIdentifier(string $identifier): string + { + $quoteCharacter = $this->getQuoteCharacter(); + + return $quoteCharacter . \str_replace($quoteCharacter, $quoteCharacter . $quoteCharacter, $identifier) . $quoteCharacter; + } + + private function getQuoteCharacter(): string + { + return match ($this->config::class) { + Mysql::class => '`', + default => '"', + }; + } + + /** @phpstan-assert ConnectionQueryPDO $query */ + private function validateConnectionQueryType(ConnectionQueryInterface $query): void + { + if (!$query instanceof ConnectionQueryPDO) { + throw new InvalidArgumentException('Invalid query class provided'); + } + } +} diff --git a/src/PDO/ConnectionQueryPDO.php b/src/PDO/ConnectionQueryPDO.php new file mode 100644 index 0000000..301815d --- /dev/null +++ b/src/PDO/ConnectionQueryPDO.php @@ -0,0 +1,25 @@ +statement; + } + + public function getQuery(): string + { + return $this->query; + } +} diff --git a/tests/Integration/AbstractCommonTests.php b/tests/Integration/AbstractCommonTests.php new file mode 100644 index 0000000..3706616 --- /dev/null +++ b/tests/Integration/AbstractCommonTests.php @@ -0,0 +1,602 @@ +prepareAndExecuteQuery('DROP TABLE IF EXISTS account'); + $db->prepareAndExecuteQuery(static::createAccountTableQuery()); + + return $db; + } + + /** @param string[] $identifiers */ + protected static function quoteIdentifiers(array $identifiers): array + { + return \array_map(self::$db->quoteIdentifier(...), $identifiers); + } + + protected static function generatePlaceholders(array $values): array + { + return \array_map(fn($v) => '?', $values); + } + + protected static function prepareSelectFromAccount(array $where): ConnectionQueryInterface + { + return self::$db->prepareQuery( + 'SELECT * FROM ' . + self::$db->quoteIdentifier('account') . + ' WHERE ' . + \implode(' AND ', \array_map(fn (string $w): string => $w . ' = ?', self::quoteIdentifiers(\array_keys($where)))), + ); + } + + protected static function prepareInsertIntoAccount(array $accountData): ConnectionQueryInterface + { + return self::$db->prepareQuery( + 'INSERT INTO ' . + self::$db->quoteIdentifier('account') . + ' (' . + \implode(', ', self::quoteIdentifiers(\array_keys($accountData))) . + ') VALUES (' . + \implode(', ', self::generatePlaceholders($accountData)) . + ')', + ); + } + + protected static function prepareUpdateAccount(array $update, array $where): ConnectionQueryInterface + { + return self::$db->prepareQuery( + 'UPDATE ' . + self::$db->quoteIdentifier('account') . + ' SET ' . + \implode(', ', \array_map(fn (string $w): string => $w . ' = ?', self::quoteIdentifiers(\array_keys($update)))) . + ' WHERE ' . + \implode(', ', \array_map(fn (string $w): string => $w . ' = ?', self::quoteIdentifiers(\array_keys($where)))), + ); + } + + public static function setUpBeforeClass(): void + { + static::waitUntilThisDatabaseReady(); + } + + protected function setUp(): void + { + if (!static::shouldExecuteTests()) { + $this->markTestSkipped('Not in an environment with correct database'); + } + } + + public function testInsert(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $accountData = [ + 'username' => 'Mary', + 'password' => 'secret', + 'email' => 'mary@mary.com', + 'birthdate' => '1984-05-08', + 'balance' => 105.2, + 'description' => 'I am dynamic and nice!', + 'picture' => new LargeObject(\hex2bin(\md5('dadaism'))), + 'active' => true, + 'create_date' => 48674935, + ]; + + $insertQuery = self::prepareInsertIntoAccount($accountData); + + self::$db->executeQuery($insertQuery, $accountData); + + $userId = self::$db->lastInsertId(); + + $this->assertSame('1', $userId); + + $where = ['user_id' => 1]; + + $selectQuery = self::prepareSelectFromAccount($where); + self::$db->executeQuery($selectQuery, $where); + + $insertedData = self::$db->fetchOne($selectQuery); + + if ($insertedData === null) { + throw new \LogicException('Inserted row not found'); + } + + $accountData['phone'] = null; + $accountData['user_id'] = Coerce::toInt($userId); + + $this->compareDataArrays($accountData, $insertedData); + } + + public function testUpdate(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $this->initializeDataWithDefaultTwoEntries(); + + $picture = new LargeObject(\hex2bin(\md5('dadaism'))); + + $accountData = [ + 'username' => 'John', + 'password' => 'othersecret', + 'email' => 'supi@mary.com', + 'birthdate' => '1984-05-08', + 'balance' => 800, + 'description' => 'I am dynamicer and nicer!', + 'picture' => $picture, + 'active' => true, + 'create_date' => 486749356, + ]; + + $where = ['user_id' => 2]; + + // UPDATE where changes are made and we should get one affected row + $updateQuery = self::prepareUpdateAccount($accountData, $where); + + self::$db->executeQuery($updateQuery, \array_merge(\array_values($accountData), \array_values($where))); + + $rowsAffected = self::$db->rowCount($updateQuery); + + $this->assertEquals(1, $rowsAffected); + + $selectQuery = self::prepareSelectFromAccount($where); + self::$db->executeQuery($selectQuery, $where); + + $insertedData = self::$db->fetchOne($selectQuery); + + if ($insertedData === null) { + throw new \LogicException('Inserted row not found'); + } + + $comparableAccountData = $accountData; + $comparableAccountData['phone'] = null; + $comparableAccountData['user_id'] = 2; + + $this->compareDataArrays($comparableAccountData, $insertedData); + + $accountData['picture'] = $picture; + + // UPDATE where we do not change anything and test if we still get 1 as $rowsAffected + self::$db->executeQuery($updateQuery, \array_merge(\array_values($accountData), \array_values($where))); + + $rowsAffected = self::$db->rowCount($updateQuery); + + $this->assertEquals(1, $rowsAffected); + } + + public function testCount(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $countQuery = self::$db->prepareQuery('SELECT COUNT(*) AS num FROM ' . self::$db->quoteIdentifier('account')); + self::$db->executeQuery($countQuery); + + $rowData = self::$db->fetchOne($countQuery); + + if ($rowData === null) { + throw new \LogicException('Expected row did not exist'); + } + + $rowData['num'] = \intval($rowData['num']); + + $this->assertEquals(['num' => 0], $rowData); + + $this->initializeDataWithDefaultTwoEntries(); + + self::$db->executeQuery($countQuery); + + $rowData = self::$db->fetchOne($countQuery); + + if ($rowData === null) { + throw new \LogicException('Expected row did not exist'); + } + + $rowData['num'] = \intval($rowData['num']); + + $this->assertEquals(['num' => 2], $rowData); + } + + public function testSelect(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $selectQuery = self::$db->prepareQuery('SELECT * FROM ' . self::$db->quoteIdentifier('account')); + self::$db->executeQuery($selectQuery); + + $rowData = self::$db->fetchOne($selectQuery); + + $this->assertEquals(null, $rowData); + + $this->initializeDataWithDefaultTwoEntries(); + + $accountData = [ + 'user_id' => 2, + 'username' => 'John', + 'password' => 'othersecret', + 'email' => 'supi@mary.com', + 'phone' => null, + 'birthdate' => '1984-05-08', + 'balance' => 800, + 'description' => 'I am dynamicer and nicer!', + 'picture' => \hex2bin(\md5('dadaism')), + 'active' => true, + 'create_date' => 486749356, + ]; + + $selectQuery = self::$db->prepareQuery('SELECT * FROM ' . self::$db->quoteIdentifier('account') . ' WHERE user_id = ?'); + self::$db->executeQuery($selectQuery, ['user_id' => 2]); + + $rowData = self::$db->fetchOne($selectQuery); + + if ($rowData === null) { + throw new \LogicException('Expected row did not exist'); + } + + $rowData['user_id'] = Coerce::toInt($rowData['user_id']); + $rowData['active'] = Coerce::toBool($rowData['active']); + $rowData['create_date'] = Coerce::toInt($rowData['create_date']); + $rowData['balance'] = \round(Coerce::toFloat($rowData['balance']), 2); + + $this->assertEquals($accountData, $rowData); + } + + public function testSelectFetchAll(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $this->initializeDataWithDefaultTwoEntries(); + + $selectQuery = self::$db->prepareQuery('SELECT * FROM ' . self::$db->quoteIdentifier('account')); + self::$db->executeQuery($selectQuery); + + $rows = self::$db->fetchAll($selectQuery); + + $this->assertSame(2, \count($rows)); + + $defaultRows = $this->getDefaultTwoEntries(); + + foreach ($rows as $key => $row) { + $defaultRows[$key]['phone'] = null; + $defaultRows[$key]['user_id'] = $row['user_id']; + + $this->compareDataArrays($defaultRows[$key], $row); + } + + self::$db->freeResults($selectQuery); + } + + public function testTransactionSelectAndUpdate(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $this->initializeDataWithDefaultTwoEntries(); + + self::$db->beginTransaction(); + + $selectQuery = self::prepareSelectFromAccount(['user_id' => 2]); + self::$db->executeQuery($selectQuery, ['user_id' => 2]); + + $rowData = self::$db->fetchOne($selectQuery); + + $this->assertEquals(true, $rowData['active'] ?? false); + + $updateQuery = self::prepareUpdateAccount(['active' => false], ['user_id' => 2]); + self::$db->executeQuery($updateQuery, ['active' => false, 'user_id' => 2]); + + self::$db->commitTransaction(); + + self::$db->executeQuery($selectQuery, ['user_id' => 2]); + + $rowData = self::$db->fetchOne($selectQuery); + + $this->assertEquals(false, $rowData['active'] ?? true); + } + + public function testTransactionWithRollback(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + self::$db->beginTransaction(); + + $this->initializeDataWithDefaultTwoEntries(); + + self::$db->rollbackTransaction(); + + $selectQuery = self::$db->prepareQuery('SELECT * FROM ' . self::$db->quoteIdentifier('account')); + self::$db->executeQuery($selectQuery); + + $rows = self::$db->fetchAll($selectQuery); + + $this->assertSame(0, \count($rows)); + } + + public function testDelete(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $this->initializeDataWithDefaultTwoEntries(); + + $deleteQuery = self::$db->prepareQuery('DELETE FROM ' . self::$db->quoteIdentifier('account') . ' WHERE user_id = ?'); + self::$db->executeQuery($deleteQuery, ['user_id' => 2]); + + $rowsAffected = self::$db->rowCount($deleteQuery); + + $this->assertEquals(1, $rowsAffected); + + $selectQuery = self::prepareSelectFromAccount(['user_id' => 2]); + self::$db->executeQuery($selectQuery, ['user_id' => 2]); + + $rowData = self::$db->fetchOne($selectQuery); + + $this->assertEquals(null, $rowData); + + self::$db->executeQuery($deleteQuery, ['user_id' => 2]); + + $rowsAffected = self::$db->rowCount($deleteQuery); + + $this->assertEquals(0, $rowsAffected); + } + + public function testInsertInvalidFieldnameError(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $accountData = [ + 'username' => 'Mary', + 'password' => 'secret', + 'email' => 'mary@mary.com', + 'birthdate' => '1984-05-08', + 'balance' => 105.2, + 'description' => 'I am dynamic and nice!', + 'picture' => new LargeObject(\hex2bin(\md5('dadaism'))), + 'active' => true, + 'create_date' => 48674935, + 'missing' => 5, + ]; + + try { + $insertQuery = self::prepareInsertIntoAccount($accountData); + self::$db->executeQuery($insertQuery, $accountData); + + $this->fail('No exception was thrown'); + } catch (DriverException $e) { + $this->assertSame(InvalidFieldNameException::class, $e::class); + } + } + + public function testInsertNullInNotNullFieldError(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $accountData = [ + 'username' => null, + 'password' => 'secret', + 'email' => 'mary@mary.com', + 'birthdate' => '1984-05-08', + 'balance' => 105.2, + 'description' => 'I am dynamic and nice!', + 'picture' => new LargeObject(\hex2bin(\md5('dadaism'))), + 'active' => true, + 'create_date' => 48674935, + ]; + + try { + $insertQuery = self::prepareInsertIntoAccount($accountData); + self::$db->executeQuery($insertQuery, $accountData); + + $this->fail('No exception was thrown'); + } catch (DriverException $e) { + $this->assertSame(NotNullConstraintViolationException::class, $e::class); + } + } + + public function testInsertDuplicateError(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + self::initializeDataWithDefaultTwoEntries(); + + $accountData = [ + 'user_id' => 1, + 'username' => 'Mary', + 'password' => 'secret', + 'email' => 'mary@mary.com', + 'birthdate' => '1984-05-08', + 'balance' => 105.2, + 'description' => 'I am dynamic and nice!', + 'picture' => new LargeObject(\hex2bin(\md5('dadaism'))), + 'active' => true, + 'create_date' => 48674935, + ]; + + $insertQuery = self::prepareInsertIntoAccount($accountData); + + try { + self::$db->executeQuery($insertQuery, $accountData); + + $this->fail('No exception was thrown'); + } catch (DriverException $e) { + $this->assertSame(UniqueConstraintViolationException::class, $e::class); + } + } + + public function testInsertInNonexistentTableError(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $query = 'INSERT INTO nonexistent (ladida) VALUES (?)'; + + try { + $insertQuery = self::$db->prepareQuery($query); + + self::$db->executeQuery($insertQuery, ['lulu']); + + $this->fail('No exception was thrown'); + } catch (DriverException $e) { + $this->assertSame(TableNotFoundException::class, $e::class); + $this->assertSame($query, $e->getQuery()); + } + } + + public function testSyntaxError(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $query = 'nonsense'; + + try { + $insertQuery = self::$db->prepareQuery($query); + + self::$db->executeQuery($insertQuery, ['lulu']); + + $this->fail('No exception was thrown'); + } catch (DriverException $e) { + $this->assertSame(SyntaxErrorException::class, $e::class); + $this->assertSame($query, $e->getQuery()); + } + } + + public function testNonUniqueFieldNameError(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + try { + $insertQuery = self::$db->prepareQuery('SELECT * FROM account a, account b WHERE user_id = ?'); + + self::$db->executeQuery($insertQuery, [1]); + + $this->fail('No exception was thrown'); + } catch (DriverException $e) { + $this->assertSame(NonUniqueFieldNameException::class, $e::class); + } + } + + public function testDuplicateTableError(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + try { + self::$db->prepareAndExecuteQuery(static::createAccountTableQuery()); + + $this->fail('No exception was thrown'); + } catch (DriverException $e) { + $this->assertSame(TableExistsException::class, $e::class); + } + } + + public function testReconnect(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + self::$db->reconnect(); + + $this->assertTrue(true); + } + + protected function getDefaultTwoEntries(): array + { + return [ + [ + 'username' => 'Mary', + 'password' => 'secret', + 'email' => 'mary@mary.com', + 'birthdate' => '1984-05-08', + 'balance' => 105.20, + 'description' => 'I am dynamic and nice!', + 'picture' => new LargeObject(\hex2bin(\md5('dadaism'))), + 'active' => true, + 'create_date' => 48674935, + ], + [ + 'username' => 'John', + 'password' => 'othersecret', + 'email' => 'supi@mary.com', + 'birthdate' => '1984-05-08', + 'balance' => 800.0, + 'description' => 'I am dynamicer and nicer!', + 'picture' => new LargeObject(\hex2bin(\md5('dadaism'))), + 'active' => true, + 'create_date' => 486749356, + ], + ]; + } + + protected function initializeDataWithDefaultTwoEntries(): void + { + foreach ($this->getDefaultTwoEntries() as $accountData) { + $insertQuery ??= self::prepareInsertIntoAccount($accountData); + + self::$db->executeQuery($insertQuery, $accountData); + } + } + + protected function compareDataArrays(array $expected, array $actual): void + { + $this->assertCount(\count($expected), $actual); + + $this->compareDataArraysWithoutCount($expected, $actual); + } + + protected function compareDataArraysWithoutCount(array $expected, array $actual): void + { + foreach ($expected as $fieldName => $value) { + if ($value instanceof LargeObject) { + $value = $value->getString(); + } + + if (\is_int($value)) { + $this->assertSame($value, Coerce::toInt($actual[$fieldName])); + } elseif (\is_float($value)) { + $this->assertSame($value, Coerce::toFloat($actual[$fieldName])); + } elseif (\is_bool($value)) { + $this->assertSame($value, Coerce::toBool($actual[$fieldName])); + } else { + $this->assertSame($value, $actual[$fieldName]); + } + } + } +} diff --git a/tests/Integration/Features/NonSecureConnectionTestTrait.php b/tests/Integration/Features/NonSecureConnectionTestTrait.php new file mode 100644 index 0000000..2198768 --- /dev/null +++ b/tests/Integration/Features/NonSecureConnectionTestTrait.php @@ -0,0 +1,23 @@ +fail('No exception was thrown'); + } catch (DriverException $e) { + $this->assertSame(ConnectionException::class, $e::class); + } + } +} diff --git a/tests/Integration/Features/SchemaIdentifierTestsTrait.php b/tests/Integration/Features/SchemaIdentifierTestsTrait.php new file mode 100644 index 0000000..4fccc83 --- /dev/null +++ b/tests/Integration/Features/SchemaIdentifierTestsTrait.php @@ -0,0 +1,76 @@ +initializeDataWithDefaultTwoEntries(); + + $selectQuery = self::$db->prepareQuery( + 'SELECT * FROM ' . + self::$db->quoteIdentifier('shop.account') . + ' WHERE 1=1', + ); + + self::$db->executeQuery($selectQuery); + + $rows = self::$db->fetchAll($selectQuery); + + $this->assertSame(2, \count($rows)); + + $defaultRows = $this->getDefaultTwoEntries(); + + foreach ($rows as $key => $row) { + $this->compareDataArraysWithoutCount($defaultRows[$key], $row); + } + + self::$db->freeResults($selectQuery); + } + + public function testInsertWithDatabase(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $accountData = [ + 'username' => 'John', + 'password' => 'othersecret', + 'email' => 'supi@mary.com', + 'birthdate' => '1984-05-08', + 'balance' => 800, + 'description' => 'I am dynamicer and nicer!', + 'active' => true, + 'create_date' => 486749356, + ]; + + $insertQuery = self::$db->prepareQuery( + 'INSERT INTO ' . + self::$db->quoteIdentifier('shop.account') . + ' (' . + \implode(', ', self::quoteIdentifiers(\array_keys($accountData))) . + ') VALUES (' . + \implode(', ', self::generatePlaceholders($accountData)) . + ')', + ); + + self::$db->executeQuery($insertQuery, $accountData); + + $userId = self::$db->lastInsertId(); + + $where = ['user_id' => $userId]; + + $selectQuery = self::prepareSelectFromAccount($where); + self::$db->executeQuery($selectQuery, $where); + + $insertedData = self::$db->fetchOne($selectQuery); + + if ($insertedData === null) { + throw new \LogicException('Inserted row not found'); + } + + $this->compareDataArraysWithoutCount($accountData, $insertedData); + } +} diff --git a/tests/Integration/MariaDBSSLTest.php b/tests/Integration/MariaDBSSLTest.php new file mode 100644 index 0000000..429fc31 --- /dev/null +++ b/tests/Integration/MariaDBSSLTest.php @@ -0,0 +1,46 @@ +nonSecureConnectionMustFail(new Mysql( + host: $_SERVER['SQUIRREL_CONNECTION_HOST_MARIADB'] . '_ssl', + user: $_SERVER['SQUIRREL_CONNECTION_USER'], + password: $_SERVER['SQUIRREL_CONNECTION_PASSWORD'], + dbname: $_SERVER['SQUIRREL_CONNECTION_DBNAME'], + )); + } +} diff --git a/tests/Integration/MariaDBTest.php b/tests/Integration/MariaDBTest.php new file mode 100644 index 0000000..775e09e --- /dev/null +++ b/tests/Integration/MariaDBTest.php @@ -0,0 +1,32 @@ +nonSecureConnectionMustFail(new Mysql( + host: $_SERVER['SQUIRREL_CONNECTION_HOST_MYSQL'] . '_ssl', + user: $_SERVER['SQUIRREL_CONNECTION_USER'], + password: $_SERVER['SQUIRREL_CONNECTION_PASSWORD'], + dbname: $_SERVER['SQUIRREL_CONNECTION_DBNAME'], + )); + } +} diff --git a/tests/Integration/MySQLTest.php b/tests/Integration/MySQLTest.php new file mode 100644 index 0000000..a81fb92 --- /dev/null +++ b/tests/Integration/MySQLTest.php @@ -0,0 +1,139 @@ +assertSame(ConnectionException::class, $e::class); + } + } + + public function testInsertNoLargeObject(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + $accountData = [ + 'username' => 'Mary', + 'password' => 'secret', + 'email' => 'mysql@mary.com', + 'birthdate' => '1984-05-08', + 'balance' => 105.20, + 'description' => 'I am dynamic and nice!', + 'picture' => \hex2bin(\md5('dadaism')), + 'active' => true, + 'create_date' => '48674935', + ]; + + $insertQuery = self::prepareInsertIntoAccount($accountData); + + self::$db->executeQuery($insertQuery, $accountData); + + $userId = self::$db->lastInsertId(); + + $where = ['user_id' => $userId]; + + $selectQuery = self::prepareSelectFromAccount($where); + self::$db->executeQuery($selectQuery, $where); + + $insertedData = self::$db->fetchOne($selectQuery); + + if ($insertedData === null) { + throw new \LogicException('Inserted row not found'); + } + + $accountData['picture'] = \hex2bin(\md5('dadaism')); + $accountData['phone'] = null; + $accountData['user_id'] = $userId; + $insertedData['user_id'] = Coerce::toInt($insertedData['user_id']); + + $accountData['active'] = Coerce::toBool($accountData['active']); + $insertedData['active'] = Coerce::toBool($insertedData['active']); + + $accountData['create_date'] = Coerce::toInt($accountData['create_date']); + $insertedData['create_date'] = Coerce::toInt($insertedData['create_date']); + + $accountData['balance'] = \round(Coerce::toFloat($accountData['balance']), 2); + $insertedData['balance'] = \round(Coerce::toFloat($insertedData['balance']), 2); + + $this->assertEquals($accountData, $insertedData); + } + + public function testDatabaseDoesNotExistError(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + try { + $insertQuery = self::$db->prepareQuery('USE doesnotexist'); + + self::$db->executeQuery($insertQuery); + + $this->fail('No exception was thrown'); + } catch (DriverException $e) { + $this->assertSame(AuthorizationException::class, $e::class); + } + } +} diff --git a/tests/Integration/PostgreSQLSSLTest.php b/tests/Integration/PostgreSQLSSLTest.php new file mode 100644 index 0000000..365ada6 --- /dev/null +++ b/tests/Integration/PostgreSQLSSLTest.php @@ -0,0 +1,39 @@ +assertSame(ConnectionException::class, $e::class); + $this->assertSame('08006', $e->getSqlState()); + } + } + + public function testSpecialTypes(): void + { + self::$db = static::getConnectionAndInitializeAccount(); + + self::$db->prepareAndExecuteQuery('DROP TABLE IF EXISTS locations'); + + self::$db->prepareAndExecuteQuery( + 'CREATE TABLE locations ( + current_location POINT, + ip_address INET, + create_date INTEGER NOT NULL + );', + ); + + self::$db->prepareAndExecuteQuery('INSERT INTO "locations" (current_location, ip_address, create_date) VALUES (?, ?, ?)', [ + 'current_location' => '(5,13)', + 'ip_address' => '212.55.108.55', + 'create_date' => 34534543, + ]); + + $selectQuery = self::$db->prepareQuery('SELECT * FROM ' . self::$db->quoteIdentifier('locations')); + self::$db->executeQuery($selectQuery); + + $entry = self::$db->fetchOne($selectQuery); + + if ($entry === null) { + throw new \LogicException('Inserted row not found'); + } + + $this->assertEquals([ + 'current_location' => '(5,13)', + 'ip_address' => '212.55.108.55', + 'create_date' => '34534543', + ], $entry); + } +} diff --git a/tests/Integration/SQLiteTest.php b/tests/Integration/SQLiteTest.php new file mode 100644 index 0000000..ce71878 --- /dev/null +++ b/tests/Integration/SQLiteTest.php @@ -0,0 +1,93 @@ + 'Mary', + 'password' => 'secret', + 'email' => 'mysql@mary.com', + 'birthdate' => '1984-05-08', + 'balance' => 105.20, + 'description' => 'I am dynamic and nice!', + 'picture' => \hex2bin(\md5('dadaism')), + 'active' => true, + 'create_date' => '48674935', + ]; + + $insertQuery = self::prepareInsertIntoAccount($accountData); + + self::$db->executeQuery($insertQuery, $accountData); + + $userId = self::$db->lastInsertId(); + + $where = ['user_id' => $userId]; + + $selectQuery = self::prepareSelectFromAccount($where); + self::$db->executeQuery($selectQuery, $where); + + $insertedData = self::$db->fetchOne($selectQuery); + + if ($insertedData === null) { + throw new \LogicException('Inserted row not found'); + } + + $accountData['picture'] = \hex2bin(\md5('dadaism')); + $accountData['phone'] = null; + $accountData['user_id'] = $userId; + $insertedData['user_id'] = Coerce::toInt($insertedData['user_id']); + + $accountData['active'] = Coerce::toBool($accountData['active']); + $insertedData['active'] = Coerce::toBool($insertedData['active']); + + $accountData['create_date'] = Coerce::toInt($accountData['create_date']); + $insertedData['create_date'] = Coerce::toInt($insertedData['create_date']); + + $accountData['balance'] = \round(Coerce::toFloat($accountData['balance']), 2); + $insertedData['balance'] = \round(Coerce::toFloat($insertedData['balance']), 2); + + $this->assertEquals($accountData, $insertedData); + } +} diff --git a/tools/cache/.gitkeep b/tools/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tools/phpstan-baseline.php b/tools/phpstan-baseline.php new file mode 100644 index 0000000..f0a629f --- /dev/null +++ b/tools/phpstan-baseline.php @@ -0,0 +1,23 @@ + '#^Method Squirrel\\\\Connection\\\\PDO\\\\ConnectionPDO\\:\\:resolveStreamsinEntry\\(\\) has parameter \\$entry with no value type specified in iterable type array\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/../src/PDO/ConnectionPDO.php', +]; +$ignoreErrors[] = [ + // identifier: missingType.iterableValue + 'message' => '#^Method Squirrel\\\\Connection\\\\PDO\\\\ConnectionPDO\\:\\:resolveStreamsinEntry\\(\\) return type has no value type specified in iterable type array\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/../src/PDO/ConnectionPDO.php', +]; +$ignoreErrors[] = [ + // identifier: argument.type + 'message' => '#^Parameter \\#1 \\$entry of method Squirrel\\\\Connection\\\\PDO\\\\ConnectionPDO\\:\\:resolveStreamsinEntry\\(\\) expects array, mixed given\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/../src/PDO/ConnectionPDO.php', +]; + +return ['parameters' => ['ignoreErrors' => $ignoreErrors]]; diff --git a/tools/phpstan.neon b/tools/phpstan.neon new file mode 100644 index 0000000..df87f79 --- /dev/null +++ b/tools/phpstan.neon @@ -0,0 +1,8 @@ +includes: + - phpstan-baseline.php + +parameters: + level: max + paths: + - ../src + tmpDir: cache/phpstan \ No newline at end of file diff --git a/tools/phpunit.xml.dist b/tools/phpunit.xml.dist new file mode 100644 index 0000000..deb783e --- /dev/null +++ b/tools/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + + + ../tests + + + + + ../src + + + diff --git a/tools/psalm-baseline.xml b/tools/psalm-baseline.xml new file mode 100644 index 0000000..1e0de88 --- /dev/null +++ b/tools/psalm-baseline.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tools/psalm.xml b/tools/psalm.xml new file mode 100644 index 0000000..923972d --- /dev/null +++ b/tools/psalm.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/ruleset.xml b/tools/ruleset.xml new file mode 100644 index 0000000..1193160 --- /dev/null +++ b/tools/ruleset.xml @@ -0,0 +1,62 @@ + + + PSR12 with some additional useful sniffs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vendor-bin/phpcs/composer.json b/vendor-bin/phpcs/composer.json new file mode 100644 index 0000000..03a90f1 --- /dev/null +++ b/vendor-bin/phpcs/composer.json @@ -0,0 +1,11 @@ +{ + "require": { + "squizlabs/php_codesniffer": "^3.6", + "slevomat/coding-standard": "^8.0" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": false + } + } +} diff --git a/vendor-bin/phpstan/composer.json b/vendor-bin/phpstan/composer.json new file mode 100644 index 0000000..4746394 --- /dev/null +++ b/vendor-bin/phpstan/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "phpstan/phpstan": "^1.0" + } +} diff --git a/vendor-bin/psalm/composer.json b/vendor-bin/psalm/composer.json new file mode 100644 index 0000000..a271f3a --- /dev/null +++ b/vendor-bin/psalm/composer.json @@ -0,0 +1,7 @@ +{ + "require": { + "vimeo/psalm": "^5.2", + "psalm/plugin-phpunit": "*", + "psalm/plugin-mockery": "*" + } +}