├── .github └── workflows │ ├── ci.yml │ └── psalm.yml ├── .gitignore ├── .php_cs ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist ├── psalm.xml ├── src └── EasyDBCache.php └── tests └── EasyDBCacheTest.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | modern: 7 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} 8 | runs-on: ${{ matrix.operating-system }} 9 | strategy: 10 | matrix: 11 | operating-system: ['ubuntu-latest'] 12 | php-versions: ['8.0', '8.1'] 13 | phpunit-versions: ['latest'] 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: ${{ matrix.php-versions }} 22 | extensions: mbstring, intl, sodium 23 | ini-values: post_max_size=256M, max_execution_time=180 24 | tools: psalm, phpunit:${{ matrix.phpunit-versions }} 25 | 26 | - name: Install dependencies 27 | run: composer install 28 | 29 | - name: PHPUnit tests 30 | uses: php-actions/phpunit@v2 31 | timeout-minutes: 30 32 | with: 33 | memory_limit: 256M 34 | -------------------------------------------------------------------------------- /.github/workflows/psalm.yml: -------------------------------------------------------------------------------- 1 | name: Psalm 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | psalm: 11 | name: Psalm on PHP ${{ matrix.php-versions }} 12 | runs-on: ${{ matrix.operating-system }} 13 | strategy: 14 | matrix: 15 | operating-system: ['ubuntu-latest'] 16 | php-versions: ['8.1'] 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php-versions }} 25 | tools: psalm:4 26 | coverage: none 27 | 28 | - name: Install Composer dependencies 29 | uses: "ramsey/composer-install@v1" 30 | 31 | - name: Static Analysis 32 | run: psalm 33 | 34 | - name: Taint Analysis 35 | run: psalm --taint-analysis 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor/ 3 | /composer.lock 4 | /.editorconfig 5 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | #!/bin/env php 2 | level(Symfony\CS\FixerInterface::PSR2_LEVEL) 6 | ->fixers([ 7 | '-psr0', 8 | 'concat_with_spaces', 9 | 'newline_after_open_tag', 10 | 'ordered_use', 11 | 'short_array_syntax', 12 | ]) 13 | ->finder( 14 | Symfony\CS\Finder\DefaultFinder::create() 15 | ->in(__DIR__) 16 | ) 17 | ; 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 - 2018 Paragon Initiative Enterprises 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EasyDB (Caching) 2 | 3 | [![Build Status](https://github.com/paragonie/easydb-cache/actions/workflows/ci.yml/badge.svg)](https://github.com/paragonie/easydb-cache/actions) 4 | [![Latest Stable Version](https://poser.pugx.org/paragonie/easydb-cache/v/stable)](https://packagist.org/packages/paragonie/easydb-cache) 5 | [![Latest Unstable Version](https://poser.pugx.org/paragonie/easydb-cache/v/unstable)](https://packagist.org/packages/paragonie/easydb-cache) 6 | [![License](https://poser.pugx.org/paragonie/easydb-cache/license)](https://packagist.org/packages/paragonie/easydb-cache) 7 | [![Downloads](https://img.shields.io/packagist/dt/paragonie/easydb-cache.svg)](https://packagist.org/packages/paragonie/easydb-cache) 8 | 9 | Extends [EasyDB](https://github.com/paragonie/easydb), caches Prepared Statements 10 | to reduce the number of database round trips. **Requires PHP 8.0 or newer.** 11 | 12 | ## Installing 13 | 14 | ```terminal 15 | composer require paragonie/easydb-cache 16 | ``` 17 | 18 | ## Usage 19 | 20 | To use EasyDB with prepared statement caching, you can either change the class you're importing 21 | in your code, or update your code to use `EasyDBCache` instead. Alternatively, you can use the 22 | named constructor with your existing object. 23 | 24 | Afterwards, the EasyDB API is exactly the same as EasyDBCache. 25 | 26 | ### Updating Import Statements 27 | 28 | ```diff 29 | - use ParagonIE\EasyDB\EasyDB; 30 | + use ParagonIE\EasyDB\EasyDBCache; 31 | ``` 32 | 33 | ### Updating Your Code 34 | 35 | ```diff 36 | use ParagonIE\EasyDB\EasyDB; 37 | + use ParagonIE\EasyDB\EasyDBCache; 38 | 39 | - $db = new EasyDB( 40 | + $db = new EasyDBCache( 41 | ``` 42 | 43 | ### Named Constructor 44 | 45 | ```diff 46 | + use ParagonIE\EasyDB\EasyDBCache; 47 | 48 | - $db = new EasyDB(/* ... */); 49 | + $db = EasyDBCache::fromEasyDB(new EasyDB(/* ... */)); 50 | ``` 51 | 52 | ## Support Contracts 53 | 54 | If your company uses this library in their products or services, you may be 55 | interested in [purchasing a support contract from Paragon Initiative Enterprises](https://paragonie.com/enterprise). 56 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paragonie/easydb-cache", 3 | "description": "Caching Adapter for EasyDB (caches Prepared Statements to reduce round trips)", 4 | "keywords": [ 5 | "database", 6 | "caching", 7 | "PDO", 8 | "sql", 9 | "security" 10 | ], 11 | "license": "MIT", 12 | "type": "library", 13 | "authors": [ 14 | { 15 | "name": "Scott Arciszewski", 16 | "email": "scott@paragonie.com", 17 | "homepage": "https://paragonie.com", 18 | "role": "Developer" 19 | }, 20 | { 21 | "name": "Woody Gilk", 22 | "homepage": "https://github.com/shadowhand", 23 | "role": "Contributor" 24 | }, 25 | { 26 | "name": "SignpostMarv", 27 | "homepage": "https://github.com/SignpostMarv", 28 | "role": "Contributor" 29 | } 30 | ], 31 | "support": { 32 | "issues": "https://github.com/paragonie/easydb-cache/issues", 33 | "email": "info@paragonie.com", 34 | "source": "https://github.com/paragonie/easydb-cache" 35 | }, 36 | "require": { 37 | "php": "^8", 38 | "ext-pdo": "*", 39 | "paragonie/easydb": "^3", 40 | "paragonie/hidden-string": "^2", 41 | "paragonie/sodium_compat": "^1.17" 42 | }, 43 | "autoload": { 44 | "psr-4": { 45 | "ParagonIE\\EasyDB\\": "src" 46 | } 47 | }, 48 | "autoload-dev": { 49 | "psr-4": { 50 | "ParagonIE\\EasyDB\\Tests\\": "tests" 51 | } 52 | }, 53 | "require-dev": { 54 | "phpunit/phpunit": "^9", 55 | "squizlabs/php_codesniffer": "^2.7", 56 | "vimeo/psalm": "^4" 57 | }, 58 | "scripts": { 59 | "check-style": "phpcs -p --standard=PSR2 --runtime-set ignore_errors_on_exit 1 --runtime-set ignore_warnings_on_exit 1 src tests", 60 | "fix-style": "phpcbf -p --standard=PSR2 --runtime-set ignore_errors_on_exit 1 --runtime-set ignore_warnings_on_exit 1 src tests", 61 | "test": "phpunit && psalm" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | ./tests 16 | 17 | 18 | 19 | ./src 20 | 21 | ./vendor 22 | ./tests 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/EasyDBCache.php: -------------------------------------------------------------------------------- 1 | $cache */ 25 | protected array $cache = []; 26 | 27 | /** 28 | * Dependency-Injectable constructor 29 | * 30 | * @param PDO $pdo 31 | * @param string $dbEngine 32 | * @param array $options Extra options 33 | * @param HiddenString|null $cacheKey Key for cache lookups 34 | * 35 | * @throws Exception 36 | */ 37 | public function __construct( 38 | PDO $pdo, 39 | string $dbEngine = '', 40 | array $options = [], 41 | ?HiddenString $cacheKey = null 42 | ) { 43 | parent::__construct($pdo, $dbEngine, $options); 44 | if (is_null($cacheKey)) { 45 | $cacheKey = new HiddenString( 46 | sodium_crypto_shorthash_keygen() 47 | ); 48 | } 49 | $this->cacheKey = $cacheKey; 50 | } 51 | 52 | /** 53 | * @param EasyDB $db 54 | * @param ?HiddenString $cacheKey 55 | * @return EasyDBCache 56 | * @throws Exception 57 | */ 58 | public static function fromEasyDB( 59 | EasyDB $db, 60 | ?HiddenString $cacheKey = null 61 | ): EasyDBCache { 62 | return new EasyDBCache( 63 | $db->pdo, 64 | $db->dbEngine, 65 | $db->options, 66 | $cacheKey 67 | ); 68 | } 69 | 70 | /** 71 | * Flushes the cache of prepared statements 72 | * 73 | * @return void 74 | */ 75 | public function clearStatementCache() 76 | { 77 | $this->cache = []; 78 | } 79 | 80 | /** 81 | * @param string $statement 82 | * @return bool 83 | * 84 | * @throws SodiumException 85 | */ 86 | public function isCached(string $statement): bool 87 | { 88 | $cacheKey = $this->getCacheIndex($statement); 89 | return !empty($this->cache[$cacheKey]); 90 | } 91 | 92 | /** 93 | * @param string $statement 94 | * @return string 95 | * 96 | * @throws SodiumException 97 | */ 98 | protected function getCacheIndex(string $statement): string 99 | { 100 | return sodium_crypto_shorthash( 101 | $statement, 102 | $this->cacheKey->getString() 103 | ); 104 | } 105 | 106 | /** 107 | * @param mixed ...$args 108 | * @return PDOStatement 109 | * 110 | * @throws Exception 111 | * @throws SodiumException 112 | */ 113 | public function prepare(mixed ...$args): PDOStatement 114 | { 115 | if (count($args) < 1) { 116 | throw new Exception(__FUNCTION__ . ' expects 1 argument, 0 given.'); 117 | } 118 | $statement = $args[0]; 119 | if (!is_string($statement)) { 120 | throw new TypeError(__FUNCTION__ . ' argument 1 must be a string.'); 121 | } 122 | $cacheKey = $this->getCacheIndex($statement); 123 | if (empty($this->cache[$cacheKey])) { 124 | $this->cache[$cacheKey] = parent::prepare(...$args); 125 | } 126 | return $this->cache[$cacheKey]; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/EasyDBCacheTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('SQLite driver not installed.'); 24 | } 25 | $pdo = new \PDO('sqlite::memory:'); 26 | $this->db = new EasyDBCache($pdo); 27 | $this->db->query("CREATE TABLE foo (bar TEXT, baz TEXT);"); 28 | $this->fuzz = bin2hex(random_bytes(16)); 29 | $this->db->insert('foo', ['bar' => 'easydb', 'baz' => $this->fuzz]); 30 | $this->db->insert('foo', ['bar' => 'ezdb', 'baz' => $this->fuzz]); 31 | 32 | $this->db2 = new EasyDB($pdo); 33 | } 34 | 35 | public function testConstructors() 36 | { 37 | $pdo = new \PDO('sqlite::memory:'); 38 | $cacheKey = new HiddenString(sodium_crypto_shorthash_keygen()); 39 | $easy = new EasyDB($pdo); 40 | $c1 = new EasyDBCache($pdo, 'sqlite', [], $cacheKey); 41 | $c2 = EasyDBCache::fromEasyDB($easy, $cacheKey); 42 | $this->assertTrue($c1->getPdo() instanceof \PDO); 43 | $this->assertTrue($c2->getPdo() instanceof \PDO); 44 | } 45 | 46 | /** 47 | * @throws SodiumException 48 | */ 49 | public function testPrepareReuse() 50 | { 51 | $query = "SELECT * FROM foo WHERE bar = ?"; 52 | $query2 = "SELECT * FROM foo WHERE baz = ?"; 53 | 54 | // Preliminary: 55 | $this->assertFalse( 56 | $this->db->isCached($query), 57 | 'Prepared statement was already cached.' 58 | ); 59 | 60 | $resultA = $this->db->run($query, 'easydb'); 61 | $this->assertCount(1, $resultA); 62 | $this->assertTrue( 63 | $this->db->isCached($query), 64 | 'Prepared statement cache miss.' 65 | ); 66 | 67 | $resultB = $this->db->run($query, 'easydb'); 68 | $this->assertCount(1, $resultA); 69 | $this->assertEquals( 70 | $resultA, 71 | $resultB, 72 | 'Different results from same query?' 73 | ); 74 | 75 | $this->assertFalse( 76 | $this->db->isCached($query2), 77 | 'Prepared statement #2 was already cached.' 78 | ); 79 | 80 | $results = $this->db->run($query2, $this->fuzz); 81 | $this->assertTrue( 82 | $this->db->isCached($query2), 83 | 'Prepared statement #2 cache miss.' 84 | ); 85 | $this->assertCount(2, $results); 86 | 87 | $this->db->clearStatementCache(); 88 | $this->assertFalse( 89 | $this->db->isCached($query2), 90 | 'Clear statement cache failed' 91 | ); 92 | } 93 | 94 | /** 95 | * @throws SodiumException 96 | */ 97 | public function testSpeed() 98 | { 99 | if (!extension_loaded('sodium') || PHP_VERSION_ID >= 70300) { 100 | $this->markTestSkipped('Do not run this test without ext/sodium'); 101 | } 102 | 103 | // Initialize variables: 104 | $stop = $uncacheTime = $cacheTime = 0; 105 | 106 | $start = microtime(true); 107 | for ($i = 0; $i < 100000; ++$i) { 108 | $this->db->prepare("SELECT * FROM foo WHERE bar = ? OR baz = ?"); 109 | } 110 | $stop = microtime(true); 111 | $cacheTime = $stop - $start; 112 | 113 | $start = microtime(true); 114 | for ($i = 0; $i < 100000; ++$i) { 115 | $this->db2->prepare("SELECT * FROM foo WHERE bar = ? OR baz = ?"); 116 | } 117 | $stop = microtime(true); 118 | $uncacheTime = $stop - $start; 119 | 120 | $this->assertLessThan($uncacheTime, $cacheTime); 121 | } 122 | } 123 | --------------------------------------------------------------------------------