├── .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 | [](https://github.com/paragonie/easydb-cache/actions)
4 | [](https://packagist.org/packages/paragonie/easydb-cache)
5 | [](https://packagist.org/packages/paragonie/easydb-cache)
6 | [](https://packagist.org/packages/paragonie/easydb-cache)
7 | [](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 |
--------------------------------------------------------------------------------